generated from fahricansecer/boilerplate-fe
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fda025de3 |
@@ -26,6 +26,5 @@ jobs:
|
|||||||
docker run -d \
|
docker run -d \
|
||||||
--name haruncan-studio-fe-container \
|
--name haruncan-studio-fe-container \
|
||||||
--restart always \
|
--restart always \
|
||||||
--network gitea-server_gitea \
|
|
||||||
-p 1509:80 \
|
-p 1509:80 \
|
||||||
haruncan-studio-fe:latest
|
haruncan-studio-fe:latest
|
||||||
4
mcp-servers/rpi-deploy-mcp-server/.gitignore
vendored
Normal file
4
mcp-servers/rpi-deploy-mcp-server/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.tgz
|
||||||
|
.env
|
||||||
1688
mcp-servers/rpi-deploy-mcp-server/package-lock.json
generated
Normal file
1688
mcp-servers/rpi-deploy-mcp-server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
mcp-servers/rpi-deploy-mcp-server/package.json
Normal file
27
mcp-servers/rpi-deploy-mcp-server/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
43
mcp-servers/rpi-deploy-mcp-server/src/constants.ts
Normal file
43
mcp-servers/rpi-deploy-mcp-server/src/constants.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// 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',
|
||||||
|
];
|
||||||
29
mcp-servers/rpi-deploy-mcp-server/src/index.ts
Normal file
29
mcp-servers/rpi-deploy-mcp-server/src/index.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/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);
|
||||||
|
});
|
||||||
86
mcp-servers/rpi-deploy-mcp-server/src/ssh-client.ts
Normal file
86
mcp-servers/rpi-deploy-mcp-server/src/ssh-client.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
149
mcp-servers/rpi-deploy-mcp-server/src/tools/containers.ts
Normal file
149
mcp-servers/rpi-deploy-mcp-server/src/tools/containers.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
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)}` }] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
163
mcp-servers/rpi-deploy-mcp-server/src/tools/deploy.ts
Normal file
163
mcp-servers/rpi-deploy-mcp-server/src/tools/deploy.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
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)}` }] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
134
mcp-servers/rpi-deploy-mcp-server/src/tools/system.ts
Normal file
134
mcp-servers/rpi-deploy-mcp-server/src/tools/system.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
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)}` }] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
19
mcp-servers/rpi-deploy-mcp-server/tsconfig.json
Normal file
19
mcp-servers/rpi-deploy-mcp-server/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"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,30 +13,6 @@ server {
|
|||||||
try_files $uri $uri/ /index.html;
|
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)
|
# Statik dosya cache (JS, CSS, images, fonts)
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
|
|||||||
@@ -32,15 +32,8 @@ export default function Admin({ forceOpen = false }: { forceOpen?: boolean }) {
|
|||||||
|
|
||||||
// Client state
|
// Client state
|
||||||
const [newClientName, setNewClientName] = useState('');
|
const [newClientName, setNewClientName] = useState('');
|
||||||
const [newClientWebsite, setNewClientWebsite] = useState('');
|
|
||||||
const [clientUploading, setClientUploading] = useState(false);
|
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 clientFileRef = useRef<HTMLInputElement>(null);
|
||||||
const clientLogoChangeRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
// Audit Logs state
|
// Audit Logs state
|
||||||
const [auditLogs, setAuditLogs] = useState<api.AuditLogAPI[]>([]);
|
const [auditLogs, setAuditLogs] = useState<api.AuditLogAPI[]>([]);
|
||||||
@@ -175,10 +168,9 @@ export default function Admin({ forceOpen = false }: { forceOpen?: boolean }) {
|
|||||||
throw new Error('Sunucu görsel URL\'sini döndüremedi. Lütfen tekrar deneyin.');
|
throw new Error('Sunucu görsel URL\'sini döndüremedi. Lütfen tekrar deneyin.');
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.createClient({ name: newClientName.trim(), logo: logoUrl, website: newClientWebsite.trim() || undefined });
|
await api.createClient({ name: newClientName.trim(), logo: logoUrl });
|
||||||
await refreshClients();
|
await refreshClients();
|
||||||
setNewClientName('');
|
setNewClientName('');
|
||||||
setNewClientWebsite('');
|
|
||||||
showMessage('Marka eklendi', 'success');
|
showMessage('Marka eklendi', 'success');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showMessage(err.message || 'Marka eklenemedi', 'error');
|
showMessage(err.message || 'Marka eklenemedi', 'error');
|
||||||
@@ -524,8 +516,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">
|
<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>
|
<h4 className="text-sm font-mono text-slate-400 mb-3">YENİ MARKA EKLE</h4>
|
||||||
<div className="grid md:grid-cols-2 gap-4 mb-4">
|
<div className="flex items-end gap-4">
|
||||||
<div className="space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<label className="text-xs font-mono text-slate-500">MARKA ADI</label>
|
<label className="text-xs font-mono text-slate-500">MARKA ADI</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -535,204 +527,48 @@ export default function Admin({ forceOpen = false }: { forceOpen?: boolean }) {
|
|||||||
placeholder="Netflix, Disney, Warner Bros..."
|
placeholder="Netflix, Disney, Warner Bros..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div>
|
||||||
<label className="text-xs font-mono text-slate-500">WEB SİTESİ <span className="text-slate-600">(Opsiyonel — SEO backlink)</span></label>
|
|
||||||
<input
|
<input
|
||||||
type="url"
|
ref={clientFileRef}
|
||||||
value={newClientWebsite}
|
type="file"
|
||||||
onChange={(e) => setNewClientWebsite(e.target.value)}
|
accept="image/*"
|
||||||
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"
|
className="hidden"
|
||||||
placeholder="https://www.netflix.com"
|
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>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
{/* Hidden file input for logo change */}
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
<input
|
{data.clients.map((client) => (
|
||||||
ref={clientLogoChangeRef}
|
<div key={client.id} className="relative group p-4 bg-slate-900/50 border border-white/10 rounded-lg flex flex-col items-center">
|
||||||
type="file"
|
<img
|
||||||
accept="image/*"
|
src={api.getMediaUrl(client.logo)}
|
||||||
className="hidden"
|
alt={client.name}
|
||||||
onChange={async (e) => {
|
className="h-[60px] w-auto object-contain mb-3"
|
||||||
const file = e.target.files?.[0];
|
style={{ maxWidth: '200px' }}
|
||||||
if (file && editingClientId) {
|
/>
|
||||||
setClientLogoUploading(editingClientId);
|
<span className="text-xs font-mono text-slate-400 text-center">{client.name}</span>
|
||||||
try {
|
<button
|
||||||
const response = await api.uploadFile(file);
|
onClick={() => handleRemoveClient(client.id)}
|
||||||
const media = (response as any).data || response;
|
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity p-1 text-red-400 hover:text-red-300"
|
||||||
let logoUrl = '';
|
>
|
||||||
if (media && typeof media === 'object') {
|
<Trash2 className="w-4 h-4" />
|
||||||
if (media.url) logoUrl = media.url;
|
</button>
|
||||||
else if (media.filename) logoUrl = `/uploads/${media.filename}`;
|
</div>
|
||||||
}
|
))}
|
||||||
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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -30,18 +30,16 @@ export default function Clients() {
|
|||||||
{duplicated.map((client, index) => (
|
{duplicated.map((client, index) => (
|
||||||
<a
|
<a
|
||||||
key={`${client.id}-${index}`}
|
key={`${client.id}-${index}`}
|
||||||
href={client.website || undefined}
|
href={client.website || '#'}
|
||||||
target={client.website ? '_blank' : undefined}
|
target={client.website ? '_blank' : undefined}
|
||||||
rel={client.website ? 'noopener' : undefined}
|
rel="noopener noreferrer"
|
||||||
className={`flex-shrink-0 group transition-all duration-500 ${client.website ? 'cursor-pointer' : 'cursor-default'}`}
|
className="flex-shrink-0 group transition-all duration-500"
|
||||||
title={client.website ? `${client.name} — Visit Website` : client.name}
|
title={client.name}
|
||||||
aria-label={client.website ? `Visit ${client.name} official website` : `${client.name} logo`}
|
|
||||||
onClick={(e) => { if (!client.website) e.preventDefault(); }}
|
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={getMediaUrl(client.logo)}
|
src={getMediaUrl(client.logo)}
|
||||||
alt={`${client.name} logo — official partner of Harun CAN Studio`}
|
alt={client.name}
|
||||||
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' : ''}`}
|
className="h-[50px] md:h-[60px] w-auto object-contain opacity-95 group-hover:opacity-100 transition-all duration-500"
|
||||||
style={{ maxWidth: '250px' }}
|
style={{ maxWidth: '250px' }}
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user