@crvouga/atlas 0.5.0 → 0.6.1

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.
Files changed (148) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +51 -16
  3. package/dist/core/src/cli.d.ts +1 -1
  4. package/dist/core/src/cli.js +8 -3
  5. package/dist/core/src/config.d.ts +7 -0
  6. package/dist/core/src/config.js +48 -14
  7. package/dist/core/src/run/execute.d.ts +6 -0
  8. package/dist/core/src/run/execute.js +4 -2
  9. package/dist/reports/src/doctor.d.ts +1 -1
  10. package/dist/reports/src/doctor.js +3 -1
  11. package/dist/reports/src/http-store.d.ts +4 -0
  12. package/dist/reports/src/http-store.js +6 -3
  13. package/dist/reports/src/init.js +24 -16
  14. package/dist/reports/src/live-reporter.d.ts +34 -0
  15. package/dist/reports/src/live-reporter.js +122 -0
  16. package/dist/reports/src/package-managers.d.ts +35 -0
  17. package/dist/reports/src/package-managers.js +23 -0
  18. package/dist/reports/src/report-cli.d.ts +1 -1
  19. package/dist/reports/src/report-cli.js +3 -2
  20. package/dist/reports/src/store-url.js +1 -1
  21. package/dist/runners/src/codecept.d.ts +14 -0
  22. package/dist/runners/src/codecept.js +16 -0
  23. package/dist/runners/src/jest.d.ts +50 -0
  24. package/dist/runners/src/jest.js +52 -0
  25. package/dist/runners/src/mocha.d.ts +40 -0
  26. package/dist/runners/src/mocha.js +39 -0
  27. package/dist/runners/src/node-test.d.ts +28 -0
  28. package/dist/runners/src/node-test.js +44 -0
  29. package/dist/runners/src/publish.d.ts +30 -0
  30. package/dist/runners/src/publish.js +68 -0
  31. package/dist/schema/src/api.d.ts +1 -0
  32. package/dist/schema/src/api.js +1 -1
  33. package/dist/schema/src/cli-results.d.ts +233 -2
  34. package/dist/schema/src/cli-results.js +14 -0
  35. package/dist/schema/src/index.d.ts +2 -0
  36. package/dist/schema/src/index.js +2 -0
  37. package/dist/schema/src/live-run.d.ts +123 -0
  38. package/dist/schema/src/live-run.js +30 -0
  39. package/dist/schema/src/manifest.d.ts +2 -2
  40. package/dist/schema/src/project.d.ts +20 -0
  41. package/dist/schema/src/project.js +10 -0
  42. package/dist/schema/src/report-sources.d.ts +1 -0
  43. package/dist/schema/src/report-sources.js +2 -1
  44. package/dist/schema/src/runs-index.d.ts +1 -1
  45. package/dist/schema/src/server-config.d.ts +84 -0
  46. package/dist/schema/src/server-config.js +12 -0
  47. package/dist/server/src/app.d.ts +10 -0
  48. package/dist/server/src/app.js +148 -10
  49. package/dist/server/src/config.d.ts +1 -1
  50. package/dist/server/src/config.js +20 -3
  51. package/dist/server/src/dev.d.ts +3 -3
  52. package/dist/server/src/dev.js +22 -13
  53. package/dist/server/src/events.d.ts +3 -3
  54. package/dist/server/src/events.js +1 -1
  55. package/dist/server/src/index.d.ts +2 -0
  56. package/dist/server/src/index.js +2 -0
  57. package/dist/server/src/live-runs.d.ts +52 -0
  58. package/dist/server/src/live-runs.js +229 -0
  59. package/dist/server/src/llms.js +17 -8
  60. package/dist/server/src/main.d.ts +1 -1
  61. package/dist/server/src/main.js +7 -5
  62. package/dist/server/src/openapi.d.ts +2 -5
  63. package/dist/server/src/openapi.js +71 -7
  64. package/dist/server/src/projects.d.ts +3 -0
  65. package/dist/server/src/projects.js +154 -0
  66. package/dist/server/src/settings-page.d.ts +9 -0
  67. package/dist/server/src/settings-page.js +93 -0
  68. package/dist/server/src/settings.d.ts +23 -0
  69. package/dist/server/src/settings.js +72 -0
  70. package/dist/server/src/site.d.ts +8 -6
  71. package/dist/server/src/site.js +45 -10
  72. package/dist/server/src/store-api.js +43 -22
  73. package/docs/agents.md +166 -15
  74. package/docs/deploying.md +24 -20
  75. package/docs/dogfooding.md +7 -3
  76. package/docs/extending.md +3 -3
  77. package/docs/integrating.md +19 -11
  78. package/docs/quickstart.md +81 -0
  79. package/docs/reference/cli.md +9 -7
  80. package/docs/reference/core.md +6 -0
  81. package/docs/reference/http-api.md +498 -2
  82. package/docs/reference/reports.md +84 -1
  83. package/docs/reference/runners.md +169 -0
  84. package/docs/reference/schema.md +120 -0
  85. package/docs/reference/server.md +154 -14
  86. package/docs/report-backends.md +8 -8
  87. package/docs/report-contract.md +7 -7
  88. package/docs/reporting-issues.md +3 -3
  89. package/docs/runners.md +69 -0
  90. package/docs/server.md +155 -25
  91. package/docs/statecharts.md +22 -20
  92. package/llms-full.txt +1079 -126
  93. package/llms.txt +7 -4
  94. package/package.json +33 -9
  95. package/packages/core/README.md +3 -1
  96. package/packages/runners/README.md +6 -0
  97. package/packages/runners/compat/codecept.cjs +2 -0
  98. package/packages/runners/compat/jest.cjs +2 -0
  99. package/packages/runners/compat/mocha.cjs +2 -0
  100. package/packages/schema/schemas/atlas-change.schema.json +4 -0
  101. package/packages/schema/schemas/hello-event.schema.json +4 -0
  102. package/packages/schema/schemas/live-run-start.schema.json +42 -0
  103. package/packages/schema/schemas/live-run-update.schema.json +93 -0
  104. package/packages/schema/schemas/live-run.schema.json +225 -0
  105. package/packages/schema/schemas/project-slug.schema.json +8 -0
  106. package/packages/schema/schemas/project.schema.json +22 -0
  107. package/packages/schema/schemas/projects.schema.json +33 -0
  108. package/packages/schema/schemas/server-config.schema.json +14 -0
  109. package/packages/schema/schemas/server-editable-settings.schema.json +132 -0
  110. package/packages/schema/schemas/server-settings-snapshot.schema.json +285 -0
  111. package/packages/server/openapi.json +5101 -779
  112. package/packages/visualizer/README.md +13 -13
  113. package/packages/visualizer/bin/atlas-client.mjs +176 -0
  114. package/packages/visualizer/bin/atlas-visualizer.mjs +2 -175
  115. package/packages/visualizer/src/app/AppShell.tsx +26 -17
  116. package/packages/visualizer/src/app/report-sources.tsx +2 -1
  117. package/packages/visualizer/src/components/ActivityIndicator.module.css +4 -0
  118. package/packages/visualizer/src/components/ActivityIndicator.tsx +6 -0
  119. package/packages/visualizer/src/components/Header.module.css +101 -227
  120. package/packages/visualizer/src/components/Header.tsx +79 -41
  121. package/packages/visualizer/src/components/ProjectPicker.tsx +64 -0
  122. package/packages/visualizer/src/data/adapters/report-backend.test.ts +3 -2
  123. package/packages/visualizer/src/data/adapters/report-backend.ts +1 -1
  124. package/packages/visualizer/src/data/queries.ts +14 -7
  125. package/packages/visualizer/src/evidence/api.ts +2 -1
  126. package/packages/visualizer/src/evidence/check-detail.tsx +19 -4
  127. package/packages/visualizer/src/evidence/document-page.tsx +6 -2
  128. package/packages/visualizer/src/evidence/evidence-context.tsx +17 -12
  129. package/packages/visualizer/src/evidence/evidence.module.css +11 -1
  130. package/packages/visualizer/src/evidence/feature-page.tsx +7 -3
  131. package/packages/visualizer/src/evidence/model-page.tsx +6 -2
  132. package/packages/visualizer/src/evidence/project-home.tsx +45 -0
  133. package/packages/visualizer/src/evidence/recording-page.tsx +4 -1
  134. package/packages/visualizer/src/evidence/spec-index.tsx +2 -1
  135. package/packages/visualizer/src/evidence/test-filter.tsx +21 -0
  136. package/packages/visualizer/src/live/live-query.ts +21 -0
  137. package/packages/visualizer/src/live/live-runs.tsx +69 -0
  138. package/packages/visualizer/src/live/live-stream.ts +30 -9
  139. package/packages/visualizer/src/live/report-progress.ts +62 -0
  140. package/packages/visualizer/src/live/test-progress.tsx +34 -0
  141. package/packages/visualizer/src/runtime.ts +17 -1
  142. package/packages/visualizer/src/state/ui-store.ts +3 -2
  143. package/packages/visualizer/src/views/ChartPage.tsx +2 -0
  144. package/packages/visualizer/src/views/ChartTests.tsx +43 -0
  145. package/packages/visualizer/src/views/SidePanel.tsx +2 -0
  146. package/packages/visualizer/vite/atlas-files.test.ts +4 -4
  147. package/packages/visualizer/vite/atlas-files.ts +1 -5
  148. package/packages/visualizer/vite/atlas-server-proxy.ts +2 -1
