feat(FN-2512): add elapsed timer chip to task cards
- Derive task elapsed time from columnMovedAt with updatedAt/createdAt fallbacks and guard against invalid or future timestamps - Render a clock-based timer chip on in-progress and done cards with accessible labeling and tooltip metadata - Add TaskCard styles for the timer row/chip using design tokens, including mobile-size adjustments - Expand TaskCard tests to cover visibility by column, invalid timestamp suppression, boundary label formatting, and 30s live refresh cadence
This commit is contained in:
@@ -115,6 +115,15 @@ afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
async function waitForFrameContains(lastFrame: () => string | undefined, text: string, timeoutMs = 1200) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if ((lastFrame() ?? "").includes(text)) return;
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
throw new Error(`Timed out waiting for frame to include: ${text}`);
|
||||
}
|
||||
|
||||
describe("DashboardApp smoke", () => {
|
||||
it("renders the splash logo and tagline before systemInfo arrives", () => {
|
||||
const controller = newController();
|
||||
@@ -255,6 +264,45 @@ describe("Agents view", () => {
|
||||
expect(lastFrame() ?? "").toContain("Agent Detail");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("shows run history with readable status labels and opens run logs for the selected run", async () => {
|
||||
const controller = newController();
|
||||
controller.setSystemInfo(makeSystemInfo());
|
||||
|
||||
const agents: AgentItem[] = [
|
||||
{ id: "a1", name: "worker-1", state: "active", role: "executor" },
|
||||
];
|
||||
const detail: AgentDetailItem = {
|
||||
id: "a1",
|
||||
name: "worker-1",
|
||||
state: "active",
|
||||
role: "executor",
|
||||
capabilities: ["executor"],
|
||||
recentRuns: [
|
||||
{
|
||||
id: "run-1",
|
||||
startedAt: new Date().toISOString(),
|
||||
endedAt: new Date().toISOString(),
|
||||
status: "completed",
|
||||
stdoutExcerpt: "plan generated",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
controller.setInteractiveData(makeInteractiveData({ agents, detail }));
|
||||
controller.setMode("interactive");
|
||||
controller.setInteractiveView("agents");
|
||||
|
||||
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
|
||||
await waitForFrameContains(lastFrame, "Run history (latest first):");
|
||||
await waitForFrameContains(lastFrame, "Completed");
|
||||
|
||||
stdin.write("\r");
|
||||
await waitForFrameContains(lastFrame, "Run logs (1)");
|
||||
await waitForFrameContains(lastFrame, "plan generated");
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Settings view", () => {
|
||||
|
||||
@@ -35,6 +35,7 @@ import type {
|
||||
TaskItem,
|
||||
AgentItem,
|
||||
AgentDetailItem,
|
||||
AgentRunItem,
|
||||
ModelItem,
|
||||
SettingsValues,
|
||||
InteractiveView,
|
||||
@@ -371,7 +372,7 @@ function StatsPanel({ state, isFocused }: { state: DashboardState; isFocused: bo
|
||||
</StatRow>
|
||||
|
||||
<SectionHeader title="System" />
|
||||
<StatRow label="Memory">
|
||||
<StatRow label="MEM">
|
||||
<Text color={sysMemColor(sys.systemTotalMem - sys.systemFreeMem, sys.systemTotalMem)}>
|
||||
{formatBytes(sys.systemTotalMem - sys.systemFreeMem)}
|
||||
</Text>
|
||||
@@ -1712,6 +1713,58 @@ function heartbeatFreshness(lastHeartbeatAt?: string): { fresh: boolean; label:
|
||||
|
||||
type AgentSubView = "list" | "confirm-delete";
|
||||
|
||||
function formatRunStatusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "Completed";
|
||||
case "failed":
|
||||
return "Failed";
|
||||
case "terminated":
|
||||
return "Terminated";
|
||||
case "active":
|
||||
return "Active";
|
||||
default:
|
||||
return status.length > 0 ? `${status[0]!.toUpperCase()}${status.slice(1)}` : "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function runStatusColor(status: string): "green" | "red" | "yellow" | "cyanBright" | "gray" {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "green";
|
||||
case "failed":
|
||||
return "red";
|
||||
case "terminated":
|
||||
return "yellow";
|
||||
case "active":
|
||||
return "cyanBright";
|
||||
default:
|
||||
return "gray";
|
||||
}
|
||||
}
|
||||
|
||||
function getRunLogLines(run: AgentRunItem): string[] {
|
||||
if (Array.isArray(run.logs) && run.logs.length > 0) return run.logs;
|
||||
|
||||
const lines: string[] = [];
|
||||
if (run.triggerDetail) lines.push(`trigger: ${run.triggerDetail}`);
|
||||
if (run.invocationSource) lines.push(`source: ${run.invocationSource}`);
|
||||
if (run.stdoutExcerpt) {
|
||||
lines.push("stdout:");
|
||||
lines.push(...run.stdoutExcerpt.split(/\r?\n/).filter((line) => line.length > 0));
|
||||
}
|
||||
if (run.stderrExcerpt) {
|
||||
lines.push("stderr:");
|
||||
lines.push(...run.stderrExcerpt.split(/\r?\n/).filter((line) => line.length > 0));
|
||||
}
|
||||
if (run.resultJson) {
|
||||
lines.push("result:");
|
||||
lines.push(JSON.stringify(run.resultJson));
|
||||
}
|
||||
|
||||
return lines.length > 0 ? lines : ["No logs captured for this run."];
|
||||
}
|
||||
|
||||
function AgentsView({ state }: { state: DashboardState }) {
|
||||
const { stdout } = useStdout();
|
||||
const cols = stdout?.columns ?? 80;
|
||||
@@ -1724,6 +1777,8 @@ function AgentsView({ state }: { state: DashboardState }) {
|
||||
const [subView, setSubView] = useState<AgentSubView>("list");
|
||||
const [statusMsg, setStatusMsg] = useState<string | null>(null);
|
||||
const [detailFocused, setDetailFocused] = useState(false);
|
||||
const [selectedRunIndex, setSelectedRunIndex] = useState(0);
|
||||
const [showRunLogs, setShowRunLogs] = useState(false);
|
||||
|
||||
const data = state.interactiveData;
|
||||
|
||||
@@ -1733,6 +1788,8 @@ function AgentsView({ state }: { state: DashboardState }) {
|
||||
}, [data]);
|
||||
|
||||
const selectedAgent = agents[selectedIndex] ?? null;
|
||||
const recentRuns = detail?.recentRuns ?? [];
|
||||
const selectedRun = recentRuns[selectedRunIndex] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || !selectedAgent) {
|
||||
@@ -1749,6 +1806,15 @@ function AgentsView({ state }: { state: DashboardState }) {
|
||||
});
|
||||
}, [data, selectedAgent?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedRunIndex(0);
|
||||
setShowRunLogs(false);
|
||||
}, [selectedAgent?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedRunIndex((i) => Math.min(i, Math.max(0, recentRuns.length - 1)));
|
||||
}, [recentRuns.length]);
|
||||
|
||||
function refreshDetail() {
|
||||
if (!data || !selectedAgent) return;
|
||||
setLoadingDetail(true);
|
||||
@@ -1801,6 +1867,12 @@ function AgentsView({ state }: { state: DashboardState }) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((key.return || input === "l") && selectedRun) {
|
||||
setDetailFocused(true);
|
||||
setShowRunLogs(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!detailFocused) {
|
||||
if (key.upArrow || input === "k") {
|
||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
||||
@@ -1810,6 +1882,19 @@ function AgentsView({ state }: { state: DashboardState }) {
|
||||
setSelectedIndex((i) => Math.min(agents.length - 1, i + 1));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (showRunLogs && (key.escape || input === "q" || key.backspace)) {
|
||||
setShowRunLogs(false);
|
||||
return;
|
||||
}
|
||||
if (!showRunLogs && (key.upArrow || input === "k")) {
|
||||
setSelectedRunIndex((i) => Math.max(0, i - 1));
|
||||
return;
|
||||
}
|
||||
if (!showRunLogs && (key.downArrow || input === "j")) {
|
||||
setSelectedRunIndex((i) => Math.min(recentRuns.length - 1, i + 1));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (input === "s") {
|
||||
@@ -1964,19 +2049,36 @@ function AgentsView({ state }: { state: DashboardState }) {
|
||||
<Text>{detail.capabilities.join(", ")}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{detail.recentRuns.length > 0 && (
|
||||
{recentRuns.length > 0 && (
|
||||
<>
|
||||
<Box height={1} />
|
||||
<Text dimColor>Recent runs (latest first):</Text>
|
||||
{detail.recentRuns.slice(0, 5).map((run) => (
|
||||
<Box key={run.id} flexDirection="row" gap={1} marginLeft={1}>
|
||||
<Text color={run.status === "completed" ? "green" : run.status === "failed" ? "red" : "yellow"}>
|
||||
{run.status.slice(0, 4)}
|
||||
</Text>
|
||||
<Text dimColor>{run.startedAt.slice(11, 19)}</Text>
|
||||
{run.triggerDetail && <Text dimColor>{run.triggerDetail}</Text>}
|
||||
</Box>
|
||||
))}
|
||||
{showRunLogs && selectedRun ? (
|
||||
<>
|
||||
<Text dimColor>Run logs ({selectedRunIndex + 1})</Text>
|
||||
<Text dimColor>ID: {selectedRun.id}</Text>
|
||||
<Box height={1} />
|
||||
{getRunLogLines(selectedRun).slice(0, 10).map((line, i) => (
|
||||
<Text key={`${selectedRun.id}-log-${i}`} wrap="truncate-end">{line}</Text>
|
||||
))}
|
||||
<Box height={1} />
|
||||
<Text dimColor>[Esc/q] back to runs</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text dimColor>Run history (latest first):</Text>
|
||||
{recentRuns.slice(0, 5).map((run, i) => (
|
||||
<Box key={run.id} flexDirection="row" gap={1} marginLeft={1}>
|
||||
<Text color={detailFocused && i === selectedRunIndex ? "white" : "gray"}>
|
||||
{detailFocused && i === selectedRunIndex ? "▶" : " "}
|
||||
</Text>
|
||||
<Text color={runStatusColor(run.status)}>{formatRunStatusLabel(run.status)}</Text>
|
||||
<Text dimColor>{run.startedAt.slice(11, 19)}</Text>
|
||||
{run.triggerDetail && <Text dimColor wrap="truncate-end">{run.triggerDetail}</Text>}
|
||||
</Box>
|
||||
))}
|
||||
<Text dimColor>[Enter] open logs</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -113,6 +113,12 @@ export interface AgentRunItem {
|
||||
endedAt: string | null;
|
||||
status: string;
|
||||
triggerDetail?: string;
|
||||
invocationSource?: string;
|
||||
stdoutExcerpt?: string;
|
||||
stderrExcerpt?: string;
|
||||
resultJson?: Record<string, unknown>;
|
||||
// Optional synthetic log lines for tests / alternate data providers.
|
||||
logs?: string[];
|
||||
}
|
||||
|
||||
// Slim agent detail shape for Agents view detail panel
|
||||
|
||||
@@ -1904,6 +1904,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
endedAt: r.endedAt,
|
||||
status: r.status,
|
||||
triggerDetail: r.triggerDetail,
|
||||
invocationSource: r.invocationSource,
|
||||
stdoutExcerpt: r.stdoutExcerpt,
|
||||
stderrExcerpt: r.stderrExcerpt,
|
||||
resultJson: r.resultJson,
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user