mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
v0.0.24
This commit is contained in:
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.github
|
||||
*.md
|
||||
.env
|
||||
.dockerflow-*.json
|
||||
+1
-1
@@ -2,4 +2,4 @@ node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.env
|
||||
.dockerflow-positions.json
|
||||
.dockerflow-*.json
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 }} />
|
||||
|
||||
@@ -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) */}
|
||||
|
||||
@@ -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]`}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 ──
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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[] }
|
||||
|
||||
Reference in New Issue
Block a user