@@ -13,10 +13,11 @@
13
13
  * operations and new events. See AGENTS.md ("The server is a living application").
14
14
  */
15
15
  import { z } from 'zod/v4';
16
+ import { ProjectsSchema } from '../../schema/src/project.js';
16
17
  import { EffectiveDeltaSchema, EffectiveEvidenceSchema, EvidencePageSchema, HealthSchema, LayerRowsSchema, CheckDetailSchema, OutlineSchema, ProblemSchema, ReadySchema, ServerInfoSchema, StoreListingSchema, StoreWriteResultSchema, ChecksResultSchema, HelloEventSchema, ReportEventSchema, PingEventSchema } from '../../schema/src/api.js';
17
- import { AtlasChangeSchema, PublishChecksSchema, ReportSourcesSchema, ReportStatusSchema, RunsIndexSchema, SpecIndexSchema } from '../../schema/src/index.js';
18
+ import { LiveRunSchema, LiveRunStartSchema, LiveRunUpdateSchema, ServerEditableSettingsSchema, ServerSettingsSnapshotSchema, AtlasChangeSchema, PublishChecksSchema, ReportSourcesSchema, ReportStatusSchema, RunsIndexSchema, SpecIndexSchema } from '../../schema/src/index.js';
18
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`. */
19
- export const API_VERSION = '1.0.0';
20
+ export const API_VERSION = '1.3.0';
20
21
  /** The JSON Schema of a Zod schema, as OpenAPI 3.1 embeds it. `output` for what the server sends, `input` for what it accepts. */
21
22
  function schemaOf(schema, io = 'output') {
22
23
  const { $schema: _dialect, ...json } = z.toJSONSchema(schema, { target: 'draft-2020-12', unrepresentable: 'any', io });
@@ -27,13 +28,15 @@ function schemaOf(schema, io = 'output') {
27
28
  * the shape the server sends, `input` the shape it accepts.
28
29
  */
29
30
  const COMPONENTS = {
31
+ LiveRun: [LiveRunSchema, 'output'], LiveRunStart: [LiveRunStartSchema, 'input'], LiveRunUpdate: [LiveRunUpdateSchema, 'input'],
32
+ ServerEditableSettings: [ServerEditableSettingsSchema, 'input'], ServerSettingsSnapshot: [ServerSettingsSnapshotSchema, 'output'],
30
33
  Problem: [ProblemSchema, 'output'], Health: [HealthSchema, 'output'], Ready: [ReadySchema, 'output'], ServerInfo: [ServerInfoSchema, 'output'],
31
34
  EffectiveEvidence: [EffectiveEvidenceSchema, 'output'], EffectiveDelta: [EffectiveDeltaSchema, 'output'], ReportStatus: [ReportStatusSchema, 'output'],
32
35
  PublishChecks: [PublishChecksSchema.partial({ recordedAtMs: true }), 'input'], ChecksResult: [ChecksResultSchema, 'output'],
33
36
  StoreListing: [StoreListingSchema, 'output'], StoreWriteResult: [StoreWriteResultSchema, 'output'],
34
37
  Outline: [OutlineSchema, 'output'], EvidencePage: [EvidencePageSchema, 'output'], LayerRows: [LayerRowsSchema, 'output'], CheckDetail: [CheckDetailSchema, 'output'],
35
38
  SpecIndex: [SpecIndexSchema, 'output'], ReportSources: [ReportSourcesSchema, 'output'], RunsIndex: [RunsIndexSchema, 'output'],
36
- HelloEvent: [HelloEventSchema, 'output'], ReportEvent: [ReportEventSchema, 'output'], AtlasChangeEvent: [AtlasChangeSchema, 'output'], PingEvent: [PingEventSchema, 'output']
39
+ RunEvent: [LiveRunSchema, 'output'], HelloEvent: [HelloEventSchema, 'output'], ReportEvent: [ReportEventSchema, 'output'], AtlasChangeEvent: [AtlasChangeSchema, 'output'], PingEvent: [PingEventSchema, 'output']
37
40
  };
38
41
  const ref = (name) => ({ $ref: `#/components/schemas/${name}` });
