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 [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
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
@@ -341,11 +364,17 @@ function Dashboard({ token }: { token: string }) {
|
||||
|
||||
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) {
|
||||
if (n.type === "service") {
|
||||
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;
|
||||
});
|
||||
}
|
||||
}, [filteredServices, filteredConnections, canInteract]);
|
||||
}, [filteredServices, filteredConnections, canInteract, containerSettings, globalThresholds, discordEnabled]);
|
||||
|
||||
// Recompute edges + handles on drag end (not every pixel)
|
||||
const recomputeEdges = useCallback((currentNodes: Node[]) => {
|
||||
@@ -786,6 +815,7 @@ function Dashboard({ token }: { token: string }) {
|
||||
envFiles={envFiles}
|
||||
onEnvFileChange={handleEnvFileChange}
|
||||
events={events}
|
||||
onContainerSettingsChange={(uid, settings) => setContainerSettings((prev) => ({ ...prev, [uid]: settings }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -140,7 +140,7 @@ const en = {
|
||||
"detail.memoryUsage": "Memory Usage",
|
||||
"detail.memory": "Memory",
|
||||
"detail.noStats": "No stats available",
|
||||
"detail.cpuHistory": "CPU History",
|
||||
"detail.cpuHistory": "Usage History",
|
||||
"detail.memoryHistory": "Memory History",
|
||||
"detail.noHistory": "No historical data available",
|
||||
"detail.loadingHistory": "Loading history...",
|
||||
@@ -398,7 +398,7 @@ const es: Record<TranslationKey, string> = {
|
||||
"detail.memoryUsage": "Uso de Memoria",
|
||||
"detail.memory": "Memoria",
|
||||
"detail.noStats": "No hay estad\u00edsticas disponibles",
|
||||
"detail.cpuHistory": "Historial de CPU",
|
||||
"detail.cpuHistory": "Historial de Consumo",
|
||||
"detail.memoryHistory": "Historial de Memoria",
|
||||
"detail.noHistory": "No hay datos hist\u00f3ricos disponibles",
|
||||
"detail.loadingHistory": "Cargando historial...",
|
||||
|
||||
@@ -38,6 +38,8 @@ interface ServiceNodeData {
|
||||
activeHandles?: string[];
|
||||
highlighted?: boolean;
|
||||
locked?: boolean;
|
||||
cpuThreshold?: number;
|
||||
memThreshold?: number;
|
||||
[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-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<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)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<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)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -62,9 +62,12 @@ interface DetailPanelProps {
|
||||
envFiles: Record<string, string>;
|
||||
onEnvFileChange: (composeFile: string, envFile: string | null) => void;
|
||||
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 [initialLogs, setInitialLogs] = useState<LogLine[]>([]);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
@@ -145,11 +148,13 @@ export function DetailPanel({ service, stats, logLines, token, closing, locked,
|
||||
});
|
||||
setCsSaved(true);
|
||||
setTimeout(() => setCsSaved(false), 1500);
|
||||
// Notify parent so dashboard ServiceNode thresholds update live
|
||||
onContainerSettingsChange?.(service.uid, containerSettings);
|
||||
} catch {}
|
||||
setCsSaving(false);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [containerSettings, csLoaded, service.uid, token]);
|
||||
}, [containerSettings, csLoaded, service.uid, token, onContainerSettingsChange]);
|
||||
|
||||
// Scroll modal to bottom when opened or when logs arrive
|
||||
useEffect(() => {
|
||||
|
||||
+10
-9
@@ -55,9 +55,11 @@ export function computeMemoryBreakdown(memoryStats: any): {
|
||||
|
||||
export async function pollStats(services: Service[]): Promise<Stats[]> {
|
||||
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 {
|
||||
const container = docker.getContainer(svc.id);
|
||||
const raw = await Promise.race([
|
||||
@@ -76,8 +78,6 @@ export async function pollStats(services: Service[]): Promise<Stats[]> {
|
||||
? (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;
|
||||
@@ -86,7 +86,7 @@ export async function pollStats(services: Service[]): Promise<Stats[]> {
|
||||
const memLimit = mb.limit || 1;
|
||||
const TO_MB = 1024 * 1024;
|
||||
|
||||
results.push({
|
||||
return {
|
||||
service: svc.uid,
|
||||
cpu: parseFloat(cpu.toFixed(2)),
|
||||
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)),
|
||||
limit_mb: parseFloat((mb.limit / TO_MB).toFixed(1)),
|
||||
},
|
||||
});
|
||||
};
|
||||
} 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) {
|
||||
|
||||
Reference in New Issue
Block a user