diff --git a/Makefile b/Makefile index f769ed092..f6b4b3836 100644 --- a/Makefile +++ b/Makefile @@ -47,8 +47,8 @@ openapi: --output "$$output" \ --outputTypes yaml --parseInternal; \ python3 scripts/patch-agents-openapi.py "$$output/swagger.yaml"; \ - go run ./scripts/openapi-split $(OPENAPI_FLAGS) "$$output/swagger.yaml" "$$output/extensions.json" contracts/agents-api/core.openapi.yaml contracts/agents-api/runtime.openapi.yaml; \ - python3 scripts/generate-public-api.py $(OPENAPI_FLAGS) --extensions "$$output/extensions.json" + go run ./scripts/openapi-split $(OPENAPI_FLAGS) "$$output/swagger.yaml" "$$output/extensions.json" "$$output/core.json" contracts/agents-api/core.openapi.yaml contracts/agents-api/runtime.openapi.yaml; \ + python3 scripts/generate-public-api.py $(OPENAPI_FLAGS) --extensions "$$output/extensions.json" --core "$$output/core.json" check-sqlc: python3 scripts/check-sqlc.py diff --git a/apps/web/src/features/agents/AgentsPage.tsx b/apps/web/src/features/agents/AgentsPage.tsx index e3f9fab0e..af7e82846 100644 --- a/apps/web/src/features/agents/AgentsPage.tsx +++ b/apps/web/src/features/agents/AgentsPage.tsx @@ -21,8 +21,7 @@ import { useAgentDetail } from "../resources/detail-queries"; function coverage(summary: ProjectSummary | undefined, locale?: string): string { - if (!summary || summary.coverage.sessions === 0) return MISSING; - return formatPercent(summary.coverage.reported / summary.coverage.sessions, locale); + return summary ? formatPercent(summary.coverage.ratio, locale) : MISSING; } /** diff --git a/apps/web/src/features/api-keys/KeyFlowDialogs.test.tsx b/apps/web/src/features/api-keys/KeyFlowDialogs.test.tsx index df1cedf90..bfe3ab950 100644 --- a/apps/web/src/features/api-keys/KeyFlowDialogs.test.tsx +++ b/apps/web/src/features/api-keys/KeyFlowDialogs.test.tsx @@ -3,15 +3,14 @@ import type { ReactElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; -import { AgentCoreError } from "@oac/agents-client"; +import { type AdminIssuedAPIKey, type AdminProject, AgentCoreError } from "@oac/agents-client"; import { flowError, keyFlowReducer, type KeyFlow } from "./key-flows"; import { KeyFlowDialogs, PendingKeyNotice } from "./KeyFlowDialogs"; import type { KeyFlowControls } from "./use-key-flow"; -import { type AdminIssuedKey, type Project } from "../../lib/admin-view"; -const project: Project = { id: "proj_7f3a91c2", name: "Production", status: "active", created_at: 100, archived_at: null, active_key_count: 1 }; +const project: AdminProject = { id: "proj_7f3a91c2", name: "Production", created_at: "1970-01-01T00:01:40Z", archived_at: null, active_key_count: 1 }; const secret = "pc_live_" + "s".repeat(40); -const issued: AdminIssuedKey = { id: "9f0e1d2c-3b4a-4c5d-8e6f-7a8b9c0d1e2f", project_id: "proj_7f3a91c2", name: "bob-laptop", prefix: "pc_live_Zq8", created_at: 300, revoked_at: null, key: secret }; +const issued: AdminIssuedAPIKey = { id: "9f0e1d2c-3b4a-4c5d-8e6f-7a8b9c0d1e2f", project_id: "proj_7f3a91c2", name: "bob-laptop", prefix: "pc_live_Zq8", created_at: "1970-01-01T00:05:00Z", revoked_at: null, key: secret }; const controls = (flow: KeyFlow): KeyFlowControls => ({ flow, dispatch: () => undefined, submit: async () => undefined }); // The how-to-call card under a new key reads the installation through the query cache. const render = (element: ReactElement) => renderToStaticMarkup({element}); diff --git a/apps/web/src/features/api-keys/ProjectDetail.tsx b/apps/web/src/features/api-keys/ProjectDetail.tsx index 435c0d9b2..98ea1cb25 100644 --- a/apps/web/src/features/api-keys/ProjectDetail.tsx +++ b/apps/web/src/features/api-keys/ProjectDetail.tsx @@ -1,3 +1,4 @@ +import type { AdminAPIKey, AdminProject } from "@oac/agents-client"; import { useQuery } from "@tanstack/react-query"; import { Plus } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -5,7 +6,7 @@ import { useTranslation } from "react-i18next"; import { EmptyState, HelpTip, Kpi, KpiStrip, Section, StatusDot } from "../../components/console-ui"; import { CopyableId, RowActions } from "../../components/list-ui"; -import { formatCompact, formatDateTime, formatInteger, formatRelative } from "../../lib/format"; +import { epochSeconds, formatCompact, formatDateTime, formatInteger, formatRelative } from "../../lib/format"; import { useConsoleNavigation } from "../../lib/console-navigation"; import type { ConsoleView } from "../../lib/console-routes"; import { admin } from "../../lib/projects"; @@ -14,11 +15,11 @@ import { prefixLabel } from "./key-flows"; import { loadedFrom, projectKeysQuery, projectSummaryQuery, type Loaded } from "./project-queries"; import { ProjectStatus } from "./ProjectStatus"; import { WriteOperations } from "./WriteOperations"; -import { type AdminKey, type Project, type ProjectSummary } from "../../lib/admin-view"; +import { type ProjectSummary } from "../../lib/admin-view"; import { TableSkeleton } from "../../components/Skeleton"; /** A project's keys, active first, from the query cache. */ -export function useProjectKeys(projectId: string | null): Loaded & { retry: () => void } { +export function useProjectKeys(projectId: string | null): Loaded & { retry: () => void } { const query = useQuery({ ...projectKeysQuery(projectId ?? ""), enabled: projectId !== null }); const { refetch } = query; return { ...loadedFrom(query), retry: () => { void refetch(); } }; @@ -38,11 +39,11 @@ const assetLinks: ReadonlyArray<{ key: "agents" | "environment_templates" | "ski * write-operation history. */ export function ProjectDetail({ project, keys, busy, onIssue, onRevoke }: { - project: Project; + project: AdminProject; keys: ReturnType; busy: boolean; onIssue: () => void; - onRevoke: (key: AdminKey, activeCount: number) => void; + onRevoke: (key: AdminAPIKey, activeCount: number) => void; }) { const { t, i18n } = useTranslation("keys"); const { t: tCommon } = useTranslation("common"); @@ -73,8 +74,8 @@ export function ProjectDetail({ project, keys, busy, onIssue, onRevoke }: {
{t("detail.id")}
{t("detail.status")}
-
{t("detail.created")}
{formatDateTime(project.created_at, locale)}
- {project.archived_at !== null ?
{t("detail.archived")}
{formatDateTime(project.archived_at, locale)}
: null} +
{t("detail.created")}
{formatDateTime(epochSeconds(project.created_at), locale)}
+ {project.archived_at !== null ?
{t("detail.archived")}
{formatDateTime(epochSeconds(project.archived_at), locale)}
: null}
{t("detail.lastActive")}
{formatRelative(summary?.last_active_at, now, locale)}
@@ -117,7 +118,7 @@ export function ProjectDetail({ project, keys, busy, onIssue, onRevoke }: { @@ -132,7 +133,7 @@ export function ProjectDetail({ project, keys, busy, onIssue, onRevoke }: { ) : null}} help={t("detail.keysHelp")} - actions={project.status === "active" ? ( + actions={project.archived_at === null ? ( @@ -145,7 +146,7 @@ export function ProjectDetail({ project, keys, busy, onIssue, onRevoke }: { ) : !keys.value.length && !unknownUsage ? ( {t("actions.issue")} : undefined} + action={project.archived_at === null ? : undefined} /> ) : (
@@ -177,13 +178,13 @@ export function ProjectDetail({ project, keys, busy, onIssue, onRevoke }: { - {revoked ? {formatDateTime(key.revoked_at, locale)} : null} + {revoked ? {formatDateTime(epochSeconds(key.revoked_at), locale)} : null} - {formatDateTime(key.created_at, locale)} + {formatDateTime(epochSeconds(key.created_at), locale)} {keyUsage(byKey?.get(key.id))} - {!revoked && project.status === "active" ? ( + {!revoked && project.archived_at === null ? (
@@ -379,12 +378,12 @@ function usageTitle(summary: ProjectSummary, t: TFunction<"overview">, locale: s return t("projects.tokenDetail", { input: number(summary.usage.input_tokens), output: number(summary.usage.output_tokens), - cached: number(summary.usage.cached_tokens), - reasoning: number(summary.usage.reasoning_tokens), + cached: number(summary.usage.input_tokens_details.cached_tokens), + reasoning: number(summary.usage.output_tokens_details.reasoning_tokens), }); } -function ProjectUsageTable({ rows, failed, now, onOpen }: { rows: ProjectUsageRow[] | null; failed: boolean; now: number; onOpen: (project: Project) => void }) { +function ProjectUsageTable({ rows, failed, now, onOpen }: { rows: ProjectUsageRow[] | null; failed: boolean; now: number; onOpen: (project: AdminProject) => void }) { const { t, i18n } = useTranslation("overview"); const { t: tCommon } = useTranslation("common"); const locale = i18n.resolvedLanguage; @@ -409,14 +408,13 @@ function ProjectUsageTable({ rows, failed, now, onOpen }: { rows: ProjectUsageRo {rows.map(({ project, summary }) => { - const coverage = summary ? coverageRatio(summary.coverage) : null; return ( onOpen(project)}> - {project.status === "archived" ? {t("projects.archived")} : null} + {project.archived_at !== null ? {t("projects.archived")} : null} {count(project.active_key_count)} {count(summary?.sessions.total)} @@ -424,8 +422,8 @@ function ProjectUsageTable({ rows, failed, now, onOpen }: { rows: ProjectUsageRo {count(summary?.sessions.requires_action)} {count(summary?.sessions.failed)} {summary?.usage ? formatCompact(summary.usage.total_tokens, locale) : MISSING} - - {formatPercent(coverage, locale)} + + {formatPercent(summary?.coverage.ratio, locale)} {formatRelative(summary?.last_active_at ?? null, now, locale)} diff --git a/apps/web/src/features/overview/getting-started.test.ts b/apps/web/src/features/overview/getting-started.test.ts index 0194079ae..97249ca59 100644 --- a/apps/web/src/features/overview/getting-started.test.ts +++ b/apps/web/src/features/overview/getting-started.test.ts @@ -75,9 +75,9 @@ describe("Getting started steps", () => { it("needs an active project with an active key, and any Session", () => { const steps = (projects: Parameters[0]["projects"], sessions: number | "failed" | null = 0) => gettingStartedSteps({ sandboxReset: false, fleet: { status: "loading" }, projects, sessions, harnesses: undefined }); expect(steps([]).key).toEqual({ state: "todo", project: null }); - const older = project("p1", { active_key_count: 0, created_at: 1 }); - const newer = project("p2", { active_key_count: 0, created_at: 2 }); - expect(steps([older, newer, project("p3", { status: "archived", active_key_count: 0, created_at: 3 })]).key).toEqual({ state: "todo", project: newer }); + const older = project("p1", { active_key_count: 0, created_at: "1970-01-01T00:00:01Z" }); + const newer = project("p2", { active_key_count: 0, created_at: "1970-01-01T00:00:02Z" }); + expect(steps([older, newer, project("p3", { archived_at: "1970-01-01T00:00:04Z", active_key_count: 0, created_at: "1970-01-01T00:00:03Z" })]).key).toEqual({ state: "todo", project: newer }); expect(steps([older, project("p4")]).key.state).toBe("done"); expect(steps(undefined).key.state).toBeNull(); expect(steps("failed").key.state).toBe("unknown"); @@ -86,9 +86,9 @@ describe("Getting started steps", () => { it("opens the call samples of the newest active project with a key, else of the newest active project", () => { const call = (projects: Parameters[0]["projects"]) => gettingStartedSteps({ sandboxReset: false, fleet: { status: "loading" }, projects, sessions: 0, harnesses: undefined }).session.project; - const keyed = project("p1", { created_at: 1 }); - const newer = project("p2", { active_key_count: 0, created_at: 2 }); - const archived = project("p3", { status: "archived", active_key_count: 0, created_at: 3 }); + const keyed = project("p1", { created_at: "1970-01-01T00:00:01Z" }); + const newer = project("p2", { active_key_count: 0, created_at: "1970-01-01T00:00:02Z" }); + const archived = project("p3", { archived_at: "1970-01-01T00:00:04Z", active_key_count: 0, created_at: "1970-01-01T00:00:03Z" }); expect(call([keyed, newer, archived])).toBe(keyed); expect(call([newer, archived])).toBe(newer); expect([call([archived]), call(undefined), call("failed")]).toEqual([null, null, null]); diff --git a/apps/web/src/features/overview/getting-started.ts b/apps/web/src/features/overview/getting-started.ts index 915595455..adc43a39c 100644 --- a/apps/web/src/features/overview/getting-started.ts +++ b/apps/web/src/features/overview/getting-started.ts @@ -1,6 +1,5 @@ -import type { CoreHarness } from "@oac/agents-client"; +import type { AdminProject, CoreHarness } from "@oac/agents-client"; -import { type Project } from "../../lib/admin-view"; import { nodeServingReady } from "../fleet/fleet-model"; import { type FleetState } from "../fleet/use-sandbox-fleet"; import { templateBuildStatus } from "../sandbox/deployment-specification"; @@ -20,9 +19,9 @@ export interface GettingStartedSteps { sandboxes: { state: StepState; action: SandboxAction; cloud: boolean }; model: StepState; /** `project` is the active project a key would be issued for; null means create one first. */ - key: { state: StepState; project: Project | null }; + key: { state: StepState; project: AdminProject | null }; /** `project` is the active project whose call samples the step opens; null leads to the project list. */ - session: { state: StepState; project: Project | null }; + session: { state: StepState; project: AdminProject | null }; } export function gettingStartedSteps(input: { @@ -32,7 +31,7 @@ export function gettingStartedSteps(input: { /** Undefined while reading; a failed installation read cannot confirm readiness. */ localOnly?: boolean | "failed"; /** Undefined until the project list is read. */ - projects: readonly Project[] | "failed" | undefined; + projects: readonly AdminProject[] | "failed" | undefined; /** Sessions in every project, by Core's summary; null until it is read. */ sessions: number | "failed" | null; /** Core's harnesses; undefined until they are read. */ @@ -87,13 +86,13 @@ export function modelStep(harnesses: readonly CoreHarness[] | "failed" | undefin } /** The newest of the projects, most likely the one just created. */ -function newestOf(projects: readonly Project[]): Project | null { - return projects.reduce((best, project) => (!best || project.created_at > best.created_at ? project : best), null); +function newestOf(projects: readonly AdminProject[]): AdminProject | null { + return projects.reduce((best, project) => (!best || Date.parse(project.created_at) > Date.parse(best.created_at) ? project : best), null); } -function keyStep(projects: readonly Project[] | "failed" | undefined): GettingStartedSteps["key"] { +function keyStep(projects: readonly AdminProject[] | "failed" | undefined): GettingStartedSteps["key"] { if (!projects || projects === "failed") return { state: projects ? "unknown" : null, project: null }; - const active = projects.filter((project) => project.status === "active"); + const active = projects.filter((project) => project.archived_at === null); if (active.some((project) => project.active_key_count > 0)) return { state: "done", project: null }; return { state: "todo", project: newestOf(active) }; } @@ -102,9 +101,9 @@ function keyStep(projects: readonly Project[] | "failed" | undefined): GettingSt * Where the first Session's call samples are: the newest active project with * an active key, else the newest active project (whose page issues one). */ -function callProject(projects: readonly Project[] | "failed" | undefined): Project | null { +function callProject(projects: readonly AdminProject[] | "failed" | undefined): AdminProject | null { if (!projects || projects === "failed") return null; - const active = projects.filter((project) => project.status === "active"); + const active = projects.filter((project) => project.archived_at === null); return newestOf(active.filter((project) => project.active_key_count > 0)) ?? newestOf(active); } diff --git a/apps/web/src/features/overview/overview-loader.ts b/apps/web/src/features/overview/overview-loader.ts index eba9be5c2..5c3dffaae 100644 --- a/apps/web/src/features/overview/overview-loader.ts +++ b/apps/web/src/features/overview/overview-loader.ts @@ -1,8 +1,8 @@ -import type { AgentSession } from "@oac/agents-client"; +import type { AdminProject, AgentSession } from "@oac/agents-client"; import { readProjectsSessions, type InProject, type ProjectReadFailure, type SessionLister } from "../metrics/project-sessions"; import { activityStart, attentionCount, overviewReadDone, projectRows } from "./overview-model"; -import { type Project, type ProjectSummary } from "../../lib/admin-view"; +import { type ProjectSummary } from "../../lib/admin-view"; /** Most Sessions read per project for the 24-hour activity and the attention list. */ export const OVERVIEW_SESSION_CAP = 1_000; @@ -10,10 +10,10 @@ export const OVERVIEW_SESSION_CAP = 1_000; export interface OverviewSessions { sessions: InProject[]; /** Projects whose read stopped at the cap before covering the window or every Session needing attention. */ - truncated: Project[]; + truncated: AdminProject[]; failures: ProjectReadFailure[]; /** Failed projects whose previously read Sessions are retained. */ - stale: Project[]; + stale: AdminProject[]; } export type OverviewSummary = @@ -31,7 +31,7 @@ export interface OverviewData { export interface OverviewSource { summary: (signal: AbortSignal) => Promise; - sessions: (project: Project) => SessionLister; + sessions: (project: AdminProject) => SessionLister; } /** @@ -48,7 +48,7 @@ export function needsSessionRead(row: ProjectSummary | undefined, since: number) } export async function loadOverview( - projects: readonly Project[], + projects: readonly AdminProject[], source: OverviewSource, nowSeconds: number, signal: AbortSignal, diff --git a/apps/web/src/features/overview/overview-model.test.ts b/apps/web/src/features/overview/overview-model.test.ts index d431ef8a3..4834d0bf1 100644 --- a/apps/web/src/features/overview/overview-model.test.ts +++ b/apps/web/src/features/overview/overview-model.test.ts @@ -8,7 +8,6 @@ import { activityStart, attentionCount, attentionSessions, - coverageRatio, isGatewayOrNetworkFailure, overviewReadDone, projectUsageRows, @@ -19,12 +18,10 @@ import { webApiReachable, } from "./overview-model"; -const usage = (total: number) => ({ input_tokens: total, output_tokens: 0, total_tokens: total, cached_tokens: 0, reasoning_tokens: 0 }); - describe("project usage rows", () => { it("joins every project with its summary row, active and recent first, missing rows as null", () => { const rows = projectUsageRows( - [project("old"), project("archived", { status: "archived" }), project("recent"), project("silent")], + [project("old"), project("archived", { archived_at: "1970-01-01T00:00:02Z" }), project("recent"), project("silent")], [ summary("old", { last_active_at: 100 }), summary("recent", { last_active_at: 500 }), @@ -37,22 +34,14 @@ describe("project usage rows", () => { expect(rows.find((row) => row.project.id === "silent")?.summary).toBeNull(); }); - it("adds up Session counts and keeps usage unknown when no project reported any", () => { - const withUsage = summaryTotals([ - summary("a", { sessions: { total: 3, idle: 1, in_progress: 1, requires_action: 0, failed: 1 }, usage: usage(10), coverage: { sessions: 3, reported: 2 } }), - summary("b", { sessions: { total: 2, idle: 1, in_progress: 0, requires_action: 1, failed: 0 }, coverage: { sessions: 2, reported: 0 } }), - summary("a", { agent_id: "agent_a", sessions: { total: 50, idle: 50, in_progress: 0, requires_action: 0, failed: 0 }, usage: usage(99) }), + it("adds up the Session counts of project rows", () => { + const totals = summaryTotals([ + summary("a", { sessions: { total: 3, idle: 1, in_progress: 1, requires_action: 0, failed: 1 } }), + summary("b", { sessions: { total: 2, idle: 1, in_progress: 0, requires_action: 1, failed: 0 } }), + summary("a", { agent_id: "agent_a", sessions: { total: 50, idle: 50, in_progress: 0, requires_action: 0, failed: 0 } }), ]); - expect(withUsage.sessions).toEqual({ total: 5, idle: 2, in_progress: 1, requires_action: 1, failed: 1 }); - expect(withUsage.usage?.total_tokens).toBe(10); - expect(withUsage.coverage).toEqual({ sessions: 5, reported: 2 }); - expect(attentionCount(withUsage.sessions)).toBe(2); - expect(summaryTotals([summary("b")]).usage).toBeNull(); - }); - - it("reports coverage only when there are Sessions", () => { - expect(coverageRatio({ sessions: 4, reported: 3 })).toBe(0.75); - expect(coverageRatio({ sessions: 0, reported: 0 })).toBeNull(); + expect(totals).toEqual({ total: 5, idle: 2, in_progress: 1, requires_action: 1, failed: 1 }); + expect(attentionCount(totals)).toBe(2); }); }); diff --git a/apps/web/src/features/overview/overview-model.ts b/apps/web/src/features/overview/overview-model.ts index ef13afa7b..338f58a72 100644 --- a/apps/web/src/features/overview/overview-model.ts +++ b/apps/web/src/features/overview/overview-model.ts @@ -1,82 +1,50 @@ -import type { AgentSession } from "@oac/agents-client"; +import type { AdminProject, AgentSession } from "@oac/agents-client"; import type { CapacitySummary } from "../fleet/fleet-model"; import type { InProject } from "../metrics/project-sessions"; -import { type Project, type ProjectSummary } from "../../lib/admin-view"; +import { type ProjectSummary } from "../../lib/admin-view"; /** Pure projections behind the Overview. Missing inputs stay null, never zero. */ -export interface SessionCounts { - total: number; - idle: number; - in_progress: number; - requires_action: number; - failed: number; -} - -export type SummaryUsage = NonNullable; +export type SessionCounts = ProjectSummary["sessions"]; export interface ProjectUsageRow { - project: Project; + project: AdminProject; /** The project's `/summary` row, or null when Core returned none for it. */ summary: ProjectSummary | null; } /** Project rows of a `/summary` response (Agent and key rows are skipped). */ export function projectRows(summary: readonly ProjectSummary[]): ProjectSummary[] { - return summary.filter((row) => row.agent_id === null && !row.key); + return summary.filter((row) => row.agent_id === null && row.key_id === null); } /** * One row per project, joined with its `/summary` row. Active projects come * first, then the most recently active. */ -export function projectUsageRows(projects: readonly Project[], summary: readonly ProjectSummary[]): ProjectUsageRow[] { +export function projectUsageRows(projects: readonly AdminProject[], summary: readonly ProjectSummary[]): ProjectUsageRow[] { const byProject = new Map(projectRows(summary).map((row) => [row.project_id, row])); return projects .map((project) => ({ project, summary: byProject.get(project.id) ?? null })) .sort((a, b) => ( - Number(a.project.status === "archived") - Number(b.project.status === "archived") + Number(a.project.archived_at !== null) - Number(b.project.archived_at !== null) || (b.summary?.last_active_at ?? -1) - (a.summary?.last_active_at ?? -1) || a.project.name.localeCompare(b.project.name) )); } -export interface SummaryTotals { - sessions: SessionCounts; - /** Sum over projects that reported usage; null when none did. */ - usage: SummaryUsage | null; - coverage: { sessions: number; reported: number }; -} - -/** Totals over every project row. */ -export function summaryTotals(summary: readonly ProjectSummary[]): SummaryTotals { +/** Session counts over every project row. */ +export function summaryTotals(summary: readonly ProjectSummary[]): SessionCounts { const sessions: SessionCounts = { total: 0, idle: 0, in_progress: 0, requires_action: 0, failed: 0 }; - const coverage = { sessions: 0, reported: 0 }; - let usage: SummaryUsage | null = null; for (const row of projectRows(summary)) { sessions.total += row.sessions.total; sessions.idle += row.sessions.idle; sessions.in_progress += row.sessions.in_progress; sessions.requires_action += row.sessions.requires_action; sessions.failed += row.sessions.failed; - coverage.sessions += row.coverage.sessions; - coverage.reported += row.coverage.reported; - if (row.usage) { - usage ??= { input_tokens: 0, output_tokens: 0, total_tokens: 0, cached_tokens: 0, reasoning_tokens: 0 }; - usage.input_tokens += row.usage.input_tokens; - usage.output_tokens += row.usage.output_tokens; - usage.total_tokens += row.usage.total_tokens; - usage.cached_tokens += row.usage.cached_tokens; - usage.reasoning_tokens += row.usage.reasoning_tokens; - } } - return { sessions, usage, coverage }; -} - -/** Share of Sessions that reported usage; null when there are none. */ -export function coverageRatio(coverage: { sessions: number; reported: number }): number | null { - return coverage.sessions > 0 ? coverage.reported / coverage.sessions : null; + return sessions; } export function attentionCount(counts: Pick): number { diff --git a/apps/web/src/features/overview/overview-queries.ts b/apps/web/src/features/overview/overview-queries.ts index 6440af462..5b95711b2 100644 --- a/apps/web/src/features/overview/overview-queries.ts +++ b/apps/web/src/features/overview/overview-queries.ts @@ -1,11 +1,12 @@ +import type { AdminProject } from "@oac/agents-client"; import { queryOptions } from "@tanstack/react-query"; -import { loadSummary, type Project } from "../../lib/admin-view"; +import { loadSummary } from "../../lib/admin-view"; import { projectClient } from "../../lib/projects"; import { loadOverview, type OverviewData } from "./overview-loader"; /** The Overview's summary and Session reads for the listed projects, keyed by their IDs. */ -export function overviewQuery(projects: readonly Project[]) { +export function overviewQuery(projects: readonly AdminProject[]) { const queryKey = ["overview", projects.map((project) => project.id)]; return queryOptions({ queryKey, diff --git a/apps/web/src/features/overview/test-fixtures.ts b/apps/web/src/features/overview/test-fixtures.ts index 96ed55aac..e11dfcea3 100644 --- a/apps/web/src/features/overview/test-fixtures.ts +++ b/apps/web/src/features/overview/test-fixtures.ts @@ -1,10 +1,10 @@ -import type { AgentSession, SandboxNode } from "@oac/agents-client"; -import { type OwnedRuntimeObservation, type Project, type ProjectSummary } from "../../lib/admin-view"; +import type { AdminProject, AgentSession, SandboxNode } from "@oac/agents-client"; +import { type OwnedRuntimeObservation, type ProjectSummary } from "../../lib/admin-view"; /** Fixtures shared by the Monitor page tests. Not part of the application bundle. */ -export function project(id: string, overrides: Partial = {}): Project { - return { id, name: id, status: "active", created_at: 1, archived_at: null, active_key_count: 1, ...overrides }; +export function project(id: string, overrides: Partial = {}): AdminProject { + return { id, name: id, created_at: "1970-01-01T00:00:01Z", archived_at: null, active_key_count: 1, ...overrides }; } export function summary(projectId: string, overrides: Partial = {}): ProjectSummary { @@ -16,7 +16,7 @@ export function summary(projectId: string, overrides: Partial = assets: { agents: 1, skills: 0, environment_templates: 0, files: 0, vaults: 0, credentials: 0 }, sessions: { total: 0, idle: 0, in_progress: 0, requires_action: 0, failed: 0 }, usage: null, - coverage: { sessions: 0, reported: 0 }, + coverage: { total_sessions: 0, measured_sessions: 0, ratio: null }, last_active_at: null, ...overrides, }; diff --git a/apps/web/src/features/sandbox/NodeDetail.tsx b/apps/web/src/features/sandbox/NodeDetail.tsx index 9483c0879..6425e9766 100644 --- a/apps/web/src/features/sandbox/NodeDetail.tsx +++ b/apps/web/src/features/sandbox/NodeDetail.tsx @@ -4,13 +4,13 @@ import { useTranslation } from "react-i18next"; import { EmptyState, HelpTip, Kpi, KpiStrip, Section } from "../../components/console-ui"; import { CopyableId } from "../../components/list-ui"; -import { formatDateTime, formatInteger, formatRelative, formatSpan, MISSING } from "../../lib/format"; +import { epochSeconds, formatDateTime, formatInteger, formatRelative, formatSpan, MISSING } from "../../lib/format"; import { nodeProviderDiagnostic, sandboxDiagnosticMessage } from "../../lib/sandbox-diagnostic"; import { sandboxStateLabel } from "../../lib/sandbox-labels"; import { DiagnosticTip } from "../fleet/DiagnosticTip"; import { NodeRolloutStatus } from "./NodeRolloutStatus"; import { phaseTiming } from "./allocation-phase"; -import { nodeState, NodeStatus, OldAddressHint, seconds } from "./NodeList"; +import { nodeState, NodeStatus, OldAddressHint } from "./NodeList"; /** Why a node is not serving: disconnected, or the reason its provider is not ready. */ function nodeDiagnostic(node: SandboxNode): string { @@ -73,14 +73,14 @@ export function NodeDetail({ node, allocations, coreUrl, targetGeneration, stale
{t("Last seen")}
-
- {node.last_seen_at ? formatRelative(seconds(node.last_seen_at), now, locale) : t("Never")} +
+ {node.last_seen_at ? formatRelative(epochSeconds(node.last_seen_at), now, locale) : t("Never")}
{t("Target generation")}
{targetGeneration ?? MISSING}
{t("Target preparation")}
{t("Serving generation")}{t("The saved serving generation is not proof that this node is online, ready or has free capacity.")}
{node.rollout.ready_generation ?? MISSING}
-
{t("Added")}
{formatDateTime(seconds(node.created_at), locale)}
+
{t("Added")}
{formatDateTime(epochSeconds(node.created_at), locale)}
{/* Cleanup and host resources are on Sandbox metrics; the node's limit is here as well, beside Edit node that sets it. */} @@ -123,7 +123,7 @@ export function NodeDetail({ node, allocations, coreUrl, targetGeneration, stale {allocation.compute_phase ? sandboxStateLabel(allocation.compute_phase, shortLocale) : MISSING} {suspension ? : null} {allocation.diagnostic ? : MISSING} - {formatDateTime(seconds(allocation.created_at), locale)} + {formatDateTime(epochSeconds(allocation.created_at), locale)} ))} diff --git a/apps/web/src/features/sandbox/NodeEnrollment.tsx b/apps/web/src/features/sandbox/NodeEnrollment.tsx index 9a1ef808a..8247faea1 100644 --- a/apps/web/src/features/sandbox/NodeEnrollment.tsx +++ b/apps/web/src/features/sandbox/NodeEnrollment.tsx @@ -96,7 +96,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, const backend = sandboxProviderLabel(deployment.provider, locale); // Nodes and their sandboxes reach Core at its public URL, so a loopback one serves no other machine; // and without the provider's node files the installer would fail on the host. Either way no command - // is issued, nor before the installation is read: a failed read (an older Core, say) proves nothing. + // is issued, nor before the installation is read: a failed read proves nothing about the address. const blocker: { text: string; failed?: boolean } | null = deployment.reset ? { text: t("Node enrollment is paused while reset is in progress.") } : !fresh diff --git a/apps/web/src/features/sandbox/NodeList.tsx b/apps/web/src/features/sandbox/NodeList.tsx index b5c4fd979..055c9b700 100644 --- a/apps/web/src/features/sandbox/NodeList.tsx +++ b/apps/web/src/features/sandbox/NodeList.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { HelpTip, StatusDot, type Tone } from "../../components/console-ui"; import { NameCell, RowActions } from "../../components/list-ui"; -import { formatDateTime, formatRelative } from "../../lib/format"; +import { epochSeconds, formatDateTime, formatRelative } from "../../lib/format"; import type { MessageKey } from "../../lib/locale-strings"; import { nodeProviderDiagnostic } from "../../lib/sandbox-diagnostic"; import { DiagnosticTip } from "../fleet/DiagnosticTip"; @@ -56,12 +56,6 @@ export function OldAddressHint() { return {t("Remove and add again")}; } -export function seconds(value: string | null): number | null { - if (!value) return null; - const parsed = Date.parse(value); - return Number.isNaN(parsed) ? null : Math.floor(parsed / 1000); -} - export function NodeList({ nodes, allocations, coreUrl, stale, disabled, suspends = false, onOpen, onRemove }: { nodes: readonly SandboxNode[]; /** The deployment's address; a node enrolled with another one is on an old address. */ @@ -111,10 +105,10 @@ export function NodeList({ nodes, allocations, coreUrl, stale, disabled, suspend {node.active} / {node.max_active} {suspends ? {suspendedSandboxes(node)} : null} - - {node.last_seen_at ? formatRelative(seconds(node.last_seen_at), now, locale) : t("Never")} + + {node.last_seen_at ? formatRelative(epochSeconds(node.last_seen_at), now, locale) : t("Never")} - {formatDateTime(seconds(node.created_at), locale)} + {formatDateTime(epochSeconds(node.created_at), locale)}