@crvouga/atlas 0.5.0 → 0.6.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 +6 -0
- package/README.md +30 -11
- package/dist/core/src/cli.d.ts +1 -1
- package/dist/core/src/cli.js +8 -3
- package/dist/core/src/config.d.ts +7 -0
- package/dist/core/src/config.js +48 -14
- package/dist/core/src/run/execute.d.ts +6 -0
- package/dist/core/src/run/execute.js +4 -2
- package/dist/reports/src/doctor.d.ts +1 -1
- package/dist/reports/src/doctor.js +3 -1
- package/dist/reports/src/http-store.d.ts +4 -0
- package/dist/reports/src/http-store.js +6 -3
- package/dist/reports/src/init.js +6 -1
- package/dist/reports/src/live-reporter.d.ts +34 -0
- package/dist/reports/src/live-reporter.js +122 -0
- package/dist/reports/src/report-cli.d.ts +1 -1
- package/dist/reports/src/report-cli.js +3 -2
- package/dist/reports/src/store-url.js +1 -1
- package/dist/runners/src/codecept.d.ts +14 -0
- package/dist/runners/src/codecept.js +16 -0
- package/dist/runners/src/jest.d.ts +50 -0
- package/dist/runners/src/jest.js +52 -0
- package/dist/runners/src/mocha.d.ts +40 -0
- package/dist/runners/src/mocha.js +39 -0
- package/dist/runners/src/node-test.d.ts +28 -0
- package/dist/runners/src/node-test.js +44 -0
- package/dist/runners/src/publish.d.ts +30 -0
- package/dist/runners/src/publish.js +68 -0
- package/dist/schema/src/api.d.ts +1 -0
- package/dist/schema/src/api.js +1 -1
- package/dist/schema/src/index.d.ts +2 -0
- package/dist/schema/src/index.js +2 -0
- package/dist/schema/src/live-run.d.ts +123 -0
- package/dist/schema/src/live-run.js +30 -0
- package/dist/schema/src/manifest.d.ts +2 -2
- package/dist/schema/src/project.d.ts +20 -0
- package/dist/schema/src/project.js +10 -0
- package/dist/schema/src/report-sources.d.ts +1 -0
- package/dist/schema/src/report-sources.js +2 -1
- package/dist/schema/src/runs-index.d.ts +1 -1
- package/dist/schema/src/server-config.d.ts +84 -0
- package/dist/schema/src/server-config.js +12 -0
- package/dist/server/src/app.d.ts +10 -0
- package/dist/server/src/app.js +148 -10
- package/dist/server/src/config.d.ts +1 -1
- package/dist/server/src/config.js +20 -3
- package/dist/server/src/dev.d.ts +3 -3
- package/dist/server/src/dev.js +22 -13
- package/dist/server/src/events.d.ts +3 -3
- package/dist/server/src/events.js +1 -1
- package/dist/server/src/index.d.ts +2 -0
- package/dist/server/src/index.js +2 -0
- package/dist/server/src/live-runs.d.ts +52 -0
- package/dist/server/src/live-runs.js +229 -0
- package/dist/server/src/main.d.ts +1 -1
- package/dist/server/src/main.js +7 -5
- package/dist/server/src/openapi.d.ts +2 -5
- package/dist/server/src/openapi.js +71 -7
- package/dist/server/src/projects.d.ts +3 -0
- package/dist/server/src/projects.js +154 -0
- package/dist/server/src/settings-page.d.ts +9 -0
- package/dist/server/src/settings-page.js +93 -0
- package/dist/server/src/settings.d.ts +23 -0
- package/dist/server/src/settings.js +72 -0
- package/dist/server/src/site.d.ts +8 -6
- package/dist/server/src/site.js +45 -10
- package/dist/server/src/store-api.js +43 -22
- package/docs/deploying.md +19 -19
- package/docs/dogfooding.md +2 -2
- package/docs/extending.md +3 -3
- package/docs/integrating.md +9 -9
- package/docs/reference/cli.md +9 -7
- package/docs/reference/core.md +6 -0
- package/docs/reference/http-api.md +498 -2
- package/docs/reference/reports.md +55 -1
- package/docs/reference/runners.md +169 -0
- package/docs/reference/schema.md +111 -0
- package/docs/reference/server.md +154 -14
- package/docs/report-backends.md +8 -8
- package/docs/report-contract.md +7 -7
- package/docs/reporting-issues.md +3 -3
- package/docs/runners.md +69 -0
- package/docs/server.md +61 -25
- package/docs/statecharts.md +22 -20
- package/llms-full.txt +713 -105
- package/llms.txt +3 -1
- package/package.json +31 -9
- package/packages/runners/README.md +6 -0
- package/packages/runners/compat/codecept.cjs +2 -0
- package/packages/runners/compat/jest.cjs +2 -0
- package/packages/runners/compat/mocha.cjs +2 -0
- package/packages/schema/schemas/atlas-change.schema.json +4 -0
- package/packages/schema/schemas/hello-event.schema.json +4 -0
- package/packages/schema/schemas/live-run-start.schema.json +42 -0
- package/packages/schema/schemas/live-run-update.schema.json +93 -0
- package/packages/schema/schemas/live-run.schema.json +225 -0
- package/packages/schema/schemas/project-slug.schema.json +8 -0
- package/packages/schema/schemas/project.schema.json +22 -0
- package/packages/schema/schemas/projects.schema.json +33 -0
- package/packages/schema/schemas/server-config.schema.json +14 -0
- package/packages/schema/schemas/server-editable-settings.schema.json +132 -0
- package/packages/schema/schemas/server-settings-snapshot.schema.json +285 -0
- package/packages/server/openapi.json +5101 -779
- package/packages/visualizer/README.md +13 -13
- package/packages/visualizer/bin/atlas-client.mjs +176 -0
- package/packages/visualizer/bin/atlas-visualizer.mjs +2 -175
- package/packages/visualizer/src/app/AppShell.tsx +26 -17
- package/packages/visualizer/src/app/report-sources.tsx +2 -1
- package/packages/visualizer/src/components/ActivityIndicator.module.css +4 -0
- package/packages/visualizer/src/components/ActivityIndicator.tsx +6 -0
- package/packages/visualizer/src/components/Header.module.css +101 -227
- package/packages/visualizer/src/components/Header.tsx +79 -41
- package/packages/visualizer/src/components/ProjectPicker.tsx +64 -0
- package/packages/visualizer/src/data/adapters/report-backend.test.ts +3 -2
- package/packages/visualizer/src/data/adapters/report-backend.ts +1 -1
- package/packages/visualizer/src/data/queries.ts +14 -7
- package/packages/visualizer/src/evidence/api.ts +2 -1
- package/packages/visualizer/src/evidence/check-detail.tsx +19 -4
- package/packages/visualizer/src/evidence/document-page.tsx +6 -2
- package/packages/visualizer/src/evidence/evidence-context.tsx +17 -12
- package/packages/visualizer/src/evidence/evidence.module.css +11 -1
- package/packages/visualizer/src/evidence/feature-page.tsx +7 -3
- package/packages/visualizer/src/evidence/model-page.tsx +6 -2
- package/packages/visualizer/src/evidence/project-home.tsx +45 -0
- package/packages/visualizer/src/evidence/recording-page.tsx +4 -1
- package/packages/visualizer/src/evidence/spec-index.tsx +2 -1
- package/packages/visualizer/src/evidence/test-filter.tsx +21 -0
- package/packages/visualizer/src/live/live-query.ts +21 -0
- package/packages/visualizer/src/live/live-runs.tsx +69 -0
- package/packages/visualizer/src/live/live-stream.ts +30 -9
- package/packages/visualizer/src/live/report-progress.ts +62 -0
- package/packages/visualizer/src/live/test-progress.tsx +34 -0
- package/packages/visualizer/src/runtime.ts +17 -1
- package/packages/visualizer/src/state/ui-store.ts +3 -2
- package/packages/visualizer/src/views/ChartPage.tsx +2 -0
- package/packages/visualizer/src/views/ChartTests.tsx +43 -0
- package/packages/visualizer/src/views/SidePanel.tsx +2 -0
- package/packages/visualizer/vite/atlas-files.test.ts +4 -4
- package/packages/visualizer/vite/atlas-files.ts +1 -5
- package/packages/visualizer/vite/atlas-server-proxy.ts +2 -1
|
@@ -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';
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @module Finding the
|
|
2
|
+
* @module Finding the client
|
|
3
3
|
*
|
|
4
|
-
* Locates the built
|
|
5
|
-
* cache from the
|
|
4
|
+
* Locates the built client a server serves: the folder named by `--site`, `./atlas-site`, or one built once into the user's
|
|
5
|
+
* cache from the client source shipped in the package (`atlas client build --no-data`). The build is the same for every
|
|
6
6
|
* project because the server writes its own address book into the page, so it is built at most once per installed version.
|
|
7
7
|
*/
|
|
8
|
-
/**
|
|
8
|
+
/** Run the Atlas client build tools. Returns the child's exit code; throws when the client is missing. */
|
|
9
|
+
export declare function clientMain(argv: readonly string[]): Promise<number>;
|
|
10
|
+
/** Where the client's source is, in this repository and in an installed package. */
|
|
9
11
|
export declare function visualizerRoot(): string | null;
|
|
10
|
-
/** Build the
|
|
12
|
+
/** Build the client once into a folder, for a server that was not given a prebuilt one. */
|
|
11
13
|
export declare function buildSite(out: string, log: (text: string) => void): Promise<boolean>;
|
|
12
|
-
/** The
|
|
14
|
+
/** The client to serve: the one named, a build beside the project, or one built into the user's cache. */
|
|
13
15
|
export declare function findSite(named: string | undefined, cwd: string, log: (text: string) => void): Promise<string | null>;
|
package/dist/server/src/site.js
CHANGED
|
@@ -1,16 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @module Finding the
|
|
2
|
+
* @module Finding the client
|
|
3
3
|
*
|
|
4
|
-
* Locates the built
|
|
5
|
-
* cache from the
|
|
4
|
+
* Locates the built client a server serves: the folder named by `--site`, `./atlas-site`, or one built once into the user's
|
|
5
|
+
* cache from the client source shipped in the package (`atlas client build --no-data`). The build is the same for every
|
|
6
6
|
* project because the server writes its own address book into the page, so it is built at most once per installed version.
|
|
7
7
|
*/
|
|
8
8
|
import { spawn } from 'node:child_process';
|
|
9
|
-
import { existsSync } from 'node:fs';
|
|
9
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
10
11
|
import os from 'node:os';
|
|
11
12
|
import path from 'node:path';
|
|
12
13
|
import { fileURLToPath } from 'node:url';
|
|
13
|
-
/**
|
|
14
|
+
/** Run the Atlas client build tools. Returns the child's exit code; throws when the client is missing. */
|
|
15
|
+
export async function clientMain(argv) {
|
|
16
|
+
const root = visualizerRoot();
|
|
17
|
+
if (!root)
|
|
18
|
+
throw new Error('The Atlas client is not installed alongside the server.');
|
|
19
|
+
return new Promise(resolve => {
|
|
20
|
+
const child = spawn(process.execPath, [path.join(root, 'bin', 'atlas-client.mjs'), ...(argv.length ? argv : ['--help'])], { stdio: 'inherit' });
|
|
21
|
+
child.on('error', () => resolve(1));
|
|
22
|
+
child.on('close', code => resolve(code ?? 1));
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
function clientCacheKey() {
|
|
26
|
+
const root = visualizerRoot();
|
|
27
|
+
const hash = createHash('sha256');
|
|
28
|
+
if (!root)
|
|
29
|
+
return 'missing';
|
|
30
|
+
const walk = (dir) => {
|
|
31
|
+
for (const item of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
32
|
+
const file = path.join(dir, item.name);
|
|
33
|
+
if (item.isDirectory())
|
|
34
|
+
walk(file);
|
|
35
|
+
else {
|
|
36
|
+
hash.update(path.relative(root, file));
|
|
37
|
+
hash.update(readFileSync(file));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
walk(path.join(root, 'src'));
|
|
42
|
+
walk(path.join(root, 'vite'));
|
|
43
|
+
hash.update(readFileSync(path.join(root, 'vite.config.ts')));
|
|
44
|
+
hash.update(readFileSync(path.join(root, 'index.html')));
|
|
45
|
+
hash.update(readFileSync(path.resolve(root, '../../package.json')));
|
|
46
|
+
return hash.digest('hex').slice(0, 20);
|
|
47
|
+
}
|
|
48
|
+
/** Where the client's source is, in this repository and in an installed package. */
|
|
14
49
|
export function visualizerRoot() {
|
|
15
50
|
for (const relative of ['../../visualizer', '../../../packages/visualizer']) {
|
|
16
51
|
const dir = fileURLToPath(new URL(relative, import.meta.url));
|
|
@@ -19,23 +54,23 @@ export function visualizerRoot() {
|
|
|
19
54
|
}
|
|
20
55
|
return null;
|
|
21
56
|
}
|
|
22
|
-
/** Build the
|
|
57
|
+
/** Build the client once into a folder, for a server that was not given a prebuilt one. */
|
|
23
58
|
export async function buildSite(out, log) {
|
|
24
59
|
const root = visualizerRoot();
|
|
25
60
|
if (!root)
|
|
26
61
|
return false;
|
|
27
|
-
log(`Building the
|
|
62
|
+
log(`Building the Atlas client into ${out} (once; pass --site to use a prebuilt client)\n`);
|
|
28
63
|
return new Promise(resolve => {
|
|
29
|
-
const child = spawn(process.execPath, [path.join(root, 'bin', 'atlas-
|
|
64
|
+
const child = spawn(process.execPath, [path.join(root, 'bin', 'atlas-client.mjs'), 'build', '--no-data', '--out', out], { stdio: ['ignore', 'ignore', 'inherit'], env: { ...process.env, INIT_CWD: process.cwd() } });
|
|
30
65
|
child.on('error', () => resolve(false));
|
|
31
66
|
child.on('close', code => resolve(code === 0 && existsSync(path.join(out, 'index.html'))));
|
|
32
67
|
});
|
|
33
68
|
}
|
|
34
|
-
/** The
|
|
69
|
+
/** The client to serve: the one named, a build beside the project, or one built into the user's cache. */
|
|
35
70
|
export async function findSite(named, cwd, log) {
|
|
36
71
|
if (named)
|
|
37
72
|
return existsSync(path.join(named, 'index.html')) ? named : null;
|
|
38
|
-
const cache = path.join(os.homedir(), '.cache', 'atlas', '
|
|
73
|
+
const cache = path.join(os.homedir(), '.cache', 'atlas', 'client', clientCacheKey());
|
|
39
74
|
for (const candidate of [path.resolve(cwd, 'atlas-site'), cache])
|
|
40
75
|
if (existsSync(path.join(candidate, 'index.html')))
|
|
41
76
|
return candidate;
|