@agimon-ai/doompi-log 0.0.1-alpha.73 → 0.0.1-alpha.76
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/extensions/web.mjs +4 -2
- package/dist/extensions/web.mjs.map +1 -1
- package/dist/src/services/hubApi/index.cjs +13 -6
- package/dist/src/services/hubApi/index.cjs.map +1 -1
- package/dist/src/services/hubApi/index.mjs +13 -6
- package/dist/src/services/hubApi/index.mjs.map +1 -1
- package/package.json +7 -7
package/dist/extensions/web.mjs
CHANGED
|
@@ -867,6 +867,7 @@ function MetricsPanel(_props) {
|
|
|
867
867
|
const [response, setResponse] = useState(void 0);
|
|
868
868
|
const [error, setError] = useState("");
|
|
869
869
|
const [loading, setLoading] = useState(true);
|
|
870
|
+
const [refreshKey, setRefreshKey] = useState(0);
|
|
870
871
|
useEffect(() => {
|
|
871
872
|
const controller = new AbortController();
|
|
872
873
|
setLoading(true);
|
|
@@ -884,7 +885,8 @@ function MetricsPanel(_props) {
|
|
|
884
885
|
}, [
|
|
885
886
|
dimension,
|
|
886
887
|
period,
|
|
887
|
-
focus
|
|
888
|
+
focus,
|
|
889
|
+
refreshKey
|
|
888
890
|
]);
|
|
889
891
|
const report = response === void 0 || isMetricsUnavailable(response) ? void 0 : response;
|
|
890
892
|
return /* @__PURE__ */ jsxs("div", {
|
|
@@ -926,7 +928,7 @@ function MetricsPanel(_props) {
|
|
|
926
928
|
variant: "ghost",
|
|
927
929
|
size: "xs",
|
|
928
930
|
className: "ml-auto text-2xs",
|
|
929
|
-
onClick: () =>
|
|
931
|
+
onClick: () => setRefreshKey((current) => current + 1),
|
|
930
932
|
disabled: loading,
|
|
931
933
|
children: "refresh"
|
|
932
934
|
})
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"web.mjs","names":["routes","settingMetrics"],"sources":["../../src/types/webMetrics.ts","../../src/types/apiRoutes.ts","../../generated/client.ts","../../src/extensions/(frontend)/setting/_lib/metricsApi.ts","../../src/extensions/(frontend)/setting/_lib/chartScale.ts","../../src/extensions/(frontend)/setting/_components/charts/GroupBars.tsx","../../src/extensions/(frontend)/setting/_components/charts/TimelineChart.tsx","../../src/types/issueGrouping.ts","../../src/extensions/(frontend)/setting/_components/IssuesDetail.tsx","../../src/extensions/(frontend)/setting/_components/IssuesSection.tsx","../../src/extensions/(frontend)/setting/_components/MetricsReportView.tsx","../../src/extensions/(frontend)/setting/_components/MetricsNotice.tsx","../../src/extensions/(frontend)/setting/_components/MetricsPanel.tsx","../../src/extensions/(frontend)/setting/metrics.panel.web.tsx","../../generated/web.ts"],"sourcesContent":["/**\n * The metrics API, shared by this package's hub-scoped route and its cockpit\n * plugin. The two halves run in different processes, so the wire vocabulary is\n * declared here: the `(frontend)` half may reach `src/types` and nothing else\n * on the node side.\n *\n * The shapes are narrower than log-sink-mcp's own report, which carries filter\n * echoes, span counts and token distributions the page never draws.\n * Re-exporting the sink's types would also tie the browser bundle to a node\n * package. What crosses the wire is what a chart reads.\n *\n * Two things the sink does not have, so this contract does not pretend to:\n * money, and per-tool failures. The sink records tokens only, and its tool rows\n * carry an invocation count with no error field.\n */\n\n/**\n * The segment this package's API is mounted under.\n *\n * Read by the hub's `DoomApi`, which still declares its own base path. The\n * browser does not read it: the generated client is handed the same segment by\n * the build, off the `api/log/` folder that creates the mount, so no URL is\n * spelled twice.\n */\nexport const LOG_API_BASE_PATH = 'log';\n\n/**\n * The dimensions the page offers.\n *\n * log-sink-mcp also groups by 'workflow-run', 'workflow-name', 'job' and\n * 'step'. Those are omitted on purpose: no DoomPi package emits the matching\n * attributes today, so offering them would draw an empty chart and read as a\n * bug rather than as a gap.\n */\nexport const METRICS_DIMENSIONS = ['session', 'agent', 'model', 'provider'] as const;\nexport type MetricsDimension = (typeof METRICS_DIMENSIONS)[number];\n\nexport const METRICS_PERIODS = ['day', 'week', 'month', 'all'] as const;\nexport type MetricsPeriod = (typeof METRICS_PERIODS)[number];\n\nexport const METRICS_QUERY_PARAMS = {\n dimension: 'dimension',\n period: 'period',\n /** Narrows the whole report to one value of the current dimension. */\n focus: 'focus',\n} as const;\n\n/** How many groups and tools one response carries; the page ranks, it does not page. */\nexport const METRICS_GROUP_LIMIT = 20;\n\n/** One point on the token timeline. */\nexport interface MetricsBucket {\n /**\n * The sink's own local-time label ('YYYY-MM-DD', or 'YYYY-MM-DD HH:00' for an\n * hour bucket). Carried rather than derived: bucket boundaries are aligned to\n * local time, so slicing the ISO instant renders the wrong day east of\n * Greenwich.\n */\n label: string;\n totalTokens: number;\n inputTokens: number;\n outputTokens: number;\n}\n\n/** One row of the selected dimension. */\nexport interface MetricsGroup {\n /**\n * The group's identity as the sink reports it. For the session dimension\n * this is an opaque hash, never a session the cockpit can open, because\n * doompi-telemetry hashes identifier-shaped attributes before export.\n */\n key: string;\n totalTokens: number;\n inputTokens: number;\n outputTokens: number;\n /** Problems the sink detected for this group; 0 when it saw none. */\n issueCount: number;\n failed: boolean;\n}\n\n/**\n * One tool's activity.\n *\n * `calls` is exact. `p90TotalTokens` is not this tool's consumption: the sink\n * attributes the whole turn's tokens to every tool that ran in that turn, so\n * the figure ranks tools and does not measure them. The page must say so\n * wherever it draws this field.\n *\n * There is no failure count here because the sink's tool rows do not carry\n * one. Per-tool failures live in its separate agent-issues report, which the\n * running daemon does not expose over HTTP.\n */\nexport interface MetricsTool {\n name: string;\n calls: number;\n p90TotalTokens: number;\n}\n\n/** Which transport answered, so the page can say where its numbers came from. */\nexport type MetricsTransport = 'http' | 'worker';\n\nexport interface MetricsTotals {\n /**\n * The providers' own reported total. It is not inputTokens + outputTokens:\n * on a cached agent workload it is dominated by cache traffic, and those two\n * fields are a fraction of a percent of it. The page must never present the\n * three as a breakdown.\n */\n totalTokens: number;\n inputTokens: number;\n outputTokens: number;\n /** Cache reads, usually the largest real component of totalTokens. */\n cachedTokens: number;\n reasoningTokens: number;\n groupCount: number;\n /** Groups the sink flagged as having failed, out of groupCount. */\n failedGroups: number;\n issueCount: number;\n}\n\nexport interface MetricsReport {\n generatedAt: string;\n dimension: MetricsDimension;\n period: MetricsPeriod;\n /**\n * The width of one timeline bucket, chosen by the sink from the period. A\n * long period over a short history collapses to a single bucket, so the page\n * says which unit it is drawing rather than implying a series it does not\n * have.\n */\n bucketUnit: string;\n /**\n * The dimension value this report is narrowed to, echoed back from the sink\n * rather than from the request. A daemon older than the filter ignores it\n * and returns everything, so trusting the request would label unfiltered\n * numbers as filtered; absence here means the drill-down did not happen.\n */\n focus?: string;\n /** Undefined when the source could not say which transport answered. */\n transport?: MetricsTransport;\n totals: MetricsTotals;\n timeline: MetricsBucket[];\n groups: MetricsGroup[];\n tools: MetricsTool[];\n}\n\n/**\n * Why the page has nothing to draw.\n *\n * The sink is a separate daemon that may be missing or simply empty, and those\n * are different things to tell a reader. Neither is an error: the metrics\n * source already treats a rejecting daemon as unavailable rather than fatal.\n *\n * 'no-api' is the third case and belongs to the cockpit rather than the sink.\n * A hub serving a bundle without package APIs mounted answers this route with\n * the SPA shell, and a 200 of HTML must read as a feature that is not\n * installed, not as a corrupt response.\n */\nexport type MetricsUnavailableReason = 'no-sink' | 'no-data' | 'no-api';\n\nexport interface MetricsUnavailable {\n unavailable: MetricsUnavailableReason;\n detail: string;\n}\n\nexport type MetricsResponse = MetricsReport | MetricsUnavailable;\n\nexport function isMetricsUnavailable(response: MetricsResponse): response is MetricsUnavailable {\n return 'unavailable' in response;\n}\n\nexport function isMetricsDimension(value: string): value is MetricsDimension {\n return (METRICS_DIMENSIONS as readonly string[]).includes(value);\n}\n\nexport function isMetricsPeriod(value: string): value is MetricsPeriod {\n return (METRICS_PERIODS as readonly string[]).includes(value);\n}\n\n/** One tool's failure count, paired with its call count from the metrics report. */\nexport interface IssueToolRow {\n name: string;\n failures: number;\n}\n\n/**\n * One recurring problem, as the sink fingerprints it.\n *\n * `occurrenceCount` is how many times it happened, which is the number a\n * reader acts on. `detail` is the actionable line; `message` is often only a\n * record name such as 'pi.tool_result'.\n */\nexport interface IssueSample {\n fingerprint: string;\n occurrenceCount: number;\n category: string;\n timestamp: string;\n level: string;\n message: string;\n detail: string;\n tool: string | null;\n errorType: string | null;\n agentName: string | null;\n model: string | null;\n statusCode: string | null;\n}\n\n/**\n * The detail behind the issue count.\n *\n * Fetched separately from the report because the running sink exposes no\n * issues route over HTTP, so this costs a subprocess. The page asks for it\n * when a reader opens the section, not with every refresh.\n */\nexport interface IssuesView {\n totalIssues: number;\n uniqueIncidents: number;\n byCategory: Record<string, number>;\n byTool: Record<string, number>;\n byErrorType: Record<string, number>;\n samples: IssueSample[];\n}\n\nexport type IssuesResponse = IssuesView | MetricsUnavailable;\n\nexport function isIssuesUnavailable(response: IssuesResponse): response is MetricsUnavailable {\n return 'unavailable' in response;\n}\n\n/** How many incidents the issues section ranks. */\nexport const ISSUE_SAMPLE_LIMIT = 50;\n\n/** How many tool rows the report ranks, so a failing tool has a denominator. */\nexport const METRICS_TOOL_LIMIT = 15;\n","import { apiResponse, defineApiRoutes } from '@agimon-ai/doompi-core/web';\n\nimport { METRICS_QUERY_PARAMS, type IssuesResponse, type MetricsResponse } from './webMetrics';\n\n/**\n * This package's routes, as data.\n *\n * No scope and no base path. The build reads both off\n * `src/extensions/(backend)/api/log/`, so the mount is stated once, by the\n * folder that creates it. They used to be written here, in the contract, and\n * again inside every URL the page built, and the copies were free to disagree.\n *\n * This is the one file the routes, the Hono app and the browser all read, so a\n * path cannot move on one side alone.\n */\nexport default defineApiRoutes({\n metrics: {\n method: 'GET',\n path: '/metrics',\n query: [METRICS_QUERY_PARAMS.dimension, METRICS_QUERY_PARAMS.period, METRICS_QUERY_PARAMS.focus],\n response: apiResponse<MetricsResponse>(),\n },\n /**\n * Separate from the report because the hub answers it from a subprocess: the\n * running daemon exposes no issues route, so folding it in would make every\n * refresh wait on the slowest transport.\n */\n issues: {\n method: 'GET',\n path: '/issues',\n query: [METRICS_QUERY_PARAMS.focus],\n response: apiResponse<IssuesResponse>(),\n },\n});\n","// Generated by @agimon-ai/doompi-build. Do not edit by hand.\nimport { createApiClient, sessionApiAddress } from '@agimon-ai/doompi-core/web';\nimport { sealedTransport } from '@agimon-ai/doompi-web-security/browser';\n\nimport routes from '../src/types/apiRoutes';\n\n/** Scopes the tree mounts 'log' at. */\nexport const apiScopes = ['global', 'workspace', 'session'] as const;\n\nexport const api = createApiClient(routes, {\n scopes: apiScopes,\n basePath: 'log',\n transport: (input, init) => sealedTransport.fetch(input, init),\n sessionAddress: sessionApiAddress,\n});\n","import type { ApiResult } from '@agimon-ai/doompi-core/web';\n\nimport { api } from '../../../../../generated/client';\nimport {\n isMetricsUnavailable,\n METRICS_QUERY_PARAMS,\n type MetricsDimension,\n type MetricsPeriod,\n type MetricsResponse,\n type IssuesResponse,\n} from '../../../../types/webMetrics';\n\n/**\n * The page's half of this package's metrics API. The only place the cockpit\n * talks to the hub for metrics, so if the transport ever changes, it changes\n * here alone.\n *\n * These are adapters now, not a transport. The generated client owns the URL\n * and the sealed transport with it, so nothing here spells a route or reaches\n * for `fetch`; a plugin calling `fetch` directly would send plaintext to the\n * tunnel's relay. What stays is this package's own vocabulary: which of the\n * empty states a reader is shown, and which answers are states rather than\n * failures.\n */\n\nconst UNREACHABLE = 'The cockpit hub is unreachable.';\n\n/**\n * A hub with no package APIs mounted serves the SPA shell for this route, so\n * the answer is a 200 of HTML rather than an error status. The client parses no\n * JSON out of it and reports no body at all, which is read here as an\n * uninstalled feature, because that is what it is.\n */\nconst NO_API_DETAIL = 'This cockpit is running a bundle without the log package API, so there are no metrics to read.';\n\nconst NO_API: MetricsResponse = { unavailable: 'no-api', detail: NO_API_DETAIL };\n\nexport type MetricsResult = { report: MetricsResponse } | { error: string };\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\n/** A body the hub sent, or which of the two silences it was. */\ntype Outcome = { body: Record<string, unknown> } | { noApi: true } | { error: string };\n\n/**\n * What the hub's answer means, before either route reads its own shape out of\n * it.\n *\n * `status: 0` is the transport never answering, which covers both a dead hub\n * and a caller that aborted. The client cannot tell those apart, so the signal\n * is asked: an abort is the caller replacing this request, and reporting it\n * would overwrite whatever the replacement renders.\n */\nfunction outcomeOf(result: ApiResult<unknown>, signal?: AbortSignal): Outcome {\n if (result.status === 0) return { error: signal?.aborted === true ? '' : UNREACHABLE };\n const status = `The hub answered ${String(result.status)}.`;\n // No body at all: unparseable and successful is the SPA fallback answering,\n // so the route is not mounted. Unparseable and failed is a hub that broke\n // mid-response, which is only worth the status it carried.\n if (result.data === undefined) return result.ok ? { noApi: true } : { error: status };\n if (!isRecord(result.data)) return { error: 'The hub returned a response the cockpit could not read.' };\n if (!result.ok) return { error: result.error === '' ? status : result.error };\n return { body: result.data };\n}\n\n/**\n * Reads one report.\n *\n * A missing sink is not an error here: the route answers it as an\n * `unavailable` body, so the page can name which of the empty states it is in\n * rather than showing one generic failure.\n */\nexport async function fetchMetrics(\n dimension: MetricsDimension,\n period: MetricsPeriod,\n focus?: string,\n signal?: AbortSignal,\n): Promise<MetricsResult> {\n const result = await api.global.metrics({\n query: {\n [METRICS_QUERY_PARAMS.dimension]: dimension,\n [METRICS_QUERY_PARAMS.period]: period,\n [METRICS_QUERY_PARAMS.focus]: focus === '' ? undefined : focus,\n },\n ...(signal === undefined ? {} : { signal }),\n });\n\n const outcome = outcomeOf(result, signal);\n if ('error' in outcome) return { error: outcome.error };\n if ('noApi' in outcome) return { report: NO_API };\n\n const report = outcome.body as unknown as MetricsResponse;\n if (!isMetricsUnavailable(report) && !Array.isArray(report.groups)) {\n return { error: 'The hub returned a report the cockpit could not read.' };\n }\n return { report };\n}\n\nexport type IssuesResult = { issues: IssuesResponse } | { error: string };\n\n/**\n * Reads the detail behind the issue count.\n *\n * Separate call because the hub answers it from a subprocess: folding it into\n * the report would make every refresh wait on the slowest transport.\n */\nexport async function fetchIssues(focus?: string, signal?: AbortSignal): Promise<IssuesResult> {\n const result = await api.global.issues({\n query: { [METRICS_QUERY_PARAMS.focus]: focus === '' ? undefined : focus },\n ...(signal === undefined ? {} : { signal }),\n });\n\n const outcome = outcomeOf(result, signal);\n if ('error' in outcome) return { error: outcome.error };\n if ('noApi' in outcome) return { issues: { unavailable: 'no-api', detail: NO_API_DETAIL } };\n return { issues: outcome.body as unknown as IssuesResponse };\n}\n","/**\n * The arithmetic behind the charts, kept out of the components so it can be\n * tested as plain functions rather than through a render.\n *\n * There is no charting library in the cockpit bundle and the plugin import\n * allowlist would not admit one, so these are the primitives the SVG is drawn\n * from. They are deliberately small: a bar length and a tick set, nothing that\n * pretends to be a plotting engine.\n */\n\n/** Bar length as a fraction of the track, guarding the all-zero series. */\nexport function barFraction(value: number, max: number): number {\n if (!Number.isFinite(value) || value <= 0) return 0;\n if (!Number.isFinite(max) || max <= 0) return 0;\n return Math.min(1, value / max);\n}\n\n/** The largest value in a series, or 0 for an empty one. */\nexport function seriesMax(values: readonly number[]): number {\n return values.reduce((highest, value) => (value > highest ? value : highest), 0);\n}\n\n/**\n * Compact token counts. Charts label axes and bars in a few characters, and a\n * raw nine-digit total makes every row the same illegible width.\n */\nexport function formatTokens(value: number): string {\n // A field the hub did not send is unknown, not zero. Rendering it as '0'\n // states a fact nobody measured, which is how a version skew between the\n // page bundle and the hub API turns into a confident wrong number.\n if (!Number.isFinite(value)) return '\\u2014';\n if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;\n if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`;\n return String(Math.round(value));\n}\n\n/**\n * Evenly spaced x positions for a series, in the 0..1 range.\n *\n * A single point sits at the left edge rather than dividing by zero, which is\n * what a one-bucket timeline is: the sink answered, there is just one day.\n */\nexport function evenPositions(count: number): number[] {\n if (count <= 0) return [];\n if (count === 1) return [0];\n return Array.from({ length: count }, (_, index) => index / (count - 1));\n}\n","import type { MetricsGroup } from '../../../../../types/webMetrics';\nimport { barFraction, formatTokens, seriesMax } from '../../_lib/chartScale';\n\n/**\n * Tokens per group, as horizontal bars.\n *\n * Horizontal rather than vertical because the labels are model names and\n * session hashes, which do not fit under a column. Each row is a button when\n * the caller supplies a handler: clicking one narrows the whole page to that\n * group, which is the drill-down.\n *\n * A row is not a link to a session even when the dimension is 'session'. The\n * identifier is a hash, so there is no session for it to open.\n */\n\ninterface GroupBarsProps {\n groups: readonly MetricsGroup[];\n /** The group the page is currently narrowed to, if any. */\n focus?: string;\n onFocus?: (key: string) => void;\n}\n\nexport function GroupBars({ groups, focus, onFocus }: GroupBarsProps) {\n const max = seriesMax(groups.map((group) => group.totalTokens));\n\n return (\n <div className=\"flex flex-col gap-[3px]\">\n {/* The two right-hand columns were bare numbers. A row reading \"11.0M 12\"\n gave no way to know the second figure counted issues. Same widths and\n padding as a row, so the heads sit over their own columns. */}\n <div aria-hidden className=\"flex items-center gap-2 px-1 text-2xs text-doom-faint/70\">\n <span className=\"min-w-0 flex-1\" />\n <span className=\"w-14 shrink-0 text-right\">tokens</span>\n <span className=\"w-10 shrink-0 text-right\">issues</span>\n </div>\n <ul className=\"flex flex-col gap-[3px]\" data-testid=\"metrics-group-bars\">\n {groups.map((group) => {\n const width = `${(barFraction(group.totalTokens, max) * 100).toFixed(2)}%`;\n const selected = focus === group.key;\n const row = (\n <>\n <span className=\"min-w-0 flex-1 truncate text-left text-doom-dim\">{group.key}</span>\n <span className=\"w-14 shrink-0 text-right text-doom-hi\">{formatTokens(group.totalTokens)}</span>\n <span className=\"w-10 shrink-0 text-right\">\n {group.issueCount === 0 ? (\n <span className=\"text-doom-faint/50\">ok</span>\n ) : (\n <span className=\"text-doom-red\">{group.issueCount}</span>\n )}\n </span>\n </>\n );\n\n return (\n <li key={group.key} className=\"relative\">\n {/* The bar is behind the text rather than beside it, so a long\n model name keeps its full width instead of competing with the\n track for horizontal space. */}\n <span\n aria-hidden=\"true\"\n className={`absolute inset-y-0 left-0 rounded-xs ${selected ? 'bg-doom-blue/30' : 'bg-doom-blue/15'}`}\n style={{ width }}\n />\n {onFocus === undefined ? (\n <span className=\"relative flex items-center gap-2 px-1 py-[3px] text-xs\">{row}</span>\n ) : (\n <button\n type=\"button\"\n onClick={() => onFocus(selected ? '' : group.key)}\n aria-pressed={selected}\n data-testid={`metrics-group-${group.key}`}\n className=\"relative flex w-full items-center gap-2 rounded-xs px-1 py-[3px] text-xs hover:bg-doom-tint focus-visible:outline focus-visible:outline-1 focus-visible:outline-doom-blue\"\n >\n {row}\n </button>\n )}\n </li>\n );\n })}\n </ul>\n </div>\n );\n}\n","import type { MetricsBucket } from '../../../../../types/webMetrics';\nimport { barFraction, formatTokens, seriesMax } from '../../_lib/chartScale';\n\n/**\n * Tokens per bucket, as columns.\n *\n * Drawn as SVG rather than with a charting library because the cockpit bundle\n * has none and the plugin import allowlist admits none. That constraint is a\n * good fit here: this is a bar per bucket, and a dependency would cost more\n * than it saves.\n *\n * Columns use currentColor and theme classes rather than literal colours, so\n * the chart follows whichever theme the cockpit renders with.\n */\n\nconst VIEWBOX_HEIGHT = 100;\n\n/**\n * Widest a single column may get.\n *\n * Dividing the width by the bucket count alone turns a one-bucket period into\n * a slab spanning the whole panel at full height, which reads as a rendering\n * fault rather than as \"one bucket\". Capping it keeps a short series looking\n * like bars on an axis.\n */\nconst MAX_COLUMN_PERCENT = 9;\n\ninterface TimelineChartProps {\n buckets: readonly MetricsBucket[];\n /** The sink's bucket width, named so a flat chart explains itself; absent on an older hub. */\n bucketUnit?: string;\n}\n\nexport function TimelineChart({ buckets, bucketUnit }: TimelineChartProps) {\n // An older hub does not send the unit; say nothing rather than 'undefined'.\n const unit = bucketUnit === undefined || bucketUnit === '' ? '' : `${bucketUnit} `;\n const max = seriesMax(buckets.map((bucket) => bucket.totalTokens));\n const columnWidth = Math.min(MAX_COLUMN_PERCENT, 100 / Math.max(1, buckets.length));\n\n return (\n <div className=\"flex flex-col gap-1\" data-testid=\"metrics-timeline-chart\">\n <svg\n viewBox={`0 0 100 ${VIEWBOX_HEIGHT}`}\n preserveAspectRatio=\"none\"\n className=\"h-24 w-full text-doom-blue\"\n role=\"img\"\n aria-label={`tokens per bucket, peak ${formatTokens(max)}`}\n >\n {buckets.map((bucket, index) => {\n const height = barFraction(bucket.totalTokens, max) * VIEWBOX_HEIGHT;\n return (\n <rect\n key={bucket.label}\n x={index * columnWidth + columnWidth * 0.15}\n y={VIEWBOX_HEIGHT - height}\n width={columnWidth * 0.7}\n height={height}\n fill=\"currentColor\"\n >\n <title>{`${bucket.label}: ${formatTokens(bucket.totalTokens)} tokens`}</title>\n </rect>\n );\n })}\n </svg>\n <div className=\"flex flex-wrap justify-between gap-x-3 text-2xs text-doom-faint\">\n <span>{buckets[0]?.label ?? ''}</span>\n {buckets.length > 1 ? <span>peak {formatTokens(max)}</span> : null}\n <span>\n {buckets.length === 1\n ? `one ${unit}bucket, ${formatTokens(max)}; pick a shorter period for finer buckets`\n : `${String(buckets.length)} ${unit}buckets`}\n </span>\n </div>\n </div>\n );\n}\n","import type { IssueSample } from './webMetrics';\n\n/**\n * Turning a list of incidents into a ranked list of problems.\n *\n * Pure, and in types/ so both halves may use it: the page ranks, and a test\n * can assert the ranking without a render or a subprocess.\n *\n * Two things the sink's own output needs fixing for. It can emit the same\n * fingerprint more than once, so occurrences have to be re-summed or the same\n * problem appears three times at a third of its real weight. And its ordering\n * is by recency, which buries a problem that happened twenty times under one\n * that happened once a minute ago.\n */\n\nexport interface IssueGroup {\n /** Stable identity for a row, unique after merging. */\n key: string;\n /** How many times this exact problem happened. */\n occurrences: number;\n category: string;\n /** The actionable line: the spawn that failed, the path that was missing. */\n detail: string;\n tool: string | null;\n errorType: string | null;\n agentName: string | null;\n model: string | null;\n statusCode: string | null;\n /** Most recent occurrence across the merged incidents. */\n lastSeen: string;\n /** Every incident folded into this row, for the expanded view. */\n members: IssueSample[];\n}\n\n/**\n * A key that separates problems a reader would act on separately.\n *\n * The sink's fingerprint is the primary key, but it can be empty, and several\n * distinct bash failures share 'tool_failure|pi|bash||pi.tool_result' because\n * the record name is all they carry. Folding the detail in keeps two different\n * failures apart while still merging repeats of the same one.\n */\nexport function groupingKey(sample: IssueSample): string {\n const base = sample.fingerprint === '' ? `${sample.category}|${sample.message}` : sample.fingerprint;\n return `${base}|${sample.detail}`;\n}\n\n/** Incidents merged by problem and ranked by how often each happened. */\nexport function groupIssues(samples: readonly IssueSample[]): IssueGroup[] {\n const groups = new Map<string, IssueGroup>();\n\n for (const sample of samples) {\n const key = groupingKey(sample);\n const existing = groups.get(key);\n if (existing === undefined) {\n groups.set(key, {\n key,\n occurrences: sample.occurrenceCount,\n category: sample.category,\n detail: sample.detail,\n tool: sample.tool,\n errorType: sample.errorType,\n agentName: sample.agentName,\n model: sample.model,\n statusCode: sample.statusCode,\n lastSeen: sample.timestamp,\n members: [sample],\n });\n continue;\n }\n existing.occurrences += sample.occurrenceCount;\n existing.members.push(sample);\n if (sample.timestamp > existing.lastSeen) existing.lastSeen = sample.timestamp;\n // A merged row keeps whichever identifying field any member supplied, so\n // one incident missing an agent name does not blank it for the group.\n existing.tool ??= sample.tool;\n existing.errorType ??= sample.errorType;\n existing.agentName ??= sample.agentName;\n existing.model ??= sample.model;\n existing.statusCode ??= sample.statusCode;\n }\n\n return [...groups.values()].sort(\n (left, right) => right.occurrences - left.occurrences || left.detail.localeCompare(right.detail),\n );\n}\n","import { useState } from 'react';\n\nimport { groupIssues, type IssueGroup } from '../../../../types/issueGrouping';\nimport type { IssuesView, MetricsTool } from '../../../../types/webMetrics';\nimport { barFraction } from '../_lib/chartScale';\n\n/**\n * What is actually going wrong, ranked by how often.\n *\n * A count of 69 issues tells nobody what to change. The same 69 collapsed into\n * \"this spawn failed 20 times, this hook failed 3\" is a work list, so the\n * ranked bar is the primary view here and the totals are context beside it.\n *\n * Each bar expands to the incidents behind it, because the row states the\n * problem and the reader still needs the session, model, and timestamps to go\n * and look at one.\n */\n\nfunction countRows(counts: Record<string, number>): [string, number][] {\n return Object.entries(counts).sort(([, left], [, right]) => right - left);\n}\n\n/** A short, human label for a problem; the detail can be a whole command line. */\nfunction titleOf(group: IssueGroup): string {\n if (group.errorType !== null) return group.errorType;\n if (group.tool !== null) return `${group.tool} failed`;\n return group.category;\n}\n\ninterface IssueRowProps {\n group: IssueGroup;\n max: number;\n tools: readonly MetricsTool[];\n}\n\nfunction IssueRow({ group, max, tools }: IssueRowProps) {\n const [open, setOpen] = useState(false);\n const width = `${(barFraction(group.occurrences, max) * 100).toFixed(2)}%`;\n const calls = tools.find((tool) => tool.name === group.tool)?.calls;\n\n return (\n <li className=\"flex flex-col\">\n <div className=\"relative\">\n <span aria-hidden=\"true\" className=\"absolute inset-y-0 left-0 rounded-xs bg-doom-red/20\" style={{ width }} />\n <button\n type=\"button\"\n onClick={() => setOpen(!open)}\n aria-expanded={open}\n data-testid={`metrics-issue-${group.key}`}\n className=\"relative flex w-full items-center gap-2 rounded-xs px-1 py-[3px] text-left text-xs hover:bg-doom-tint focus-visible:outline focus-visible:outline-1 focus-visible:outline-doom-blue\"\n >\n <span className=\"w-8 shrink-0 text-right font-bold text-doom-red\">{group.occurrences}</span>\n <span className=\"w-24 shrink-0 truncate text-doom-hi\">{titleOf(group)}</span>\n <span className=\"min-w-0 flex-1 truncate text-doom-dim\">{group.detail}</span>\n {calls === undefined ? null : <span className=\"shrink-0 text-doom-faint\">of {calls} calls</span>}\n </button>\n </div>\n\n {open ? (\n <div className=\"flex flex-col gap-1 px-2 py-2 text-xs\" data-testid={`metrics-issue-body-${group.key}`}>\n <div className=\"flex flex-wrap gap-x-3 gap-y-1 text-2xs text-doom-faint\">\n <span>category {group.category}</span>\n {group.tool === null ? null : <span>tool {group.tool}</span>}\n {group.errorType === null ? null : <span>error {group.errorType}</span>}\n {group.statusCode === null ? null : <span>status {group.statusCode}</span>}\n {group.agentName === null ? null : <span>agent {group.agentName}</span>}\n {group.model === null ? null : <span>model {group.model}</span>}\n <span>last seen {group.lastSeen}</span>\n </div>\n <span className=\"break-words text-doom-dim\">{group.detail}</span>\n <ul className=\"flex flex-col gap-[1px] text-2xs text-doom-faint\">\n {group.members.map((member, index) => (\n <li key={`${member.timestamp}-${String(index)}`} className=\"flex flex-wrap gap-x-2\">\n <span>{member.timestamp}</span>\n <span>{member.level}</span>\n <span>\n {member.occurrenceCount}\n {'\\u00d7'}\n </span>\n <span className=\"min-w-0 truncate\">{member.message}</span>\n </li>\n ))}\n </ul>\n </div>\n ) : null}\n </li>\n );\n}\n\nexport interface IssuesDetailProps {\n view: IssuesView;\n /** Tool call counts from the report, used as the denominator. */\n tools: readonly MetricsTool[];\n}\n\nexport function IssuesDetail({ view, tools }: IssuesDetailProps) {\n const groups = groupIssues(view.samples);\n const max = groups[0]?.occurrences ?? 0;\n const callsByTool = new Map(tools.map((tool) => [tool.name, tool.calls]));\n\n return (\n <div className=\"flex flex-col gap-3\">\n <span className=\"text-xs text-doom-dim\">\n <span className=\"text-doom-hi\">{view.totalIssues}</span> occurrences of{' '}\n <span className=\"text-doom-hi\">{groups.length}</span> distinct problems, worst first\n </span>\n\n {groups.length === 0 ? null : (\n <ul className=\"flex flex-col gap-[2px]\" data-testid=\"metrics-issue-groups\">\n {groups.map((group) => (\n <IssueRow key={group.key} group={group} max={max} tools={tools} />\n ))}\n </ul>\n )}\n\n <div className=\"flex flex-col gap-1\">\n <span className=\"text-2xs font-bold text-doom-faint\">failures by tool</span>\n <table className=\"w-full text-xs\" data-testid=\"metrics-issues-tools\">\n <tbody>\n {countRows(view.byTool).map(([name, failures]) => {\n const calls = callsByTool.get(name);\n return (\n <tr key={name} className=\"border-b border-doom-border/40\">\n <td className=\"min-w-0 truncate py-1 text-doom-dim\">{name}</td>\n <td className=\"w-16 py-1 text-right text-doom-red\">{failures}</td>\n <td className=\"w-28 py-1 text-right text-doom-faint\">\n {/* Only tools the report also ranked have a denominator;\n the two reports scan on their own limits. */}\n {calls === undefined ? 'of unknown calls' : `of ${String(calls)} calls`}\n </td>\n </tr>\n );\n })}\n </tbody>\n </table>\n </div>\n\n <div className=\"flex flex-wrap gap-x-4 gap-y-1 text-xs text-doom-dim\">\n <span className=\"text-2xs font-bold text-doom-faint\">by category</span>\n {countRows(view.byCategory).map(([name, count]) => (\n <span key={name}>\n {name} <span className=\"text-doom-hi\">{count}</span>\n </span>\n ))}\n </div>\n </div>\n );\n}\n","import { Button, Spinner } from '@agimon-ai/doompi-web-components';\nimport { useState } from 'react';\n\nimport { isIssuesUnavailable, type IssuesView, type MetricsTool } from '../../../../types/webMetrics';\nimport { fetchIssues } from '../_lib/metricsApi';\nimport { IssuesDetail } from './IssuesDetail';\n\n/**\n * The detail behind the issue count.\n *\n * Collapsed until asked for, because the hub answers this from a subprocess:\n * the running log sink serves no issues route over HTTP. Opening it is the\n * reader agreeing to that cost, which is better than making every refresh of\n * the whole page wait on the slowest transport.\n *\n * Only the going-and-getting lives here; IssuesDetail owns what the numbers\n * look like once they arrive.\n */\n\ninterface IssuesSectionProps {\n /** Tool call counts from the report, used as the denominator. */\n tools: readonly MetricsTool[];\n /** The dimension value the page is narrowed to, forwarded as a session filter. */\n focus?: string;\n}\n\nexport function IssuesSection({ tools, focus }: IssuesSectionProps) {\n const [view, setView] = useState<IssuesView | undefined>(undefined);\n const [message, setMessage] = useState('');\n const [loading, setLoading] = useState(false);\n const [open, setOpen] = useState(false);\n\n const load = (): void => {\n setOpen(true);\n setLoading(true);\n setMessage('');\n void fetchIssues(focus).then((result) => {\n setLoading(false);\n if ('error' in result) {\n if (result.error !== '') setMessage(result.error);\n return;\n }\n if (isIssuesUnavailable(result.issues)) {\n setMessage(result.issues.detail);\n return;\n }\n setView(result.issues);\n });\n };\n\n return (\n <section className=\"flex flex-col gap-1\" data-testid=\"metrics-issues\">\n <div className=\"flex items-center gap-2\">\n <span className=\"text-2xs font-bold text-doom-faint\">issues</span>\n {open ? null : (\n <Button variant=\"ghost\" size=\"xs\" className=\"text-2xs\" onClick={load} data-testid=\"metrics-issues-open\">\n show detail\n </Button>\n )}\n {open && !loading ? (\n <Button variant=\"ghost\" size=\"xs\" className=\"text-2xs\" onClick={load} data-testid=\"metrics-issues-reload\">\n reload\n </Button>\n ) : null}\n {loading ? <Spinner /> : null}\n </div>\n {/* Reading this scans the whole window in a subprocess, so the reader is\n told why it is behind a click rather than left wondering. */}\n {open ? null : (\n <span className=\"text-2xs text-doom-faint/70\">\n the log sink has no issues endpoint, so this is read separately and takes a moment\n </span>\n )}\n\n {message === '' ? null : (\n <span className=\"text-xs text-doom-yellow\" data-testid=\"metrics-issues-message\">\n {message}\n </span>\n )}\n\n {view === undefined ? null : <IssuesDetail view={view} tools={tools} />}\n </section>\n );\n}\n","import { Badge } from '@agimon-ai/doompi-web-components';\n\nimport type { MetricsDimension, MetricsReport } from '../../../../types/webMetrics';\nimport { formatTokens } from '../_lib/chartScale';\nimport { GroupBars } from './charts/GroupBars';\nimport { TimelineChart } from './charts/TimelineChart';\nimport { IssuesSection } from './IssuesSection';\n\n/**\n * One report, drawn.\n *\n * Separated from the panel so the loaded shape is a pure function of a report:\n * the panel owns the selects, the fetch and the empty states, and this owns\n * what a report looks like. That split is what lets the degenerate shapes, a\n * single bucket or an all-zero series, be rendered and asserted directly.\n */\n\nexport const DIMENSION_LABELS: Record<MetricsDimension, string> = {\n session: 'session',\n agent: 'agent',\n model: 'model',\n provider: 'provider',\n};\n\n/**\n * The session dimension shows an opaque hash. doompi-telemetry hashes\n * identifier-shaped attributes before export, by design, so there is no\n * session here the cockpit could open even if the row were clickable.\n */\nexport const DIMENSION_NOTES: Partial<Record<MetricsDimension, string>> = {\n session: 'session ids are hashed before export, so these identify a session without naming one',\n};\n\nexport interface MetricsReportViewProps {\n report: MetricsReport;\n onFocus: (key: string) => void;\n}\n\nexport function MetricsReportView({ report, onFocus }: MetricsReportViewProps) {\n return (\n <div className=\"flex flex-col gap-4\">\n <div className=\"flex flex-col gap-1\">\n <div className=\"flex flex-wrap items-center gap-3 text-xs text-doom-dim\">\n <span>\n <span className=\"text-doom-hi\">{formatTokens(report.totals.totalTokens)}</span> total\n </span>\n <span>\n <span className=\"text-doom-hi\">{formatTokens(report.totals.cachedTokens)}</span> cache reads\n </span>\n <span>\n <span className=\"text-doom-hi\">{formatTokens(report.totals.outputTokens)}</span> out\n </span>\n <span>\n <span className=\"text-doom-hi\">{formatTokens(report.totals.inputTokens)}</span> in\n </span>\n {report.totals.reasoningTokens === 0 ? null : (\n <span>\n <span className=\"text-doom-hi\">{formatTokens(report.totals.reasoningTokens)}</span> reasoning\n </span>\n )}\n {report.totals.issueCount === 0 ? null : <Badge tone=\"red\">{report.totals.issueCount} issues</Badge>}\n {report.totals.failedGroups === 0 ? null : (\n <span className=\"text-doom-faint\">\n {report.totals.failedGroups} of {report.totals.groupCount} {DIMENSION_LABELS[report.dimension]}s failed\n </span>\n )}\n </div>\n {/*\n The total is the providers' own figure, and on a cached workload\n the named parts are a fraction of a percent of it. Listing them\n beside it without this line reads as a breakdown that does not\n add up, which is worse than not showing them.\n */}\n <span className=\"text-2xs text-doom-faint/70\">\n total is what the providers reported and is dominated by cache traffic; the parts beside it are counted\n separately and do not sum to it\n </span>\n </div>\n\n <section className=\"flex flex-col gap-1\">\n <span className=\"text-2xs font-bold text-doom-faint\">tokens over time</span>\n <TimelineChart buckets={report.timeline} bucketUnit={report.bucketUnit} />\n </section>\n\n <section className=\"flex flex-col gap-1\">\n <span className=\"text-2xs font-bold text-doom-faint\">tokens by {DIMENSION_LABELS[report.dimension]}</span>\n {DIMENSION_NOTES[report.dimension] === undefined ? null : (\n <span className=\"text-2xs text-doom-faint/70\">{DIMENSION_NOTES[report.dimension]}</span>\n )}\n <GroupBars groups={report.groups} focus={report.focus} onFocus={onFocus} />\n </section>\n\n <section className=\"flex flex-col gap-1\">\n <span className=\"text-2xs font-bold text-doom-faint\">tool calls</span>\n {/*\n The token column is a ranking hint, not a measurement. The sink\n attributes a turn's whole total to every tool that ran in that\n turn, so saying otherwise here would be a lie the chart repeats.\n */}\n <span className=\"text-2xs text-doom-faint/70\">\n call counts are exact; the token column ranks tools by the turns they ran in, and is not each tool's own\n consumption\n </span>\n <table className=\"w-full text-xs\" data-testid=\"metrics-tools\">\n {/* Without heads the two right columns are just numbers; \"1006\" and\n \"325.9k\" do not say which is a call count and which is tokens. */}\n <thead>\n <tr className=\"text-2xs text-doom-faint/70\">\n <th className=\"py-1 text-left font-normal\">tool</th>\n <th className=\"w-20 py-1 text-right font-normal\">calls</th>\n <th className=\"w-20 py-1 text-right font-normal\">tokens</th>\n </tr>\n </thead>\n <tbody>\n {report.tools.map((tool) => (\n <tr key={tool.name} className=\"border-b border-doom-border/40\">\n <td className=\"min-w-0 truncate py-1 text-doom-dim\">{tool.name}</td>\n <td className=\"w-20 py-1 text-right text-doom-hi\">{tool.calls}</td>\n <td className=\"w-20 py-1 text-right text-doom-faint\">{formatTokens(tool.p90TotalTokens)}</td>\n </tr>\n ))}\n </tbody>\n </table>\n </section>\n\n <IssuesSection tools={report.tools} focus={report.dimension === 'session' ? report.focus : undefined} />\n\n <span className=\"text-2xs text-doom-faint/70\" data-testid=\"metrics-provenance\">\n read over {report.transport ?? 'an unreported transport'} · generated {report.generatedAt}\n </span>\n </div>\n );\n}\n","import { Button, EmptyState } from '@agimon-ai/doompi-web-components';\n\nimport type { MetricsDimension, MetricsUnavailable, MetricsUnavailableReason } from '../../../../types/webMetrics';\nimport { DIMENSION_LABELS } from './MetricsReportView';\n\n/**\n * Everything the page says instead of, or above, a report.\n *\n * Kept pure and apart from the panel because these are the states that decide\n * whether a reader trusts the numbers, and they are the ones a fetching\n * component makes awkward to assert. The panel decides which state it is in;\n * this decides how each one reads.\n */\n\nconst EMPTY_TITLES: Record<MetricsUnavailableReason, string> = {\n 'no-sink': 'no log sink',\n 'no-data': 'nothing recorded yet',\n 'no-api': 'metrics not installed',\n};\n\nexport function EmptyForReason({ response }: { response: MetricsUnavailable }) {\n return (\n <EmptyState title={EMPTY_TITLES[response.unavailable]} description={response.detail} data-testid=\"metrics-empty\" />\n );\n}\n\nexport interface FocusNoticeProps {\n /** What the reader asked to narrow to; empty means they asked for nothing. */\n requested: string;\n /** What the sink echoed back as applied; absent means it ignored the filter. */\n applied: string | undefined;\n dimension: MetricsDimension;\n onClear: () => void;\n}\n\nexport function FocusNotice({ requested, applied, dimension, onClear }: FocusNoticeProps) {\n if (requested === '') return null;\n if (applied === undefined) {\n // The sink answered without echoing the filter, so these are the machine's\n // whole numbers. Saying \"showing model X\" over them would be a lie, so the\n // drill-down is reported as refused instead.\n return (\n <span className=\"text-xs text-doom-yellow\" data-testid=\"metrics-focus-refused\">\n this log sink does not support narrowing by {DIMENSION_LABELS[dimension]}, so the numbers below are still\n everything\n <Button variant=\"ghost\" size=\"xs\" className=\"ml-2 text-2xs\" onClick={onClear}>\n clear\n </Button>\n </span>\n );\n }\n return (\n <span className=\"text-xs text-doom-dim\" data-testid=\"metrics-focus\">\n narrowed to <span className=\"text-doom-hi\">{applied}</span>\n <Button variant=\"ghost\" size=\"xs\" className=\"ml-2 text-2xs\" onClick={onClear}>\n clear\n </Button>\n </span>\n );\n}\n","import type { SettingsPanelProps } from '@agimon-ai/doompi-core/web';\nimport {\n Button,\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n Spinner,\n} from '@agimon-ai/doompi-web-components';\nimport { useEffect, useState } from 'react';\n\nimport {\n isMetricsUnavailable,\n METRICS_DIMENSIONS,\n METRICS_PERIODS,\n type MetricsDimension,\n type MetricsPeriod,\n type MetricsReport,\n type MetricsResponse,\n} from '../../../../types/webMetrics';\nimport { fetchMetrics } from '../_lib/metricsApi';\nimport { EmptyForReason, FocusNotice } from './MetricsNotice';\nimport { DIMENSION_LABELS } from './MetricsReportView';\nimport { MetricsReportView } from './MetricsReportView';\n\n/**\n * The metrics settings page.\n *\n * Reports rather than writes, which is why it is drawn instead of declared as\n * fields. The numbers come from the machine's log sink, so every one of them\n * can be absent for a reason the reader needs told apart: no sink installed,\n * a sink with nothing recorded yet, or a hub that did not answer.\n *\n * Tables here, charts next. The data path is worth proving before the drawing\n * code is layered on it.\n */\n\nexport function MetricsPanel(_props: SettingsPanelProps) {\n const [dimension, setDimension] = useState<MetricsDimension>('model');\n const [period, setPeriod] = useState<MetricsPeriod>('week');\n const [focus, setFocus] = useState('');\n const [response, setResponse] = useState<MetricsResponse | undefined>(undefined);\n const [error, setError] = useState('');\n const [loading, setLoading] = useState(true);\n\n useEffect(() => {\n const controller = new AbortController();\n setLoading(true);\n void fetchMetrics(dimension, period, focus, controller.signal).then((result) => {\n if (controller.signal.aborted) return;\n if ('error' in result) {\n // An empty message is an aborted request, which the next effect replaces.\n if (result.error !== '') setError(result.error);\n } else {\n setError('');\n setResponse(result.report);\n }\n setLoading(false);\n });\n return () => controller.abort();\n }, [dimension, period, focus]);\n\n const report: MetricsReport | undefined =\n response === undefined || isMetricsUnavailable(response) ? undefined : response;\n\n return (\n <div className=\"flex flex-col gap-3\" data-testid=\"metrics-panel\">\n <div className=\"flex min-w-0 flex-wrap items-center gap-2\">\n <Select\n value={dimension}\n onValueChange={(next) => {\n setFocus('');\n setDimension(next as MetricsDimension);\n }}\n >\n <SelectTrigger data-testid=\"metrics-dimension\" className=\"w-[140px] text-xs\">\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {METRICS_DIMENSIONS.map((candidate) => (\n <SelectItem key={candidate} value={candidate}>\n {DIMENSION_LABELS[candidate]}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n <Select value={period} onValueChange={(next) => setPeriod(next as MetricsPeriod)}>\n <SelectTrigger data-testid=\"metrics-period\" className=\"w-[110px] text-xs\">\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {METRICS_PERIODS.map((candidate) => (\n <SelectItem key={candidate} value={candidate}>\n {candidate}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n {loading ? <Spinner /> : null}\n <Button\n variant=\"ghost\"\n size=\"xs\"\n className=\"ml-auto text-2xs\"\n onClick={() => setPeriod((current) => current)}\n disabled={loading}\n >\n refresh\n </Button>\n </div>\n\n {error === '' ? null : (\n <span className=\"text-xs text-doom-red\" data-testid=\"metrics-error\">\n {error}\n </span>\n )}\n\n {response !== undefined && isMetricsUnavailable(response) ? <EmptyForReason response={response} /> : null}\n\n {report === undefined ? null : (\n <FocusNotice requested={focus} applied={report.focus} dimension={dimension} onClear={() => setFocus('')} />\n )}\n\n {report === undefined ? null : <MetricsReportView report={report} onFocus={setFocus} />}\n </div>\n );\n}\n","import { defineSettingsPanel } from '@agimon-ai/doompi-core/web';\n\nimport { MetricsPanel } from './_components/MetricsPanel';\nexport default defineSettingsPanel({\n label: 'metrics',\n detail: 'where this machine spent its tokens and its money',\n component: MetricsPanel,\n});\n","// Generated by @agimon-ai/doompi-build. Do not edit by hand.\nimport { defineWebPlugin, type WebPluginContributions } from '@agimon-ai/doompi-core/web';\n\nimport settingMetrics from '../src/extensions/(frontend)/setting/metrics.panel.web';\n\nconst via = <T>(identity: Record<string, unknown>, value: unknown): T => ({ ...identity, ...(value as object) }) as T;\n\nexport const webPlugin = defineWebPlugin({\n id: 'log',\n global: {\n get settingsPanels(): WebPluginContributions['settingsPanels'] { return [via({ id: 'metrics' }, settingMetrics)]; },\n },\n});\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"web.mjs","names":["routes","settingMetrics"],"sources":["../../src/types/webMetrics.ts","../../src/types/apiRoutes.ts","../../generated/client.ts","../../src/extensions/(frontend)/setting/_lib/metricsApi.ts","../../src/extensions/(frontend)/setting/_lib/chartScale.ts","../../src/extensions/(frontend)/setting/_components/charts/GroupBars.tsx","../../src/extensions/(frontend)/setting/_components/charts/TimelineChart.tsx","../../src/types/issueGrouping.ts","../../src/extensions/(frontend)/setting/_components/IssuesDetail.tsx","../../src/extensions/(frontend)/setting/_components/IssuesSection.tsx","../../src/extensions/(frontend)/setting/_components/MetricsReportView.tsx","../../src/extensions/(frontend)/setting/_components/MetricsNotice.tsx","../../src/extensions/(frontend)/setting/_components/MetricsPanel.tsx","../../src/extensions/(frontend)/setting/metrics.panel.web.tsx","../../generated/web.ts"],"sourcesContent":["/**\n * The metrics API, shared by this package's hub-scoped route and its cockpit\n * plugin. The two halves run in different processes, so the wire vocabulary is\n * declared here: the `(frontend)` half may reach `src/types` and nothing else\n * on the node side.\n *\n * The shapes are narrower than log-sink-mcp's own report, which carries filter\n * echoes, span counts and token distributions the page never draws.\n * Re-exporting the sink's types would also tie the browser bundle to a node\n * package. What crosses the wire is what a chart reads.\n *\n * Two things the sink does not have, so this contract does not pretend to:\n * money, and per-tool failures. The sink records tokens only, and its tool rows\n * carry an invocation count with no error field.\n */\n\n/**\n * The segment this package's API is mounted under.\n *\n * Read by the hub's `DoomApi`, which still declares its own base path. The\n * browser does not read it: the generated client is handed the same segment by\n * the build, off the `api/log/` folder that creates the mount, so no URL is\n * spelled twice.\n */\nexport const LOG_API_BASE_PATH = 'log';\n\n/**\n * The dimensions the page offers.\n *\n * log-sink-mcp also groups by 'workflow-run', 'workflow-name', 'job' and\n * 'step'. Those are omitted on purpose: no DoomPi package emits the matching\n * attributes today, so offering them would draw an empty chart and read as a\n * bug rather than as a gap.\n */\nexport const METRICS_DIMENSIONS = ['session', 'agent', 'model', 'provider'] as const;\nexport type MetricsDimension = (typeof METRICS_DIMENSIONS)[number];\n\nexport const METRICS_PERIODS = ['day', 'week', 'month', 'all'] as const;\nexport type MetricsPeriod = (typeof METRICS_PERIODS)[number];\n\nexport const METRICS_QUERY_PARAMS = {\n dimension: 'dimension',\n period: 'period',\n /** Narrows the whole report to one value of the current dimension. */\n focus: 'focus',\n} as const;\n\n/** How many groups and tools one response carries; the page ranks, it does not page. */\nexport const METRICS_GROUP_LIMIT = 20;\n\n/** One point on the token timeline. */\nexport interface MetricsBucket {\n /**\n * The sink's own local-time label ('YYYY-MM-DD', or 'YYYY-MM-DD HH:00' for an\n * hour bucket). Carried rather than derived: bucket boundaries are aligned to\n * local time, so slicing the ISO instant renders the wrong day east of\n * Greenwich.\n */\n label: string;\n totalTokens: number;\n inputTokens: number;\n outputTokens: number;\n}\n\n/** One row of the selected dimension. */\nexport interface MetricsGroup {\n /**\n * The group's identity as the sink reports it. For the session dimension\n * this is an opaque hash, never a session the cockpit can open, because\n * doompi-telemetry hashes identifier-shaped attributes before export.\n */\n key: string;\n totalTokens: number;\n inputTokens: number;\n outputTokens: number;\n /** Problems the sink detected for this group; 0 when it saw none. */\n issueCount: number;\n failed: boolean;\n}\n\n/**\n * One tool's activity.\n *\n * `calls` is exact. `p90TotalTokens` is not this tool's consumption: the sink\n * attributes the whole turn's tokens to every tool that ran in that turn, so\n * the figure ranks tools and does not measure them. The page must say so\n * wherever it draws this field.\n *\n * There is no failure count here because the sink's tool rows do not carry\n * one. Per-tool failures live in its separate agent-issues report, which the\n * running daemon does not expose over HTTP.\n */\nexport interface MetricsTool {\n name: string;\n calls: number;\n p90TotalTokens: number;\n}\n\n/** Which transport answered, so the page can say where its numbers came from. */\nexport type MetricsTransport = 'http' | 'worker';\n\nexport interface MetricsTotals {\n /**\n * The providers' own reported total. It is not inputTokens + outputTokens:\n * on a cached agent workload it is dominated by cache traffic, and those two\n * fields are a fraction of a percent of it. The page must never present the\n * three as a breakdown.\n */\n totalTokens: number;\n inputTokens: number;\n outputTokens: number;\n /** Cache reads, usually the largest real component of totalTokens. */\n cachedTokens: number;\n reasoningTokens: number;\n groupCount: number;\n /** Groups the sink flagged as having failed, out of groupCount. */\n failedGroups: number;\n issueCount: number;\n}\n\nexport interface MetricsReport {\n generatedAt: string;\n dimension: MetricsDimension;\n period: MetricsPeriod;\n /**\n * The width of one timeline bucket, chosen by the sink from the period. A\n * long period over a short history collapses to a single bucket, so the page\n * says which unit it is drawing rather than implying a series it does not\n * have.\n */\n bucketUnit: string;\n /**\n * The dimension value this report is narrowed to, echoed back from the sink\n * rather than from the request. A daemon older than the filter ignores it\n * and returns everything, so trusting the request would label unfiltered\n * numbers as filtered; absence here means the drill-down did not happen.\n */\n focus?: string;\n /** Undefined when the source could not say which transport answered. */\n transport?: MetricsTransport;\n totals: MetricsTotals;\n timeline: MetricsBucket[];\n groups: MetricsGroup[];\n tools: MetricsTool[];\n}\n\n/**\n * Why the page has nothing to draw.\n *\n * The sink is a separate daemon that may be missing or simply empty, and those\n * are different things to tell a reader. Neither is an error: the metrics\n * source already treats a rejecting daemon as unavailable rather than fatal.\n *\n * 'no-api' is the third case and belongs to the cockpit rather than the sink.\n * A hub serving a bundle without package APIs mounted answers this route with\n * the SPA shell, and a 200 of HTML must read as a feature that is not\n * installed, not as a corrupt response.\n */\nexport type MetricsUnavailableReason = 'no-sink' | 'no-data' | 'no-api';\n\nexport interface MetricsUnavailable {\n unavailable: MetricsUnavailableReason;\n detail: string;\n}\n\nexport type MetricsResponse = MetricsReport | MetricsUnavailable;\n\nexport function isMetricsUnavailable(response: MetricsResponse): response is MetricsUnavailable {\n return 'unavailable' in response;\n}\n\nexport function isMetricsDimension(value: string): value is MetricsDimension {\n return (METRICS_DIMENSIONS as readonly string[]).includes(value);\n}\n\nexport function isMetricsPeriod(value: string): value is MetricsPeriod {\n return (METRICS_PERIODS as readonly string[]).includes(value);\n}\n\n/** One tool's failure count, paired with its call count from the metrics report. */\nexport interface IssueToolRow {\n name: string;\n failures: number;\n}\n\n/**\n * One recurring problem, as the sink fingerprints it.\n *\n * `occurrenceCount` is how many times it happened, which is the number a\n * reader acts on. `detail` is the actionable line; `message` is often only a\n * record name such as 'pi.tool_result'.\n */\nexport interface IssueSample {\n fingerprint: string;\n occurrenceCount: number;\n category: string;\n timestamp: string;\n level: string;\n message: string;\n detail: string;\n tool: string | null;\n errorType: string | null;\n agentName: string | null;\n model: string | null;\n statusCode: string | null;\n}\n\n/**\n * The detail behind the issue count.\n *\n * Fetched separately from the report because the running sink exposes no\n * issues route over HTTP, so this costs a subprocess. The page asks for it\n * when a reader opens the section, not with every refresh.\n */\nexport interface IssuesView {\n totalIssues: number;\n uniqueIncidents: number;\n byCategory: Record<string, number>;\n byTool: Record<string, number>;\n byErrorType: Record<string, number>;\n samples: IssueSample[];\n}\n\nexport type IssuesResponse = IssuesView | MetricsUnavailable;\n\nexport function isIssuesUnavailable(response: IssuesResponse): response is MetricsUnavailable {\n return 'unavailable' in response;\n}\n\n/** How many incidents the issues section ranks. */\nexport const ISSUE_SAMPLE_LIMIT = 50;\n\n/** How many tool rows the report ranks, so a failing tool has a denominator. */\nexport const METRICS_TOOL_LIMIT = 15;\n","import { apiResponse, defineApiRoutes } from '@agimon-ai/doompi-core/web';\n\nimport { METRICS_QUERY_PARAMS, type IssuesResponse, type MetricsResponse } from './webMetrics';\n\n/**\n * This package's routes, as data.\n *\n * No scope and no base path. The build reads both off\n * `src/extensions/(backend)/api/log/`, so the mount is stated once, by the\n * folder that creates it. They used to be written here, in the contract, and\n * again inside every URL the page built, and the copies were free to disagree.\n *\n * This is the one file the routes, the Hono app and the browser all read, so a\n * path cannot move on one side alone.\n */\nexport default defineApiRoutes({\n metrics: {\n method: 'GET',\n path: '/metrics',\n query: [METRICS_QUERY_PARAMS.dimension, METRICS_QUERY_PARAMS.period, METRICS_QUERY_PARAMS.focus],\n response: apiResponse<MetricsResponse>(),\n },\n /**\n * Separate from the report because the hub answers it from a subprocess: the\n * running daemon exposes no issues route, so folding it in would make every\n * refresh wait on the slowest transport.\n */\n issues: {\n method: 'GET',\n path: '/issues',\n query: [METRICS_QUERY_PARAMS.focus],\n response: apiResponse<IssuesResponse>(),\n },\n});\n","// Generated by @agimon-ai/doompi-build. Do not edit by hand.\nimport { createApiClient, sessionApiAddress } from '@agimon-ai/doompi-core/web';\nimport { sealedTransport } from '@agimon-ai/doompi-web-security/browser';\n\nimport routes from '../src/types/apiRoutes';\n\n/** Scopes the tree mounts 'log' at. */\nexport const apiScopes = ['global', 'workspace', 'session'] as const;\n\nexport const api = createApiClient(routes, {\n scopes: apiScopes,\n basePath: 'log',\n transport: (input, init) => sealedTransport.fetch(input, init),\n sessionAddress: sessionApiAddress,\n});\n","import type { ApiResult } from '@agimon-ai/doompi-core/web';\n\nimport { api } from '../../../../../generated/client';\nimport {\n isMetricsUnavailable,\n METRICS_QUERY_PARAMS,\n type MetricsDimension,\n type MetricsPeriod,\n type MetricsResponse,\n type IssuesResponse,\n} from '../../../../types/webMetrics';\n\n/**\n * The page's half of this package's metrics API. The only place the cockpit\n * talks to the hub for metrics, so if the transport ever changes, it changes\n * here alone.\n *\n * These are adapters now, not a transport. The generated client owns the URL\n * and the sealed transport with it, so nothing here spells a route or reaches\n * for `fetch`; a plugin calling `fetch` directly would send plaintext to the\n * tunnel's relay. What stays is this package's own vocabulary: which of the\n * empty states a reader is shown, and which answers are states rather than\n * failures.\n */\n\nconst UNREACHABLE = 'The cockpit hub is unreachable.';\n\n/**\n * A hub with no package APIs mounted serves the SPA shell for this route, so\n * the answer is a 200 of HTML rather than an error status. The client parses no\n * JSON out of it and reports no body at all, which is read here as an\n * uninstalled feature, because that is what it is.\n */\nconst NO_API_DETAIL = 'This cockpit is running a bundle without the log package API, so there are no metrics to read.';\n\nconst NO_API: MetricsResponse = { unavailable: 'no-api', detail: NO_API_DETAIL };\n\nexport type MetricsResult = { report: MetricsResponse } | { error: string };\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\n/** A body the hub sent, or which of the two silences it was. */\ntype Outcome = { body: Record<string, unknown> } | { noApi: true } | { error: string };\n\n/**\n * What the hub's answer means, before either route reads its own shape out of\n * it.\n *\n * `status: 0` is the transport never answering, which covers both a dead hub\n * and a caller that aborted. The client cannot tell those apart, so the signal\n * is asked: an abort is the caller replacing this request, and reporting it\n * would overwrite whatever the replacement renders.\n */\nfunction outcomeOf(result: ApiResult<unknown>, signal?: AbortSignal): Outcome {\n if (result.status === 0) return { error: signal?.aborted === true ? '' : UNREACHABLE };\n const status = `The hub answered ${String(result.status)}.`;\n // No body at all: unparseable and successful is the SPA fallback answering,\n // so the route is not mounted. Unparseable and failed is a hub that broke\n // mid-response, which is only worth the status it carried.\n if (result.data === undefined) return result.ok ? { noApi: true } : { error: status };\n if (!isRecord(result.data)) return { error: 'The hub returned a response the cockpit could not read.' };\n if (!result.ok) return { error: result.error === '' ? status : result.error };\n return { body: result.data };\n}\n\n/**\n * Reads one report.\n *\n * A missing sink is not an error here: the route answers it as an\n * `unavailable` body, so the page can name which of the empty states it is in\n * rather than showing one generic failure.\n */\nexport async function fetchMetrics(\n dimension: MetricsDimension,\n period: MetricsPeriod,\n focus?: string,\n signal?: AbortSignal,\n): Promise<MetricsResult> {\n const result = await api.global.metrics({\n query: {\n [METRICS_QUERY_PARAMS.dimension]: dimension,\n [METRICS_QUERY_PARAMS.period]: period,\n [METRICS_QUERY_PARAMS.focus]: focus === '' ? undefined : focus,\n },\n ...(signal === undefined ? {} : { signal }),\n });\n\n const outcome = outcomeOf(result, signal);\n if ('error' in outcome) return { error: outcome.error };\n if ('noApi' in outcome) return { report: NO_API };\n\n const report = outcome.body as unknown as MetricsResponse;\n if (!isMetricsUnavailable(report) && !Array.isArray(report.groups)) {\n return { error: 'The hub returned a report the cockpit could not read.' };\n }\n return { report };\n}\n\nexport type IssuesResult = { issues: IssuesResponse } | { error: string };\n\n/**\n * Reads the detail behind the issue count.\n *\n * Separate call because the hub answers it from a subprocess: folding it into\n * the report would make every refresh wait on the slowest transport.\n */\nexport async function fetchIssues(focus?: string, signal?: AbortSignal): Promise<IssuesResult> {\n const result = await api.global.issues({\n query: { [METRICS_QUERY_PARAMS.focus]: focus === '' ? undefined : focus },\n ...(signal === undefined ? {} : { signal }),\n });\n\n const outcome = outcomeOf(result, signal);\n if ('error' in outcome) return { error: outcome.error };\n if ('noApi' in outcome) return { issues: { unavailable: 'no-api', detail: NO_API_DETAIL } };\n return { issues: outcome.body as unknown as IssuesResponse };\n}\n","/**\n * The arithmetic behind the charts, kept out of the components so it can be\n * tested as plain functions rather than through a render.\n *\n * There is no charting library in the cockpit bundle and the plugin import\n * allowlist would not admit one, so these are the primitives the SVG is drawn\n * from. They are deliberately small: a bar length and a tick set, nothing that\n * pretends to be a plotting engine.\n */\n\n/** Bar length as a fraction of the track, guarding the all-zero series. */\nexport function barFraction(value: number, max: number): number {\n if (!Number.isFinite(value) || value <= 0) return 0;\n if (!Number.isFinite(max) || max <= 0) return 0;\n return Math.min(1, value / max);\n}\n\n/** The largest value in a series, or 0 for an empty one. */\nexport function seriesMax(values: readonly number[]): number {\n return values.reduce((highest, value) => (value > highest ? value : highest), 0);\n}\n\n/**\n * Compact token counts. Charts label axes and bars in a few characters, and a\n * raw nine-digit total makes every row the same illegible width.\n */\nexport function formatTokens(value: number): string {\n // A field the hub did not send is unknown, not zero. Rendering it as '0'\n // states a fact nobody measured, which is how a version skew between the\n // page bundle and the hub API turns into a confident wrong number.\n if (!Number.isFinite(value)) return '\\u2014';\n if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;\n if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`;\n return String(Math.round(value));\n}\n\n/**\n * Evenly spaced x positions for a series, in the 0..1 range.\n *\n * A single point sits at the left edge rather than dividing by zero, which is\n * what a one-bucket timeline is: the sink answered, there is just one day.\n */\nexport function evenPositions(count: number): number[] {\n if (count <= 0) return [];\n if (count === 1) return [0];\n return Array.from({ length: count }, (_, index) => index / (count - 1));\n}\n","import type { MetricsGroup } from '../../../../../types/webMetrics';\nimport { barFraction, formatTokens, seriesMax } from '../../_lib/chartScale';\n\n/**\n * Tokens per group, as horizontal bars.\n *\n * Horizontal rather than vertical because the labels are model names and\n * session hashes, which do not fit under a column. Each row is a button when\n * the caller supplies a handler: clicking one narrows the whole page to that\n * group, which is the drill-down.\n *\n * A row is not a link to a session even when the dimension is 'session'. The\n * identifier is a hash, so there is no session for it to open.\n */\n\ninterface GroupBarsProps {\n groups: readonly MetricsGroup[];\n /** The group the page is currently narrowed to, if any. */\n focus?: string;\n onFocus?: (key: string) => void;\n}\n\nexport function GroupBars({ groups, focus, onFocus }: GroupBarsProps) {\n const max = seriesMax(groups.map((group) => group.totalTokens));\n\n return (\n <div className=\"flex flex-col gap-[3px]\">\n {/* The two right-hand columns were bare numbers. A row reading \"11.0M 12\"\n gave no way to know the second figure counted issues. Same widths and\n padding as a row, so the heads sit over their own columns. */}\n <div aria-hidden className=\"flex items-center gap-2 px-1 text-2xs text-doom-faint/70\">\n <span className=\"min-w-0 flex-1\" />\n <span className=\"w-14 shrink-0 text-right\">tokens</span>\n <span className=\"w-10 shrink-0 text-right\">issues</span>\n </div>\n <ul className=\"flex flex-col gap-[3px]\" data-testid=\"metrics-group-bars\">\n {groups.map((group) => {\n const width = `${(barFraction(group.totalTokens, max) * 100).toFixed(2)}%`;\n const selected = focus === group.key;\n const row = (\n <>\n <span className=\"min-w-0 flex-1 truncate text-left text-doom-dim\">{group.key}</span>\n <span className=\"w-14 shrink-0 text-right text-doom-hi\">{formatTokens(group.totalTokens)}</span>\n <span className=\"w-10 shrink-0 text-right\">\n {group.issueCount === 0 ? (\n <span className=\"text-doom-faint/50\">ok</span>\n ) : (\n <span className=\"text-doom-red\">{group.issueCount}</span>\n )}\n </span>\n </>\n );\n\n return (\n <li key={group.key} className=\"relative\">\n {/* The bar is behind the text rather than beside it, so a long\n model name keeps its full width instead of competing with the\n track for horizontal space. */}\n <span\n aria-hidden=\"true\"\n className={`absolute inset-y-0 left-0 rounded-xs ${selected ? 'bg-doom-blue/30' : 'bg-doom-blue/15'}`}\n style={{ width }}\n />\n {onFocus === undefined ? (\n <span className=\"relative flex items-center gap-2 px-1 py-[3px] text-xs\">{row}</span>\n ) : (\n <button\n type=\"button\"\n onClick={() => onFocus(selected ? '' : group.key)}\n aria-pressed={selected}\n data-testid={`metrics-group-${group.key}`}\n className=\"relative flex w-full items-center gap-2 rounded-xs px-1 py-[3px] text-xs hover:bg-doom-tint focus-visible:outline focus-visible:outline-1 focus-visible:outline-doom-blue\"\n >\n {row}\n </button>\n )}\n </li>\n );\n })}\n </ul>\n </div>\n );\n}\n","import type { MetricsBucket } from '../../../../../types/webMetrics';\nimport { barFraction, formatTokens, seriesMax } from '../../_lib/chartScale';\n\n/**\n * Tokens per bucket, as columns.\n *\n * Drawn as SVG rather than with a charting library because the cockpit bundle\n * has none and the plugin import allowlist admits none. That constraint is a\n * good fit here: this is a bar per bucket, and a dependency would cost more\n * than it saves.\n *\n * Columns use currentColor and theme classes rather than literal colours, so\n * the chart follows whichever theme the cockpit renders with.\n */\n\nconst VIEWBOX_HEIGHT = 100;\n\n/**\n * Widest a single column may get.\n *\n * Dividing the width by the bucket count alone turns a one-bucket period into\n * a slab spanning the whole panel at full height, which reads as a rendering\n * fault rather than as \"one bucket\". Capping it keeps a short series looking\n * like bars on an axis.\n */\nconst MAX_COLUMN_PERCENT = 9;\n\ninterface TimelineChartProps {\n buckets: readonly MetricsBucket[];\n /** The sink's bucket width, named so a flat chart explains itself; absent on an older hub. */\n bucketUnit?: string;\n}\n\nexport function TimelineChart({ buckets, bucketUnit }: TimelineChartProps) {\n // An older hub does not send the unit; say nothing rather than 'undefined'.\n const unit = bucketUnit === undefined || bucketUnit === '' ? '' : `${bucketUnit} `;\n const max = seriesMax(buckets.map((bucket) => bucket.totalTokens));\n const columnWidth = Math.min(MAX_COLUMN_PERCENT, 100 / Math.max(1, buckets.length));\n\n return (\n <div className=\"flex flex-col gap-1\" data-testid=\"metrics-timeline-chart\">\n <svg\n viewBox={`0 0 100 ${VIEWBOX_HEIGHT}`}\n preserveAspectRatio=\"none\"\n className=\"h-24 w-full text-doom-blue\"\n role=\"img\"\n aria-label={`tokens per bucket, peak ${formatTokens(max)}`}\n >\n {buckets.map((bucket, index) => {\n const height = barFraction(bucket.totalTokens, max) * VIEWBOX_HEIGHT;\n return (\n <rect\n key={bucket.label}\n x={index * columnWidth + columnWidth * 0.15}\n y={VIEWBOX_HEIGHT - height}\n width={columnWidth * 0.7}\n height={height}\n fill=\"currentColor\"\n >\n <title>{`${bucket.label}: ${formatTokens(bucket.totalTokens)} tokens`}</title>\n </rect>\n );\n })}\n </svg>\n <div className=\"flex flex-wrap justify-between gap-x-3 text-2xs text-doom-faint\">\n <span>{buckets[0]?.label ?? ''}</span>\n {buckets.length > 1 ? <span>peak {formatTokens(max)}</span> : null}\n <span>\n {buckets.length === 1\n ? `one ${unit}bucket, ${formatTokens(max)}; pick a shorter period for finer buckets`\n : `${String(buckets.length)} ${unit}buckets`}\n </span>\n </div>\n </div>\n );\n}\n","import type { IssueSample } from './webMetrics';\n\n/**\n * Turning a list of incidents into a ranked list of problems.\n *\n * Pure, and in types/ so both halves may use it: the page ranks, and a test\n * can assert the ranking without a render or a subprocess.\n *\n * Two things the sink's own output needs fixing for. It can emit the same\n * fingerprint more than once, so occurrences have to be re-summed or the same\n * problem appears three times at a third of its real weight. And its ordering\n * is by recency, which buries a problem that happened twenty times under one\n * that happened once a minute ago.\n */\n\nexport interface IssueGroup {\n /** Stable identity for a row, unique after merging. */\n key: string;\n /** How many times this exact problem happened. */\n occurrences: number;\n category: string;\n /** The actionable line: the spawn that failed, the path that was missing. */\n detail: string;\n tool: string | null;\n errorType: string | null;\n agentName: string | null;\n model: string | null;\n statusCode: string | null;\n /** Most recent occurrence across the merged incidents. */\n lastSeen: string;\n /** Every incident folded into this row, for the expanded view. */\n members: IssueSample[];\n}\n\n/**\n * A key that separates problems a reader would act on separately.\n *\n * The sink's fingerprint is the primary key, but it can be empty, and several\n * distinct bash failures share 'tool_failure|pi|bash||pi.tool_result' because\n * the record name is all they carry. Folding the detail in keeps two different\n * failures apart while still merging repeats of the same one.\n */\nexport function groupingKey(sample: IssueSample): string {\n const base = sample.fingerprint === '' ? `${sample.category}|${sample.message}` : sample.fingerprint;\n return `${base}|${sample.detail}`;\n}\n\n/** Incidents merged by problem and ranked by how often each happened. */\nexport function groupIssues(samples: readonly IssueSample[]): IssueGroup[] {\n const groups = new Map<string, IssueGroup>();\n\n for (const sample of samples) {\n const key = groupingKey(sample);\n const existing = groups.get(key);\n if (existing === undefined) {\n groups.set(key, {\n key,\n occurrences: sample.occurrenceCount,\n category: sample.category,\n detail: sample.detail,\n tool: sample.tool,\n errorType: sample.errorType,\n agentName: sample.agentName,\n model: sample.model,\n statusCode: sample.statusCode,\n lastSeen: sample.timestamp,\n members: [sample],\n });\n continue;\n }\n existing.occurrences += sample.occurrenceCount;\n existing.members.push(sample);\n if (sample.timestamp > existing.lastSeen) existing.lastSeen = sample.timestamp;\n // A merged row keeps whichever identifying field any member supplied, so\n // one incident missing an agent name does not blank it for the group.\n existing.tool ??= sample.tool;\n existing.errorType ??= sample.errorType;\n existing.agentName ??= sample.agentName;\n existing.model ??= sample.model;\n existing.statusCode ??= sample.statusCode;\n }\n\n return [...groups.values()].sort(\n (left, right) => right.occurrences - left.occurrences || left.detail.localeCompare(right.detail),\n );\n}\n","import { useState } from 'react';\n\nimport { groupIssues, type IssueGroup } from '../../../../types/issueGrouping';\nimport type { IssuesView, MetricsTool } from '../../../../types/webMetrics';\nimport { barFraction } from '../_lib/chartScale';\n\n/**\n * What is actually going wrong, ranked by how often.\n *\n * A count of 69 issues tells nobody what to change. The same 69 collapsed into\n * \"this spawn failed 20 times, this hook failed 3\" is a work list, so the\n * ranked bar is the primary view here and the totals are context beside it.\n *\n * Each bar expands to the incidents behind it, because the row states the\n * problem and the reader still needs the session, model, and timestamps to go\n * and look at one.\n */\n\nfunction countRows(counts: Record<string, number>): [string, number][] {\n return Object.entries(counts).sort(([, left], [, right]) => right - left);\n}\n\n/** A short, human label for a problem; the detail can be a whole command line. */\nfunction titleOf(group: IssueGroup): string {\n if (group.errorType !== null) return group.errorType;\n if (group.tool !== null) return `${group.tool} failed`;\n return group.category;\n}\n\ninterface IssueRowProps {\n group: IssueGroup;\n max: number;\n tools: readonly MetricsTool[];\n}\n\nfunction IssueRow({ group, max, tools }: IssueRowProps) {\n const [open, setOpen] = useState(false);\n const width = `${(barFraction(group.occurrences, max) * 100).toFixed(2)}%`;\n const calls = tools.find((tool) => tool.name === group.tool)?.calls;\n\n return (\n <li className=\"flex flex-col\">\n <div className=\"relative\">\n <span aria-hidden=\"true\" className=\"absolute inset-y-0 left-0 rounded-xs bg-doom-red/20\" style={{ width }} />\n <button\n type=\"button\"\n onClick={() => setOpen(!open)}\n aria-expanded={open}\n data-testid={`metrics-issue-${group.key}`}\n className=\"relative flex w-full items-center gap-2 rounded-xs px-1 py-[3px] text-left text-xs hover:bg-doom-tint focus-visible:outline focus-visible:outline-1 focus-visible:outline-doom-blue\"\n >\n <span className=\"w-8 shrink-0 text-right font-bold text-doom-red\">{group.occurrences}</span>\n <span className=\"w-24 shrink-0 truncate text-doom-hi\">{titleOf(group)}</span>\n <span className=\"min-w-0 flex-1 truncate text-doom-dim\">{group.detail}</span>\n {calls === undefined ? null : <span className=\"shrink-0 text-doom-faint\">of {calls} calls</span>}\n </button>\n </div>\n\n {open ? (\n <div className=\"flex flex-col gap-1 px-2 py-2 text-xs\" data-testid={`metrics-issue-body-${group.key}`}>\n <div className=\"flex flex-wrap gap-x-3 gap-y-1 text-2xs text-doom-faint\">\n <span>category {group.category}</span>\n {group.tool === null ? null : <span>tool {group.tool}</span>}\n {group.errorType === null ? null : <span>error {group.errorType}</span>}\n {group.statusCode === null ? null : <span>status {group.statusCode}</span>}\n {group.agentName === null ? null : <span>agent {group.agentName}</span>}\n {group.model === null ? null : <span>model {group.model}</span>}\n <span>last seen {group.lastSeen}</span>\n </div>\n <span className=\"break-words text-doom-dim\">{group.detail}</span>\n <ul className=\"flex flex-col gap-[1px] text-2xs text-doom-faint\">\n {group.members.map((member, index) => (\n <li key={`${member.timestamp}-${String(index)}`} className=\"flex flex-wrap gap-x-2\">\n <span>{member.timestamp}</span>\n <span>{member.level}</span>\n <span>\n {member.occurrenceCount}\n {'\\u00d7'}\n </span>\n <span className=\"min-w-0 truncate\">{member.message}</span>\n </li>\n ))}\n </ul>\n </div>\n ) : null}\n </li>\n );\n}\n\nexport interface IssuesDetailProps {\n view: IssuesView;\n /** Tool call counts from the report, used as the denominator. */\n tools: readonly MetricsTool[];\n}\n\nexport function IssuesDetail({ view, tools }: IssuesDetailProps) {\n const groups = groupIssues(view.samples);\n const max = groups[0]?.occurrences ?? 0;\n const callsByTool = new Map(tools.map((tool) => [tool.name, tool.calls]));\n\n return (\n <div className=\"flex flex-col gap-3\">\n <span className=\"text-xs text-doom-dim\">\n <span className=\"text-doom-hi\">{view.totalIssues}</span> occurrences of{' '}\n <span className=\"text-doom-hi\">{groups.length}</span> distinct problems, worst first\n </span>\n\n {groups.length === 0 ? null : (\n <ul className=\"flex flex-col gap-[2px]\" data-testid=\"metrics-issue-groups\">\n {groups.map((group) => (\n <IssueRow key={group.key} group={group} max={max} tools={tools} />\n ))}\n </ul>\n )}\n\n <div className=\"flex flex-col gap-1\">\n <span className=\"text-2xs font-bold text-doom-faint\">failures by tool</span>\n <table className=\"w-full text-xs\" data-testid=\"metrics-issues-tools\">\n <tbody>\n {countRows(view.byTool).map(([name, failures]) => {\n const calls = callsByTool.get(name);\n return (\n <tr key={name} className=\"border-b border-doom-border/40\">\n <td className=\"min-w-0 truncate py-1 text-doom-dim\">{name}</td>\n <td className=\"w-16 py-1 text-right text-doom-red\">{failures}</td>\n <td className=\"w-28 py-1 text-right text-doom-faint\">\n {/* Only tools the report also ranked have a denominator;\n the two reports scan on their own limits. */}\n {calls === undefined ? 'of unknown calls' : `of ${String(calls)} calls`}\n </td>\n </tr>\n );\n })}\n </tbody>\n </table>\n </div>\n\n <div className=\"flex flex-wrap gap-x-4 gap-y-1 text-xs text-doom-dim\">\n <span className=\"text-2xs font-bold text-doom-faint\">by category</span>\n {countRows(view.byCategory).map(([name, count]) => (\n <span key={name}>\n {name} <span className=\"text-doom-hi\">{count}</span>\n </span>\n ))}\n </div>\n </div>\n );\n}\n","import { Button, Spinner } from '@agimon-ai/doompi-web-components';\nimport { useState } from 'react';\n\nimport { isIssuesUnavailable, type IssuesView, type MetricsTool } from '../../../../types/webMetrics';\nimport { fetchIssues } from '../_lib/metricsApi';\nimport { IssuesDetail } from './IssuesDetail';\n\n/**\n * The detail behind the issue count.\n *\n * Collapsed until asked for, because the hub answers this from a subprocess:\n * the running log sink serves no issues route over HTTP. Opening it is the\n * reader agreeing to that cost, which is better than making every refresh of\n * the whole page wait on the slowest transport.\n *\n * Only the going-and-getting lives here; IssuesDetail owns what the numbers\n * look like once they arrive.\n */\n\ninterface IssuesSectionProps {\n /** Tool call counts from the report, used as the denominator. */\n tools: readonly MetricsTool[];\n /** The dimension value the page is narrowed to, forwarded as a session filter. */\n focus?: string;\n}\n\nexport function IssuesSection({ tools, focus }: IssuesSectionProps) {\n const [view, setView] = useState<IssuesView | undefined>(undefined);\n const [message, setMessage] = useState('');\n const [loading, setLoading] = useState(false);\n const [open, setOpen] = useState(false);\n\n const load = (): void => {\n setOpen(true);\n setLoading(true);\n setMessage('');\n void fetchIssues(focus).then((result) => {\n setLoading(false);\n if ('error' in result) {\n if (result.error !== '') setMessage(result.error);\n return;\n }\n if (isIssuesUnavailable(result.issues)) {\n setMessage(result.issues.detail);\n return;\n }\n setView(result.issues);\n });\n };\n\n return (\n <section className=\"flex flex-col gap-1\" data-testid=\"metrics-issues\">\n <div className=\"flex items-center gap-2\">\n <span className=\"text-2xs font-bold text-doom-faint\">issues</span>\n {open ? null : (\n <Button variant=\"ghost\" size=\"xs\" className=\"text-2xs\" onClick={load} data-testid=\"metrics-issues-open\">\n show detail\n </Button>\n )}\n {open && !loading ? (\n <Button variant=\"ghost\" size=\"xs\" className=\"text-2xs\" onClick={load} data-testid=\"metrics-issues-reload\">\n reload\n </Button>\n ) : null}\n {loading ? <Spinner /> : null}\n </div>\n {/* Reading this scans the whole window in a subprocess, so the reader is\n told why it is behind a click rather than left wondering. */}\n {open ? null : (\n <span className=\"text-2xs text-doom-faint/70\">\n the log sink has no issues endpoint, so this is read separately and takes a moment\n </span>\n )}\n\n {message === '' ? null : (\n <span className=\"text-xs text-doom-yellow\" data-testid=\"metrics-issues-message\">\n {message}\n </span>\n )}\n\n {view === undefined ? null : <IssuesDetail view={view} tools={tools} />}\n </section>\n );\n}\n","import { Badge } from '@agimon-ai/doompi-web-components';\n\nimport type { MetricsDimension, MetricsReport } from '../../../../types/webMetrics';\nimport { formatTokens } from '../_lib/chartScale';\nimport { GroupBars } from './charts/GroupBars';\nimport { TimelineChart } from './charts/TimelineChart';\nimport { IssuesSection } from './IssuesSection';\n\n/**\n * One report, drawn.\n *\n * Separated from the panel so the loaded shape is a pure function of a report:\n * the panel owns the selects, the fetch and the empty states, and this owns\n * what a report looks like. That split is what lets the degenerate shapes, a\n * single bucket or an all-zero series, be rendered and asserted directly.\n */\n\nexport const DIMENSION_LABELS: Record<MetricsDimension, string> = {\n session: 'session',\n agent: 'agent',\n model: 'model',\n provider: 'provider',\n};\n\n/**\n * The session dimension shows an opaque hash. doompi-telemetry hashes\n * identifier-shaped attributes before export, by design, so there is no\n * session here the cockpit could open even if the row were clickable.\n */\nexport const DIMENSION_NOTES: Partial<Record<MetricsDimension, string>> = {\n session: 'session ids are hashed before export, so these identify a session without naming one',\n};\n\nexport interface MetricsReportViewProps {\n report: MetricsReport;\n onFocus: (key: string) => void;\n}\n\nexport function MetricsReportView({ report, onFocus }: MetricsReportViewProps) {\n return (\n <div className=\"flex flex-col gap-4\">\n <div className=\"flex flex-col gap-1\">\n <div className=\"flex flex-wrap items-center gap-3 text-xs text-doom-dim\">\n <span>\n <span className=\"text-doom-hi\">{formatTokens(report.totals.totalTokens)}</span> total\n </span>\n <span>\n <span className=\"text-doom-hi\">{formatTokens(report.totals.cachedTokens)}</span> cache reads\n </span>\n <span>\n <span className=\"text-doom-hi\">{formatTokens(report.totals.outputTokens)}</span> out\n </span>\n <span>\n <span className=\"text-doom-hi\">{formatTokens(report.totals.inputTokens)}</span> in\n </span>\n {report.totals.reasoningTokens === 0 ? null : (\n <span>\n <span className=\"text-doom-hi\">{formatTokens(report.totals.reasoningTokens)}</span> reasoning\n </span>\n )}\n {report.totals.issueCount === 0 ? null : <Badge tone=\"red\">{report.totals.issueCount} issues</Badge>}\n {report.totals.failedGroups === 0 ? null : (\n <span className=\"text-doom-faint\">\n {report.totals.failedGroups} of {report.totals.groupCount} {DIMENSION_LABELS[report.dimension]}s failed\n </span>\n )}\n </div>\n {/*\n The total is the providers' own figure, and on a cached workload\n the named parts are a fraction of a percent of it. Listing them\n beside it without this line reads as a breakdown that does not\n add up, which is worse than not showing them.\n */}\n <span className=\"text-2xs text-doom-faint/70\">\n total is what the providers reported and is dominated by cache traffic; the parts beside it are counted\n separately and do not sum to it\n </span>\n </div>\n\n <section className=\"flex flex-col gap-1\">\n <span className=\"text-2xs font-bold text-doom-faint\">tokens over time</span>\n <TimelineChart buckets={report.timeline} bucketUnit={report.bucketUnit} />\n </section>\n\n <section className=\"flex flex-col gap-1\">\n <span className=\"text-2xs font-bold text-doom-faint\">tokens by {DIMENSION_LABELS[report.dimension]}</span>\n {DIMENSION_NOTES[report.dimension] === undefined ? null : (\n <span className=\"text-2xs text-doom-faint/70\">{DIMENSION_NOTES[report.dimension]}</span>\n )}\n <GroupBars groups={report.groups} focus={report.focus} onFocus={onFocus} />\n </section>\n\n <section className=\"flex flex-col gap-1\">\n <span className=\"text-2xs font-bold text-doom-faint\">tool calls</span>\n {/*\n The token column is a ranking hint, not a measurement. The sink\n attributes a turn's whole total to every tool that ran in that\n turn, so saying otherwise here would be a lie the chart repeats.\n */}\n <span className=\"text-2xs text-doom-faint/70\">\n call counts are exact; the token column ranks tools by the turns they ran in, and is not each tool's own\n consumption\n </span>\n <table className=\"w-full text-xs\" data-testid=\"metrics-tools\">\n {/* Without heads the two right columns are just numbers; \"1006\" and\n \"325.9k\" do not say which is a call count and which is tokens. */}\n <thead>\n <tr className=\"text-2xs text-doom-faint/70\">\n <th className=\"py-1 text-left font-normal\">tool</th>\n <th className=\"w-20 py-1 text-right font-normal\">calls</th>\n <th className=\"w-20 py-1 text-right font-normal\">tokens</th>\n </tr>\n </thead>\n <tbody>\n {report.tools.map((tool) => (\n <tr key={tool.name} className=\"border-b border-doom-border/40\">\n <td className=\"min-w-0 truncate py-1 text-doom-dim\">{tool.name}</td>\n <td className=\"w-20 py-1 text-right text-doom-hi\">{tool.calls}</td>\n <td className=\"w-20 py-1 text-right text-doom-faint\">{formatTokens(tool.p90TotalTokens)}</td>\n </tr>\n ))}\n </tbody>\n </table>\n </section>\n\n <IssuesSection tools={report.tools} focus={report.dimension === 'session' ? report.focus : undefined} />\n\n <span className=\"text-2xs text-doom-faint/70\" data-testid=\"metrics-provenance\">\n read over {report.transport ?? 'an unreported transport'} · generated {report.generatedAt}\n </span>\n </div>\n );\n}\n","import { Button, EmptyState } from '@agimon-ai/doompi-web-components';\n\nimport type { MetricsDimension, MetricsUnavailable, MetricsUnavailableReason } from '../../../../types/webMetrics';\nimport { DIMENSION_LABELS } from './MetricsReportView';\n\n/**\n * Everything the page says instead of, or above, a report.\n *\n * Kept pure and apart from the panel because these are the states that decide\n * whether a reader trusts the numbers, and they are the ones a fetching\n * component makes awkward to assert. The panel decides which state it is in;\n * this decides how each one reads.\n */\n\nconst EMPTY_TITLES: Record<MetricsUnavailableReason, string> = {\n 'no-sink': 'no log sink',\n 'no-data': 'nothing recorded yet',\n 'no-api': 'metrics not installed',\n};\n\nexport function EmptyForReason({ response }: { response: MetricsUnavailable }) {\n return (\n <EmptyState title={EMPTY_TITLES[response.unavailable]} description={response.detail} data-testid=\"metrics-empty\" />\n );\n}\n\nexport interface FocusNoticeProps {\n /** What the reader asked to narrow to; empty means they asked for nothing. */\n requested: string;\n /** What the sink echoed back as applied; absent means it ignored the filter. */\n applied: string | undefined;\n dimension: MetricsDimension;\n onClear: () => void;\n}\n\nexport function FocusNotice({ requested, applied, dimension, onClear }: FocusNoticeProps) {\n if (requested === '') return null;\n if (applied === undefined) {\n // The sink answered without echoing the filter, so these are the machine's\n // whole numbers. Saying \"showing model X\" over them would be a lie, so the\n // drill-down is reported as refused instead.\n return (\n <span className=\"text-xs text-doom-yellow\" data-testid=\"metrics-focus-refused\">\n this log sink does not support narrowing by {DIMENSION_LABELS[dimension]}, so the numbers below are still\n everything\n <Button variant=\"ghost\" size=\"xs\" className=\"ml-2 text-2xs\" onClick={onClear}>\n clear\n </Button>\n </span>\n );\n }\n return (\n <span className=\"text-xs text-doom-dim\" data-testid=\"metrics-focus\">\n narrowed to <span className=\"text-doom-hi\">{applied}</span>\n <Button variant=\"ghost\" size=\"xs\" className=\"ml-2 text-2xs\" onClick={onClear}>\n clear\n </Button>\n </span>\n );\n}\n","import type { SettingsPanelProps } from '@agimon-ai/doompi-core/web';\nimport {\n Button,\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n Spinner,\n} from '@agimon-ai/doompi-web-components';\nimport { useEffect, useState } from 'react';\n\nimport {\n isMetricsUnavailable,\n METRICS_DIMENSIONS,\n METRICS_PERIODS,\n type MetricsDimension,\n type MetricsPeriod,\n type MetricsReport,\n type MetricsResponse,\n} from '../../../../types/webMetrics';\nimport { fetchMetrics } from '../_lib/metricsApi';\nimport { EmptyForReason, FocusNotice } from './MetricsNotice';\nimport { DIMENSION_LABELS } from './MetricsReportView';\nimport { MetricsReportView } from './MetricsReportView';\n\n/**\n * The metrics settings page.\n *\n * Reports rather than writes, which is why it is drawn instead of declared as\n * fields. The numbers come from the machine's log sink, so every one of them\n * can be absent for a reason the reader needs told apart: no sink installed,\n * a sink with nothing recorded yet, or a hub that did not answer.\n *\n * Tables here, charts next. The data path is worth proving before the drawing\n * code is layered on it.\n */\n\nexport function MetricsPanel(_props: SettingsPanelProps) {\n const [dimension, setDimension] = useState<MetricsDimension>('model');\n const [period, setPeriod] = useState<MetricsPeriod>('week');\n const [focus, setFocus] = useState('');\n const [response, setResponse] = useState<MetricsResponse | undefined>(undefined);\n const [error, setError] = useState('');\n const [loading, setLoading] = useState(true);\n const [refreshKey, setRefreshKey] = useState(0);\n\n useEffect(() => {\n const controller = new AbortController();\n setLoading(true);\n void fetchMetrics(dimension, period, focus, controller.signal).then((result) => {\n if (controller.signal.aborted) return;\n if ('error' in result) {\n // An empty message is an aborted request, which the next effect replaces.\n if (result.error !== '') setError(result.error);\n } else {\n setError('');\n setResponse(result.report);\n }\n setLoading(false);\n });\n return () => controller.abort();\n }, [dimension, period, focus, refreshKey]);\n\n const report: MetricsReport | undefined =\n response === undefined || isMetricsUnavailable(response) ? undefined : response;\n\n return (\n <div className=\"flex flex-col gap-3\" data-testid=\"metrics-panel\">\n <div className=\"flex min-w-0 flex-wrap items-center gap-2\">\n <Select\n value={dimension}\n onValueChange={(next) => {\n setFocus('');\n setDimension(next as MetricsDimension);\n }}\n >\n <SelectTrigger data-testid=\"metrics-dimension\" className=\"w-[140px] text-xs\">\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {METRICS_DIMENSIONS.map((candidate) => (\n <SelectItem key={candidate} value={candidate}>\n {DIMENSION_LABELS[candidate]}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n <Select value={period} onValueChange={(next) => setPeriod(next as MetricsPeriod)}>\n <SelectTrigger data-testid=\"metrics-period\" className=\"w-[110px] text-xs\">\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {METRICS_PERIODS.map((candidate) => (\n <SelectItem key={candidate} value={candidate}>\n {candidate}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n {loading ? <Spinner /> : null}\n <Button\n variant=\"ghost\"\n size=\"xs\"\n className=\"ml-auto text-2xs\"\n onClick={() => setRefreshKey((current) => current + 1)}\n disabled={loading}\n >\n refresh\n </Button>\n </div>\n\n {error === '' ? null : (\n <span className=\"text-xs text-doom-red\" data-testid=\"metrics-error\">\n {error}\n </span>\n )}\n\n {response !== undefined && isMetricsUnavailable(response) ? <EmptyForReason response={response} /> : null}\n\n {report === undefined ? null : (\n <FocusNotice requested={focus} applied={report.focus} dimension={dimension} onClear={() => setFocus('')} />\n )}\n\n {report === undefined ? null : <MetricsReportView report={report} onFocus={setFocus} />}\n </div>\n );\n}\n","import { defineSettingsPanel } from '@agimon-ai/doompi-core/web';\n\nimport { MetricsPanel } from './_components/MetricsPanel';\nexport default defineSettingsPanel({\n label: 'metrics',\n detail: 'where this machine spent its tokens and its money',\n component: MetricsPanel,\n});\n","// Generated by @agimon-ai/doompi-build. Do not edit by hand.\nimport { defineWebPlugin, type WebPluginContributions } from '@agimon-ai/doompi-core/web';\n\nimport settingMetrics from '../src/extensions/(frontend)/setting/metrics.panel.web';\n\nconst via = <T>(identity: Record<string, unknown>, value: unknown): T => ({ ...identity, ...(value as object) }) as T;\n\nexport const webPlugin = defineWebPlugin({\n id: 'log',\n global: {\n get settingsPanels(): WebPluginContributions['settingsPanels'] { return [via({ id: 'metrics' }, settingMetrics)]; },\n },\n});\n"],"mappings":"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"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
require("../../constants/telemetry.cjs");
|
|
1
2
|
const require_index = require("../metricsSource/index.cjs");
|
|
2
3
|
const require_index$1 = require("../issuesSource/index.cjs");
|
|
3
4
|
const require_webMetrics = require("../../types/webMetrics.cjs");
|
|
@@ -113,17 +114,23 @@ function toReport(report, source, dimension) {
|
|
|
113
114
|
};
|
|
114
115
|
}
|
|
115
116
|
/**
|
|
116
|
-
* A report
|
|
117
|
-
*
|
|
118
|
-
*
|
|
117
|
+
* A report with nothing in it reads as "no data", not as an empty chart. The
|
|
118
|
+
* reader may have used the HTTP sink or a worker against an empty history, so
|
|
119
|
+
* the empty-state detail must not claim the sink is running.
|
|
119
120
|
*/
|
|
120
121
|
function isEmpty(report) {
|
|
121
122
|
return report.totals.totalTokens === 0 && report.groups.length === 0 && report.tools.length === 0;
|
|
122
123
|
}
|
|
123
124
|
function createLogHubApi(options = {}) {
|
|
124
125
|
const app = new hono.Hono();
|
|
125
|
-
const source = options.source ?? require_index.createMetricsSource(
|
|
126
|
-
|
|
126
|
+
const source = options.source ?? require_index.createMetricsSource({
|
|
127
|
+
packageName: "@agimon-ai/doompi-log",
|
|
128
|
+
serviceName: "pi"
|
|
129
|
+
});
|
|
130
|
+
const issues = options.issues ?? require_index$1.createIssuesSource({
|
|
131
|
+
packageName: "@agimon-ai/doompi-log",
|
|
132
|
+
serviceName: "pi"
|
|
133
|
+
});
|
|
127
134
|
app.get(require_apiRoutes.default.metrics.path, async (context) => {
|
|
128
135
|
const requestedDimension = context.req.query(require_webMetrics.METRICS_QUERY_PARAMS.dimension) ?? DEFAULT_DIMENSION;
|
|
129
136
|
const requestedPeriod = context.req.query(require_webMetrics.METRICS_QUERY_PARAMS.period) ?? DEFAULT_PERIOD;
|
|
@@ -150,7 +157,7 @@ function createLogHubApi(options = {}) {
|
|
|
150
157
|
const body = toReport(report, source, requestedDimension);
|
|
151
158
|
if (isEmpty(body)) return context.json({
|
|
152
159
|
unavailable: "no-data",
|
|
153
|
-
detail: "
|
|
160
|
+
detail: "No recorded usage was found for this period."
|
|
154
161
|
});
|
|
155
162
|
return context.json(body);
|
|
156
163
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.cjs","names":["Hono","createMetricsSource","createIssuesSource","routes","METRICS_QUERY_PARAMS","isMetricsDimension","isMetricsPeriod"],"sources":["../../../../src/services/hubApi/index.ts"],"sourcesContent":["import type { DoomApi, DoomApiContext, DoomApiHandler } from '@agimon-ai/doompi-core/package-api';\nimport type { LogMetricGroupRow, LogMetricsReport, ToolMetricRow } from '@agimon-ai/log-sink-mcp';\nimport { Hono } from 'hono';\n\nimport { createIssuesSource } from '../../services/issuesSource';\nimport { createMetricsSource } from '../../services/metricsSource';\nimport routes from '../../types/apiRoutes';\nimport type { IssuesSource } from '../../types/issuesSource';\nimport type { MetricsFilter, MetricsSource } from '../../types/metricsSource';\nimport {\n isMetricsDimension,\n isMetricsPeriod,\n LOG_API_BASE_PATH,\n ISSUE_SAMPLE_LIMIT,\n METRICS_GROUP_LIMIT,\n METRICS_QUERY_PARAMS,\n METRICS_TOOL_LIMIT,\n type MetricsBucket,\n type MetricsDimension,\n type MetricsGroup,\n type MetricsPeriod,\n type MetricsReport,\n type MetricsTool,\n type MetricsUnavailable,\n type IssuesView,\n} from '../../types/webMetrics';\n\n/**\n * The cockpit's half of this package's metrics access.\n *\n * Hub-scoped rather than session-scoped: the questions this answers, where the\n * tokens went and which agents failed, are about the machine over time, and a\n * session server can only see itself. The sink is already machine-wide, so the\n * hub is the only place the two agree.\n *\n * The query path is not reimplemented here. `createMetricsSource` already owns\n * the sink's two transports and its fallback order, and the TUI overlay reads\n * through the same source, so both surfaces answer from one implementation.\n */\n\nconst DEFAULT_DIMENSION: MetricsDimension = 'model';\nconst DEFAULT_PERIOD: MetricsPeriod = 'week';\n\n/** The sink groups sessions under a different name than the page's dimension. */\nconst SINK_GROUP_BY = {\n session: 'session',\n agent: 'agent',\n model: 'model',\n provider: 'provider',\n} as const;\n\nfunction toBucket(bucket: LogMetricsReport['timeline'][number]): MetricsBucket {\n return {\n label: bucket.label,\n totalTokens: bucket.totalTokens,\n inputTokens: bucket.inputTokens,\n outputTokens: bucket.outputTokens,\n };\n}\n\nfunction toGroup(row: LogMetricGroupRow): MetricsGroup {\n return {\n key: row.key,\n totalTokens: row.totalTokens,\n inputTokens: row.inputTokens,\n outputTokens: row.outputTokens,\n issueCount: row.issueCount,\n failed: row.failed,\n };\n}\n\n/**\n * The turn's p90 rather than a sum: the sink attributes a whole turn's tokens\n * to every tool that ran in it, so adding them across calls would multiply the\n * same tokens by however many tools shared the turn.\n */\nfunction toTool(row: ToolMetricRow): MetricsTool {\n return {\n name: row.toolName,\n calls: row.invocationCount,\n p90TotalTokens: row.toolCallTurn.p90TotalTokens,\n };\n}\n\n/** Which filter field carries a focus value, per dimension. */\nconst FOCUS_FIELD: Record<MetricsDimension, keyof MetricsFilter> = {\n session: 'sessionId',\n agent: 'agentName',\n model: 'model',\n provider: 'provider',\n};\n\n/**\n * The focus the sink actually applied, read from its own echo.\n *\n * A daemon that predates a filter ignores it and answers with everything. If\n * the page trusted the request instead of this echo, it would label the whole\n * machine's numbers as one model's.\n */\nfunction appliedFocus(report: LogMetricsReport, dimension: MetricsDimension): string | undefined {\n // The daemon is a separate process on its own release cadence, so its\n // response is parsed defensively rather than trusted to carry every field.\n const filters = report.filters as LogMetricsReport['filters'] | undefined;\n if (filters === undefined) return undefined;\n if (dimension === 'session') return filters.sessionId;\n if (dimension === 'agent') return filters.agentName;\n const applied = dimension === 'model' ? filters.model : filters.provider;\n if (applied === undefined) return undefined;\n return Array.isArray(applied) ? applied[0] : applied;\n}\n\n/**\n * The sink's own type says Date, but both transports parse JSON, so what\n * arrives is an ISO string. Trusting the declared type here throws on every\n * real response, which no test using a hand-built Date would ever catch.\n */\nfunction generatedAtIso(value: LogMetricsReport['generatedAt']): string {\n if (value instanceof Date) return value.toISOString();\n const parsed = new Date(value as unknown as string);\n return Number.isNaN(parsed.getTime()) ? new Date(0).toISOString() : parsed.toISOString();\n}\n\nfunction toReport(report: LogMetricsReport, source: MetricsSource, dimension: MetricsDimension): MetricsReport {\n const focus = appliedFocus(report, dimension);\n return {\n generatedAt: generatedAtIso(report.generatedAt),\n dimension: report.groupBy as MetricsDimension,\n period: report.period as MetricsPeriod,\n bucketUnit: String(report.bucket),\n transport: source.lastTransport(),\n ...(focus === undefined ? {} : { focus }),\n totals: {\n totalTokens: report.totals.totalTokens,\n inputTokens: report.totals.inputTokens,\n outputTokens: report.totals.outputTokens,\n cachedTokens: report.totals.cachedInputTokens,\n reasoningTokens: report.totals.reasoningOutputTokens,\n groupCount: report.totals.groupCount,\n failedGroups: report.totals.failedGroups,\n issueCount: report.totals.issueCount,\n },\n timeline: report.timeline.map(toBucket),\n groups: report.groups.map(toGroup),\n tools: report.tools.rows.map(toTool),\n };\n}\n\n/**\n * A report the sink answered but with nothing in it reads as \"no data\", not as\n * an empty chart. The distinction the reader needs is whether telemetry is not\n * being recorded or simply has not been recorded yet.\n */\nfunction isEmpty(report: MetricsReport): boolean {\n return report.totals.totalTokens === 0 && report.groups.length === 0 && report.tools.length === 0;\n}\n\nexport interface HubApiOptions {\n /** Injected by tests; production resolves the machine's sink. */\n source?: MetricsSource;\n issues?: IssuesSource;\n}\n\nexport function createLogHubApi(options: HubApiOptions = {}): Hono {\n const app = new Hono();\n // One source for the life of the hub: it caches the resolved sink endpoint,\n // and rebuilding it per request would re-probe the daemon every time.\n const source = options.source ?? createMetricsSource();\n const issues = options.issues ?? createIssuesSource();\n\n app.get(routes.metrics.path, async (context) => {\n const requestedDimension = context.req.query(METRICS_QUERY_PARAMS.dimension) ?? DEFAULT_DIMENSION;\n const requestedPeriod = context.req.query(METRICS_QUERY_PARAMS.period) ?? DEFAULT_PERIOD;\n if (!isMetricsDimension(requestedDimension)) {\n return context.json({ error: `Unknown dimension '${requestedDimension}'.` }, 400);\n }\n if (!isMetricsPeriod(requestedPeriod)) {\n return context.json({ error: `Unknown period '${requestedPeriod}'.` }, 400);\n }\n\n const focus = context.req.query(METRICS_QUERY_PARAMS.focus);\n const filter: MetricsFilter | undefined =\n focus === undefined || focus === '' ? undefined : { [FOCUS_FIELD[requestedDimension]]: focus };\n\n let report: LogMetricsReport;\n try {\n report = await source.query({\n groupBy: SINK_GROUP_BY[requestedDimension],\n period: requestedPeriod,\n limit: METRICS_GROUP_LIMIT,\n toolLimit: METRICS_TOOL_LIMIT,\n ...(filter === undefined ? {} : { filter }),\n });\n } catch (error) {\n // Both transports declined. That is the ordinary state on a machine\n // where the sink has never run, so it is reported as absence rather\n // than raised as a fault the page has to render as a crash.\n const unavailable: MetricsUnavailable = {\n unavailable: 'no-sink',\n detail: error instanceof Error ? error.message : 'The log sink did not answer.',\n };\n return context.json(unavailable);\n }\n\n const body = toReport(report, source, requestedDimension);\n if (isEmpty(body)) {\n const unavailable: MetricsUnavailable = {\n unavailable: 'no-data',\n detail: 'The log sink is running but has recorded no usage for this period.',\n };\n return context.json(unavailable);\n }\n return context.json(body);\n });\n\n /**\n * The detail behind the count the report carries. Separate from /metrics\n * because it costs a subprocess: the running daemon has no issues route, so\n * folding it into every report would make the whole page as slow as its\n * slowest transport.\n */\n app.get(routes.issues.path, async (context) => {\n const focus = context.req.query(METRICS_QUERY_PARAMS.focus);\n try {\n const report = await issues.query({\n limit: ISSUE_SAMPLE_LIMIT,\n ...(focus === undefined || focus === '' ? {} : { sessionId: focus }),\n });\n return context.json(report satisfies IssuesView);\n } catch (error) {\n const unavailable: MetricsUnavailable = {\n unavailable: 'no-sink',\n detail: error instanceof Error ? error.message : 'The log sink did not answer.',\n };\n return context.json(unavailable);\n }\n });\n\n return app;\n}\n\n/** The named export a host imports from this package's built hub entry. */\nexport const api: DoomApi = {\n basePath: LOG_API_BASE_PATH,\n start(_context: DoomApiContext): DoomApiHandler {\n const app = createLogHubApi();\n return {\n fetch: (request) => app.fetch(request),\n // The source holds no handle of its own: the HTTP transport is a fetch\n // per query and the CLI transport is a subprocess bounded by its timeout.\n close: () => undefined,\n };\n },\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.cjs","names":["Hono","createMetricsSource","createIssuesSource","routes","METRICS_QUERY_PARAMS","isMetricsDimension","isMetricsPeriod"],"sources":["../../../../src/services/hubApi/index.ts"],"sourcesContent":["import type { DoomApi, DoomApiContext, DoomApiHandler } from '@agimon-ai/doompi-core/package-api';\nimport type { LogMetricGroupRow, LogMetricsReport, ToolMetricRow } from '@agimon-ai/log-sink-mcp';\nimport { Hono } from 'hono';\n\nimport { PACKAGE_NAME, SERVICE_NAME } from '../../constants/telemetry';\nimport { createIssuesSource } from '../../services/issuesSource';\nimport { createMetricsSource } from '../../services/metricsSource';\nimport routes from '../../types/apiRoutes';\nimport type { IssuesSource } from '../../types/issuesSource';\nimport type { MetricsFilter, MetricsSource } from '../../types/metricsSource';\nimport {\n isMetricsDimension,\n isMetricsPeriod,\n LOG_API_BASE_PATH,\n ISSUE_SAMPLE_LIMIT,\n METRICS_GROUP_LIMIT,\n METRICS_QUERY_PARAMS,\n METRICS_TOOL_LIMIT,\n type MetricsBucket,\n type MetricsDimension,\n type MetricsGroup,\n type MetricsPeriod,\n type MetricsReport,\n type MetricsTool,\n type MetricsUnavailable,\n type IssuesView,\n} from '../../types/webMetrics';\n\n/**\n * The cockpit's half of this package's metrics access.\n *\n * Hub-scoped rather than session-scoped: the questions this answers, where the\n * tokens went and which agents failed, are about the machine over time, and a\n * session server can only see itself. The sink is already machine-wide, so the\n * hub is the only place the two agree.\n *\n * The query path is not reimplemented here. `createMetricsSource` already owns\n * the sink's two transports and its fallback order, and the TUI overlay reads\n * through the same source, so both surfaces answer from one implementation.\n */\n\nconst DEFAULT_DIMENSION: MetricsDimension = 'model';\nconst DEFAULT_PERIOD: MetricsPeriod = 'week';\n\n/** The sink groups sessions under a different name than the page's dimension. */\nconst SINK_GROUP_BY = {\n session: 'session',\n agent: 'agent',\n model: 'model',\n provider: 'provider',\n} as const;\n\nfunction toBucket(bucket: LogMetricsReport['timeline'][number]): MetricsBucket {\n return {\n label: bucket.label,\n totalTokens: bucket.totalTokens,\n inputTokens: bucket.inputTokens,\n outputTokens: bucket.outputTokens,\n };\n}\n\nfunction toGroup(row: LogMetricGroupRow): MetricsGroup {\n return {\n key: row.key,\n totalTokens: row.totalTokens,\n inputTokens: row.inputTokens,\n outputTokens: row.outputTokens,\n issueCount: row.issueCount,\n failed: row.failed,\n };\n}\n\n/**\n * The turn's p90 rather than a sum: the sink attributes a whole turn's tokens\n * to every tool that ran in it, so adding them across calls would multiply the\n * same tokens by however many tools shared the turn.\n */\nfunction toTool(row: ToolMetricRow): MetricsTool {\n return {\n name: row.toolName,\n calls: row.invocationCount,\n p90TotalTokens: row.toolCallTurn.p90TotalTokens,\n };\n}\n\n/** Which filter field carries a focus value, per dimension. */\nconst FOCUS_FIELD: Record<MetricsDimension, keyof MetricsFilter> = {\n session: 'sessionId',\n agent: 'agentName',\n model: 'model',\n provider: 'provider',\n};\n\n/**\n * The focus the sink actually applied, read from its own echo.\n *\n * A daemon that predates a filter ignores it and answers with everything. If\n * the page trusted the request instead of this echo, it would label the whole\n * machine's numbers as one model's.\n */\nfunction appliedFocus(report: LogMetricsReport, dimension: MetricsDimension): string | undefined {\n // The daemon is a separate process on its own release cadence, so its\n // response is parsed defensively rather than trusted to carry every field.\n const filters = report.filters as LogMetricsReport['filters'] | undefined;\n if (filters === undefined) return undefined;\n if (dimension === 'session') return filters.sessionId;\n if (dimension === 'agent') return filters.agentName;\n const applied = dimension === 'model' ? filters.model : filters.provider;\n if (applied === undefined) return undefined;\n return Array.isArray(applied) ? applied[0] : applied;\n}\n\n/**\n * The sink's own type says Date, but both transports parse JSON, so what\n * arrives is an ISO string. Trusting the declared type here throws on every\n * real response, which no test using a hand-built Date would ever catch.\n */\nfunction generatedAtIso(value: LogMetricsReport['generatedAt']): string {\n if (value instanceof Date) return value.toISOString();\n const parsed = new Date(value as unknown as string);\n return Number.isNaN(parsed.getTime()) ? new Date(0).toISOString() : parsed.toISOString();\n}\n\nfunction toReport(report: LogMetricsReport, source: MetricsSource, dimension: MetricsDimension): MetricsReport {\n const focus = appliedFocus(report, dimension);\n return {\n generatedAt: generatedAtIso(report.generatedAt),\n dimension: report.groupBy as MetricsDimension,\n period: report.period as MetricsPeriod,\n bucketUnit: String(report.bucket),\n transport: source.lastTransport(),\n ...(focus === undefined ? {} : { focus }),\n totals: {\n totalTokens: report.totals.totalTokens,\n inputTokens: report.totals.inputTokens,\n outputTokens: report.totals.outputTokens,\n cachedTokens: report.totals.cachedInputTokens,\n reasoningTokens: report.totals.reasoningOutputTokens,\n groupCount: report.totals.groupCount,\n failedGroups: report.totals.failedGroups,\n issueCount: report.totals.issueCount,\n },\n timeline: report.timeline.map(toBucket),\n groups: report.groups.map(toGroup),\n tools: report.tools.rows.map(toTool),\n };\n}\n\n/**\n * A report with nothing in it reads as \"no data\", not as an empty chart. The\n * reader may have used the HTTP sink or a worker against an empty history, so\n * the empty-state detail must not claim the sink is running.\n */\nfunction isEmpty(report: MetricsReport): boolean {\n return report.totals.totalTokens === 0 && report.groups.length === 0 && report.tools.length === 0;\n}\n\nexport interface HubApiOptions {\n /** Injected by tests; production resolves the machine's sink. */\n source?: MetricsSource;\n issues?: IssuesSource;\n}\n\nexport function createLogHubApi(options: HubApiOptions = {}): Hono {\n const app = new Hono();\n // One source for the life of the hub: it caches the resolved sink endpoint,\n // and rebuilding it per request would re-probe the daemon every time.\n const source = options.source ?? createMetricsSource({ packageName: PACKAGE_NAME, serviceName: SERVICE_NAME });\n const issues = options.issues ?? createIssuesSource({ packageName: PACKAGE_NAME, serviceName: SERVICE_NAME });\n\n app.get(routes.metrics.path, async (context) => {\n const requestedDimension = context.req.query(METRICS_QUERY_PARAMS.dimension) ?? DEFAULT_DIMENSION;\n const requestedPeriod = context.req.query(METRICS_QUERY_PARAMS.period) ?? DEFAULT_PERIOD;\n if (!isMetricsDimension(requestedDimension)) {\n return context.json({ error: `Unknown dimension '${requestedDimension}'.` }, 400);\n }\n if (!isMetricsPeriod(requestedPeriod)) {\n return context.json({ error: `Unknown period '${requestedPeriod}'.` }, 400);\n }\n\n const focus = context.req.query(METRICS_QUERY_PARAMS.focus);\n const filter: MetricsFilter | undefined =\n focus === undefined || focus === '' ? undefined : { [FOCUS_FIELD[requestedDimension]]: focus };\n\n let report: LogMetricsReport;\n try {\n report = await source.query({\n groupBy: SINK_GROUP_BY[requestedDimension],\n period: requestedPeriod,\n limit: METRICS_GROUP_LIMIT,\n toolLimit: METRICS_TOOL_LIMIT,\n ...(filter === undefined ? {} : { filter }),\n });\n } catch (error) {\n // Both transports declined. That is the ordinary state on a machine\n // where the sink has never run, so it is reported as absence rather\n // than raised as a fault the page has to render as a crash.\n const unavailable: MetricsUnavailable = {\n unavailable: 'no-sink',\n detail: error instanceof Error ? error.message : 'The log sink did not answer.',\n };\n return context.json(unavailable);\n }\n\n const body = toReport(report, source, requestedDimension);\n if (isEmpty(body)) {\n const unavailable: MetricsUnavailable = {\n unavailable: 'no-data',\n detail: 'No recorded usage was found for this period.',\n };\n return context.json(unavailable);\n }\n return context.json(body);\n });\n\n /**\n * The detail behind the count the report carries. Separate from /metrics\n * because it costs a subprocess: the running daemon has no issues route, so\n * folding it into every report would make the whole page as slow as its\n * slowest transport.\n */\n app.get(routes.issues.path, async (context) => {\n const focus = context.req.query(METRICS_QUERY_PARAMS.focus);\n try {\n const report = await issues.query({\n limit: ISSUE_SAMPLE_LIMIT,\n ...(focus === undefined || focus === '' ? {} : { sessionId: focus }),\n });\n return context.json(report satisfies IssuesView);\n } catch (error) {\n const unavailable: MetricsUnavailable = {\n unavailable: 'no-sink',\n detail: error instanceof Error ? error.message : 'The log sink did not answer.',\n };\n return context.json(unavailable);\n }\n });\n\n return app;\n}\n\n/** The named export a host imports from this package's built hub entry. */\nexport const api: DoomApi = {\n basePath: LOG_API_BASE_PATH,\n start(_context: DoomApiContext): DoomApiHandler {\n const app = createLogHubApi();\n return {\n fetch: (request) => app.fetch(request),\n // The source holds no handle of its own: the HTTP transport is a fetch\n // per query and the CLI transport is a subprocess bounded by its timeout.\n close: () => undefined,\n };\n },\n};\n"],"mappings":"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"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import "../../constants/telemetry.mjs";
|
|
1
2
|
import { createMetricsSource } from "../metricsSource/index.mjs";
|
|
2
3
|
import { createIssuesSource } from "../issuesSource/index.mjs";
|
|
3
4
|
import { METRICS_QUERY_PARAMS, isMetricsDimension, isMetricsPeriod } from "../../types/webMetrics.mjs";
|
|
@@ -113,17 +114,23 @@ function toReport(report, source, dimension) {
|
|
|
113
114
|
};
|
|
114
115
|
}
|
|
115
116
|
/**
|
|
116
|
-
* A report
|
|
117
|
-
*
|
|
118
|
-
*
|
|
117
|
+
* A report with nothing in it reads as "no data", not as an empty chart. The
|
|
118
|
+
* reader may have used the HTTP sink or a worker against an empty history, so
|
|
119
|
+
* the empty-state detail must not claim the sink is running.
|
|
119
120
|
*/
|
|
120
121
|
function isEmpty(report) {
|
|
121
122
|
return report.totals.totalTokens === 0 && report.groups.length === 0 && report.tools.length === 0;
|
|
122
123
|
}
|
|
123
124
|
function createLogHubApi(options = {}) {
|
|
124
125
|
const app = new Hono();
|
|
125
|
-
const source = options.source ?? createMetricsSource(
|
|
126
|
-
|
|
126
|
+
const source = options.source ?? createMetricsSource({
|
|
127
|
+
packageName: "@agimon-ai/doompi-log",
|
|
128
|
+
serviceName: "pi"
|
|
129
|
+
});
|
|
130
|
+
const issues = options.issues ?? createIssuesSource({
|
|
131
|
+
packageName: "@agimon-ai/doompi-log",
|
|
132
|
+
serviceName: "pi"
|
|
133
|
+
});
|
|
127
134
|
app.get(apiRoutes_default.metrics.path, async (context) => {
|
|
128
135
|
const requestedDimension = context.req.query(METRICS_QUERY_PARAMS.dimension) ?? DEFAULT_DIMENSION;
|
|
129
136
|
const requestedPeriod = context.req.query(METRICS_QUERY_PARAMS.period) ?? DEFAULT_PERIOD;
|
|
@@ -150,7 +157,7 @@ function createLogHubApi(options = {}) {
|
|
|
150
157
|
const body = toReport(report, source, requestedDimension);
|
|
151
158
|
if (isEmpty(body)) return context.json({
|
|
152
159
|
unavailable: "no-data",
|
|
153
|
-
detail: "
|
|
160
|
+
detail: "No recorded usage was found for this period."
|
|
154
161
|
});
|
|
155
162
|
return context.json(body);
|
|
156
163
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":["routes"],"sources":["../../../../src/services/hubApi/index.ts"],"sourcesContent":["import type { DoomApi, DoomApiContext, DoomApiHandler } from '@agimon-ai/doompi-core/package-api';\nimport type { LogMetricGroupRow, LogMetricsReport, ToolMetricRow } from '@agimon-ai/log-sink-mcp';\nimport { Hono } from 'hono';\n\nimport { createIssuesSource } from '../../services/issuesSource';\nimport { createMetricsSource } from '../../services/metricsSource';\nimport routes from '../../types/apiRoutes';\nimport type { IssuesSource } from '../../types/issuesSource';\nimport type { MetricsFilter, MetricsSource } from '../../types/metricsSource';\nimport {\n isMetricsDimension,\n isMetricsPeriod,\n LOG_API_BASE_PATH,\n ISSUE_SAMPLE_LIMIT,\n METRICS_GROUP_LIMIT,\n METRICS_QUERY_PARAMS,\n METRICS_TOOL_LIMIT,\n type MetricsBucket,\n type MetricsDimension,\n type MetricsGroup,\n type MetricsPeriod,\n type MetricsReport,\n type MetricsTool,\n type MetricsUnavailable,\n type IssuesView,\n} from '../../types/webMetrics';\n\n/**\n * The cockpit's half of this package's metrics access.\n *\n * Hub-scoped rather than session-scoped: the questions this answers, where the\n * tokens went and which agents failed, are about the machine over time, and a\n * session server can only see itself. The sink is already machine-wide, so the\n * hub is the only place the two agree.\n *\n * The query path is not reimplemented here. `createMetricsSource` already owns\n * the sink's two transports and its fallback order, and the TUI overlay reads\n * through the same source, so both surfaces answer from one implementation.\n */\n\nconst DEFAULT_DIMENSION: MetricsDimension = 'model';\nconst DEFAULT_PERIOD: MetricsPeriod = 'week';\n\n/** The sink groups sessions under a different name than the page's dimension. */\nconst SINK_GROUP_BY = {\n session: 'session',\n agent: 'agent',\n model: 'model',\n provider: 'provider',\n} as const;\n\nfunction toBucket(bucket: LogMetricsReport['timeline'][number]): MetricsBucket {\n return {\n label: bucket.label,\n totalTokens: bucket.totalTokens,\n inputTokens: bucket.inputTokens,\n outputTokens: bucket.outputTokens,\n };\n}\n\nfunction toGroup(row: LogMetricGroupRow): MetricsGroup {\n return {\n key: row.key,\n totalTokens: row.totalTokens,\n inputTokens: row.inputTokens,\n outputTokens: row.outputTokens,\n issueCount: row.issueCount,\n failed: row.failed,\n };\n}\n\n/**\n * The turn's p90 rather than a sum: the sink attributes a whole turn's tokens\n * to every tool that ran in it, so adding them across calls would multiply the\n * same tokens by however many tools shared the turn.\n */\nfunction toTool(row: ToolMetricRow): MetricsTool {\n return {\n name: row.toolName,\n calls: row.invocationCount,\n p90TotalTokens: row.toolCallTurn.p90TotalTokens,\n };\n}\n\n/** Which filter field carries a focus value, per dimension. */\nconst FOCUS_FIELD: Record<MetricsDimension, keyof MetricsFilter> = {\n session: 'sessionId',\n agent: 'agentName',\n model: 'model',\n provider: 'provider',\n};\n\n/**\n * The focus the sink actually applied, read from its own echo.\n *\n * A daemon that predates a filter ignores it and answers with everything. If\n * the page trusted the request instead of this echo, it would label the whole\n * machine's numbers as one model's.\n */\nfunction appliedFocus(report: LogMetricsReport, dimension: MetricsDimension): string | undefined {\n // The daemon is a separate process on its own release cadence, so its\n // response is parsed defensively rather than trusted to carry every field.\n const filters = report.filters as LogMetricsReport['filters'] | undefined;\n if (filters === undefined) return undefined;\n if (dimension === 'session') return filters.sessionId;\n if (dimension === 'agent') return filters.agentName;\n const applied = dimension === 'model' ? filters.model : filters.provider;\n if (applied === undefined) return undefined;\n return Array.isArray(applied) ? applied[0] : applied;\n}\n\n/**\n * The sink's own type says Date, but both transports parse JSON, so what\n * arrives is an ISO string. Trusting the declared type here throws on every\n * real response, which no test using a hand-built Date would ever catch.\n */\nfunction generatedAtIso(value: LogMetricsReport['generatedAt']): string {\n if (value instanceof Date) return value.toISOString();\n const parsed = new Date(value as unknown as string);\n return Number.isNaN(parsed.getTime()) ? new Date(0).toISOString() : parsed.toISOString();\n}\n\nfunction toReport(report: LogMetricsReport, source: MetricsSource, dimension: MetricsDimension): MetricsReport {\n const focus = appliedFocus(report, dimension);\n return {\n generatedAt: generatedAtIso(report.generatedAt),\n dimension: report.groupBy as MetricsDimension,\n period: report.period as MetricsPeriod,\n bucketUnit: String(report.bucket),\n transport: source.lastTransport(),\n ...(focus === undefined ? {} : { focus }),\n totals: {\n totalTokens: report.totals.totalTokens,\n inputTokens: report.totals.inputTokens,\n outputTokens: report.totals.outputTokens,\n cachedTokens: report.totals.cachedInputTokens,\n reasoningTokens: report.totals.reasoningOutputTokens,\n groupCount: report.totals.groupCount,\n failedGroups: report.totals.failedGroups,\n issueCount: report.totals.issueCount,\n },\n timeline: report.timeline.map(toBucket),\n groups: report.groups.map(toGroup),\n tools: report.tools.rows.map(toTool),\n };\n}\n\n/**\n * A report the sink answered but with nothing in it reads as \"no data\", not as\n * an empty chart. The distinction the reader needs is whether telemetry is not\n * being recorded or simply has not been recorded yet.\n */\nfunction isEmpty(report: MetricsReport): boolean {\n return report.totals.totalTokens === 0 && report.groups.length === 0 && report.tools.length === 0;\n}\n\nexport interface HubApiOptions {\n /** Injected by tests; production resolves the machine's sink. */\n source?: MetricsSource;\n issues?: IssuesSource;\n}\n\nexport function createLogHubApi(options: HubApiOptions = {}): Hono {\n const app = new Hono();\n // One source for the life of the hub: it caches the resolved sink endpoint,\n // and rebuilding it per request would re-probe the daemon every time.\n const source = options.source ?? createMetricsSource();\n const issues = options.issues ?? createIssuesSource();\n\n app.get(routes.metrics.path, async (context) => {\n const requestedDimension = context.req.query(METRICS_QUERY_PARAMS.dimension) ?? DEFAULT_DIMENSION;\n const requestedPeriod = context.req.query(METRICS_QUERY_PARAMS.period) ?? DEFAULT_PERIOD;\n if (!isMetricsDimension(requestedDimension)) {\n return context.json({ error: `Unknown dimension '${requestedDimension}'.` }, 400);\n }\n if (!isMetricsPeriod(requestedPeriod)) {\n return context.json({ error: `Unknown period '${requestedPeriod}'.` }, 400);\n }\n\n const focus = context.req.query(METRICS_QUERY_PARAMS.focus);\n const filter: MetricsFilter | undefined =\n focus === undefined || focus === '' ? undefined : { [FOCUS_FIELD[requestedDimension]]: focus };\n\n let report: LogMetricsReport;\n try {\n report = await source.query({\n groupBy: SINK_GROUP_BY[requestedDimension],\n period: requestedPeriod,\n limit: METRICS_GROUP_LIMIT,\n toolLimit: METRICS_TOOL_LIMIT,\n ...(filter === undefined ? {} : { filter }),\n });\n } catch (error) {\n // Both transports declined. That is the ordinary state on a machine\n // where the sink has never run, so it is reported as absence rather\n // than raised as a fault the page has to render as a crash.\n const unavailable: MetricsUnavailable = {\n unavailable: 'no-sink',\n detail: error instanceof Error ? error.message : 'The log sink did not answer.',\n };\n return context.json(unavailable);\n }\n\n const body = toReport(report, source, requestedDimension);\n if (isEmpty(body)) {\n const unavailable: MetricsUnavailable = {\n unavailable: 'no-data',\n detail: 'The log sink is running but has recorded no usage for this period.',\n };\n return context.json(unavailable);\n }\n return context.json(body);\n });\n\n /**\n * The detail behind the count the report carries. Separate from /metrics\n * because it costs a subprocess: the running daemon has no issues route, so\n * folding it into every report would make the whole page as slow as its\n * slowest transport.\n */\n app.get(routes.issues.path, async (context) => {\n const focus = context.req.query(METRICS_QUERY_PARAMS.focus);\n try {\n const report = await issues.query({\n limit: ISSUE_SAMPLE_LIMIT,\n ...(focus === undefined || focus === '' ? {} : { sessionId: focus }),\n });\n return context.json(report satisfies IssuesView);\n } catch (error) {\n const unavailable: MetricsUnavailable = {\n unavailable: 'no-sink',\n detail: error instanceof Error ? error.message : 'The log sink did not answer.',\n };\n return context.json(unavailable);\n }\n });\n\n return app;\n}\n\n/** The named export a host imports from this package's built hub entry. */\nexport const api: DoomApi = {\n basePath: LOG_API_BASE_PATH,\n start(_context: DoomApiContext): DoomApiHandler {\n const app = createLogHubApi();\n return {\n fetch: (request) => app.fetch(request),\n // The source holds no handle of its own: the HTTP transport is a fetch\n // per query and the CLI transport is a subprocess bounded by its timeout.\n close: () => undefined,\n };\n },\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":["routes"],"sources":["../../../../src/services/hubApi/index.ts"],"sourcesContent":["import type { DoomApi, DoomApiContext, DoomApiHandler } from '@agimon-ai/doompi-core/package-api';\nimport type { LogMetricGroupRow, LogMetricsReport, ToolMetricRow } from '@agimon-ai/log-sink-mcp';\nimport { Hono } from 'hono';\n\nimport { PACKAGE_NAME, SERVICE_NAME } from '../../constants/telemetry';\nimport { createIssuesSource } from '../../services/issuesSource';\nimport { createMetricsSource } from '../../services/metricsSource';\nimport routes from '../../types/apiRoutes';\nimport type { IssuesSource } from '../../types/issuesSource';\nimport type { MetricsFilter, MetricsSource } from '../../types/metricsSource';\nimport {\n isMetricsDimension,\n isMetricsPeriod,\n LOG_API_BASE_PATH,\n ISSUE_SAMPLE_LIMIT,\n METRICS_GROUP_LIMIT,\n METRICS_QUERY_PARAMS,\n METRICS_TOOL_LIMIT,\n type MetricsBucket,\n type MetricsDimension,\n type MetricsGroup,\n type MetricsPeriod,\n type MetricsReport,\n type MetricsTool,\n type MetricsUnavailable,\n type IssuesView,\n} from '../../types/webMetrics';\n\n/**\n * The cockpit's half of this package's metrics access.\n *\n * Hub-scoped rather than session-scoped: the questions this answers, where the\n * tokens went and which agents failed, are about the machine over time, and a\n * session server can only see itself. The sink is already machine-wide, so the\n * hub is the only place the two agree.\n *\n * The query path is not reimplemented here. `createMetricsSource` already owns\n * the sink's two transports and its fallback order, and the TUI overlay reads\n * through the same source, so both surfaces answer from one implementation.\n */\n\nconst DEFAULT_DIMENSION: MetricsDimension = 'model';\nconst DEFAULT_PERIOD: MetricsPeriod = 'week';\n\n/** The sink groups sessions under a different name than the page's dimension. */\nconst SINK_GROUP_BY = {\n session: 'session',\n agent: 'agent',\n model: 'model',\n provider: 'provider',\n} as const;\n\nfunction toBucket(bucket: LogMetricsReport['timeline'][number]): MetricsBucket {\n return {\n label: bucket.label,\n totalTokens: bucket.totalTokens,\n inputTokens: bucket.inputTokens,\n outputTokens: bucket.outputTokens,\n };\n}\n\nfunction toGroup(row: LogMetricGroupRow): MetricsGroup {\n return {\n key: row.key,\n totalTokens: row.totalTokens,\n inputTokens: row.inputTokens,\n outputTokens: row.outputTokens,\n issueCount: row.issueCount,\n failed: row.failed,\n };\n}\n\n/**\n * The turn's p90 rather than a sum: the sink attributes a whole turn's tokens\n * to every tool that ran in it, so adding them across calls would multiply the\n * same tokens by however many tools shared the turn.\n */\nfunction toTool(row: ToolMetricRow): MetricsTool {\n return {\n name: row.toolName,\n calls: row.invocationCount,\n p90TotalTokens: row.toolCallTurn.p90TotalTokens,\n };\n}\n\n/** Which filter field carries a focus value, per dimension. */\nconst FOCUS_FIELD: Record<MetricsDimension, keyof MetricsFilter> = {\n session: 'sessionId',\n agent: 'agentName',\n model: 'model',\n provider: 'provider',\n};\n\n/**\n * The focus the sink actually applied, read from its own echo.\n *\n * A daemon that predates a filter ignores it and answers with everything. If\n * the page trusted the request instead of this echo, it would label the whole\n * machine's numbers as one model's.\n */\nfunction appliedFocus(report: LogMetricsReport, dimension: MetricsDimension): string | undefined {\n // The daemon is a separate process on its own release cadence, so its\n // response is parsed defensively rather than trusted to carry every field.\n const filters = report.filters as LogMetricsReport['filters'] | undefined;\n if (filters === undefined) return undefined;\n if (dimension === 'session') return filters.sessionId;\n if (dimension === 'agent') return filters.agentName;\n const applied = dimension === 'model' ? filters.model : filters.provider;\n if (applied === undefined) return undefined;\n return Array.isArray(applied) ? applied[0] : applied;\n}\n\n/**\n * The sink's own type says Date, but both transports parse JSON, so what\n * arrives is an ISO string. Trusting the declared type here throws on every\n * real response, which no test using a hand-built Date would ever catch.\n */\nfunction generatedAtIso(value: LogMetricsReport['generatedAt']): string {\n if (value instanceof Date) return value.toISOString();\n const parsed = new Date(value as unknown as string);\n return Number.isNaN(parsed.getTime()) ? new Date(0).toISOString() : parsed.toISOString();\n}\n\nfunction toReport(report: LogMetricsReport, source: MetricsSource, dimension: MetricsDimension): MetricsReport {\n const focus = appliedFocus(report, dimension);\n return {\n generatedAt: generatedAtIso(report.generatedAt),\n dimension: report.groupBy as MetricsDimension,\n period: report.period as MetricsPeriod,\n bucketUnit: String(report.bucket),\n transport: source.lastTransport(),\n ...(focus === undefined ? {} : { focus }),\n totals: {\n totalTokens: report.totals.totalTokens,\n inputTokens: report.totals.inputTokens,\n outputTokens: report.totals.outputTokens,\n cachedTokens: report.totals.cachedInputTokens,\n reasoningTokens: report.totals.reasoningOutputTokens,\n groupCount: report.totals.groupCount,\n failedGroups: report.totals.failedGroups,\n issueCount: report.totals.issueCount,\n },\n timeline: report.timeline.map(toBucket),\n groups: report.groups.map(toGroup),\n tools: report.tools.rows.map(toTool),\n };\n}\n\n/**\n * A report with nothing in it reads as \"no data\", not as an empty chart. The\n * reader may have used the HTTP sink or a worker against an empty history, so\n * the empty-state detail must not claim the sink is running.\n */\nfunction isEmpty(report: MetricsReport): boolean {\n return report.totals.totalTokens === 0 && report.groups.length === 0 && report.tools.length === 0;\n}\n\nexport interface HubApiOptions {\n /** Injected by tests; production resolves the machine's sink. */\n source?: MetricsSource;\n issues?: IssuesSource;\n}\n\nexport function createLogHubApi(options: HubApiOptions = {}): Hono {\n const app = new Hono();\n // One source for the life of the hub: it caches the resolved sink endpoint,\n // and rebuilding it per request would re-probe the daemon every time.\n const source = options.source ?? createMetricsSource({ packageName: PACKAGE_NAME, serviceName: SERVICE_NAME });\n const issues = options.issues ?? createIssuesSource({ packageName: PACKAGE_NAME, serviceName: SERVICE_NAME });\n\n app.get(routes.metrics.path, async (context) => {\n const requestedDimension = context.req.query(METRICS_QUERY_PARAMS.dimension) ?? DEFAULT_DIMENSION;\n const requestedPeriod = context.req.query(METRICS_QUERY_PARAMS.period) ?? DEFAULT_PERIOD;\n if (!isMetricsDimension(requestedDimension)) {\n return context.json({ error: `Unknown dimension '${requestedDimension}'.` }, 400);\n }\n if (!isMetricsPeriod(requestedPeriod)) {\n return context.json({ error: `Unknown period '${requestedPeriod}'.` }, 400);\n }\n\n const focus = context.req.query(METRICS_QUERY_PARAMS.focus);\n const filter: MetricsFilter | undefined =\n focus === undefined || focus === '' ? undefined : { [FOCUS_FIELD[requestedDimension]]: focus };\n\n let report: LogMetricsReport;\n try {\n report = await source.query({\n groupBy: SINK_GROUP_BY[requestedDimension],\n period: requestedPeriod,\n limit: METRICS_GROUP_LIMIT,\n toolLimit: METRICS_TOOL_LIMIT,\n ...(filter === undefined ? {} : { filter }),\n });\n } catch (error) {\n // Both transports declined. That is the ordinary state on a machine\n // where the sink has never run, so it is reported as absence rather\n // than raised as a fault the page has to render as a crash.\n const unavailable: MetricsUnavailable = {\n unavailable: 'no-sink',\n detail: error instanceof Error ? error.message : 'The log sink did not answer.',\n };\n return context.json(unavailable);\n }\n\n const body = toReport(report, source, requestedDimension);\n if (isEmpty(body)) {\n const unavailable: MetricsUnavailable = {\n unavailable: 'no-data',\n detail: 'No recorded usage was found for this period.',\n };\n return context.json(unavailable);\n }\n return context.json(body);\n });\n\n /**\n * The detail behind the count the report carries. Separate from /metrics\n * because it costs a subprocess: the running daemon has no issues route, so\n * folding it into every report would make the whole page as slow as its\n * slowest transport.\n */\n app.get(routes.issues.path, async (context) => {\n const focus = context.req.query(METRICS_QUERY_PARAMS.focus);\n try {\n const report = await issues.query({\n limit: ISSUE_SAMPLE_LIMIT,\n ...(focus === undefined || focus === '' ? {} : { sessionId: focus }),\n });\n return context.json(report satisfies IssuesView);\n } catch (error) {\n const unavailable: MetricsUnavailable = {\n unavailable: 'no-sink',\n detail: error instanceof Error ? error.message : 'The log sink did not answer.',\n };\n return context.json(unavailable);\n }\n });\n\n return app;\n}\n\n/** The named export a host imports from this package's built hub entry. */\nexport const api: DoomApi = {\n basePath: LOG_API_BASE_PATH,\n start(_context: DoomApiContext): DoomApiHandler {\n const app = createLogHubApi();\n return {\n fetch: (request) => app.fetch(request),\n // The source holds no handle of its own: the HTTP transport is a fetch\n // per query and the CLI transport is a subprocess bounded by its timeout.\n close: () => undefined,\n };\n },\n};\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agimon-ai/doompi-log",
|
|
3
|
-
"version": "0.0.1-alpha.
|
|
3
|
+
"version": "0.0.1-alpha.76",
|
|
4
4
|
"description": "Pi session metrics, findings, sink status, and a Log Metrics overlay for agent observability.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent-observability",
|
|
@@ -80,18 +80,18 @@
|
|
|
80
80
|
"registry": "https://registry.npmjs.org/"
|
|
81
81
|
},
|
|
82
82
|
"dependencies": {
|
|
83
|
-
"@agimon-ai/doompi-core": "0.0.1-alpha.
|
|
84
|
-
"@agimon-ai/doompi-telemetry": "0.0.1-alpha.
|
|
85
|
-
"@agimon-ai/doompi-web-components": "0.0.1-alpha.
|
|
86
|
-
"@agimon-ai/doompi-web-security": "0.0.1-alpha.
|
|
83
|
+
"@agimon-ai/doompi-core": "0.0.1-alpha.76",
|
|
84
|
+
"@agimon-ai/doompi-telemetry": "0.0.1-alpha.73",
|
|
85
|
+
"@agimon-ai/doompi-web-components": "0.0.1-alpha.34",
|
|
86
|
+
"@agimon-ai/doompi-web-security": "0.0.1-alpha.36",
|
|
87
87
|
"@agimon-ai/log-sink-mcp": "0.29.26",
|
|
88
88
|
"@deepseek-ai/cordis": "4.0.2",
|
|
89
89
|
"hono": "4.13.7",
|
|
90
90
|
"typebox": "1.3.30"
|
|
91
91
|
},
|
|
92
92
|
"devDependencies": {
|
|
93
|
-
"@agimon-ai/doompi-build": "0.0.1-alpha.
|
|
94
|
-
"@agimon-ai/doompi-ui": "0.0.1-alpha.
|
|
93
|
+
"@agimon-ai/doompi-build": "0.0.1-alpha.5",
|
|
94
|
+
"@agimon-ai/doompi-ui": "0.0.1-alpha.76",
|
|
95
95
|
"@earendil-works/pi-coding-agent": "0.85.1",
|
|
96
96
|
"@earendil-works/pi-tui": "0.85.1",
|
|
97
97
|
"@tanstack/react-store": "0.11.1",
|