@crvouga/atlas 0.10.2 → 0.12.0
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/CHANGELOG.md +12 -0
- package/README.md +7 -0
- package/dist/reports/src/catalog-health.d.ts +142 -0
- package/dist/reports/src/catalog-health.js +257 -0
- package/dist/reports/src/test-catalog.d.ts +8 -0
- package/dist/reports/src/test-catalog.js +5 -1
- package/dist/schema/src/api.d.ts +79 -0
- package/dist/schema/src/api.js +50 -0
- package/dist/server/src/app.js +9 -0
- package/dist/server/src/llms.js +1 -0
- package/dist/server/src/openapi.js +18 -2
- package/dist/server/src/settings-page.d.ts +4 -4
- package/dist/server/src/settings-page.js +17 -78
- package/docs/reference/http-api.md +34 -0
- package/docs/reference/reports.md +224 -0
- package/docs/reference/schema.md +10 -0
- package/docs/reference/server.md +4 -4
- package/docs/server.md +10 -0
- package/docs/ui.md +142 -0
- package/docs/workspaces.md +130 -0
- package/llms-full.txt +322 -0
- package/llms.txt +2 -0
- package/package.json +17 -10
- package/packages/schema/schemas/catalog-health.schema.json +400 -0
- package/packages/server/openapi.json +596 -0
- package/packages/visualizer/src/admin/SettingsPage.tsx +456 -0
- package/packages/visualizer/src/admin/main.tsx +11 -0
- package/packages/visualizer/src/app/AppShell.tsx +62 -38
- package/packages/visualizer/src/app/gazetteer.tsx +90 -83
- package/packages/visualizer/src/app/router.tsx +33 -15
- package/packages/visualizer/src/components/ActivityIndicator.tsx +4 -5
- package/packages/visualizer/src/components/DataIssues.tsx +36 -54
- package/packages/visualizer/src/components/Header.tsx +197 -125
- package/packages/visualizer/src/components/Legend.tsx +13 -41
- package/packages/visualizer/src/components/ProjectPicker.tsx +61 -26
- package/packages/visualizer/src/components/Screen.tsx +80 -71
- package/packages/visualizer/src/components/SpecLogo.tsx +15 -0
- package/packages/visualizer/src/components/Status.tsx +20 -34
- package/packages/visualizer/src/components/labels.tsx +8 -3
- package/packages/visualizer/src/components/spec-logos/APACHE-2.0.txt +206 -0
- package/packages/visualizer/src/components/spec-logos/CAMUNDA-MIT.txt +21 -0
- package/packages/visualizer/src/components/spec-logos/FILE-ICONS-ISC.txt +13 -0
- package/packages/visualizer/src/components/spec-logos/README.md +23 -0
- package/packages/visualizer/src/components/spec-logos/SIMPLE-ICONS-CC0.txt +30 -0
- package/packages/visualizer/src/components/spec-logos/alloy.svg +1 -0
- package/packages/visualizer/src/components/spec-logos/dmn.svg +3 -0
- package/packages/visualizer/src/components/spec-logos/gherkin.svg +1 -0
- package/packages/visualizer/src/components/spec-logos/openslo.svg +63 -0
- package/packages/visualizer/src/components/spec-logos/quint.svg +1 -0
- package/packages/visualizer/src/evidence/artifacts.tsx +263 -110
- package/packages/visualizer/src/evidence/chart-source.tsx +18 -0
- package/packages/visualizer/src/evidence/check-detail.tsx +283 -139
- package/packages/visualizer/src/evidence/decision-table.tsx +35 -26
- package/packages/visualizer/src/evidence/document-page.tsx +51 -29
- package/packages/visualizer/src/evidence/feature-page.tsx +121 -53
- package/packages/visualizer/src/evidence/marks.tsx +112 -79
- package/packages/visualizer/src/evidence/model-page.tsx +77 -46
- package/packages/visualizer/src/evidence/model-trace.tsx +55 -35
- package/packages/visualizer/src/evidence/overview.tsx +137 -69
- package/packages/visualizer/src/evidence/recording-page.tsx +70 -31
- package/packages/visualizer/src/evidence/recording.tsx +135 -68
- package/packages/visualizer/src/evidence/snapshot.tsx +99 -43
- package/packages/visualizer/src/evidence/source-text.tsx +33 -0
- package/packages/visualizer/src/evidence/sources-panel.tsx +138 -144
- package/packages/visualizer/src/evidence/spec-header.tsx +52 -36
- package/packages/visualizer/src/evidence/spec-index.tsx +61 -32
- package/packages/visualizer/src/evidence/spec-page.tsx +56 -25
- package/packages/visualizer/src/evidence/test-filter.tsx +38 -14
- package/packages/visualizer/src/explorer/Explorer.tsx +688 -261
- package/packages/visualizer/src/explorer/ExplorerIndex.tsx +136 -68
- package/packages/visualizer/src/explorer/RunsView.tsx +307 -94
- package/packages/visualizer/src/explorer/TestDetail.tsx +393 -118
- package/packages/visualizer/src/explorer/parts.tsx +35 -13
- package/packages/visualizer/src/find/Finder.tsx +106 -90
- package/packages/visualizer/src/find/open.ts +17 -20
- package/packages/visualizer/src/live/test-progress.tsx +60 -22
- package/packages/visualizer/src/looks/Stage.tsx +223 -110
- package/packages/visualizer/src/main.tsx +9 -11
- package/packages/visualizer/src/map/ChartMap.tsx +125 -69
- package/packages/visualizer/src/map/EventEdge.tsx +59 -37
- package/packages/visualizer/src/map/map.module.css +12 -546
- package/packages/visualizer/src/map/nodes.tsx +124 -70
- package/packages/visualizer/src/reachable.test.ts +3 -2
- package/packages/visualizer/src/spec-kind.ts +2 -2
- package/packages/visualizer/src/ui/grammars/ALLOY-MIT.txt +21 -0
- package/packages/visualizer/src/ui/grammars/QUINT-APACHE-2.0.txt +206 -0
- package/packages/visualizer/src/ui/grammars/README.md +14 -0
- package/packages/visualizer/src/ui/grammars/alloy.json +71 -0
- package/packages/visualizer/src/ui/grammars/quint.json +152 -0
- package/packages/visualizer/src/ui/highlight.ts +50 -0
- package/packages/visualizer/src/ui/theme.tsx +109 -0
- package/packages/visualizer/src/views/ChartPage.tsx +46 -26
- package/packages/visualizer/src/views/ChartTests.tsx +59 -22
- package/packages/visualizer/src/views/ProductPage.tsx +16 -15
- package/packages/visualizer/src/views/SidePanel.tsx +241 -131
- package/packages/visualizer/src/app/gazetteer.module.css +0 -107
- package/packages/visualizer/src/components/ActivityIndicator.module.css +0 -4
- package/packages/visualizer/src/components/DataIssues.module.css +0 -136
- package/packages/visualizer/src/components/Header.module.css +0 -114
- package/packages/visualizer/src/components/Legend.module.css +0 -84
- package/packages/visualizer/src/components/Screen.module.css +0 -312
- package/packages/visualizer/src/components/Status.module.css +0 -116
- package/packages/visualizer/src/components/icons.tsx +0 -156
- package/packages/visualizer/src/evidence/artifacts.module.css +0 -213
- package/packages/visualizer/src/evidence/evidence.module.css +0 -907
- package/packages/visualizer/src/evidence/recording.module.css +0 -460
- package/packages/visualizer/src/evidence/sources.module.css +0 -168
- package/packages/visualizer/src/evidence/spec-snapshot.tsx +0 -26
- package/packages/visualizer/src/explorer/explorer.module.css +0 -1238
- package/packages/visualizer/src/find/Finder.module.css +0 -168
- package/packages/visualizer/src/looks/stage.module.css +0 -300
- package/packages/visualizer/src/styles/global.css +0 -105
- package/packages/visualizer/src/styles/tokens.css +0 -80
- package/packages/visualizer/src/views/views.module.css +0 -546
package/dist/server/src/llms.js
CHANGED
|
@@ -68,6 +68,7 @@ Every endpoint below answers JSON, or Markdown with \`format=markdown\`. Prefix
|
|
|
68
68
|
curl "${url}/api/projects/$ATLAS_PROJECT/reports/tests?format=markdown&limit=50" # summary, environments, domains, failing, flaky, slowest, then the tests
|
|
69
69
|
curl "${url}/api/projects/$ATLAS_PROJECT/reports/tests?status=failed,flaky&environment=desktop-web" # filter: q, status, environment, producer, domain, spec, tag, run; sort, offset, limit
|
|
70
70
|
curl "${url}/api/projects/$ATLAS_PROJECT/reports/tests/history?key=<url-encoded test key>" # one test: every recorded result, durations, branches, CI runs
|
|
71
|
+
curl "${url}/api/projects/$ATLAS_PROJECT/reports/tests/health?format=markdown" # why Specs, domains and counts disagree; add root=<from>><to> to declare a path-root mapping
|
|
71
72
|
curl "${url}/api/projects/$ATLAS_PROJECT/reports/runs?format=markdown" # CI runs, newest first, with their results by status
|
|
72
73
|
curl "${url}/api/projects/$ATLAS_PROJECT/reports/runs/detail?key=<url-encoded run key>" # every test one run recorded
|
|
73
74
|
\`\`\`
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { z } from 'zod/v4';
|
|
16
16
|
import { ProjectsSchema } from '../../schema/src/project.js';
|
|
17
|
-
import { EffectiveDeltaSchema, EffectiveEvidenceSchema, EvidencePageSchema, HealthSchema, LayerRowsSchema, CheckDetailSchema, OutlineSchema, ProblemSchema, ReadySchema, ServerInfoSchema, StoreListingSchema, StoreWriteResultSchema, ChecksResultSchema, HelloEventSchema, ReportEventSchema, PingEventSchema, TestCatalogSchema, TestHistorySchema, RunListSchema, RunDetailSchema } from '../../schema/src/api.js';
|
|
17
|
+
import { EffectiveDeltaSchema, EffectiveEvidenceSchema, EvidencePageSchema, HealthSchema, LayerRowsSchema, CheckDetailSchema, OutlineSchema, ProblemSchema, ReadySchema, ServerInfoSchema, StoreListingSchema, StoreWriteResultSchema, ChecksResultSchema, HelloEventSchema, ReportEventSchema, PingEventSchema, TestCatalogSchema, CatalogHealthSchema, TestHistorySchema, RunListSchema, RunDetailSchema } from '../../schema/src/api.js';
|
|
18
18
|
import { LiveRunSchema, LiveRunStartSchema, LiveRunUpdateSchema, ServerEditableSettingsSchema, ServerSettingsSnapshotSchema, AtlasChangeSchema, PublishChecksSchema, ReportSourcesSchema, ReportStatusSchema, RunsIndexSchema, SpecIndexSchema } from '../../schema/src/index.js';
|
|
19
19
|
/** The version of the API described, `major.minor.patch`: the minor grows when an operation or field is added, the major never within `/api`. */
|
|
20
20
|
export const API_VERSION = '1.4.0';
|
|
@@ -35,7 +35,7 @@ const COMPONENTS = {
|
|
|
35
35
|
PublishChecks: [PublishChecksSchema.partial({ recordedAtMs: true }), 'input'], ChecksResult: [ChecksResultSchema, 'output'],
|
|
36
36
|
StoreListing: [StoreListingSchema, 'output'], StoreWriteResult: [StoreWriteResultSchema, 'output'],
|
|
37
37
|
Outline: [OutlineSchema, 'output'], EvidencePage: [EvidencePageSchema, 'output'], LayerRows: [LayerRowsSchema, 'output'], CheckDetail: [CheckDetailSchema, 'output'],
|
|
38
|
-
TestCatalog: [TestCatalogSchema, 'output'], TestHistory: [TestHistorySchema, 'output'], RunList: [RunListSchema, 'output'], RunDetail: [RunDetailSchema, 'output'],
|
|
38
|
+
TestCatalog: [TestCatalogSchema, 'output'], CatalogHealth: [CatalogHealthSchema, 'output'], TestHistory: [TestHistorySchema, 'output'], RunList: [RunListSchema, 'output'], RunDetail: [RunDetailSchema, 'output'],
|
|
39
39
|
SpecIndex: [SpecIndexSchema, 'output'], ReportSources: [ReportSourcesSchema, 'output'], RunsIndex: [RunsIndexSchema, 'output'],
|
|
40
40
|
RunEvent: [LiveRunSchema, 'output'], HelloEvent: [HelloEventSchema, 'output'], ReportEvent: [ReportEventSchema, 'output'], AtlasChangeEvent: [AtlasChangeSchema, 'output'], PingEvent: [PingEventSchema, 'output']
|
|
41
41
|
};
|
|
@@ -279,6 +279,22 @@ const BASE_OPERATIONS = [
|
|
|
279
279
|
],
|
|
280
280
|
responses: { '200': { ...json('The catalog.', 'TestCatalog'), headers: { ETag: { description: 'Changes with the report, its history and the query.', schema: str } } }, '304': { description: 'Unchanged since the `If-None-Match` ETag.' }, '401': UNAUTHORIZED, '503': NOT_READY }
|
|
281
281
|
},
|
|
282
|
+
{
|
|
283
|
+
route: 'GET /api/reports/tests/health', path: '/api/reports/tests/health', operationId: 'getCatalogHealth', tag: 'Report', role: 'read',
|
|
284
|
+
summary: 'Why specs, domains and counts disagree',
|
|
285
|
+
description: [
|
|
286
|
+
'Diagnoses the report\'s metadata, so a domain filter or a coverage percentage can be trusted or fixed at its source. It reads; it never changes a status, a slot or a winner.',
|
|
287
|
+
'',
|
|
288
|
+
'`counts` keeps the denominators apart: spec files, defined specs, specs with an executed test (and those without: the defined coverage gaps), tests, outlines, examples and per-environment observations. `reconciliation` walks from the Specs count to the Tests count. `findings` name ambiguous and unmatched spec paths, identical or same-titled specs, a domain shared by nearly every spec, tests with no spec, checks that fall back to their title as identity and root mappings that matched nothing.',
|
|
289
|
+
'',
|
|
290
|
+
'Two paths are one logical spec only when a `root` mapping relates them (`from>to`: paths under `from` are the same specs as paths under `to`; an empty `from` matches every path). Without one, similar paths are reported as ambiguous and never merged. A check\'s label `outline` names the outline its example belongs to. Send `format=markdown` for Markdown.'
|
|
291
|
+
].join('\n'),
|
|
292
|
+
parameters: [
|
|
293
|
+
{ name: 'root', in: 'query', description: 'A root mapping `from>to`, URL-encoded. Repeatable or comma-separated.', schema: str, example: '>specs/' },
|
|
294
|
+
{ name: 'format', in: 'query', description: '`markdown` for a Markdown report instead of JSON.', schema: { type: 'string', enum: ['json', 'markdown'] } }
|
|
295
|
+
],
|
|
296
|
+
responses: { '200': { ...json('The diagnostics.', 'CatalogHealth'), headers: { ETag: { description: 'Changes with the report, its history and the mappings.', schema: str } } }, '304': { description: 'Unchanged since the `If-None-Match` ETag.' }, '401': UNAUTHORIZED, '503': NOT_READY }
|
|
297
|
+
},
|
|
282
298
|
{
|
|
283
299
|
route: 'GET /api/reports/tests/history', path: '/api/reports/tests/history', operationId: 'getTestHistory', tag: 'Report', role: 'read',
|
|
284
300
|
summary: 'One test and every result recorded for it',
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
/**
|
|
2
|
-
export declare const SETTINGS_PAGE = "<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>Settings \u00B7 Atlas</title>\n<style>\n:root{--font:-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;--mono:ui-monospace,monospace;--bg:#fff;--bg-2:#f7f9f9;--ink:#0f1419;--ink-3:#536471;--line:#eff3f4;--line-2:#cfd9de;--place:#0b63ce;--place-soft:#e8f1fd;--passed-ink:#116329;--passed-soft:#e6f4ea;--failed-ink:#a40e26;--failed-soft:#ffebe9;--focus:#0b63ce}\n/* atlas-client-tokens */\n*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 var(--font);-webkit-font-smoothing:antialiased}a{color:var(--place);text-decoration:none}button,input,select,textarea{font:inherit}button,a,input,select,textarea,summary{outline-offset:3px}:focus-visible{outline:2px solid var(--focus)}[hidden]{display:none!important}\n.topbar{height:54px;display:flex;align-items:center;gap:16px;padding:0 24px;border-bottom:1px solid var(--line);background:var(--bg)}.brand{font-size:15px;font-weight:700;color:var(--ink)}.crumb{display:flex;align-items:center;gap:16px;color:var(--ink-3)}.crumb span:first-child{color:var(--line-2)}.back{margin-left:auto;font-size:13px;color:var(--ink-3)}\n.layout{display:grid;grid-template-columns:180px minmax(0,760px);gap:48px;max-width:1080px;padding:40px 24px 64px;margin:auto}.side{position:sticky;top:24px;align-self:start}.side p{margin:0 0 12px 10px;color:var(--ink-3);font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.06em}.side a{display:block;border-radius:6px;padding:8px 10px;color:var(--ink-3);font-size:13px}.side a:hover{background:var(--bg-2);color:var(--ink)}main{min-width:0}h1{margin:0 0 8px;font-size:26px;line-height:1.25;letter-spacing:-.025em}h2{font-size:16px;margin:0 0 4px}h3{font-size:13px;margin:0 0 8px}.lead{margin:0 0 24px;color:var(--ink-3);max-width:62ch}.section{padding:24px 0;border-top:1px solid var(--line);scroll-margin-top:24px}.description{margin:0 0 18px;color:var(--ink-3);font-size:13px}.access{border:1px solid var(--line-2);border-radius:8px;padding:20px;margin-bottom:24px}.access h2{margin-bottom:6px}.access .description{margin-bottom:14px}.access-row{display:flex;align-items:flex-end;gap:10px}.access-row .field{flex:1;margin:0}.field{margin:0 0 18px;min-width:0}.field:last-child{margin-bottom:0}label{display:block;font-weight:500;font-size:13px;margin-bottom:6px}input,select,textarea{display:block;min-height:36px;width:100%;min-width:0;padding:8px 10px;border:1px solid var(--line-2);border-radius:6px;color:var(--ink);background:var(--bg);line-height:1.4}input::placeholder{color:var(--ink-3);opacity:.8}input:disabled,select:disabled,textarea:disabled{background:var(--bg-2);color:var(--ink-3)}.hint,.current{margin:6px 0 0;color:var(--ink-3);font-size:12px;overflow-wrap:anywhere}.current:empty{display:none}.grid{display:grid;grid-template-columns:1fr 1fr;gap:0 20px}fieldset{border:0;padding:0;margin:0;min-width:0}button,.button{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:34px;padding:7px 12px;border:1px solid var(--line-2);border-radius:6px;background:var(--bg);color:var(--ink);font-size:13px;font-weight:500;cursor:pointer;line-height:1.4;white-space:nowrap}button:hover:not(:disabled){background:var(--bg-2)}button:disabled{opacity:.5;cursor:default}.primary{background:var(--place);border-color:var(--place);color:#fff}.primary:hover:not(:disabled){background:var(--place);filter:brightness(.95)}.badge{font-size:11px;border-radius:4px;background:var(--passed-soft);color:var(--passed-ink);padding:3px 6px;margin-left:8px;font-weight:500}.notice{padding:12px 14px;border-radius:6px;background:var(--place-soft);font-size:13px;color:var(--ink-3);margin:0 0 20px}.notice strong{color:var(--ink)}#status:empty{display:none}#status{padding:12px 14px;border-radius:6px;font-size:13px;margin:0 0 20px;overflow-wrap:anywhere}#status[data-state=error]{background:var(--failed-soft);color:var(--failed-ink)}#status[data-state=success]{background:var(--passed-soft);color:var(--passed-ink)}#status[data-state=info]{background:var(--bg-2);color:var(--ink-3)}\n.backend{border:1px solid var(--line);border-radius:8px;padding:14px;margin:0 0 12px}.backend-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.backend-head strong{font-size:13px}.remove{border:0;color:var(--ink-3);min-height:28px;padding:4px 8px}.empty{padding:12px 0;color:var(--ink-3);font-size:13px}.savebar{display:flex;align-items:center;gap:16px;padding:20px 0;border-top:1px solid var(--line);margin-top:20px}.savebar p{margin:0;color:var(--ink-3);font-size:12px}details{border:1px solid var(--line);border-radius:6px;margin:12px 0}summary{padding:12px 14px;cursor:pointer;font-size:13px;font-weight:500}details>div{padding:0 14px 14px}textarea#settings{min-height:240px;resize:vertical;font:12px/1.6 var(--mono);tab-size:2}pre{font:12px/1.6 var(--mono);margin:0;white-space:pre-wrap;overflow-wrap:anywhere}code{font-family:var(--mono);font-size:12px}.visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap}\n#load[aria-busy=true]::before{content:\"\";width:10px;height:10px;border:1.5px solid currentColor;border-right-color:transparent;border-bottom-color:transparent;border-radius:50%;opacity:.65;animation:working 1.8s linear infinite}@keyframes working{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){#load[aria-busy=true]::before{animation:none}}\n@media(max-width:760px){.topbar{padding:0 16px}.layout{display:block;padding:24px 16px 40px}.side{position:static;display:flex;flex-wrap:wrap;gap:4px;margin-bottom:24px}.side p{display:none}.side a{padding:6px 10px}.grid{grid-template-columns:1fr;gap:0}.access-row{align-items:stretch;flex-direction:column}.savebar{align-items:flex-start;flex-direction:column;gap:10px}h1{font-size:24px}}\n</style></head><body>\n<header class=\"topbar\"><a class=\"brand report-link\" href=\"/\">Atlas</a><div class=\"crumb\"><span aria-hidden=\"true\">\u203A</span><span>Settings</span></div><a class=\"back report-link\" href=\"/\">\u2190 Back to reports</a></header>\n<div class=\"layout\"><nav class=\"side\" aria-label=\"Settings sections\"><p>Server settings</p><a href=\"#access\">Access</a><a href=\"#storage\">Storage</a><a href=\"#live\">Live updates</a><a href=\"#rules\">Report rules</a><a href=\"#advanced\">Advanced</a></nav>\n<main><h1>Atlas server settings</h1><p class=\"lead\">Configure how this Atlas node stores reports and tracks test runs. These settings apply to every project on this node.</p>\n<section class=\"access\" id=\"access\"><h2>Administrator access <span id=\"connected\" class=\"badge\" hidden>Connected</span></h2><p class=\"description\">Enter an admin token to view and edit settings. For a local server without authentication, leave it blank.</p><div class=\"access-row\"><div class=\"field\"><label for=\"token\">Admin token</label><input id=\"token\" type=\"password\" autocomplete=\"off\" spellcheck=\"false\" placeholder=\"Enter your admin token\" aria-describedby=\"token-hint\"></div><button id=\"load\" type=\"button\">Load settings</button></div><p class=\"hint\" id=\"token-hint\">Your token stays in this page and is never saved in browser storage.</p></section>\n<p id=\"status\" role=\"status\" aria-live=\"polite\"></p>\n<div id=\"configuration\" hidden>\n<p class=\"notice\"><strong>Changes apply after a server restart.</strong> Environment variables and command-line flags can override saved settings. Current values are shown under each field.</p>\n<form id=\"form\"><fieldset id=\"fields\" disabled>\n<section class=\"section\" id=\"storage\"><h2>Storage</h2><p class=\"description\">Choose where reports are stored. Changing a location does not move existing reports.</p>\n<div class=\"field\"><label for=\"data\">Local data folder</label><input id=\"data\" type=\"text\" placeholder=\"./atlas-data\"><p class=\"hint\">The node's local storage. Relative paths start from the server configuration directory.</p><p class=\"current\" id=\"data-current\"></p></div>\n<div class=\"field\"><label for=\"write\">Upload destination</label><select id=\"write\"><option value=\"\">Use default destination</option><option value=\"data\">Local data folder</option></select><p class=\"hint\">New uploads go here. Reports are read from all configured storage locations.</p><p class=\"current\" id=\"write-current\"></p></div>\n<h3>Additional storage</h3><p class=\"description\">Add a folder, S3-compatible bucket, or another Atlas server. Keep credentials in the server environment.</p><div id=\"backends\"></div><p class=\"empty\" id=\"no-backends\">No additional storage configured. Atlas uses its local data folder.</p><button id=\"add-backend\" type=\"button\">Add storage</button>\n</section>\n<section class=\"section\" id=\"live\"><h2>Live updates</h2><p class=\"description\">Control when silent or stalled test runs are marked interrupted.</p><div class=\"grid\">\n<div class=\"field\"><label for=\"runTimeoutSeconds\">Missing heartbeat timeout (seconds)</label><input id=\"runTimeoutSeconds\" type=\"number\" min=\"10\" max=\"3600\" step=\"any\"><p class=\"hint\">Interrupt a run when its producer stops checking in. Default: 30 seconds.</p><p class=\"current\" id=\"runTimeoutSeconds-current\"></p></div>\n<div class=\"field\"><label for=\"runStallSeconds\">No-progress timeout (seconds)</label><input id=\"runStallSeconds\" type=\"number\" min=\"0\" max=\"86400\" step=\"any\"><p class=\"hint\">Interrupt runs that stop making progress. Use 0 to disable. Default: 900 seconds.</p><p class=\"current\" id=\"runStallSeconds-current\"></p></div>\n<div class=\"field\"><label for=\"refreshSeconds\">Storage refresh interval (seconds)</label><input id=\"refreshSeconds\" type=\"number\" min=\"1\" step=\"any\"><p class=\"hint\">Check for reports written outside this node. Default: 10 seconds.</p><p class=\"current\" id=\"refreshSeconds-current\"></p></div>\n<div class=\"field\"><label for=\"heartbeatSeconds\">Client keepalive interval (seconds)</label><input id=\"heartbeatSeconds\" type=\"number\" min=\"1\" step=\"any\"><p class=\"hint\">Keep browser connections open between updates. Default: 15 seconds.</p><p class=\"current\" id=\"heartbeatSeconds-current\"></p></div>\n</div></section>\n<section class=\"section\" id=\"rules\"><h2>Report rules</h2><p class=\"description\">Choose which uploaded results contribute to the combined report.</p>\n<div class=\"field\"><label for=\"outlineBranch\">Preferred specification branch</label><input id=\"outlineBranch\" type=\"text\" placeholder=\"Automatic\"><p class=\"hint\">Leave blank to prefer main, then master, then the newest available specs.</p><p class=\"current\" id=\"outlineBranch-current\"></p></div>\n<div class=\"field\"><label for=\"branches\">Include branches</label><input id=\"branches\" type=\"text\" placeholder=\"All branches\"><p class=\"hint\">Comma-separated branch names. Leave blank to include every branch.</p><p class=\"current\" id=\"branches-current\"></p></div>\n<div class=\"field\"><label for=\"ignoreModified\">Results from modified checkouts</label><select id=\"ignoreModified\"><option value=\"\">Use default (include)</option><option value=\"false\">Include</option><option value=\"true\">Exclude</option></select><p class=\"current\" id=\"ignoreModified-current\"></p></div>\n</section>\n<section class=\"section\" id=\"advanced\"><h2>Advanced</h2><p class=\"description\">Request limits, logging, and the complete editable configuration.</p><div class=\"grid\">\n<div class=\"field\"><label for=\"maxObjectMiB\">Maximum upload size (MiB)</label><input id=\"maxObjectMiB\" type=\"number\" min=\"0.001\" step=\"any\"><p class=\"current\" id=\"maxObjectMiB-current\"></p></div>\n<div class=\"field\"><label for=\"maxJsonMiB\">Maximum JSON size (MiB)</label><input id=\"maxJsonMiB\" type=\"number\" min=\"0.001\" step=\"any\"><p class=\"current\" id=\"maxJsonMiB-current\"></p></div>\n<div class=\"field\"><label for=\"maxStreams\">Maximum live connections</label><input id=\"maxStreams\" type=\"number\" min=\"1\" step=\"1\"><p class=\"current\" id=\"maxStreams-current\"></p></div>\n<div class=\"field\"><label for=\"log\">Request logging</label><select id=\"log\"><option value=\"\">Use default (enabled)</option><option value=\"true\">Enabled</option><option value=\"false\">Disabled</option></select><p class=\"current\" id=\"log-current\"></p></div>\n</div><details id=\"json-editor\"><summary>Edit configuration as JSON</summary><div><p class=\"description\">This is the same saved configuration as the form above. Omitted values use defaults after restart.</p><label for=\"settings\" class=\"visually-hidden\">Storage and report settings (JSON)</label><textarea id=\"settings\" spellcheck=\"false\" disabled></textarea></div></details>\n<details><summary>View currently active settings</summary><div><p class=\"description\">The configuration running now, including environment and command-line overrides.</p><pre id=\"active\"></pre></div></details>\n</section></fieldset><footer class=\"savebar\"><button id=\"save\" class=\"primary\" type=\"button\" disabled>Save changes</button><p>Saved to <code>atlas.server.json</code>. Restart Atlas to apply.</p></footer></form></div>\n</main></div>\n<script type=\"module\">\nconst el=id=>document.getElementById(id);\nconst fields=[['data',['data']],['outlineBranch',['outlineBranch']],['runTimeoutSeconds',['runTimeoutSeconds']],['runStallSeconds',['runStallSeconds']],['refreshSeconds',['refreshSeconds']],['heartbeatSeconds',['heartbeatSeconds']],['branches',['include','branches']],['ignoreModified',['include','ignoreModified']],['log',['log']],['maxObjectMiB',['limits','maxObjectMiB']],['maxJsonMiB',['limits','maxJsonMiB']],['maxStreams',['limits','maxStreams']]];\nlet revision, draft={}, active={}, stale=false, busy=false;\nconst at=(object,path)=>path.reduce((value,key)=>value&&typeof value==='object'?value[key]:undefined,object);\nconst copy=value=>JSON.parse(JSON.stringify(value));\nfunction set(object,path,value){let held=object;for(const key of path.slice(0,-1)){if(!held[key]||typeof held[key]!=='object')held[key]={};held=held[key];}if(value===undefined)delete held[path.at(-1)];else held[path.at(-1)]=value;}\nfunction message(text,state='info'){el('status').textContent=text;el('status').dataset.state=state;el('status').setAttribute('role',state==='error'?'alert':'status');}\nfunction option(text,value){const node=document.createElement('option');node.textContent=text;node.value=value;return node;}\nfunction destinations(){const select=el('write'), value=select.value;select.replaceChildren(option('Use default destination',''),option('Local data folder','data'));for(const row of el('backends').children){const id=row.querySelector('[data-key=id]').value.trim();if(id){const label=row.querySelector('[data-key=label]').value.trim();select.add(option(label?label+' ('+id+')':id,id));}}if([...select.options].some(option=>option.value===value))select.value=value;}\nfunction addBackend(backend={}){const row=document.createElement('div');row.className='backend';if(backend.write!==undefined)row.dataset.write=String(backend.write);const head=document.createElement('div');head.className='backend-head';const title=document.createElement('strong');title.textContent='Storage location';const remove=document.createElement('button');remove.type='button';remove.className='remove';remove.textContent='Remove';remove.onclick=()=>{row.remove();destinations();el('no-backends').hidden=el('backends').children.length>0;sync();};head.append(title,remove);row.append(head);const group=document.createElement('div');group.className='grid';for(const [key,label,placeholder] of [['id','Storage ID','archive'],['label','Display name (optional)','Archive'],['url','Location','s3://reports/atlas']]){const field=document.createElement('div');field.className='field';const input=document.createElement('input');input.type='text';input.dataset.key=key;input.value=backend[key]??'';input.placeholder=placeholder;input.id='backend-'+key+'-'+Math.random().toString(36).slice(2);input.required=key!=='label';if(key==='id')input.pattern='[a-z0-9-]+';const caption=document.createElement('label');caption.htmlFor=input.id;caption.textContent=label;field.append(caption,input);if(key==='url')row.append(field);else group.append(field);}row.insertBefore(group,row.children[1]??null);el('backends').append(row);el('no-backends').hidden=true;destinations();}\nfunction fill(settings){draft=copy(settings);for(const [id,path]of fields){const value=at(settings,path);el(id).value=Array.isArray(value)?value.join(', '):value===undefined?'':String(value);}el('backends').replaceChildren();for(const backend of settings.backends??[])addBackend(backend);el('no-backends').hidden=el('backends').children.length>0;destinations();if(settings.write&&!Array.from(el('write').options).some(option=>option.value===settings.write))el('write').add(option(settings.write,settings.write));el('write').value=settings.write??'';}\nfunction formSettings(){const value=copy(draft);for(const [id,path]of fields){const input=el(id),text=input.value.trim();set(value,path,text===''?undefined:id==='branches'?text.split(',').map(item=>item.trim()).filter(Boolean):input.type==='number'?Number(text):id==='log'||id==='ignoreModified'?text==='true':text);}for(const key of ['include','limits'])if(value[key]&&Object.keys(value[key]).length===0)delete value[key];const backends=[...el('backends').children].map(row=>{const backend={};for(const input of row.querySelectorAll('[data-key]')){const text=input.value.trim();if(text||input.dataset.key!=='label')backend[input.dataset.key]=text;}if(row.dataset.write!==undefined)backend.write=row.dataset.write==='true';return backend;});if(backends.length||draft.backends!==undefined)value.backends=backends;else delete value.backends;if(el('write').value)value.write=el('write').value;else delete value.write;return value;}\nfunction sync(){el('settings').value=JSON.stringify(formSettings(),null,2);}\nfunction currentValues(){for(const [id,path]of fields){const value=at(active,path);el(id+'-current').textContent=value===undefined?'':'Current: '+(Array.isArray(value)?value.join(', ')||'All branches':String(value));if(el(id).tagName==='INPUT'&&value!==undefined&&el(id).value==='')el(id).placeholder=String(value);}el('write-current').textContent='Current: '+(active.write??'data');}\nfunction controls(){el('load').setAttribute('aria-busy',String(busy));el('load').disabled=busy;el('load').textContent=busy?'Working\u2026':revision?'Reload settings':'Load settings';el('save').disabled=busy||!revision||stale;el('fields').disabled=busy||!revision;el('settings').disabled=busy||!revision;}\nasync function request(method){const token=el('token').value;const headers=token?{authorization:'Bearer '+token}:{};let body;if(method==='PUT'){const parsed=JSON.parse(el('settings').value);if(!parsed||typeof parsed!=='object'||Array.isArray(parsed))throw Error('Settings must be a JSON object.');headers['content-type']='application/json';headers['if-match']='\"'+revision+'\"';body=JSON.stringify(parsed);}const response=await fetch('/api/settings',{method,headers,body,cache:'no-store'});const data=await response.json();if(!response.ok){if(response.status===409||response.status===428)stale=true;throw Error(data.detail||data.error||'Request failed ('+response.status+')');}revision=data.revision;stale=false;active=data.active;fill(data.settings);el('settings').value=JSON.stringify(data.settings,null,2);el('active').textContent=JSON.stringify(data.active,null,2);currentValues();el('configuration').hidden=false;el('connected').hidden=false;message(method==='PUT'||data.restartRequired?'Saved. Restart Atlas to apply.':'Settings loaded. Edit saved values below; blank fields use defaults after restart.',method==='PUT'||data.restartRequired?'success':'info');}\nfor(const [id,method]of [['load','GET'],['save','PUT']])el(id).onclick=async()=>{if(busy||(method==='PUT'&&(!revision||stale)))return;if(method==='PUT'&&!el('json-editor').open&&!el('form').reportValidity()){message('Check the highlighted fields before saving.','error');return;}if(method==='GET'){revision=undefined;el('connected').hidden=true;}busy=true;controls();try{await request(method);}catch(error){message(error instanceof SyntaxError?'The JSON configuration is invalid. Fix it before saving.':error.message,'error');}finally{busy=false;controls();}};\nel('form').onsubmit=event=>{event.preventDefault();if(!el('save').disabled)el('save').click();};\nel('fields').addEventListener('input',event=>{if(event.target===el('settings'))return;if(event.target.dataset.key==='id'||event.target.dataset.key==='label')destinations();sync();});\nel('fields').addEventListener('change',event=>{if(event.target===el('settings'))return;if(event.target===el('write'))for(const row of el('backends').children)delete row.dataset.write;sync();});\nel('settings').addEventListener('input',()=>{try{const settings=JSON.parse(el('settings').value);if(settings&&typeof settings==='object'&&!Array.isArray(settings))fill(settings);}catch{}});\nel('add-backend').onclick=()=>{addBackend();sync();el('backends').lastElementChild.querySelector('input').focus();};\nconst project=new URL(location.href).searchParams.get('project');if(project&&/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(project)&&project.length<=64)for(const link of document.querySelectorAll('.report-link'))link.href='/?project='+encodeURIComponent(project)+'#/';\n</script></body></html>";
|
|
1
|
+
/** Document shell for the shared React settings UI. No configuration or credentials are embedded. */
|
|
2
|
+
export declare const SETTINGS_PAGE = "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>Settings \u00B7 Atlas</title><!-- atlas-admin-styles --></head><body><div id=\"root\"><h1>Atlas server settings</h1><p>Loading settings interface\u2026</p></div><!-- atlas-admin-script --></body></html>";
|
|
3
3
|
/**
|
|
4
|
-
* Render
|
|
5
|
-
*
|
|
4
|
+
* Render the settings UI with the same provider and theme as the viewer. Bundle once on first request,
|
|
5
|
+
* without a prebuilt viewer, CDN, configuration data, or import-time work. A failed build can be retried.
|
|
6
6
|
* @example
|
|
7
7
|
* const html = await renderSettingsPage();
|
|
8
8
|
*/
|
|
@@ -1,93 +1,32 @@
|
|
|
1
1
|
/** @module Settings page
|
|
2
|
-
*
|
|
2
|
+
* The administrator's React and Mantine entry point, bundled lazily for API-only servers too.
|
|
3
3
|
* Credentials remain in page memory; the settings API owns authorization and validation.
|
|
4
4
|
*/
|
|
5
|
-
import { readFile } from 'node:fs/promises';
|
|
6
5
|
import path from 'node:path';
|
|
7
6
|
import { visualizerRoot } from './site.js';
|
|
8
|
-
/**
|
|
9
|
-
export const SETTINGS_PAGE = `<!doctype html>
|
|
10
|
-
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Settings · Atlas</title>
|
|
11
|
-
<style>
|
|
12
|
-
:root{--font:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--mono:ui-monospace,monospace;--bg:#fff;--bg-2:#f7f9f9;--ink:#0f1419;--ink-3:#536471;--line:#eff3f4;--line-2:#cfd9de;--place:#0b63ce;--place-soft:#e8f1fd;--passed-ink:#116329;--passed-soft:#e6f4ea;--failed-ink:#a40e26;--failed-soft:#ffebe9;--focus:#0b63ce}
|
|
13
|
-
/* atlas-client-tokens */
|
|
14
|
-
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 var(--font);-webkit-font-smoothing:antialiased}a{color:var(--place);text-decoration:none}button,input,select,textarea{font:inherit}button,a,input,select,textarea,summary{outline-offset:3px}:focus-visible{outline:2px solid var(--focus)}[hidden]{display:none!important}
|
|
15
|
-
.topbar{height:54px;display:flex;align-items:center;gap:16px;padding:0 24px;border-bottom:1px solid var(--line);background:var(--bg)}.brand{font-size:15px;font-weight:700;color:var(--ink)}.crumb{display:flex;align-items:center;gap:16px;color:var(--ink-3)}.crumb span:first-child{color:var(--line-2)}.back{margin-left:auto;font-size:13px;color:var(--ink-3)}
|
|
16
|
-
.layout{display:grid;grid-template-columns:180px minmax(0,760px);gap:48px;max-width:1080px;padding:40px 24px 64px;margin:auto}.side{position:sticky;top:24px;align-self:start}.side p{margin:0 0 12px 10px;color:var(--ink-3);font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.06em}.side a{display:block;border-radius:6px;padding:8px 10px;color:var(--ink-3);font-size:13px}.side a:hover{background:var(--bg-2);color:var(--ink)}main{min-width:0}h1{margin:0 0 8px;font-size:26px;line-height:1.25;letter-spacing:-.025em}h2{font-size:16px;margin:0 0 4px}h3{font-size:13px;margin:0 0 8px}.lead{margin:0 0 24px;color:var(--ink-3);max-width:62ch}.section{padding:24px 0;border-top:1px solid var(--line);scroll-margin-top:24px}.description{margin:0 0 18px;color:var(--ink-3);font-size:13px}.access{border:1px solid var(--line-2);border-radius:8px;padding:20px;margin-bottom:24px}.access h2{margin-bottom:6px}.access .description{margin-bottom:14px}.access-row{display:flex;align-items:flex-end;gap:10px}.access-row .field{flex:1;margin:0}.field{margin:0 0 18px;min-width:0}.field:last-child{margin-bottom:0}label{display:block;font-weight:500;font-size:13px;margin-bottom:6px}input,select,textarea{display:block;min-height:36px;width:100%;min-width:0;padding:8px 10px;border:1px solid var(--line-2);border-radius:6px;color:var(--ink);background:var(--bg);line-height:1.4}input::placeholder{color:var(--ink-3);opacity:.8}input:disabled,select:disabled,textarea:disabled{background:var(--bg-2);color:var(--ink-3)}.hint,.current{margin:6px 0 0;color:var(--ink-3);font-size:12px;overflow-wrap:anywhere}.current:empty{display:none}.grid{display:grid;grid-template-columns:1fr 1fr;gap:0 20px}fieldset{border:0;padding:0;margin:0;min-width:0}button,.button{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:34px;padding:7px 12px;border:1px solid var(--line-2);border-radius:6px;background:var(--bg);color:var(--ink);font-size:13px;font-weight:500;cursor:pointer;line-height:1.4;white-space:nowrap}button:hover:not(:disabled){background:var(--bg-2)}button:disabled{opacity:.5;cursor:default}.primary{background:var(--place);border-color:var(--place);color:#fff}.primary:hover:not(:disabled){background:var(--place);filter:brightness(.95)}.badge{font-size:11px;border-radius:4px;background:var(--passed-soft);color:var(--passed-ink);padding:3px 6px;margin-left:8px;font-weight:500}.notice{padding:12px 14px;border-radius:6px;background:var(--place-soft);font-size:13px;color:var(--ink-3);margin:0 0 20px}.notice strong{color:var(--ink)}#status:empty{display:none}#status{padding:12px 14px;border-radius:6px;font-size:13px;margin:0 0 20px;overflow-wrap:anywhere}#status[data-state=error]{background:var(--failed-soft);color:var(--failed-ink)}#status[data-state=success]{background:var(--passed-soft);color:var(--passed-ink)}#status[data-state=info]{background:var(--bg-2);color:var(--ink-3)}
|
|
17
|
-
.backend{border:1px solid var(--line);border-radius:8px;padding:14px;margin:0 0 12px}.backend-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.backend-head strong{font-size:13px}.remove{border:0;color:var(--ink-3);min-height:28px;padding:4px 8px}.empty{padding:12px 0;color:var(--ink-3);font-size:13px}.savebar{display:flex;align-items:center;gap:16px;padding:20px 0;border-top:1px solid var(--line);margin-top:20px}.savebar p{margin:0;color:var(--ink-3);font-size:12px}details{border:1px solid var(--line);border-radius:6px;margin:12px 0}summary{padding:12px 14px;cursor:pointer;font-size:13px;font-weight:500}details>div{padding:0 14px 14px}textarea#settings{min-height:240px;resize:vertical;font:12px/1.6 var(--mono);tab-size:2}pre{font:12px/1.6 var(--mono);margin:0;white-space:pre-wrap;overflow-wrap:anywhere}code{font-family:var(--mono);font-size:12px}.visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap}
|
|
18
|
-
#load[aria-busy=true]::before{content:"";width:10px;height:10px;border:1.5px solid currentColor;border-right-color:transparent;border-bottom-color:transparent;border-radius:50%;opacity:.65;animation:working 1.8s linear infinite}@keyframes working{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){#load[aria-busy=true]::before{animation:none}}
|
|
19
|
-
@media(max-width:760px){.topbar{padding:0 16px}.layout{display:block;padding:24px 16px 40px}.side{position:static;display:flex;flex-wrap:wrap;gap:4px;margin-bottom:24px}.side p{display:none}.side a{padding:6px 10px}.grid{grid-template-columns:1fr;gap:0}.access-row{align-items:stretch;flex-direction:column}.savebar{align-items:flex-start;flex-direction:column;gap:10px}h1{font-size:24px}}
|
|
20
|
-
</style></head><body>
|
|
21
|
-
<header class="topbar"><a class="brand report-link" href="/">Atlas</a><div class="crumb"><span aria-hidden="true">›</span><span>Settings</span></div><a class="back report-link" href="/">← Back to reports</a></header>
|
|
22
|
-
<div class="layout"><nav class="side" aria-label="Settings sections"><p>Server settings</p><a href="#access">Access</a><a href="#storage">Storage</a><a href="#live">Live updates</a><a href="#rules">Report rules</a><a href="#advanced">Advanced</a></nav>
|
|
23
|
-
<main><h1>Atlas server settings</h1><p class="lead">Configure how this Atlas node stores reports and tracks test runs. These settings apply to every project on this node.</p>
|
|
24
|
-
<section class="access" id="access"><h2>Administrator access <span id="connected" class="badge" hidden>Connected</span></h2><p class="description">Enter an admin token to view and edit settings. For a local server without authentication, leave it blank.</p><div class="access-row"><div class="field"><label for="token">Admin token</label><input id="token" type="password" autocomplete="off" spellcheck="false" placeholder="Enter your admin token" aria-describedby="token-hint"></div><button id="load" type="button">Load settings</button></div><p class="hint" id="token-hint">Your token stays in this page and is never saved in browser storage.</p></section>
|
|
25
|
-
<p id="status" role="status" aria-live="polite"></p>
|
|
26
|
-
<div id="configuration" hidden>
|
|
27
|
-
<p class="notice"><strong>Changes apply after a server restart.</strong> Environment variables and command-line flags can override saved settings. Current values are shown under each field.</p>
|
|
28
|
-
<form id="form"><fieldset id="fields" disabled>
|
|
29
|
-
<section class="section" id="storage"><h2>Storage</h2><p class="description">Choose where reports are stored. Changing a location does not move existing reports.</p>
|
|
30
|
-
<div class="field"><label for="data">Local data folder</label><input id="data" type="text" placeholder="./atlas-data"><p class="hint">The node's local storage. Relative paths start from the server configuration directory.</p><p class="current" id="data-current"></p></div>
|
|
31
|
-
<div class="field"><label for="write">Upload destination</label><select id="write"><option value="">Use default destination</option><option value="data">Local data folder</option></select><p class="hint">New uploads go here. Reports are read from all configured storage locations.</p><p class="current" id="write-current"></p></div>
|
|
32
|
-
<h3>Additional storage</h3><p class="description">Add a folder, S3-compatible bucket, or another Atlas server. Keep credentials in the server environment.</p><div id="backends"></div><p class="empty" id="no-backends">No additional storage configured. Atlas uses its local data folder.</p><button id="add-backend" type="button">Add storage</button>
|
|
33
|
-
</section>
|
|
34
|
-
<section class="section" id="live"><h2>Live updates</h2><p class="description">Control when silent or stalled test runs are marked interrupted.</p><div class="grid">
|
|
35
|
-
<div class="field"><label for="runTimeoutSeconds">Missing heartbeat timeout (seconds)</label><input id="runTimeoutSeconds" type="number" min="10" max="3600" step="any"><p class="hint">Interrupt a run when its producer stops checking in. Default: 30 seconds.</p><p class="current" id="runTimeoutSeconds-current"></p></div>
|
|
36
|
-
<div class="field"><label for="runStallSeconds">No-progress timeout (seconds)</label><input id="runStallSeconds" type="number" min="0" max="86400" step="any"><p class="hint">Interrupt runs that stop making progress. Use 0 to disable. Default: 900 seconds.</p><p class="current" id="runStallSeconds-current"></p></div>
|
|
37
|
-
<div class="field"><label for="refreshSeconds">Storage refresh interval (seconds)</label><input id="refreshSeconds" type="number" min="1" step="any"><p class="hint">Check for reports written outside this node. Default: 10 seconds.</p><p class="current" id="refreshSeconds-current"></p></div>
|
|
38
|
-
<div class="field"><label for="heartbeatSeconds">Client keepalive interval (seconds)</label><input id="heartbeatSeconds" type="number" min="1" step="any"><p class="hint">Keep browser connections open between updates. Default: 15 seconds.</p><p class="current" id="heartbeatSeconds-current"></p></div>
|
|
39
|
-
</div></section>
|
|
40
|
-
<section class="section" id="rules"><h2>Report rules</h2><p class="description">Choose which uploaded results contribute to the combined report.</p>
|
|
41
|
-
<div class="field"><label for="outlineBranch">Preferred specification branch</label><input id="outlineBranch" type="text" placeholder="Automatic"><p class="hint">Leave blank to prefer main, then master, then the newest available specs.</p><p class="current" id="outlineBranch-current"></p></div>
|
|
42
|
-
<div class="field"><label for="branches">Include branches</label><input id="branches" type="text" placeholder="All branches"><p class="hint">Comma-separated branch names. Leave blank to include every branch.</p><p class="current" id="branches-current"></p></div>
|
|
43
|
-
<div class="field"><label for="ignoreModified">Results from modified checkouts</label><select id="ignoreModified"><option value="">Use default (include)</option><option value="false">Include</option><option value="true">Exclude</option></select><p class="current" id="ignoreModified-current"></p></div>
|
|
44
|
-
</section>
|
|
45
|
-
<section class="section" id="advanced"><h2>Advanced</h2><p class="description">Request limits, logging, and the complete editable configuration.</p><div class="grid">
|
|
46
|
-
<div class="field"><label for="maxObjectMiB">Maximum upload size (MiB)</label><input id="maxObjectMiB" type="number" min="0.001" step="any"><p class="current" id="maxObjectMiB-current"></p></div>
|
|
47
|
-
<div class="field"><label for="maxJsonMiB">Maximum JSON size (MiB)</label><input id="maxJsonMiB" type="number" min="0.001" step="any"><p class="current" id="maxJsonMiB-current"></p></div>
|
|
48
|
-
<div class="field"><label for="maxStreams">Maximum live connections</label><input id="maxStreams" type="number" min="1" step="1"><p class="current" id="maxStreams-current"></p></div>
|
|
49
|
-
<div class="field"><label for="log">Request logging</label><select id="log"><option value="">Use default (enabled)</option><option value="true">Enabled</option><option value="false">Disabled</option></select><p class="current" id="log-current"></p></div>
|
|
50
|
-
</div><details id="json-editor"><summary>Edit configuration as JSON</summary><div><p class="description">This is the same saved configuration as the form above. Omitted values use defaults after restart.</p><label for="settings" class="visually-hidden">Storage and report settings (JSON)</label><textarea id="settings" spellcheck="false" disabled></textarea></div></details>
|
|
51
|
-
<details><summary>View currently active settings</summary><div><p class="description">The configuration running now, including environment and command-line overrides.</p><pre id="active"></pre></div></details>
|
|
52
|
-
</section></fieldset><footer class="savebar"><button id="save" class="primary" type="button" disabled>Save changes</button><p>Saved to <code>atlas.server.json</code>. Restart Atlas to apply.</p></footer></form></div>
|
|
53
|
-
</main></div>
|
|
54
|
-
<script type="module">
|
|
55
|
-
const el=id=>document.getElementById(id);
|
|
56
|
-
const fields=[['data',['data']],['outlineBranch',['outlineBranch']],['runTimeoutSeconds',['runTimeoutSeconds']],['runStallSeconds',['runStallSeconds']],['refreshSeconds',['refreshSeconds']],['heartbeatSeconds',['heartbeatSeconds']],['branches',['include','branches']],['ignoreModified',['include','ignoreModified']],['log',['log']],['maxObjectMiB',['limits','maxObjectMiB']],['maxJsonMiB',['limits','maxJsonMiB']],['maxStreams',['limits','maxStreams']]];
|
|
57
|
-
let revision, draft={}, active={}, stale=false, busy=false;
|
|
58
|
-
const at=(object,path)=>path.reduce((value,key)=>value&&typeof value==='object'?value[key]:undefined,object);
|
|
59
|
-
const copy=value=>JSON.parse(JSON.stringify(value));
|
|
60
|
-
function set(object,path,value){let held=object;for(const key of path.slice(0,-1)){if(!held[key]||typeof held[key]!=='object')held[key]={};held=held[key];}if(value===undefined)delete held[path.at(-1)];else held[path.at(-1)]=value;}
|
|
61
|
-
function message(text,state='info'){el('status').textContent=text;el('status').dataset.state=state;el('status').setAttribute('role',state==='error'?'alert':'status');}
|
|
62
|
-
function option(text,value){const node=document.createElement('option');node.textContent=text;node.value=value;return node;}
|
|
63
|
-
function destinations(){const select=el('write'), value=select.value;select.replaceChildren(option('Use default destination',''),option('Local data folder','data'));for(const row of el('backends').children){const id=row.querySelector('[data-key=id]').value.trim();if(id){const label=row.querySelector('[data-key=label]').value.trim();select.add(option(label?label+' ('+id+')':id,id));}}if([...select.options].some(option=>option.value===value))select.value=value;}
|
|
64
|
-
function addBackend(backend={}){const row=document.createElement('div');row.className='backend';if(backend.write!==undefined)row.dataset.write=String(backend.write);const head=document.createElement('div');head.className='backend-head';const title=document.createElement('strong');title.textContent='Storage location';const remove=document.createElement('button');remove.type='button';remove.className='remove';remove.textContent='Remove';remove.onclick=()=>{row.remove();destinations();el('no-backends').hidden=el('backends').children.length>0;sync();};head.append(title,remove);row.append(head);const group=document.createElement('div');group.className='grid';for(const [key,label,placeholder] of [['id','Storage ID','archive'],['label','Display name (optional)','Archive'],['url','Location','s3://reports/atlas']]){const field=document.createElement('div');field.className='field';const input=document.createElement('input');input.type='text';input.dataset.key=key;input.value=backend[key]??'';input.placeholder=placeholder;input.id='backend-'+key+'-'+Math.random().toString(36).slice(2);input.required=key!=='label';if(key==='id')input.pattern='[a-z0-9-]+';const caption=document.createElement('label');caption.htmlFor=input.id;caption.textContent=label;field.append(caption,input);if(key==='url')row.append(field);else group.append(field);}row.insertBefore(group,row.children[1]??null);el('backends').append(row);el('no-backends').hidden=true;destinations();}
|
|
65
|
-
function fill(settings){draft=copy(settings);for(const [id,path]of fields){const value=at(settings,path);el(id).value=Array.isArray(value)?value.join(', '):value===undefined?'':String(value);}el('backends').replaceChildren();for(const backend of settings.backends??[])addBackend(backend);el('no-backends').hidden=el('backends').children.length>0;destinations();if(settings.write&&!Array.from(el('write').options).some(option=>option.value===settings.write))el('write').add(option(settings.write,settings.write));el('write').value=settings.write??'';}
|
|
66
|
-
function formSettings(){const value=copy(draft);for(const [id,path]of fields){const input=el(id),text=input.value.trim();set(value,path,text===''?undefined:id==='branches'?text.split(',').map(item=>item.trim()).filter(Boolean):input.type==='number'?Number(text):id==='log'||id==='ignoreModified'?text==='true':text);}for(const key of ['include','limits'])if(value[key]&&Object.keys(value[key]).length===0)delete value[key];const backends=[...el('backends').children].map(row=>{const backend={};for(const input of row.querySelectorAll('[data-key]')){const text=input.value.trim();if(text||input.dataset.key!=='label')backend[input.dataset.key]=text;}if(row.dataset.write!==undefined)backend.write=row.dataset.write==='true';return backend;});if(backends.length||draft.backends!==undefined)value.backends=backends;else delete value.backends;if(el('write').value)value.write=el('write').value;else delete value.write;return value;}
|
|
67
|
-
function sync(){el('settings').value=JSON.stringify(formSettings(),null,2);}
|
|
68
|
-
function currentValues(){for(const [id,path]of fields){const value=at(active,path);el(id+'-current').textContent=value===undefined?'':'Current: '+(Array.isArray(value)?value.join(', ')||'All branches':String(value));if(el(id).tagName==='INPUT'&&value!==undefined&&el(id).value==='')el(id).placeholder=String(value);}el('write-current').textContent='Current: '+(active.write??'data');}
|
|
69
|
-
function controls(){el('load').setAttribute('aria-busy',String(busy));el('load').disabled=busy;el('load').textContent=busy?'Working…':revision?'Reload settings':'Load settings';el('save').disabled=busy||!revision||stale;el('fields').disabled=busy||!revision;el('settings').disabled=busy||!revision;}
|
|
70
|
-
async function request(method){const token=el('token').value;const headers=token?{authorization:'Bearer '+token}:{};let body;if(method==='PUT'){const parsed=JSON.parse(el('settings').value);if(!parsed||typeof parsed!=='object'||Array.isArray(parsed))throw Error('Settings must be a JSON object.');headers['content-type']='application/json';headers['if-match']='"'+revision+'"';body=JSON.stringify(parsed);}const response=await fetch('/api/settings',{method,headers,body,cache:'no-store'});const data=await response.json();if(!response.ok){if(response.status===409||response.status===428)stale=true;throw Error(data.detail||data.error||'Request failed ('+response.status+')');}revision=data.revision;stale=false;active=data.active;fill(data.settings);el('settings').value=JSON.stringify(data.settings,null,2);el('active').textContent=JSON.stringify(data.active,null,2);currentValues();el('configuration').hidden=false;el('connected').hidden=false;message(method==='PUT'||data.restartRequired?'Saved. Restart Atlas to apply.':'Settings loaded. Edit saved values below; blank fields use defaults after restart.',method==='PUT'||data.restartRequired?'success':'info');}
|
|
71
|
-
for(const [id,method]of [['load','GET'],['save','PUT']])el(id).onclick=async()=>{if(busy||(method==='PUT'&&(!revision||stale)))return;if(method==='PUT'&&!el('json-editor').open&&!el('form').reportValidity()){message('Check the highlighted fields before saving.','error');return;}if(method==='GET'){revision=undefined;el('connected').hidden=true;}busy=true;controls();try{await request(method);}catch(error){message(error instanceof SyntaxError?'The JSON configuration is invalid. Fix it before saving.':error.message,'error');}finally{busy=false;controls();}};
|
|
72
|
-
el('form').onsubmit=event=>{event.preventDefault();if(!el('save').disabled)el('save').click();};
|
|
73
|
-
el('fields').addEventListener('input',event=>{if(event.target===el('settings'))return;if(event.target.dataset.key==='id'||event.target.dataset.key==='label')destinations();sync();});
|
|
74
|
-
el('fields').addEventListener('change',event=>{if(event.target===el('settings'))return;if(event.target===el('write'))for(const row of el('backends').children)delete row.dataset.write;sync();});
|
|
75
|
-
el('settings').addEventListener('input',()=>{try{const settings=JSON.parse(el('settings').value);if(settings&&typeof settings==='object'&&!Array.isArray(settings))fill(settings);}catch{}});
|
|
76
|
-
el('add-backend').onclick=()=>{addBackend();sync();el('backends').lastElementChild.querySelector('input').focus();};
|
|
77
|
-
const project=new URL(location.href).searchParams.get('project');if(project&&/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(project)&&project.length<=64)for(const link of document.querySelectorAll('.report-link'))link.href='/?project='+encodeURIComponent(project)+'#/';
|
|
78
|
-
</script></body></html>`;
|
|
7
|
+
/** Document shell for the shared React settings UI. No configuration or credentials are embedded. */
|
|
8
|
+
export const SETTINGS_PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Settings · Atlas</title><!-- atlas-admin-styles --></head><body><div id="root"><h1>Atlas server settings</h1><p>Loading settings interface…</p></div><!-- atlas-admin-script --></body></html>`;
|
|
79
9
|
let page;
|
|
80
10
|
/**
|
|
81
|
-
* Render
|
|
82
|
-
*
|
|
11
|
+
* Render the settings UI with the same provider and theme as the viewer. Bundle once on first request,
|
|
12
|
+
* without a prebuilt viewer, CDN, configuration data, or import-time work. A failed build can be retried.
|
|
83
13
|
* @example
|
|
84
14
|
* const html = await renderSettingsPage();
|
|
85
15
|
*/
|
|
86
16
|
export function renderSettingsPage() {
|
|
87
17
|
page ??= (async () => {
|
|
88
18
|
const root = visualizerRoot();
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
19
|
+
if (!root)
|
|
20
|
+
throw new Error('The Atlas client is not installed alongside the server.');
|
|
21
|
+
const result = await Bun.build({ entrypoints: [path.join(root, 'src/admin/main.tsx')], target: 'browser', minify: true });
|
|
22
|
+
if (!result.success)
|
|
23
|
+
throw new Error(`The settings interface could not be built: ${result.logs.join('\n')}`);
|
|
24
|
+
const js = await Promise.all(result.outputs.filter((output) => output.path.endsWith('.js')).map((output) => output.text()));
|
|
25
|
+
const css = await Promise.all(result.outputs.filter((output) => output.path.endsWith('.css')).map((output) => output.text()));
|
|
26
|
+
return SETTINGS_PAGE.replace('<!-- atlas-admin-styles -->', () => `<style>${css.join('\n').replaceAll('</style', '<\\/style')}</style>`).replace('<!-- atlas-admin-script -->', () => `<script type="module">${js.join('\n').replaceAll('</script', '<\\/script')}</script>`);
|
|
27
|
+
})().catch((error) => {
|
|
28
|
+
page = undefined;
|
|
29
|
+
throw error;
|
|
30
|
+
});
|
|
92
31
|
return page;
|
|
93
32
|
}
|
|
@@ -504,6 +504,40 @@ Scoped to one project. The same project may be selected on the unprefixed API us
|
|
|
504
504
|
|
|
505
505
|
Responses: `200` The catalog.; `304` Unchanged since the `If-None-Match` ETag.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `503` The server is still reading its backends. Retry after the number of seconds in `Retry-After`.
|
|
506
506
|
|
|
507
|
+
### Why specs, domains and counts disagree
|
|
508
|
+
|
|
509
|
+
`GET /api/reports/tests/health` · role: read
|
|
510
|
+
|
|
511
|
+
Diagnoses the report's metadata, so a domain filter or a coverage percentage can be trusted or fixed at its source. It reads; it never changes a status, a slot or a winner.
|
|
512
|
+
|
|
513
|
+
`counts` keeps the denominators apart: spec files, defined specs, specs with an executed test (and those without: the defined coverage gaps), tests, outlines, examples and per-environment observations. `reconciliation` walks from the Specs count to the Tests count. `findings` name ambiguous and unmatched spec paths, identical or same-titled specs, a domain shared by nearly every spec, tests with no spec, checks that fall back to their title as identity and root mappings that matched nothing.
|
|
514
|
+
|
|
515
|
+
Two paths are one logical spec only when a `root` mapping relates them (`from>to`: paths under `from` are the same specs as paths under `to`; an empty `from` matches every path). Without one, similar paths are reported as ambiguous and never merged. A check's label `outline` names the outline its example belongs to. Send `format=markdown` for Markdown.
|
|
516
|
+
|
|
517
|
+
- `root` (query): A root mapping `from>to`, URL-encoded. Repeatable or comma-separated.
|
|
518
|
+
- `format` (query): `markdown` for a Markdown report instead of JSON.
|
|
519
|
+
- `project` (query): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
|
|
520
|
+
|
|
521
|
+
Responses: `200` The diagnostics.; `304` Unchanged since the `If-None-Match` ETag.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `503` The server is still reading its backends. Retry after the number of seconds in `Retry-After`.
|
|
522
|
+
|
|
523
|
+
### Why specs, domains and counts disagree
|
|
524
|
+
|
|
525
|
+
`GET /api/projects/{project}/reports/tests/health` · role: read
|
|
526
|
+
|
|
527
|
+
Diagnoses the report's metadata, so a domain filter or a coverage percentage can be trusted or fixed at its source. It reads; it never changes a status, a slot or a winner.
|
|
528
|
+
|
|
529
|
+
`counts` keeps the denominators apart: spec files, defined specs, specs with an executed test (and those without: the defined coverage gaps), tests, outlines, examples and per-environment observations. `reconciliation` walks from the Specs count to the Tests count. `findings` name ambiguous and unmatched spec paths, identical or same-titled specs, a domain shared by nearly every spec, tests with no spec, checks that fall back to their title as identity and root mappings that matched nothing.
|
|
530
|
+
|
|
531
|
+
Two paths are one logical spec only when a `root` mapping relates them (`from>to`: paths under `from` are the same specs as paths under `to`; an empty `from` matches every path). Without one, similar paths are reported as ambiguous and never merged. A check's label `outline` names the outline its example belongs to. Send `format=markdown` for Markdown.
|
|
532
|
+
|
|
533
|
+
Scoped to one project. The same project may be selected on the unprefixed API using ?project= or X-Atlas-Project. Unknown projects return 404 on reads and are created by an authenticated upload.
|
|
534
|
+
|
|
535
|
+
- `project` (path, required): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
|
|
536
|
+
- `root` (query): A root mapping `from>to`, URL-encoded. Repeatable or comma-separated.
|
|
537
|
+
- `format` (query): `markdown` for a Markdown report instead of JSON.
|
|
538
|
+
|
|
539
|
+
Responses: `200` The diagnostics.; `304` Unchanged since the `If-None-Match` ETag.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `503` The server is still reading its backends. Retry after the number of seconds in `Retry-After`.
|
|
540
|
+
|
|
507
541
|
### One test and every result recorded for it
|
|
508
542
|
|
|
509
543
|
`GET /api/reports/tests/history` · role: read
|
|
@@ -330,6 +330,227 @@ export async function readBundleIndex(
|
|
|
330
330
|
|
|
331
331
|
Reads and parses the bundle index at `key`; an index that is not there is an empty bundle (`{}`). Invalid JSON throws.
|
|
332
332
|
|
|
333
|
+
### Catalog health: spec identity, domains and denominators
|
|
334
|
+
|
|
335
|
+
Source: `packages/reports/src/catalog-health.ts`. Import: `@crvouga/atlas/reports/catalog-health`
|
|
336
|
+
|
|
337
|
+
Explains why the Specs and the Tests of a report may disagree, and what a producer or an operator can change about it.
|
|
338
|
+
`catalogHealth` reads a `CatalogModel` (the tests of a report and the specs it is laid over) and writes a
|
|
339
|
+
`CatalogHealth` document:
|
|
340
|
+
|
|
341
|
+
- **Spec identity.** The same promise can reach Atlas under two paths (`checkout/cart.feature` published,
|
|
342
|
+
`specs/checkout/cart.feature` recorded). Atlas joins two paths only when a declared root mapping (`RootMapping`)
|
|
343
|
+
relates them, and then shows one logical spec with its original paths. Without a mapping it never merges by a
|
|
344
|
+
similar title or file name: it reports the pair as ambiguous and names the candidates.
|
|
345
|
+
- **Domains.** A domain is whatever the spec's metadata says. When almost every spec shares one value, often the root
|
|
346
|
+
folder, the document says so; it never replaces explicit metadata.
|
|
347
|
+
- **Denominators.** Defined specs, specs with an executed test, tests (check identities), examples and per-environment
|
|
348
|
+
observations are different numbers. `reconciliation` walks from the number the Specs view shows to the number the
|
|
349
|
+
Tests view groups by, step by step, and `counts.scenarios` tells one outline, its examples and their observations apart.
|
|
350
|
+
- **Fallback identity.** A check whose id is its title has no stable id; renaming it starts a new check. Counted, not
|
|
351
|
+
guessed at.
|
|
352
|
+
|
|
353
|
+
It only reads: it never changes a status, a slot or a winner, so it cannot disagree with the report it describes.
|
|
354
|
+
Optional label `outline` on a check names the scenario outline its example belongs to.
|
|
355
|
+
|
|
356
|
+
See docs/server.md#reading-the-report-as-an-agent
|
|
357
|
+
|
|
358
|
+
#### `CATALOG_HEALTH_VERSION`
|
|
359
|
+
|
|
360
|
+
```ts
|
|
361
|
+
export const CATALOG_HEALTH_VERSION = …
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
The version of the `CatalogHealth` document. Only optional fields are added within it.
|
|
365
|
+
|
|
366
|
+
#### `HEALTH_ITEM_LIMIT`
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
export const HEALTH_ITEM_LIMIT = …
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
The most paths listed in one finding; its `count` is always the whole.
|
|
373
|
+
|
|
374
|
+
#### `RootMapping`
|
|
375
|
+
|
|
376
|
+
```ts
|
|
377
|
+
export type RootMapping = { readonly from: string; readonly to: string };
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Declares that paths under `from` are the same specs as paths under `to`, e.g. `{ from: '', to: 'specs/' }`. Empty `from` matches every path.
|
|
381
|
+
|
|
382
|
+
#### `HealthItem`
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
export type HealthItem = {
|
|
386
|
+
readonly path: string;
|
|
387
|
+
/** Other paths it could be the same promise as, when any; Atlas has not merged them. */
|
|
388
|
+
readonly candidates?: readonly { readonly path: string; readonly reason: string }[];
|
|
389
|
+
};
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
One thing worth a look.
|
|
393
|
+
|
|
394
|
+
#### `HealthCode`
|
|
395
|
+
|
|
396
|
+
```ts
|
|
397
|
+
export type HealthCode =
|
|
398
|
+
| 'ambiguous-spec-path'
|
|
399
|
+
| 'unmatched-spec-path'
|
|
400
|
+
| 'duplicate-spec-content'
|
|
401
|
+
| 'duplicate-spec-title'
|
|
402
|
+
| 'generic-domain'
|
|
403
|
+
| 'tests-without-spec'
|
|
404
|
+
| 'no-spec-definitions'
|
|
405
|
+
| 'fallback-identity'
|
|
406
|
+
| 'unused-root-mapping'
|
|
407
|
+
| 'spec-without-execution'
|
|
408
|
+
| 'historical-only';
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
Codes of `HealthFinding.code`. The set grows; a reader treats an unknown code as informational.
|
|
412
|
+
|
|
413
|
+
#### `HealthFinding`
|
|
414
|
+
|
|
415
|
+
```ts
|
|
416
|
+
export type HealthFinding = {
|
|
417
|
+
readonly code: HealthCode;
|
|
418
|
+
/** `warning` is something that makes a count or a filter misleading; `info` explains a number. */
|
|
419
|
+
readonly severity: 'warning' | 'info';
|
|
420
|
+
readonly message: string;
|
|
421
|
+
/** How many paths, specs or tests it affects. */
|
|
422
|
+
readonly count: number;
|
|
423
|
+
/** Up to `HEALTH_ITEM_LIMIT` of them. */
|
|
424
|
+
readonly items: readonly HealthItem[];
|
|
425
|
+
};
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
A finding: what is inconsistent, how many it affects, and what to change.
|
|
429
|
+
|
|
430
|
+
#### `SpecAlias`
|
|
431
|
+
|
|
432
|
+
```ts
|
|
433
|
+
export type SpecAlias = {
|
|
434
|
+
/** The logical spec's path after mapping. */
|
|
435
|
+
readonly path: string;
|
|
436
|
+
/** Every original path it was seen under, sorted. */
|
|
437
|
+
readonly sources: readonly string[];
|
|
438
|
+
};
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
A spec that reached Atlas under more than one path and was joined by a declared root mapping.
|
|
442
|
+
|
|
443
|
+
#### `ReconciliationStep`
|
|
444
|
+
|
|
445
|
+
```ts
|
|
446
|
+
export type ReconciliationStep = {
|
|
447
|
+
readonly code: 'merged-by-mapping' | 'defined-without-results' | 'results-without-definition';
|
|
448
|
+
readonly label: string;
|
|
449
|
+
/** The change to the running total: negative removes specs, positive adds them. */
|
|
450
|
+
readonly delta: number;
|
|
451
|
+
};
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
One step from the Specs count to the Tests count.
|
|
455
|
+
|
|
456
|
+
#### `CatalogHealth`
|
|
457
|
+
|
|
458
|
+
```ts
|
|
459
|
+
export type CatalogHealth = {
|
|
460
|
+
readonly schemaVersion: typeof CATALOG_HEALTH_VERSION;
|
|
461
|
+
readonly version: string;
|
|
462
|
+
readonly generatedAtMs: number;
|
|
463
|
+
/** True when there is no warning. Informational findings do not count. */
|
|
464
|
+
readonly ok: boolean;
|
|
465
|
+
readonly counts: {
|
|
466
|
+
readonly specs: {
|
|
467
|
+
/** Spec files the report is laid over (before mapping). */
|
|
468
|
+
readonly files: number;
|
|
469
|
+
/** Logical specs among them: files joined by a root mapping count once. */
|
|
470
|
+
readonly defined: number;
|
|
471
|
+
/** Logical specs, defined or not, with at least one test. */
|
|
472
|
+
readonly withTests: number;
|
|
473
|
+
/** Defined logical specs with a test that has a passed or failed result. They are the denominator of execution coverage. */
|
|
474
|
+
readonly executed: number;
|
|
475
|
+
/** Defined logical specs with no test that has a passed or failed result: the defined coverage gaps. */
|
|
476
|
+
readonly withoutExecution: number;
|
|
477
|
+
};
|
|
478
|
+
readonly tests: {
|
|
479
|
+
readonly total: number;
|
|
480
|
+
readonly withSpec: number;
|
|
481
|
+
readonly withoutSpec: number;
|
|
482
|
+
/** Tests whose current status is `passed`. A definition with no test never adds to it. */
|
|
483
|
+
readonly passed: number;
|
|
484
|
+
/** Tests the history holds but the report has no current result for. */
|
|
485
|
+
readonly historicalOnly: number;
|
|
486
|
+
};
|
|
487
|
+
readonly scenarios: {
|
|
488
|
+
/** Outlines: tests sharing the label `outline` count once; a test without it is its own outline. */
|
|
489
|
+
readonly outlines: number;
|
|
490
|
+
/** Concrete examples: tests (a check identity), however many environments ran them. */
|
|
491
|
+
readonly examples: number;
|
|
492
|
+
/** Current results per producer, environment and parameter set. */
|
|
493
|
+
readonly observations: number;
|
|
494
|
+
/** Observations that passed or failed. */
|
|
495
|
+
readonly executedObservations: number;
|
|
496
|
+
readonly passedObservations: number;
|
|
497
|
+
};
|
|
498
|
+
};
|
|
499
|
+
…
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
The `GET /api/reports/tests/health` document.
|
|
503
|
+
|
|
504
|
+
#### `parseRootMapping`
|
|
505
|
+
|
|
506
|
+
```ts
|
|
507
|
+
export function parseRootMapping(text: string): RootMapping | null
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
Parses `from>to` (as `?root=` gives it) into a mapping. Returns null when there is no `>`. Whitespace around each side is trimmed.
|
|
511
|
+
|
|
512
|
+
#### `parseRootMappings`
|
|
513
|
+
|
|
514
|
+
```ts
|
|
515
|
+
export function parseRootMappings(params: URLSearchParams): RootMapping[]
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
Reads the repeatable `root` search parameter: each value `from>to`, several may be comma-separated. Values without `>` are ignored.
|
|
519
|
+
|
|
520
|
+
#### `canonicalSpecPath`
|
|
521
|
+
|
|
522
|
+
```ts
|
|
523
|
+
export function canonicalSpecPath(path: string, roots: readonly RootMapping[]): { readonly path: string; readonly mapping: RootMapping | null }
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
The logical path of a spec path: the path Atlas already normalises (`specPath`), after the first mapping whose `from`
|
|
527
|
+
is a prefix of it (the longest `from` wins). A path already under `to` is left alone. Returns the path and the mapping
|
|
528
|
+
that changed it, if any.
|
|
529
|
+
|
|
530
|
+
#### `catalogHealth`
|
|
531
|
+
|
|
532
|
+
```ts
|
|
533
|
+
export function catalogHealth(model: CatalogModel, roots: readonly RootMapping[] = []): CatalogHealth
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
Diagnoses a report's spec identity, domains and counts. `roots` are the declared path-root mappings. Pure: the same
|
|
537
|
+
model and mappings give the same document, and nothing in it changes what the report says passed.
|
|
538
|
+
|
|
539
|
+
**Example**
|
|
540
|
+
|
|
541
|
+
```ts
|
|
542
|
+
const health = catalogHealth(model, [{ from: '', to: 'specs/' }]);
|
|
543
|
+
if (!health.ok) console.log(healthMarkdown(health));
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
#### `healthMarkdown`
|
|
547
|
+
|
|
548
|
+
```ts
|
|
549
|
+
export function healthMarkdown(health: CatalogHealth): string
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
The health document as Markdown, for an agent or a terminal.
|
|
553
|
+
|
|
333
554
|
### Catalog search, filter, group and sort
|
|
334
555
|
|
|
335
556
|
Source: `packages/reports/src/catalog.ts`. Import: `@crvouga/atlas/reports/catalog`
|
|
@@ -7564,6 +7785,7 @@ const catalog = testCatalog(catalogModel(layers, { history }));
|
|
|
7564
7785
|
|
|
7565
7786
|
- `get rev(): number { return this.#rev; }` — Changes each time a layer is added.
|
|
7566
7787
|
- `get specs(): RecordedSpecs { return this.#specs; }` — The specs the added layers recorded.
|
|
7788
|
+
- `tests(): readonly { readonly key: string; readonly id: string; readonly specPath: string | null }[]` — Every test the history holds a result for (keyed as `TestSummary.key`), including tests the report no longer has a current result for.
|
|
7567
7789
|
- `get layers(): number { return this.#seen.size; }` — How many layers were added.
|
|
7568
7790
|
- `add(layer: LayerHeader, rows: readonly LayerRow[], specs: readonly SpecEntry[] = [], backend = layer.backend): void` — Takes in one layer: its header, its rows, the specs it recorded and the backend it was read from. A layer id already added is ignored.
|
|
7569
7791
|
- `of(key: string): readonly TestResultEntry[]` — The recorded results of one test (by `TestSummary.key`), newest first. Empty when none.
|
|
@@ -7616,6 +7838,8 @@ export type CatalogModel = {
|
|
|
7616
7838
|
readonly facets: CatalogFacets;
|
|
7617
7839
|
readonly haystacks: ReadonlyMap<string, string>;
|
|
7618
7840
|
readonly history: TestHistory | null;
|
|
7841
|
+
/** The specs the report is laid over, sorted by path: the given ones, else those the layers recorded. What `catalogHealth` calls defined. Absent on a model built from a server's catalog document. */
|
|
7842
|
+
readonly specs?: readonly SpecEntry[];
|
|
7619
7843
|
};
|
|
7620
7844
|
```
|
|
7621
7845
|
|
package/docs/reference/schema.md
CHANGED
|
@@ -234,6 +234,16 @@ export const TestCatalogSchema = …
|
|
|
234
234
|
`GET /api/reports/tests`: every test of the report with a summary, insights and facets. The summary and facets cover
|
|
235
235
|
every test; `tests` is the page of those the query matched.
|
|
236
236
|
|
|
237
|
+
#### `CatalogHealthSchema`
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
export const CatalogHealthSchema = …
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
`GET /api/reports/tests/health`: why the report's Specs, domains and counts may disagree. Counts keep the denominators
|
|
244
|
+
apart (spec files, defined specs, specs with an executed test, tests, outlines, examples, observations), `reconciliation`
|
|
245
|
+
walks from one to another, and `findings` name what to fix. It reads the report; it never changes what passed.
|
|
246
|
+
|
|
237
247
|
#### `TestHistorySchema`
|
|
238
248
|
|
|
239
249
|
```ts
|
package/docs/reference/server.md
CHANGED
|
@@ -607,7 +607,7 @@ Wrap the single-project protocol in project selection. Auth, upload validation a
|
|
|
607
607
|
Source: `packages/server/src/settings-page.ts`
|
|
608
608
|
|
|
609
609
|
@module Settings page
|
|
610
|
-
|
|
610
|
+
The administrator's React and Mantine entry point, bundled lazily for API-only servers too.
|
|
611
611
|
Credentials remain in page memory; the settings API owns authorization and validation.
|
|
612
612
|
|
|
613
613
|
#### `SETTINGS_PAGE`
|
|
@@ -616,7 +616,7 @@ Credentials remain in page memory; the settings API owns authorization and valid
|
|
|
616
616
|
export const SETTINGS_PAGE = …
|
|
617
617
|
```
|
|
618
618
|
|
|
619
|
-
|
|
619
|
+
Document shell for the shared React settings UI. No configuration or credentials are embedded.
|
|
620
620
|
|
|
621
621
|
#### `renderSettingsPage`
|
|
622
622
|
|
|
@@ -624,8 +624,8 @@ The settings form and its browser controller, also usable without a built client
|
|
|
624
624
|
export function renderSettingsPage(): Promise<string>
|
|
625
625
|
```
|
|
626
626
|
|
|
627
|
-
Render
|
|
628
|
-
|
|
627
|
+
Render the settings UI with the same provider and theme as the viewer. Bundle once on first request,
|
|
628
|
+
without a prebuilt viewer, CDN, configuration data, or import-time work. A failed build can be retried.
|
|
629
629
|
|
|
630
630
|
**Example**
|
|
631
631
|
|