refactor: improve sandbox commands code structure
Improvements: - Extract validation into separate function - Split display logic from business logic - Create reusable container matcher for agent filtering - Abstract status/image formatting into helpers - Reduce code duplication between containers and browsers - Extract container removal into generic function - Add type safety with FilteredContainers type - Improve readability with smaller, focused functions Changes: - validateRecreateOptions(): Validate mutual exclusivity - fetchAndFilterContainers(): Fetch + filter in one place - createAgentMatcher(): Reusable agent filter predicate - displayContainers/Browsers(): Dedicated display functions - displaySummary/RecreatePreview/Result(): Clear separation - removeContainer(): Generic removal with error handling - Format helpers: formatStatus, formatImageMatch, etc. - Count helpers: countRunning, countMismatches Result: 85 more lines but much better maintainability and testability.
This commit is contained in:
committed by
Peter Steinberger
parent
bcd52ee546
commit
00b77421dd
@@ -1,21 +1,39 @@
|
|||||||
import { confirm as clackConfirm } from "@clack/prompts";
|
import { confirm as clackConfirm } from "@clack/prompts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
type SandboxBrowserInfo,
|
||||||
|
type SandboxContainerInfo,
|
||||||
listSandboxBrowsers,
|
listSandboxBrowsers,
|
||||||
listSandboxContainers,
|
listSandboxContainers,
|
||||||
removeSandboxBrowserContainer,
|
removeSandboxBrowserContainer,
|
||||||
removeSandboxContainer,
|
removeSandboxContainer,
|
||||||
} from "../agents/sandbox.js";
|
} from "../agents/sandbox.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { stylePromptTitle } from "../terminal/prompt-style.js";
|
|
||||||
|
|
||||||
// --- List Command ---
|
// --- Types ---
|
||||||
|
|
||||||
type SandboxListOptions = {
|
type SandboxListOptions = {
|
||||||
browser: boolean;
|
browser: boolean;
|
||||||
json: boolean;
|
json: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SandboxRecreateOptions = {
|
||||||
|
all: boolean;
|
||||||
|
session?: string;
|
||||||
|
agent?: string;
|
||||||
|
browser: boolean;
|
||||||
|
force: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ContainerItem = SandboxContainerInfo | SandboxBrowserInfo;
|
||||||
|
|
||||||
|
type FilteredContainers = {
|
||||||
|
containers: SandboxContainerInfo[];
|
||||||
|
browsers: SandboxBrowserInfo[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- List Command ---
|
||||||
|
|
||||||
export async function sandboxListCommand(
|
export async function sandboxListCommand(
|
||||||
opts: SandboxListOptions,
|
opts: SandboxListOptions,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
@@ -28,74 +46,157 @@ export async function sandboxListCommand(
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
runtime.log(
|
runtime.log(JSON.stringify({ containers, browsers }, null, 2));
|
||||||
JSON.stringify(
|
|
||||||
{ containers, browsers },
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.browser) {
|
if (opts.browser) {
|
||||||
if (browsers.length === 0) {
|
displayBrowsers(browsers, runtime);
|
||||||
runtime.log("No sandbox browser containers found.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
runtime.log("\n🌐 Sandbox Browser Containers:\n");
|
|
||||||
for (const browser of browsers) {
|
|
||||||
const status = browser.running ? "🟢 running" : "⚫ stopped";
|
|
||||||
const imageStatus = browser.imageMatch ? "✓" : "⚠️ mismatch";
|
|
||||||
const age = formatAge(Date.now() - browser.createdAtMs);
|
|
||||||
const idle = formatAge(Date.now() - browser.lastUsedAtMs);
|
|
||||||
|
|
||||||
runtime.log(` ${browser.containerName}`);
|
|
||||||
runtime.log(` Status: ${status}`);
|
|
||||||
runtime.log(` Image: ${browser.image} ${imageStatus}`);
|
|
||||||
runtime.log(` CDP: ${browser.cdpPort}`);
|
|
||||||
if (browser.noVncPort) {
|
|
||||||
runtime.log(` noVNC: ${browser.noVncPort}`);
|
|
||||||
}
|
|
||||||
runtime.log(` Age: ${age}`);
|
|
||||||
runtime.log(` Idle: ${idle}`);
|
|
||||||
runtime.log(` Session: ${browser.sessionKey}`);
|
|
||||||
runtime.log("");
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (containers.length === 0) {
|
displayContainers(containers, runtime);
|
||||||
runtime.log("No sandbox containers found.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
runtime.log("\n📦 Sandbox Containers:\n");
|
|
||||||
for (const container of containers) {
|
|
||||||
const status = container.running ? "🟢 running" : "⚫ stopped";
|
|
||||||
const imageStatus = container.imageMatch ? "✓" : "⚠️ mismatch";
|
|
||||||
const age = formatAge(Date.now() - container.createdAtMs);
|
|
||||||
const idle = formatAge(Date.now() - container.lastUsedAtMs);
|
|
||||||
|
|
||||||
runtime.log(` ${container.containerName}`);
|
|
||||||
runtime.log(` Status: ${status}`);
|
|
||||||
runtime.log(` Image: ${container.image} ${imageStatus}`);
|
|
||||||
runtime.log(` Age: ${age}`);
|
|
||||||
runtime.log(` Idle: ${idle}`);
|
|
||||||
runtime.log(` Session: ${container.sessionKey}`);
|
|
||||||
runtime.log("");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary
|
displaySummary(containers, browsers, runtime);
|
||||||
const totalContainers = containers.length + browsers.length;
|
}
|
||||||
const runningCount =
|
|
||||||
containers.filter((c) => c.running).length +
|
// --- Recreate Command ---
|
||||||
browsers.filter((b) => b.running).length;
|
|
||||||
const mismatchCount =
|
export async function sandboxRecreateCommand(
|
||||||
containers.filter((c) => !c.imageMatch).length +
|
opts: SandboxRecreateOptions,
|
||||||
browsers.filter((b) => !b.imageMatch).length;
|
runtime: RuntimeEnv,
|
||||||
|
): Promise<void> {
|
||||||
|
validateRecreateOptions(opts, runtime);
|
||||||
|
|
||||||
|
const filtered = await fetchAndFilterContainers(opts);
|
||||||
|
|
||||||
|
if (filtered.containers.length + filtered.browsers.length === 0) {
|
||||||
|
runtime.log("No containers found matching the criteria.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
displayRecreatePreview(filtered, runtime);
|
||||||
|
|
||||||
|
if (!opts.force && !(await confirmRecreate())) {
|
||||||
|
runtime.log("Cancelled.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await removeContainers(filtered, runtime);
|
||||||
|
displayRecreateResult(result, runtime);
|
||||||
|
|
||||||
|
if (result.failCount > 0) {
|
||||||
|
runtime.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Validation ---
|
||||||
|
|
||||||
|
function validateRecreateOptions(
|
||||||
|
opts: SandboxRecreateOptions,
|
||||||
|
runtime: RuntimeEnv,
|
||||||
|
): void {
|
||||||
|
if (!opts.all && !opts.session && !opts.agent) {
|
||||||
|
runtime.error("Please specify --all, --session <key>, or --agent <id>");
|
||||||
|
runtime.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exclusiveCount = [opts.all, opts.session, opts.agent].filter(Boolean)
|
||||||
|
.length;
|
||||||
|
if (exclusiveCount > 1) {
|
||||||
|
runtime.error("Please specify only one of: --all, --session, --agent");
|
||||||
|
runtime.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Filtering ---
|
||||||
|
|
||||||
|
async function fetchAndFilterContainers(
|
||||||
|
opts: SandboxRecreateOptions,
|
||||||
|
): Promise<FilteredContainers> {
|
||||||
|
const allContainers = await listSandboxContainers().catch(() => []);
|
||||||
|
const allBrowsers = await listSandboxBrowsers().catch(() => []);
|
||||||
|
|
||||||
|
let containers = opts.browser ? [] : allContainers;
|
||||||
|
let browsers = opts.browser ? allBrowsers : [];
|
||||||
|
|
||||||
|
if (opts.session) {
|
||||||
|
containers = containers.filter((c) => c.sessionKey === opts.session);
|
||||||
|
browsers = browsers.filter((b) => b.sessionKey === opts.session);
|
||||||
|
} else if (opts.agent) {
|
||||||
|
const matchesAgent = createAgentMatcher(opts.agent);
|
||||||
|
containers = containers.filter(matchesAgent);
|
||||||
|
browsers = browsers.filter(matchesAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { containers, browsers };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAgentMatcher(agentId: string) {
|
||||||
|
const agentPrefix = `agent:${agentId}`;
|
||||||
|
return (item: ContainerItem) =>
|
||||||
|
item.sessionKey === agentPrefix ||
|
||||||
|
item.sessionKey.startsWith(`${agentPrefix}:`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Display Functions ---
|
||||||
|
|
||||||
|
function displayContainers(
|
||||||
|
containers: SandboxContainerInfo[],
|
||||||
|
runtime: RuntimeEnv,
|
||||||
|
): void {
|
||||||
|
if (containers.length === 0) {
|
||||||
|
runtime.log("No sandbox containers found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.log("\n📦 Sandbox Containers:\n");
|
||||||
|
for (const container of containers) {
|
||||||
|
runtime.log(` ${container.containerName}`);
|
||||||
|
runtime.log(` Status: ${formatStatus(container.running)}`);
|
||||||
|
runtime.log(` Image: ${container.image} ${formatImageMatch(container.imageMatch)}`);
|
||||||
|
runtime.log(` Age: ${formatAge(Date.now() - container.createdAtMs)}`);
|
||||||
|
runtime.log(` Idle: ${formatAge(Date.now() - container.lastUsedAtMs)}`);
|
||||||
|
runtime.log(` Session: ${container.sessionKey}`);
|
||||||
|
runtime.log("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayBrowsers(
|
||||||
|
browsers: SandboxBrowserInfo[],
|
||||||
|
runtime: RuntimeEnv,
|
||||||
|
): void {
|
||||||
|
if (browsers.length === 0) {
|
||||||
|
runtime.log("No sandbox browser containers found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.log("\n🌐 Sandbox Browser Containers:\n");
|
||||||
|
for (const browser of browsers) {
|
||||||
|
runtime.log(` ${browser.containerName}`);
|
||||||
|
runtime.log(` Status: ${formatStatus(browser.running)}`);
|
||||||
|
runtime.log(` Image: ${browser.image} ${formatImageMatch(browser.imageMatch)}`);
|
||||||
|
runtime.log(` CDP: ${browser.cdpPort}`);
|
||||||
|
if (browser.noVncPort) {
|
||||||
|
runtime.log(` noVNC: ${browser.noVncPort}`);
|
||||||
|
}
|
||||||
|
runtime.log(` Age: ${formatAge(Date.now() - browser.createdAtMs)}`);
|
||||||
|
runtime.log(` Idle: ${formatAge(Date.now() - browser.lastUsedAtMs)}`);
|
||||||
|
runtime.log(` Session: ${browser.sessionKey}`);
|
||||||
|
runtime.log("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displaySummary(
|
||||||
|
containers: SandboxContainerInfo[],
|
||||||
|
browsers: SandboxBrowserInfo[],
|
||||||
|
runtime: RuntimeEnv,
|
||||||
|
): void {
|
||||||
|
const totalCount = containers.length + browsers.length;
|
||||||
|
const runningCount = countRunning(containers) + countRunning(browsers);
|
||||||
|
const mismatchCount = countMismatches(containers) + countMismatches(browsers);
|
||||||
|
|
||||||
|
runtime.log(`Total: ${totalCount} (${runningCount} running)`);
|
||||||
|
|
||||||
runtime.log(`Total: ${totalContainers} (${runningCount} running)`);
|
|
||||||
if (mismatchCount > 0) {
|
if (mismatchCount > 0) {
|
||||||
runtime.log(
|
runtime.log(
|
||||||
`\n⚠️ ${mismatchCount} container(s) with image mismatch detected.`,
|
`\n⚠️ ${mismatchCount} container(s) with image mismatch detected.`,
|
||||||
@@ -106,152 +207,126 @@ export async function sandboxListCommand(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Recreate Command ---
|
function displayRecreatePreview(
|
||||||
|
filtered: FilteredContainers,
|
||||||
type SandboxRecreateOptions = {
|
|
||||||
all: boolean;
|
|
||||||
session?: string;
|
|
||||||
agent?: string;
|
|
||||||
browser: boolean;
|
|
||||||
force: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function sandboxRecreateCommand(
|
|
||||||
opts: SandboxRecreateOptions,
|
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
): Promise<void> {
|
): void {
|
||||||
// Validation
|
|
||||||
if (!opts.all && !opts.session && !opts.agent) {
|
|
||||||
runtime.error(
|
|
||||||
"Please specify --all, --session <key>, or --agent <id>",
|
|
||||||
);
|
|
||||||
runtime.exit(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
(opts.all && opts.session) ||
|
|
||||||
(opts.all && opts.agent) ||
|
|
||||||
(opts.session && opts.agent)
|
|
||||||
) {
|
|
||||||
runtime.error("Please specify only one of: --all, --session, --agent");
|
|
||||||
runtime.exit(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch containers
|
|
||||||
const allContainers = await listSandboxContainers().catch(() => []);
|
|
||||||
const allBrowsers = await listSandboxBrowsers().catch(() => []);
|
|
||||||
|
|
||||||
// Filter based on options
|
|
||||||
let containersToRemove = opts.browser ? [] : allContainers;
|
|
||||||
let browsersToRemove = opts.browser ? allBrowsers : [];
|
|
||||||
|
|
||||||
if (opts.session) {
|
|
||||||
containersToRemove = containersToRemove.filter(
|
|
||||||
(c) => c.sessionKey === opts.session,
|
|
||||||
);
|
|
||||||
browsersToRemove = browsersToRemove.filter(
|
|
||||||
(b) => b.sessionKey === opts.session,
|
|
||||||
);
|
|
||||||
} else if (opts.agent) {
|
|
||||||
const agentPrefix = `agent:${opts.agent}`;
|
|
||||||
containersToRemove = containersToRemove.filter(
|
|
||||||
(c) => c.sessionKey === agentPrefix || c.sessionKey.startsWith(`${agentPrefix}:`),
|
|
||||||
);
|
|
||||||
browsersToRemove = browsersToRemove.filter(
|
|
||||||
(b) => b.sessionKey === agentPrefix || b.sessionKey.startsWith(`${agentPrefix}:`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalToRemove = containersToRemove.length + browsersToRemove.length;
|
|
||||||
|
|
||||||
if (totalToRemove === 0) {
|
|
||||||
runtime.log("No containers found matching the criteria.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show what will be removed
|
|
||||||
runtime.log("\nContainers to be recreated:\n");
|
runtime.log("\nContainers to be recreated:\n");
|
||||||
|
|
||||||
if (containersToRemove.length > 0) {
|
if (filtered.containers.length > 0) {
|
||||||
runtime.log("📦 Sandbox Containers:");
|
runtime.log("📦 Sandbox Containers:");
|
||||||
for (const container of containersToRemove) {
|
for (const container of filtered.containers) {
|
||||||
const status = container.running ? "running" : "stopped";
|
runtime.log(
|
||||||
runtime.log(` - ${container.containerName} (${status})`);
|
` - ${container.containerName} (${formatSimpleStatus(container.running)})`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (browsersToRemove.length > 0) {
|
if (filtered.browsers.length > 0) {
|
||||||
runtime.log("\n🌐 Browser Containers:");
|
runtime.log("\n🌐 Browser Containers:");
|
||||||
for (const browser of browsersToRemove) {
|
for (const browser of filtered.browsers) {
|
||||||
const status = browser.running ? "running" : "stopped";
|
runtime.log(
|
||||||
runtime.log(` - ${browser.containerName} (${status})`);
|
` - ${browser.containerName} (${formatSimpleStatus(browser.running)})`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime.log(`\nTotal: ${totalToRemove} container(s)`);
|
const total = filtered.containers.length + filtered.browsers.length;
|
||||||
|
runtime.log(`\nTotal: ${total} container(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
// Confirmation
|
function displayRecreateResult(
|
||||||
if (!opts.force) {
|
result: { successCount: number; failCount: number },
|
||||||
const shouldContinue = await clackConfirm({
|
runtime: RuntimeEnv,
|
||||||
message: "This will stop and remove these containers. Continue?",
|
): void {
|
||||||
initialValue: false,
|
runtime.log(
|
||||||
});
|
`\nDone: ${result.successCount} removed, ${result.failCount} failed`,
|
||||||
|
);
|
||||||
|
|
||||||
if (!shouldContinue || shouldContinue === Symbol.for("clack:cancel")) {
|
if (result.successCount > 0) {
|
||||||
runtime.log("Cancelled.");
|
runtime.log(
|
||||||
return;
|
"\nContainers will be automatically recreated when the agent is next used.",
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Remove containers
|
// --- Container Operations ---
|
||||||
|
|
||||||
|
async function confirmRecreate(): Promise<boolean> {
|
||||||
|
const result = await clackConfirm({
|
||||||
|
message: "This will stop and remove these containers. Continue?",
|
||||||
|
initialValue: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result !== false && result !== Symbol.for("clack:cancel");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeContainers(
|
||||||
|
filtered: FilteredContainers,
|
||||||
|
runtime: RuntimeEnv,
|
||||||
|
): Promise<{ successCount: number; failCount: number }> {
|
||||||
runtime.log("\nRemoving containers...\n");
|
runtime.log("\nRemoving containers...\n");
|
||||||
|
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let failCount = 0;
|
let failCount = 0;
|
||||||
|
|
||||||
for (const container of containersToRemove) {
|
for (const container of filtered.containers) {
|
||||||
try {
|
const result = await removeContainer(
|
||||||
await removeSandboxContainer(container.containerName);
|
container.containerName,
|
||||||
runtime.log(`✓ Removed ${container.containerName}`);
|
removeSandboxContainer,
|
||||||
successCount++;
|
runtime,
|
||||||
} catch (err) {
|
|
||||||
runtime.error(
|
|
||||||
`✗ Failed to remove ${container.containerName}: ${String(err)}`,
|
|
||||||
);
|
|
||||||
failCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const browser of browsersToRemove) {
|
|
||||||
try {
|
|
||||||
await removeSandboxBrowserContainer(browser.containerName);
|
|
||||||
runtime.log(`✓ Removed ${browser.containerName}`);
|
|
||||||
successCount++;
|
|
||||||
} catch (err) {
|
|
||||||
runtime.error(
|
|
||||||
`✗ Failed to remove ${browser.containerName}: ${String(err)}`,
|
|
||||||
);
|
|
||||||
failCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Summary
|
|
||||||
runtime.log(`\nDone: ${successCount} removed, ${failCount} failed`);
|
|
||||||
|
|
||||||
if (successCount > 0) {
|
|
||||||
runtime.log(
|
|
||||||
"\nContainers will be automatically recreated when the agent is next used.",
|
|
||||||
);
|
);
|
||||||
|
if (result.success) {
|
||||||
|
successCount++;
|
||||||
|
} else {
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (failCount > 0) {
|
for (const browser of filtered.browsers) {
|
||||||
runtime.exit(1);
|
const result = await removeContainer(
|
||||||
|
browser.containerName,
|
||||||
|
removeSandboxBrowserContainer,
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
if (result.success) {
|
||||||
|
successCount++;
|
||||||
|
} else {
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { successCount, failCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeContainer(
|
||||||
|
containerName: string,
|
||||||
|
removeFn: (name: string) => Promise<void>,
|
||||||
|
runtime: RuntimeEnv,
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
try {
|
||||||
|
await removeFn(containerName);
|
||||||
|
runtime.log(`✓ Removed ${containerName}`);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
runtime.error(`✗ Failed to remove ${containerName}: ${String(err)}`);
|
||||||
|
return { success: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Formatting Helpers ---
|
||||||
|
|
||||||
|
function formatStatus(running: boolean): string {
|
||||||
|
return running ? "🟢 running" : "⚫ stopped";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSimpleStatus(running: boolean): string {
|
||||||
|
return running ? "running" : "stopped";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatImageMatch(matches: boolean): string {
|
||||||
|
return matches ? "✓" : "⚠️ mismatch";
|
||||||
|
}
|
||||||
|
|
||||||
function formatAge(ms: number): string {
|
function formatAge(ms: number): string {
|
||||||
const seconds = Math.floor(ms / 1000);
|
const seconds = Math.floor(ms / 1000);
|
||||||
@@ -264,3 +339,13 @@ function formatAge(ms: number): string {
|
|||||||
if (minutes > 0) return `${minutes}m`;
|
if (minutes > 0) return `${minutes}m`;
|
||||||
return `${seconds}s`;
|
return `${seconds}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Counting Helpers ---
|
||||||
|
|
||||||
|
function countRunning(items: ContainerItem[]): number {
|
||||||
|
return items.filter((item) => item.running).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countMismatches(items: ContainerItem[]): number {
|
||||||
|
return items.filter((item) => !item.imageMatch).length;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user