perf: reduce container runtime resource spikes across builds
This commit is contained in:
1 parent
9c21c86fd5
commit
dbc819583b
6 files changed
+1010
-106
No files matched your search
@@ -78,7 +78,9 @@ function createFailedRebuildTimeline(activeStep: number): ProcessLogStep[] {
|
||||
export default function ContainersPage() {
|
||||
const [data, setData] = useState<Container[]>([]);
|
||||
const [serverList, setServerList] = useState<Server[]>([]);
|
||||
const [selectedServerId, setSelectedServerId] = useState("");
|
||||
const [selectedServerId, setSelectedServerId] = useState(() =>
|
||||
readStoredServerSelection(PAGE_KEY),
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("ALL");
|
||||
@@ -169,13 +171,15 @@ export default function ContainersPage() {
|
||||
|
||||
useEffect(() => {
|
||||
const storedServerId = readStoredServerSelection(PAGE_KEY);
|
||||
setSelectedServerId(storedServerId);
|
||||
const hasCache = applyCachedState(storedServerId);
|
||||
void load({
|
||||
sync: true,
|
||||
serverId: storedServerId,
|
||||
silent: hasCache,
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
const hasCache = applyCachedState(storedServerId);
|
||||
void load({
|
||||
serverId: storedServerId,
|
||||
silent: hasCache,
|
||||
});
|
||||
});
|
||||
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [applyCachedState, load]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -198,7 +202,7 @@ export default function ContainersPage() {
|
||||
storeServerSelection(PAGE_KEY, serverId);
|
||||
setSelectedServerId(serverId);
|
||||
const hasCache = applyCachedState(serverId);
|
||||
await load({ sync: true, serverId, silent: hasCache });
|
||||
await load({ serverId, silent: hasCache });
|
||||
};
|
||||
|
||||
const runAction = async (
|
||||
|
||||
+357
-80
@@ -15,7 +15,9 @@ import {
|
||||
projectsApi,
|
||||
type Container,
|
||||
type ContainerDetails,
|
||||
type ContainerProcess,
|
||||
type ContainerProjectEnvFile,
|
||||
type ContainerRuntimeStats,
|
||||
type Domain,
|
||||
type ProjectEnvironmentRecord,
|
||||
type ProjectRecord,
|
||||
@@ -64,6 +66,10 @@ type PendingConfirmAction = {
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
const LOGS_REFRESH_INTERVAL_MS = 10000;
|
||||
const LOGS_REFRESH_TAIL_LINES = 50;
|
||||
const LOGS_STREAM_LIMIT = 50;
|
||||
|
||||
function formatStatus(value: string) {
|
||||
return value.charAt(0) + value.slice(1).toLowerCase();
|
||||
}
|
||||
@@ -215,8 +221,58 @@ function buildFallbackMetrics(container: Container): AppMetric[] {
|
||||
];
|
||||
}
|
||||
|
||||
type RuntimeMetricSource = {
|
||||
stats: ContainerRuntimeStats;
|
||||
processes?: ContainerProcess[];
|
||||
};
|
||||
|
||||
function createEmptyRuntimeStats(): ContainerRuntimeStats {
|
||||
return {
|
||||
cpuPercent: 0,
|
||||
memoryPercent: 0,
|
||||
pids: 0,
|
||||
memory: { raw: "" },
|
||||
network: { raw: "" },
|
||||
io: { raw: "" },
|
||||
};
|
||||
}
|
||||
|
||||
function createRuntimeDetailSnapshot({
|
||||
container,
|
||||
inspect = {},
|
||||
logs = "",
|
||||
processes = [],
|
||||
stats,
|
||||
}: {
|
||||
container: Container;
|
||||
inspect?: Record<string, unknown>;
|
||||
logs?: string;
|
||||
processes?: ContainerProcess[];
|
||||
stats: ContainerRuntimeStats;
|
||||
}): ContainerDetails {
|
||||
return {
|
||||
container: {
|
||||
id: container.id,
|
||||
name: container.name,
|
||||
image: container.image,
|
||||
status: container.status,
|
||||
dockerId: container.dockerId,
|
||||
serverId: container.serverId,
|
||||
},
|
||||
server: {
|
||||
id: container.serverId,
|
||||
name: container.server?.name ?? "-",
|
||||
ip: container.server?.ip ?? "-",
|
||||
},
|
||||
logs,
|
||||
inspect,
|
||||
stats,
|
||||
processes,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMetrics(
|
||||
detail: ContainerDetails | null,
|
||||
detail: RuntimeMetricSource | null,
|
||||
container: Container,
|
||||
): AppMetric[] {
|
||||
if (!detail) return buildFallbackMetrics(container);
|
||||
@@ -259,7 +315,9 @@ function buildMetrics(
|
||||
{
|
||||
label: "Processes",
|
||||
value: String(detail.stats.pids),
|
||||
subvalue: `${detail.processes.length} visible processes`,
|
||||
subvalue: detail.processes
|
||||
? `${detail.processes.length} visible processes`
|
||||
: `${detail.stats.pids} running PIDs`,
|
||||
tone: "green",
|
||||
points: sparkline(Math.max(8, detail.stats.pids)),
|
||||
},
|
||||
@@ -275,6 +333,7 @@ function buildAppDetail({
|
||||
runtimeNotice,
|
||||
fallbackLogs,
|
||||
projectEnv,
|
||||
metrics,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
environment: ProjectEnvironmentRecord;
|
||||
@@ -284,15 +343,17 @@ function buildAppDetail({
|
||||
runtimeNotice?: string;
|
||||
fallbackLogs?: string;
|
||||
projectEnv: ContainerProjectEnvFile | null;
|
||||
metrics?: RuntimeMetricSource | null;
|
||||
}): AppDetail {
|
||||
const status = formatStatus(container.status);
|
||||
const isRunning = container.status === "RUNNING";
|
||||
const metricSource = metrics ?? detail;
|
||||
const serverName = detail?.server.name ?? container.server?.name ?? "-";
|
||||
const serverIp = detail?.server.ip ?? container.server?.ip ?? "-";
|
||||
const cpu = detail
|
||||
? `${detail.stats.cpuPercent.toFixed(2)}%`
|
||||
const cpu = metricSource
|
||||
? `${metricSource.stats.cpuPercent.toFixed(2)}%`
|
||||
: (container.cpuUsage ?? "0%");
|
||||
const memory = detail?.stats.memory.used || container.ramUsage || "-";
|
||||
const memory = metricSource?.stats.memory.used || container.ramUsage || "-";
|
||||
const rawLogs = detail ? detail.logs : fallbackLogs || runtimeNotice || "";
|
||||
const logs = parseRecentLogs(rawLogs, 8);
|
||||
const logStream = parseRecentLogs(rawLogs, 100);
|
||||
@@ -328,7 +389,7 @@ function buildAppDetail({
|
||||
recentLogs: logStream,
|
||||
}),
|
||||
terminal: createTerminalData(container, detail),
|
||||
metrics: buildMetrics(detail, container),
|
||||
metrics: buildMetrics(metricSource, container),
|
||||
deployment: {
|
||||
status: isRunning ? "Success" : status,
|
||||
commit: "-",
|
||||
@@ -428,6 +489,17 @@ export default function AppContainerDetailPage() {
|
||||
const rebuildJobIdRef = useRef<string | null>(null);
|
||||
const rebuildCancelRequestedRef = useRef(false);
|
||||
const runtimeMetricsInFlightRef = useRef(false);
|
||||
const runtimeDetailRef = useRef<ContainerDetails | null>(null);
|
||||
const environmentRecordRef = useRef<ProjectEnvironmentRecord | null>(null);
|
||||
const projectEnvRef = useRef<ContainerProjectEnvFile | null>(null);
|
||||
const domainsLoadedRef = useRef(false);
|
||||
const domainsLoadingRef = useRef(false);
|
||||
const runtimeDetailLoadedRef = useRef(false);
|
||||
const runtimeDetailLoadingRef = useRef(false);
|
||||
const projectEnvLoadedRef = useRef(false);
|
||||
const projectEnvLoadingRef = useRef(false);
|
||||
const logsLoadedRef = useRef(false);
|
||||
const logsInFlightRef = useRef(false);
|
||||
const environmentContainersHref = `/projects/${params.projectId}/environments/${params.environmentId}`;
|
||||
const appDetailId = appDetail?.id;
|
||||
|
||||
@@ -440,59 +512,40 @@ export default function AppContainerDetailPage() {
|
||||
setError("");
|
||||
setActionError("");
|
||||
setRuntimeNotice("");
|
||||
runtimeDetailRef.current = null;
|
||||
projectEnvRef.current = null;
|
||||
domainsLoadedRef.current = false;
|
||||
runtimeDetailLoadedRef.current = false;
|
||||
projectEnvLoadedRef.current = false;
|
||||
logsLoadedRef.current = false;
|
||||
|
||||
try {
|
||||
const [
|
||||
projectResponse,
|
||||
containerResponse,
|
||||
detailResult,
|
||||
domainsResponse,
|
||||
projectEnvResponse,
|
||||
] = await Promise.all([
|
||||
projectsApi.detail(params.projectId),
|
||||
containersApi.get(params.containerId),
|
||||
containersApi
|
||||
.details(params.containerId, 300)
|
||||
.then((response) => ({
|
||||
detail: response.data ?? null,
|
||||
logs: "",
|
||||
notice: "",
|
||||
}))
|
||||
.catch(async (detailsError) => {
|
||||
const message =
|
||||
detailsError instanceof Error
|
||||
? detailsError.message
|
||||
: "Runtime detail is unavailable.";
|
||||
let logs = "";
|
||||
const [projectResponse, containerResponse, metricsResult] =
|
||||
await Promise.all([
|
||||
projectsApi.detail(params.projectId),
|
||||
containersApi.get(params.containerId),
|
||||
containersApi
|
||||
.metrics(params.containerId)
|
||||
.then((response) => ({
|
||||
metrics: response.data ?? null,
|
||||
notice: "",
|
||||
}))
|
||||
.catch((metricsError) => {
|
||||
const message =
|
||||
metricsError instanceof Error
|
||||
? metricsError.message
|
||||
: "Runtime metrics are unavailable.";
|
||||
|
||||
try {
|
||||
const logResponse = await containersApi.logs(
|
||||
params.containerId,
|
||||
100,
|
||||
);
|
||||
logs = logResponse.data?.logs ?? "";
|
||||
} catch {
|
||||
logs = "";
|
||||
}
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
logs,
|
||||
notice: message,
|
||||
};
|
||||
}),
|
||||
domainsApi
|
||||
.list()
|
||||
.catch(() => ({ success: false, data: [] as Domain[] })),
|
||||
containersApi.projectEnv(params.containerId).catch(() => ({
|
||||
success: false,
|
||||
data: null as ContainerProjectEnvFile | null,
|
||||
})),
|
||||
]);
|
||||
return {
|
||||
metrics: null,
|
||||
notice: message,
|
||||
};
|
||||
}),
|
||||
]);
|
||||
|
||||
const project = projectResponse.data;
|
||||
const container = containerResponse.data;
|
||||
const detail = detailResult.detail;
|
||||
const metrics = metricsResult.metrics;
|
||||
const environment = project?.environments.find(
|
||||
(item) => item.id === params.environmentId,
|
||||
);
|
||||
@@ -516,17 +569,24 @@ export default function AppContainerDetailPage() {
|
||||
project,
|
||||
environment,
|
||||
container,
|
||||
detail,
|
||||
domains: domainsResponse.data ?? [],
|
||||
runtimeNotice: detailResult.notice,
|
||||
fallbackLogs: detailResult.logs,
|
||||
projectEnv: projectEnvResponse.data ?? null,
|
||||
detail: null,
|
||||
domains: [],
|
||||
runtimeNotice: metricsResult.notice,
|
||||
projectEnv: null,
|
||||
metrics,
|
||||
}),
|
||||
);
|
||||
if (detailResult.notice) {
|
||||
setRuntimeNotice(detailResult.notice);
|
||||
if (metricsResult.notice) {
|
||||
setRuntimeNotice(metricsResult.notice);
|
||||
}
|
||||
setContainerRecord(container);
|
||||
environmentRecordRef.current = environment;
|
||||
runtimeDetailRef.current = metrics
|
||||
? createRuntimeDetailSnapshot({
|
||||
container,
|
||||
stats: metrics.stats,
|
||||
})
|
||||
: null;
|
||||
} catch (loadError) {
|
||||
setAppDetail(null);
|
||||
setContainerRecord(null);
|
||||
@@ -563,10 +623,16 @@ export default function AppContainerDetailPage() {
|
||||
runtimeMetricsInFlightRef.current = true;
|
||||
|
||||
try {
|
||||
const response = await containersApi.details(params.containerId, 20);
|
||||
const detail = response.data;
|
||||
const nextCpu = `${detail.stats.cpuPercent.toFixed(2)}%`;
|
||||
const nextMemory = detail.stats.memory.used || container.ramUsage || "-";
|
||||
const response = await containersApi.metrics(params.containerId);
|
||||
const metrics = response.data;
|
||||
const nextCpu = `${metrics.stats.cpuPercent.toFixed(2)}%`;
|
||||
const nextMemory = metrics.stats.memory.used || container.ramUsage || "-";
|
||||
runtimeDetailRef.current = runtimeDetailRef.current
|
||||
? { ...runtimeDetailRef.current, stats: metrics.stats }
|
||||
: createRuntimeDetailSnapshot({
|
||||
container,
|
||||
stats: metrics.stats,
|
||||
});
|
||||
|
||||
setContainerRecord((current) =>
|
||||
current
|
||||
@@ -582,11 +648,7 @@ export default function AppContainerDetailPage() {
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
metrics: buildMetrics(detail, container),
|
||||
runtime: createRuntimeData(container, detail),
|
||||
storage: createStorageData(container, detail),
|
||||
advanced: createAdvancedData(container, detail),
|
||||
terminal: createTerminalData(container, detail),
|
||||
metrics: buildMetrics(metrics, container),
|
||||
health: {
|
||||
...current.health,
|
||||
status: "Healthy",
|
||||
@@ -613,14 +675,187 @@ export default function AppContainerDetailPage() {
|
||||
}
|
||||
}, [params.containerId]);
|
||||
|
||||
const hydrateDomains = useCallback(async () => {
|
||||
const container = containerRecordRef.current;
|
||||
if (!container || domainsLoadedRef.current || domainsLoadingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
domainsLoadingRef.current = true;
|
||||
|
||||
try {
|
||||
const response = await domainsApi.list();
|
||||
const domains = response.data ?? [];
|
||||
domainsLoadedRef.current = true;
|
||||
|
||||
setAppDetail((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
domains: getLinkedDomains(domains, container),
|
||||
openUrl:
|
||||
getDomainUrl(domains, container) ??
|
||||
getContainerWebUiUrl(container),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
} catch {
|
||||
domainsLoadedRef.current = true;
|
||||
} finally {
|
||||
domainsLoadingRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const hydrateRuntimeDetail = useCallback(async () => {
|
||||
const container = containerRecordRef.current;
|
||||
if (
|
||||
!container ||
|
||||
runtimeDetailLoadedRef.current ||
|
||||
runtimeDetailLoadingRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
runtimeDetailLoadingRef.current = true;
|
||||
|
||||
try {
|
||||
const [inspectResult, metricsResult, processesResult] =
|
||||
await Promise.allSettled([
|
||||
containersApi.inspect(params.containerId),
|
||||
containersApi.metrics(params.containerId),
|
||||
containersApi.processes(params.containerId),
|
||||
]);
|
||||
const previous = runtimeDetailRef.current;
|
||||
const stats =
|
||||
metricsResult.status === "fulfilled"
|
||||
? metricsResult.value.data.stats
|
||||
: (previous?.stats ?? createEmptyRuntimeStats());
|
||||
const detail = createRuntimeDetailSnapshot({
|
||||
container,
|
||||
stats,
|
||||
inspect:
|
||||
inspectResult.status === "fulfilled"
|
||||
? inspectResult.value.data
|
||||
: (previous?.inspect ?? {}),
|
||||
processes:
|
||||
processesResult.status === "fulfilled"
|
||||
? processesResult.value.data
|
||||
: (previous?.processes ?? []),
|
||||
logs: previous?.logs ?? "",
|
||||
});
|
||||
runtimeDetailRef.current = detail;
|
||||
runtimeDetailLoadedRef.current = true;
|
||||
setRuntimeNotice("");
|
||||
|
||||
setAppDetail((current) => {
|
||||
const environment = environmentRecordRef.current;
|
||||
if (!current || !environment) return current;
|
||||
|
||||
return {
|
||||
...current,
|
||||
serverName: detail.server.name,
|
||||
serverIp: detail.server.ip,
|
||||
metrics: buildMetrics(detail, container),
|
||||
runtime: createRuntimeData(container, detail),
|
||||
deployments: createDeploymentsData(container, detail),
|
||||
advanced: createAdvancedData(container, detail),
|
||||
environment: createEnvironmentData({
|
||||
container,
|
||||
detail,
|
||||
environment,
|
||||
projectEnv: projectEnvRef.current,
|
||||
}),
|
||||
storage: createStorageData(container, detail),
|
||||
logsDetail: createLogsData({
|
||||
container,
|
||||
detail,
|
||||
recentLogs: current.logsDetail.streams.map((log) => ({
|
||||
time: log.time,
|
||||
level:
|
||||
log.level === "DEBUG"
|
||||
? ("INFO" as const)
|
||||
: (log.level as RecentLogLine["level"]),
|
||||
message: log.message,
|
||||
})),
|
||||
}),
|
||||
terminal: createTerminalData(container, detail),
|
||||
};
|
||||
});
|
||||
} catch (runtimeError) {
|
||||
setRuntimeNotice(
|
||||
runtimeError instanceof Error
|
||||
? runtimeError.message
|
||||
: "Runtime detail is unavailable.",
|
||||
);
|
||||
} finally {
|
||||
runtimeDetailLoadingRef.current = false;
|
||||
}
|
||||
}, [params.containerId]);
|
||||
|
||||
const hydrateProjectEnv = useCallback(async () => {
|
||||
const container = containerRecordRef.current;
|
||||
const environment = environmentRecordRef.current;
|
||||
if (
|
||||
!container ||
|
||||
!environment ||
|
||||
projectEnvLoadedRef.current ||
|
||||
projectEnvLoadingRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
projectEnvLoadingRef.current = true;
|
||||
|
||||
try {
|
||||
const response = await containersApi.projectEnv(params.containerId);
|
||||
const projectEnv = response.data ?? null;
|
||||
projectEnvRef.current = projectEnv;
|
||||
projectEnvLoadedRef.current = true;
|
||||
|
||||
setAppDetail((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
environment: createEnvironmentData({
|
||||
container,
|
||||
detail: runtimeDetailRef.current,
|
||||
environment,
|
||||
projectEnv,
|
||||
}),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
} catch {
|
||||
projectEnvLoadedRef.current = true;
|
||||
} finally {
|
||||
projectEnvLoadingRef.current = false;
|
||||
}
|
||||
}, [params.containerId]);
|
||||
|
||||
const refreshLogs = useCallback(async () => {
|
||||
if (logsInFlightRef.current) return;
|
||||
|
||||
logsInFlightRef.current = true;
|
||||
setLogsRefreshing(true);
|
||||
|
||||
try {
|
||||
const response = await containersApi.logs(params.containerId, 100);
|
||||
const response = await containersApi.logs(
|
||||
params.containerId,
|
||||
LOGS_REFRESH_TAIL_LINES,
|
||||
);
|
||||
const rawLogs = response.data?.logs ?? "";
|
||||
const nextLogs = parseRecentLogs(rawLogs, 8);
|
||||
const nextLogStream = parseRecentLogs(rawLogs, 100);
|
||||
const nextLogStream = parseRecentLogs(rawLogs, LOGS_STREAM_LIMIT);
|
||||
logsLoadedRef.current = true;
|
||||
runtimeDetailRef.current = runtimeDetailRef.current
|
||||
? { ...runtimeDetailRef.current, logs: rawLogs }
|
||||
: containerRecord
|
||||
? createRuntimeDetailSnapshot({
|
||||
container: containerRecord,
|
||||
stats: createEmptyRuntimeStats(),
|
||||
logs: rawLogs,
|
||||
})
|
||||
: null;
|
||||
|
||||
setAppDetail((current) =>
|
||||
current
|
||||
@@ -628,11 +863,11 @@ export default function AppContainerDetailPage() {
|
||||
...current,
|
||||
logs: nextLogs,
|
||||
logsDetail: containerRecord
|
||||
? createLogsData({
|
||||
container: containerRecord,
|
||||
detail: null,
|
||||
recentLogs: nextLogStream,
|
||||
})
|
||||
? createLogsData({
|
||||
container: containerRecord,
|
||||
detail: runtimeDetailRef.current,
|
||||
recentLogs: nextLogStream,
|
||||
})
|
||||
: current.logsDetail,
|
||||
health: {
|
||||
...current.health,
|
||||
@@ -663,6 +898,7 @@ export default function AppContainerDetailPage() {
|
||||
: current,
|
||||
);
|
||||
} finally {
|
||||
logsInFlightRef.current = false;
|
||||
setLogsRefreshing(false);
|
||||
}
|
||||
}, [containerRecord, params.containerId]);
|
||||
@@ -1099,6 +1335,43 @@ export default function AppContainerDetailPage() {
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appDetailId) return;
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
if (activeTab === "overview" || activeTab === "domains") {
|
||||
void hydrateDomains();
|
||||
}
|
||||
|
||||
if (
|
||||
activeTab === "runtime" ||
|
||||
activeTab === "storage" ||
|
||||
activeTab === "advanced" ||
|
||||
activeTab === "terminal" ||
|
||||
activeTab === "deployments"
|
||||
) {
|
||||
void hydrateRuntimeDetail();
|
||||
}
|
||||
|
||||
if (activeTab === "environment") {
|
||||
void hydrateProjectEnv();
|
||||
}
|
||||
|
||||
if (activeTab === "logs" && !logsLoadedRef.current) {
|
||||
void refreshLogs();
|
||||
}
|
||||
});
|
||||
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [
|
||||
activeTab,
|
||||
appDetailId,
|
||||
hydrateDomains,
|
||||
hydrateProjectEnv,
|
||||
hydrateRuntimeDetail,
|
||||
refreshLogs,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appDetailId || containerRecord?.status !== "RUNNING") return;
|
||||
|
||||
@@ -1124,17 +1397,21 @@ export default function AppContainerDetailPage() {
|
||||
if (!logsAutoRefresh) return;
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
void refreshLogs();
|
||||
if (activeTab === "logs" && !document.hidden) {
|
||||
void refreshLogs();
|
||||
}
|
||||
});
|
||||
const intervalId = window.setInterval(() => {
|
||||
void refreshLogs();
|
||||
}, 5000);
|
||||
if (activeTab === "logs" && !document.hidden) {
|
||||
void refreshLogs();
|
||||
}
|
||||
}, LOGS_REFRESH_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [logsAutoRefresh, refreshLogs]);
|
||||
}, [activeTab, logsAutoRefresh, refreshLogs]);
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
|
||||
@@ -1581,6 +1581,18 @@ export interface ContainerDetails {
|
||||
processes: ContainerProcess[];
|
||||
}
|
||||
|
||||
export interface ContainerMetrics {
|
||||
container: {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
dockerId: string | null;
|
||||
serverId: string;
|
||||
};
|
||||
stats: ContainerRuntimeStats;
|
||||
sampledAt: string;
|
||||
}
|
||||
|
||||
export interface ContainerFileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
@@ -1855,11 +1867,21 @@ export const containers = {
|
||||
`/containers/${id}/inspect`,
|
||||
{ timeoutMs: 20000 },
|
||||
),
|
||||
processes: (id: string) =>
|
||||
get<{ success: boolean; data: ContainerProcess[] }>(
|
||||
`/containers/${id}/processes`,
|
||||
{ timeoutMs: 20000 },
|
||||
),
|
||||
details: (id: string, lines?: number) =>
|
||||
get<{ success: boolean; data: ContainerDetails }>(
|
||||
`/containers/${id}/details${lines ? `?lines=${lines}` : ""}`,
|
||||
{ timeoutMs: 30000 },
|
||||
),
|
||||
metrics: (id: string) =>
|
||||
get<{ success: boolean; data: ContainerMetrics }>(
|
||||
`/containers/${id}/metrics`,
|
||||
{ timeoutMs: 15000 },
|
||||
),
|
||||
listFiles: (id: string, path?: string) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (path) qs.set("path", path);
|
||||
|
||||
+416
-18
@@ -124,6 +124,222 @@ const ContainerRenamePathSchema = z.object({
|
||||
|
||||
const CONTAINER_UPLOAD_CONTENT_BASE64_MAX_LENGTH = 8_000_000;
|
||||
const CONTAINER_UPLOAD_BODY_LIMIT_BYTES = 10 * 1024 * 1024;
|
||||
const CONTAINER_METRICS_CACHE_TTL_MS = 5_000;
|
||||
const DOCKER_TIMING_SAMPLE_LIMIT = 100;
|
||||
|
||||
type DockerCommandCategory = "list" | "logs" | "inspect" | "stats" | "top";
|
||||
type DockerCommandStatus = "success" | "error";
|
||||
type DockerCommandTimingEvent = {
|
||||
endpoint: string;
|
||||
category: DockerCommandCategory;
|
||||
serverId: string;
|
||||
serverName?: string;
|
||||
containerId?: string;
|
||||
status: DockerCommandStatus;
|
||||
durationMs: number;
|
||||
timedOut: boolean;
|
||||
};
|
||||
type DockerCommandTimingSink = (event: DockerCommandTimingEvent) => void;
|
||||
|
||||
const dockerTimingStats = new Map<
|
||||
string,
|
||||
{ count: number; samples: number[] }
|
||||
>();
|
||||
|
||||
function isLikelyTimeout(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error ?? "");
|
||||
return /timed?\s*out|timeout|aborted|abort/i.test(message);
|
||||
}
|
||||
|
||||
function percentile95(samples: number[]) {
|
||||
if (samples.length === 0) return 0;
|
||||
const sorted = [...samples].sort((left, right) => left - right);
|
||||
const index = Math.min(
|
||||
sorted.length - 1,
|
||||
Math.ceil(sorted.length * 0.95) - 1,
|
||||
);
|
||||
return sorted[index] ?? 0;
|
||||
}
|
||||
|
||||
function recordDockerCommandTiming(
|
||||
app: FastifyInstance,
|
||||
event: DockerCommandTimingEvent,
|
||||
) {
|
||||
const key = `${event.endpoint}:${event.category}:${event.status}`;
|
||||
const current = dockerTimingStats.get(key) ?? { count: 0, samples: [] };
|
||||
const samples = [...current.samples, event.durationMs].slice(
|
||||
-DOCKER_TIMING_SAMPLE_LIMIT,
|
||||
);
|
||||
const count = current.count + 1;
|
||||
dockerTimingStats.set(key, { count, samples });
|
||||
|
||||
app.log.info(
|
||||
{
|
||||
event: "docker_command_timing",
|
||||
endpoint: event.endpoint,
|
||||
category: event.category,
|
||||
serverId: event.serverId,
|
||||
serverName: event.serverName,
|
||||
containerId: event.containerId,
|
||||
status: event.status,
|
||||
durationMs: event.durationMs,
|
||||
timedOut: event.timedOut,
|
||||
count,
|
||||
p95DurationMs: percentile95(samples),
|
||||
sampleSize: samples.length,
|
||||
},
|
||||
"Docker command timing",
|
||||
);
|
||||
}
|
||||
|
||||
async function measureDockerCommand<T>(
|
||||
sink: DockerCommandTimingSink | undefined,
|
||||
event: Omit<DockerCommandTimingEvent, "durationMs" | "status" | "timedOut">,
|
||||
operation: () => Promise<T>,
|
||||
) {
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
const result = await operation();
|
||||
sink?.({
|
||||
...event,
|
||||
status: "success",
|
||||
durationMs: Date.now() - startedAt,
|
||||
timedOut: false,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
sink?.({
|
||||
...event,
|
||||
status: "error",
|
||||
durationMs: Date.now() - startedAt,
|
||||
timedOut: isLikelyTimeout(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export type ContainerMetricsPayload = {
|
||||
container: {
|
||||
id: string;
|
||||
name: string;
|
||||
status: ContainerStatus;
|
||||
dockerId: string | null;
|
||||
serverId: string;
|
||||
};
|
||||
stats: Awaited<ReturnType<typeof ssh.dockerStats>>;
|
||||
sampledAt: string;
|
||||
};
|
||||
|
||||
type ContainerMetricsCacheEntry = {
|
||||
expiresAt: number;
|
||||
promise: Promise<ContainerMetricsPayload>;
|
||||
};
|
||||
|
||||
const containerMetricsCache = new Map<string, ContainerMetricsCacheEntry>();
|
||||
|
||||
type ReadContainerMetricsWithCacheArgs = {
|
||||
organizationId?: string;
|
||||
serverId: string;
|
||||
serverName?: string;
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
containerStatus: ContainerStatus;
|
||||
dockerId: string | null;
|
||||
runtimeId: string;
|
||||
loadStats: () => Promise<Awaited<ReturnType<typeof ssh.dockerStats>>>;
|
||||
};
|
||||
|
||||
function getContainerMetricsCacheKey(args: {
|
||||
organizationId?: string;
|
||||
serverId: string;
|
||||
containerId: string;
|
||||
runtimeId: string;
|
||||
}) {
|
||||
return [
|
||||
args.organizationId ?? "unknown-organization",
|
||||
args.serverId,
|
||||
args.containerId,
|
||||
args.runtimeId,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
export function invalidateContainerMetricsCache(args: {
|
||||
organizationId?: string;
|
||||
serverId: string;
|
||||
containerId: string;
|
||||
}) {
|
||||
const prefix = [
|
||||
args.organizationId ?? "unknown-organization",
|
||||
args.serverId,
|
||||
args.containerId,
|
||||
"",
|
||||
].join(":");
|
||||
|
||||
for (const key of containerMetricsCache.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
containerMetricsCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function clearContainerMetricsCacheForTest() {
|
||||
containerMetricsCache.clear();
|
||||
}
|
||||
|
||||
export async function readContainerMetricsWithCache({
|
||||
organizationId,
|
||||
serverId,
|
||||
containerId,
|
||||
containerName,
|
||||
containerStatus,
|
||||
dockerId,
|
||||
runtimeId,
|
||||
loadStats,
|
||||
}: ReadContainerMetricsWithCacheArgs): Promise<ContainerMetricsPayload> {
|
||||
const cacheKey = getContainerMetricsCacheKey({
|
||||
organizationId,
|
||||
serverId,
|
||||
containerId,
|
||||
runtimeId,
|
||||
});
|
||||
const now = Date.now();
|
||||
const cached = containerMetricsCache.get(cacheKey);
|
||||
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.promise;
|
||||
}
|
||||
|
||||
let entry: ContainerMetricsCacheEntry;
|
||||
const promise = loadStats().then((stats): ContainerMetricsPayload => {
|
||||
entry.expiresAt = Date.now() + CONTAINER_METRICS_CACHE_TTL_MS;
|
||||
|
||||
return {
|
||||
container: {
|
||||
id: containerId,
|
||||
name: containerName,
|
||||
status: containerStatus,
|
||||
dockerId,
|
||||
serverId,
|
||||
},
|
||||
stats,
|
||||
sampledAt: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
entry = {
|
||||
expiresAt: Number.POSITIVE_INFINITY,
|
||||
promise,
|
||||
};
|
||||
|
||||
containerMetricsCache.set(cacheKey, entry);
|
||||
|
||||
try {
|
||||
return await promise;
|
||||
} catch (error) {
|
||||
containerMetricsCache.delete(cacheKey);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const ContainerUploadFileSchema = z.object({
|
||||
directoryPath: z.string().min(1).max(2048),
|
||||
@@ -801,6 +1017,7 @@ async function syncContainersForServers(
|
||||
updatedAt: Date;
|
||||
}>,
|
||||
userId?: string,
|
||||
timingSink?: DockerCommandTimingSink,
|
||||
) {
|
||||
const summary: Array<{
|
||||
serverId: string;
|
||||
@@ -812,7 +1029,16 @@ async function syncContainersForServers(
|
||||
const pendingNotifications: Array<Promise<unknown>> = [];
|
||||
let actualContainers;
|
||||
try {
|
||||
actualContainers = await ssh.listDockerContainers(server);
|
||||
actualContainers = await measureDockerCommand(
|
||||
timingSink,
|
||||
{
|
||||
endpoint: "containers.sync",
|
||||
category: "list",
|
||||
serverId: server.id,
|
||||
serverName: server.name,
|
||||
},
|
||||
() => ssh.listDockerContainers(server),
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
error instanceof Error
|
||||
@@ -1292,7 +1518,11 @@ export async function containerRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
try {
|
||||
const summary = await syncContainersForServers(servers, req.userId);
|
||||
const summary = await syncContainersForServers(
|
||||
servers,
|
||||
req.userId,
|
||||
(event) => recordDockerCommandTiming(app, event),
|
||||
);
|
||||
|
||||
const containers = await prisma.container.findMany({
|
||||
where: {
|
||||
@@ -2081,6 +2311,54 @@ export async function containerRoutes(app: FastifyInstance) {
|
||||
return reply.send({ success: true, data: container });
|
||||
});
|
||||
|
||||
// GET /containers/:id/metrics - lightweight runtime metrics for polling.
|
||||
app.get(
|
||||
"/:id/metrics",
|
||||
{ preHandler: containerReadAccess },
|
||||
async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const container = await getAccessibleContainer(id, req.organizationId);
|
||||
if (!container)
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Container not found" });
|
||||
|
||||
const runtimeId = container.dockerId || container.name;
|
||||
|
||||
try {
|
||||
const data = await readContainerMetricsWithCache({
|
||||
organizationId: req.organizationId,
|
||||
serverId: container.serverId,
|
||||
serverName: container.server.name,
|
||||
containerId: container.id,
|
||||
containerName: container.name,
|
||||
containerStatus: container.status,
|
||||
dockerId: container.dockerId,
|
||||
runtimeId,
|
||||
loadStats: () =>
|
||||
measureDockerCommand(
|
||||
(event) => recordDockerCommandTiming(app, event),
|
||||
{
|
||||
endpoint: "containers.metrics",
|
||||
category: "stats",
|
||||
serverId: container.serverId,
|
||||
serverName: container.server.name,
|
||||
containerId: container.id,
|
||||
},
|
||||
() => ssh.dockerStats(container.server, runtimeId),
|
||||
),
|
||||
});
|
||||
|
||||
return reply.send({ success: true, data });
|
||||
} catch (err: unknown) {
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Failed to load metrics",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// GET /containers/:id/details — logs, inspect, runtime stats and processes
|
||||
app.get(
|
||||
"/:id/details",
|
||||
@@ -2100,14 +2378,55 @@ export async function containerRoutes(app: FastifyInstance) {
|
||||
try {
|
||||
const [logsResult, inspectResult, statsResult, processesResult] =
|
||||
await Promise.allSettled([
|
||||
ssh.dockerLogs(
|
||||
container.server,
|
||||
runtimeId,
|
||||
parseInt(lines, 10) || 200,
|
||||
measureDockerCommand(
|
||||
(event) => recordDockerCommandTiming(app, event),
|
||||
{
|
||||
endpoint: "containers.details",
|
||||
category: "logs",
|
||||
serverId: container.serverId,
|
||||
serverName: container.server.name,
|
||||
containerId: container.id,
|
||||
},
|
||||
() =>
|
||||
ssh.dockerLogs(
|
||||
container.server,
|
||||
runtimeId,
|
||||
parseInt(lines, 10) || 200,
|
||||
),
|
||||
),
|
||||
measureDockerCommand(
|
||||
(event) => recordDockerCommandTiming(app, event),
|
||||
{
|
||||
endpoint: "containers.details",
|
||||
category: "inspect",
|
||||
serverId: container.serverId,
|
||||
serverName: container.server.name,
|
||||
containerId: container.id,
|
||||
},
|
||||
() => ssh.dockerInspect(container.server, runtimeId),
|
||||
),
|
||||
measureDockerCommand(
|
||||
(event) => recordDockerCommandTiming(app, event),
|
||||
{
|
||||
endpoint: "containers.details",
|
||||
category: "stats",
|
||||
serverId: container.serverId,
|
||||
serverName: container.server.name,
|
||||
containerId: container.id,
|
||||
},
|
||||
() => ssh.dockerStats(container.server, runtimeId),
|
||||
),
|
||||
measureDockerCommand(
|
||||
(event) => recordDockerCommandTiming(app, event),
|
||||
{
|
||||
endpoint: "containers.details",
|
||||
category: "top",
|
||||
serverId: container.serverId,
|
||||
serverName: container.server.name,
|
||||
containerId: container.id,
|
||||
},
|
||||
() => ssh.dockerTop(container.server, runtimeId),
|
||||
),
|
||||
ssh.dockerInspect(container.server, runtimeId),
|
||||
ssh.dockerStats(container.server, runtimeId),
|
||||
ssh.dockerTop(container.server, runtimeId),
|
||||
]);
|
||||
const logs = logsResult.status === "fulfilled" ? logsResult.value : "";
|
||||
const inspect =
|
||||
@@ -2169,7 +2488,17 @@ export async function containerRoutes(app: FastifyInstance) {
|
||||
const runtimeId = container.dockerId || container.name;
|
||||
|
||||
try {
|
||||
const inspect = await ssh.dockerInspect(container.server, runtimeId);
|
||||
const inspect = await measureDockerCommand(
|
||||
(event) => recordDockerCommandTiming(app, event),
|
||||
{
|
||||
endpoint: "containers.inspect",
|
||||
category: "inspect",
|
||||
serverId: container.serverId,
|
||||
serverName: container.server.name,
|
||||
containerId: container.id,
|
||||
},
|
||||
() => ssh.dockerInspect(container.server, runtimeId),
|
||||
);
|
||||
return reply.send({ success: true, data: inspect });
|
||||
} catch (err: any) {
|
||||
return reply.status(500).send({ success: false, error: err.message });
|
||||
@@ -2177,7 +2506,44 @@ export async function containerRoutes(app: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
// POST /containers — deploy a new container
|
||||
// GET /containers/:id/processes - fetch only the runtime process table.
|
||||
app.get(
|
||||
"/:id/processes",
|
||||
{ preHandler: containerReadAccess },
|
||||
async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const container = await getAccessibleContainer(id, req.organizationId);
|
||||
if (!container)
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Container not found" });
|
||||
|
||||
const runtimeId = container.dockerId || container.name;
|
||||
|
||||
try {
|
||||
const processes = await measureDockerCommand(
|
||||
(event) => recordDockerCommandTiming(app, event),
|
||||
{
|
||||
endpoint: "containers.processes",
|
||||
category: "top",
|
||||
serverId: container.serverId,
|
||||
serverName: container.server.name,
|
||||
containerId: container.id,
|
||||
},
|
||||
() => ssh.dockerTop(container.server, runtimeId),
|
||||
);
|
||||
return reply.send({ success: true, data: processes });
|
||||
} catch (err: unknown) {
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error:
|
||||
err instanceof Error ? err.message : "Failed to load processes",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// POST /containers - deploy a new container
|
||||
app.post(
|
||||
"/",
|
||||
{ preHandler: containerWriteAccess, bodyLimit: 1024 * 1024 },
|
||||
@@ -2485,7 +2851,9 @@ export async function containerRoutes(app: FastifyInstance) {
|
||||
deploymentPath = result.deploymentPath;
|
||||
}
|
||||
|
||||
await syncContainersForServers([server], req.userId);
|
||||
await syncContainersForServers([server], req.userId, (event) =>
|
||||
recordDockerCommandTiming(app, event),
|
||||
);
|
||||
|
||||
const serverContainers = await prisma.container.findMany({
|
||||
where: { serverId: server.id },
|
||||
@@ -2650,7 +3018,17 @@ export async function containerRoutes(app: FastifyInstance) {
|
||||
// Read actual runtime status after action so DB reflects reality.
|
||||
let inspect: any = null;
|
||||
try {
|
||||
inspect = await ssh.dockerInspect(container.server, runtimeRefUsed);
|
||||
inspect = await measureDockerCommand(
|
||||
(event) => recordDockerCommandTiming(app, event),
|
||||
{
|
||||
endpoint: "containers.action",
|
||||
category: "inspect",
|
||||
serverId: container.serverId,
|
||||
serverName: container.server.name,
|
||||
containerId: container.id,
|
||||
},
|
||||
() => ssh.dockerInspect(container.server, runtimeRefUsed),
|
||||
);
|
||||
} catch {
|
||||
inspect = null;
|
||||
}
|
||||
@@ -2680,6 +3058,12 @@ export async function containerRoutes(app: FastifyInstance) {
|
||||
message: `Container "${container.name}" → ${action}`,
|
||||
});
|
||||
|
||||
invalidateContainerMetricsCache({
|
||||
organizationId: req.organizationId,
|
||||
serverId: container.serverId,
|
||||
containerId: container.id,
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: updatedContainer ?? undefined,
|
||||
@@ -2897,7 +3281,9 @@ export async function containerRoutes(app: FastifyInstance) {
|
||||
),
|
||||
});
|
||||
|
||||
await syncContainersForServers([container.server], req.userId);
|
||||
await syncContainersForServers([container.server], req.userId, (event) =>
|
||||
recordDockerCommandTiming(app, event),
|
||||
);
|
||||
|
||||
const serverContainers = await prisma.container.findMany({
|
||||
where: { serverId: container.serverId },
|
||||
@@ -2990,12 +3376,24 @@ export async function containerRoutes(app: FastifyInstance) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: "Container has no Docker ID" });
|
||||
const runtimeId = container.dockerId;
|
||||
|
||||
try {
|
||||
const logs = await ssh.dockerLogs(
|
||||
container.server,
|
||||
container.dockerId,
|
||||
parseInt(lines),
|
||||
const logs = await measureDockerCommand(
|
||||
(event) => recordDockerCommandTiming(app, event),
|
||||
{
|
||||
endpoint: "containers.logs",
|
||||
category: "logs",
|
||||
serverId: container.serverId,
|
||||
serverName: container.server.name,
|
||||
containerId: container.id,
|
||||
},
|
||||
() =>
|
||||
ssh.dockerLogs(
|
||||
container.server,
|
||||
runtimeId,
|
||||
parseInt(lines),
|
||||
),
|
||||
);
|
||||
return reply.send({
|
||||
success: true,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
clearContainerMetricsCacheForTest,
|
||||
invalidateContainerMetricsCache,
|
||||
readContainerMetricsWithCache,
|
||||
} from "../../src/server/routes/containers";
|
||||
|
||||
const stats = {
|
||||
cpuPercent: 12.5,
|
||||
memoryPercent: 32.1,
|
||||
pids: 4,
|
||||
memory: {
|
||||
raw: "128MiB / 512MiB",
|
||||
used: "128MiB",
|
||||
limit: "512MiB",
|
||||
usedBytes: 134217728,
|
||||
limitBytes: 536870912,
|
||||
},
|
||||
network: {
|
||||
raw: "1kB / 2kB",
|
||||
read: "1kB",
|
||||
write: "2kB",
|
||||
totalBytes: null,
|
||||
readBytes: null,
|
||||
writeBytes: null,
|
||||
},
|
||||
io: {
|
||||
raw: "3kB / 4kB",
|
||||
read: "3kB",
|
||||
write: "4kB",
|
||||
totalBytes: null,
|
||||
readBytes: null,
|
||||
writeBytes: null,
|
||||
},
|
||||
};
|
||||
|
||||
function readMetrics(loadStats: () => Promise<typeof stats>) {
|
||||
return readContainerMetricsWithCache({
|
||||
organizationId: "org-1",
|
||||
serverId: "server-1",
|
||||
containerId: "container-1",
|
||||
containerName: "app",
|
||||
containerStatus: "RUNNING",
|
||||
dockerId: "abc123",
|
||||
runtimeId: "abc123",
|
||||
loadStats,
|
||||
});
|
||||
}
|
||||
|
||||
test("container metrics cache dedupes concurrent samples and reuses short-lived results", async () => {
|
||||
clearContainerMetricsCacheForTest();
|
||||
let loadCount = 0;
|
||||
let resolveStats: (value: typeof stats) => void = () => undefined;
|
||||
|
||||
const loadStats = async () => {
|
||||
loadCount += 1;
|
||||
return new Promise<typeof stats>((resolve) => {
|
||||
resolveStats = resolve;
|
||||
});
|
||||
};
|
||||
|
||||
const first = readMetrics(loadStats);
|
||||
const second = readMetrics(loadStats);
|
||||
|
||||
assert.equal(loadCount, 1);
|
||||
resolveStats(stats);
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([first, second]);
|
||||
|
||||
assert.deepEqual(firstResult.stats, stats);
|
||||
assert.deepEqual(secondResult.stats, stats);
|
||||
assert.equal(loadCount, 1);
|
||||
|
||||
const cachedResult = await readMetrics(loadStats);
|
||||
|
||||
assert.deepEqual(cachedResult.stats, stats);
|
||||
assert.equal(loadCount, 1);
|
||||
});
|
||||
|
||||
test("container metrics cache invalidation forces a fresh sample", async () => {
|
||||
clearContainerMetricsCacheForTest();
|
||||
let loadCount = 0;
|
||||
const loadStats = async () => {
|
||||
loadCount += 1;
|
||||
return stats;
|
||||
};
|
||||
|
||||
await readMetrics(loadStats);
|
||||
await readMetrics(loadStats);
|
||||
assert.equal(loadCount, 1);
|
||||
|
||||
invalidateContainerMetricsCache({
|
||||
organizationId: "org-1",
|
||||
serverId: "server-1",
|
||||
containerId: "container-1",
|
||||
});
|
||||
|
||||
await readMetrics(loadStats);
|
||||
assert.equal(loadCount, 2);
|
||||
});
|
||||
|
||||
test("container metrics cache does not retain failed samples", async () => {
|
||||
clearContainerMetricsCacheForTest();
|
||||
let loadCount = 0;
|
||||
const loadStats = async () => {
|
||||
loadCount += 1;
|
||||
if (loadCount === 1) {
|
||||
throw new Error("docker stats failed");
|
||||
}
|
||||
return stats;
|
||||
};
|
||||
|
||||
await assert.rejects(() => readMetrics(loadStats), /docker stats failed/);
|
||||
const result = await readMetrics(loadStats);
|
||||
|
||||
assert.deepEqual(result.stats, stats);
|
||||
assert.equal(loadCount, 2);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const CONTAINER_DETAIL_PAGE =
|
||||
"src/app/projects/[projectId]/environments/[environmentId]/containers/[containerId]/page.tsx";
|
||||
const CONTAINER_ROUTES = "src/server/routes/containers.ts";
|
||||
|
||||
function readSource(path: string) {
|
||||
return readFileSync(path, "utf8");
|
||||
}
|
||||
|
||||
function sourceBlock(source: string, startPattern: string, endPattern: string) {
|
||||
const start = source.indexOf(startPattern);
|
||||
assert.notEqual(start, -1, `Missing source block start: ${startPattern}`);
|
||||
|
||||
const end = source.indexOf(endPattern, start);
|
||||
assert.notEqual(end, -1, `Missing source block end: ${endPattern}`);
|
||||
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
test("container detail runtime polling uses the lightweight metrics API", () => {
|
||||
const source = readSource(CONTAINER_DETAIL_PAGE);
|
||||
const refreshBlock = sourceBlock(
|
||||
source,
|
||||
"const refreshRuntimeMetrics = useCallback",
|
||||
"const hydrateDomains = useCallback",
|
||||
);
|
||||
|
||||
assert.match(refreshBlock, /containersApi\.metrics\(params\.containerId\)/);
|
||||
assert.doesNotMatch(refreshBlock, /containersApi\.details/);
|
||||
});
|
||||
|
||||
test("metrics endpoint does not call heavy Docker detail commands", () => {
|
||||
const source = readSource(CONTAINER_ROUTES);
|
||||
const metricsRouteBlock = sourceBlock(
|
||||
source,
|
||||
'"/:id/metrics"',
|
||||
"GET /containers/:id/details",
|
||||
);
|
||||
|
||||
assert.match(metricsRouteBlock, /readContainerMetricsWithCache/);
|
||||
assert.match(metricsRouteBlock, /dockerStats/);
|
||||
assert.doesNotMatch(metricsRouteBlock, /dockerLogs/);
|
||||
assert.doesNotMatch(metricsRouteBlock, /dockerInspect/);
|
||||
assert.doesNotMatch(metricsRouteBlock, /dockerTop/);
|
||||
});
|
||||
|
||||
test("processes endpoint is isolated to Docker top", () => {
|
||||
const source = readSource(CONTAINER_ROUTES);
|
||||
const processesRouteBlock = sourceBlock(
|
||||
source,
|
||||
'"/:id/processes"',
|
||||
"POST /containers - deploy a new container",
|
||||
);
|
||||
|
||||
assert.match(processesRouteBlock, /dockerTop/);
|
||||
assert.doesNotMatch(processesRouteBlock, /dockerLogs/);
|
||||
assert.doesNotMatch(processesRouteBlock, /dockerInspect/);
|
||||
assert.doesNotMatch(processesRouteBlock, /dockerStats/);
|
||||
});
|
||||
|
||||
test("logs auto refresh keeps tab visibility and in-flight guards", () => {
|
||||
const source = readSource(CONTAINER_DETAIL_PAGE);
|
||||
const refreshLogsBlock = sourceBlock(
|
||||
source,
|
||||
"const refreshLogs = useCallback",
|
||||
"const pageHeaderActions = useMemo",
|
||||
);
|
||||
const autoRefreshBlock = sourceBlock(
|
||||
source,
|
||||
"if (!logsAutoRefresh) return",
|
||||
"return (",
|
||||
);
|
||||
|
||||
assert.match(refreshLogsBlock, /logsInFlightRef\.current/);
|
||||
assert.match(refreshLogsBlock, /LOGS_REFRESH_TAIL_LINES/);
|
||||
assert.match(autoRefreshBlock, /activeTab === "logs"/);
|
||||
assert.match(autoRefreshBlock, /!document\.hidden/);
|
||||
assert.match(autoRefreshBlock, /LOGS_REFRESH_INTERVAL_MS/);
|
||||
});
|
||||
Reference in new issue
Block a user