mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
v0.0.32
This commit is contained in:
+33
-3
@@ -138,6 +138,29 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; service: Service } | null>(null);
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; service: Service } | null>(null);
|
||||||
const [envFiles, setEnvFiles] = useState<Record<string, string>>({});
|
const [envFiles, setEnvFiles] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// Thresholds (per-container settings + global Discord config). Used in
|
||||||
|
// dashboard ServiceNode to color progress bars amber when exceeded.
|
||||||
|
const [containerSettings, setContainerSettings] = useState<Record<string, { notificationsEnabled?: boolean; cpuThreshold?: number | null; memThreshold?: number | null }>>({});
|
||||||
|
const [globalThresholds, setGlobalThresholds] = useState<{ cpu: number; mem: number }>({ cpu: 50, mem: 60 });
|
||||||
|
const [discordEnabled, setDiscordEnabled] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
fetch("/api/container-settings", { headers })
|
||||||
|
.then((r) => r.ok ? r.json() : {})
|
||||||
|
.then(setContainerSettings)
|
||||||
|
.catch(() => {});
|
||||||
|
fetch("/api/discord-config", { headers })
|
||||||
|
.then((r) => r.ok ? r.json() : null)
|
||||||
|
.then((c: any) => {
|
||||||
|
if (c) {
|
||||||
|
setGlobalThresholds({ cpu: c.thresholds?.cpuPercent ?? 50, mem: c.thresholds?.memPercent ?? 60 });
|
||||||
|
setDiscordEnabled(!!(c.enabled && c.webhookUrl));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
// Close filter dropdown on outside click
|
// Close filter dropdown on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => {
|
||||||
@@ -341,11 +364,17 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
|
|
||||||
const { nodes: newNodes } = buildLayout(filteredServices, filteredConnections);
|
const { nodes: newNodes } = buildLayout(filteredServices, filteredConnections);
|
||||||
|
|
||||||
// Mark service nodes as locked when restricted mode is active
|
// Mark service nodes as locked + inject effective thresholds for progress bar coloring
|
||||||
for (const n of newNodes) {
|
for (const n of newNodes) {
|
||||||
if (n.type === "service") {
|
if (n.type === "service") {
|
||||||
const svc = filteredServices.find((s) => s.uid === n.id);
|
const svc = filteredServices.find((s) => s.uid === n.id);
|
||||||
if (svc) (n.data as any).locked = !canInteract(svc);
|
if (svc) {
|
||||||
|
(n.data as any).locked = !canInteract(svc);
|
||||||
|
const cs = containerSettings[svc.uid];
|
||||||
|
const notifsOn = discordEnabled && (cs?.notificationsEnabled !== false);
|
||||||
|
(n.data as any).cpuThreshold = notifsOn ? (cs?.cpuThreshold ?? globalThresholds.cpu) : undefined;
|
||||||
|
(n.data as any).memThreshold = notifsOn ? (cs?.memThreshold ?? globalThresholds.mem) : undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,7 +458,7 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [filteredServices, filteredConnections, canInteract]);
|
}, [filteredServices, filteredConnections, canInteract, containerSettings, globalThresholds, discordEnabled]);
|
||||||
|
|
||||||
// Recompute edges + handles on drag end (not every pixel)
|
// Recompute edges + handles on drag end (not every pixel)
|
||||||
const recomputeEdges = useCallback((currentNodes: Node[]) => {
|
const recomputeEdges = useCallback((currentNodes: Node[]) => {
|
||||||
@@ -786,6 +815,7 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
envFiles={envFiles}
|
envFiles={envFiles}
|
||||||
onEnvFileChange={handleEnvFileChange}
|
onEnvFileChange={handleEnvFileChange}
|
||||||
events={events}
|
events={events}
|
||||||
|
onContainerSettingsChange={(uid, settings) => setContainerSettings((prev) => ({ ...prev, [uid]: settings }))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-2
@@ -140,7 +140,7 @@ const en = {
|
|||||||
"detail.memoryUsage": "Memory Usage",
|
"detail.memoryUsage": "Memory Usage",
|
||||||
"detail.memory": "Memory",
|
"detail.memory": "Memory",
|
||||||
"detail.noStats": "No stats available",
|
"detail.noStats": "No stats available",
|
||||||
"detail.cpuHistory": "CPU History",
|
"detail.cpuHistory": "Usage History",
|
||||||
"detail.memoryHistory": "Memory History",
|
"detail.memoryHistory": "Memory History",
|
||||||
"detail.noHistory": "No historical data available",
|
"detail.noHistory": "No historical data available",
|
||||||
"detail.loadingHistory": "Loading history...",
|
"detail.loadingHistory": "Loading history...",
|
||||||
@@ -398,7 +398,7 @@ const es: Record<TranslationKey, string> = {
|
|||||||
"detail.memoryUsage": "Uso de Memoria",
|
"detail.memoryUsage": "Uso de Memoria",
|
||||||
"detail.memory": "Memoria",
|
"detail.memory": "Memoria",
|
||||||
"detail.noStats": "No hay estad\u00edsticas disponibles",
|
"detail.noStats": "No hay estad\u00edsticas disponibles",
|
||||||
"detail.cpuHistory": "Historial de CPU",
|
"detail.cpuHistory": "Historial de Consumo",
|
||||||
"detail.memoryHistory": "Historial de Memoria",
|
"detail.memoryHistory": "Historial de Memoria",
|
||||||
"detail.noHistory": "No hay datos hist\u00f3ricos disponibles",
|
"detail.noHistory": "No hay datos hist\u00f3ricos disponibles",
|
||||||
"detail.loadingHistory": "Cargando historial...",
|
"detail.loadingHistory": "Cargando historial...",
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ interface ServiceNodeData {
|
|||||||
activeHandles?: string[];
|
activeHandles?: string[];
|
||||||
highlighted?: boolean;
|
highlighted?: boolean;
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
|
cpuThreshold?: number;
|
||||||
|
memThreshold?: number;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,13 +223,21 @@ export const ServiceNode = memo(function ServiceNode({ data, id }: NodeProps) {
|
|||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-cyan-500/60 rounded-full transition-all duration-700"
|
className={`h-full rounded-full transition-all duration-700 ${
|
||||||
|
d.cpuThreshold !== undefined && nodeStats.cpu > d.cpuThreshold
|
||||||
|
? "bg-amber-500/80"
|
||||||
|
: "bg-cyan-500/60"
|
||||||
|
}`}
|
||||||
style={{ width: `${Math.min(nodeStats.cpu, 100)}%` }}
|
style={{ width: `${Math.min(nodeStats.cpu, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-violet-500/60 rounded-full transition-all duration-700"
|
className={`h-full rounded-full transition-all duration-700 ${
|
||||||
|
d.memThreshold !== undefined && nodeStats.mem_percent > d.memThreshold
|
||||||
|
? "bg-amber-500/80"
|
||||||
|
: "bg-violet-500/60"
|
||||||
|
}`}
|
||||||
style={{ width: `${Math.min(nodeStats.mem_percent, 100)}%` }}
|
style={{ width: `${Math.min(nodeStats.mem_percent, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -62,9 +62,12 @@ interface DetailPanelProps {
|
|||||||
envFiles: Record<string, string>;
|
envFiles: Record<string, string>;
|
||||||
onEnvFileChange: (composeFile: string, envFile: string | null) => void;
|
onEnvFileChange: (composeFile: string, envFile: string | null) => void;
|
||||||
events: DockerEvent[];
|
events: DockerEvent[];
|
||||||
|
/** Called when container settings change (thresholds, notifications toggle).
|
||||||
|
* Lets the parent (App.tsx) update dashboard ServiceNode threshold coloring live. */
|
||||||
|
onContainerSettingsChange?: (uid: string, settings: ContainerSettings) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DetailPanel({ service, stats, logLines, token, closing, locked, onClose, onAction, clearProcessing, pushActionError, sendMessage, clearLogLines, connections, services, getLogsSince, initialLogsFullscreen, initialTab, envFiles, onEnvFileChange, events }: DetailPanelProps) {
|
export function DetailPanel({ service, stats, logLines, token, closing, locked, onClose, onAction, clearProcessing, pushActionError, sendMessage, clearLogLines, connections, services, getLogsSince, initialLogsFullscreen, initialTab, envFiles, onEnvFileChange, events, onContainerSettingsChange }: DetailPanelProps) {
|
||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
const [initialLogs, setInitialLogs] = useState<LogLine[]>([]);
|
const [initialLogs, setInitialLogs] = useState<LogLine[]>([]);
|
||||||
const [autoScroll, setAutoScroll] = useState(true);
|
const [autoScroll, setAutoScroll] = useState(true);
|
||||||
@@ -145,11 +148,13 @@ export function DetailPanel({ service, stats, logLines, token, closing, locked,
|
|||||||
});
|
});
|
||||||
setCsSaved(true);
|
setCsSaved(true);
|
||||||
setTimeout(() => setCsSaved(false), 1500);
|
setTimeout(() => setCsSaved(false), 1500);
|
||||||
|
// Notify parent so dashboard ServiceNode thresholds update live
|
||||||
|
onContainerSettingsChange?.(service.uid, containerSettings);
|
||||||
} catch {}
|
} catch {}
|
||||||
setCsSaving(false);
|
setCsSaving(false);
|
||||||
}, 500);
|
}, 500);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [containerSettings, csLoaded, service.uid, token]);
|
}, [containerSettings, csLoaded, service.uid, token, onContainerSettingsChange]);
|
||||||
|
|
||||||
// Scroll modal to bottom when opened or when logs arrive
|
// Scroll modal to bottom when opened or when logs arrive
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
+10
-9
@@ -55,9 +55,11 @@ export function computeMemoryBreakdown(memoryStats: any): {
|
|||||||
|
|
||||||
export async function pollStats(services: Service[]): Promise<Stats[]> {
|
export async function pollStats(services: Service[]): Promise<Stats[]> {
|
||||||
const running = services.filter((s) => s.state === "running");
|
const running = services.filter((s) => s.state === "running");
|
||||||
const results: Stats[] = [];
|
|
||||||
|
|
||||||
for (const svc of running) {
|
// Poll all containers in parallel — sequential polling makes the first cycle
|
||||||
|
// take ~3s × N containers, blocking the dashboard on page load. The Docker
|
||||||
|
// daemon handles concurrent stats requests fine.
|
||||||
|
const results = await Promise.all(running.map(async (svc): Promise<Stats | null> => {
|
||||||
try {
|
try {
|
||||||
const container = docker.getContainer(svc.id);
|
const container = docker.getContainer(svc.id);
|
||||||
const raw = await Promise.race([
|
const raw = await Promise.race([
|
||||||
@@ -76,8 +78,6 @@ export async function pollStats(services: Service[]): Promise<Stats[]> {
|
|||||||
? (cpuDelta / sysDelta) * onlineCpus * 100
|
? (cpuDelta / sysDelta) * onlineCpus * 100
|
||||||
: 0;
|
: 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
|
const cpu = svc.cpu_quota > 0
|
||||||
? (cpuHost * 100000 / svc.cpu_quota)
|
? (cpuHost * 100000 / svc.cpu_quota)
|
||||||
: cpuHost;
|
: cpuHost;
|
||||||
@@ -86,7 +86,7 @@ export async function pollStats(services: Service[]): Promise<Stats[]> {
|
|||||||
const memLimit = mb.limit || 1;
|
const memLimit = mb.limit || 1;
|
||||||
const TO_MB = 1024 * 1024;
|
const TO_MB = 1024 * 1024;
|
||||||
|
|
||||||
results.push({
|
return {
|
||||||
service: svc.uid,
|
service: svc.uid,
|
||||||
cpu: parseFloat(cpu.toFixed(2)),
|
cpu: parseFloat(cpu.toFixed(2)),
|
||||||
mem_mb: parseFloat((mb.real / TO_MB).toFixed(1)),
|
mem_mb: parseFloat((mb.real / TO_MB).toFixed(1)),
|
||||||
@@ -97,13 +97,14 @@ export async function pollStats(services: Service[]): Promise<Stats[]> {
|
|||||||
total_mb: parseFloat((mb.total / TO_MB).toFixed(1)),
|
total_mb: parseFloat((mb.total / TO_MB).toFixed(1)),
|
||||||
limit_mb: parseFloat((mb.limit / TO_MB).toFixed(1)),
|
limit_mb: parseFloat((mb.limit / TO_MB).toFixed(1)),
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// Container may have stopped between discovery and stats
|
// Container may have stopped between discovery and stats, or stats timed out
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}));
|
||||||
|
|
||||||
return results;
|
return results.filter((r): r is Stats => r !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function watchDockerEvents(onEvent: (event: DockerEvent) => void) {
|
export function watchDockerEvents(onEvent: (event: DockerEvent) => void) {
|
||||||
|
|||||||
Reference in New Issue
Block a user