mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
centralize date formatters
This commit is contained in:
3
.github/instructions/copilot.instructions.md
vendored
Normal file
3
.github/instructions/copilot.instructions.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
Avoid a file that just re-exports from another file. Instead, import directly from the original file. This helps to reduce unnecessary indirection and makes it easier to understand where the code is coming from.
|
||||
|
||||
Avoid redundancies and inconsistencies. If a function already exists in one file, do not create a new function that does the same thing in another file. Instead, import the existing function and use it. This helps to keep the codebase clean and maintainable.
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
markExited,
|
||||
setJobTtlMs,
|
||||
} from "./bash-process-registry.js";
|
||||
import { formatDurationCompact } from "../infra/format-duration.ts";
|
||||
import {
|
||||
deriveSessionName,
|
||||
formatDuration,
|
||||
killSession,
|
||||
pad,
|
||||
sliceLogLines,
|
||||
@@ -118,7 +118,7 @@ export function createProcessTool(
|
||||
.toSorted((a, b) => b.startedAt - a.startedAt)
|
||||
.map((s) => {
|
||||
const label = s.name ? truncateMiddle(s.name, 80) : truncateMiddle(s.command, 120);
|
||||
return `${s.sessionId} ${pad(s.status, 9)} ${formatDuration(s.runtimeMs)} :: ${label}`;
|
||||
return `${s.sessionId} ${pad(s.status, 9)} ${formatDurationCompact(s.runtimeMs) ?? "n/a"} :: ${label}`;
|
||||
});
|
||||
return {
|
||||
content: [
|
||||
|
||||
@@ -244,19 +244,6 @@ function stripQuotes(value: string): string {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number) {
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`;
|
||||
}
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const rem = seconds % 60;
|
||||
return `${minutes}m${rem.toString().padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
export function pad(str: string, width: number) {
|
||||
if (str.length >= width) {
|
||||
return str;
|
||||
|
||||
@@ -22,26 +22,10 @@ import {
|
||||
queueEmbeddedPiMessage,
|
||||
waitForEmbeddedPiRunEnd,
|
||||
} from "./pi-embedded.js";
|
||||
import { formatDurationCompact } from "../infra/format-duration.ts";
|
||||
import { type AnnounceQueueItem, enqueueAnnounce } from "./subagent-announce-queue.js";
|
||||
import { readLatestAssistantReply } from "./tools/agent-step.js";
|
||||
|
||||
function formatDurationShort(valueMs?: number) {
|
||||
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const totalSeconds = Math.round(valueMs / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (hours > 0) {
|
||||
return `${hours}h${minutes}m`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m${seconds}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
function formatTokenCount(value?: number) {
|
||||
if (!value || !Number.isFinite(value)) {
|
||||
return "0";
|
||||
@@ -267,7 +251,7 @@ async function buildSubagentStatsLine(params: {
|
||||
: undefined;
|
||||
|
||||
const parts: string[] = [];
|
||||
const runtime = formatDurationShort(runtimeMs);
|
||||
const runtime = formatDurationCompact(runtimeMs);
|
||||
parts.push(`runtime ${runtime ?? "n/a"}`);
|
||||
if (typeof total === "number") {
|
||||
const inputText = typeof input === "number" ? formatTokenCount(input) : "n/a";
|
||||
|
||||
@@ -2,6 +2,12 @@ import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveUserTimezone } from "../agents/date-time.js";
|
||||
import { normalizeChatType } from "../channels/chat-type.js";
|
||||
import { resolveSenderLabel, type SenderLabelParams } from "../channels/sender-label.js";
|
||||
import {
|
||||
resolveTimezone,
|
||||
formatUtcTimestamp,
|
||||
formatZonedTimestamp,
|
||||
} from "../infra/format-datetime.ts";
|
||||
import { formatTimeAgo } from "../infra/format-relative.ts";
|
||||
|
||||
export type AgentEnvelopeParams = {
|
||||
channel: string;
|
||||
@@ -66,15 +72,6 @@ function normalizeEnvelopeOptions(options?: EnvelopeFormatOptions): NormalizedEn
|
||||
};
|
||||
}
|
||||
|
||||
function resolveExplicitTimezone(value: string): string | undefined {
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date());
|
||||
return value;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEnvelopeTimezone {
|
||||
const trimmed = options.timezone?.trim();
|
||||
if (!trimmed) {
|
||||
@@ -90,46 +87,10 @@ function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEn
|
||||
if (lowered === "user") {
|
||||
return { mode: "iana", timeZone: resolveUserTimezone(options.userTimezone) };
|
||||
}
|
||||
const explicit = resolveExplicitTimezone(trimmed);
|
||||
const explicit = resolveTimezone(trimmed);
|
||||
return explicit ? { mode: "iana", timeZone: explicit } : { mode: "utc" };
|
||||
}
|
||||
|
||||
function formatUtcTimestamp(date: Date): string {
|
||||
const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
|
||||
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(date.getUTCDate()).padStart(2, "0");
|
||||
const hh = String(date.getUTCHours()).padStart(2, "0");
|
||||
const min = String(date.getUTCMinutes()).padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd}T${hh}:${min}Z`;
|
||||
}
|
||||
|
||||
export function formatZonedTimestamp(date: Date, timeZone?: string): string | undefined {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
timeZoneName: "short",
|
||||
}).formatToParts(date);
|
||||
const pick = (type: string) => parts.find((part) => part.type === type)?.value;
|
||||
const yyyy = pick("year");
|
||||
const mm = pick("month");
|
||||
const dd = pick("day");
|
||||
const hh = pick("hour");
|
||||
const min = pick("minute");
|
||||
const tz = [...parts]
|
||||
.toReversed()
|
||||
.find((part) => part.type === "timeZoneName")
|
||||
?.value?.trim();
|
||||
if (!yyyy || !mm || !dd || !hh || !min) {
|
||||
return undefined;
|
||||
}
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${min}${tz ? ` ${tz}` : ""}`;
|
||||
}
|
||||
|
||||
function formatTimestamp(
|
||||
ts: number | Date | undefined,
|
||||
options?: EnvelopeFormatOptions,
|
||||
@@ -152,47 +113,27 @@ function formatTimestamp(
|
||||
if (zone.mode === "local") {
|
||||
return formatZonedTimestamp(date);
|
||||
}
|
||||
return formatZonedTimestamp(date, zone.timeZone);
|
||||
}
|
||||
|
||||
function formatElapsedTime(currentMs: number, previousMs: number): string | undefined {
|
||||
const elapsedMs = currentMs - previousMs;
|
||||
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const seconds = Math.floor(elapsedMs / 1000);
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) {
|
||||
return `${hours}h`;
|
||||
}
|
||||
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d`;
|
||||
return formatZonedTimestamp(date, { timeZone: zone.timeZone });
|
||||
}
|
||||
|
||||
export function formatAgentEnvelope(params: AgentEnvelopeParams): string {
|
||||
const channel = params.channel?.trim() || "Channel";
|
||||
const parts: string[] = [channel];
|
||||
const resolved = normalizeEnvelopeOptions(params.envelope);
|
||||
const elapsed =
|
||||
resolved.includeElapsed && params.timestamp && params.previousTimestamp
|
||||
? formatElapsedTime(
|
||||
params.timestamp instanceof Date ? params.timestamp.getTime() : params.timestamp,
|
||||
params.previousTimestamp instanceof Date
|
||||
? params.previousTimestamp.getTime()
|
||||
: params.previousTimestamp,
|
||||
)
|
||||
: undefined;
|
||||
let elapsed: string | undefined;
|
||||
if (resolved.includeElapsed && params.timestamp && params.previousTimestamp) {
|
||||
const currentMs =
|
||||
params.timestamp instanceof Date ? params.timestamp.getTime() : params.timestamp;
|
||||
const previousMs =
|
||||
params.previousTimestamp instanceof Date
|
||||
? params.previousTimestamp.getTime()
|
||||
: params.previousTimestamp;
|
||||
const elapsedMs = currentMs - previousMs;
|
||||
elapsed =
|
||||
Number.isFinite(elapsedMs) && elapsedMs >= 0
|
||||
? formatTimeAgo(elapsedMs, { suffix: false })
|
||||
: undefined;
|
||||
}
|
||||
if (params.from?.trim()) {
|
||||
const from = params.from.trim();
|
||||
parts.push(elapsed ? `${from} +${elapsed}` : from);
|
||||
|
||||
@@ -19,12 +19,12 @@ import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
||||
import { stopSubagentsForRequester } from "./abort.js";
|
||||
import { clearSessionQueues } from "./queue.js";
|
||||
import {
|
||||
formatAgeShort,
|
||||
formatDurationShort,
|
||||
formatRunLabel,
|
||||
formatRunStatus,
|
||||
sortSubagentRuns,
|
||||
} from "./subagents-utils.js";
|
||||
import { formatDurationCompact } from "../../infra/format-duration.ts";
|
||||
import { formatTimeAgo } from "../../infra/format-relative.ts";
|
||||
|
||||
type SubagentTargetResolution = {
|
||||
entry?: SubagentRunRecord;
|
||||
@@ -45,7 +45,7 @@ function formatTimestampWithAge(valueMs?: number) {
|
||||
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
|
||||
return "n/a";
|
||||
}
|
||||
return `${formatTimestamp(valueMs)} (${formatAgeShort(Date.now() - valueMs)})`;
|
||||
return `${formatTimestamp(valueMs)} (${formatTimeAgo(Date.now() - valueMs, { fallback: "n/a" })})`;
|
||||
}
|
||||
|
||||
function resolveRequesterSessionKey(params: Parameters<CommandHandler>[0]): string | undefined {
|
||||
@@ -214,8 +214,8 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo
|
||||
const label = formatRunLabel(entry);
|
||||
const runtime =
|
||||
entry.endedAt && entry.startedAt
|
||||
? formatDurationShort(entry.endedAt - entry.startedAt)
|
||||
: formatAgeShort(Date.now() - (entry.startedAt ?? entry.createdAt));
|
||||
? (formatDurationCompact(entry.endedAt - entry.startedAt) ?? "n/a")
|
||||
: formatTimeAgo(Date.now() - (entry.startedAt ?? entry.createdAt), { fallback: "n/a" });
|
||||
const runId = entry.runId.slice(0, 8);
|
||||
lines.push(
|
||||
`${index + 1}) ${status} · ${label} · ${runtime} · run ${runId} · ${entry.childSessionKey}`,
|
||||
@@ -296,7 +296,7 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo
|
||||
const { entry: sessionEntry } = loadSubagentSessionEntry(params, run.childSessionKey);
|
||||
const runtime =
|
||||
run.startedAt && Number.isFinite(run.startedAt)
|
||||
? formatDurationShort((run.endedAt ?? Date.now()) - run.startedAt)
|
||||
? (formatDurationCompact((run.endedAt ?? Date.now()) - run.startedAt) ?? "n/a")
|
||||
: "n/a";
|
||||
const outcome = run.outcome
|
||||
? `${run.outcome.status}${run.outcome.error ? ` (${run.outcome.error})` : ""}`
|
||||
|
||||
@@ -5,6 +5,11 @@ import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
|
||||
import { ensureSkillsWatcher, getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
|
||||
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
|
||||
import { buildChannelSummary } from "../../infra/channel-summary.js";
|
||||
import {
|
||||
resolveTimezone,
|
||||
formatUtcTimestamp,
|
||||
formatZonedTimestamp,
|
||||
} from "../../infra/format-datetime.ts";
|
||||
import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
|
||||
import { drainSystemEventEntries } from "../../infra/system-events.js";
|
||||
|
||||
@@ -39,15 +44,6 @@ export async function prependSystemEvents(params: {
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const resolveExplicitTimezone = (value: string): string | undefined => {
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date());
|
||||
return value;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveSystemEventTimezone = (cfg: OpenClawConfig) => {
|
||||
const raw = cfg.agents?.defaults?.envelopeTimezone?.trim();
|
||||
if (!raw) {
|
||||
@@ -66,49 +62,10 @@ export async function prependSystemEvents(params: {
|
||||
timeZone: resolveUserTimezone(cfg.agents?.defaults?.userTimezone),
|
||||
};
|
||||
}
|
||||
const explicit = resolveExplicitTimezone(raw);
|
||||
const explicit = resolveTimezone(raw);
|
||||
return explicit ? { mode: "iana" as const, timeZone: explicit } : { mode: "local" as const };
|
||||
};
|
||||
|
||||
const formatUtcTimestamp = (date: Date): string => {
|
||||
const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
|
||||
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(date.getUTCDate()).padStart(2, "0");
|
||||
const hh = String(date.getUTCHours()).padStart(2, "0");
|
||||
const min = String(date.getUTCMinutes()).padStart(2, "0");
|
||||
const sec = String(date.getUTCSeconds()).padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd}T${hh}:${min}:${sec}Z`;
|
||||
};
|
||||
|
||||
const formatZonedTimestamp = (date: Date, timeZone?: string): string | undefined => {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hourCycle: "h23",
|
||||
timeZoneName: "short",
|
||||
}).formatToParts(date);
|
||||
const pick = (type: string) => parts.find((part) => part.type === type)?.value;
|
||||
const yyyy = pick("year");
|
||||
const mm = pick("month");
|
||||
const dd = pick("day");
|
||||
const hh = pick("hour");
|
||||
const min = pick("minute");
|
||||
const sec = pick("second");
|
||||
const tz = [...parts]
|
||||
.toReversed()
|
||||
.find((part) => part.type === "timeZoneName")
|
||||
?.value?.trim();
|
||||
if (!yyyy || !mm || !dd || !hh || !min || !sec) {
|
||||
return undefined;
|
||||
}
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${sec}${tz ? ` ${tz}` : ""}`;
|
||||
};
|
||||
|
||||
const formatSystemEventTimestamp = (ts: number, cfg: OpenClawConfig) => {
|
||||
const date = new Date(ts);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
@@ -116,12 +73,14 @@ export async function prependSystemEvents(params: {
|
||||
}
|
||||
const zone = resolveSystemEventTimezone(cfg);
|
||||
if (zone.mode === "utc") {
|
||||
return formatUtcTimestamp(date);
|
||||
return formatUtcTimestamp(date, { seconds: true });
|
||||
}
|
||||
if (zone.mode === "local") {
|
||||
return formatZonedTimestamp(date) ?? "unknown-time";
|
||||
return formatZonedTimestamp(date, { seconds: true }) ?? "unknown-time";
|
||||
}
|
||||
return formatZonedTimestamp(date, zone.timeZone) ?? "unknown-time";
|
||||
return (
|
||||
formatZonedTimestamp(date, { timeZone: zone.timeZone, seconds: true }) ?? "unknown-time"
|
||||
);
|
||||
};
|
||||
|
||||
const systemLines: string[] = [];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SubagentRunRecord } from "../../agents/subagent-registry.js";
|
||||
import { formatDurationCompact } from "../../infra/format-duration.js";
|
||||
import {
|
||||
formatDurationShort,
|
||||
formatRunLabel,
|
||||
formatRunStatus,
|
||||
resolveSubagentLabel,
|
||||
@@ -54,8 +54,8 @@ describe("subagents utils", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("formats duration short for seconds and minutes", () => {
|
||||
expect(formatDurationShort(45_000)).toBe("45s");
|
||||
expect(formatDurationShort(65_000)).toBe("1m5s");
|
||||
it("formats duration compact for seconds and minutes", () => {
|
||||
expect(formatDurationCompact(45_000)).toBe("45s");
|
||||
expect(formatDurationCompact(65_000)).toBe("1m5s");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,42 +1,6 @@
|
||||
import type { SubagentRunRecord } from "../../agents/subagent-registry.js";
|
||||
import { truncateUtf16Safe } from "../../utils.js";
|
||||
|
||||
export function formatDurationShort(valueMs?: number) {
|
||||
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
|
||||
return "n/a";
|
||||
}
|
||||
const totalSeconds = Math.round(valueMs / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (hours > 0) {
|
||||
return `${hours}h${minutes}m`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m${seconds}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
export function formatAgeShort(valueMs?: number) {
|
||||
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
|
||||
return "n/a";
|
||||
}
|
||||
const minutes = Math.round(valueMs / 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 function resolveSubagentLabel(entry: SubagentRunRecord, fallback = "subagent") {
|
||||
const raw = entry.label?.trim() || entry.task?.trim() || "";
|
||||
return raw || fallback;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { resolveModelAuthMode } from "../agents/model-auth.js";
|
||||
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
||||
import { resolveSandboxRuntimeStatus } from "../agents/sandbox.js";
|
||||
import { derivePromptTokens, normalizeUsage, type UsageLike } from "../agents/usage.js";
|
||||
import { formatTimeAgo } from "../infra/format-relative.ts";
|
||||
import {
|
||||
resolveMainSessionKey,
|
||||
resolveSessionFilePath,
|
||||
@@ -134,24 +135,6 @@ export const formatContextUsageShort = (
|
||||
contextTokens: number | null | undefined,
|
||||
) => `Context ${formatTokens(total, contextTokens ?? null)}`;
|
||||
|
||||
const formatAge = (ms?: number | null) => {
|
||||
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`;
|
||||
};
|
||||
|
||||
const formatQueueDetails = (queue?: QueueStatus) => {
|
||||
if (!queue) {
|
||||
@@ -386,7 +369,7 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
const updatedAt = entry?.updatedAt;
|
||||
const sessionLine = [
|
||||
`Session: ${args.sessionKey ?? "unknown"}`,
|
||||
typeof updatedAt === "number" ? `updated ${formatAge(now - updatedAt)}` : "no activity",
|
||||
typeof updatedAt === "number" ? `updated ${formatTimeAgo(now - updatedAt)}` : "no activity",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" • ");
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CronJob, CronSchedule } from "../../cron/types.js";
|
||||
import type { GatewayRpcOpts } from "../gateway-rpc.js";
|
||||
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||
import { parseAbsoluteTimeMs } from "../../cron/parse.js";
|
||||
import { formatDurationHuman } from "../../infra/format-duration.ts";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
||||
import { callGatewayFromCli } from "../gateway-rpc.js";
|
||||
@@ -107,19 +108,6 @@ const formatIsoMinute = (iso: string) => {
|
||||
return `${isoStr.slice(0, 10)} ${isoStr.slice(11, 16)}Z`;
|
||||
};
|
||||
|
||||
const formatDuration = (ms: number) => {
|
||||
if (ms < 60_000) {
|
||||
return `${Math.max(1, Math.round(ms / 1000))}s`;
|
||||
}
|
||||
if (ms < 3_600_000) {
|
||||
return `${Math.round(ms / 60_000)}m`;
|
||||
}
|
||||
if (ms < 86_400_000) {
|
||||
return `${Math.round(ms / 3_600_000)}h`;
|
||||
}
|
||||
return `${Math.round(ms / 86_400_000)}d`;
|
||||
};
|
||||
|
||||
const formatSpan = (ms: number) => {
|
||||
if (ms < 60_000) {
|
||||
return "<1m";
|
||||
@@ -147,7 +135,7 @@ const formatSchedule = (schedule: CronSchedule) => {
|
||||
return `at ${formatIsoMinute(schedule.at)}`;
|
||||
}
|
||||
if (schedule.kind === "every") {
|
||||
return `every ${formatDuration(schedule.everyMs)}`;
|
||||
return `every ${formatDurationHuman(schedule.everyMs)}`;
|
||||
}
|
||||
return schedule.tz ? `cron ${schedule.expr} @ ${schedule.tz}` : `cron ${schedule.expr}`;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Command } from "commander";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import { formatTimeAgo } from "../infra/format-relative.ts";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { renderTable } from "../terminal/table.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
@@ -49,23 +50,6 @@ type DevicePairingList = {
|
||||
paired?: PairedDevice[];
|
||||
};
|
||||
|
||||
function formatAge(msAgo: number) {
|
||||
const s = Math.max(0, Math.floor(msAgo / 1000));
|
||||
if (s < 60) {
|
||||
return `${s}s`;
|
||||
}
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) {
|
||||
return `${m}m`;
|
||||
}
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) {
|
||||
return `${h}h`;
|
||||
}
|
||||
const d = Math.floor(h / 24);
|
||||
return `${d}d`;
|
||||
}
|
||||
|
||||
const devicesCallOpts = (cmd: Command, defaults?: { timeoutMs?: number }) =>
|
||||
cmd
|
||||
.option("--url <url>", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)")
|
||||
@@ -147,7 +131,7 @@ export function registerDevicesCli(program: Command) {
|
||||
Device: req.displayName || req.deviceId,
|
||||
Role: req.role ?? "",
|
||||
IP: req.remoteIp ?? "",
|
||||
Age: typeof req.ts === "number" ? `${formatAge(Date.now() - req.ts)} ago` : "",
|
||||
Age: typeof req.ts === "number" ? formatTimeAgo(Date.now() - req.ts) : "",
|
||||
Flags: req.isRepair ? "repair" : "",
|
||||
})),
|
||||
}).trimEnd(),
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ExecApprovalsAgent,
|
||||
type ExecApprovalsFile,
|
||||
} from "../infra/exec-approvals.js";
|
||||
import { formatTimeAgo } from "../infra/format-relative.ts";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { renderTable } from "../terminal/table.js";
|
||||
@@ -31,22 +32,7 @@ type ExecApprovalsCliOpts = NodesRpcOpts & {
|
||||
agent?: string;
|
||||
};
|
||||
|
||||
function formatAge(msAgo: number) {
|
||||
const s = Math.max(0, Math.floor(msAgo / 1000));
|
||||
if (s < 60) {
|
||||
return `${s}s`;
|
||||
}
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) {
|
||||
return `${m}m`;
|
||||
}
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) {
|
||||
return `${h}h`;
|
||||
}
|
||||
const d = Math.floor(h / 24);
|
||||
return `${d}d`;
|
||||
}
|
||||
|
||||
|
||||
async function readStdin(): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
@@ -142,7 +128,7 @@ function renderApprovalsSnapshot(snapshot: ExecApprovalsSnapshot, targetLabel: s
|
||||
Target: targetLabel,
|
||||
Agent: agentId,
|
||||
Pattern: pattern,
|
||||
LastUsed: lastUsedAt ? `${formatAge(Math.max(0, now - lastUsedAt))} ago` : muted("unknown"),
|
||||
LastUsed: lastUsedAt ? formatTimeAgo(Math.max(0, now - lastUsedAt)) : muted("unknown"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,5 @@
|
||||
import type { NodeListNode, PairedNode, PairingList, PendingRequest } from "./types.js";
|
||||
|
||||
export function formatAge(msAgo: number) {
|
||||
const s = Math.max(0, Math.floor(msAgo / 1000));
|
||||
if (s < 60) {
|
||||
return `${s}s`;
|
||||
}
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) {
|
||||
return `${m}m`;
|
||||
}
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) {
|
||||
return `${h}h`;
|
||||
}
|
||||
const d = Math.floor(h / 24);
|
||||
return `${d}d`;
|
||||
}
|
||||
|
||||
export function parsePairingList(value: unknown): PairingList {
|
||||
const obj = typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
|
||||
const pending = Array.isArray(obj.pending) ? (obj.pending as PendingRequest[]) : [];
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { NodesRpcOpts } from "./types.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { renderTable } from "../../terminal/table.js";
|
||||
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
|
||||
import { formatAge, parsePairingList } from "./format.js";
|
||||
import { parsePairingList } from "./format.js";
|
||||
import { formatTimeAgo } from "../../infra/format-relative.ts";
|
||||
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
||||
|
||||
export function registerNodesPairingCommands(nodes: Command) {
|
||||
@@ -33,7 +34,7 @@ export function registerNodesPairingCommands(nodes: Command) {
|
||||
IP: r.remoteIp ?? "",
|
||||
Requested:
|
||||
typeof r.ts === "number"
|
||||
? `${formatAge(Math.max(0, now - r.ts))} ago`
|
||||
? formatTimeAgo(Math.max(0, now - r.ts))
|
||||
: muted("unknown"),
|
||||
Repair: r.isRepair ? warn("yes") : "",
|
||||
}));
|
||||
|
||||
@@ -5,7 +5,8 @@ import { renderTable } from "../../terminal/table.js";
|
||||
import { shortenHomeInString } from "../../utils.js";
|
||||
import { parseDurationMs } from "../parse-duration.js";
|
||||
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
|
||||
import { formatAge, formatPermissions, parseNodeList, parsePairingList } from "./format.js";
|
||||
import { formatPermissions, parseNodeList, parsePairingList } from "./format.js";
|
||||
import { formatTimeAgo } from "../../infra/format-relative.ts";
|
||||
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
||||
|
||||
function formatVersionLabel(raw: string) {
|
||||
@@ -178,7 +179,7 @@ export function registerNodesStatusCommands(nodes: Command) {
|
||||
const connected = n.connected ? ok("connected") : muted("disconnected");
|
||||
const since =
|
||||
typeof n.connectedAtMs === "number"
|
||||
? ` (${formatAge(Math.max(0, now - n.connectedAtMs))} ago)`
|
||||
? ` (${formatTimeAgo(Math.max(0, now - n.connectedAtMs))})`
|
||||
: "";
|
||||
|
||||
return {
|
||||
@@ -361,7 +362,7 @@ export function registerNodesStatusCommands(nodes: Command) {
|
||||
IP: r.remoteIp ?? "",
|
||||
Requested:
|
||||
typeof r.ts === "number"
|
||||
? `${formatAge(Math.max(0, now - r.ts))} ago`
|
||||
? formatTimeAgo(Math.max(0, now - r.ts))
|
||||
: muted("unknown"),
|
||||
Repair: r.isRepair ? warn("yes") : "",
|
||||
}));
|
||||
@@ -397,7 +398,7 @@ export function registerNodesStatusCommands(nodes: Command) {
|
||||
IP: n.remoteIp ?? "",
|
||||
LastConnect:
|
||||
typeof lastConnectedAtMs === "number"
|
||||
? `${formatAge(Math.max(0, now - lastConnectedAtMs))} ago`
|
||||
? formatTimeAgo(Math.max(0, now - lastConnectedAtMs))
|
||||
: muted("unknown"),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
type UpdateStepResult,
|
||||
type UpdateStepProgress,
|
||||
} from "../infra/update-runner.js";
|
||||
import { formatDurationPrecise } from "../infra/format-duration.ts";
|
||||
import { syncPluginsForUpdateChannel, updateNpmInstalledPlugins } from "../plugins/update.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
@@ -575,7 +576,7 @@ function createUpdateProgress(enabled: boolean): ProgressController {
|
||||
}
|
||||
|
||||
const label = getStepLabel(step);
|
||||
const duration = theme.muted(`(${formatDuration(step.durationMs)})`);
|
||||
const duration = theme.muted(`(${formatDurationPrecise(step.durationMs)})`);
|
||||
const icon = step.exitCode === 0 ? theme.success("\u2713") : theme.error("\u2717");
|
||||
|
||||
currentSpinner.stop(`${icon} ${label} ${duration}`);
|
||||
@@ -603,14 +604,6 @@ function createUpdateProgress(enabled: boolean): ProgressController {
|
||||
};
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`;
|
||||
}
|
||||
const seconds = (ms / 1000).toFixed(1);
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
function formatStepStatus(exitCode: number | null): string {
|
||||
if (exitCode === 0) {
|
||||
return theme.success("\u2713");
|
||||
@@ -668,7 +661,7 @@ function printResult(result: UpdateRunResult, opts: PrintResultOptions) {
|
||||
defaultRuntime.log(theme.heading("Steps:"));
|
||||
for (const step of result.steps) {
|
||||
const status = formatStepStatus(step.exitCode);
|
||||
const duration = theme.muted(`(${formatDuration(step.durationMs)})`);
|
||||
const duration = theme.muted(`(${formatDurationPrecise(step.durationMs)})`);
|
||||
defaultRuntime.log(` ${status} ${step.name} ${duration}`);
|
||||
|
||||
if (step.exitCode !== 0 && step.stderrTail) {
|
||||
@@ -683,7 +676,7 @@ function printResult(result: UpdateRunResult, opts: PrintResultOptions) {
|
||||
}
|
||||
|
||||
defaultRuntime.log("");
|
||||
defaultRuntime.log(`Total time: ${theme.muted(formatDuration(result.durationMs))}`);
|
||||
defaultRuntime.log(`Total time: ${theme.muted(formatDurationPrecise(result.durationMs))}`);
|
||||
}
|
||||
|
||||
export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 { formatTimeAgo } from "../../infra/format-relative.ts";
|
||||
import { collectChannelStatusIssues } from "../../infra/channels-status-issues.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||
import { formatDocsLink } from "../../terminal/links.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}`);
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
import type { SandboxBrowserInfo, SandboxContainerInfo } from "../agents/sandbox.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { formatDurationCompact } from "../infra/format-duration.ts";
|
||||
import {
|
||||
formatAge,
|
||||
formatImageMatch,
|
||||
formatSimpleStatus,
|
||||
formatStatus,
|
||||
@@ -40,8 +40,8 @@ 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,8 @@ 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("");
|
||||
},
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatDurationCompact } from "../infra/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
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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-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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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-relative.ts";
|
||||
export { formatDurationPrecise } from "../../infra/format-duration.ts";
|
||||
|
||||
export function formatGatewayAuthUsed(
|
||||
auth: {
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -26,12 +26,12 @@ 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,
|
||||
shortenText,
|
||||
} from "./status.format.js";
|
||||
import { formatTimeAgo } from "../infra/format-relative.ts";
|
||||
import { resolveGatewayProbeAuth } from "./status.gateway-probe.js";
|
||||
import { scanStatus } from "./status.scan.js";
|
||||
import {
|
||||
@@ -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),
|
||||
}))
|
||||
|
||||
@@ -1,35 +1,14 @@
|
||||
import type { SessionStatus } from "./status.types.js";
|
||||
import { formatDurationPrecise } from "../infra/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) => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
PresenceUpdateListener,
|
||||
} from "@buape/carbon";
|
||||
import { danger } from "../../globals.js";
|
||||
import { formatDurationSeconds } from "../../infra/format-duration.js";
|
||||
import { formatDurationSeconds } from "../../infra/format-duration.ts";
|
||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { formatZonedTimestamp } from "../../auto-reply/envelope.js";
|
||||
import { formatZonedTimestamp } from "../../infra/format-datetime.js";
|
||||
import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js";
|
||||
|
||||
describe("injectTimestamp", () => {
|
||||
@@ -23,7 +23,7 @@ describe("injectTimestamp", () => {
|
||||
|
||||
it("uses channel envelope format with DOW prefix", () => {
|
||||
const now = new Date();
|
||||
const expected = formatZonedTimestamp(now, "America/New_York");
|
||||
const expected = formatZonedTimestamp(now, { timeZone: "America/New_York" });
|
||||
|
||||
const result = injectTimestamp("hello", { timezone: "America/New_York" });
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { OpenClawConfig } from "../../config/types.js";
|
||||
import { resolveUserTimezone } from "../../agents/date-time.js";
|
||||
import { formatZonedTimestamp } from "../../auto-reply/envelope.js";
|
||||
import { formatZonedTimestamp } from "../../infra/format-datetime.ts";
|
||||
|
||||
/**
|
||||
* Cron jobs inject "Current time: ..." into their messages.
|
||||
@@ -56,7 +56,7 @@ export function injectTimestamp(message: string, opts?: TimestampInjectionOption
|
||||
const now = opts?.now ?? new Date();
|
||||
const timezone = opts?.timezone ?? "UTC";
|
||||
|
||||
const formatted = formatZonedTimestamp(now, timezone);
|
||||
const formatted = formatZonedTimestamp(now, { timeZone: timezone });
|
||||
if (!formatted) {
|
||||
return message;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ChannelAccountSnapshot, ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import { listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import { type OpenClawConfig, loadConfig } from "../config/config.js";
|
||||
import { formatTimeAgo } from "./format-relative.ts";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
|
||||
@@ -224,7 +225,7 @@ export async function buildChannelSummary(
|
||||
line += ` ${self.e164}`;
|
||||
}
|
||||
if (authAgeMs != null && authAgeMs >= 0) {
|
||||
line += ` auth ${formatAge(authAgeMs)}`;
|
||||
line += ` auth ${formatTimeAgo(authAgeMs)}`;
|
||||
}
|
||||
|
||||
lines.push(tint(line, statusColor));
|
||||
@@ -252,22 +253,3 @@ export async function buildChannelSummary(
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function formatAge(ms: number): string {
|
||||
if (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`;
|
||||
}
|
||||
|
||||
89
src/infra/format-datetime.ts
Normal file
89
src/infra/format-datetime.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Centralized date/time formatting utilities.
|
||||
*
|
||||
* All formatters are timezone-aware, using Intl.DateTimeFormat.
|
||||
* Consolidates duplicated formatUtcTimestamp / formatZonedTimestamp / resolveExplicitTimezone
|
||||
* that previously lived in envelope.ts and session-updates.ts.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Validate an IANA timezone string. Returns the string if valid, undefined otherwise.
|
||||
*/
|
||||
export function resolveTimezone(value: string): string | undefined {
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date());
|
||||
return value;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export type FormatTimestampOptions = {
|
||||
/** Include seconds in the output. Default: false */
|
||||
seconds?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a Date as a UTC timestamp string.
|
||||
*
|
||||
* Without seconds: `2024-01-15T14:30Z`
|
||||
* With seconds: `2024-01-15T14:30:05Z`
|
||||
*/
|
||||
export function formatUtcTimestamp(date: Date, options?: FormatTimestampOptions): string {
|
||||
const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
|
||||
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(date.getUTCDate()).padStart(2, "0");
|
||||
const hh = String(date.getUTCHours()).padStart(2, "0");
|
||||
const min = String(date.getUTCMinutes()).padStart(2, "0");
|
||||
if (!options?.seconds) {
|
||||
return `${yyyy}-${mm}-${dd}T${hh}:${min}Z`;
|
||||
}
|
||||
const sec = String(date.getUTCSeconds()).padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd}T${hh}:${min}:${sec}Z`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Date with timezone display using Intl.DateTimeFormat.
|
||||
*
|
||||
* Without seconds: `2024-01-15 14:30 EST`
|
||||
* With seconds: `2024-01-15 14:30:05 EST`
|
||||
*
|
||||
* Returns undefined if Intl formatting fails.
|
||||
*/
|
||||
export function formatZonedTimestamp(
|
||||
date: Date,
|
||||
options?: { timeZone?: string; seconds?: boolean },
|
||||
): string | undefined {
|
||||
const intlOptions: Intl.DateTimeFormatOptions = {
|
||||
timeZone: options?.timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
timeZoneName: "short",
|
||||
};
|
||||
if (options?.seconds) {
|
||||
intlOptions.second = "2-digit";
|
||||
}
|
||||
const parts = new Intl.DateTimeFormat("en-US", intlOptions).formatToParts(date);
|
||||
const pick = (type: string) => parts.find((part) => part.type === type)?.value;
|
||||
const yyyy = pick("year");
|
||||
const mm = pick("month");
|
||||
const dd = pick("day");
|
||||
const hh = pick("hour");
|
||||
const min = pick("minute");
|
||||
const sec = options?.seconds ? pick("second") : undefined;
|
||||
const tz = [...parts]
|
||||
.toReversed()
|
||||
.find((part) => part.type === "timeZoneName")
|
||||
?.value?.trim();
|
||||
if (!yyyy || !mm || !dd || !hh || !min) {
|
||||
return undefined;
|
||||
}
|
||||
if (options?.seconds && sec) {
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${sec}${tz ? ` ${tz}` : ""}`;
|
||||
}
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${min}${tz ? ` ${tz}` : ""}`;
|
||||
}
|
||||
@@ -18,12 +18,8 @@ export function formatDurationSeconds(
|
||||
return unit === "seconds" ? `${trimmed} seconds` : `${trimmed}s`;
|
||||
}
|
||||
|
||||
export type FormatDurationMsOptions = {
|
||||
decimals?: number;
|
||||
unit?: "s" | "seconds";
|
||||
};
|
||||
|
||||
export function formatDurationMs(ms: number, options: FormatDurationMsOptions = {}): string {
|
||||
/** Precise decimal-seconds output: "500ms" or "1.23s". Input is milliseconds. */
|
||||
export function formatDurationPrecise(ms: number, options: FormatDurationSecondsOptions = {}): string {
|
||||
if (!Number.isFinite(ms)) {
|
||||
return "unknown";
|
||||
}
|
||||
@@ -35,3 +31,70 @@ export function formatDurationMs(ms: number, options: FormatDurationMsOptions =
|
||||
unit: options.unit ?? "s",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact compound duration: "500ms", "45s", "2m5s", "1h30m".
|
||||
* With `spaced`: "45s", "2m 5s", "1h 30m".
|
||||
* Omits trailing zero components: "1m" not "1m 0s", "2h" not "2h 0m".
|
||||
* Returns undefined for null/undefined/non-finite/non-positive input.
|
||||
*/
|
||||
export function formatDurationCompact(ms: number, options?: { spaced?: boolean }): string;
|
||||
export function formatDurationCompact(
|
||||
ms?: number | null,
|
||||
options?: { spaced?: boolean },
|
||||
): string | undefined;
|
||||
export function formatDurationCompact(
|
||||
ms?: number | null,
|
||||
options?: { spaced?: boolean },
|
||||
): string | undefined {
|
||||
if (ms == null || !Number.isFinite(ms) || ms <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (ms < 1000) {
|
||||
return `${Math.round(ms)}ms`;
|
||||
}
|
||||
const sep = options?.spaced ? " " : "";
|
||||
const totalSeconds = Math.round(ms / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (hours >= 24) {
|
||||
const days = Math.floor(hours / 24);
|
||||
const remainingHours = hours % 24;
|
||||
return remainingHours > 0 ? `${days}d${sep}${remainingHours}h` : `${days}d`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return minutes > 0 ? `${hours}h${sep}${minutes}m` : `${hours}h`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return seconds > 0 ? `${minutes}m${sep}${seconds}s` : `${minutes}m`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounded single-unit duration for display: "500ms", "5s", "3m", "2h", "5d".
|
||||
* Returns fallback string for null/undefined/non-finite input.
|
||||
*/
|
||||
export function formatDurationHuman(ms?: number | null, fallback = "n/a"): string {
|
||||
if (ms == null || !Number.isFinite(ms) || ms < 0) {
|
||||
return fallback;
|
||||
}
|
||||
if (ms < 1000) {
|
||||
return `${Math.round(ms)}ms`;
|
||||
}
|
||||
const sec = Math.round(ms / 1000);
|
||||
if (sec < 60) {
|
||||
return `${sec}s`;
|
||||
}
|
||||
const min = Math.round(sec / 60);
|
||||
if (min < 60) {
|
||||
return `${min}m`;
|
||||
}
|
||||
const hr = Math.round(min / 60);
|
||||
if (hr < 24) {
|
||||
return `${hr}h`;
|
||||
}
|
||||
const day = Math.round(hr / 24);
|
||||
return `${day}d`;
|
||||
}
|
||||
|
||||
112
src/infra/format-relative.ts
Normal file
112
src/infra/format-relative.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Centralized relative-time formatting utilities.
|
||||
*
|
||||
* Consolidates 7+ scattered implementations (formatAge, formatAgeShort, formatAgo,
|
||||
* formatRelativeTime, formatElapsedTime) into two functions:
|
||||
*
|
||||
* - `formatTimeAgo(durationMs)` — format a duration as "5m ago" / "5m" (for known elapsed time)
|
||||
* - `formatRelativeTimestamp(epochMs)` — format an epoch timestamp relative to now (handles future)
|
||||
*/
|
||||
|
||||
export type FormatTimeAgoOptions = {
|
||||
/** Append "ago" suffix. Default: true. When false, returns bare unit: "5m", "2h" */
|
||||
suffix?: boolean;
|
||||
/** Return value for invalid/null/negative input. Default: "unknown" */
|
||||
fallback?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a duration (in ms) as a human-readable relative time.
|
||||
*
|
||||
* Input: how many milliseconds ago something happened.
|
||||
*
|
||||
* With suffix (default): "just now", "5m ago", "3h ago", "2d ago"
|
||||
* Without suffix: "0s", "5m", "3h", "2d"
|
||||
*/
|
||||
export function formatTimeAgo(
|
||||
durationMs: number | null | undefined,
|
||||
options?: FormatTimeAgoOptions,
|
||||
): string {
|
||||
const suffix = options?.suffix !== false;
|
||||
const fallback = options?.fallback ?? "unknown";
|
||||
|
||||
if (durationMs == null || !Number.isFinite(durationMs) || durationMs < 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const totalSeconds = Math.round(durationMs / 1000);
|
||||
const minutes = Math.round(totalSeconds / 60);
|
||||
|
||||
if (minutes < 1) {
|
||||
return suffix ? "just now" : `${totalSeconds}s`;
|
||||
}
|
||||
if (minutes < 60) {
|
||||
return suffix ? `${minutes}m ago` : `${minutes}m`;
|
||||
}
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 48) {
|
||||
return suffix ? `${hours}h ago` : `${hours}h`;
|
||||
}
|
||||
const days = Math.round(hours / 24);
|
||||
return suffix ? `${days}d ago` : `${days}d`;
|
||||
}
|
||||
|
||||
export type FormatRelativeTimestampOptions = {
|
||||
/** If true, fall back to short date (e.g. "Oct 5") for timestamps >7 days. Default: false */
|
||||
dateFallback?: boolean;
|
||||
/** IANA timezone for date fallback display */
|
||||
timezone?: string;
|
||||
/** Return value for invalid/null input. Default: "n/a" */
|
||||
fallback?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format an epoch timestamp relative to now.
|
||||
*
|
||||
* Handles both past ("5m ago") and future ("in 5m") timestamps.
|
||||
* Optionally falls back to a short date for timestamps older than 7 days.
|
||||
*/
|
||||
export function formatRelativeTimestamp(
|
||||
timestampMs: number | null | undefined,
|
||||
options?: FormatRelativeTimestampOptions,
|
||||
): string {
|
||||
const fallback = options?.fallback ?? "n/a";
|
||||
if (timestampMs == null || !Number.isFinite(timestampMs)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const diff = Date.now() - timestampMs;
|
||||
const absDiff = Math.abs(diff);
|
||||
const isPast = diff >= 0;
|
||||
|
||||
const sec = Math.round(absDiff / 1000);
|
||||
if (sec < 60) {
|
||||
return isPast ? "just now" : "in <1m";
|
||||
}
|
||||
|
||||
const min = Math.round(sec / 60);
|
||||
if (min < 60) {
|
||||
return isPast ? `${min}m ago` : `in ${min}m`;
|
||||
}
|
||||
|
||||
const hr = Math.round(min / 60);
|
||||
if (hr < 48) {
|
||||
return isPast ? `${hr}h ago` : `in ${hr}h`;
|
||||
}
|
||||
|
||||
const day = Math.round(hr / 24);
|
||||
if (!options?.dateFallback || day <= 7) {
|
||||
return isPast ? `${day}d ago` : `in ${day}d`;
|
||||
}
|
||||
|
||||
// Fall back to short date display for old timestamps
|
||||
try {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
...(options.timezone ? { timeZone: options.timezone } : {}),
|
||||
}).format(new Date(timestampMs));
|
||||
} catch {
|
||||
return `${day}d ago`;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { resolveAgentMaxConcurrent } from "../config/agent-limits.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { computeBackoff, sleepWithAbort } from "../infra/backoff.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { formatDurationMs } from "../infra/format-duration.js";
|
||||
import { formatDurationPrecise } from "../infra/format-duration.ts";
|
||||
import { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
|
||||
import { resolveTelegramAccount } from "./accounts.js";
|
||||
import { resolveTelegramAllowedUpdates } from "./allowed-updates.js";
|
||||
@@ -195,7 +195,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
|
||||
const reason = isConflict ? "getUpdates conflict" : "network error";
|
||||
const errMsg = formatErrorMessage(err);
|
||||
(opts.runtime?.error ?? console.error)(
|
||||
`Telegram ${reason}: ${errMsg}; retrying in ${formatDurationMs(delayMs)}.`,
|
||||
`Telegram ${reason}: ${errMsg}; retrying in ${formatDurationPrecise(delayMs)}.`,
|
||||
);
|
||||
try {
|
||||
await sleepWithAbort(delayMs, opts.abortSignal);
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
resolveResponseUsageMode,
|
||||
} from "../auto-reply/thinking.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { formatRelativeTime } from "../utils/time-format.js";
|
||||
import { formatRelativeTimestamp } from "../infra/format-relative.ts";
|
||||
import { helpText, parseCommand } from "./commands.js";
|
||||
import {
|
||||
createFilterableSelectList,
|
||||
@@ -158,7 +158,7 @@ export function createCommandHandlers(context: CommandHandlerContext) {
|
||||
// Avoid redundant "title (key)" when title matches key
|
||||
const label = title && title !== formattedKey ? `${title} (${formattedKey})` : formattedKey;
|
||||
// Build description: time + message preview
|
||||
const timePart = session.updatedAt ? formatRelativeTime(session.updatedAt) : "";
|
||||
const timePart = session.updatedAt ? formatRelativeTimestamp(session.updatedAt, { dateFallback: true, fallback: "" }) : "";
|
||||
const preview = session.lastMessagePreview?.replace(/\s+/g, " ").trim();
|
||||
const description =
|
||||
timePart && preview ? `${timePart} · ${preview}` : (preview ?? timePart);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GatewayStatusSummary } from "./tui-types.js";
|
||||
import { formatAge } from "../infra/channel-summary.js";
|
||||
import { formatTimeAgo } from "../infra/format-relative.ts";
|
||||
import { formatTokenCount } from "../utils/usage-format.js";
|
||||
import { formatContextUsageLine } from "./tui-formatters.js";
|
||||
|
||||
@@ -14,7 +14,7 @@ export function formatStatusSummary(summary: GatewayStatusSummary) {
|
||||
const linked = summary.linkChannel.linked === true;
|
||||
const authAge =
|
||||
linked && typeof summary.linkChannel.authAgeMs === "number"
|
||||
? ` (last refreshed ${formatAge(summary.linkChannel.authAgeMs)})`
|
||||
? ` (last refreshed ${formatTimeAgo(summary.linkChannel.authAgeMs)})`
|
||||
: "";
|
||||
lines.push(`${linkLabel}: ${linked ? "linked" : "not linked"}${authAge}`);
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export function formatStatusSummary(summary: GatewayStatusSummary) {
|
||||
if (recent.length > 0) {
|
||||
lines.push("Recent sessions:");
|
||||
for (const entry of recent) {
|
||||
const ageLabel = typeof entry.age === "number" ? formatAge(entry.age) : "no activity";
|
||||
const ageLabel = typeof entry.age === "number" ? formatTimeAgo(entry.age) : "no activity";
|
||||
const model = entry.model ?? "unknown";
|
||||
const usage = formatContextUsageLine({
|
||||
total: entry.totalTokens ?? null,
|
||||
|
||||
@@ -1,25 +1,6 @@
|
||||
export function formatRelativeTime(timestamp: number): string {
|
||||
const now = Date.now();
|
||||
const diff = now - timestamp;
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
import { formatRelativeTimestamp } from "../infra/format-relative.ts";
|
||||
|
||||
if (seconds < 60) {
|
||||
return "just now";
|
||||
}
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m ago`;
|
||||
}
|
||||
if (hours < 24) {
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
if (days === 1) {
|
||||
return "Yesterday";
|
||||
}
|
||||
if (days < 7) {
|
||||
return `${days}d ago`;
|
||||
}
|
||||
return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
/** Delegates to centralized formatRelativeTimestamp with date fallback for >7d. */
|
||||
export function formatRelativeTime(timestamp: number): string {
|
||||
return formatRelativeTimestamp(timestamp, { dateFallback: true, fallback: "unknown" });
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { formatCliCommand } from "../../cli/command-format.js";
|
||||
import { waitForever } from "../../cli/wait.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { formatDurationMs } from "../../infra/format-duration.js";
|
||||
import { formatDurationPrecise } from "../../infra/format-duration.ts";
|
||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||
import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejections.js";
|
||||
import { getChildLogger } from "../../logging.js";
|
||||
@@ -432,7 +432,7 @@ export async function monitorWebChannel(
|
||||
"web reconnect: scheduling retry",
|
||||
);
|
||||
runtime.error(
|
||||
`WhatsApp Web connection closed (status ${statusCode}). Retry ${reconnectAttempts}/${reconnectPolicy.maxAttempts || "∞"} in ${formatDurationMs(delay)}… (${errorStr})`,
|
||||
`WhatsApp Web connection closed (status ${statusCode}). Retry ${reconnectAttempts}/${reconnectPolicy.maxAttempts || "∞"} in ${formatDurationPrecise(delay)}… (${errorStr})`,
|
||||
);
|
||||
await closeListener();
|
||||
try {
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatAgo, stripThinkingTags } from "./format.ts";
|
||||
import { formatRelativeTimestamp, stripThinkingTags } from "./format.ts";
|
||||
|
||||
describe("formatAgo", () => {
|
||||
it("returns 'in <1m' for timestamps less than 60s in the future", () => {
|
||||
expect(formatAgo(Date.now() + 30_000)).toBe("in <1m");
|
||||
expect(formatRelativeTimestamp(Date.now() + 30_000)).toBe("in <1m");
|
||||
});
|
||||
|
||||
it("returns 'Xm from now' for future timestamps", () => {
|
||||
expect(formatAgo(Date.now() + 5 * 60_000)).toBe("5m from now");
|
||||
expect(formatRelativeTimestamp(Date.now() + 5 * 60_000)).toBe("5m from now");
|
||||
});
|
||||
|
||||
it("returns 'Xh from now' for future timestamps", () => {
|
||||
expect(formatAgo(Date.now() + 3 * 60 * 60_000)).toBe("3h from now");
|
||||
expect(formatRelativeTimestamp(Date.now() + 3 * 60 * 60_000)).toBe("3h from now");
|
||||
});
|
||||
|
||||
it("returns 'Xd from now' for future timestamps beyond 48h", () => {
|
||||
expect(formatAgo(Date.now() + 3 * 24 * 60 * 60_000)).toBe("3d from now");
|
||||
expect(formatRelativeTimestamp(Date.now() + 3 * 24 * 60 * 60_000)).toBe("3d from now");
|
||||
});
|
||||
|
||||
it("returns 'Xs ago' for recent past timestamps", () => {
|
||||
expect(formatAgo(Date.now() - 10_000)).toBe("10s ago");
|
||||
expect(formatRelativeTimestamp(Date.now() - 10_000)).toBe("10s ago");
|
||||
});
|
||||
|
||||
it("returns 'Xm ago' for past timestamps", () => {
|
||||
expect(formatAgo(Date.now() - 5 * 60_000)).toBe("5m ago");
|
||||
expect(formatRelativeTimestamp(Date.now() - 5 * 60_000)).toBe("5m ago");
|
||||
});
|
||||
|
||||
it("returns 'n/a' for null/undefined", () => {
|
||||
expect(formatAgo(null)).toBe("n/a");
|
||||
expect(formatAgo(undefined)).toBe("n/a");
|
||||
expect(formatRelativeTimestamp(null)).toBe("n/a");
|
||||
expect(formatRelativeTimestamp(undefined)).toBe("n/a");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { formatDurationHuman } from "../../../src/infra/format-duration.ts";
|
||||
import { formatRelativeTimestamp } from "../../../src/infra/format-relative.ts";
|
||||
import { stripReasoningTagsFromText } from "../../../src/shared/text/reasoning-tags.js";
|
||||
|
||||
export { formatRelativeTimestamp, formatDurationHuman };
|
||||
|
||||
export function formatMs(ms?: number | null): string {
|
||||
if (!ms && ms !== 0) {
|
||||
return "n/a";
|
||||
@@ -7,52 +11,6 @@ export function formatMs(ms?: number | null): string {
|
||||
return new Date(ms).toLocaleString();
|
||||
}
|
||||
|
||||
export function formatAgo(ms?: number | null): string {
|
||||
if (!ms && ms !== 0) {
|
||||
return "n/a";
|
||||
}
|
||||
const diff = Date.now() - ms;
|
||||
const absDiff = Math.abs(diff);
|
||||
const suffix = diff < 0 ? "from now" : "ago";
|
||||
const sec = Math.round(absDiff / 1000);
|
||||
if (sec < 60) {
|
||||
return diff < 0 ? "in <1m" : `${sec}s ago`;
|
||||
}
|
||||
const min = Math.round(sec / 60);
|
||||
if (min < 60) {
|
||||
return `${min}m ${suffix}`;
|
||||
}
|
||||
const hr = Math.round(min / 60);
|
||||
if (hr < 48) {
|
||||
return `${hr}h ${suffix}`;
|
||||
}
|
||||
const day = Math.round(hr / 24);
|
||||
return `${day}d ${suffix}`;
|
||||
}
|
||||
|
||||
export function formatDurationMs(ms?: number | null): string {
|
||||
if (!ms && ms !== 0) {
|
||||
return "n/a";
|
||||
}
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`;
|
||||
}
|
||||
const sec = Math.round(ms / 1000);
|
||||
if (sec < 60) {
|
||||
return `${sec}s`;
|
||||
}
|
||||
const min = Math.round(sec / 60);
|
||||
if (min < 60) {
|
||||
return `${min}m`;
|
||||
}
|
||||
const hr = Math.round(min / 60);
|
||||
if (hr < 48) {
|
||||
return `${hr}h`;
|
||||
}
|
||||
const day = Math.round(hr / 24);
|
||||
return `${day}d`;
|
||||
}
|
||||
|
||||
export function formatList(values?: Array<string | null | undefined>): string {
|
||||
if (!values || values.length === 0) {
|
||||
return "none";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CronJob, GatewaySessionRow, PresenceEntry } from "./types.ts";
|
||||
import { formatAgo, formatDurationMs, formatMs } from "./format.ts";
|
||||
import { formatRelativeTimestamp, formatDurationHuman, formatMs } from "./format.ts";
|
||||
|
||||
export function formatPresenceSummary(entry: PresenceEntry): string {
|
||||
const host = entry.host ?? "unknown";
|
||||
@@ -11,14 +11,14 @@ export function formatPresenceSummary(entry: PresenceEntry): string {
|
||||
|
||||
export function formatPresenceAge(entry: PresenceEntry): string {
|
||||
const ts = entry.ts ?? null;
|
||||
return ts ? formatAgo(ts) : "n/a";
|
||||
return ts ? formatRelativeTimestamp(ts) : "n/a";
|
||||
}
|
||||
|
||||
export function formatNextRun(ms?: number | null) {
|
||||
if (!ms) {
|
||||
return "n/a";
|
||||
}
|
||||
return `${formatMs(ms)} (${formatAgo(ms)})`;
|
||||
return `${formatMs(ms)} (${formatRelativeTimestamp(ms)})`;
|
||||
}
|
||||
|
||||
export function formatSessionTokens(row: GatewaySessionRow) {
|
||||
@@ -57,7 +57,7 @@ export function formatCronSchedule(job: CronJob) {
|
||||
return Number.isFinite(atMs) ? `At ${formatMs(atMs)}` : `At ${s.at}`;
|
||||
}
|
||||
if (s.kind === "every") {
|
||||
return `Every ${formatDurationMs(s.everyMs)}`;
|
||||
return `Every ${formatDurationHuman(s.everyMs)}`;
|
||||
}
|
||||
return `Cron ${s.expr}${s.tz ? ` (${s.tz})` : ""}`;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
normalizeToolName,
|
||||
resolveToolProfilePolicy,
|
||||
} from "../../../../src/agents/tool-policy.js";
|
||||
import { formatAgo } from "../format.ts";
|
||||
import { formatRelativeTimestamp } from "../format.ts";
|
||||
import {
|
||||
formatCronPayload,
|
||||
formatCronSchedule,
|
||||
@@ -1112,7 +1112,7 @@ function renderAgentChannels(params: {
|
||||
params.agentIdentity,
|
||||
);
|
||||
const entries = resolveChannelEntries(params.snapshot);
|
||||
const lastSuccessLabel = params.lastSuccess ? formatAgo(params.lastSuccess) : "never";
|
||||
const lastSuccessLabel = params.lastSuccess ? formatRelativeTimestamp(params.lastSuccess) : "never";
|
||||
return html`
|
||||
<section class="grid grid-cols-2">
|
||||
${renderAgentContextCard(context, "Workspace, identity, and model configuration.")}
|
||||
@@ -1407,7 +1407,7 @@ function renderAgentFiles(params: {
|
||||
function renderAgentFileRow(file: AgentFileEntry, active: string | null, onSelect: () => void) {
|
||||
const status = file.missing
|
||||
? "Missing"
|
||||
: `${formatBytes(file.size)} · ${formatAgo(file.updatedAtMs ?? null)}`;
|
||||
: `${formatBytes(file.size)} · ${formatRelativeTimestamp(file.updatedAtMs ?? null)}`;
|
||||
return html`
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { html, nothing } from "lit";
|
||||
import type { DiscordStatus } from "../types.ts";
|
||||
import type { ChannelsProps } from "./channels.types.ts";
|
||||
import { formatAgo } from "../format.ts";
|
||||
import { formatRelativeTimestamp } from "../format.ts";
|
||||
import { renderChannelConfigSection } from "./channels.config.ts";
|
||||
|
||||
export function renderDiscordCard(params: {
|
||||
@@ -28,11 +28,11 @@ export function renderDiscordCard(params: {
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"}</span>
|
||||
<span>${discord?.lastStartAt ? formatRelativeTimestamp(discord.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"}</span>
|
||||
<span>${discord?.lastProbeAt ? formatRelativeTimestamp(discord.lastProbeAt) : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { html, nothing } from "lit";
|
||||
import type { GoogleChatStatus } from "../types.ts";
|
||||
import type { ChannelsProps } from "./channels.types.ts";
|
||||
import { formatAgo } from "../format.ts";
|
||||
import { formatRelativeTimestamp } from "../format.ts";
|
||||
import { renderChannelConfigSection } from "./channels.config.ts";
|
||||
|
||||
export function renderGoogleChatCard(params: {
|
||||
@@ -42,11 +42,11 @@ export function renderGoogleChatCard(params: {
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${googleChat?.lastStartAt ? formatAgo(googleChat.lastStartAt) : "n/a"}</span>
|
||||
<span>${googleChat?.lastStartAt ? formatRelativeTimestamp(googleChat.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${googleChat?.lastProbeAt ? formatAgo(googleChat.lastProbeAt) : "n/a"}</span>
|
||||
<span>${googleChat?.lastProbeAt ? formatRelativeTimestamp(googleChat.lastProbeAt) : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { html, nothing } from "lit";
|
||||
import type { IMessageStatus } from "../types.ts";
|
||||
import type { ChannelsProps } from "./channels.types.ts";
|
||||
import { formatAgo } from "../format.ts";
|
||||
import { formatRelativeTimestamp } from "../format.ts";
|
||||
import { renderChannelConfigSection } from "./channels.config.ts";
|
||||
|
||||
export function renderIMessageCard(params: {
|
||||
@@ -28,11 +28,11 @@ export function renderIMessageCard(params: {
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"}</span>
|
||||
<span>${imessage?.lastStartAt ? formatRelativeTimestamp(imessage.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"}</span>
|
||||
<span>${imessage?.lastProbeAt ? formatRelativeTimestamp(imessage.lastProbeAt) : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { html, nothing } from "lit";
|
||||
import type { ChannelAccountSnapshot, NostrStatus } from "../types.ts";
|
||||
import type { ChannelsProps } from "./channels.types.ts";
|
||||
import { formatAgo } from "../format.ts";
|
||||
import { formatRelativeTimestamp } from "../format.ts";
|
||||
import { renderChannelConfigSection } from "./channels.config.ts";
|
||||
import {
|
||||
renderNostrProfileForm,
|
||||
@@ -79,7 +79,7 @@ export function renderNostrCard(params: {
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last inbound</span>
|
||||
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
|
||||
<span>${account.lastInboundAt ? formatRelativeTimestamp(account.lastInboundAt) : "n/a"}</span>
|
||||
</div>
|
||||
${
|
||||
account.lastError
|
||||
@@ -213,7 +213,7 @@ export function renderNostrCard(params: {
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${summaryLastStartAt ? formatAgo(summaryLastStartAt) : "n/a"}</span>
|
||||
<span>${summaryLastStartAt ? formatRelativeTimestamp(summaryLastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
@@ -2,22 +2,6 @@ import { html, nothing } from "lit";
|
||||
import type { ChannelAccountSnapshot } from "../types.ts";
|
||||
import type { ChannelKey, ChannelsProps } from "./channels.types.ts";
|
||||
|
||||
export function formatDuration(ms?: number | null) {
|
||||
if (!ms && ms !== 0) {
|
||||
return "n/a";
|
||||
}
|
||||
const sec = Math.round(ms / 1000);
|
||||
if (sec < 60) {
|
||||
return `${sec}s`;
|
||||
}
|
||||
const min = Math.round(sec / 60);
|
||||
if (min < 60) {
|
||||
return `${min}m`;
|
||||
}
|
||||
const hr = Math.round(min / 60);
|
||||
return `${hr}h`;
|
||||
}
|
||||
|
||||
export function channelEnabled(key: ChannelKey, props: ChannelsProps) {
|
||||
const snapshot = props.snapshot;
|
||||
const channels = snapshot?.channels as Record<string, unknown> | null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { html, nothing } from "lit";
|
||||
import type { SignalStatus } from "../types.ts";
|
||||
import type { ChannelsProps } from "./channels.types.ts";
|
||||
import { formatAgo } from "../format.ts";
|
||||
import { formatRelativeTimestamp } from "../format.ts";
|
||||
import { renderChannelConfigSection } from "./channels.config.ts";
|
||||
|
||||
export function renderSignalCard(params: {
|
||||
@@ -32,11 +32,11 @@ export function renderSignalCard(params: {
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"}</span>
|
||||
<span>${signal?.lastStartAt ? formatRelativeTimestamp(signal.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"}</span>
|
||||
<span>${signal?.lastProbeAt ? formatRelativeTimestamp(signal.lastProbeAt) : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { html, nothing } from "lit";
|
||||
import type { SlackStatus } from "../types.ts";
|
||||
import type { ChannelsProps } from "./channels.types.ts";
|
||||
import { formatAgo } from "../format.ts";
|
||||
import { formatRelativeTimestamp } from "../format.ts";
|
||||
import { renderChannelConfigSection } from "./channels.config.ts";
|
||||
|
||||
export function renderSlackCard(params: {
|
||||
@@ -28,11 +28,11 @@ export function renderSlackCard(params: {
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"}</span>
|
||||
<span>${slack?.lastStartAt ? formatRelativeTimestamp(slack.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"}</span>
|
||||
<span>${slack?.lastProbeAt ? formatRelativeTimestamp(slack.lastProbeAt) : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { html, nothing } from "lit";
|
||||
import type { ChannelAccountSnapshot, TelegramStatus } from "../types.ts";
|
||||
import type { ChannelsProps } from "./channels.types.ts";
|
||||
import { formatAgo } from "../format.ts";
|
||||
import { formatRelativeTimestamp } from "../format.ts";
|
||||
import { renderChannelConfigSection } from "./channels.config.ts";
|
||||
|
||||
export function renderTelegramCard(params: {
|
||||
@@ -36,7 +36,7 @@ export function renderTelegramCard(params: {
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last inbound</span>
|
||||
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
|
||||
<span>${account.lastInboundAt ? formatRelativeTimestamp(account.lastInboundAt) : "n/a"}</span>
|
||||
</div>
|
||||
${
|
||||
account.lastError
|
||||
@@ -81,11 +81,11 @@ export function renderTelegramCard(params: {
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last start</span>
|
||||
<span>${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"}</span>
|
||||
<span>${telegram?.lastStartAt ? formatRelativeTimestamp(telegram.lastStartAt) : "n/a"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last probe</span>
|
||||
<span>${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"}</span>
|
||||
<span>${telegram?.lastProbeAt ? formatRelativeTimestamp(telegram.lastProbeAt) : "n/a"}</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
WhatsAppStatus,
|
||||
} from "../types.ts";
|
||||
import type { ChannelKey, ChannelsChannelData, ChannelsProps } from "./channels.types.ts";
|
||||
import { formatAgo } from "../format.ts";
|
||||
import { formatRelativeTimestamp } from "../format.ts";
|
||||
import { renderChannelConfigSection } from "./channels.config.ts";
|
||||
import { renderDiscordCard } from "./channels.discord.ts";
|
||||
import { renderGoogleChatCard } from "./channels.googlechat.ts";
|
||||
@@ -73,7 +73,7 @@ export function renderChannels(props: ChannelsProps) {
|
||||
<div class="card-title">Channel health</div>
|
||||
<div class="card-sub">Channel status snapshots from the gateway.</div>
|
||||
</div>
|
||||
<div class="muted">${props.lastSuccessAt ? formatAgo(props.lastSuccessAt) : "n/a"}</div>
|
||||
<div class="muted">${props.lastSuccessAt ? formatRelativeTimestamp(props.lastSuccessAt) : "n/a"}</div>
|
||||
</div>
|
||||
${
|
||||
props.lastError
|
||||
@@ -308,7 +308,7 @@ function renderGenericAccount(account: ChannelAccountSnapshot) {
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last inbound</span>
|
||||
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
|
||||
<span>${account.lastInboundAt ? formatRelativeTimestamp(account.lastInboundAt) : "n/a"}</span>
|
||||
</div>
|
||||
${
|
||||
account.lastError
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { html, nothing } from "lit";
|
||||
import type { WhatsAppStatus } from "../types.ts";
|
||||
import type { ChannelsProps } from "./channels.types.ts";
|
||||
import { formatAgo } from "../format.ts";
|
||||
import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts";
|
||||
import { renderChannelConfigSection } from "./channels.config.ts";
|
||||
import { formatDuration } from "./channels.shared.ts";
|
||||
|
||||
export function renderWhatsAppCard(params: {
|
||||
props: ChannelsProps;
|
||||
@@ -38,19 +37,19 @@ export function renderWhatsAppCard(params: {
|
||||
<div>
|
||||
<span class="label">Last connect</span>
|
||||
<span>
|
||||
${whatsapp?.lastConnectedAt ? formatAgo(whatsapp.lastConnectedAt) : "n/a"}
|
||||
${whatsapp?.lastConnectedAt ? formatRelativeTimestamp(whatsapp.lastConnectedAt) : "n/a"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Last message</span>
|
||||
<span>
|
||||
${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"}
|
||||
${whatsapp?.lastMessageAt ? formatRelativeTimestamp(whatsapp.lastMessageAt) : "n/a"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Auth age</span>
|
||||
<span>
|
||||
${whatsapp?.authAgeMs != null ? formatDuration(whatsapp.authAgeMs) : "n/a"}
|
||||
${whatsapp?.authAgeMs != null ? formatDurationHuman(whatsapp.authAgeMs) : "n/a"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { html, nothing } from "lit";
|
||||
import type { ChannelUiMetaEntry, CronJob, CronRunLogEntry, CronStatus } from "../types.ts";
|
||||
import type { CronFormState } from "../ui-types.ts";
|
||||
import { formatAgo, formatMs } from "../format.ts";
|
||||
import { formatRelativeTimestamp, formatMs } from "../format.ts";
|
||||
import { pathForTab } from "../navigation.ts";
|
||||
import { formatCronSchedule, formatNextRun } from "../presenter.ts";
|
||||
|
||||
@@ -482,7 +482,7 @@ function formatStateRelative(ms?: number) {
|
||||
if (typeof ms !== "number" || !Number.isFinite(ms)) {
|
||||
return "n/a";
|
||||
}
|
||||
return formatAgo(ms);
|
||||
return formatRelativeTimestamp(ms);
|
||||
}
|
||||
|
||||
function renderJobState(job: CronJob) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
ExecApprovalsFile,
|
||||
ExecApprovalsSnapshot,
|
||||
} from "../controllers/exec-approvals.ts";
|
||||
import { clampText, formatAgo, formatList } from "../format.ts";
|
||||
import { clampText, formatRelativeTimestamp, formatList } from "../format.ts";
|
||||
|
||||
export type NodesProps = {
|
||||
loading: boolean;
|
||||
@@ -130,7 +130,7 @@ function renderDevices(props: NodesProps) {
|
||||
|
||||
function renderPendingDevice(req: PendingDevice, props: NodesProps) {
|
||||
const name = req.displayName?.trim() || req.deviceId;
|
||||
const age = typeof req.ts === "number" ? formatAgo(req.ts) : "n/a";
|
||||
const age = typeof req.ts === "number" ? formatRelativeTimestamp(req.ts) : "n/a";
|
||||
const role = req.role?.trim() ? `role: ${req.role}` : "role: -";
|
||||
const repair = req.isRepair ? " · repair" : "";
|
||||
const ip = req.remoteIp ? ` · ${req.remoteIp}` : "";
|
||||
@@ -189,7 +189,7 @@ function renderPairedDevice(device: PairedDevice, props: NodesProps) {
|
||||
function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: NodesProps) {
|
||||
const status = token.revokedAtMs ? "revoked" : "active";
|
||||
const scopes = `scopes: ${formatList(token.scopes)}`;
|
||||
const when = formatAgo(token.rotatedAtMs ?? token.createdAtMs ?? token.lastUsedAtMs ?? null);
|
||||
const when = formatRelativeTimestamp(token.rotatedAtMs ?? token.createdAtMs ?? token.lastUsedAtMs ?? null);
|
||||
return html`
|
||||
<div class="row" style="justify-content: space-between; gap: 8px;">
|
||||
<div class="list-sub">${token.role} · ${status} · ${scopes} · ${when}</div>
|
||||
@@ -931,7 +931,7 @@ function renderAllowlistEntry(
|
||||
entry: ExecApprovalsAllowlistEntry,
|
||||
index: number,
|
||||
) {
|
||||
const lastUsed = entry.lastUsedAt ? formatAgo(entry.lastUsedAt) : "never";
|
||||
const lastUsed = entry.lastUsedAt ? formatRelativeTimestamp(entry.lastUsedAt) : "never";
|
||||
const lastCommand = entry.lastUsedCommand ? clampText(entry.lastUsedCommand, 120) : null;
|
||||
const lastPath = entry.lastResolvedPath ? clampText(entry.lastResolvedPath, 120) : null;
|
||||
return html`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { html } from "lit";
|
||||
import type { GatewayHelloOk } from "../gateway.ts";
|
||||
import type { UiSettings } from "../storage.ts";
|
||||
import { formatAgo, formatDurationMs } from "../format.ts";
|
||||
import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts";
|
||||
import { formatNextRun } from "../presenter.ts";
|
||||
|
||||
export type OverviewProps = {
|
||||
@@ -26,7 +26,7 @@ export function renderOverview(props: OverviewProps) {
|
||||
const snapshot = props.hello?.snapshot as
|
||||
| { uptimeMs?: number; policy?: { tickIntervalMs?: number } }
|
||||
| undefined;
|
||||
const uptime = snapshot?.uptimeMs ? formatDurationMs(snapshot.uptimeMs) : "n/a";
|
||||
const uptime = snapshot?.uptimeMs ? formatDurationHuman(snapshot.uptimeMs) : "n/a";
|
||||
const tick = snapshot?.policy?.tickIntervalMs ? `${snapshot.policy.tickIntervalMs}ms` : "n/a";
|
||||
const authHint = (() => {
|
||||
if (props.connected || !props.lastError) {
|
||||
@@ -198,7 +198,7 @@ export function renderOverview(props: OverviewProps) {
|
||||
<div class="stat">
|
||||
<div class="stat-label">Last Channels Refresh</div>
|
||||
<div class="stat-value">
|
||||
${props.lastChannelsRefresh ? formatAgo(props.lastChannelsRefresh) : "n/a"}
|
||||
${props.lastChannelsRefresh ? formatRelativeTimestamp(props.lastChannelsRefresh) : "n/a"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { html, nothing } from "lit";
|
||||
import type { GatewaySessionRow, SessionsListResult } from "../types.ts";
|
||||
import { formatAgo } from "../format.ts";
|
||||
import { formatRelativeTimestamp } from "../format.ts";
|
||||
import { pathForTab } from "../navigation.ts";
|
||||
import { formatSessionTokens } from "../presenter.ts";
|
||||
|
||||
@@ -221,7 +221,7 @@ function renderRow(
|
||||
onDelete: SessionsProps["onDelete"],
|
||||
disabled: boolean,
|
||||
) {
|
||||
const updated = row.updatedAt ? formatAgo(row.updatedAt) : "n/a";
|
||||
const updated = row.updatedAt ? formatRelativeTimestamp(row.updatedAt) : "n/a";
|
||||
const rawThinking = row.thinkingLevel ?? "";
|
||||
const isBinaryThinking = isBinaryThinkingProvider(row.modelProvider);
|
||||
const thinking = resolveThinkLevelDisplay(rawThinking, isBinaryThinking);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { html, svg, nothing } from "lit";
|
||||
import { formatDurationCompact } from "../../../../src/infra/format-duration.ts";
|
||||
import { extractQueryTerms, filterSessionsByQuery, parseToolSummary } from "../usage-helpers.ts";
|
||||
|
||||
// Inline styles for usage view (app uses light DOM, so static styles don't work)
|
||||
@@ -2461,19 +2462,6 @@ function formatIsoDate(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function formatDurationShort(ms?: number): string {
|
||||
if (!ms || ms <= 0) {
|
||||
return "0s";
|
||||
}
|
||||
if (ms >= 60_000) {
|
||||
return `${Math.round(ms / 60000)}m`;
|
||||
}
|
||||
if (ms >= 1000) {
|
||||
return `${Math.round(ms / 1000)}s`;
|
||||
}
|
||||
return `${Math.round(ms)}ms`;
|
||||
}
|
||||
|
||||
function parseYmdDate(dateStr: string): Date | null {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr);
|
||||
if (!match) {
|
||||
@@ -2500,23 +2488,6 @@ function formatFullDate(dateStr: string): string {
|
||||
return date.toLocaleDateString(undefined, { month: "long", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
||||
function formatDurationMs(ms?: number): string {
|
||||
if (!ms || ms <= 0) {
|
||||
return "—";
|
||||
}
|
||||
const totalSeconds = Math.round(ms / 1000);
|
||||
const seconds = totalSeconds % 60;
|
||||
const minutes = Math.floor(totalSeconds / 60) % 60;
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
function downloadTextFile(filename: string, content: string, type = "text/plain") {
|
||||
const blob = new Blob([content], { type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -3467,7 +3438,7 @@ function renderUsageInsights(
|
||||
stats.throughputCostPerMin !== undefined
|
||||
? `${formatCost(stats.throughputCostPerMin, 4)} / min`
|
||||
: "—";
|
||||
const avgDurationLabel = stats.durationCount > 0 ? formatDurationShort(stats.avgDurationMs) : "—";
|
||||
const avgDurationLabel = stats.durationCount > 0 ? (formatDurationCompact(stats.avgDurationMs, { spaced: true }) ?? "—") : "—";
|
||||
const cacheHint = "Cache hit rate = cache read / (input + cache read). Higher is better.";
|
||||
const errorHint = "Error rate = errors / total messages. Lower is better.";
|
||||
const throughputHint = "Throughput shows tokens per minute over active time. Higher is better.";
|
||||
@@ -3672,7 +3643,7 @@ function renderSessionsCard(
|
||||
parts.push(`errors:${s.usage.messageCounts.errors}`);
|
||||
}
|
||||
if (showColumn("duration") && s.usage?.durationMs) {
|
||||
parts.push(`dur:${formatDurationMs(s.usage.durationMs)}`);
|
||||
parts.push(`dur:${formatDurationCompact(s.usage.durationMs, { spaced: true }) ?? "—"}`);
|
||||
}
|
||||
return parts;
|
||||
};
|
||||
@@ -3976,7 +3947,7 @@ function renderSessionSummary(session: UsageSessionEntry) {
|
||||
</div>
|
||||
<div class="session-summary-card">
|
||||
<div class="session-summary-title">Duration</div>
|
||||
<div class="session-summary-value">${formatDurationMs(usage.durationMs)}</div>
|
||||
<div class="session-summary-value">${formatDurationCompact(usage.durationMs, { spaced: true }) ?? "—"}</div>
|
||||
<div class="session-summary-meta">${formatTs(usage.firstActivity)} → ${formatTs(usage.lastActivity)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user