generated from fahricansecer/boilerplate-fe
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a9b47c9f2 | ||
| 89d3f93207 | |||
|
|
f84f60198d |
@@ -26,5 +26,6 @@ jobs:
|
||||
docker run -d \
|
||||
--name haruncan-studio-fe-container \
|
||||
--restart always \
|
||||
--network gitea-server_gitea \
|
||||
-p 1509:80 \
|
||||
haruncan-studio-fe:latest
|
||||
4
mcp-servers/rpi-deploy-mcp-server/.gitignore
vendored
4
mcp-servers/rpi-deploy-mcp-server/.gitignore
vendored
@@ -1,4 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tgz
|
||||
.env
|
||||
1688
mcp-servers/rpi-deploy-mcp-server/package-lock.json
generated
1688
mcp-servers/rpi-deploy-mcp-server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"name": "rpi-deploy-mcp-server",
|
||||
"version": "1.0.0",
|
||||
"description": "MCP server for Raspberry Pi deployment management via SSH",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.6.1",
|
||||
"ssh2": "^1.16.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/ssh2": "^1.15.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
// SSH connection and command execution constants
|
||||
|
||||
export const SSH_CONFIG = {
|
||||
host: process.env.RPI_HOST || '95.70.252.214',
|
||||
port: parseInt(process.env.RPI_PORT || '2222'),
|
||||
username: process.env.RPI_USER || 'haruncan',
|
||||
password: process.env.RPI_PASSWORD,
|
||||
privateKeyPath: process.env.RPI_KEY_PATH,
|
||||
};
|
||||
|
||||
// Safety limits
|
||||
export const MAX_LOG_LINES = 500;
|
||||
export const DEFAULT_LOG_LINES = 50;
|
||||
export const COMMAND_TIMEOUT_MS = 30_000;
|
||||
export const CHARACTER_LIMIT = 25_000;
|
||||
|
||||
// Commands that are considered safe (read-only)
|
||||
export const SAFE_COMMAND_PREFIXES = [
|
||||
'docker ps',
|
||||
'docker logs',
|
||||
'docker inspect',
|
||||
'docker network',
|
||||
'docker images',
|
||||
'docker stats',
|
||||
'docker compose ps',
|
||||
'docker compose logs',
|
||||
'df',
|
||||
'free',
|
||||
'uptime',
|
||||
'top -bn1',
|
||||
'cat',
|
||||
'ls',
|
||||
'tail',
|
||||
'head',
|
||||
'grep',
|
||||
'wc',
|
||||
'hostname',
|
||||
'uname',
|
||||
'date',
|
||||
'whoami',
|
||||
'systemctl status',
|
||||
'journalctl',
|
||||
];
|
||||
@@ -1,29 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { registerContainerTools } from './tools/containers.js';
|
||||
import { registerSystemTools } from './tools/system.js';
|
||||
import { registerDeployTools } from './tools/deploy.js';
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'rpi-deploy',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
// Register all tools
|
||||
registerContainerTools(server);
|
||||
registerSystemTools(server);
|
||||
registerDeployTools(server);
|
||||
|
||||
// Start server with stdio transport
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error('[rpi-deploy] MCP server started on stdio');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[rpi-deploy] Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import { Client, type ConnectConfig } from 'ssh2';
|
||||
import { readFileSync } from 'fs';
|
||||
import { SSH_CONFIG, COMMAND_TIMEOUT_MS } from './constants.js';
|
||||
|
||||
export interface CommandResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number;
|
||||
}
|
||||
|
||||
function buildConnectConfig(): ConnectConfig {
|
||||
const config: ConnectConfig = {
|
||||
host: SSH_CONFIG.host,
|
||||
port: SSH_CONFIG.port,
|
||||
username: SSH_CONFIG.username,
|
||||
readyTimeout: 10_000,
|
||||
};
|
||||
|
||||
if (SSH_CONFIG.privateKeyPath) {
|
||||
try {
|
||||
config.privateKey = readFileSync(SSH_CONFIG.privateKeyPath);
|
||||
} catch {
|
||||
// fallback to password
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.privateKey && SSH_CONFIG.password) {
|
||||
config.password = SSH_CONFIG.password;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function executeCommand(command: string): Promise<CommandResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const conn = new Client();
|
||||
const timeout = setTimeout(() => {
|
||||
conn.end();
|
||||
reject(new Error(`Command timed out after ${COMMAND_TIMEOUT_MS}ms: ${command}`));
|
||||
}, COMMAND_TIMEOUT_MS);
|
||||
|
||||
conn.on('ready', () => {
|
||||
conn.exec(command, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(timeout);
|
||||
conn.end();
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
stream.on('data', (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on('data', (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
stream.on('close', (code: number) => {
|
||||
clearTimeout(timeout);
|
||||
conn.end();
|
||||
resolve({ stdout: stdout.trim(), stderr: stderr.trim(), code: code ?? 0 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
conn.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`SSH connection failed: ${err.message}`));
|
||||
});
|
||||
|
||||
conn.connect(buildConnectConfig());
|
||||
});
|
||||
}
|
||||
|
||||
export async function testConnection(): Promise<boolean> {
|
||||
try {
|
||||
const result = await executeCommand('echo "connected"');
|
||||
return result.stdout === 'connected';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import { executeCommand } from '../ssh-client.js';
|
||||
import { DEFAULT_LOG_LINES, MAX_LOG_LINES, CHARACTER_LIMIT } from '../constants.js';
|
||||
|
||||
export function registerContainerTools(server: McpServer): void {
|
||||
|
||||
// ── rpi_list_containers ──────────────────────────────────────────────
|
||||
server.registerTool(
|
||||
'rpi_list_containers',
|
||||
{
|
||||
title: 'List Docker Containers',
|
||||
description: `List all Docker containers on the Raspberry Pi with their status, ports, and resource usage.
|
||||
|
||||
Returns a formatted table of all containers (running and stopped) including:
|
||||
- Container ID, Name, Image, Status, Ports, Created time
|
||||
|
||||
Args:
|
||||
- all (boolean): Include stopped containers (default: true)
|
||||
- format ('table' | 'json'): Output format (default: 'table')`,
|
||||
inputSchema: {
|
||||
all: z.boolean().default(true).describe('Include stopped containers'),
|
||||
format: z.enum(['table', 'json']).default('table').describe('Output format'),
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
},
|
||||
async ({ all, format }) => {
|
||||
try {
|
||||
const flags = all ? '-a' : '';
|
||||
const formatFlag = format === 'json'
|
||||
? '--format "{{json .}}"'
|
||||
: '--format "table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"';
|
||||
|
||||
const result = await executeCommand(`docker ps ${flags} ${formatFlag}`);
|
||||
|
||||
if (result.code !== 0) {
|
||||
return { content: [{ type: 'text', text: `Error: ${result.stderr || 'Failed to list containers'}` }] };
|
||||
}
|
||||
|
||||
const output = result.stdout || 'No containers found.';
|
||||
return { content: [{ type: 'text', text: output }] };
|
||||
} catch (error) {
|
||||
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ── rpi_container_logs ───────────────────────────────────────────────
|
||||
server.registerTool(
|
||||
'rpi_container_logs',
|
||||
{
|
||||
title: 'Get Container Logs',
|
||||
description: `Fetch logs from a specific Docker container on the Raspberry Pi.
|
||||
|
||||
Args:
|
||||
- container (string): Container name or ID
|
||||
- lines (number): Number of tail lines to fetch (default: 50, max: 500)
|
||||
- since (string): Show logs since timestamp (e.g., "1h", "30m", "2024-01-01")
|
||||
- grep (string): Optional grep filter to apply on logs`,
|
||||
inputSchema: {
|
||||
container: z.string().min(1).describe('Container name or ID'),
|
||||
lines: z.number().int().min(1).max(MAX_LOG_LINES).default(DEFAULT_LOG_LINES).describe('Number of tail lines'),
|
||||
since: z.string().optional().describe('Show logs since (e.g., "1h", "30m")'),
|
||||
grep: z.string().optional().describe('Filter logs with grep pattern'),
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
},
|
||||
async ({ container, lines, since, grep }) => {
|
||||
try {
|
||||
let cmd = `docker logs --tail ${lines}`;
|
||||
if (since) cmd += ` --since ${since}`;
|
||||
cmd += ` ${container} 2>&1`;
|
||||
if (grep) cmd += ` | grep -i "${grep}"`;
|
||||
|
||||
const result = await executeCommand(cmd);
|
||||
|
||||
if (result.code !== 0 && !result.stdout) {
|
||||
return { content: [{ type: 'text', text: `Error: ${result.stderr || `Container '${container}' not found`}` }] };
|
||||
}
|
||||
|
||||
let output = result.stdout || 'No logs found.';
|
||||
if (output.length > CHARACTER_LIMIT) {
|
||||
output = output.slice(-CHARACTER_LIMIT);
|
||||
output = `[...truncated, showing last ${CHARACTER_LIMIT} characters]\n\n${output}`;
|
||||
}
|
||||
|
||||
return { content: [{ type: 'text', text: `## Logs: ${container} (last ${lines} lines)\n\n\`\`\`\n${output}\n\`\`\`` }] };
|
||||
} catch (error) {
|
||||
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ── rpi_restart_container ────────────────────────────────────────────
|
||||
server.registerTool(
|
||||
'rpi_restart_container',
|
||||
{
|
||||
title: 'Restart Docker Container',
|
||||
description: `Restart a specific Docker container on the Raspberry Pi.
|
||||
|
||||
⚠️ This is a destructive operation — the container will be temporarily unavailable.
|
||||
|
||||
Args:
|
||||
- container (string): Container name or ID to restart
|
||||
- timeout (number): Seconds to wait before killing (default: 10)`,
|
||||
inputSchema: {
|
||||
container: z.string().min(1).describe('Container name or ID'),
|
||||
timeout: z.number().int().min(0).max(120).default(10).describe('Seconds to wait before killing'),
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
},
|
||||
async ({ container, timeout }) => {
|
||||
try {
|
||||
const result = await executeCommand(`docker restart --time ${timeout} ${container}`);
|
||||
|
||||
if (result.code !== 0) {
|
||||
return { content: [{ type: 'text', text: `Error restarting '${container}': ${result.stderr}` }] };
|
||||
}
|
||||
|
||||
// Verify the container is running
|
||||
const verify = await executeCommand(`docker ps --filter "name=${container}" --format "{{.Names}}\t{{.Status}}"`);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `✅ Container '${container}' restarted successfully.\n\nCurrent status:\n${verify.stdout}`
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import { executeCommand } from '../ssh-client.js';
|
||||
|
||||
export function registerDeployTools(server: McpServer): void {
|
||||
|
||||
// ── rpi_deploy_status ────────────────────────────────────────────────
|
||||
server.registerTool(
|
||||
'rpi_deploy_status',
|
||||
{
|
||||
title: 'Deploy Status',
|
||||
description: `Check the current deployment status on the Raspberry Pi.
|
||||
|
||||
Gathers information about:
|
||||
- Running containers and their health
|
||||
- Docker Compose project status
|
||||
- Last container restart/creation times
|
||||
- Gitea runner status (if available)`,
|
||||
inputSchema: {
|
||||
compose_dir: z.string().optional().describe('Path to docker-compose directory (default: auto-detect)'),
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
},
|
||||
async ({ compose_dir }) => {
|
||||
try {
|
||||
const sections: string[] = ['# 🚀 Deploy Status\n'];
|
||||
|
||||
// Container status with creation time
|
||||
const containers = await executeCommand(
|
||||
'docker ps -a --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.CreatedAt}}"'
|
||||
);
|
||||
sections.push(`## Containers\n\`\`\`\n${containers.stdout || 'No containers found'}\n\`\`\`\n`);
|
||||
|
||||
// Docker compose status if compose_dir specified
|
||||
if (compose_dir) {
|
||||
const composeStatus = await executeCommand(`cd ${compose_dir} && docker compose ps 2>&1`);
|
||||
sections.push(`## Docker Compose (${compose_dir})\n\`\`\`\n${composeStatus.stdout || composeStatus.stderr}\n\`\`\`\n`);
|
||||
}
|
||||
|
||||
// Check for Gitea runner
|
||||
const runner = await executeCommand('docker ps --filter "name=runner" --filter "name=gitea" --format "{{.Names}}\t{{.Status}}"');
|
||||
if (runner.stdout) {
|
||||
sections.push(`## Gitea Runner\n\`\`\`\n${runner.stdout}\n\`\`\`\n`);
|
||||
}
|
||||
|
||||
// Recent container events (last 10)
|
||||
const events = await executeCommand(
|
||||
'docker events --since "1h" --until "0s" --format "{{.Time}} {{.Action}} {{.Actor.Attributes.name}}" 2>/dev/null | tail -10'
|
||||
);
|
||||
if (events.stdout) {
|
||||
sections.push(`## Recent Events (last 1h)\n\`\`\`\n${events.stdout}\n\`\`\`\n`);
|
||||
}
|
||||
|
||||
return { content: [{ type: 'text', text: sections.join('\n') }] };
|
||||
} catch (error) {
|
||||
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ── rpi_docker_compose_up ────────────────────────────────────────────
|
||||
server.registerTool(
|
||||
'rpi_docker_compose_up',
|
||||
{
|
||||
title: 'Docker Compose Up',
|
||||
description: `Run 'docker compose up -d' in a specified directory on the Raspberry Pi.
|
||||
|
||||
⚠️ This will start/restart services defined in docker-compose.yml.
|
||||
|
||||
Args:
|
||||
- directory (string): Absolute path to the directory containing docker-compose.yml
|
||||
- build (boolean): Force rebuild images before starting (default: false)
|
||||
- services (string[]): Specific services to start (default: all)`,
|
||||
inputSchema: {
|
||||
directory: z.string().min(1).describe('Absolute path to docker-compose directory'),
|
||||
build: z.boolean().default(false).describe('Rebuild images before starting'),
|
||||
services: z.array(z.string()).optional().describe('Specific services to start'),
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
},
|
||||
async ({ directory, build, services }) => {
|
||||
try {
|
||||
let cmd = `cd ${directory} && docker compose up -d`;
|
||||
if (build) cmd += ' --build';
|
||||
if (services?.length) cmd += ` ${services.join(' ')}`;
|
||||
|
||||
const result = await executeCommand(cmd);
|
||||
|
||||
if (result.code !== 0) {
|
||||
return { content: [{ type: 'text', text: `❌ docker compose up failed:\n\`\`\`\n${result.stderr || result.stdout}\n\`\`\`` }] };
|
||||
}
|
||||
|
||||
// Show final status
|
||||
const status = await executeCommand(`cd ${directory} && docker compose ps`);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `✅ docker compose up completed in \`${directory}\`\n\n## Current Status\n\`\`\`\n${status.stdout}\n\`\`\``
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ── rpi_docker_compose_down ──────────────────────────────────────────
|
||||
server.registerTool(
|
||||
'rpi_docker_compose_down',
|
||||
{
|
||||
title: 'Docker Compose Down',
|
||||
description: `Run 'docker compose down' in a specified directory on the Raspberry Pi.
|
||||
|
||||
⚠️ This will stop and remove containers, networks defined in docker-compose.yml.
|
||||
|
||||
Args:
|
||||
- directory (string): Absolute path to the directory containing docker-compose.yml
|
||||
- volumes (boolean): Remove named volumes too (default: false)`,
|
||||
inputSchema: {
|
||||
directory: z.string().min(1).describe('Absolute path to docker-compose directory'),
|
||||
volumes: z.boolean().default(false).describe('Also remove named volumes'),
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
},
|
||||
async ({ directory, volumes }) => {
|
||||
try {
|
||||
let cmd = `cd ${directory} && docker compose down`;
|
||||
if (volumes) cmd += ' -v';
|
||||
|
||||
const result = await executeCommand(cmd);
|
||||
|
||||
if (result.code !== 0) {
|
||||
return { content: [{ type: 'text', text: `❌ docker compose down failed:\n\`\`\`\n${result.stderr || result.stdout}\n\`\`\`` }] };
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `✅ docker compose down completed in \`${directory}\`\n\n\`\`\`\n${result.stdout || result.stderr || 'Services stopped and removed.'}\n\`\`\``
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import { executeCommand } from '../ssh-client.js';
|
||||
import { SAFE_COMMAND_PREFIXES } from '../constants.js';
|
||||
|
||||
export function registerSystemTools(server: McpServer): void {
|
||||
|
||||
// ── rpi_system_status ────────────────────────────────────────────────
|
||||
server.registerTool(
|
||||
'rpi_system_status',
|
||||
{
|
||||
title: 'Raspberry Pi System Status',
|
||||
description: `Get system resource usage on the Raspberry Pi: CPU load, memory, disk, uptime, and temperature.
|
||||
|
||||
Returns a formatted overview of the system health.`,
|
||||
inputSchema: {},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
const commands = [
|
||||
{ label: 'Hostname', cmd: 'hostname' },
|
||||
{ label: 'Uptime', cmd: 'uptime' },
|
||||
{ label: 'CPU Temperature', cmd: 'cat /sys/class/thermal/thermal_zone0/temp 2>/dev/null || echo "N/A"' },
|
||||
{ label: 'Memory', cmd: 'free -h | head -3' },
|
||||
{ label: 'Disk', cmd: 'df -h / | tail -1' },
|
||||
{ label: 'CPU Load', cmd: 'top -bn1 | head -5' },
|
||||
];
|
||||
|
||||
const sections: string[] = ['# 🖥️ Raspberry Pi System Status\n'];
|
||||
|
||||
for (const { label, cmd } of commands) {
|
||||
const result = await executeCommand(cmd);
|
||||
let value = result.stdout || 'N/A';
|
||||
|
||||
// Convert CPU temp from millidegrees
|
||||
if (label === 'CPU Temperature' && value !== 'N/A') {
|
||||
const temp = parseInt(value);
|
||||
if (!isNaN(temp)) {
|
||||
value = `${(temp / 1000).toFixed(1)}°C`;
|
||||
}
|
||||
}
|
||||
|
||||
sections.push(`## ${label}\n\`\`\`\n${value}\n\`\`\`\n`);
|
||||
}
|
||||
|
||||
return { content: [{ type: 'text', text: sections.join('\n') }] };
|
||||
} catch (error) {
|
||||
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ── rpi_docker_networks ──────────────────────────────────────────────
|
||||
server.registerTool(
|
||||
'rpi_docker_networks',
|
||||
{
|
||||
title: 'List Docker Networks',
|
||||
description: `List all Docker networks on the Raspberry Pi with driver and scope info.`,
|
||||
inputSchema: {},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
const result = await executeCommand('docker network ls --format "table {{.ID}}\t{{.Name}}\t{{.Driver}}\t{{.Scope}}"');
|
||||
|
||||
if (result.code !== 0) {
|
||||
return { content: [{ type: 'text', text: `Error: ${result.stderr}` }] };
|
||||
}
|
||||
|
||||
return { content: [{ type: 'text', text: `## Docker Networks\n\n\`\`\`\n${result.stdout}\n\`\`\`` }] };
|
||||
} catch (error) {
|
||||
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ── rpi_run_command ──────────────────────────────────────────────────
|
||||
server.registerTool(
|
||||
'rpi_run_command',
|
||||
{
|
||||
title: 'Run Safe Command on RPi',
|
||||
description: `Execute a read-only command on the Raspberry Pi via SSH.
|
||||
|
||||
Only whitelisted safe commands are allowed (docker ps, docker logs, df, free, uptime, cat, ls, tail, head, grep, etc.).
|
||||
|
||||
Args:
|
||||
- command (string): The command to execute (must start with a safe prefix)`,
|
||||
inputSchema: {
|
||||
command: z.string().min(1).max(500).describe('Safe read-only command to execute'),
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: true,
|
||||
},
|
||||
},
|
||||
async ({ command }) => {
|
||||
try {
|
||||
const trimmedCmd = command.trim();
|
||||
const isSafe = SAFE_COMMAND_PREFIXES.some(prefix => trimmedCmd.startsWith(prefix));
|
||||
|
||||
if (!isSafe) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `❌ Command rejected: '${trimmedCmd}' is not in the safe command list.\n\nAllowed prefixes:\n${SAFE_COMMAND_PREFIXES.map(p => ` - ${p}`).join('\n')}`
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
const result = await executeCommand(trimmedCmd);
|
||||
|
||||
const output = result.stdout || result.stderr || '(no output)';
|
||||
const status = result.code === 0 ? '✅' : `⚠️ (exit code: ${result.code})`;
|
||||
|
||||
return { content: [{ type: 'text', text: `${status} \`${trimmedCmd}\`\n\n\`\`\`\n${output}\n\`\`\`` }] };
|
||||
} catch (error) {
|
||||
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
24
nginx.conf
24
nginx.conf
@@ -13,6 +13,30 @@ server {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API Proxy — resolver sayesinde backend kapalıyken de nginx başlar
|
||||
location /api {
|
||||
resolver 127.0.0.11 valid=30s ipv6=off;
|
||||
set $backend "backend-container:3000";
|
||||
proxy_pass http://$backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Uploads Proxy
|
||||
location /uploads {
|
||||
resolver 127.0.0.11 valid=30s ipv6=off;
|
||||
set $backend "backend-container:3000";
|
||||
proxy_pass http://$backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Statik dosya cache (JS, CSS, images, fonts)
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||
expires 1y;
|
||||
|
||||
@@ -32,8 +32,15 @@ export default function Admin({ forceOpen = false }: { forceOpen?: boolean }) {
|
||||
|
||||
// Client state
|
||||
const [newClientName, setNewClientName] = useState('');
|
||||
const [newClientWebsite, setNewClientWebsite] = useState('');
|
||||
const [clientUploading, setClientUploading] = useState(false);
|
||||
const [editingClientId, setEditingClientId] = useState<string | null>(null);
|
||||
const [editingClientName, setEditingClientName] = useState('');
|
||||
const [editingClientWebsite, setEditingClientWebsite] = useState('');
|
||||
const [clientSaving, setClientSaving] = useState(false);
|
||||
const [clientLogoUploading, setClientLogoUploading] = useState<string | null>(null);
|
||||
const clientFileRef = useRef<HTMLInputElement>(null);
|
||||
const clientLogoChangeRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Audit Logs state
|
||||
const [auditLogs, setAuditLogs] = useState<api.AuditLogAPI[]>([]);
|
||||
@@ -168,9 +175,10 @@ export default function Admin({ forceOpen = false }: { forceOpen?: boolean }) {
|
||||
throw new Error('Sunucu görsel URL\'sini döndüremedi. Lütfen tekrar deneyin.');
|
||||
}
|
||||
|
||||
await api.createClient({ name: newClientName.trim(), logo: logoUrl });
|
||||
await api.createClient({ name: newClientName.trim(), logo: logoUrl, website: newClientWebsite.trim() || undefined });
|
||||
await refreshClients();
|
||||
setNewClientName('');
|
||||
setNewClientWebsite('');
|
||||
showMessage('Marka eklendi', 'success');
|
||||
} catch (err: any) {
|
||||
showMessage(err.message || 'Marka eklenemedi', 'error');
|
||||
@@ -516,8 +524,8 @@ export default function Admin({ forceOpen = false }: { forceOpen?: boolean }) {
|
||||
|
||||
<div className="p-4 bg-slate-900/50 border border-white/10 rounded-lg mb-6">
|
||||
<h4 className="text-sm font-mono text-slate-400 mb-3">YENİ MARKA EKLE</h4>
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="grid md:grid-cols-2 gap-4 mb-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-mono text-slate-500">MARKA ADI</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -527,48 +535,204 @@ export default function Admin({ forceOpen = false }: { forceOpen?: boolean }) {
|
||||
placeholder="Netflix, Disney, Warner Bros..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-mono text-slate-500">WEB SİTESİ <span className="text-slate-600">(Opsiyonel — SEO backlink)</span></label>
|
||||
<input
|
||||
ref={clientFileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) await handleAddClient(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
type="url"
|
||||
value={newClientWebsite}
|
||||
onChange={(e) => setNewClientWebsite(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-white/10 px-4 py-2.5 text-white focus:border-[#FF5733] outline-none transition-colors text-sm rounded-sm"
|
||||
placeholder="https://www.netflix.com"
|
||||
/>
|
||||
<button
|
||||
onClick={() => clientFileRef.current?.click()}
|
||||
disabled={clientUploading || !newClientName.trim()}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-[#FF5733] text-white font-bold text-sm hover:bg-white hover:text-slate-950 transition-colors disabled:opacity-50 disabled:cursor-not-allowed rounded-sm"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
{clientUploading ? 'Yükleniyor...' : 'Logo Yükle & Ekle'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
ref={clientFileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) await handleAddClient(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => clientFileRef.current?.click()}
|
||||
disabled={clientUploading || !newClientName.trim()}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-[#FF5733] text-white font-bold text-sm hover:bg-white hover:text-slate-950 transition-colors disabled:opacity-50 disabled:cursor-not-allowed rounded-sm"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
{clientUploading ? 'Yükleniyor...' : 'Logo Yükle & Ekle'}
|
||||
</button>
|
||||
{newClientWebsite && (
|
||||
<span className="text-[10px] font-mono text-green-400/70 flex items-center gap-1">🔗 SEO backlink aktif</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{data.clients.map((client) => (
|
||||
<div key={client.id} className="relative group p-4 bg-slate-900/50 border border-white/10 rounded-lg flex flex-col items-center">
|
||||
<img
|
||||
src={api.getMediaUrl(client.logo)}
|
||||
alt={client.name}
|
||||
className="h-[60px] w-auto object-contain mb-3"
|
||||
style={{ maxWidth: '200px' }}
|
||||
/>
|
||||
<span className="text-xs font-mono text-slate-400 text-center">{client.name}</span>
|
||||
<button
|
||||
onClick={() => handleRemoveClient(client.id)}
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity p-1 text-red-400 hover:text-red-300"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{/* Hidden file input for logo change */}
|
||||
<input
|
||||
ref={clientLogoChangeRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file && editingClientId) {
|
||||
setClientLogoUploading(editingClientId);
|
||||
try {
|
||||
const response = await api.uploadFile(file);
|
||||
const media = (response as any).data || response;
|
||||
let logoUrl = '';
|
||||
if (media && typeof media === 'object') {
|
||||
if (media.url) logoUrl = media.url;
|
||||
else if (media.filename) logoUrl = `/uploads/${media.filename}`;
|
||||
}
|
||||
if (!logoUrl) throw new Error('Görsel URL alınamadı');
|
||||
await api.updateClient(editingClientId, { logo: logoUrl });
|
||||
await refreshClients();
|
||||
showMessage('Logo güncellendi', 'success');
|
||||
} catch (err: any) {
|
||||
showMessage(err.message || 'Logo güncellenemedi', 'error');
|
||||
} finally {
|
||||
setClientLogoUploading(null);
|
||||
}
|
||||
}
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{data.clients.map((client) => {
|
||||
const isEditing = editingClientId === client.id;
|
||||
return (
|
||||
<div key={client.id} className={`relative group p-4 border rounded-lg flex flex-col transition-colors ${
|
||||
isEditing
|
||||
? 'bg-slate-800/80 border-[#FF5733]/30'
|
||||
: 'bg-slate-900/50 border-white/10'
|
||||
}`}>
|
||||
{/* Logo area */}
|
||||
<div className="flex items-center justify-center mb-3 relative">
|
||||
<img
|
||||
src={api.getMediaUrl(client.logo)}
|
||||
alt={client.name}
|
||||
className={`h-[60px] w-auto object-contain transition-opacity ${
|
||||
clientLogoUploading === client.id ? 'opacity-30' : ''
|
||||
}`}
|
||||
style={{ maxWidth: '200px' }}
|
||||
/>
|
||||
{clientLogoUploading === client.id && (
|
||||
<span className="absolute text-xs text-white animate-pulse">Yükleniyor...</span>
|
||||
)}
|
||||
{isEditing && clientLogoUploading !== client.id && (
|
||||
<button
|
||||
onClick={() => clientLogoChangeRef.current?.click()}
|
||||
className="absolute inset-0 bg-black/50 rounded flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity cursor-pointer"
|
||||
title="Logo değiştir"
|
||||
>
|
||||
<Upload className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
{isEditing ? (
|
||||
<div className="w-full space-y-3 mt-1">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-mono text-slate-500">MARKA ADI</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingClientName}
|
||||
onChange={(e) => setEditingClientName(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-white/10 px-3 py-1.5 text-sm text-white focus:border-[#FF5733] outline-none rounded-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-mono text-slate-500">WEB SİTESİ <span className="text-slate-600">(SEO)</span></label>
|
||||
<input
|
||||
type="url"
|
||||
value={editingClientWebsite}
|
||||
onChange={(e) => setEditingClientWebsite(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-white/10 px-3 py-1.5 text-sm text-white focus:border-[#FF5733] outline-none rounded-sm"
|
||||
placeholder="https://www.example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
onClick={async () => {
|
||||
setClientSaving(true);
|
||||
try {
|
||||
await api.updateClient(client.id, {
|
||||
name: editingClientName.trim() || client.name,
|
||||
website: editingClientWebsite.trim() || null as any,
|
||||
});
|
||||
await refreshClients();
|
||||
setEditingClientId(null);
|
||||
showMessage('Marka güncellendi', 'success');
|
||||
} catch (err: any) {
|
||||
showMessage(err.message || 'Güncellenemedi', 'error');
|
||||
} finally {
|
||||
setClientSaving(false);
|
||||
}
|
||||
}}
|
||||
disabled={clientSaving}
|
||||
className="flex-1 flex items-center justify-center gap-1 px-3 py-1.5 text-xs font-bold bg-[#FF5733] text-white rounded-sm hover:bg-white hover:text-slate-950 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-3 h-3" />
|
||||
{clientSaving ? 'Kaydediliyor...' : 'Kaydet'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingClientId(null)}
|
||||
className="px-3 py-1.5 text-xs text-slate-400 hover:text-white border border-white/10 rounded-sm transition-colors"
|
||||
>
|
||||
İptal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xs font-mono text-slate-400 text-center mb-1">{client.name}</span>
|
||||
<span
|
||||
className="text-[10px] font-mono px-2 py-0.5 rounded border transition-colors text-center"
|
||||
style={{
|
||||
borderColor: client.website ? 'rgba(74, 222, 128, 0.3)' : 'rgba(255,255,255,0.05)',
|
||||
color: client.website ? 'rgb(74, 222, 128)' : 'rgb(71, 85, 105)',
|
||||
backgroundColor: client.website ? 'rgba(74, 222, 128, 0.05)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
{client.website ? `🔗 ${client.website.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '')}` : 'Web sitesi yok'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Action buttons (visible on hover when not editing) */}
|
||||
{!isEditing && (
|
||||
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingClientId(client.id);
|
||||
setEditingClientName(client.name);
|
||||
setEditingClientWebsite(client.website || '');
|
||||
}}
|
||||
className="p-1.5 text-blue-400 hover:text-blue-300 hover:bg-blue-500/10 rounded transition-colors"
|
||||
title="Düzenle"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemoveClient(client.id)}
|
||||
className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded transition-colors"
|
||||
title="Sil"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -30,16 +30,18 @@ export default function Clients() {
|
||||
{duplicated.map((client, index) => (
|
||||
<a
|
||||
key={`${client.id}-${index}`}
|
||||
href={client.website || '#'}
|
||||
href={client.website || undefined}
|
||||
target={client.website ? '_blank' : undefined}
|
||||
rel="noopener noreferrer"
|
||||
className="flex-shrink-0 group transition-all duration-500"
|
||||
title={client.name}
|
||||
rel={client.website ? 'noopener' : undefined}
|
||||
className={`flex-shrink-0 group transition-all duration-500 ${client.website ? 'cursor-pointer' : 'cursor-default'}`}
|
||||
title={client.website ? `${client.name} — Visit Website` : client.name}
|
||||
aria-label={client.website ? `Visit ${client.name} official website` : `${client.name} logo`}
|
||||
onClick={(e) => { if (!client.website) e.preventDefault(); }}
|
||||
>
|
||||
<img
|
||||
src={getMediaUrl(client.logo)}
|
||||
alt={client.name}
|
||||
className="h-[50px] md:h-[60px] w-auto object-contain opacity-95 group-hover:opacity-100 transition-all duration-500"
|
||||
alt={`${client.name} logo — official partner of Harun CAN Studio`}
|
||||
className={`h-[50px] md:h-[60px] w-auto object-contain opacity-95 group-hover:opacity-100 transition-all duration-500 ${client.website ? 'group-hover:scale-105' : ''}`}
|
||||
style={{ maxWidth: '250px' }}
|
||||
/>
|
||||
</a>
|
||||
|
||||
Reference in New Issue
Block a user