Centralize date/time formatting utilities (#11831)

This commit is contained in:
max
2026-02-08 04:53:31 -08:00
committed by GitHub
parent 74fbbda283
commit a1123dd9be
77 changed files with 1508 additions and 1075 deletions

View File

@@ -5,8 +5,8 @@ import { formatCliCommand } from "../../cli/command-format.js";
import { withProgress } from "../../cli/progress.js";
import { type OpenClawConfig, readConfigFileSnapshot } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
import { formatAge } from "../../infra/channel-summary.js";
import { collectChannelStatusIssues } from "../../infra/channels-status-issues.js";
import { formatTimeAgo } from "../../infra/format-time/format-relative.ts";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
@@ -48,10 +48,10 @@ export function formatGatewayChannelsStatusLines(payload: Record<string, unknown
? account.lastOutboundAt
: null;
if (inboundAt) {
bits.push(`in:${formatAge(Date.now() - inboundAt)}`);
bits.push(`in:${formatTimeAgo(Date.now() - inboundAt)}`);
}
if (outboundAt) {
bits.push(`out:${formatAge(Date.now() - outboundAt)}`);
bits.push(`out:${formatTimeAgo(Date.now() - outboundAt)}`);
}
if (typeof account.mode === "string" && account.mode.length > 0) {
bits.push(`mode:${account.mode}`);

View File

@@ -5,12 +5,8 @@
import type { SandboxBrowserInfo, SandboxContainerInfo } from "../agents/sandbox.js";
import type { RuntimeEnv } from "../runtime.js";
import { formatCliCommand } from "../cli/command-format.js";
import {
formatAge,
formatImageMatch,
formatSimpleStatus,
formatStatus,
} from "./sandbox-formatters.js";
import { formatDurationCompact } from "../infra/format-time/format-duration.ts";
import { formatImageMatch, formatSimpleStatus, formatStatus } from "./sandbox-formatters.js";
type DisplayConfig<T> = {
emptyMessage: string;
@@ -40,8 +36,12 @@ export function displayContainers(containers: SandboxContainerInfo[], runtime: R
rt.log(` ${container.containerName}`);
rt.log(` Status: ${formatStatus(container.running)}`);
rt.log(` Image: ${container.image} ${formatImageMatch(container.imageMatch)}`);
rt.log(` Age: ${formatAge(Date.now() - container.createdAtMs)}`);
rt.log(` Idle: ${formatAge(Date.now() - container.lastUsedAtMs)}`);
rt.log(
` Age: ${formatDurationCompact(Date.now() - container.createdAtMs, { spaced: true }) ?? "0s"}`,
);
rt.log(
` Idle: ${formatDurationCompact(Date.now() - container.lastUsedAtMs, { spaced: true }) ?? "0s"}`,
);
rt.log(` Session: ${container.sessionKey}`);
rt.log("");
},
@@ -64,8 +64,12 @@ export function displayBrowsers(browsers: SandboxBrowserInfo[], runtime: Runtime
if (browser.noVncPort) {
rt.log(` noVNC: ${browser.noVncPort}`);
}
rt.log(` Age: ${formatAge(Date.now() - browser.createdAtMs)}`);
rt.log(` Idle: ${formatAge(Date.now() - browser.lastUsedAtMs)}`);
rt.log(
` Age: ${formatDurationCompact(Date.now() - browser.createdAtMs, { spaced: true }) ?? "0s"}`,
);
rt.log(
` Idle: ${formatDurationCompact(Date.now() - browser.lastUsedAtMs, { spaced: true }) ?? "0s"}`,
);
rt.log(` Session: ${browser.sessionKey}`);
rt.log("");
},

View File

@@ -1,13 +1,16 @@
import { describe, expect, it } from "vitest";
import { formatDurationCompact } from "../infra/format-time/format-duration.js";
import {
countMismatches,
countRunning,
formatAge,
formatImageMatch,
formatSimpleStatus,
formatStatus,
} from "./sandbox-formatters.js";
/** Helper matching old formatAge behavior: spaced compound duration */
const formatAge = (ms: number) => formatDurationCompact(ms, { spaced: true }) ?? "0s";
describe("sandbox-formatters", () => {
describe("formatStatus", () => {
it("should format running status", () => {
@@ -47,21 +50,21 @@ describe("sandbox-formatters", () => {
it("should format minutes", () => {
expect(formatAge(60000)).toBe("1m");
expect(formatAge(90000)).toBe("1m");
expect(formatAge(90000)).toBe("1m 30s"); // 90 seconds = 1m 30s
expect(formatAge(300000)).toBe("5m");
});
it("should format hours and minutes", () => {
expect(formatAge(3600000)).toBe("1h 0m");
expect(formatAge(3600000)).toBe("1h");
expect(formatAge(3660000)).toBe("1h 1m");
expect(formatAge(7200000)).toBe("2h 0m");
expect(formatAge(7200000)).toBe("2h");
expect(formatAge(5400000)).toBe("1h 30m");
});
it("should format days and hours", () => {
expect(formatAge(86400000)).toBe("1d 0h");
expect(formatAge(86400000)).toBe("1d");
expect(formatAge(90000000)).toBe("1d 1h");
expect(formatAge(172800000)).toBe("2d 0h");
expect(formatAge(172800000)).toBe("2d");
expect(formatAge(183600000)).toBe("2d 3h");
});
@@ -70,9 +73,9 @@ describe("sandbox-formatters", () => {
});
it("should handle edge cases", () => {
expect(formatAge(59999)).toBe("59s"); // Just under 1 minute
expect(formatAge(3599999)).toBe("59m"); // Just under 1 hour
expect(formatAge(86399999)).toBe("23h 59m"); // Just under 1 day
expect(formatAge(59999)).toBe("1m"); // Rounds to 1 minute exactly
expect(formatAge(3599999)).toBe("1h"); // Rounds to 1 hour exactly
expect(formatAge(86399999)).toBe("1d"); // Rounds to 1 day exactly
});
});

View File

@@ -14,24 +14,6 @@ export function formatImageMatch(matches: boolean): string {
return matches ? "✓" : "⚠️ mismatch";
}
export function formatAge(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {
return `${days}d ${hours % 24}h`;
}
if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
}
if (minutes > 0) {
return `${minutes}m`;
}
return `${seconds}s`;
}
/**
* Type guard and counter utilities
*/

View File

@@ -5,6 +5,7 @@ import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import { loadConfig } from "../config/config.js";
import { loadSessionStore, resolveStorePath, type SessionEntry } from "../config/sessions.js";
import { info } from "../globals.js";
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
import { isRich, theme } from "../terminal/theme.js";
type SessionRow = {
@@ -90,7 +91,7 @@ const formatKindCell = (kind: SessionRow["kind"], rich: boolean) => {
};
const formatAgeCell = (updatedAt: number | null | undefined, rich: boolean) => {
const ageLabel = updatedAt ? formatAge(Date.now() - updatedAt) : "unknown";
const ageLabel = updatedAt ? formatTimeAgo(Date.now() - updatedAt) : "unknown";
const padded = ageLabel.padEnd(AGE_PAD);
return rich ? theme.muted(padded) : padded;
};
@@ -116,25 +117,6 @@ const formatFlagsCell = (row: SessionRow, rich: boolean) => {
return label.length === 0 ? "" : rich ? theme.muted(label) : label;
};
const formatAge = (ms: number | null | undefined) => {
if (!ms || ms < 0) {
return "unknown";
}
const minutes = Math.round(ms / 60_000);
if (minutes < 1) {
return "just now";
}
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.round(minutes / 60);
if (hours < 48) {
return `${hours}h ago`;
}
const days = Math.round(hours / 24);
return `${days}d ago`;
};
function classifyKey(key: string, entry?: SessionEntry): SessionRow["kind"] {
if (key === "global") {
return "global";

View File

@@ -28,7 +28,7 @@ import { VERSION } from "../version.js";
import { resolveControlUiLinks } from "./onboard-helpers.js";
import { getAgentLocalStatuses } from "./status-all/agents.js";
import { buildChannelsTable } from "./status-all/channels.js";
import { formatDuration, formatGatewayAuthUsed } from "./status-all/format.js";
import { formatDurationPrecise, formatGatewayAuthUsed } from "./status-all/format.js";
import { pickGatewaySelfPresence } from "./status-all/gateway.js";
import { buildStatusAllReportLines } from "./status-all/report-lines.js";
@@ -354,7 +354,7 @@ export async function statusAllCommand(
const gatewayTarget = remoteUrlMissing ? `fallback ${connection.url}` : connection.url;
const gatewayStatus = gatewayReachable
? `reachable ${formatDuration(gatewayProbe?.connectLatencyMs)}`
? `reachable ${formatDurationPrecise(gatewayProbe?.connectLatencyMs ?? 0)}`
: gatewayProbe?.error
? `unreachable (${gatewayProbe.error})`
: "unreachable";

View File

@@ -8,7 +8,7 @@ import type { OpenClawConfig } from "../../config/config.js";
import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js";
import { listChannelPlugins } from "../../channels/plugins/index.js";
import { sha256HexPrefix } from "../../logging/redact-identifier.js";
import { formatAge } from "./format.js";
import { formatTimeAgo } from "./format.js";
export type ChannelRow = {
id: ChannelId;
@@ -436,7 +436,7 @@ export async function buildChannelsTable(
extra.push(link.selfE164);
}
if (link.linked && link.authAgeMs != null && link.authAgeMs >= 0) {
extra.push(`auth ${formatAge(link.authAgeMs)}`);
extra.push(`auth ${formatTimeAgo(link.authAgeMs)}`);
}
if (accounts.length > 1 || plugin.meta.forceAccountBinding) {
extra.push(`accounts ${accounts.length || 1}`);

View File

@@ -5,7 +5,7 @@ import {
type RestartSentinelPayload,
summarizeRestartSentinel,
} from "../../infra/restart-sentinel.js";
import { formatAge, redactSecrets } from "./format.js";
import { formatTimeAgo, redactSecrets } from "./format.js";
import { readFileTailLines, summarizeLogTail } from "./gateway.js";
type ConfigIssueLike = { path: string; message: string };
@@ -106,7 +106,7 @@ export async function appendStatusAllDiagnosis(params: {
if (params.sentinel?.payload) {
emitCheck("Restart sentinel present", "warn");
lines.push(
` ${muted(`${summarizeRestartSentinel(params.sentinel.payload)} · ${formatAge(Date.now() - params.sentinel.payload.ts)}`)}`,
` ${muted(`${summarizeRestartSentinel(params.sentinel.payload)} · ${formatTimeAgo(Date.now() - params.sentinel.payload.ts)}`)}`,
);
} else {
emitCheck("Restart sentinel: none", "ok");

View File

@@ -1,31 +1,5 @@
export const formatAge = (ms: number | null | undefined) => {
if (!ms || ms < 0) {
return "unknown";
}
const minutes = Math.round(ms / 60_000);
if (minutes < 1) {
return "just now";
}
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.round(minutes / 60);
if (hours < 48) {
return `${hours}h ago`;
}
const days = Math.round(hours / 24);
return `${days}d ago`;
};
export const formatDuration = (ms: number | null | undefined) => {
if (ms == null || !Number.isFinite(ms)) {
return "unknown";
}
if (ms < 1000) {
return `${Math.round(ms)}ms`;
}
return `${(ms / 1000).toFixed(1)}s`;
};
export { formatTimeAgo } from "../../infra/format-time/format-relative.ts";
export { formatDurationPrecise } from "../../infra/format-time/format-duration.ts";
export function formatGatewayAuthUsed(
auth: {

View File

@@ -2,7 +2,7 @@ import type { ProgressReporter } from "../../cli/progress.js";
import { renderTable } from "../../terminal/table.js";
import { isRich, theme } from "../../terminal/theme.js";
import { appendStatusAllDiagnosis } from "./diagnosis.js";
import { formatAge } from "./format.js";
import { formatTimeAgo } from "./format.js";
type OverviewRow = { Item: string; Value: string };
@@ -128,7 +128,7 @@ export async function buildStatusAllReportLines(params: {
? ok("OK")
: "unknown",
Sessions: String(a.sessionsCount),
Active: a.lastActiveAgeMs != null ? formatAge(a.lastActiveAgeMs) : "unknown",
Active: a.lastActiveAgeMs != null ? formatTimeAgo(a.lastActiveAgeMs) : "unknown",
Store: a.sessionsPath,
}));

View File

@@ -5,6 +5,7 @@ import { withProgress } from "../cli/progress.js";
import { resolveGatewayPort } from "../config/config.js";
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
import { info } from "../globals.js";
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
import { formatUsageReportLines, loadProviderUsageSummary } from "../infra/provider-usage.js";
import {
formatUpdateChannelLabel,
@@ -26,7 +27,6 @@ import { statusAllCommand } from "./status-all.js";
import { formatGatewayAuthUsed } from "./status-all/format.js";
import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js";
import {
formatAge,
formatDuration,
formatKTokens,
formatTokensCompact,
@@ -239,7 +239,7 @@ export async function statusCommand(
? `${agentStatus.bootstrapPendingCount} bootstrapping`
: "no bootstraps";
const def = agentStatus.agents.find((a) => a.id === agentStatus.defaultId);
const defActive = def?.lastActiveAgeMs != null ? formatAge(def.lastActiveAgeMs) : "unknown";
const defActive = def?.lastActiveAgeMs != null ? formatTimeAgo(def.lastActiveAgeMs) : "unknown";
const defSuffix = def ? ` · default ${def.id} active ${defActive}` : "";
return `${agentStatus.agents.length} · ${pending} · sessions ${agentStatus.totalSessions}${defSuffix}`;
})();
@@ -294,7 +294,7 @@ export async function statusCommand(
if (!lastHeartbeat) {
return muted("none");
}
const age = formatAge(Date.now() - lastHeartbeat.ts);
const age = formatTimeAgo(Date.now() - lastHeartbeat.ts);
const channel = lastHeartbeat.channel ?? "unknown";
const accountLabel = lastHeartbeat.accountId ? `account ${lastHeartbeat.accountId}` : null;
return [lastHeartbeat.status, `${age} ago`, channel, accountLabel].filter(Boolean).join(" · ");
@@ -527,7 +527,7 @@ export async function statusCommand(
? summary.sessions.recent.map((sess) => ({
Key: shortenText(sess.key, 32),
Kind: sess.kind,
Age: sess.updatedAt ? formatAge(sess.age) : "no activity",
Age: sess.updatedAt ? formatTimeAgo(sess.age) : "no activity",
Model: sess.model ?? "unknown",
Tokens: formatTokensCompact(sess),
}))

View File

@@ -1,35 +1,14 @@
import type { SessionStatus } from "./status.types.js";
import { formatDurationPrecise } from "../infra/format-time/format-duration.ts";
export const formatKTokens = (value: number) =>
`${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`;
export const formatAge = (ms: number | null | undefined) => {
if (!ms || ms < 0) {
return "unknown";
}
const minutes = Math.round(ms / 60_000);
if (minutes < 1) {
return "just now";
}
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.round(minutes / 60);
if (hours < 48) {
return `${hours}h ago`;
}
const days = Math.round(hours / 24);
return `${days}d ago`;
};
export const formatDuration = (ms: number | null | undefined) => {
if (ms == null || !Number.isFinite(ms)) {
return "unknown";
}
if (ms < 1000) {
return `${Math.round(ms)}ms`;
}
return `${(ms / 1000).toFixed(1)}s`;
return formatDurationPrecise(ms, { decimals: 1 });
};
export const shortenText = (value: string, maxLen: number) => {