From 1ee37368bdc071d7f1fe18c470ed485dff6459c9 Mon Sep 17 00:00:00 2001 From: RGJorge Date: Fri, 8 May 2026 02:19:45 +0000 Subject: [PATCH] v0.0.24 --- .dockerflow-env-files.json | 3 +- .dockerignore | 7 + .gitignore | 2 +- CLAUDE.md | 8 + Dockerfile | 38 ++ README.md | 10 +- docker-compose.yml | 20 ++ src/client/App.tsx | 25 +- src/client/components/EdgeLegend.tsx | 5 +- src/client/components/HeaderBar.tsx | 34 +- src/client/components/LoginScreen.tsx | 24 +- src/client/components/NodeContextMenu.tsx | 16 +- src/client/i18n.tsx | 406 ++++++++++++++++++++++ src/client/nodes/ServiceNode.tsx | 8 +- src/client/pages/MonitoringPage.tsx | 12 +- src/client/pages/SettingsPage.tsx | 316 +++++++++++++++-- src/client/panels/DetailPanel.tsx | 153 ++++---- src/client/panels/LogPanel.tsx | 8 +- src/server/discord.ts | 254 ++++++++++++++ src/server/docker.ts | 2 +- src/server/index.ts | 89 ++++- src/server/watcher.ts | 11 +- src/shared/types.ts | 17 + 23 files changed, 1321 insertions(+), 147 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 src/client/i18n.tsx create mode 100644 src/server/discord.ts diff --git a/.dockerflow-env-files.json b/.dockerflow-env-files.json index 0f883f5..e788fac 100644 --- a/.dockerflow-env-files.json +++ b/.dockerflow-env-files.json @@ -1,5 +1,6 @@ { "/home/jorge/git/fidelizacion/docker-compose.prod.yml": ".env.prod", "/home/jorge/git/fidelizacion/api/docker-compose.dev.yml": ".env", - "/home/jorge/git/ninjasagacw/docker-compose.infra.yml": ".env" + "/home/jorge/git/ninjasagacw/docker-compose.infra.yml": ".env", + "/home/jorge/git/alteonx-dockerflow/docker-compose.yml": ".env" } \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..031b7a2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +.git +.github +*.md +.env +.dockerflow-*.json diff --git a/.gitignore b/.gitignore index c6f2cf7..7787458 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,4 @@ node_modules/ dist/ *.log .env -.dockerflow-positions.json +.dockerflow-*.json diff --git a/CLAUDE.md b/CLAUDE.md index f33d577..4d88423 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,3 +30,11 @@ - `bun run dev` — desarrollo (servidor + cliente) - `bun run build` — build de produccion + +## i18n (Internacionalizacion) + +- Todo texto visible en la UI debe usar el sistema de traducciones (`useT()` hook de `src/client/i18n.tsx`) +- Al agregar texto nuevo, agregar la key en ambos diccionarios (en + es) en `i18n.tsx` +- Keys usan formato `seccion.descripcion` (ej. `"settings.save"`, `"actions.restart"`) +- Nunca hardcodear strings de UI directamente en JSX +- El idioma se persiste en `localStorage("df:lang")`, default `"en"` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b6fbe87 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +# ── Stage 1: build frontend ── +FROM oven/bun:1 AS build + +WORKDIR /app + +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile + +COPY src/ src/ +COPY vite.config.ts tsconfig.json ./ + +RUN bun run build + +# ── Stage 2: runtime ── +FROM oven/bun:1-slim + +# Docker CLI needed for rebuild/remove via `docker compose` +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends docker-ce-cli \ + && apt-get purge -y curl \ + && apt-get autoremove -y \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY --from=build /app/dist/ dist/ +COPY --from=build /app/src/server/ src/server/ +COPY --from=build /app/src/shared/ src/shared/ +COPY --from=build /app/node_modules/ node_modules/ +COPY --from=build /app/package.json package.json + +EXPOSE 9470 + +CMD ["bun", "run", "src/server/index.ts", "--all"] diff --git a/README.md b/README.md index aaa792e..38815ac 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,15 @@ bun run dev Abre `http://localhost:9420` (Vite dev con hot reload, proxea API al backend en puerto 9470). -### Produccion +### Produccion (Docker) + +```bash +docker compose up -d +``` + +Abre `http://localhost:9470`. + +### Produccion (manual) ```bash bun run build diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..578b4f9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +services: + containerflow: + build: . + ports: + - "${EXTERNAL_PORT:-9470}:9470" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - containerflow-data:/app/data + environment: + - DATA_DIR=/app/data + env_file: .env + restart: unless-stopped + deploy: + resources: + limits: + cpus: "0.15" + memory: 256M + +volumes: + containerflow-data: diff --git a/src/client/App.tsx b/src/client/App.tsx index c1b3392..e130578 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -15,6 +15,7 @@ import "@xyflow/react/dist/style.css"; import { ServiceNode } from "./nodes/ServiceNode"; import { GroupNode } from "./nodes/GroupNode"; import { useDocker } from "./hooks/useDocker"; +import { I18nProvider, useT } from "./i18n"; import { createStatsStore, StatsStoreContext } from "./hooks/useStatsStore"; import { buildLayout, computeEdges, NODE_WIDTH, NODE_HEIGHT, GROUP_PADDING, GROUP_HEADER } from "./engine/layout"; import { DetailPanel } from "./panels/DetailPanel"; @@ -64,12 +65,18 @@ export default function App() { }, []); if (needsAuth === null) return
; - if (needsAuth) return { setAuthToken(t); setNeedsAuth(false); }} />; - return ; + return ( + + {needsAuth + ? { setAuthToken(tk); setNeedsAuth(false); }} /> + : } + + ); } function Dashboard({ token }: { token: string }) { + const { t } = useT(); const statsStore = useMemo(() => createStatsStore(), []); const savedPositions = useRef>({}); const onPositions = useCallback((pos: Record) => { @@ -480,7 +487,7 @@ function Dashboard({ token }: { token: string }) { /> {activePage === "monitoring" && } - {activePage === "settings" && } + {activePage === "settings" && } {/* Canvas — inset (only visible on dashboard) */}
@@ -577,7 +584,7 @@ function Dashboard({ token }: { token: string }) { onClick={() => setFilterOpen((v) => !v)} className="flex items-center gap-2 text-sm text-slate-400 bg-slate-800/80 backdrop-blur-sm hover:bg-slate-700/80 border border-slate-700/50 px-3 py-1.5 rounded-md transition-colors" > - Projects + {t("filter.projects")} {projects.length - hiddenProjects.size}/{projects.length} @@ -602,7 +609,7 @@ function Dashboard({ token }: { token: string }) { }`}> {hiddenProjects.size === 0 && }
- All + {t("filter.all")} {services.filter((s) => s.state === "running").length} / @@ -727,18 +734,18 @@ function Dashboard({ token }: { token: string }) { )} - {connected ? "Live" : "Offline"} + {connected ? t("footer.live") : t("footer.offline")}
{filteredServices.filter((s) => s.state === "running").length} - /{filteredServices.length} containers + /{filteredServices.length} {t("footer.containers")}
- {services.length} containers - {projects.length} projects + {services.length} {t("footer.containers")} + {projects.length} {t("footer.projects")}
AlteonX diff --git a/src/client/components/EdgeLegend.tsx b/src/client/components/EdgeLegend.tsx index 3725add..478c248 100644 --- a/src/client/components/EdgeLegend.tsx +++ b/src/client/components/EdgeLegend.tsx @@ -1,4 +1,5 @@ import { Database, Zap, Radio, Globe } from "lucide-react"; +import { useT } from "../i18n"; const LEGEND_ITEMS = [ { icon: Database, color: "#336791", label: "Database" }, @@ -8,9 +9,11 @@ const LEGEND_ITEMS = [ ] as const; export function EdgeLegend() { + const { t } = useT(); + return (
- Conexiones + {t("legend.connections")} {LEGEND_ITEMS.map(({ icon: Icon, color, label }) => (
diff --git a/src/client/components/HeaderBar.tsx b/src/client/components/HeaderBar.tsx index b48b0c1..360a9c4 100644 --- a/src/client/components/HeaderBar.tsx +++ b/src/client/components/HeaderBar.tsx @@ -5,6 +5,7 @@ import { Play, Square, RotateCcw, } from "lucide-react"; import type { Service, DockerEvent } from "../../shared/types"; +import { useT } from "../i18n"; export type Page = "dashboard" | "monitoring" | "settings"; @@ -53,6 +54,7 @@ interface NotificationBellProps { } function NotificationBell({ events }: NotificationBellProps) { + const { t } = useT(); const [open, setOpen] = useState(false); const [lastSeen, setLastSeen] = useState(events.length); const ref = useRef(null); @@ -93,10 +95,10 @@ function NotificationBell({ events }: NotificationBellProps) { {open && (
- Recent Events + {t("header.recentEvents")}
{recent.length === 0 ? ( -
No events yet
+
{t("header.noEvents")}
) : ( recent.map((ev, i) => (
@@ -133,14 +135,15 @@ export function HeaderBar({ onPageChange, events, }: HeaderBarProps) { + const { t, lang, setLang } = useT(); return (
{/* Left: Navigation */} {/* Center: Logo */} @@ -175,6 +178,27 @@ export function HeaderBar({ )} + {/* Language toggle */} +
+ +
+ +
+ {/* Logout (only if auth is active) */} diff --git a/src/client/components/LoginScreen.tsx b/src/client/components/LoginScreen.tsx index 92218dd..6f800af 100644 --- a/src/client/components/LoginScreen.tsx +++ b/src/client/components/LoginScreen.tsx @@ -1,11 +1,13 @@ import { useState } from "react"; import { Lock, Eye, EyeOff, Terminal } from "lucide-react"; +import { useT } from "../i18n"; interface LoginScreenProps { onAuth: (token: string) => void; } export function LoginScreen({ onAuth }: LoginScreenProps) { + const { t } = useT(); const [token, setToken] = useState(""); const [error, setError] = useState(""); const [showToken, setShowToken] = useState(false); @@ -31,8 +33,8 @@ export function LoginScreen({ onAuth }: LoginScreenProps) { hackerLog([ "$ containerflow connect --auth", - "> Establishing secure connection...", - "> Validating AUTH_TOKEN...", + `> ${t("login.establishingConnection")}`, + `> ${t("login.validatingToken")}`, ], async () => { try { const res = await fetch("/api/health", { @@ -40,23 +42,23 @@ export function LoginScreen({ onAuth }: LoginScreenProps) { }); if (res.ok) { hackerLog([ - "> Token accepted", - "> Loading Docker socket...", - "> Connection established!", + `> ${t("login.tokenAccepted")}`, + `> ${t("login.loadingDocker")}`, + `> ${t("login.connectionEstablished")}`, ], () => { localStorage.setItem("df:token", token); setConnected(true); setTimeout(() => onAuth(token), 800); }); } else { - hackerLog(["> ERROR: Invalid token", "> Connection refused"], () => { - setError("Token invalido"); + hackerLog([`> ${t("login.errorInvalidToken")}`, `> ${t("login.errorConnectionRefused")}`], () => { + setError(t("login.invalidToken")); setConnecting(false); }); } } catch { - hackerLog(["> ERROR: Connection failed"], () => { - setError("No se pudo conectar"); + hackerLog([`> ${t("login.errorConnectionFailed")}`], () => { + setError(t("login.connectionFailed")); setConnecting(false); }); } @@ -110,7 +112,7 @@ export function LoginScreen({ onAuth }: LoginScreenProps) { }`} > - {connecting ? "Connecting..." : "Connect"} + {connecting ? t("login.connecting") : t("login.connect")} @@ -122,7 +124,7 @@ export function LoginScreen({ onAuth }: LoginScreenProps) { key={i} className={`${ line.includes("ERROR") ? "text-red-400" : - line.includes("accepted") || line.includes("established") ? "text-emerald-400" : + line.includes("accepted") || line.includes("established") || line.includes("aceptado") || line.includes("establecida") ? "text-emerald-400" : line.startsWith("$") ? "text-cyan-400" : "text-slate-400" } animate-[fadeIn_0.15s_ease-out]`} > diff --git a/src/client/components/NodeContextMenu.tsx b/src/client/components/NodeContextMenu.tsx index e9aac4d..0c430b0 100644 --- a/src/client/components/NodeContextMenu.tsx +++ b/src/client/components/NodeContextMenu.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef } from "react"; import { RotateCw, Square, Play, Trash2, Terminal, ExternalLink, Hammer } from "lucide-react"; import type { Service } from "../../shared/types"; +import { useT } from "../i18n"; interface NodeContextMenuProps { position: { x: number; y: number }; @@ -11,6 +12,7 @@ interface NodeContextMenuProps { } export function NodeContextMenu({ position, service, onAction, onOpenLogs, onClose }: NodeContextMenuProps) { + const { t } = useT(); const ref = useRef(null); useEffect(() => { @@ -48,23 +50,23 @@ export function NodeContextMenu({ position, service, onAction, onOpenLogs, onClo > {isRunning ? ( <> - { onAction("restart"); onClose(); }} /> - { onAction("stop"); onClose(); }} /> + { onAction("restart"); onClose(); }} /> + { onAction("stop"); onClose(); }} /> ) : ( <> - { onAction("start"); onClose(); }} /> - { onAction("remove"); onClose(); }} /> + { onAction("start"); onClose(); }} /> + { onAction("remove"); onClose(); }} /> )} {service.compose_file && ( <>
- { onAction("rebuild"); onClose(); }} /> + { onAction("rebuild"); onClose(); }} /> )}
- { onOpenLogs(); onClose(); }} /> + { onOpenLogs(); onClose(); }} /> {isRunning && firstPort && ( - Open :{firstPort.host} + {t("actions.open")} :{firstPort.host} )}
diff --git a/src/client/i18n.tsx b/src/client/i18n.tsx new file mode 100644 index 0000000..8800409 --- /dev/null +++ b/src/client/i18n.tsx @@ -0,0 +1,406 @@ +import { createContext, useContext, useState, useCallback, type ReactNode } from "react"; + +const en = { + // Header / Nav + "header.dashboard": "Dashboard", + "header.monitoring": "Monitoring", + "header.settings": "Settings", + "header.recentEvents": "Recent Events", + "header.noEvents": "No events yet", + + // Footer + "footer.live": "Live", + "footer.offline": "Offline", + "footer.containers": "containers", + "footer.projects": "projects", + + // Filter + "filter.projects": "Projects", + "filter.all": "All", + + // Login + "login.connecting": "Connecting...", + "login.connect": "Connect", + "login.invalidToken": "Invalid token", + "login.connectionFailed": "Connection failed", + "login.establishingConnection": "Establishing secure connection...", + "login.validatingToken": "Validating AUTH_TOKEN...", + "login.tokenAccepted": "Token accepted", + "login.loadingDocker": "Loading Docker socket...", + "login.connectionEstablished": "Connection established!", + "login.errorInvalidToken": "ERROR: Invalid token", + "login.errorConnectionRefused": "Connection refused", + "login.errorConnectionFailed": "ERROR: Connection failed", + + // Context menu + "actions.restart": "Restart", + "actions.stop": "Stop", + "actions.start": "Start", + "actions.remove": "Remove", + "actions.rebuild": "Rebuild", + "actions.openLogs": "Open Logs", + "actions.open": "Open", + "actions.retry": "Retry", + + // Edge legend + "legend.connections": "Connections", + + // Service node + "node.noTag": "No Tag", + "node.cpu": "CPU", + "node.mem": "MEM", + + // Detail panel + "detail.processing": "Processing...", + "detail.logs": "Logs", + "detail.info": "Info", + "detail.stats": "Stats", + "detail.env": "Env", + "detail.config": "Config", + "detail.exec": "Exec", + "detail.collapse": "Collapse", + "detail.expand": "Expand", + "detail.close": "Close", + "detail.cancel": "Cancel", + "detail.loadingLogs": "Loading logs...", + "detail.noLogs": "No logs available", + "detail.linesHidden": "lines hidden", + "detail.streaming": "streaming", + + // Detail panel - Info tab + "detail.status": "Status", + "detail.image": "Image", + "detail.container": "Container", + "detail.project": "Project", + "detail.compose": "Compose", + "detail.envFile": "Env File", + "detail.envFileAutoDetect": "Auto (detect)", + "detail.envFileAuto": "Auto", + "detail.envFileTip": "Only files starting with .env are detected", + "detail.ports": "Ports", + "detail.networks": "Networks", + "detail.connectedTo": "Connected to", + + // Detail panel - Config tab + "detail.restartPolicy": "Restart Policy", + "detail.resourceLimits": "Resource Limits", + "detail.memoryLimit": "Memory Limit", + "detail.cpuQuota": "CPU Quota", + "detail.unlimited": "Unlimited", + "detail.healthCheck": "Health Check", + "detail.healthNotConfigured": "Not configured", + "detail.recentChecks": "Recent checks", + + // Detail panel - Env tab + "detail.variables": "variables", + "detail.copyAll": "Copy all", + "detail.copied": "Copied!", + "detail.hideAll": "Hide all", + "detail.showAll": "Show all", + "detail.noEnvVars": "No environment variables available", + + // Detail panel - Stats tab + "detail.cpuUsage": "CPU Usage", + "detail.memoryUsage": "Memory Usage", + "detail.memory": "Memory", + "detail.noStats": "No stats available", + + // Detail panel - Actions / Confirmations + "detail.actionSuccess": "successful", + "detail.actionFailed": "Failed to", + "detail.confirmStop": "Stop this container? This will interrupt the service.", + "detail.confirmRestart": "Restart this container? This will briefly interrupt the service.", + "detail.confirmRemove": "Remove this container? This will stop and delete it.", + "detail.confirmRebuild": "Rebuild this container? This will rebuild the image and recreate the container.", + + // Detail panel - Crash + "detail.containerCrashed": "Container crashed", + "detail.exitCode": "Exit code", + "detail.oomKilled": "OOM Killed", + "detail.restarted": "Restarted", + "detail.times": "times", + "detail.checkLogs": "Check the logs below for details", + + // Detail panel - Exec + "detail.execPlaceholder": "e.g. python manage.py migrate", + "detail.run": "Run", + "detail.exitCodeLabel": "Exit code", + "detail.noOutput": "(no output)", + + // Log panel + "logPanel.streaming": "streaming", + "logPanel.loadingLogs": "Loading logs...", + "logPanel.noLogs": "No logs available", + + // Monitoring page + "monitoring.title": "Event History", + "monitoring.subtitle": "Docker container events in real-time", + "monitoring.noEvents": "No events yet. Events will appear here as containers start, stop, or restart.", + "monitoring.alertRules": "Alert Rules", + "monitoring.alertRulesDesc": "Configure alerting rules for container events \u2014 coming soon", + + // Settings page + "settings.title": "Settings", + "settings.subtitle": "Application configuration", + "settings.general": "General", + "settings.version": "Version", + "settings.mode": "Mode", + "settings.singleHost": "Single Host", + "settings.projects": "Projects", + "settings.containers": "Containers", + "settings.dockerHosts": "Docker Hosts", + "settings.dockerHostsDesc": "Multi-host management \u2014 coming soon", + "settings.dockerHostsDetail": "Connect to remote Docker daemons and manage multiple hosts from a single dashboard.", + "settings.discord": "Discord Notifications", + "settings.webhookUrl": "Webhook URL", + "settings.sending": "Sending...", + "settings.test": "Test", + "settings.webhookSuccess": "Webhook sent successfully!", + "settings.events": "Events", + "settings.containerStateChanges": "Container state changes", + "settings.containerStateChangesTooltip": "Notifies when Docker detects automatic state changes: start, stop, die, restart, or health status changes (crashes, OOM, restart policies).", + "settings.resourceAlerts": "Resource alerts", + "settings.resourceAlertsTooltip": "Monitors CPU and memory usage every 5 seconds. Sends an alert when a container exceeds the configured thresholds.", + "settings.uiActions": "UI actions", + "settings.uiActionsTooltip": "Notifies when someone performs a manual action from the ContainerFlow UI: stop, start, restart, or rebuild.", + "settings.actionErrors": "Action errors", + "settings.actionErrorsTooltip": "Notifies when an action fails, such as a rebuild that exits with an error. Includes the error details in the message.", + "settings.resourceThresholds": "Resource Thresholds", + "settings.resourceThresholdsTooltip": "Set the percentage at which CPU or memory usage triggers a Discord alert. Checked every 5 seconds during stats polling.", + "settings.cpu": "CPU", + "settings.memory": "Memory", + "settings.cooldown": "Cooldown (minutes)", + "settings.cooldownTooltip": "Minimum time between duplicate notifications for the same container and event type. Applies to state changes, resource alerts, and UI actions.", + "settings.downReminder": "Down service reminder (minutes)", + "settings.downReminderTooltip": "How often to resend a notification while a container remains down. You will keep receiving alerts at this interval until the service recovers.", + "settings.saving": "Saving...", + "settings.saved": "Saved", + "settings.save": "Save", + "settings.configSaved": "Configuration saved", + "settings.requestFailed": "Request failed", +} as const; + +export type TranslationKey = keyof typeof en; + +const es: Record = { + // Header / Nav + "header.dashboard": "Dashboard", + "header.monitoring": "Monitoreo", + "header.settings": "Configuraci\u00f3n", + "header.recentEvents": "Eventos Recientes", + "header.noEvents": "Sin eventos a\u00fan", + + // Footer + "footer.live": "En vivo", + "footer.offline": "Desconectado", + "footer.containers": "contenedores", + "footer.projects": "proyectos", + + // Filter + "filter.projects": "Proyectos", + "filter.all": "Todos", + + // Login + "login.connecting": "Conectando...", + "login.connect": "Conectar", + "login.invalidToken": "Token inv\u00e1lido", + "login.connectionFailed": "No se pudo conectar", + "login.establishingConnection": "Estableciendo conexi\u00f3n segura...", + "login.validatingToken": "Validando AUTH_TOKEN...", + "login.tokenAccepted": "Token aceptado", + "login.loadingDocker": "Cargando socket de Docker...", + "login.connectionEstablished": "\u00a1Conexi\u00f3n establecida!", + "login.errorInvalidToken": "ERROR: Token inv\u00e1lido", + "login.errorConnectionRefused": "Conexi\u00f3n rechazada", + "login.errorConnectionFailed": "ERROR: Conexi\u00f3n fallida", + + // Context menu + "actions.restart": "Reiniciar", + "actions.stop": "Detener", + "actions.start": "Iniciar", + "actions.remove": "Eliminar", + "actions.rebuild": "Reconstruir", + "actions.openLogs": "Ver Logs", + "actions.open": "Abrir", + "actions.retry": "Reintentar", + + // Edge legend + "legend.connections": "Conexiones", + + // Service node + "node.noTag": "Sin Tag", + "node.cpu": "CPU", + "node.mem": "MEM", + + // Detail panel + "detail.processing": "Procesando...", + "detail.logs": "Logs", + "detail.info": "Info", + "detail.stats": "Stats", + "detail.env": "Env", + "detail.config": "Config", + "detail.exec": "Exec", + "detail.collapse": "Colapsar", + "detail.expand": "Expandir", + "detail.close": "Cerrar", + "detail.cancel": "Cancelar", + "detail.loadingLogs": "Cargando logs...", + "detail.noLogs": "No hay logs disponibles", + "detail.linesHidden": "l\u00edneas ocultas", + "detail.streaming": "en vivo", + + // Detail panel - Info tab + "detail.status": "Estado", + "detail.image": "Imagen", + "detail.container": "Contenedor", + "detail.project": "Proyecto", + "detail.compose": "Compose", + "detail.envFile": "Env File", + "detail.envFileAutoDetect": "Auto (detectar)", + "detail.envFileAuto": "Auto", + "detail.envFileTip": "Solo se detectan archivos que comienzan con .env", + "detail.ports": "Puertos", + "detail.networks": "Redes", + "detail.connectedTo": "Conectado a", + + // Detail panel - Config tab + "detail.restartPolicy": "Pol\u00edtica de Reinicio", + "detail.resourceLimits": "L\u00edmites de Recursos", + "detail.memoryLimit": "L\u00edmite de Memoria", + "detail.cpuQuota": "Cuota de CPU", + "detail.unlimited": "Sin l\u00edmite", + "detail.healthCheck": "Health Check", + "detail.healthNotConfigured": "No configurado", + "detail.recentChecks": "Chequeos recientes", + + // Detail panel - Env tab + "detail.variables": "variables", + "detail.copyAll": "Copiar todo", + "detail.copied": "\u00a1Copiado!", + "detail.hideAll": "Ocultar todo", + "detail.showAll": "Mostrar todo", + "detail.noEnvVars": "No hay variables de entorno disponibles", + + // Detail panel - Stats tab + "detail.cpuUsage": "Uso de CPU", + "detail.memoryUsage": "Uso de Memoria", + "detail.memory": "Memoria", + "detail.noStats": "No hay estad\u00edsticas disponibles", + + // Detail panel - Actions / Confirmations + "detail.actionSuccess": "exitoso", + "detail.actionFailed": "Error al", + "detail.confirmStop": "\u00bfDetener este contenedor? Esto interrumpir\u00e1 el servicio.", + "detail.confirmRestart": "\u00bfReiniciar este contenedor? Esto interrumpir\u00e1 brevemente el servicio.", + "detail.confirmRemove": "\u00bfEliminar este contenedor? Esto lo detendr\u00e1 y eliminar\u00e1.", + "detail.confirmRebuild": "\u00bfReconstruir este contenedor? Esto reconstruir\u00e1 la imagen y recrear\u00e1 el contenedor.", + + // Detail panel - Crash + "detail.containerCrashed": "Contenedor crash\u00f3", + "detail.exitCode": "C\u00f3digo de salida", + "detail.oomKilled": "OOM Killed", + "detail.restarted": "Reiniciado", + "detail.times": "veces", + "detail.checkLogs": "Revisa los logs abajo para m\u00e1s detalles", + + // Detail panel - Exec + "detail.execPlaceholder": "ej. python manage.py migrate", + "detail.run": "Ejecutar", + "detail.exitCodeLabel": "C\u00f3digo de salida", + "detail.noOutput": "(sin salida)", + + // Log panel + "logPanel.streaming": "en vivo", + "logPanel.loadingLogs": "Cargando logs...", + "logPanel.noLogs": "No hay logs disponibles", + + // Monitoring page + "monitoring.title": "Historial de Eventos", + "monitoring.subtitle": "Eventos de contenedores Docker en tiempo real", + "monitoring.noEvents": "Sin eventos a\u00fan. Los eventos aparecer\u00e1n aqu\u00ed cuando los contenedores inicien, se detengan o reinicien.", + "monitoring.alertRules": "Reglas de Alerta", + "monitoring.alertRulesDesc": "Configurar reglas de alerta para eventos de contenedores \u2014 pr\u00f3ximamente", + + // Settings page + "settings.title": "Configuraci\u00f3n", + "settings.subtitle": "Configuraci\u00f3n de la aplicaci\u00f3n", + "settings.general": "General", + "settings.version": "Versi\u00f3n", + "settings.mode": "Modo", + "settings.singleHost": "Host \u00danico", + "settings.projects": "Proyectos", + "settings.containers": "Contenedores", + "settings.dockerHosts": "Hosts de Docker", + "settings.dockerHostsDesc": "Gesti\u00f3n multi-host \u2014 pr\u00f3ximamente", + "settings.dockerHostsDetail": "Conecta a daemons de Docker remotos y gestiona m\u00faltiples hosts desde un solo dashboard.", + "settings.discord": "Notificaciones de Discord", + "settings.webhookUrl": "URL del Webhook", + "settings.sending": "Enviando...", + "settings.test": "Probar", + "settings.webhookSuccess": "\u00a1Webhook enviado exitosamente!", + "settings.events": "Eventos", + "settings.containerStateChanges": "Cambios de estado de contenedores", + "settings.containerStateChangesTooltip": "Notifica cuando Docker detecta cambios de estado autom\u00e1ticos: inicio, parada, muerte, reinicio o cambios de salud (crashes, OOM, pol\u00edticas de reinicio).", + "settings.resourceAlerts": "Alertas de recursos", + "settings.resourceAlertsTooltip": "Monitorea el uso de CPU y memoria cada 5 segundos. Env\u00eda una alerta cuando un contenedor excede los umbrales configurados.", + "settings.uiActions": "Acciones de UI", + "settings.uiActionsTooltip": "Notifica cuando alguien realiza una acci\u00f3n manual desde la UI de ContainerFlow: detener, iniciar, reiniciar o reconstruir.", + "settings.actionErrors": "Errores de acciones", + "settings.actionErrorsTooltip": "Notifica cuando una acci\u00f3n falla, como un rebuild que termina con error. Incluye los detalles del error en el mensaje.", + "settings.resourceThresholds": "Umbrales de Recursos", + "settings.resourceThresholdsTooltip": "Configura el porcentaje en el que el uso de CPU o memoria dispara una alerta de Discord. Se revisa cada 5 segundos durante el polling de estad\u00edsticas.", + "settings.cpu": "CPU", + "settings.memory": "Memoria", + "settings.cooldown": "Cooldown (minutos)", + "settings.cooldownTooltip": "Tiempo m\u00ednimo entre notificaciones duplicadas para el mismo contenedor y tipo de evento. Aplica a cambios de estado, alertas de recursos y acciones de UI.", + "settings.downReminder": "Recordatorio de servicio ca\u00eddo (minutos)", + "settings.downReminderTooltip": "Cada cu\u00e1nto reenviar una notificaci\u00f3n mientras un contenedor siga ca\u00eddo. Seguir\u00e1s recibiendo alertas en este intervalo hasta que el servicio se recupere.", + "settings.saving": "Guardando...", + "settings.saved": "Guardado", + "settings.save": "Guardar", + "settings.configSaved": "Configuraci\u00f3n guardada", + "settings.requestFailed": "Error en la solicitud", +}; + +export type Lang = "en" | "es"; + +interface I18nContextValue { + lang: Lang; + setLang: (lang: Lang) => void; + t: (key: TranslationKey) => string; +} + +const I18nContext = createContext(null); + +const dictionaries = { en, es } as const; + +export function I18nProvider({ children }: { children: ReactNode }) { + const [lang, setLangState] = useState(() => { + try { + const saved = localStorage.getItem("df:lang"); + if (saved === "es" || saved === "en") return saved; + } catch {} + return "en"; + }); + + const setLang = useCallback((l: Lang) => { + setLangState(l); + try { localStorage.setItem("df:lang", l); } catch {} + }, []); + + const t = useCallback((key: TranslationKey): string => { + return dictionaries[lang][key] || key; + }, [lang]); + + return ( + + {children} + + ); +} + +export function useT() { + const ctx = useContext(I18nContext); + if (!ctx) throw new Error("useT must be used within I18nProvider"); + return ctx; +} diff --git a/src/client/nodes/ServiceNode.tsx b/src/client/nodes/ServiceNode.tsx index aea08c6..7a9b0f9 100644 --- a/src/client/nodes/ServiceNode.tsx +++ b/src/client/nodes/ServiceNode.tsx @@ -1,6 +1,7 @@ import { memo, useState, useEffect } from "react"; import { Handle, Position, type NodeProps } from "@xyflow/react"; import { useNodeStats } from "../hooks/useStatsStore"; +import { useT } from "../i18n"; import { Database, Zap, @@ -113,6 +114,7 @@ function ProcessingTimer({ startedAt }: { startedAt: number }) { } export const ServiceNode = memo(function ServiceNode({ data, id }: NodeProps) { + const { t } = useT(); const d = data as unknown as ServiceNodeData; const nodeStats = useNodeStats(id); const s = stateStyles[d.state] || stateStyles.exited; @@ -184,7 +186,7 @@ export const ServiceNode = memo(function ServiceNode({ data, id }: NodeProps) { )}
- {d.image.startsWith("sha256:") ? `Sin Tag (${d.image.slice(7, 19)})` : d.image} + {d.image.startsWith("sha256:") ? `${t("node.noTag")} (${d.image.slice(7, 19)})` : d.image}
@@ -207,8 +209,8 @@ export const ServiceNode = memo(function ServiceNode({ data, id }: NodeProps) { {nodeStats && (
- CPU {nodeStats.cpu.toFixed(1)}% - MEM {nodeStats.mem_mb.toFixed(0)}MB + {t("node.cpu")} {nodeStats.cpu.toFixed(1)}% + {t("node.mem")} {nodeStats.mem_mb.toFixed(0)}MB
diff --git a/src/client/pages/MonitoringPage.tsx b/src/client/pages/MonitoringPage.tsx index b173498..6428119 100644 --- a/src/client/pages/MonitoringPage.tsx +++ b/src/client/pages/MonitoringPage.tsx @@ -1,5 +1,6 @@ import { Activity, Play, Square, RotateCcw, AlertTriangle } from "lucide-react"; import type { DockerEvent } from "../../shared/types"; +import { useT } from "../i18n"; function timeAgo(ts: number): string { const diff = Math.floor((Date.now() / 1000) - ts); @@ -37,6 +38,7 @@ interface MonitoringPageProps { } export function MonitoringPage({ events }: MonitoringPageProps) { + const { t } = useT(); const sorted = [...events].reverse(); return ( @@ -46,8 +48,8 @@ export function MonitoringPage({ events }: MonitoringPageProps) {
-

Event History

-

Docker container events in real-time

+

{t("monitoring.title")}

+

{t("monitoring.subtitle")}

@@ -56,7 +58,7 @@ export function MonitoringPage({ events }: MonitoringPageProps) { {sorted.length === 0 ? (
-

No events yet. Events will appear here as containers start, stop, or restart.

+

{t("monitoring.noEvents")}

) : (
@@ -79,8 +81,8 @@ export function MonitoringPage({ events }: MonitoringPageProps) { {/* Alert Rules placeholder */}
-

Alert Rules

-

Configure alerting rules for container events — coming soon

+

{t("monitoring.alertRules")}

+

{t("monitoring.alertRulesDesc")}

diff --git a/src/client/pages/SettingsPage.tsx b/src/client/pages/SettingsPage.tsx index b71410d..9fbb451 100644 --- a/src/client/pages/SettingsPage.tsx +++ b/src/client/pages/SettingsPage.tsx @@ -1,11 +1,128 @@ -import { Settings, Server, Bell, Info } from "lucide-react"; +import { useState, useEffect, useCallback } from "react"; +import { Settings, Server, Bell, Info, Send, Save, Check, X, HelpCircle } from "lucide-react"; +import type { DiscordConfig } from "../../shared/types"; +import { useT } from "../i18n"; interface SettingsPageProps { projects: string[]; servicesCount: number; + token: string; } -export function SettingsPage({ projects, servicesCount }: SettingsPageProps) { +const DEFAULT_CONFIG: DiscordConfig = { + enabled: false, + webhookUrl: "", + events: { + containerStateChanges: true, + resourceAlerts: true, + uiActions: true, + actionErrors: true, + }, + thresholds: { + cpuPercent: 80, + memPercent: 90, + }, + cooldownMinutes: 5, + downReminderMinutes: 5, +}; + +function Tooltip({ text }: { text: string }) { + const [show, setShow] = useState(false); + return ( + + + {show && ( +
+ {text} +
+
+ )} + + ); +} + +function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) { + return ( + + ); +} + +export function SettingsPage({ projects, servicesCount, token }: SettingsPageProps) { + const { t } = useT(); + const [config, setConfig] = useState(DEFAULT_CONFIG); + const [loaded, setLoaded] = useState(false); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState<{ ok: boolean; error?: string } | null>(null); + + const headers = useCallback((): Record => { + const h: Record = { "Content-Type": "application/json" }; + if (token) h["Authorization"] = `Bearer ${token}`; + return h; + }, [token]); + + useEffect(() => { + fetch("/api/discord-config", { headers: headers() }) + .then((r) => r.ok ? r.json() : DEFAULT_CONFIG) + .then((data: DiscordConfig) => { + setConfig({ ...DEFAULT_CONFIG, ...data, events: { ...DEFAULT_CONFIG.events, ...data.events }, thresholds: { ...DEFAULT_CONFIG.thresholds, ...data.thresholds } }); + setLoaded(true); + }) + .catch(() => setLoaded(true)); + }, [headers]); + + const handleSave = async () => { + setSaving(true); + setSaved(false); + try { + const res = await fetch("/api/discord-config", { method: "PUT", headers: headers(), body: JSON.stringify(config) }); + if (res.ok) { + setSaved(true); + setTimeout(() => setSaved(false), 3000); + } + } catch {} + setSaving(false); + }; + + const handleTest = async () => { + setTesting(true); + setTestResult(null); + try { + const res = await fetch("/api/discord-config/test", { method: "POST", headers: headers(), body: JSON.stringify({ webhookUrl: config.webhookUrl }) }); + const result = await res.json(); + setTestResult(result); + setTimeout(() => setTestResult(null), 5000); + } catch { + setTestResult({ ok: false, error: t("settings.requestFailed") }); + } + setTesting(false); + }; + + const updateEvents = (key: keyof DiscordConfig["events"], value: boolean) => { + setConfig((prev) => ({ ...prev, events: { ...prev.events, [key]: value } })); + }; + + const updateThresholds = (key: keyof DiscordConfig["thresholds"], value: number) => { + setConfig((prev) => ({ ...prev, thresholds: { ...prev.thresholds, [key]: value } })); + }; + return (
@@ -13,8 +130,8 @@ export function SettingsPage({ projects, servicesCount }: SettingsPageProps) {
-

Settings

-

Application configuration

+

{t("settings.title")}

+

{t("settings.subtitle")}

@@ -22,23 +139,23 @@ export function SettingsPage({ projects, servicesCount }: SettingsPageProps) {
-

General

+

{t("settings.general")}

- Version + {t("settings.version")} v0.0.1
- Mode - Single Host + {t("settings.mode")} + {t("settings.singleHost")}
- Projects + {t("settings.projects")} {projects.length}
- Containers + {t("settings.containers")} {servicesCount}
@@ -48,20 +165,179 @@ export function SettingsPage({ projects, servicesCount }: SettingsPageProps) {
-

Docker Hosts

+

{t("settings.dockerHosts")}

-

Multi-host management — coming soon

-

Connect to remote Docker daemons and manage multiple hosts from a single dashboard.

+

{t("settings.dockerHostsDesc")}

+

{t("settings.dockerHostsDetail")}

- {/* Notifications */} -
-
- -

Notifications

+ {/* Discord Notifications */} +
+
+
+ +

{t("settings.discord")}

+
+ setConfig((prev) => ({ ...prev, enabled: v }))} /> +
+ + {loaded && ( +
+ {/* Webhook URL */} +
+ +
+ setConfig((prev) => ({ ...prev, webhookUrl: e.target.value }))} + placeholder="https://discord.com/api/webhooks/..." + className="flex-1 bg-slate-900/50 border border-slate-700/60 rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-slate-600 focus:outline-none focus:border-cyan-500/50" + /> + +
+ {testResult && ( +
+ {testResult.ok ? : } + {testResult.ok ? t("settings.webhookSuccess") : testResult.error} +
+ )} +
+ + {/* Event Toggles */} +
+ +
+
+ + {t("settings.containerStateChanges")} + + + updateEvents("containerStateChanges", v)} /> +
+
+ + {t("settings.resourceAlerts")} + + + updateEvents("resourceAlerts", v)} /> +
+
+ + {t("settings.uiActions")} + + + updateEvents("uiActions", v)} /> +
+
+ + {t("settings.actionErrors")} + + + updateEvents("actionErrors", v)} /> +
+
+
+ + {/* Thresholds (only visible when resourceAlerts is on) */} + {config.events.resourceAlerts && ( +
+ +
+
+
+ {t("settings.cpu")} + {config.thresholds.cpuPercent}% +
+ updateThresholds("cpuPercent", parseInt(e.target.value))} + className="w-full h-1.5 bg-slate-700 rounded-full appearance-none cursor-pointer accent-cyan-500" + /> +
+
+
+ {t("settings.memory")} + {config.thresholds.memPercent}% +
+ updateThresholds("memPercent", parseInt(e.target.value))} + className="w-full h-1.5 bg-slate-700 rounded-full appearance-none cursor-pointer accent-cyan-500" + /> +
+
+
+ )} + + {/* Cooldown + Down reminder */} +
+
+ + { + const v = parseInt(e.target.value); + if (v >= 1 && v <= 60) setConfig((prev) => ({ ...prev, cooldownMinutes: v })); + }} + className="w-24 bg-slate-900/50 border border-slate-700/60 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-cyan-500/50" + /> +
+
+ + { + const v = parseInt(e.target.value); + if (v >= 1 && v <= 60) setConfig((prev) => ({ ...prev, downReminderMinutes: v })); + }} + className="w-24 bg-slate-900/50 border border-slate-700/60 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-cyan-500/50" + /> +
+
+
+ )} + + {/* Save */} +
+ + {saved && {t("settings.configSaved")}}
-

Webhook & email notifications — coming soon

-

Configure Slack, Discord, or email alerts for container events and health checks.

diff --git a/src/client/panels/DetailPanel.tsx b/src/client/panels/DetailPanel.tsx index 3498084..1a06325 100644 --- a/src/client/panels/DetailPanel.tsx +++ b/src/client/panels/DetailPanel.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState, useCallback, useMemo, startTransition } from "react"; import { X, Pause, Play, Square, RotateCw, Hammer, Trash2, Terminal, Network, Globe, Info as InfoIcon, Activity, Variable, Settings, ChevronUp, ChevronDown, Eye, EyeOff, Copy, Check, Loader2, AlertTriangle, Maximize2, ExternalLink, Pencil, HelpCircle } from "lucide-react"; import type { Service, Stats, LogLine, WSMessage, Connection, DockerEvent } from "../../shared/types"; +import { useT } from "../i18n"; type Tab = "info" | "config" | "env" | "stats"; @@ -21,11 +22,18 @@ const SYSTEM_ENV_KEYS = new Set([ "MONGO_VERSION", "MONGO_MAJOR", "MONGO_PACKAGE", "MONGO_REPO", ]); -const TABS: { id: Tab; label: string; icon: typeof InfoIcon }[] = [ - { id: "info", label: "Info", icon: InfoIcon }, - { id: "stats", label: "Stats", icon: Activity }, - { id: "env", label: "Env", icon: Variable }, - { id: "config", label: "Config", icon: Settings }, +const TAB_KEYS = { + info: "detail.info", + stats: "detail.stats", + env: "detail.env", + config: "detail.config", +} as const; + +const TABS: { id: Tab; icon: typeof InfoIcon }[] = [ + { id: "info", icon: InfoIcon }, + { id: "stats", icon: Activity }, + { id: "env", icon: Variable }, + { id: "config", icon: Settings }, ]; interface DetailPanelProps { @@ -49,6 +57,7 @@ interface DetailPanelProps { } export function DetailPanel({ service, stats, logLines, token, closing, onClose, onAction, clearProcessing, sendMessage, clearLogLines, connections, services, getLogsSince, initialLogsFullscreen, envFiles, onEnvFileChange, events }: DetailPanelProps) { + const { t } = useT(); const [initialLogs, setInitialLogs] = useState([]); const [autoScroll, setAutoScroll] = useState(true); const [loading, setLoading] = useState(true); @@ -123,17 +132,17 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, const res = await fetch(`/api/containers/${service.id}/${action}`, { method: "POST", headers }); const data = await res.json(); if (res.ok) { - setActionResult({ type: "success", message: `${action} successful` }); + setActionResult({ type: "success", message: `${action} ${t("detail.actionSuccess")}` }); if (action === "remove") { setTimeout(() => handleClose(), 1000); } } else { clearProcessing(service.uid); - setActionResult({ type: "error", message: data.error || `Failed to ${action}` }); + setActionResult({ type: "error", message: data.error || `${t("detail.actionFailed")} ${action}` }); } } catch { clearProcessing(service.uid); - setActionResult({ type: "error", message: `Failed to ${action}` }); + setActionResult({ type: "error", message: `${t("detail.actionFailed")} ${action}` }); } finally { setActionLoading(null); setTimeout(() => setActionResult(null), 3000); @@ -334,7 +343,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, {isProcessing ? (
- Processing... + {t("detail.processing")}
) : ( <> @@ -343,10 +352,10 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, onClick={() => setConfirmAction("rebuild")} disabled={!!actionLoading} className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-cyan-400 hover:bg-cyan-400/10 transition-colors disabled:opacity-40" - title="Rebuild" + title={t("actions.rebuild")} > {actionLoading === "rebuild" ? : } - Rebuild + {t("actions.rebuild")} )} {service.state === "running" ? ( @@ -355,19 +364,19 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, onClick={() => setConfirmAction("restart")} disabled={!!actionLoading} className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-yellow-400 hover:bg-yellow-400/10 transition-colors disabled:opacity-40" - title="Restart" + title={t("actions.restart")} > {actionLoading === "restart" ? : } - Restart + {t("actions.restart")} ) : ( @@ -376,10 +385,10 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, onClick={() => setConfirmAction("remove")} disabled={!!actionLoading} className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-red-400 hover:bg-red-400/10 transition-colors disabled:opacity-40" - title="Remove" + title={t("actions.remove")} > {actionLoading === "remove" ? : } - Remove + {t("actions.remove")} )} @@ -401,7 +410,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, @@ -417,10 +426,10 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, "text-yellow-400" }`} /> - {confirmAction === "stop" ? "Stop this container? This will interrupt the service." : - confirmAction === "restart" ? "Restart this container? This will briefly interrupt the service." : - confirmAction === "remove" ? "Remove this container? This will stop and delete it." : - "Rebuild this container? This will rebuild the image and recreate the container."} + {confirmAction === "stop" ? t("detail.confirmStop") : + confirmAction === "restart" ? t("detail.confirmRestart") : + confirmAction === "remove" ? t("detail.confirmRemove") : + t("detail.confirmRebuild")}
)} @@ -455,7 +464,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, }`} > - {tab.label} + {t(TAB_KEYS[tab.id])} ); @@ -474,34 +483,34 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
-
Container crashed
+
{t("detail.containerCrashed")}
- Exit code: {service.exit_code} - {service.oom_killed && OOM Killed} - {service.restart_count > 0 && Restarted {service.restart_count} times} + {t("detail.exitCode")}: {service.exit_code} + {service.oom_killed && {t("detail.oomKilled")}} + {service.restart_count > 0 && {t("detail.restarted")} {service.restart_count} {t("detail.times")}}
-
Check the logs below for details
+
{t("detail.checkLogs")}
)} {service.status && ( - + )} - - - + + + {service.compose_file && ( - + )} {service.compose_file && (
- Env File + {t("detail.envFile")} - Only files starting with .env are detected + {t("detail.envFileTip")} @@ -512,7 +521,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, onChange={(e) => setEnvFileSelected(e.target.value)} className="flex-1 bg-slate-800 border border-slate-600 rounded px-2 py-1 text-sm font-mono text-slate-200 focus:outline-none focus:border-cyan-500" > - + {envFileOptions.map((f) => ( ))} @@ -538,7 +547,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, ) : (
- {envFiles[service.compose_file!] || "Auto"} + {envFiles[service.compose_file!] || t("detail.envFileAuto")}
@@ -786,7 +795,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, })}
) : ( -
No environment variables available
+
{t("detail.noEnvVars")}
)}
); @@ -798,14 +807,14 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, {stats ? ( <>
- 80 ? "text-red-400" : stats.cpu > 50 ? "text-yellow-400" : "text-emerald-400"} /> - 80 ? "text-red-400" : stats.mem_percent > 50 ? "text-yellow-400" : "text-emerald-400"} /> + 80 ? "text-red-400" : stats.cpu > 50 ? "text-yellow-400" : "text-emerald-400"} /> + 80 ? "text-red-400" : stats.mem_percent > 50 ? "text-yellow-400" : "text-emerald-400"} />
{/* CPU bar */}
- CPU Usage + {t("detail.cpuUsage")} {stats.cpu.toFixed(1)}%
@@ -819,7 +828,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, {/* Memory bar */}
- Memory Usage + {t("detail.memoryUsage")} {stats.mem_mb.toFixed(0)} MB ({stats.mem_percent.toFixed(1)}%)
@@ -831,7 +840,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
) : ( -
No stats available
+
{t("detail.noStats")}
)}
)} @@ -864,14 +873,14 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, title={logsExpanded ? "Collapse logs" : "Expand logs"} > {logsExpanded ? : } - {logsExpanded ? "Collapse" : "Expand"} + {logsExpanded ? t("detail.collapse") : t("detail.expand")}
- Logs + {t("detail.logs")} {service.state === "running" && subscribedRef.current && ( )} @@ -884,7 +893,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, title="Exec command" > - Exec + {t("detail.exec")} )}
@@ -963,10 +972,10 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, className="flex-1 overflow-y-auto overflow-x-hidden font-mono text-xs leading-5 px-3 py-2" > {loading && ( -
Loading logs...
+
{t("detail.loadingLogs")}
)} {!loading && allLines.length === 0 && ( -
No logs available
+
{t("detail.noLogs")}
)} {allLines.map((l, i) => (
@@ -1034,7 +1043,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, > {allLines.length > 500 && (
- {allLines.length - 500} lines hidden + {allLines.length - 500} {t("detail.linesHidden")}
)} {(allLines.length > 500 ? allLines.slice(-500) : allLines).map((l, i) => ( diff --git a/src/client/panels/LogPanel.tsx b/src/client/panels/LogPanel.tsx index 568bd82..e8db947 100644 --- a/src/client/panels/LogPanel.tsx +++ b/src/client/panels/LogPanel.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState, useCallback } from "react"; import { X, Pause, Play, Terminal } from "lucide-react"; import type { Service, LogLine, WSMessage } from "../../shared/types"; +import { useT } from "../i18n"; interface LogPanelProps { service: Service; @@ -12,6 +13,7 @@ interface LogPanelProps { } export function LogPanel({ service, logLines, token, onClose, sendMessage, clearLogLines }: LogPanelProps) { + const { t } = useT(); const [initialLogs, setInitialLogs] = useState([]); const [autoScroll, setAutoScroll] = useState(true); const [loading, setLoading] = useState(true); @@ -80,7 +82,7 @@ export function LogPanel({ service, logLines, token, onClose, sendMessage, clear {service.state === "running" && subscribedRef.current && ( - streaming + {t("logPanel.streaming")} )}
@@ -109,10 +111,10 @@ export function LogPanel({ service, logLines, token, onClose, sendMessage, clear className="flex-1 overflow-y-auto overflow-x-hidden font-mono text-xs leading-5 px-4 py-2" > {loading && ( -
Loading logs...
+
{t("logPanel.loadingLogs")}
)} {!loading && allLines.length === 0 && ( -
No logs available
+
{t("logPanel.noLogs")}
)} {allLines.map((l, i) => (
diff --git a/src/server/discord.ts b/src/server/discord.ts new file mode 100644 index 0000000..eb63a86 --- /dev/null +++ b/src/server/discord.ts @@ -0,0 +1,254 @@ +import fs from "fs"; +import path from "path"; +import type { DiscordConfig } from "../shared/types"; + +const DATA_DIR = process.env.DATA_DIR || process.cwd(); +const CONFIG_FILE = path.join(DATA_DIR, ".dockerflow-discord.json"); + +const DEFAULT_CONFIG: DiscordConfig = { + enabled: false, + webhookUrl: "", + events: { + containerStateChanges: true, + resourceAlerts: true, + uiActions: true, + actionErrors: true, + }, + thresholds: { + cpuPercent: 80, + memPercent: 90, + }, + cooldownMinutes: 5, + downReminderMinutes: 5, +}; + +export function loadDiscordConfig(): DiscordConfig { + try { + if (fs.existsSync(CONFIG_FILE)) { + const data = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8")); + return { ...DEFAULT_CONFIG, ...data, events: { ...DEFAULT_CONFIG.events, ...data.events }, thresholds: { ...DEFAULT_CONFIG.thresholds, ...data.thresholds } }; + } + } catch {} + return { ...DEFAULT_CONFIG }; +} + +export function saveDiscordConfig(config: DiscordConfig): void { + fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); +} + +// ── Cooldown ── +const cooldowns = new Map(); + +function isOnCooldown(key: string, cooldownMinutes: number): boolean { + const last = cooldowns.get(key); + if (!last) return false; + return Date.now() - last < cooldownMinutes * 60_000; +} + +function setCooldown(key: string): void { + cooldowns.set(key, Date.now()); +} + +function clearCooldown(key: string): void { + cooldowns.delete(key); +} + +// Clean stale cooldown entries every 30 minutes +setInterval(() => { + const now = Date.now(); + for (const [key, ts] of cooldowns) { + if (now - ts > 60 * 60_000) cooldowns.delete(key); + } +}, 30 * 60_000); + +// ── Down services tracker ── +// Tracks services that are down so we can re-alert periodically +const downServices = new Map(); // service → timestamp when it went down + +/** + * Called periodically (from stats polling interval) to re-send alerts + * for services that are still down after the cooldown period. + */ +export function checkDownServices(config: DiscordConfig): void { + if (!config.enabled || !config.events.containerStateChanges) return; + const now = Date.now(); + for (const [service, downSince] of downServices) { + const cooldownKey = `down:${service}`; + if (!isOnCooldown(cooldownKey, config.downReminderMinutes)) { + const downMinutes = Math.floor((now - downSince) / 60_000); + setCooldown(cooldownKey); + queueWebhook(config.webhookUrl, { + username: "ContainerFlow", + embeds: [{ + title: "Container Still Down", + color: 0xef4444, + description: `**${service}**\n\nDown for: \`${downMinutes} min\`\nStatus: \`offline\``, + footer: { text: "ContainerFlow" }, + timestamp: new Date().toISOString(), + }], + }); + } + } +} + +// ── Rate-limited queue ── +let lastSend = 0; +const sendQueue: Array<{ url: string; body: any; resolve: () => void }> = []; +let processing = false; + +async function processSendQueue() { + if (processing) return; + processing = true; + while (sendQueue.length > 0) { + const item = sendQueue.shift()!; + const elapsed = Date.now() - lastSend; + if (elapsed < 500) { + await new Promise((r) => setTimeout(r, 500 - elapsed)); + } + try { + const res = await fetch(item.url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(item.body), + }); + lastSend = Date.now(); + if (res.status === 429) { + const retryAfter = parseFloat(res.headers.get("Retry-After") || "5") * 1000; + await new Promise((r) => setTimeout(r, retryAfter)); + // Re-queue + sendQueue.unshift(item); + continue; + } + } catch (err) { + console.error("Discord webhook error:", err); + } + item.resolve(); + } + processing = false; +} + +function queueWebhook(url: string, body: any): Promise { + return new Promise((resolve) => { + sendQueue.push({ url, body, resolve }); + processSendQueue(); + }); +} + +function sendEmbed(config: DiscordConfig, embed: any, cooldownKey?: string): void { + if (!config.enabled || !config.webhookUrl) return; + if (cooldownKey && isOnCooldown(cooldownKey, config.cooldownMinutes)) return; + if (cooldownKey) setCooldown(cooldownKey); + queueWebhook(config.webhookUrl, { + username: "ContainerFlow", + embeds: [{ ...embed, timestamp: new Date().toISOString() }], + }); +} + +// ── Notification functions ── + +const STATE_COLORS: Record = { + start: 0x22c55e, // green + stop: 0xef4444, // red + die: 0xef4444, + restart: 0xf59e0b, // orange + health_status: 0xf59e0b, + create: 0x3b82f6, // blue + destroy: 0xef4444, +}; + +const STATE_TITLES: Record = { + start: "Container Started", + stop: "Container Stopped", + die: "Container Crashed", + restart: "Container Restarted", + health_status: "Health Status Changed", + create: "Container Created", + destroy: "Container Destroyed", +}; + +export function notifyStateChange(service: string, action: string, config: DiscordConfig): void { + if (!config.events.containerStateChanges) return; + + // Track down services for re-alerting + if (action === "die" || action === "stop") { + downServices.set(service, Date.now()); + } else if (action === "start") { + // Service recovered — stop tracking and clear die/stop cooldowns + downServices.delete(service); + clearCooldown(`state:die:${service}`); + clearCooldown(`state:stop:${service}`); + } + + // Cooldown per action per service + const cooldownKey = `state:${action}:${service}`; + const title = STATE_TITLES[action] || `Container ${action}`; + sendEmbed(config, { + title, + color: STATE_COLORS[action] || 0x94a3b8, + description: `**${service}**\n\nAction: \`${action}\``, + footer: { text: "ContainerFlow" }, + }, cooldownKey); +} + +export function notifyResourceAlert(service: string, resource: "cpu" | "memory", value: number, threshold: number, config: DiscordConfig): void { + if (!config.events.resourceAlerts) return; + const cooldownKey = `resource:${resource}:${service}`; + const color = value >= threshold + 10 ? 0xef4444 : 0xf59e0b; + sendEmbed(config, { + title: `Resource Alert — ${resource.toUpperCase()}`, + color, + description: `**${service}**\n\nCurrent: \`${value.toFixed(1)}%\`\nThreshold: \`${threshold}%\``, + footer: { text: "ContainerFlow" }, + }, cooldownKey); +} + +export function notifyUIAction(service: string, action: string, config: DiscordConfig): void { + if (!config.events.uiActions) return; + const cooldownKey = `ui:${action}:${service}`; + sendEmbed(config, { + title: "Manual Action", + color: 0x3b82f6, + description: `**${service}**\n\nAction: \`${action}\``, + footer: { text: "ContainerFlow" }, + }, cooldownKey); +} + +export function notifyActionError(service: string, action: string, error: string, config: DiscordConfig): void { + if (!config.events.actionErrors) return; + const cooldownKey = `error:${action}:${service}`; + const truncated = error.length > 1000 ? error.slice(0, 1000) + "..." : error; + sendEmbed(config, { + title: `Action Failed — ${action}`, + color: 0xef4444, + description: `**${service}**\n\n\`\`\`\n${truncated}\n\`\`\``, + footer: { text: "ContainerFlow" }, + }, cooldownKey); +} + +export async function testWebhook(webhookUrl: string): Promise<{ ok: boolean; error?: string }> { + try { + const res = await fetch(webhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "ContainerFlow", + embeds: [{ + title: "Test Notification", + description: "ContainerFlow Discord integration is working!", + color: 0x22c55e, + footer: { text: "ContainerFlow" }, + timestamp: new Date().toISOString(), + }], + }), + }); + if (res.status === 429) { + return { ok: false, error: "Rate limited by Discord. Try again shortly." }; + } + if (!res.ok) { + return { ok: false, error: `Discord returned status ${res.status}` }; + } + return { ok: true }; + } catch (err: any) { + return { ok: false, error: err?.message || "Failed to send webhook" }; + } +} diff --git a/src/server/docker.ts b/src/server/docker.ts index 67cfca1..f8ad3ff 100644 --- a/src/server/docker.ts +++ b/src/server/docker.ts @@ -76,7 +76,7 @@ export async function discoverServices(all: boolean, projects: string[]): Promis env: (info?.Config?.Env || []) as string[], restart_policy: info?.HostConfig?.RestartPolicy?.Name || "", memory_limit: info?.HostConfig?.Memory || 0, - cpu_quota: info?.HostConfig?.CpuQuota || 0, + cpu_quota: info?.HostConfig?.CpuQuota || (info?.HostConfig?.NanoCpus ? Math.round(info.HostConfig.NanoCpus / 1e4) : 0), health_status: healthStatus, health_log: healthLog, exit_code: exitCode, diff --git a/src/server/index.ts b/src/server/index.ts index acee5b8..736abba 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -6,10 +6,14 @@ import path from "path"; import fs from "fs"; import { docker, discoverServices, discoverConnections, getContainerLogs, streamContainerLogs } from "./docker"; import { pollStats, watchDockerEvents } from "./watcher"; -import type { Service, WSMessage } from "../shared/types"; +import { loadDiscordConfig, saveDiscordConfig, notifyStateChange, notifyResourceAlert, notifyUIAction, notifyActionError, testWebhook, checkDownServices } from "./discord"; +import type { Service, WSMessage, DiscordConfig } from "../shared/types"; + +/** Directory for persistent data files (positions, env overrides) */ +const DATA_DIR = process.env.DATA_DIR || process.cwd(); /** Env-file overrides per compose file (persisted to file) */ -const ENV_FILES_FILE = path.join(process.cwd(), ".dockerflow-env-files.json"); +const ENV_FILES_FILE = path.join(DATA_DIR, ".dockerflow-env-files.json"); function loadEnvFiles(): Record { try { @@ -156,14 +160,24 @@ app.get("/api/init", async (c) => { return c.json({ services, connections, positions }); }); +// ── Helper: get service uid from container inspect info ── +function getContainerUid(info: any): string { + const project = info.Config?.Labels?.["com.docker.compose.project"] || "standalone"; + const service = info.Config?.Labels?.["com.docker.compose.service"] || info.Name?.replace(/^\//, "") || "unknown"; + return `${project}/${service}`; +} + // ── Container actions ── app.post("/api/containers/:id/stop", async (c) => { const id = c.req.param("id"); if (!/^[a-f0-9]{12,64}$/.test(id)) return c.json({ error: "Invalid container ID" }, 400); try { const container = docker.getContainer(id); + const info = await container.inspect(); await container.stop(); immediateRefresh(); + const uid = getContainerUid(info); + try { notifyUIAction(uid, "stop", loadDiscordConfig()); } catch {} return c.json({ ok: true }); } catch (err: any) { if (err?.statusCode === 304) return c.json({ ok: true, message: "Already stopped" }); @@ -176,8 +190,11 @@ app.post("/api/containers/:id/start", async (c) => { if (!/^[a-f0-9]{12,64}$/.test(id)) return c.json({ error: "Invalid container ID" }, 400); try { const container = docker.getContainer(id); + const info = await container.inspect(); await container.start(); immediateRefresh(); + const uid = getContainerUid(info); + try { notifyUIAction(uid, "start", loadDiscordConfig()); } catch {} return c.json({ ok: true }); } catch (err: any) { if (err?.statusCode === 304) return c.json({ ok: true, message: "Already running" }); @@ -190,8 +207,11 @@ app.post("/api/containers/:id/restart", async (c) => { if (!/^[a-f0-9]{12,64}$/.test(id)) return c.json({ error: "Invalid container ID" }, 400); try { const container = docker.getContainer(id); + const info = await container.inspect(); await container.restart(); immediateRefresh(); + const uid = getContainerUid(info); + try { notifyUIAction(uid, "restart", loadDiscordConfig()); } catch {} return c.json({ ok: true }); } catch (err: any) { return c.json({ error: err?.message || "Failed to restart container" }, 500); @@ -220,11 +240,17 @@ app.post("/api/containers/:id/rebuild", async (c) => { proc.exited.then(async (exitCode) => { if (exitCode !== 0) { const stderr = await new Response(proc.stderr).text(); - broadcast({ type: "action_error", data: { uid, action: "rebuild", error: stderr || `Rebuild failed with exit code ${exitCode}` } }); + const errorMsg = stderr || `Rebuild failed with exit code ${exitCode}`; + broadcast({ type: "action_error", data: { uid, action: "rebuild", error: errorMsg } }); + try { notifyActionError(uid, "rebuild", errorMsg, loadDiscordConfig()); } catch {} + } else { + try { notifyUIAction(uid, "rebuild", loadDiscordConfig()); } catch {} } scheduleRefresh(); }).catch((err) => { - broadcast({ type: "action_error", data: { uid, action: "rebuild", error: err?.message || "Rebuild failed" } }); + const errorMsg = err?.message || "Rebuild failed"; + broadcast({ type: "action_error", data: { uid, action: "rebuild", error: errorMsg } }); + try { notifyActionError(uid, "rebuild", errorMsg, loadDiscordConfig()); } catch {} }); return c.json({ ok: true }); } catch (err: any) { @@ -334,7 +360,7 @@ app.get("/api/logs/:id", async (c) => { }); // ── Node positions (persisted to file) ── -const POSITIONS_FILE = path.join(process.cwd(), ".dockerflow-positions.json"); +const POSITIONS_FILE = path.join(DATA_DIR, ".dockerflow-positions.json"); app.get("/api/positions", (c) => { try { @@ -392,6 +418,38 @@ app.get("/api/env-files/detect/:id", async (c) => { } }); +// ── Discord config ── +app.get("/api/discord-config", (c) => { + return c.json(loadDiscordConfig()); +}); + +app.put("/api/discord-config", async (c) => { + try { + const body = await c.req.json() as DiscordConfig; + if (body.webhookUrl && !body.webhookUrl.startsWith("https://discord.com/api/webhooks/")) { + return c.json({ error: "Invalid webhook URL. Must start with https://discord.com/api/webhooks/" }, 400); + } + saveDiscordConfig(body); + return c.json({ ok: true }); + } catch { + return c.json({ error: "Failed to save" }, 500); + } +}); + +app.post("/api/discord-config/test", async (c) => { + try { + const body = await c.req.json(); + const url = body?.webhookUrl; + if (!url || !url.startsWith("https://discord.com/api/webhooks/")) { + return c.json({ error: "Invalid webhook URL" }, 400); + } + const result = await testWebhook(url); + return c.json(result); + } catch { + return c.json({ error: "Failed to test webhook" }, 500); + } +}); + // ── Cache headers for static assets ── app.use("/*", async (c, next) => { await next(); @@ -481,6 +539,23 @@ async function refreshStats(services: Service[]) { try { const stats = await pollStats(services); broadcast({ type: "stats", data: stats }); + // Check resource thresholds and down services for Discord alerts + try { + const discordConfig = loadDiscordConfig(); + if (discordConfig.enabled) { + if (discordConfig.events.resourceAlerts) { + for (const stat of stats) { + if (stat.cpu >= discordConfig.thresholds.cpuPercent) { + notifyResourceAlert(stat.service, "cpu", stat.cpu, discordConfig.thresholds.cpuPercent, discordConfig); + } + if (stat.mem_percent >= discordConfig.thresholds.memPercent) { + notifyResourceAlert(stat.service, "memory", stat.mem_percent, discordConfig.thresholds.memPercent, discordConfig); + } + } + } + checkDownServices(discordConfig); + } + } catch {} } catch (err) { console.error("Stats error:", err); } finally { @@ -514,6 +589,10 @@ function immediateRefresh() { watchDockerEvents((event) => { broadcast({ type: "docker_event", data: event }); scheduleRefresh(); + try { + const config = loadDiscordConfig(); + notifyStateChange(event.service, event.action, config); + } catch {} }); // ── Stats polling ── diff --git a/src/server/watcher.ts b/src/server/watcher.ts index 14e9b39..4d31e9d 100644 --- a/src/server/watcher.ts +++ b/src/server/watcher.ts @@ -18,11 +18,18 @@ export async function pollStats(services: Service[]): Promise { const sysDelta = raw.cpu_stats.system_cpu_usage - raw.precpu_stats.system_cpu_usage; - const cpu = + const onlineCpus = raw.cpu_stats.online_cpus || 1; + const cpuHost = sysDelta > 0 - ? (cpuDelta / sysDelta) * (raw.cpu_stats.online_cpus || 1) * 100 + ? (cpuDelta / sysDelta) * onlineCpus * 100 : 0; + // If container has a CPU limit, show % relative to its allocation + // cpu_quota: 100000 = 1 core; cpuHost: % of one host core + const cpu = svc.cpu_quota > 0 + ? (cpuHost * 100000 / svc.cpu_quota) + : cpuHost; + const memUsage = raw.memory_stats.usage || 0; const memLimit = raw.memory_stats.limit || 1; diff --git a/src/shared/types.ts b/src/shared/types.ts index f03a708..668779e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -50,6 +50,23 @@ export interface LogLine { stream: "stdout" | "stderr"; } +export interface DiscordConfig { + enabled: boolean; + webhookUrl: string; + events: { + containerStateChanges: boolean; + resourceAlerts: boolean; + uiActions: boolean; + actionErrors: boolean; + }; + thresholds: { + cpuPercent: number; + memPercent: number; + }; + cooldownMinutes: number; + downReminderMinutes: number; +} + export type WSMessage = | { type: "services"; data: Service[] } | { type: "connections"; data: Connection[] }