This commit is contained in:
RGJorge
2026-05-08 02:19:45 +00:00
parent 9e1dadafe7
commit 1ee37368bd
23 changed files with 1321 additions and 147 deletions
+2 -1
View File
@@ -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"
}
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
.git
.github
*.md
.env
.dockerflow-*.json
+1 -1
View File
@@ -2,4 +2,4 @@ node_modules/
dist/
*.log
.env
.dockerflow-positions.json
.dockerflow-*.json
+8
View File
@@ -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"`
+38
View File
@@ -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"]
+9 -1
View File
@@ -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
+20
View File
@@ -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:
+16 -9
View File
@@ -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 <div className="h-screen w-screen bg-slate-950" />;
if (needsAuth) return <LoginScreen onAuth={(t) => { setAuthToken(t); setNeedsAuth(false); }} />;
return <Dashboard token={authToken || ""} />;
return (
<I18nProvider>
{needsAuth
? <LoginScreen onAuth={(tk) => { setAuthToken(tk); setNeedsAuth(false); }} />
: <Dashboard token={authToken || ""} />}
</I18nProvider>
);
}
function Dashboard({ token }: { token: string }) {
const { t } = useT();
const statsStore = useMemo(() => createStatsStore(), []);
const savedPositions = useRef<Record<string, { x: number; y: number }>>({});
const onPositions = useCallback((pos: Record<string, { x: number; y: number }>) => {
@@ -480,7 +487,7 @@ function Dashboard({ token }: { token: string }) {
/>
{activePage === "monitoring" && <MonitoringPage events={events} />}
{activePage === "settings" && <SettingsPage projects={projects} servicesCount={services.length} />}
{activePage === "settings" && <SettingsPage projects={projects} servicesCount={services.length} token={token} />}
{/* Canvas — inset (only visible on dashboard) */}
<div className={`flex-1 min-h-0 relative mx-2 mt-1 rounded-xl overflow-hidden ring-1 ring-slate-700/60 shadow-[inset_0_2px_12px_rgba(0,0,0,0.5)] ${activePage !== "dashboard" ? "hidden" : ""}`}>
@@ -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")}
<span className="text-cyan-400 font-medium">
{projects.length - hiddenProjects.size}/{projects.length}
</span>
@@ -602,7 +609,7 @@ function Dashboard({ token }: { token: string }) {
}`}>
{hiddenProjects.size === 0 && <Check size={12} className="text-white" />}
</div>
<span className="text-slate-300 font-medium">All</span>
<span className="text-slate-300 font-medium">{t("filter.all")}</span>
<span className="ml-auto flex items-center gap-1.5 text-xs">
<span className="text-emerald-500/70">{services.filter((s) => s.state === "running").length}</span>
<span className="text-slate-600">/</span>
@@ -727,18 +734,18 @@ function Dashboard({ token }: { token: string }) {
<WifiOff size={12} className="text-red-500" />
)}
<span className={connected ? "text-emerald-500" : "text-red-500"}>
{connected ? "Live" : "Offline"}
{connected ? t("footer.live") : t("footer.offline")}
</span>
</div>
<span>
<span className="text-emerald-400">{filteredServices.filter((s) => s.state === "running").length}</span>
<span>/{filteredServices.length} containers</span>
<span>/{filteredServices.length} {t("footer.containers")}</span>
</span>
</div>
<div className="absolute left-1/2 -translate-x-1/2 flex items-center gap-4">
<span>{services.length} containers</span>
<span>{projects.length} projects</span>
<span>{services.length} {t("footer.containers")}</span>
<span>{projects.length} {t("footer.projects")}</span>
</div>
<span>AlteonX</span>
+4 -1
View File
@@ -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 (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-5 bg-slate-900/90 border border-slate-800 rounded-lg px-5 py-2.5 z-10">
<span className="text-xs text-slate-500 uppercase tracking-wider font-semibold">Conexiones</span>
<span className="text-xs text-slate-500 uppercase tracking-wider font-semibold">{t("legend.connections")}</span>
{LEGEND_ITEMS.map(({ icon: Icon, color, label }) => (
<div key={label} className="flex items-center gap-2">
<div className="w-5 h-0.5 rounded-full" style={{ backgroundColor: color }} />
+29 -5
View File
@@ -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<HTMLDivElement>(null);
@@ -93,10 +95,10 @@ function NotificationBell({ events }: NotificationBellProps) {
{open && (
<div className="absolute top-full right-0 mt-1.5 bg-slate-800 border border-slate-700 rounded-lg shadow-xl shadow-black/40 w-72 max-h-80 overflow-auto z-[9999]">
<div className="px-3 py-2 border-b border-slate-700/60 text-xs font-semibold text-slate-400 uppercase tracking-wider">
Recent Events
{t("header.recentEvents")}
</div>
{recent.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-slate-500">No events yet</div>
<div className="px-3 py-6 text-center text-sm text-slate-500">{t("header.noEvents")}</div>
) : (
recent.map((ev, i) => (
<div key={`${ev.service}-${ev.time}-${i}`} className="flex items-center gap-2.5 px-3 py-2 hover:bg-slate-700/40 transition-colors">
@@ -133,14 +135,15 @@ export function HeaderBar({
onPageChange,
events,
}: HeaderBarProps) {
const { t, lang, setLang } = useT();
return (
<div className="flex items-center justify-between px-5 py-1 bg-slate-900/90 backdrop-blur-sm relative z-[9999]">
{/* Left: Navigation */}
<nav className="flex items-center gap-1">
<NavButton icon={LayoutDashboard} label="Dashboard" active={activePage === "dashboard"} onClick={() => onPageChange("dashboard")} />
<NavButton icon={Activity} label="Monitoring" active={activePage === "monitoring"} onClick={() => onPageChange("monitoring")} />
<NavButton icon={Settings} label="Settings" active={activePage === "settings"} onClick={() => onPageChange("settings")} />
<NavButton icon={LayoutDashboard} label={t("header.dashboard")} active={activePage === "dashboard"} onClick={() => onPageChange("dashboard")} />
<NavButton icon={Activity} label={t("header.monitoring")} active={activePage === "monitoring"} onClick={() => onPageChange("monitoring")} />
<NavButton icon={Settings} label={t("header.settings")} active={activePage === "settings"} onClick={() => onPageChange("settings")} />
</nav>
{/* Center: Logo */}
@@ -175,6 +178,27 @@ export function HeaderBar({
</>
)}
{/* Language toggle */}
<div className="flex items-center bg-slate-800 rounded-md border border-slate-700/50 overflow-hidden">
<button
onClick={() => setLang("en")}
className={`px-2 py-1 text-[11px] font-semibold transition-colors ${
lang === "en" ? "bg-cyan-500/20 text-cyan-400" : "text-slate-500 hover:text-slate-300"
}`}
>
EN
</button>
<div className="w-px h-4 bg-slate-700/50" />
<button
onClick={() => setLang("es")}
className={`px-2 py-1 text-[11px] font-semibold transition-colors ${
lang === "es" ? "bg-cyan-500/20 text-cyan-400" : "text-slate-500 hover:text-slate-300"
}`}
>
ES
</button>
</div>
<NotificationBell events={events} />
{/* Logout (only if auth is active) */}
+13 -11
View File
@@ -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) {
}`}
>
<Terminal size={14} />
{connecting ? "Connecting..." : "Connect"}
{connecting ? t("login.connecting") : t("login.connect")}
</button>
</form>
@@ -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]`}
>
+9 -7
View File
@@ -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<HTMLDivElement>(null);
useEffect(() => {
@@ -48,23 +50,23 @@ export function NodeContextMenu({ position, service, onAction, onOpenLogs, onClo
>
{isRunning ? (
<>
<MenuItem icon={RotateCw} label="Restart" color="text-yellow-400" onClick={() => { onAction("restart"); onClose(); }} />
<MenuItem icon={Square} label="Stop" color="text-red-400" onClick={() => { onAction("stop"); onClose(); }} />
<MenuItem icon={RotateCw} label={t("actions.restart")} color="text-yellow-400" onClick={() => { onAction("restart"); onClose(); }} />
<MenuItem icon={Square} label={t("actions.stop")} color="text-red-400" onClick={() => { onAction("stop"); onClose(); }} />
</>
) : (
<>
<MenuItem icon={Play} label="Start" color="text-emerald-400" onClick={() => { onAction("start"); onClose(); }} />
<MenuItem icon={Trash2} label="Remove" color="text-red-400" onClick={() => { onAction("remove"); onClose(); }} />
<MenuItem icon={Play} label={t("actions.start")} color="text-emerald-400" onClick={() => { onAction("start"); onClose(); }} />
<MenuItem icon={Trash2} label={t("actions.remove")} color="text-red-400" onClick={() => { onAction("remove"); onClose(); }} />
</>
)}
{service.compose_file && (
<>
<div className="border-t border-slate-700/50 my-1" />
<MenuItem icon={Hammer} label="Rebuild" color="text-cyan-400" onClick={() => { onAction("rebuild"); onClose(); }} />
<MenuItem icon={Hammer} label={t("actions.rebuild")} color="text-cyan-400" onClick={() => { onAction("rebuild"); onClose(); }} />
</>
)}
<div className="border-t border-slate-700/50 my-1" />
<MenuItem icon={Terminal} label="Open Logs" color="text-cyan-400" onClick={() => { onOpenLogs(); onClose(); }} />
<MenuItem icon={Terminal} label={t("actions.openLogs")} color="text-cyan-400" onClick={() => { onOpenLogs(); onClose(); }} />
{isRunning && firstPort && (
<a
href={`http://${window.location.hostname}:${firstPort.host}`}
@@ -74,7 +76,7 @@ export function NodeContextMenu({ position, service, onAction, onOpenLogs, onClo
onClick={onClose}
>
<ExternalLink size={14} className="text-slate-400" />
<span>Open :{firstPort.host}</span>
<span>{t("actions.open")} :{firstPort.host}</span>
</a>
)}
</div>
+406
View File
@@ -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<TranslationKey, string> = {
// 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<I18nContextValue | null>(null);
const dictionaries = { en, es } as const;
export function I18nProvider({ children }: { children: ReactNode }) {
const [lang, setLangState] = useState<Lang>(() => {
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 (
<I18nContext.Provider value={{ lang, setLang, t }}>
{children}
</I18nContext.Provider>
);
}
export function useT() {
const ctx = useContext(I18nContext);
if (!ctx) throw new Error("useT must be used within I18nProvider");
return ctx;
}
+5 -3
View File
@@ -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) {
)}
</div>
<div className="text-xs text-slate-500 truncate mt-0.5">
{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}
</div>
</div>
</div>
@@ -207,8 +209,8 @@ export const ServiceNode = memo(function ServiceNode({ data, id }: NodeProps) {
{nodeStats && (
<div className="mt-2 space-y-1.5">
<div className="flex justify-between text-[11px] text-slate-400">
<span>CPU {nodeStats.cpu.toFixed(1)}%</span>
<span>MEM {nodeStats.mem_mb.toFixed(0)}MB</span>
<span>{t("node.cpu")} {nodeStats.cpu.toFixed(1)}%</span>
<span>{t("node.mem")} {nodeStats.mem_mb.toFixed(0)}MB</span>
</div>
<div className="flex gap-1.5">
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
+7 -5
View File
@@ -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) {
<div className="flex items-center gap-3 mb-6">
<Activity size={24} className="text-cyan-400" />
<div>
<h1 className="text-xl font-bold text-white">Event History</h1>
<p className="text-sm text-slate-500">Docker container events in real-time</p>
<h1 className="text-xl font-bold text-white">{t("monitoring.title")}</h1>
<p className="text-sm text-slate-500">{t("monitoring.subtitle")}</p>
</div>
</div>
@@ -56,7 +58,7 @@ export function MonitoringPage({ events }: MonitoringPageProps) {
{sorted.length === 0 ? (
<div className="px-6 py-12 text-center text-slate-500">
<Activity size={32} className="mx-auto mb-3 opacity-40" />
<p>No events yet. Events will appear here as containers start, stop, or restart.</p>
<p>{t("monitoring.noEvents")}</p>
</div>
) : (
<div className="divide-y divide-slate-700/40">
@@ -79,8 +81,8 @@ export function MonitoringPage({ events }: MonitoringPageProps) {
{/* Alert Rules placeholder */}
<div className="mt-8 bg-slate-800/30 border border-dashed border-slate-700/60 rounded-xl p-6 text-center">
<AlertTriangle size={24} className="mx-auto mb-2 text-slate-600" />
<p className="text-sm text-slate-500 font-medium">Alert Rules</p>
<p className="text-xs text-slate-600 mt-1">Configure alerting rules for container events coming soon</p>
<p className="text-sm text-slate-500 font-medium">{t("monitoring.alertRules")}</p>
<p className="text-xs text-slate-600 mt-1">{t("monitoring.alertRulesDesc")}</p>
</div>
</div>
</div>
+296 -20
View File
@@ -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 (
<span className="relative inline-flex">
<button
type="button"
onMouseEnter={() => setShow(true)}
onMouseLeave={() => setShow(false)}
onClick={() => setShow((v) => !v)}
className="text-slate-500 hover:text-slate-300 transition-colors"
>
<HelpCircle size={13} />
</button>
{show && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-xs text-slate-200 w-56 text-left shadow-xl z-50 leading-relaxed">
{text}
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-slate-700" />
</div>
)}
</span>
);
}
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
return (
<button
type="button"
onClick={() => !disabled && onChange(!checked)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
checked ? "bg-cyan-500" : "bg-slate-600"
} ${disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${checked ? "translate-x-4.5" : "translate-x-0.5"}`} />
</button>
);
}
export function SettingsPage({ projects, servicesCount, token }: SettingsPageProps) {
const { t } = useT();
const [config, setConfig] = useState<DiscordConfig>(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<string, string> => {
const h: Record<string, string> = { "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 (
<div className="flex-1 min-h-0 overflow-auto p-6">
<div className="max-w-3xl mx-auto">
@@ -13,8 +130,8 @@ export function SettingsPage({ projects, servicesCount }: SettingsPageProps) {
<div className="flex items-center gap-3 mb-6">
<Settings size={24} className="text-cyan-400" />
<div>
<h1 className="text-xl font-bold text-white">Settings</h1>
<p className="text-sm text-slate-500">Application configuration</p>
<h1 className="text-xl font-bold text-white">{t("settings.title")}</h1>
<p className="text-sm text-slate-500">{t("settings.subtitle")}</p>
</div>
</div>
@@ -22,23 +139,23 @@ export function SettingsPage({ projects, servicesCount }: SettingsPageProps) {
<section className="bg-slate-800/50 border border-slate-700/60 rounded-xl p-5 mb-4">
<div className="flex items-center gap-2 mb-4">
<Info size={16} className="text-cyan-400" />
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">General</h2>
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">{t("settings.general")}</h2>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="bg-slate-900/50 rounded-lg p-3">
<span className="text-slate-500 block text-xs mb-1">Version</span>
<span className="text-slate-500 block text-xs mb-1">{t("settings.version")}</span>
<span className="text-slate-200 font-mono">v0.0.1</span>
</div>
<div className="bg-slate-900/50 rounded-lg p-3">
<span className="text-slate-500 block text-xs mb-1">Mode</span>
<span className="text-slate-200 font-mono">Single Host</span>
<span className="text-slate-500 block text-xs mb-1">{t("settings.mode")}</span>
<span className="text-slate-200 font-mono">{t("settings.singleHost")}</span>
</div>
<div className="bg-slate-900/50 rounded-lg p-3">
<span className="text-slate-500 block text-xs mb-1">Projects</span>
<span className="text-slate-500 block text-xs mb-1">{t("settings.projects")}</span>
<span className="text-slate-200 font-mono">{projects.length}</span>
</div>
<div className="bg-slate-900/50 rounded-lg p-3">
<span className="text-slate-500 block text-xs mb-1">Containers</span>
<span className="text-slate-500 block text-xs mb-1">{t("settings.containers")}</span>
<span className="text-slate-200 font-mono">{servicesCount}</span>
</div>
</div>
@@ -48,20 +165,179 @@ export function SettingsPage({ projects, servicesCount }: SettingsPageProps) {
<section className="bg-slate-800/30 border border-dashed border-slate-700/60 rounded-xl p-5 mb-4">
<div className="flex items-center gap-2 mb-3">
<Server size={16} className="text-slate-500" />
<h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider">Docker Hosts</h2>
<h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider">{t("settings.dockerHosts")}</h2>
</div>
<p className="text-sm text-slate-500">Multi-host management coming soon</p>
<p className="text-xs text-slate-600 mt-1">Connect to remote Docker daemons and manage multiple hosts from a single dashboard.</p>
<p className="text-sm text-slate-500">{t("settings.dockerHostsDesc")}</p>
<p className="text-xs text-slate-600 mt-1">{t("settings.dockerHostsDetail")}</p>
</section>
{/* Notifications */}
<section className="bg-slate-800/30 border border-dashed border-slate-700/60 rounded-xl p-5">
<div className="flex items-center gap-2 mb-3">
<Bell size={16} className="text-slate-500" />
<h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider">Notifications</h2>
{/* Discord Notifications */}
<section className="bg-slate-800/50 border border-slate-700/60 rounded-xl p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Bell size={16} className="text-cyan-400" />
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">{t("settings.discord")}</h2>
</div>
<Toggle checked={config.enabled} onChange={(v) => setConfig((prev) => ({ ...prev, enabled: v }))} />
</div>
{loaded && (
<div className={config.enabled ? "" : "opacity-50 pointer-events-none"}>
{/* Webhook URL */}
<div className="mb-4">
<label className="text-xs text-slate-400 block mb-1.5">{t("settings.webhookUrl")}</label>
<div className="flex gap-2">
<input
type="text"
value={config.webhookUrl}
onChange={(e) => 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"
/>
<button
onClick={handleTest}
disabled={testing || !config.webhookUrl}
className="flex items-center gap-1.5 px-3 py-2 text-sm bg-slate-700/50 hover:bg-slate-700 border border-slate-600/50 rounded-lg text-slate-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Send size={14} />
{testing ? t("settings.sending") : t("settings.test")}
</button>
</div>
{testResult && (
<div className={`flex items-center gap-1.5 mt-2 text-xs ${testResult.ok ? "text-emerald-400" : "text-red-400"}`}>
{testResult.ok ? <Check size={12} /> : <X size={12} />}
{testResult.ok ? t("settings.webhookSuccess") : testResult.error}
</div>
)}
</div>
{/* Event Toggles */}
<div className="mb-4">
<label className="text-xs text-slate-400 block mb-2">{t("settings.events")}</label>
<div className="grid grid-cols-2 gap-3">
<div className="flex items-center justify-between bg-slate-900/50 rounded-lg px-3 py-2.5">
<span className="text-sm text-slate-300 flex items-center gap-1.5">
{t("settings.containerStateChanges")}
<Tooltip text={t("settings.containerStateChangesTooltip")} />
</span>
<Toggle checked={config.events.containerStateChanges} onChange={(v) => updateEvents("containerStateChanges", v)} />
</div>
<div className="flex items-center justify-between bg-slate-900/50 rounded-lg px-3 py-2.5">
<span className="text-sm text-slate-300 flex items-center gap-1.5">
{t("settings.resourceAlerts")}
<Tooltip text={t("settings.resourceAlertsTooltip")} />
</span>
<Toggle checked={config.events.resourceAlerts} onChange={(v) => updateEvents("resourceAlerts", v)} />
</div>
<div className="flex items-center justify-between bg-slate-900/50 rounded-lg px-3 py-2.5">
<span className="text-sm text-slate-300 flex items-center gap-1.5">
{t("settings.uiActions")}
<Tooltip text={t("settings.uiActionsTooltip")} />
</span>
<Toggle checked={config.events.uiActions} onChange={(v) => updateEvents("uiActions", v)} />
</div>
<div className="flex items-center justify-between bg-slate-900/50 rounded-lg px-3 py-2.5">
<span className="text-sm text-slate-300 flex items-center gap-1.5">
{t("settings.actionErrors")}
<Tooltip text={t("settings.actionErrorsTooltip")} />
</span>
<Toggle checked={config.events.actionErrors} onChange={(v) => updateEvents("actionErrors", v)} />
</div>
</div>
</div>
{/* Thresholds (only visible when resourceAlerts is on) */}
{config.events.resourceAlerts && (
<div className="mb-4">
<label className="text-xs text-slate-400 flex items-center gap-1.5 mb-2">
{t("settings.resourceThresholds")}
<Tooltip text={t("settings.resourceThresholdsTooltip")} />
</label>
<div className="space-y-3">
<div className="bg-slate-900/50 rounded-lg px-3 py-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-sm text-slate-300">{t("settings.cpu")}</span>
<span className="text-sm text-cyan-400 font-mono">{config.thresholds.cpuPercent}%</span>
</div>
<input
type="range"
min={50}
max={100}
value={config.thresholds.cpuPercent}
onChange={(e) => updateThresholds("cpuPercent", parseInt(e.target.value))}
className="w-full h-1.5 bg-slate-700 rounded-full appearance-none cursor-pointer accent-cyan-500"
/>
</div>
<div className="bg-slate-900/50 rounded-lg px-3 py-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-sm text-slate-300">{t("settings.memory")}</span>
<span className="text-sm text-cyan-400 font-mono">{config.thresholds.memPercent}%</span>
</div>
<input
type="range"
min={50}
max={100}
value={config.thresholds.memPercent}
onChange={(e) => updateThresholds("memPercent", parseInt(e.target.value))}
className="w-full h-1.5 bg-slate-700 rounded-full appearance-none cursor-pointer accent-cyan-500"
/>
</div>
</div>
</div>
)}
{/* Cooldown + Down reminder */}
<div className="grid grid-cols-2 gap-3 mb-4">
<div>
<label className="text-xs text-slate-400 flex items-center gap-1.5 mb-1.5">
{t("settings.cooldown")}
<Tooltip text={t("settings.cooldownTooltip")} />
</label>
<input
type="number"
min={1}
max={60}
value={config.cooldownMinutes}
onChange={(e) => {
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"
/>
</div>
<div>
<label className="text-xs text-slate-400 flex items-center gap-1.5 mb-1.5">
{t("settings.downReminder")}
<Tooltip text={t("settings.downReminderTooltip")} />
</label>
<input
type="number"
min={1}
max={60}
value={config.downReminderMinutes}
onChange={(e) => {
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"
/>
</div>
</div>
</div>
)}
{/* Save */}
<div className="flex items-center gap-3 mt-4 pt-4 border-t border-slate-700/40">
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-1.5 px-4 py-2 text-sm bg-cyan-600 hover:bg-cyan-500 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
>
{saved ? <Check size={14} /> : <Save size={14} />}
{saving ? t("settings.saving") : saved ? t("settings.saved") : t("settings.save")}
</button>
{saved && <span className="text-xs text-emerald-400">{t("settings.configSaved")}</span>}
</div>
<p className="text-sm text-slate-500">Webhook & email notifications coming soon</p>
<p className="text-xs text-slate-600 mt-1">Configure Slack, Discord, or email alerts for container events and health checks.</p>
</section>
</div>
</div>
+81 -72
View File
@@ -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<LogLine[]>([]);
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 ? (
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-medium text-yellow-400">
<Loader2 size={12} className="animate-spin" />
Processing...
{t("detail.processing")}
</div>
) : (
<>
@@ -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" ? <Loader2 size={12} className="animate-spin" /> : <Hammer size={12} />}
Rebuild
{t("actions.rebuild")}
</button>
)}
{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" ? <Loader2 size={12} className="animate-spin" /> : <RotateCw size={12} />}
Restart
{t("actions.restart")}
</button>
<button
onClick={() => setConfirmAction("stop")}
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="Stop"
title={t("actions.stop")}
>
{actionLoading === "stop" ? <Loader2 size={12} className="animate-spin" /> : <Square size={12} />}
Stop
{t("actions.stop")}
</button>
</>
) : (
@@ -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" ? <Loader2 size={12} className="animate-spin" /> : <Trash2 size={12} />}
Remove
{t("actions.remove")}
</button>
<button
onClick={() => executeAction("start")}
@@ -387,10 +396,10 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
className={`flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium transition-colors disabled:opacity-40 ${
isCrashed ? "text-orange-400 hover:bg-orange-400/10" : "text-emerald-400 hover:bg-emerald-400/10"
}`}
title={isCrashed ? "Retry start" : "Start"}
title={isCrashed ? t("actions.retry") : t("actions.start")}
>
{actionLoading === "start" ? <Loader2 size={12} className="animate-spin" /> : <Play size={12} />}
{isCrashed ? "Retry" : "Start"}
{isCrashed ? t("actions.retry") : t("actions.start")}
</button>
</>
)}
@@ -401,7 +410,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
<button
onClick={handleClose}
className="p-1.5 rounded hover:bg-slate-700/60 text-slate-400 hover:text-slate-200 transition-colors"
title="Close"
title={t("detail.close")}
>
<X size={14} />
</button>
@@ -417,10 +426,10 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
"text-yellow-400"
}`} />
<span className="text-xs text-slate-300 flex-1">
{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")}
</span>
<button
onClick={() => executeAction(confirmAction)}
@@ -430,13 +439,13 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
"bg-yellow-700 hover:bg-yellow-600"
}`}
>
{confirmAction === "stop" ? "Stop" : confirmAction === "restart" ? "Restart" : confirmAction === "remove" ? "Remove" : "Rebuild"}
{confirmAction === "stop" ? t("actions.stop") : confirmAction === "restart" ? t("actions.restart") : confirmAction === "remove" ? t("actions.remove") : t("actions.rebuild")}
</button>
<button
onClick={() => setConfirmAction(null)}
className="px-3 py-1 rounded text-[11px] font-medium text-slate-400 hover:text-slate-200 bg-slate-700 hover:bg-slate-600 transition-colors"
>
Cancel
{t("detail.cancel")}
</button>
</div>
)}
@@ -455,7 +464,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
}`}
>
<Icon size={12} />
{tab.label}
{t(TAB_KEYS[tab.id])}
<span className={`absolute bottom-0 left-0 right-0 h-px bg-cyan-400 transition-transform duration-300 ease-out origin-center ${isActive ? "scale-x-100" : "scale-x-0"}`} />
</button>
);
@@ -474,34 +483,34 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
<div className="flex items-start gap-2.5 bg-orange-500/10 border border-orange-500/30 rounded-lg px-3 py-2.5">
<AlertTriangle size={16} className="text-orange-400 shrink-0 mt-0.5" />
<div className="text-xs space-y-1">
<div className="font-semibold text-orange-300">Container crashed</div>
<div className="font-semibold text-orange-300">{t("detail.containerCrashed")}</div>
<div className="text-slate-400">
Exit code: <span className="text-orange-300 font-mono">{service.exit_code}</span>
{service.oom_killed && <span className="ml-2 text-red-400 font-semibold">OOM Killed</span>}
{service.restart_count > 0 && <span className="ml-2">Restarted <span className="text-orange-300 font-mono">{service.restart_count}</span> times</span>}
{t("detail.exitCode")}: <span className="text-orange-300 font-mono">{service.exit_code}</span>
{service.oom_killed && <span className="ml-2 text-red-400 font-semibold">{t("detail.oomKilled")}</span>}
{service.restart_count > 0 && <span className="ml-2">{t("detail.restarted")} <span className="text-orange-300 font-mono">{service.restart_count}</span> {t("detail.times")}</span>}
</div>
<div className="text-slate-500">Check the logs below for details</div>
<div className="text-slate-500">{t("detail.checkLogs")}</div>
</div>
</div>
)}
{service.status && (
<DetailRow label="Status" value={service.status} />
<DetailRow label={t("detail.status")} value={service.status} />
)}
<DetailRow label="Image" value={service.image} mono />
<DetailRow label="Container" value={service.id.slice(0, 12)} mono />
<DetailRow label="Project" value={service.project} />
<DetailRow label={t("detail.image")} value={service.image} mono />
<DetailRow label={t("detail.container")} value={service.id.slice(0, 12)} mono />
<DetailRow label={t("detail.project")} value={service.project} />
{service.compose_file && (
<DetailRow label="Compose" value={service.compose_file} mono />
<DetailRow label={t("detail.compose")} value={service.compose_file} mono />
)}
{service.compose_file && (
<div>
<span className="text-[11px] uppercase tracking-wider text-slate-500 mb-0.5 flex items-center gap-1">
Env File
{t("detail.envFile")}
<span className="relative group/tip">
<HelpCircle size={11} className="text-slate-600 hover:text-slate-400 cursor-help transition-colors" />
<span className="absolute left-full top-1/2 -translate-y-1/2 ml-1.5 px-2.5 py-1.5 bg-slate-700 text-slate-200 text-[11px] normal-case tracking-normal rounded-md shadow-lg whitespace-nowrap opacity-0 pointer-events-none group-hover/tip:opacity-100 transition-opacity z-10">
Only files starting with .env are detected
{t("detail.envFileTip")}
</span>
</span>
</span>
@@ -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"
>
<option value="">Auto (detect)</option>
<option value="">{t("detail.envFileAutoDetect")}</option>
{envFileOptions.map((f) => (
<option key={f} value={f}>{f}</option>
))}
@@ -538,7 +547,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
) : (
<div className="flex items-center gap-1.5">
<span className={`text-sm break-all ${envFiles[service.compose_file!] ? "font-mono text-slate-200" : "text-slate-500 italic"}`}>
{envFiles[service.compose_file!] || "Auto"}
{envFiles[service.compose_file!] || t("detail.envFileAuto")}
</span>
<button
onClick={() => {
@@ -563,7 +572,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
{service.ports.length > 0 && (
<div>
<span className="text-[11px] uppercase tracking-wider text-slate-500 block mb-1">Ports</span>
<span className="text-[11px] uppercase tracking-wider text-slate-500 block mb-1">{t("detail.ports")}</span>
<div className="flex flex-wrap gap-1.5">
{service.ports.map((p, i) => (
<a
@@ -585,7 +594,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
{service.networks.length > 0 && (
<div>
<span className="text-[11px] uppercase tracking-wider text-slate-500 block mb-1">Networks</span>
<span className="text-[11px] uppercase tracking-wider text-slate-500 block mb-1">{t("detail.networks")}</span>
<div className="flex flex-wrap gap-1.5">
{service.networks.map((n, i) => (
<span key={i} className="inline-flex items-center gap-1.5 text-sm font-mono bg-slate-800/80 text-purple-300 px-2.5 py-1 rounded">
@@ -603,7 +612,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
{/* Connected services */}
{connectedSvcs.length > 0 && (
<div>
<span className="text-[11px] uppercase tracking-wider text-slate-500 block mb-1">Connected to</span>
<span className="text-[11px] uppercase tracking-wider text-slate-500 block mb-1">{t("detail.connectedTo")}</span>
<div className="flex flex-wrap gap-1.5">
{connectedSvcs.map((s) => {
const dotColor = s.state === "running" ? "bg-emerald-400" : s.state === "exited" || s.state === "dead" ? "bg-red-400" : "bg-yellow-400";
@@ -625,27 +634,27 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
{/* Restart policy */}
{service.restart_policy && (
<DetailRow label="Restart Policy" value={service.restart_policy} />
<DetailRow label={t("detail.restartPolicy")} value={service.restart_policy} />
)}
{/* Resource limits */}
<div>
<span className="text-[11px] uppercase tracking-wider text-slate-500 block mb-1">Resource Limits</span>
<span className="text-[11px] uppercase tracking-wider text-slate-500 block mb-1">{t("detail.resourceLimits")}</span>
<div className="grid grid-cols-2 gap-2">
<div className="bg-slate-800/80 rounded px-3 py-2">
<span className="text-[11px] uppercase tracking-wider text-slate-500 block">Memory Limit</span>
<span className="text-[11px] uppercase tracking-wider text-slate-500 block">{t("detail.memoryLimit")}</span>
<span className="text-xs font-mono text-slate-200">
{service.memory_limit > 0
? `${(service.memory_limit / 1024 / 1024).toFixed(0)} MB`
: "Unlimited"}
: t("detail.unlimited")}
</span>
</div>
<div className="bg-slate-800/80 rounded px-3 py-2">
<span className="text-[11px] uppercase tracking-wider text-slate-500 block">CPU Quota</span>
<span className="text-[11px] uppercase tracking-wider text-slate-500 block">{t("detail.cpuQuota")}</span>
<span className="text-xs font-mono text-slate-200">
{service.cpu_quota > 0
? `${(service.cpu_quota / 1000).toFixed(0)}%`
: "Unlimited"}
: t("detail.unlimited")}
</span>
</div>
</div>
@@ -653,7 +662,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
{/* Health check */}
<div>
<span className="text-[11px] uppercase tracking-wider text-slate-500 block mb-1">Health Check</span>
<span className="text-[11px] uppercase tracking-wider text-slate-500 block mb-1">{t("detail.healthCheck")}</span>
{service.health_status ? (
<div className="space-y-2">
<span className={`inline-flex items-center gap-1.5 text-xs font-mono px-2 py-0.5 rounded ${
@@ -670,7 +679,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
</span>
{service.health_log.length > 0 && (
<div className="bg-slate-800/60 rounded p-2 space-y-0.5">
<span className="text-[10px] text-slate-500 block mb-1">Recent checks</span>
<span className="text-[10px] text-slate-500 block mb-1">{t("detail.recentChecks")}</span>
{service.health_log.map((entry, i) => (
<div key={i} className="text-[11px] font-mono text-slate-400 break-all">{entry}</div>
))}
@@ -678,7 +687,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
)}
</div>
) : (
<span className="text-xs text-slate-500">Not configured</span>
<span className="text-xs text-slate-500">{t("detail.healthNotConfigured")}</span>
)}
</div>
</div>
@@ -693,7 +702,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
return (
<div className="flex-1 overflow-y-auto px-4 py-3">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] uppercase tracking-wider text-slate-500">{filteredEnv.length} variables</span>
<span className="text-[11px] uppercase tracking-wider text-slate-500">{filteredEnv.length} {t("detail.variables")}</span>
<div className="flex items-center gap-1">
<button
onClick={() => {
@@ -716,14 +725,14 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
className="flex items-center gap-1.5 px-2 py-1 rounded text-[10px] font-medium text-slate-400 hover:text-slate-200 hover:bg-slate-800 transition-colors"
>
{copiedEnvIdx === -1 ? <Check size={11} className="text-emerald-400" /> : <Copy size={11} />}
{copiedEnvIdx === -1 ? "Copied!" : "Copy all"}
{copiedEnvIdx === -1 ? t("detail.copied") : t("detail.copyAll")}
</button>
<button
onClick={() => { setEnvVisibleAll((v) => !v); setEnvVisibleSet(new Set()); }}
className="flex items-center gap-1.5 px-2 py-1 rounded text-[10px] font-medium text-slate-400 hover:text-slate-200 hover:bg-slate-800 transition-colors"
>
{envVisibleAll ? <EyeOff size={11} /> : <Eye size={11} />}
{envVisibleAll ? "Hide all" : "Show all"}
{envVisibleAll ? t("detail.hideAll") : t("detail.showAll")}
</button>
</div>
</div>
@@ -786,7 +795,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
})}
</div>
) : (
<div className="text-slate-500 text-sm text-center py-8">No environment variables available</div>
<div className="text-slate-500 text-sm text-center py-8">{t("detail.noEnvVars")}</div>
)}
</div>
);
@@ -798,14 +807,14 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
{stats ? (
<>
<div className="grid grid-cols-2 gap-3">
<StatCard label="CPU" value={`${stats.cpu.toFixed(1)}%`} color={stats.cpu > 80 ? "text-red-400" : stats.cpu > 50 ? "text-yellow-400" : "text-emerald-400"} />
<StatCard label="Memory" value={`${stats.mem_mb.toFixed(0)} MB`} extra={`${stats.mem_percent.toFixed(1)}%`} color={stats.mem_percent > 80 ? "text-red-400" : stats.mem_percent > 50 ? "text-yellow-400" : "text-emerald-400"} />
<StatCard label={t("node.cpu")} value={`${stats.cpu.toFixed(1)}%`} color={stats.cpu > 80 ? "text-red-400" : stats.cpu > 50 ? "text-yellow-400" : "text-emerald-400"} />
<StatCard label={t("detail.memory")} value={`${stats.mem_mb.toFixed(0)} MB`} extra={`${stats.mem_percent.toFixed(1)}%`} color={stats.mem_percent > 80 ? "text-red-400" : stats.mem_percent > 50 ? "text-yellow-400" : "text-emerald-400"} />
</div>
{/* CPU bar */}
<div>
<div className="flex justify-between text-xs text-slate-500 mb-1">
<span>CPU Usage</span>
<span>{t("detail.cpuUsage")}</span>
<span>{stats.cpu.toFixed(1)}%</span>
</div>
<div className="h-2 bg-slate-800 rounded-full overflow-hidden">
@@ -819,7 +828,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
{/* Memory bar */}
<div>
<div className="flex justify-between text-xs text-slate-500 mb-1">
<span>Memory Usage</span>
<span>{t("detail.memoryUsage")}</span>
<span>{stats.mem_mb.toFixed(0)} MB ({stats.mem_percent.toFixed(1)}%)</span>
</div>
<div className="h-2 bg-slate-800 rounded-full overflow-hidden">
@@ -831,7 +840,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
</div>
</>
) : (
<div className="text-slate-500 text-sm text-center py-8">No stats available</div>
<div className="text-slate-500 text-sm text-center py-8">{t("detail.noStats")}</div>
)}
</div>
)}
@@ -864,14 +873,14 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
title={logsExpanded ? "Collapse logs" : "Expand logs"}
>
{logsExpanded ? <ChevronDown size={10} /> : <ChevronUp size={10} />}
{logsExpanded ? "Collapse" : "Expand"}
{logsExpanded ? t("detail.collapse") : t("detail.expand")}
</button>
</div>
</div>
<div className="flex items-center justify-between px-4 py-2 shrink-0">
<div className="flex items-center gap-2">
<Terminal size={14} className="text-cyan-400" />
<span className="text-sm font-medium text-slate-300">Logs</span>
<span className="text-sm font-medium text-slate-300">{t("detail.logs")}</span>
{service.state === "running" && subscribedRef.current && (
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
)}
@@ -884,7 +893,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
title="Exec command"
>
<Terminal size={12} />
Exec
{t("detail.exec")}
</button>
)}
<button
@@ -923,7 +932,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
runExec();
}
}}
placeholder="e.g. python manage.py migrate"
placeholder={t("detail.execPlaceholder")}
className="flex-1 bg-slate-900 border border-slate-600 rounded px-2.5 py-1.5 text-xs font-mono text-slate-200 placeholder:text-slate-600 focus:outline-none focus:border-purple-500"
autoFocus
/>
@@ -932,7 +941,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
disabled={!execCmd.trim() || execLoading}
className="px-3 py-1.5 rounded text-[11px] font-medium text-white bg-purple-700 hover:bg-purple-600 transition-colors disabled:opacity-40"
>
{execLoading ? <Loader2 size={12} className="animate-spin" /> : "Run"}
{execLoading ? <Loader2 size={12} className="animate-spin" /> : t("detail.run")}
</button>
<button
onClick={() => { setExecOpen(false); setExecResult(null); setExecError(null); }}
@@ -949,10 +958,10 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
<div className="space-y-1">
<div className="flex items-center gap-2 text-[11px]">
<span className={execResult.exitCode === 0 ? "text-emerald-400" : "text-red-400"}>
Exit code: {execResult.exitCode}
{t("detail.exitCodeLabel")}: {execResult.exitCode}
</span>
</div>
<pre className="bg-slate-900 rounded px-2.5 py-2 text-xs font-mono text-slate-300 max-h-48 overflow-auto whitespace-pre-wrap break-all">{execResult.output || "(no output)"}</pre>
<pre className="bg-slate-900 rounded px-2.5 py-2 text-xs font-mono text-slate-300 max-h-48 overflow-auto whitespace-pre-wrap break-all">{execResult.output || t("detail.noOutput")}</pre>
</div>
)}
</div>
@@ -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 && (
<div className="text-slate-500 py-4 text-center">Loading logs...</div>
<div className="text-slate-500 py-4 text-center">{t("detail.loadingLogs")}</div>
)}
{!loading && allLines.length === 0 && (
<div className="text-slate-500 py-4 text-center">No logs available</div>
<div className="text-slate-500 py-4 text-center">{t("detail.noLogs")}</div>
)}
{allLines.map((l, i) => (
<div key={i} className="flex gap-0 hover:bg-slate-800/40">
@@ -1034,7 +1043,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
>
{allLines.length > 500 && (
<div className="text-slate-600 text-center py-2 text-[11px]">
{allLines.length - 500} lines hidden
{allLines.length - 500} {t("detail.linesHidden")}
</div>
)}
{(allLines.length > 500 ? allLines.slice(-500) : allLines).map((l, i) => (
+5 -3
View File
@@ -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<LogLine[]>([]);
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 && (
<span className="flex items-center gap-1.5 text-xs text-cyan-400">
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
streaming
{t("logPanel.streaming")}
</span>
)}
</div>
@@ -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 && (
<div className="text-slate-500 py-4 text-center">Loading logs...</div>
<div className="text-slate-500 py-4 text-center">{t("logPanel.loadingLogs")}</div>
)}
{!loading && allLines.length === 0 && (
<div className="text-slate-500 py-4 text-center">No logs available</div>
<div className="text-slate-500 py-4 text-center">{t("logPanel.noLogs")}</div>
)}
{allLines.map((l, i) => (
<div key={i} className="flex gap-0 hover:bg-slate-800/40">
+254
View File
@@ -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<string, number>();
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<string, number>(); // 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<void> {
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<string, number> = {
start: 0x22c55e, // green
stop: 0xef4444, // red
die: 0xef4444,
restart: 0xf59e0b, // orange
health_status: 0xf59e0b,
create: 0x3b82f6, // blue
destroy: 0xef4444,
};
const STATE_TITLES: Record<string, string> = {
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" };
}
}
+1 -1
View File
@@ -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,
+84 -5
View File
@@ -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<string, string> {
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 ──
+9 -2
View File
@@ -18,11 +18,18 @@ export async function pollStats(services: Service[]): Promise<Stats[]> {
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;
+17
View File
@@ -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[] }