39
42
  const str = { type: 'string' };
@@ -49,7 +52,54 @@ const LAYER_ID = { name: 'id', in: 'path', required: true, description: 'A layer
49
52
  * Every operation of the API. The order is the order of the document. The descriptions are the documentation an
50
53
  * agent reads, so they say what to send, what comes back, and what to do when it does not work.
51
54
  */
52
- export const OPERATIONS = [
55
+ const BASE_OPERATIONS = [
56
+ {
57
+ route: 'GET /api/projects', path: '/api/projects', operationId: 'getProjects', tag: 'Projects', role: 'read',
58
+ summary: 'List projects on this Atlas node', description: 'Projects are created on their first authenticated upload. All report, spec, recording and live-run data is isolated by project. Unqualified API calls address the legacy default project. Tokens retain their node-wide role; project partitioning is not a tenant authorization boundary.',
59
+ responses: { '200': { description: 'Available project identities.', contentType: 'application/json', schema: schemaOf(ProjectsSchema) }, '401': UNAUTHORIZED, '503': problem('The project catalog is unavailable.') }
60
+ },
61
+ {
62
+ route: 'GET /api/live', path: '/api/live', operationId: 'listLiveRuns', tag: 'Live', role: 'read',
63
+ summary: 'Live test progress and recent invocations', description: 'Returns up to 50 recent producer leases, including running, completed and interrupted runs. Counters and recent check/step updates are bounded; authentication credentials are never returned. Consume run events for immediate bounded snapshots; re-fetch after reconnect or atlas:change with scope runs on older servers.',
64
+ responses: { '200': { description: 'Recent live run summaries.', contentType: 'application/json', schema: { type: 'object', properties: { runs: { type: 'array', items: ref('LiveRun') } }, required: ['runs'] } }, '401': UNAUTHORIZED }
65
+ },
66
+ {
67
+ route: 'POST /api/live/:id', path: '/api/live/{id}', operationId: 'startLiveRun', tag: 'Live', role: 'publish',
68
+ summary: 'Start a live producer lease', description: 'Register a UUID run id and fixed producer identity before tests begin. Retrying the same identity and token is idempotent. The server timestamps receipt. Send heartbeats every 5 seconds and progress as tests execute. The default heartbeat timeout is 30 seconds; a 15-minute progress stall is configurable separately.',
69
+ parameters: [{ name: 'id', in: 'path', required: true, description: 'Producer-generated UUID, also given in the body.', schema: str }],
70
+ body: { description: 'Fixed invocation identity and optional planned test count.', contentType: 'application/json', required: true, schema: ref('LiveRunStart') },
71
+ responses: { '201': json('The registered lease.', 'LiveRun'), '401': UNAUTHORIZED, '403': FORBIDDEN, '409': problem('The id already belongs to another invocation.'), '413': problem('Request too large.'), '422': problem('Invalid identity.'), '429': problem('Live run capacity reached.') }
72
+ },
73
+ {
74
+ route: 'PATCH /api/live/:id', path: '/api/live/{id}', operationId: 'updateLiveRun', tag: 'Live', role: 'publish',
75
+ summary: 'Publish progress or finish an invocation', description: 'Apply ordered check deltas, starting at sequence 1. Retry a sequence with identical content only. Completed results become immutable report layers immediately; running checks remain operational progress and do not replace historical winners. Finish as completed or interrupted. Stale updates cannot revive an expired lease; pending checks never become passes.',
76
+ parameters: [{ name: 'id', in: 'path', required: true, description: 'The UUID of a run started with this publisher token.', schema: str }],
77
+ body: { description: 'At most 1000 unique check updates; optional terminal state and reason.', contentType: 'application/json', required: true, schema: ref('LiveRunUpdate') },
78
+ responses: { '200': json('Updated progress.', 'LiveRun'), '401': UNAUTHORIZED, '403': FORBIDDEN, '404': problem('Unknown run.'), '409': problem('Run ended or sequence is stale.'), '413': problem('Request too large.'), '422': problem('Invalid progress.'), '429': problem('Run check capacity reached.') }
79
+ },
80
+ {
81
+ route: 'POST /api/live/:id/heartbeat', path: '/api/live/{id}/heartbeat', operationId: 'heartbeatLiveRun', tag: 'Live', role: 'publish',
82
+ summary: 'Renew a producer heartbeat', description: 'Renew a running lease using server receipt time, independently of test progress. No result layers are created. Heartbeats cannot revive terminal runs or bypass the configured progress-stall timeout. The producer token must be the one that started this invocation.',
83
+ parameters: [{ name: 'id', in: 'path', required: true, description: 'The UUID of this producer invocation.', schema: str }],
84
+ responses: { '200': { description: 'Heartbeat received.', contentType: 'application/json', schema: { type: 'object', properties: { ok: { const: true } } } }, '401': UNAUTHORIZED, '403': FORBIDDEN, '404': problem('Unknown run.'), '409': problem('Run ended or its lease expired.'), '422': problem('Invalid run id.') }
85
+ },
86
+ {
87
+ route: 'GET /admin', path: '/admin', operationId: 'getSettingsPage', tag: 'Operations', role: 'read',
88
+ summary: 'Server settings page', description: 'A standalone settings editor. Supply an admin Bearer token in the page to read or save configuration. The token is held only in memory.',
89
+ responses: { '200': { description: 'Settings editor.', contentType: 'text/html', schema: str }, '401': UNAUTHORIZED }
90
+ },
91
+ {
92
+ route: 'GET /api/settings', path: '/api/settings', operationId: 'getSettings', tag: 'Operations', role: 'admin',
93
+ summary: 'Read server settings', description: 'Returns editable persisted storage/report settings, active settings, a revision, and configuration precedence. Authentication settings are excluded. Requires an admin Bearer token. Embedded applications may manage settings outside this API.',
94
+ responses: { '200': json('Settings and revision.', 'ServerSettingsSnapshot'), '401': UNAUTHORIZED, '403': FORBIDDEN, '503': problem('Configuration is managed by the embedding application.') }
95
+ },
96
+ {
97
+ route: 'PUT /api/settings', path: '/api/settings', operationId: 'saveSettings', tag: 'Operations', role: 'admin',
98
+ summary: 'Save settings for the next restart', description: 'Replaces editable storage/report settings, preserving authentication and other file fields. Atomically persists a validated configuration; no report data is moved. Environment variables and flags override these values after restart. Requires an admin Bearer token, same-origin requests in browsers, and a current revision.',
99
+ parameters: [{ name: 'If-Match', in: 'header', required: true, description: 'The quoted revision returned by GET /api/settings.', schema: str }],
100
+ body: { description: 'Editable fields from ServerConfig, excluding authentication, host, port and site. Maximum 64 KiB.', contentType: 'application/json', required: true, schema: ref('ServerEditableSettings') },
101
+ responses: { '200': json('Saved settings and new revision. Restart required.', 'ServerSettingsSnapshot'), '401': UNAUTHORIZED, '403': FORBIDDEN, '409': problem('Settings changed; reload before saving.'), '413': problem('Settings exceed the body limit.'), '422': problem('Invalid configuration.'), '428': problem('A quoted settings revision is required.'), '503': problem('Configuration is managed by the embedding application.') }
102
+ },
53
103
  {
54
104
  route: 'GET /healthz', path: '/healthz', operationId: 'getHealth', tag: 'Operations', role: 'none',
55
105
  summary: 'Is the process up',
@@ -84,13 +134,15 @@ export const OPERATIONS = [
84
134
  route: 'GET /api/events', path: '/api/events', operationId: 'streamEvents', tag: 'Live', role: 'read',
85
135
  summary: 'Server-sent events: results arrived, specs or runs changed',
86
136
  description: [
87
- 'A `text/event-stream` that stays open. Events say *that* something changed; the state is always fetched, so a client that missed events (a dropped connection) loses nothing: read again on reconnect.',
137
+ 'A `text/event-stream` that stays open. Report events announce changes, and run events carry bounded live progress snapshots; authoritative state can always be fetched, so a client that missed events (a dropped connection) loses nothing: read again on reconnect.',
88
138
  '',
89
139
  '| event | data |',
90
140
  '| --- | --- |',
91
- '| `hello` | `{ version, ready, time }`, sent first |',
141
+ '| `hello` | `{ version, ready, time, heartbeatMs? }`, sent first |',
92
142
  '| `report` | `{ version, totalLayers, added: [{ id, producer, environment, branch, sha, recordedAtMs, counts }], addedTotal }`: layers arrived |',
93
143
  '| `atlas:change` | `{ scope: "specs" \\| "runs", files, runIds }`: new specs or runs |',
144
+ '| `run` | A LiveRun snapshot after start, progress, heartbeat, completion, or interruption; contains no producer credentials |',
145
+ '| `projects` | `{}` when the node discovers another project; refresh `/api/projects` |',
94
146
  '| `ping` | `{}` every `heartbeatSeconds`, so a quiet server is told apart from a dead connection |',
95
147
  '',
96
148
  'Past `limits.maxStreams` viewers the answer is `503` with `Retry-After`: poll `/api/reports/evidence/effective` instead. A browser needs no code beyond `new EventSource("/api/events")`; other clients send the token as `Authorization: Bearer`.'
@@ -295,6 +347,18 @@ export const OPERATIONS = [
295
347
  responses: { '200': { description: 'The file.', contentType: 'application/octet-stream', schema: { type: 'string', format: 'binary' } }, '401': UNAUTHORIZED, '404': problem('No such run or file.') }
296
348
  }
297
349
  ];
350
+ const NODE_OPERATIONS = new Set(['/api/projects', '/api/settings', '/api/server', '/api/openapi.json']);
351
+ const PROJECT_PARAMETER = { name: 'project', in: 'query', description: 'Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.', schema: { type: 'string', pattern: '^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$', maxLength: 64 }, example: 'todo-app' };
352
+ /** Every node operation and the same stable report protocol inside each project namespace. */
353
+ export const OPERATIONS = BASE_OPERATIONS.flatMap(operation => {
354
+ if (!operation.path.startsWith('/api/') || NODE_OPERATIONS.has(operation.path))
355
+ return [operation];
356
+ return [{ ...operation, parameters: [...(operation.parameters ?? []), PROJECT_PARAMETER] }, {
357
+ ...operation, route: operation.route.replace('/api/', '/api/projects/:project/'), path: operation.path.replace('/api/', '/api/projects/{project}/'),
358
+ operationId: `${operation.operationId}ForProject`, parameters: [{ ...PROJECT_PARAMETER, in: 'path', required: true }, ...(operation.parameters ?? [])],
359
+ description: `${operation.description}\n\nScoped 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.`
360
+ }];
361
+ });
298
362
  /**
299
363
  * Routes the server registers that are not operations of this API, each with the reason. The coverage test skips
300
364
  * these and only these.
@@ -341,7 +405,7 @@ export function openApiDocument(options = {}) {
341
405
  version: API_VERSION,
342
406
  summary: 'Take in test results from every environment, keep them, and show everyone the combined report, live.',
343
407
  description: [
344
- 'One Atlas server per project. Clients upload layers of results (`POST /api/checks`, or the store protocol under `/api/store`); viewers read the combined report (`/api/reports/evidence/effective`) and are told when it changes (`/api/events`). A report is layered, never partitioned: the newest completed result of every check wins its slot, whoever sent it.',
408
+ 'One Atlas node serves multiple projects. Clients select a project using ?project=<slug>, X-Atlas-Project, or /api/projects/<slug>/… . Each project has independent specs, layers, recordings, status, and event streams. Within each project, the newest completed result of every check wins its slot. Unqualified requests address the legacy default project.',
345
409
  '',
346
410
  '**Authentication.** With tokens configured, send `Authorization: Bearer <token>`. Roles: `read` < `publish` < `admin`. Uploading needs a Bearer token; a browser\'s HTTP Basic sign-in (any user name, the token as the password) is accepted for reading only. A server with no tokens is open and listens on this machine only unless told otherwise.',
347
411
  '',
@@ -0,0 +1,3 @@
1
+ import type { AtlasServer, AtlasServerOptions } from './app.js';
2
+ /** Wrap the single-project protocol in project selection. Auth, upload validation and composition stay identical. */
3
+ export declare function projectServer(options: AtlasServerOptions, create: (options: AtlasServerOptions) => AtlasServer): AtlasServer;
@@ -0,0 +1,154 @@
1
+ /** @module Project routing
2
+ * One node serves isolated project stores through the same authenticated API. Legacy unqualified requests address
3
+ * the default project; named projects use scoped stores without rewriting uploaded report documents.
4
+ */
5
+ import { ProjectSlugSchema } from '../../schema/src/project.js';
6
+ import { ObjectStoreWithPrefix } from '../../reports/src/store-prefix.js';
7
+ import { writeUtf8 } from '../../reports/src/object-io.js';
8
+ import { createAuth } from './auth.js';
9
+ import { problemResponse } from './problem.js';
10
+ const REGISTRY = 'server/projects/';
11
+ const SCOPED = /^\/api\/projects\/([^/]+)(\/.*)$/;
12
+ const GLOBAL = new Set(['/api/settings', '/api/projects', '/api/openapi.json', '/api/server']);
13
+ const scopeStore = (store, slug) => typeof store.forProject === 'function'
14
+ ? store.forProject(slug) : new ObjectStoreWithPrefix(store, `projects/${slug}`);
15
+ /** Wrap the single-project protocol in project selection. Auth, upload validation and composition stay identical. */
16
+ export function projectServer(options, create) {
17
+ const base = create(options);
18
+ const auth = createAuth(options.tokens ?? [], options.anonymousRead ?? false);
19
+ const write = options.backends.find(backend => backend.id === options.write).store;
20
+ const registry = options.liveStore ?? write;
21
+ const known = new Set();
22
+ const children = new Map();
23
+ let closed = false;
24
+ let listing;
25
+ let listedAt = 0;
26
+ const announce = () => {
27
+ base.events.publish('projects', {});
28
+ for (const child of children.values())
29
+ child.server.events.publish('projects', {});
30
+ };
31
+ const discover = () => {
32
+ if (listing)
33
+ return listing;
34
+ listing = (async () => {
35
+ const reads = await Promise.allSettled([...new Set([registry, ...options.backends.map(backend => backend.store)])].map(store => store.listKeys(REGISTRY)));
36
+ let reachable = false;
37
+ for (const read of reads)
38
+ if (read.status === 'fulfilled') {
39
+ reachable = true;
40
+ for (const key of read.value) {
41
+ const slug = key.slice(REGISTRY.length).replace(/\.json$/, '');
42
+ if (key.endsWith('.json') && ProjectSlugSchema.safeParse(slug).success && !known.has(slug)) {
43
+ known.add(slug);
44
+ announce();
45
+ }
46
+ }
47
+ }
48
+ if (!reachable)
49
+ throw new Error('Project catalog is unavailable.');
50
+ listedAt = Date.now();
51
+ })().finally(() => { listing = undefined; });
52
+ return listing;
53
+ };
54
+ const ready = base.ready;
55
+ const expire = () => {
56
+ for (const [slug, child] of children)
57
+ if (!child.pending && child.server.events.count === 0 && Date.now() - child.touched > 300_000) {
58
+ child.server.close();
59
+ children.delete(slug);
60
+ }
61
+ };
62
+ const timer = setInterval(expire, 60_000);
63
+ timer.unref?.();
64
+ const routes = [...base.routes, { method: 'GET', path: '/api/projects' }];
65
+ for (const route of base.routes)
66
+ if (route.path.startsWith('/api/') && route.path !== '/api/*' && !GLOBAL.has(route.path))
67
+ routes.push({ method: route.method, path: route.path.replace('/api/', '/api/projects/:project/') });
68
+ return { ...base, routes, ready,
69
+ async fetch(request) {
70
+ try {
71
+ if (closed)
72
+ return problemResponse(503, 'Atlas is stopping.');
73
+ const url = new URL(request.url);
74
+ if (url.pathname === '/api/projects' && request.method === 'GET') {
75
+ const verdict = auth.check(request, 'read');
76
+ if (!verdict.ok)
77
+ return problemResponse(verdict.status, verdict.message, verdict.status === 401 ? { 'www-authenticate': 'Basic realm="Atlas"' } : {});
78
+ if (Date.now() - listedAt > 10_000)
79
+ await discover();
80
+ const includeDefault = base.index.stats().layers > 0 || known.size === 0;
81
+ return Response.json({ projects: [...(includeDefault ? ['default'] : []), ...[...known].filter(slug => slug !== 'default').sort()].map(slug => ({ slug, name: slug === 'default' ? 'Default' : slug.split('-').map(word => word[0].toUpperCase() + word.slice(1)).join(' ') })) }, { headers: { 'cache-control': 'no-store' } });
82
+ }
83
+ const match = SCOPED.exec(url.pathname);
84
+ const selectors = [match?.[1], ...url.searchParams.getAll('project'), request.headers.get('x-atlas-project')].filter((value) => value !== null && value !== undefined);
85
+ if (new Set(selectors).size > 1)
86
+ return problemResponse(422, 'Conflicting project selectors.');
87
+ const slug = selectors[0];
88
+ if (slug !== undefined && !ProjectSlugSchema.safeParse(slug).success)
89
+ return problemResponse(422, 'Invalid project slug. Use lowercase letters, digits and hyphens (1–64 characters).');
90
+ if (!match && (!slug || GLOBAL.has(url.pathname) || !url.pathname.startsWith('/api/')))
91
+ return base.fetch(request);
92
+ if (match)
93
+ url.pathname = `/api${match[2]}`;
94
+ if (GLOBAL.has(url.pathname))
95
+ return problemResponse(404, 'This operation belongs to the Atlas node.');
96
+ url.searchParams.delete('project');
97
+ const forwarded = new Request(url.href, request);
98
+ if (url.pathname === '/api/events' && base.events.count + [...children.values()].reduce((sum, child) => sum + child.server.events.count, 0) >= (options.limits?.maxStreams ?? 500))
99
+ return problemResponse(503, 'Too many event streams on this Atlas node. Retry later.');
100
+ if (!slug || slug === 'default')
101
+ return base.fetch(forwarded);
102
+ const writing = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method);
103
+ const verdict = auth.check(request, writing ? 'publish' : 'read');
104
+ if (!verdict.ok)
105
+ return problemResponse(verdict.status, verdict.message, verdict.status === 401 ? { 'www-authenticate': 'Basic realm="Atlas"' } : {});
106
+ if (writing) {
107
+ const origin = request.headers.get('origin');
108
+ const site = request.headers.get('sec-fetch-site');
109
+ if (site === 'cross-site' || (origin && origin !== url.origin && site !== 'same-origin'))
110
+ return problemResponse(403, 'Changes must come from this server origin.');
111
+ }
112
+ if (!known.has(slug)) {
113
+ await discover();
114
+ if (!known.has(slug)) {
115
+ if (!writing)
116
+ return problemResponse(404, 'Unknown project. Publish reports with this project slug first.');
117
+ }
118
+ }
119
+ let child = children.get(slug);
120
+ if (!child) {
121
+ expire();
122
+ if (children.size >= 64)
123
+ return problemResponse(429, 'Too many active projects. Retry when another project is idle.');
124
+ child = { touched: Date.now(), pending: 0, server: create({ ...options,
125
+ ...(options.log ? { log: line => options.log({ ...line, project: slug }) } : {}),
126
+ backends: options.backends.map(backend => ({ ...backend, store: scopeStore(backend.store, slug) })),
127
+ ...(options.liveStore ? { liveStore: scopeStore(options.liveStore, slug) } : {}), site: undefined, settings: undefined }) };
128
+ children.set(slug, child);
129
+ }
130
+ child.touched = Date.now();
131
+ child.pending++;
132
+ try {
133
+ await child.server.ready;
134
+ const response = await child.server.fetch(forwarded);
135
+ if (writing && response.ok && !known.has(slug)) {
136
+ await writeUtf8(registry, `${REGISTRY}${slug}.json`, JSON.stringify({ slug }), 'application/json');
137
+ known.add(slug);
138
+ announce();
139
+ }
140
+ return response;
141
+ }
142
+ finally {
143
+ child.pending--;
144
+ child.touched = Date.now();
145
+ }
146
+ }
147
+ catch (error) {
148
+ options.log?.({ level: 'error', error: String(error) });
149
+ return problemResponse(503, 'The project backend is unavailable. Retry later.');
150
+ }
151
+ },
152
+ close() { closed = true; clearInterval(timer); base.close(); for (const child of children.values())
153
+ child.server.close(); children.clear(); } };
154
+ }
@@ -0,0 +1,9 @@
1
+ /** The settings form and its browser controller, also usable without a built client. */
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>";
3
+ /**
4
+ * Render Settings using the exact design tokens shipped with the Atlas client. Loads and caches the stylesheet
5
+ * on first request, never at import time. Minimal neutral tokens keep embedded API-only servers usable too.
6
+ * @example
7
+ * const html = await renderSettingsPage();
8
+ */
9
+ export declare function renderSettingsPage(): Promise<string>;
@@ -0,0 +1,93 @@
1
+ /** @module Settings page
2
+ * A standalone administrator form, styled with the Atlas client's shared design tokens.
3
+ * Credentials remain in page memory; the settings API owns authorization and validation.
4
+ */
5
+ import { readFile } from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import { visualizerRoot } from './site.js';
8
+ /** The settings form and its browser controller, also usable without a built client. */
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>`;
79
+ let page;
80
+ /**
81
+ * Render Settings using the exact design tokens shipped with the Atlas client. Loads and caches the stylesheet
82
+ * on first request, never at import time. Minimal neutral tokens keep embedded API-only servers usable too.
83
+ * @example
84
+ * const html = await renderSettingsPage();
85
+ */
86
+ export function renderSettingsPage() {
87
+ page ??= (async () => {
88
+ const root = visualizerRoot();
89
+ const tokens = root ? await readFile(path.join(root, 'src/styles/tokens.css'), 'utf8').catch(() => '') : '';
90
+ return SETTINGS_PAGE.replace('/* atlas-client-tokens */', tokens);
91
+ })();
92
+ return page;
93
+ }
@@ -0,0 +1,23 @@
1
+ import { type ResolvedServerConfig } from './config.js';
2
+ /** The persisted editable settings, active settings and a revision for optimistic concurrency. Authentication token settings are excluded. */
3
+ export type SettingsSnapshot = {
4
+ readonly revision: string;
5
+ readonly settings: Record<string, unknown>;
6
+ readonly active: Record<string, unknown>;
7
+ readonly restartRequired: boolean;
8
+ readonly precedence: string;
9
+ };
10
+ /** Storage for the admin API; saving is serialized and refuses a stale revision. */
11
+ export type ServerSettings = {
12
+ read(): Promise<SettingsSnapshot>;
13
+ save(settings: unknown, revision: string): Promise<SettingsSnapshot>;
14
+ };
15
+ /**
16
+ * Manage the server's configuration file with atomic replacement, preserving authentication and unrelated fields.
17
+ * Validates both the file alone and the effective configuration before saving. Never migrates or deletes report data.
18
+ * Changes need a server restart; deployment environment variables and flags override the file.
19
+ * @example
20
+ * const settings = fileServerSettings(config, argv, process.env, process.cwd());
21
+ */
22
+ export declare function fileServerSettings(config: ResolvedServerConfig, argv: readonly string[], env: Readonly<Record<string, string | undefined>>, cwd: string): ServerSettings;
23
+ export { SETTINGS_PAGE, renderSettingsPage } from './settings-page.js';
@@ -0,0 +1,72 @@
1
+ /** @module Persisted server settings
2
+ * The admin UI edits storage and report configuration in atlas.server.json. Changes take effect on restart;
3
+ * environment variables and flags keep their documented precedence. Authentication is managed outside this UI.
4
+ */
5
+ import { createHash, randomUUID } from 'node:crypto';
6
+ import { readFile, rename, rm, writeFile } from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ import { ServerEditableSettingsSchema } from '../../schema/src/server-config.js';
9
+ import { parseStoreLocation } from '../../reports/src/store-url.js';
10
+ import { ConfigError, loadServerConfig } from './config.js';
11
+ const editable = ServerEditableSettingsSchema;
12
+ const keys = new Set(Object.keys(editable.shape));
13
+ const select = (value) => Object.fromEntries(Object.entries(value).filter(([key]) => keys.has(key)));
14
+ const digest = (text) => createHash('sha256').update(text).digest('hex');
15
+ /**
16
+ * Manage the server's configuration file with atomic replacement, preserving authentication and unrelated fields.
17
+ * Validates both the file alone and the effective configuration before saving. Never migrates or deletes report data.
18
+ * Changes need a server restart; deployment environment variables and flags override the file.
19
+ * @example
20
+ * const settings = fileServerSettings(config, argv, process.env, process.cwd());
21
+ */
22
+ export function fileServerSettings(config, argv, env, cwd) {
23
+ const file = config.configFile ?? path.join(cwd, 'atlas.server.json');
24
+ const active = { data: config.data, backends: config.backends.slice(1), write: config.writeId, outlineBranch: config.outlineBranch, include: config.include, refreshSeconds: config.refreshSeconds, limits: config.limits, heartbeatSeconds: config.heartbeatSeconds, runTimeoutSeconds: config.runTimeoutSeconds, runStallSeconds: config.runStallSeconds, log: config.log };
25
+ let saved = false;
26
+ let pending = Promise.resolve();
27
+ const readText = () => readFile(file, 'utf8').catch(error => { if (error.code === 'ENOENT')
28
+ return '{}'; throw error; });
29
+ const read = async () => {
30
+ const text = await readText();
31
+ return { revision: digest(text), settings: select(JSON.parse(text)), active,
32
+ restartRequired: saved, precedence: 'File settings apply after restart. Environment variables and command-line flags override the file.' };
33
+ };
34
+ return { read, save(settings, revision) {
35
+ const update = pending.then(async () => {
36
+ const parsed = editable.safeParse(settings);
37
+ if (!parsed.success)
38
+ throw new ConfigError(parsed.error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`).join('; '));
39
+ const before = await readText();
40
+ if (digest(before) !== revision)
41
+ throw new ConfigError('Settings changed. Reload them before saving.');
42
+ const original = JSON.parse(before);
43
+ const merged = { ...Object.fromEntries(Object.entries(original).filter(([key]) => !keys.has(key))), ...parsed.data };
44
+ const text = `${JSON.stringify(merged, null, 2)}\n`;
45
+ // Prove it is independently usable, then prove the exact deployment (including overrides) can start.
46
+ const base = loadServerConfig(['--config', file], env, cwd, () => text);
47
+ const effective = loadServerConfig([...argv, '--config', file], env, cwd, () => text);
48
+ try {
49
+ for (const backend of [...base.backends, ...effective.backends])
50
+ parseStoreLocation(backend.url, path.dirname(file), env);
51
+ }
52
+ catch (error) {
53
+ throw new ConfigError(error instanceof Error ? error.message : String(error));
54
+ }
55
+ const temporary = `${file}.${randomUUID()}.tmp`;
56
+ try {
57
+ await writeFile(temporary, text, { mode: 0o600, flag: 'wx' });
58
+ if (await readText() !== before)
59
+ throw new ConfigError('Settings changed. Reload them before saving.');
60
+ await rename(temporary, file);
61
+ }
62
+ finally {
63
+ await rm(temporary, { force: true });
64
+ }
65
+ saved = true;
66
+ return read();
67
+ });
68
+ pending = update.catch(() => undefined);
69
+ return update;
70
+ } };
71
+ }
72
+ export { SETTINGS_PAGE, renderSettingsPage } from './settings-page.js';