changeledger 0.3.0 → 0.4.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/package.json +1 -1
- package/src/check.mjs +35 -9
- package/src/commands/view.mjs +10 -1
- package/src/config.mjs +6 -3
- package/src/registry.mjs +24 -1
- package/src/repo.mjs +7 -0
- package/src/viewer/domain.mjs +136 -3
- package/src/viewer/public/api.js +26 -0
- package/src/viewer/public/app-state.js +144 -5
- package/src/viewer/public/app.js +267 -9
- package/src/viewer/public/index.html +2 -0
- package/src/viewer/public/styles.css +202 -0
- package/src/viewer/server/router.mjs +50 -11
package/package.json
CHANGED
package/src/check.mjs
CHANGED
|
@@ -15,9 +15,9 @@ export function checkRepo({ config, changes, specs = [], releases = [] }, opts =
|
|
|
15
15
|
|
|
16
16
|
checkConfig(config, (c, message) => err(c ?? { name: '.changeledger/config.yml' }, message));
|
|
17
17
|
|
|
18
|
-
const statuses = config.statuses
|
|
19
|
-
const types = config.types
|
|
20
|
-
const canonical = config.stages
|
|
18
|
+
const statuses = Array.isArray(config.statuses) ? config.statuses : [];
|
|
19
|
+
const types = isMapping(config.types) ? config.types : {};
|
|
20
|
+
const canonical = Array.isArray(config.stages) ? config.stages : [];
|
|
21
21
|
|
|
22
22
|
// Scope: a single change (fast, post-write check) or the whole repo.
|
|
23
23
|
let targets = changes;
|
|
@@ -64,7 +64,8 @@ export function checkRepo({ config, changes, specs = [], releases = [] }, opts =
|
|
|
64
64
|
const ordered = [...known].sort((a, b) => canonical.indexOf(a) - canonical.indexOf(b));
|
|
65
65
|
if (known.join(',') !== ordered.join(',')) err(c, 'stages are out of canonical order');
|
|
66
66
|
|
|
67
|
-
const
|
|
67
|
+
const typeDefinition = isMapping(types[fm.type]) ? types[fm.type] : null;
|
|
68
|
+
const active = Array.isArray(typeDefinition?.stages) ? typeDefinition.stages : null;
|
|
68
69
|
if (active) {
|
|
69
70
|
for (const k of active)
|
|
70
71
|
if (!present.includes(k)) err(c, `missing active stage "## ${k}" for type ${fm.type}`);
|
|
@@ -365,9 +366,20 @@ function misplacedVerificationSuffix(task, config) {
|
|
|
365
366
|
}
|
|
366
367
|
|
|
367
368
|
function readinessConfig(config) {
|
|
369
|
+
const readiness = isMapping(config?.readiness) ? config.readiness : null;
|
|
368
370
|
return {
|
|
369
|
-
target_patterns:
|
|
370
|
-
|
|
371
|
+
target_patterns:
|
|
372
|
+
readiness && 'target_patterns' in readiness
|
|
373
|
+
? Array.isArray(readiness.target_patterns)
|
|
374
|
+
? readiness.target_patterns
|
|
375
|
+
: []
|
|
376
|
+
: ['src/**'],
|
|
377
|
+
verification_patterns:
|
|
378
|
+
readiness && 'verification_patterns' in readiness
|
|
379
|
+
? Array.isArray(readiness.verification_patterns)
|
|
380
|
+
? readiness.verification_patterns
|
|
381
|
+
: []
|
|
382
|
+
: ['test/**'],
|
|
371
383
|
};
|
|
372
384
|
}
|
|
373
385
|
|
|
@@ -445,11 +457,21 @@ function checkConfig(config, err) {
|
|
|
445
457
|
}
|
|
446
458
|
if ('statuses' in c && !Array.isArray(c.statuses)) err(null, 'config "statuses" must be a list');
|
|
447
459
|
if ('stages' in c && !Array.isArray(c.stages)) err(null, 'config "stages" must be a list');
|
|
460
|
+
if ('types' in c && !isMapping(c.types)) err(null, 'config "types" must be a mapping');
|
|
448
461
|
if ('readiness' in c) checkReadinessConfig(c.readiness, err);
|
|
449
|
-
|
|
462
|
+
const configuredTypes = isMapping(c.types) ? c.types : {};
|
|
463
|
+
if ('release' in c) checkReleaseConfig(c.release, configuredTypes, err);
|
|
450
464
|
const canonical = Array.isArray(c.stages) ? c.stages : [];
|
|
451
|
-
for (const [type, def] of Object.entries(
|
|
452
|
-
|
|
465
|
+
for (const [type, def] of Object.entries(configuredTypes)) {
|
|
466
|
+
if (!isMapping(def)) {
|
|
467
|
+
err(null, `config type "${type}" must be a mapping`);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
if (!Array.isArray(def.stages)) {
|
|
471
|
+
err(null, `config type "${type}": stages must be a list`);
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
for (const s of def.stages) {
|
|
453
475
|
if (!canonical.includes(s))
|
|
454
476
|
err(null, `config type "${type}" references unknown stage "${s}"`);
|
|
455
477
|
}
|
|
@@ -458,6 +480,10 @@ function checkConfig(config, err) {
|
|
|
458
480
|
}
|
|
459
481
|
}
|
|
460
482
|
|
|
483
|
+
function isMapping(value) {
|
|
484
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
485
|
+
}
|
|
486
|
+
|
|
461
487
|
function checkReleaseConfig(release, types, err) {
|
|
462
488
|
if (!release || typeof release !== 'object' || Array.isArray(release)) {
|
|
463
489
|
err(null, 'config "release" must be a mapping');
|
package/src/commands/view.mjs
CHANGED
|
@@ -5,7 +5,16 @@ import { resolveProjects } from '../viewer/domain.mjs';
|
|
|
5
5
|
import { createRequestListener, staticFile } from '../viewer/server/router.mjs';
|
|
6
6
|
import { hostnameOf, isAuthorizedWrite, isLocalHost } from '../viewer/server/security.mjs';
|
|
7
7
|
|
|
8
|
-
export {
|
|
8
|
+
export {
|
|
9
|
+
changeStatus,
|
|
10
|
+
readProjectConfig,
|
|
11
|
+
repairProjectPath,
|
|
12
|
+
resolveProjects,
|
|
13
|
+
saveProjectConfig,
|
|
14
|
+
searchProjects,
|
|
15
|
+
serialize,
|
|
16
|
+
unregisterProject,
|
|
17
|
+
} from '../viewer/domain.mjs';
|
|
9
18
|
export { createRequestListener, hostnameOf, isAuthorizedWrite, isLocalHost, staticFile };
|
|
10
19
|
|
|
11
20
|
export async function view(args = [], cwd = process.cwd()) {
|
package/src/config.mjs
CHANGED
|
@@ -2,13 +2,16 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { parseYaml } from './yaml.mjs';
|
|
4
4
|
|
|
5
|
-
// Walk up from `start` looking for a `.changeledger
|
|
6
|
-
//
|
|
5
|
+
// Walk up from `start` looking for a project `.changeledger/config.yml`. The
|
|
6
|
+
// config file is the marker: `~/.changeledger/` may exist only as global state
|
|
7
|
+
// and must not be mistaken for a repository (notably when Windows temp dirs
|
|
8
|
+
// live below the user's home). Returns the project data directory or null.
|
|
7
9
|
export function findChangeledgerDir(start = process.cwd()) {
|
|
8
10
|
let dir = path.resolve(start);
|
|
9
11
|
for (;;) {
|
|
10
12
|
const candidate = path.join(dir, '.changeledger');
|
|
11
|
-
|
|
13
|
+
const config = path.join(candidate, 'config.yml');
|
|
14
|
+
if (fs.existsSync(config) && fs.statSync(config).isFile()) return candidate;
|
|
12
15
|
const parent = path.dirname(dir);
|
|
13
16
|
if (parent === dir) return null;
|
|
14
17
|
dir = parent;
|
package/src/registry.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import fs from 'node:fs';
|
|
|
7
7
|
import os from 'node:os';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { withFileLock, writeFileAtomic } from './atomic-write.mjs';
|
|
10
|
+
import { loadConfig } from './config.mjs';
|
|
10
11
|
|
|
11
12
|
export function registryDir() {
|
|
12
13
|
return path.join(process.env.CHANGELEDGER_HOME || os.homedir(), '.changeledger');
|
|
@@ -42,7 +43,18 @@ export function register({ id, name, path: repoPath }) {
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
export function listProjects() {
|
|
45
|
-
return Object.entries(readRegistry()).map(([id,
|
|
46
|
+
return Object.entries(readRegistry()).map(([id, value]) => {
|
|
47
|
+
let name = value.name;
|
|
48
|
+
try {
|
|
49
|
+
const config = loadConfig(path.join(value.path, '.changeledger'));
|
|
50
|
+
if (String(config.project_id) === id && typeof config.project_name === 'string') {
|
|
51
|
+
name = config.project_name;
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// Missing projects keep their last registered display name.
|
|
55
|
+
}
|
|
56
|
+
return { id, name, path: value.path };
|
|
57
|
+
});
|
|
46
58
|
}
|
|
47
59
|
|
|
48
60
|
export function remove(id) {
|
|
@@ -53,3 +65,14 @@ export function remove(id) {
|
|
|
53
65
|
writeRegistry(reg);
|
|
54
66
|
});
|
|
55
67
|
}
|
|
68
|
+
|
|
69
|
+
export function update(id, values) {
|
|
70
|
+
fs.mkdirSync(registryDir(), { recursive: true });
|
|
71
|
+
return withFileLock(registryPath(), () => {
|
|
72
|
+
const reg = readRegistry();
|
|
73
|
+
if (!reg[id]) throw new Error(`no registered project "${id}"`);
|
|
74
|
+
reg[id] = { ...reg[id], ...values };
|
|
75
|
+
writeRegistry(reg);
|
|
76
|
+
return reg[id];
|
|
77
|
+
});
|
|
78
|
+
}
|
package/src/repo.mjs
CHANGED
|
@@ -47,6 +47,13 @@ export function loadRepo(start = process.cwd()) {
|
|
|
47
47
|
}
|
|
48
48
|
const repoRoot = path.dirname(changeledgerDir);
|
|
49
49
|
const config = loadConfig(changeledgerDir);
|
|
50
|
+
return loadRepoWithConfig(repoRoot, changeledgerDir, config);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Loads repository content using an already parsed candidate config. The viewer
|
|
54
|
+
// uses this before replacing config.yml so changes to configured directories are
|
|
55
|
+
// validated against the content they would actually expose after the save.
|
|
56
|
+
export function loadRepoWithConfig(repoRoot, changeledgerDir, config) {
|
|
50
57
|
const changesDir = resolveRepoPath(repoRoot, config.changes_dir, 'changes_dir');
|
|
51
58
|
|
|
52
59
|
const changes = [];
|
package/src/viewer/domain.mjs
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
1
2
|
import fs from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
4
|
+
import { mutateFileAtomic } from '../atomic-write.mjs';
|
|
5
|
+
import { checkRepo } from '../check.mjs';
|
|
3
6
|
import { status as applyStatusCmd, validation as applyValidation } from '../commands/agent.mjs';
|
|
4
|
-
import { findChangeledgerDir, loadConfig } from '../config.mjs';
|
|
7
|
+
import { findChangeledgerDir, loadConfig, resolveRepoPath, resolveSpecsDir } from '../config.mjs';
|
|
5
8
|
import { computeMetrics } from '../metrics.mjs';
|
|
6
9
|
import { nowUtc } from '../paths.mjs';
|
|
7
|
-
import { listProjects } from '../registry.mjs';
|
|
8
|
-
import { loadRepo } from '../repo.mjs';
|
|
10
|
+
import { listProjects, remove, update } from '../registry.mjs';
|
|
11
|
+
import { loadRepo, loadRepoWithConfig } from '../repo.mjs';
|
|
12
|
+
import { parseYaml } from '../yaml.mjs';
|
|
9
13
|
|
|
10
14
|
// Serializes a loaded repo into the flat shape the UI consumes.
|
|
11
15
|
export function serialize(repo) {
|
|
@@ -131,3 +135,132 @@ export function changeStatus(projects, { project, id, status, reason }) {
|
|
|
131
135
|
return { code: 400, body: { error: e.message } };
|
|
132
136
|
}
|
|
133
137
|
}
|
|
138
|
+
|
|
139
|
+
const revision = (text) => crypto.createHash('sha256').update(text).digest('hex');
|
|
140
|
+
|
|
141
|
+
function projectFor(projects, id) {
|
|
142
|
+
const project = projects.find((item) => item.id === id);
|
|
143
|
+
if (!project) return { code: 404, body: { error: `no project "${id}"` } };
|
|
144
|
+
if (!project.alive) return { code: 410, body: { error: 'project path is gone' } };
|
|
145
|
+
return { project };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function readProjectConfig(projects, id) {
|
|
149
|
+
const found = projectFor(projects, id);
|
|
150
|
+
if (!found.project) return found;
|
|
151
|
+
const file = path.join(found.project.path, '.changeledger', 'config.yml');
|
|
152
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
153
|
+
return { code: 200, body: { content, revision: revision(content) } };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function saveProjectConfig(projects, payload, { mutateConfig = mutateFileAtomic } = {}) {
|
|
157
|
+
const found = projectFor(projects, payload.project);
|
|
158
|
+
if (!found.project) return found;
|
|
159
|
+
if (typeof payload.content !== 'string' || typeof payload.revision !== 'string') {
|
|
160
|
+
return { code: 400, body: { error: 'content and revision are required' } };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let candidate;
|
|
164
|
+
try {
|
|
165
|
+
candidate = parseYaml(payload.content);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
return { code: 400, body: { error: error.message } };
|
|
168
|
+
}
|
|
169
|
+
if (String(candidate.project_id ?? '') !== String(found.project.id)) {
|
|
170
|
+
return { code: 400, body: { error: 'project_id cannot be changed from the viewer' } };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let repo;
|
|
174
|
+
try {
|
|
175
|
+
repo = loadRepo(found.project.path);
|
|
176
|
+
} catch {
|
|
177
|
+
return { code: 400, body: { error: 'unable to load the current project configuration' } };
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
resolveRepoPath(repo.repoRoot, candidate.changes_dir, 'changes_dir');
|
|
181
|
+
resolveSpecsDir(repo.repoRoot, candidate);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
return { code: 400, body: { error: error.message } };
|
|
184
|
+
}
|
|
185
|
+
let candidateRepo;
|
|
186
|
+
try {
|
|
187
|
+
candidateRepo = loadRepoWithConfig(repo.repoRoot, repo.changeledgerDir, candidate);
|
|
188
|
+
} catch {
|
|
189
|
+
return { code: 400, body: { error: 'candidate configuration cannot load the repository' } };
|
|
190
|
+
}
|
|
191
|
+
let errors;
|
|
192
|
+
try {
|
|
193
|
+
({ errors } = checkRepo(candidateRepo));
|
|
194
|
+
} catch {
|
|
195
|
+
return {
|
|
196
|
+
code: 400,
|
|
197
|
+
body: { error: 'candidate configuration violates the ChangeLedger contract' },
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
if (errors.length) return { code: 400, body: { error: errors[0].message } };
|
|
201
|
+
|
|
202
|
+
const file = path.join(repo.changeledgerDir, 'config.yml');
|
|
203
|
+
const projectName =
|
|
204
|
+
typeof candidate.project_name === 'string' && candidate.project_name.trim()
|
|
205
|
+
? candidate.project_name
|
|
206
|
+
: found.project.name;
|
|
207
|
+
try {
|
|
208
|
+
mutateConfig(file, (before) => {
|
|
209
|
+
if (revision(before) !== payload.revision) {
|
|
210
|
+
throw new Error('configuration changed on disk; reload before saving');
|
|
211
|
+
}
|
|
212
|
+
return payload.content;
|
|
213
|
+
});
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if (error.message === 'configuration changed on disk; reload before saving') {
|
|
216
|
+
return { code: 409, body: { error: error.message } };
|
|
217
|
+
}
|
|
218
|
+
return { code: 400, body: { error: 'unable to save project configuration' } };
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
code: 200,
|
|
222
|
+
body: { ok: true, name: projectName, revision: revision(payload.content) },
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function repairProjectPath(projects, payload, { localOnly = false } = {}) {
|
|
227
|
+
if (localOnly)
|
|
228
|
+
return { code: 403, body: { error: 'registry management is unavailable in local mode' } };
|
|
229
|
+
const project = projects.find((item) => item.id === payload.project);
|
|
230
|
+
if (!project) return { code: 404, body: { error: `no project "${payload.project}"` } };
|
|
231
|
+
if (typeof payload.path !== 'string' || !path.isAbsolute(payload.path)) {
|
|
232
|
+
return { code: 400, body: { error: 'project path must be absolute' } };
|
|
233
|
+
}
|
|
234
|
+
const root = path.resolve(payload.path);
|
|
235
|
+
let config;
|
|
236
|
+
try {
|
|
237
|
+
config = loadConfig(path.join(root, '.changeledger'));
|
|
238
|
+
} catch {
|
|
239
|
+
return { code: 400, body: { error: 'project path is not a ChangeLedger repository' } };
|
|
240
|
+
}
|
|
241
|
+
if (String(config.project_id ?? '') !== String(project.id)) {
|
|
242
|
+
return { code: 400, body: { error: 'project path belongs to a different project_id' } };
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
update(project.id, { name: config.project_name ?? project.name, path: root });
|
|
246
|
+
} catch {
|
|
247
|
+
return { code: 400, body: { error: 'unable to update project registry' } };
|
|
248
|
+
}
|
|
249
|
+
return { code: 200, body: { ok: true } };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function unregisterProject(projects, payload, { localOnly = false } = {}) {
|
|
253
|
+
if (localOnly)
|
|
254
|
+
return { code: 403, body: { error: 'registry management is unavailable in local mode' } };
|
|
255
|
+
const project = projects.find((item) => item.id === payload.project);
|
|
256
|
+
if (!project) return { code: 404, body: { error: `no project "${payload.project}"` } };
|
|
257
|
+
if (payload.confirm !== project.name) {
|
|
258
|
+
return { code: 400, body: { error: `type "${project.name}" to confirm` } };
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
remove(project.id);
|
|
262
|
+
} catch {
|
|
263
|
+
return { code: 400, body: { error: 'unable to update project registry' } };
|
|
264
|
+
}
|
|
265
|
+
return { code: 200, body: { ok: true } };
|
|
266
|
+
}
|
package/src/viewer/public/api.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
export const getProjects = () => fetch('/api/projects').then((r) => r.json());
|
|
2
2
|
|
|
3
|
+
export const getProjectConfig = (project) =>
|
|
4
|
+
fetch(`/api/project-config?project=${encodeURIComponent(project)}`).then(async (response) => {
|
|
5
|
+
const body = await response.json();
|
|
6
|
+
if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`);
|
|
7
|
+
return body;
|
|
8
|
+
});
|
|
9
|
+
|
|
3
10
|
export const getRepo = async (project) => {
|
|
4
11
|
const res = await fetch(`/api/repo?project=${encodeURIComponent(project)}`);
|
|
5
12
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
@@ -23,3 +30,22 @@ export const postStatus = (project, id, status, reason) =>
|
|
|
23
30
|
},
|
|
24
31
|
body: JSON.stringify({ project, id, status, ...(reason ? { reason } : {}) }),
|
|
25
32
|
});
|
|
33
|
+
|
|
34
|
+
const postProject = (route, body) =>
|
|
35
|
+
fetch(route, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: {
|
|
38
|
+
'Content-Type': 'application/json',
|
|
39
|
+
'x-changeledger-token': window.__CHANGELEDGER_TOKEN__,
|
|
40
|
+
},
|
|
41
|
+
body: JSON.stringify(body),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export const postProjectConfig = (project, content, revision) =>
|
|
45
|
+
postProject('/api/project-config', { project, content, revision });
|
|
46
|
+
|
|
47
|
+
export const postProjectPath = (project, path) =>
|
|
48
|
+
postProject('/api/project-path', { project, path });
|
|
49
|
+
|
|
50
|
+
export const postProjectRemove = (project, confirm) =>
|
|
51
|
+
postProject('/api/project-remove', { project, confirm });
|
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
export const VIEWER_STATE_KEY = 'changeledger.viewer-state.v1';
|
|
2
|
+
|
|
3
|
+
const VALID_VIEWS = new Set(['board', 'table', 'graph', 'specs', 'metrics', 'projects']);
|
|
4
|
+
const VALID_SORT_KEYS = new Set(['id', 'title', 'type', 'status', 'progress', 'deps']);
|
|
5
|
+
let storage = null;
|
|
6
|
+
|
|
7
|
+
const emptyProjectFilters = () => ({
|
|
8
|
+
type: 'all',
|
|
9
|
+
owner: 'all',
|
|
10
|
+
statuses: [],
|
|
11
|
+
showArchived: false,
|
|
12
|
+
showDiscarded: false,
|
|
13
|
+
});
|
|
14
|
+
|
|
1
15
|
export const state = {
|
|
2
16
|
repo: null,
|
|
3
17
|
lastJson: '',
|
|
@@ -9,14 +23,129 @@ export const state = {
|
|
|
9
23
|
showArchived: false,
|
|
10
24
|
showDiscarded: false,
|
|
11
25
|
},
|
|
26
|
+
projectFilters: {},
|
|
12
27
|
currentView: 'board',
|
|
13
28
|
sortKey: 'id',
|
|
14
29
|
sortDir: 1,
|
|
15
30
|
currentProject: null,
|
|
16
31
|
projectsList: [],
|
|
32
|
+
localOnly: false,
|
|
17
33
|
globalMode: false,
|
|
18
34
|
};
|
|
19
35
|
|
|
36
|
+
function currentProjectFilters() {
|
|
37
|
+
return {
|
|
38
|
+
type: state.filters.type,
|
|
39
|
+
owner: state.filters.owner,
|
|
40
|
+
statuses: [...state.filters.statuses],
|
|
41
|
+
showArchived: state.filters.showArchived,
|
|
42
|
+
showDiscarded: state.filters.showDiscarded,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function saveCurrentProjectFilters() {
|
|
47
|
+
if (state.currentProject) state.projectFilters[state.currentProject] = currentProjectFilters();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function applyProjectFilters(id) {
|
|
51
|
+
const filters = state.projectFilters[id] ?? emptyProjectFilters();
|
|
52
|
+
state.filters.type = typeof filters.type === 'string' ? filters.type : 'all';
|
|
53
|
+
state.filters.owner = typeof filters.owner === 'string' ? filters.owner : 'all';
|
|
54
|
+
state.filters.statuses = new Set(
|
|
55
|
+
Array.isArray(filters.statuses)
|
|
56
|
+
? filters.statuses.filter((value) => typeof value === 'string')
|
|
57
|
+
: [],
|
|
58
|
+
);
|
|
59
|
+
state.filters.showArchived = filters.showArchived === true;
|
|
60
|
+
state.filters.showDiscarded = filters.showDiscarded === true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function serializeViewerState() {
|
|
64
|
+
saveCurrentProjectFilters();
|
|
65
|
+
return {
|
|
66
|
+
version: 1,
|
|
67
|
+
currentProject: state.currentProject,
|
|
68
|
+
currentView: state.currentView,
|
|
69
|
+
globalMode: state.globalMode,
|
|
70
|
+
text: state.filters.text,
|
|
71
|
+
sortKey: state.sortKey,
|
|
72
|
+
sortDir: state.sortDir,
|
|
73
|
+
projects: state.projectFilters,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function persistViewerState() {
|
|
78
|
+
if (!storage) return false;
|
|
79
|
+
try {
|
|
80
|
+
storage.setItem(VIEWER_STATE_KEY, JSON.stringify(serializeViewerState()));
|
|
81
|
+
return true;
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function restoreViewerState(storageLike) {
|
|
88
|
+
storage = storageLike;
|
|
89
|
+
let snapshot;
|
|
90
|
+
try {
|
|
91
|
+
snapshot = JSON.parse(storage.getItem(VIEWER_STATE_KEY) || 'null');
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
if (snapshot?.version !== 1 || typeof snapshot !== 'object') return false;
|
|
96
|
+
if (typeof snapshot.currentProject === 'string') state.currentProject = snapshot.currentProject;
|
|
97
|
+
if (typeof snapshot.currentView === 'string') state.currentView = snapshot.currentView;
|
|
98
|
+
state.globalMode = snapshot.globalMode === true;
|
|
99
|
+
if (typeof snapshot.text === 'string') state.filters.text = snapshot.text;
|
|
100
|
+
if (typeof snapshot.sortKey === 'string') state.sortKey = snapshot.sortKey;
|
|
101
|
+
if (snapshot.sortDir === 1 || snapshot.sortDir === -1) state.sortDir = snapshot.sortDir;
|
|
102
|
+
if (
|
|
103
|
+
snapshot.projects &&
|
|
104
|
+
typeof snapshot.projects === 'object' &&
|
|
105
|
+
!Array.isArray(snapshot.projects)
|
|
106
|
+
) {
|
|
107
|
+
state.projectFilters = snapshot.projects;
|
|
108
|
+
}
|
|
109
|
+
if (state.currentProject) applyProjectFilters(state.currentProject);
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function initializeProjects(projects, serverCurrent) {
|
|
114
|
+
state.projectsList = projects;
|
|
115
|
+
const alive = new Set(projects.filter((project) => project.alive).map((project) => project.id));
|
|
116
|
+
const selected = alive.has(state.currentProject)
|
|
117
|
+
? state.currentProject
|
|
118
|
+
: alive.has(serverCurrent)
|
|
119
|
+
? serverCurrent
|
|
120
|
+
: (projects.find((project) => project.alive)?.id ?? null);
|
|
121
|
+
if (selected !== state.currentProject) {
|
|
122
|
+
saveCurrentProjectFilters();
|
|
123
|
+
state.currentProject = selected;
|
|
124
|
+
if (selected) applyProjectFilters(selected);
|
|
125
|
+
}
|
|
126
|
+
if (!selected) state.globalMode = false;
|
|
127
|
+
persistViewerState();
|
|
128
|
+
return selected;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function normalizeRepoState(repo) {
|
|
132
|
+
if (!VALID_VIEWS.has(state.currentView)) state.currentView = 'board';
|
|
133
|
+
if (!VALID_SORT_KEYS.has(state.sortKey)) {
|
|
134
|
+
state.sortKey = 'id';
|
|
135
|
+
state.sortDir = 1;
|
|
136
|
+
}
|
|
137
|
+
if (state.sortDir !== 1 && state.sortDir !== -1) state.sortDir = 1;
|
|
138
|
+
if (!repo.types.includes(state.filters.type)) state.filters.type = 'all';
|
|
139
|
+
const owners = new Set(repo.changes.map((change) => change.owner).filter(Boolean));
|
|
140
|
+
if (state.filters.owner !== 'all' && !owners.has(state.filters.owner))
|
|
141
|
+
state.filters.owner = 'all';
|
|
142
|
+
const statuses = new Set(repo.statuses);
|
|
143
|
+
state.filters.statuses = new Set(
|
|
144
|
+
[...state.filters.statuses].filter((status) => statuses.has(status)),
|
|
145
|
+
);
|
|
146
|
+
persistViewerState();
|
|
147
|
+
}
|
|
148
|
+
|
|
20
149
|
export function setRepo(json) {
|
|
21
150
|
state.lastJson = json;
|
|
22
151
|
state.repo = JSON.parse(json);
|
|
@@ -28,19 +157,23 @@ export function invalidateCache() {
|
|
|
28
157
|
|
|
29
158
|
export function setTextFilter(text) {
|
|
30
159
|
state.filters.text = text;
|
|
160
|
+
persistViewerState();
|
|
31
161
|
}
|
|
32
162
|
|
|
33
163
|
export function setTypeFilter(type) {
|
|
34
164
|
state.filters.type = type;
|
|
165
|
+
persistViewerState();
|
|
35
166
|
}
|
|
36
167
|
|
|
37
168
|
export function setOwnerFilter(owner) {
|
|
38
169
|
state.filters.owner = owner;
|
|
170
|
+
persistViewerState();
|
|
39
171
|
}
|
|
40
172
|
|
|
41
173
|
export function toggleStatusFilter(status) {
|
|
42
174
|
if (state.filters.statuses.has(status)) state.filters.statuses.delete(status);
|
|
43
175
|
else state.filters.statuses.add(status);
|
|
176
|
+
persistViewerState();
|
|
44
177
|
return state.filters.statuses.has(status);
|
|
45
178
|
}
|
|
46
179
|
|
|
@@ -48,29 +181,33 @@ export function clearStatusFilters() {
|
|
|
48
181
|
state.filters.statuses.clear();
|
|
49
182
|
state.filters.showArchived = false;
|
|
50
183
|
state.filters.showDiscarded = false;
|
|
184
|
+
persistViewerState();
|
|
51
185
|
}
|
|
52
186
|
|
|
53
187
|
export function toggleShowArchived() {
|
|
54
188
|
state.filters.showArchived = !state.filters.showArchived;
|
|
189
|
+
persistViewerState();
|
|
55
190
|
return state.filters.showArchived;
|
|
56
191
|
}
|
|
57
192
|
|
|
58
193
|
export function toggleShowDiscarded() {
|
|
59
194
|
state.filters.showDiscarded = !state.filters.showDiscarded;
|
|
195
|
+
persistViewerState();
|
|
60
196
|
return state.filters.showDiscarded;
|
|
61
197
|
}
|
|
62
198
|
|
|
63
|
-
export function setView(
|
|
64
|
-
state.currentView =
|
|
199
|
+
export function setView(view) {
|
|
200
|
+
state.currentView = view;
|
|
65
201
|
state.globalMode = false;
|
|
202
|
+
persistViewerState();
|
|
66
203
|
}
|
|
67
204
|
|
|
68
205
|
export function selectProject(id) {
|
|
206
|
+
saveCurrentProjectFilters();
|
|
69
207
|
state.currentProject = id;
|
|
70
208
|
state.lastJson = '';
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
state.filters.statuses.clear();
|
|
209
|
+
applyProjectFilters(id);
|
|
210
|
+
persistViewerState();
|
|
74
211
|
}
|
|
75
212
|
|
|
76
213
|
export function setSortKey(key) {
|
|
@@ -79,9 +216,11 @@ export function setSortKey(key) {
|
|
|
79
216
|
state.sortKey = key;
|
|
80
217
|
state.sortDir = 1;
|
|
81
218
|
}
|
|
219
|
+
persistViewerState();
|
|
82
220
|
}
|
|
83
221
|
|
|
84
222
|
export function toggleGlobalMode() {
|
|
85
223
|
state.globalMode = !state.globalMode;
|
|
224
|
+
persistViewerState();
|
|
86
225
|
return state.globalMode;
|
|
87
226
|
}
|
package/src/viewer/public/app.js
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
getGitRefs,
|
|
3
|
+
getProjectConfig,
|
|
4
|
+
getProjects,
|
|
5
|
+
getRepo,
|
|
6
|
+
postProjectConfig,
|
|
7
|
+
postProjectPath,
|
|
8
|
+
postProjectRemove,
|
|
9
|
+
postStatus,
|
|
10
|
+
searchAllProjects,
|
|
11
|
+
} from './api.js';
|
|
2
12
|
import {
|
|
3
13
|
clearStatusFilters,
|
|
14
|
+
initializeProjects,
|
|
4
15
|
invalidateCache,
|
|
16
|
+
normalizeRepoState,
|
|
17
|
+
restoreViewerState,
|
|
5
18
|
selectProject,
|
|
6
19
|
setOwnerFilter,
|
|
7
20
|
setRepo,
|
|
@@ -47,8 +60,9 @@ const $ = (sel) => document.querySelector(sel);
|
|
|
47
60
|
initMermaid();
|
|
48
61
|
|
|
49
62
|
async function loadProjects() {
|
|
50
|
-
const { projects, current } = await getProjects();
|
|
63
|
+
const { projects, current, localOnly } = await getProjects();
|
|
51
64
|
state.projectsList = projects;
|
|
65
|
+
state.localOnly = localOnly;
|
|
52
66
|
const sel = $('#project');
|
|
53
67
|
litRender(
|
|
54
68
|
projects.map(
|
|
@@ -57,7 +71,7 @@ async function loadProjects() {
|
|
|
57
71
|
),
|
|
58
72
|
sel,
|
|
59
73
|
);
|
|
60
|
-
|
|
74
|
+
initializeProjects(projects, current);
|
|
61
75
|
if (state.currentProject) sel.value = state.currentProject;
|
|
62
76
|
sel.style.display = projects.length > 1 ? '' : 'none';
|
|
63
77
|
await load();
|
|
@@ -65,23 +79,34 @@ async function loadProjects() {
|
|
|
65
79
|
|
|
66
80
|
async function load() {
|
|
67
81
|
if (!state.currentProject) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
82
|
+
if (state.currentView === 'projects') {
|
|
83
|
+
syncViewerShell();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
showNoProjects();
|
|
72
87
|
return;
|
|
73
88
|
}
|
|
74
89
|
try {
|
|
75
90
|
const text = await getRepo(state.currentProject);
|
|
76
91
|
if (text === state.lastJson) return;
|
|
77
92
|
setRepo(text);
|
|
93
|
+
normalizeRepoState(state.repo);
|
|
78
94
|
hydrateFilters();
|
|
79
|
-
|
|
95
|
+
syncViewerShell();
|
|
80
96
|
} catch (e) {
|
|
81
97
|
litRender(html`<p style="color:var(--bug);padding:20px">${e.message}</p>`, $('#board'));
|
|
82
98
|
}
|
|
83
99
|
}
|
|
84
100
|
|
|
101
|
+
export function showNoProjects(root = document) {
|
|
102
|
+
setView('board');
|
|
103
|
+
litRender(
|
|
104
|
+
html`<p class="empty" style="padding:20px">No projects registered. Run <code>changeledger init</code> in a repo.</p>`,
|
|
105
|
+
root.querySelector('#board'),
|
|
106
|
+
);
|
|
107
|
+
syncViewerShell(root, false);
|
|
108
|
+
}
|
|
109
|
+
|
|
85
110
|
// Rebuilt on each project load (types/statuses can differ per project).
|
|
86
111
|
function hydrateFilters() {
|
|
87
112
|
litRender(
|
|
@@ -176,6 +201,7 @@ function render() {
|
|
|
176
201
|
else if (state.currentView === 'table') renderTable();
|
|
177
202
|
else if (state.currentView === 'specs') renderSpecs();
|
|
178
203
|
else if (state.currentView === 'metrics') renderMetrics();
|
|
204
|
+
else if (state.currentView === 'projects') renderProjects();
|
|
179
205
|
else renderBoard();
|
|
180
206
|
}
|
|
181
207
|
|
|
@@ -576,12 +602,242 @@ function openSpec(s) {
|
|
|
576
602
|
renderExpandableMermaid($('#detail'));
|
|
577
603
|
}
|
|
578
604
|
|
|
579
|
-
const VIEWS = ['board', 'table', 'graph', 'specs', 'metrics'];
|
|
605
|
+
const VIEWS = ['board', 'table', 'graph', 'specs', 'metrics', 'projects'];
|
|
580
606
|
|
|
581
607
|
function renderMetrics() {
|
|
582
608
|
litRender(metricsHtml(state.repo.metrics || {}), $('#metrics'));
|
|
583
609
|
}
|
|
584
610
|
|
|
611
|
+
export function syncViewerShell(root = document, renderContent = true) {
|
|
612
|
+
root.querySelector('#search').value = state.filters.text;
|
|
613
|
+
root.querySelector('#toggle-global').classList.toggle('active', state.globalMode);
|
|
614
|
+
for (const name of VIEWS) {
|
|
615
|
+
root.querySelector(`#view-${name}`).classList.toggle('active', name === state.currentView);
|
|
616
|
+
root
|
|
617
|
+
.querySelector(`#${name}`)
|
|
618
|
+
.classList.toggle('hidden', state.globalMode || name !== state.currentView);
|
|
619
|
+
}
|
|
620
|
+
root.querySelector('#global').classList.toggle('hidden', !state.globalMode);
|
|
621
|
+
if (!renderContent) return;
|
|
622
|
+
if (state.globalMode) renderGlobal();
|
|
623
|
+
else render();
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
export function restoreInitialViewerShell(root = document, getStorage = () => window.localStorage) {
|
|
627
|
+
let browserStorage = null;
|
|
628
|
+
try {
|
|
629
|
+
browserStorage = getStorage();
|
|
630
|
+
} catch {
|
|
631
|
+
// Storage access itself may be forbidden (opaque origins/privacy policy).
|
|
632
|
+
}
|
|
633
|
+
restoreViewerState(browserStorage);
|
|
634
|
+
syncViewerShell(root, false);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
let managedProject = null;
|
|
638
|
+
let managedConfig = null;
|
|
639
|
+
|
|
640
|
+
export function projectsViewTemplate(projects, selected, config, localOnly) {
|
|
641
|
+
const project = projects.find((item) => item.id === selected);
|
|
642
|
+
return html`<div class="projects-shell">
|
|
643
|
+
<div class="projects-list">
|
|
644
|
+
<div class="projects-heading">
|
|
645
|
+
<div><span class="eyebrow">Registry</span><h1>Projects</h1></div>
|
|
646
|
+
<span class="count">${projects.length}</span>
|
|
647
|
+
</div>
|
|
648
|
+
${
|
|
649
|
+
projects.length
|
|
650
|
+
? html`<div class="project-rows">${projects.map(
|
|
651
|
+
(
|
|
652
|
+
item,
|
|
653
|
+
) => html`<button type="button" class=${`project-row${item.id === selected ? ' active' : ''}`} data-manage-project=${item.id}>
|
|
654
|
+
<span class=${`health-dot ${item.alive ? 'available' : 'missing'}`} aria-hidden="true"></span>
|
|
655
|
+
<span class="project-summary"><strong>${item.name}</strong><small>${item.path}</small></span>
|
|
656
|
+
<span class="mono project-id">${item.id}</span>
|
|
657
|
+
<span class=${`project-health ${item.alive ? 'available' : 'missing'}`}>${item.alive ? 'Available' : 'Missing'}</span>
|
|
658
|
+
</button>`,
|
|
659
|
+
)}</div>`
|
|
660
|
+
: html`<p class="empty">No projects registered.</p>`
|
|
661
|
+
}
|
|
662
|
+
</div>
|
|
663
|
+
<div class="project-editor">
|
|
664
|
+
${
|
|
665
|
+
!project
|
|
666
|
+
? html`<div class="project-placeholder"><span class="eyebrow">Configuration</span><h2>Select a project</h2><p>Inspect its registry entry and edit its ChangeLedger configuration.</p></div>`
|
|
667
|
+
: html`<div class="project-editor-head">
|
|
668
|
+
<div><span class="eyebrow">${project.id}</span><h2>${project.name}</h2><p>${project.path}</p></div>
|
|
669
|
+
<span class=${`project-health ${project.alive ? 'available' : 'missing'}`}>${project.alive ? 'Available' : 'Missing'}</span>
|
|
670
|
+
</div>
|
|
671
|
+
${
|
|
672
|
+
!localOnly
|
|
673
|
+
? html`<form class="project-path-form">
|
|
674
|
+
<label for="project-path">Registered path</label>
|
|
675
|
+
<div><input id="project-path" name="path" .value=${project.path} /><button class="button secondary" type="submit">Repair path</button></div>
|
|
676
|
+
</form>`
|
|
677
|
+
: nothing
|
|
678
|
+
}
|
|
679
|
+
${
|
|
680
|
+
project.alive
|
|
681
|
+
? config?.loading
|
|
682
|
+
? html`<p class="empty">Loading configuration…</p>`
|
|
683
|
+
: html`<form class="config-form">
|
|
684
|
+
<div class="config-label"><label for="project-config">.changeledger/config.yml</label><button type="button" class="text-button" data-reload-config>Reload</button></div>
|
|
685
|
+
<textarea id="project-config" spellcheck="false" .value=${config?.content ?? ''}></textarea>
|
|
686
|
+
<p class="project-error" ?hidden=${!config?.error}>${config?.error ?? ''}</p>
|
|
687
|
+
<div class="project-actions"><button class="button" type="submit">Save configuration</button></div>
|
|
688
|
+
</form>`
|
|
689
|
+
: html`<div class="missing-config"><h3>Configuration unavailable</h3><p>Repair the registered path to edit this project.</p></div>`
|
|
690
|
+
}
|
|
691
|
+
${
|
|
692
|
+
!localOnly
|
|
693
|
+
? html`<div class="danger-zone"><div><strong>Unregister project</strong><p>Removes this local registry entry. Repository files are never deleted.</p></div><button type="button" class="button danger" data-unregister>Unregister</button></div>`
|
|
694
|
+
: nothing
|
|
695
|
+
}`
|
|
696
|
+
}
|
|
697
|
+
</div>
|
|
698
|
+
</div>`;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
async function openManagedProject(id, { reload = false } = {}) {
|
|
702
|
+
managedProject = id;
|
|
703
|
+
const project = state.projectsList.find((item) => item.id === id);
|
|
704
|
+
if (!project?.alive) {
|
|
705
|
+
managedConfig = null;
|
|
706
|
+
renderProjects();
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
if (!reload && managedConfig?.id === id && !managedConfig.error) {
|
|
710
|
+
renderProjects();
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
managedConfig = { id, loading: true };
|
|
714
|
+
renderProjects();
|
|
715
|
+
try {
|
|
716
|
+
managedConfig = { id, ...(await getProjectConfig(id)) };
|
|
717
|
+
} catch (error) {
|
|
718
|
+
managedConfig = { id, content: '', revision: '', error: error.message };
|
|
719
|
+
}
|
|
720
|
+
renderProjects();
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
export function setProjectFormPending(root, pending) {
|
|
724
|
+
root.querySelectorAll('button, input, textarea').forEach((control) => {
|
|
725
|
+
control.disabled = pending;
|
|
726
|
+
});
|
|
727
|
+
root.classList.toggle('is-pending', pending);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
export async function projectMutation(root, request, onSuccess) {
|
|
731
|
+
setProjectFormPending(root, true);
|
|
732
|
+
const error = root.querySelector('.project-error');
|
|
733
|
+
if (error) error.hidden = true;
|
|
734
|
+
try {
|
|
735
|
+
const response = await request();
|
|
736
|
+
const body = await response.json();
|
|
737
|
+
if (!response.ok) throw new Error(body.error || 'Project update failed.');
|
|
738
|
+
await onSuccess(body);
|
|
739
|
+
} catch (failure) {
|
|
740
|
+
if (error) {
|
|
741
|
+
error.textContent = failure.message;
|
|
742
|
+
error.hidden = false;
|
|
743
|
+
} else alert(failure.message);
|
|
744
|
+
} finally {
|
|
745
|
+
setProjectFormPending(root, false);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
export function requestUnregisterConfirmation(project, ask = prompt) {
|
|
750
|
+
return ask(
|
|
751
|
+
`Type "${project.name}" to unregister this project. No repository files will be deleted.`,
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
async function refreshProjectRegistry() {
|
|
756
|
+
const { projects, current, localOnly } = await getProjects();
|
|
757
|
+
state.localOnly = localOnly;
|
|
758
|
+
initializeProjects(projects, current);
|
|
759
|
+
const select = $('#project');
|
|
760
|
+
litRender(
|
|
761
|
+
projects.map(
|
|
762
|
+
(item) =>
|
|
763
|
+
html`<option value=${item.id} ?disabled=${!item.alive}>${item.name}${item.alive ? '' : ' (missing)'}</option>`,
|
|
764
|
+
),
|
|
765
|
+
select,
|
|
766
|
+
);
|
|
767
|
+
if (state.currentProject) select.value = state.currentProject;
|
|
768
|
+
select.style.display = projects.length > 1 ? '' : 'none';
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function renderProjects() {
|
|
772
|
+
const root = $('#projects');
|
|
773
|
+
litRender(
|
|
774
|
+
projectsViewTemplate(state.projectsList, managedProject, managedConfig, state.localOnly),
|
|
775
|
+
root,
|
|
776
|
+
);
|
|
777
|
+
bindProjectViewActions(root, {
|
|
778
|
+
select: (id) => openManagedProject(id),
|
|
779
|
+
reload: () => openManagedProject(managedProject, { reload: true }),
|
|
780
|
+
save: (content, configForm) =>
|
|
781
|
+
projectMutation(
|
|
782
|
+
configForm,
|
|
783
|
+
() => postProjectConfig(managedProject, content, managedConfig.revision),
|
|
784
|
+
async (body) => {
|
|
785
|
+
managedConfig = { id: managedProject, content, revision: body.revision };
|
|
786
|
+
await refreshProjectRegistry();
|
|
787
|
+
renderProjects();
|
|
788
|
+
},
|
|
789
|
+
),
|
|
790
|
+
repair: (projectPath, pathForm) =>
|
|
791
|
+
projectMutation(
|
|
792
|
+
pathForm,
|
|
793
|
+
() => postProjectPath(managedProject, projectPath),
|
|
794
|
+
async () => {
|
|
795
|
+
await refreshProjectRegistry();
|
|
796
|
+
await openManagedProject(managedProject, { reload: true });
|
|
797
|
+
},
|
|
798
|
+
),
|
|
799
|
+
unregister: (editor) => {
|
|
800
|
+
const project = state.projectsList.find((item) => item.id === managedProject);
|
|
801
|
+
const confirm = requestUnregisterConfirmation(project);
|
|
802
|
+
if (confirm === null) return;
|
|
803
|
+
projectMutation(
|
|
804
|
+
editor,
|
|
805
|
+
() => postProjectRemove(managedProject, confirm),
|
|
806
|
+
async () => {
|
|
807
|
+
managedProject = null;
|
|
808
|
+
managedConfig = null;
|
|
809
|
+
await refreshProjectRegistry();
|
|
810
|
+
if (state.currentProject) await load();
|
|
811
|
+
renderProjects();
|
|
812
|
+
},
|
|
813
|
+
);
|
|
814
|
+
},
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
export function bindProjectViewActions(root, handlers) {
|
|
819
|
+
root.querySelectorAll('[data-manage-project]').forEach((button) => {
|
|
820
|
+
button.onclick = () => handlers.select(button.dataset.manageProject);
|
|
821
|
+
});
|
|
822
|
+
const reload = root.querySelector('[data-reload-config]');
|
|
823
|
+
if (reload) reload.onclick = () => handlers.reload();
|
|
824
|
+
const configForm = root.querySelector('.config-form');
|
|
825
|
+
if (configForm)
|
|
826
|
+
configForm.onsubmit = (event) => {
|
|
827
|
+
event.preventDefault();
|
|
828
|
+
handlers.save(configForm.querySelector('textarea').value, configForm);
|
|
829
|
+
};
|
|
830
|
+
const pathForm = root.querySelector('.project-path-form');
|
|
831
|
+
if (pathForm)
|
|
832
|
+
pathForm.onsubmit = (event) => {
|
|
833
|
+
event.preventDefault();
|
|
834
|
+
handlers.repair(pathForm.elements.path.value, pathForm);
|
|
835
|
+
};
|
|
836
|
+
const unregister = root.querySelector('[data-unregister]');
|
|
837
|
+
if (unregister)
|
|
838
|
+
unregister.onclick = () => handlers.unregister(root.querySelector('.project-editor'));
|
|
839
|
+
}
|
|
840
|
+
|
|
585
841
|
function activateView(v) {
|
|
586
842
|
setView(v);
|
|
587
843
|
$('#toggle-global').classList.remove('active');
|
|
@@ -660,6 +916,7 @@ const fmtDate = (iso) => {
|
|
|
660
916
|
// Wire the DOM and start polling. Guarded below so importing this module (tests)
|
|
661
917
|
// has no side effects; only a real browser page bootstraps.
|
|
662
918
|
function bootstrap() {
|
|
919
|
+
restoreInitialViewerShell();
|
|
663
920
|
diagramLightbox = createDiagramLightbox({
|
|
664
921
|
overlay: $('#diagram-overlay'),
|
|
665
922
|
canvas: $('#diagram-canvas'),
|
|
@@ -692,6 +949,7 @@ function bootstrap() {
|
|
|
692
949
|
$('#view-graph').onclick = () => activateView('graph');
|
|
693
950
|
$('#view-specs').onclick = () => activateView('specs');
|
|
694
951
|
$('#view-metrics').onclick = () => activateView('metrics');
|
|
952
|
+
$('#view-projects').onclick = () => activateView('projects');
|
|
695
953
|
$('#project').onchange = (e) => {
|
|
696
954
|
selectProject(e.target.value);
|
|
697
955
|
load();
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
<button type="button" id="view-graph" class="tab">Graph</button>
|
|
37
37
|
<button type="button" id="view-specs" class="tab">Specs</button>
|
|
38
38
|
<button type="button" id="view-metrics" class="tab">Metrics</button>
|
|
39
|
+
<button type="button" id="view-projects" class="tab">Projects</button>
|
|
39
40
|
</div>
|
|
40
41
|
</header>
|
|
41
42
|
|
|
@@ -45,6 +46,7 @@
|
|
|
45
46
|
<section id="specs" class="specs-view hidden"></section>
|
|
46
47
|
<section id="global" class="global-view hidden"></section>
|
|
47
48
|
<section id="metrics" class="metrics-view hidden"></section>
|
|
49
|
+
<section id="projects" class="projects-view hidden"></section>
|
|
48
50
|
|
|
49
51
|
<div id="overlay" class="overlay hidden">
|
|
50
52
|
<div id="detail" class="detail"></div>
|
|
@@ -90,6 +90,208 @@ body {
|
|
|
90
90
|
gap: 4px;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/* Project management */
|
|
94
|
+
.projects-view {
|
|
95
|
+
padding: 18px;
|
|
96
|
+
}
|
|
97
|
+
.projects-shell {
|
|
98
|
+
display: grid;
|
|
99
|
+
grid-template-columns: minmax(320px, 0.8fr) minmax(460px, 1.2fr);
|
|
100
|
+
gap: 16px;
|
|
101
|
+
max-width: 1500px;
|
|
102
|
+
margin: 0 auto;
|
|
103
|
+
}
|
|
104
|
+
.projects-list,
|
|
105
|
+
.project-editor {
|
|
106
|
+
min-width: 0;
|
|
107
|
+
border: 1px solid var(--line);
|
|
108
|
+
border-radius: 14px;
|
|
109
|
+
background: var(--panel);
|
|
110
|
+
}
|
|
111
|
+
.projects-heading,
|
|
112
|
+
.project-editor-head,
|
|
113
|
+
.danger-zone {
|
|
114
|
+
display: flex;
|
|
115
|
+
align-items: center;
|
|
116
|
+
justify-content: space-between;
|
|
117
|
+
gap: 16px;
|
|
118
|
+
}
|
|
119
|
+
.projects-heading,
|
|
120
|
+
.project-editor-head {
|
|
121
|
+
padding: 18px 20px;
|
|
122
|
+
border-bottom: 1px solid var(--line);
|
|
123
|
+
}
|
|
124
|
+
.projects-heading h1,
|
|
125
|
+
.project-editor-head h2,
|
|
126
|
+
.project-placeholder h2 {
|
|
127
|
+
margin: 2px 0 0;
|
|
128
|
+
}
|
|
129
|
+
.project-editor-head p,
|
|
130
|
+
.project-placeholder p,
|
|
131
|
+
.danger-zone p,
|
|
132
|
+
.missing-config p {
|
|
133
|
+
margin: 4px 0 0;
|
|
134
|
+
color: var(--muted);
|
|
135
|
+
}
|
|
136
|
+
.eyebrow {
|
|
137
|
+
color: var(--accent);
|
|
138
|
+
font-size: 10px;
|
|
139
|
+
font-weight: 700;
|
|
140
|
+
letter-spacing: 0.12em;
|
|
141
|
+
text-transform: uppercase;
|
|
142
|
+
}
|
|
143
|
+
.project-rows {
|
|
144
|
+
padding: 8px;
|
|
145
|
+
}
|
|
146
|
+
.project-row {
|
|
147
|
+
width: 100%;
|
|
148
|
+
display: grid;
|
|
149
|
+
grid-template-columns: 10px minmax(0, 1fr) auto auto;
|
|
150
|
+
align-items: center;
|
|
151
|
+
gap: 10px;
|
|
152
|
+
padding: 11px 12px;
|
|
153
|
+
border: 1px solid transparent;
|
|
154
|
+
border-radius: 9px;
|
|
155
|
+
background: transparent;
|
|
156
|
+
color: var(--text);
|
|
157
|
+
text-align: left;
|
|
158
|
+
cursor: pointer;
|
|
159
|
+
}
|
|
160
|
+
.project-row:hover,
|
|
161
|
+
.project-row.active {
|
|
162
|
+
border-color: var(--line);
|
|
163
|
+
background: var(--panel-2);
|
|
164
|
+
}
|
|
165
|
+
.health-dot {
|
|
166
|
+
width: 8px;
|
|
167
|
+
height: 8px;
|
|
168
|
+
border-radius: 50%;
|
|
169
|
+
background: var(--muted);
|
|
170
|
+
}
|
|
171
|
+
.health-dot.available {
|
|
172
|
+
background: var(--done);
|
|
173
|
+
}
|
|
174
|
+
.health-dot.missing {
|
|
175
|
+
background: var(--blocked);
|
|
176
|
+
}
|
|
177
|
+
.project-summary {
|
|
178
|
+
min-width: 0;
|
|
179
|
+
display: grid;
|
|
180
|
+
}
|
|
181
|
+
.project-summary small {
|
|
182
|
+
overflow: hidden;
|
|
183
|
+
color: var(--muted);
|
|
184
|
+
text-overflow: ellipsis;
|
|
185
|
+
white-space: nowrap;
|
|
186
|
+
}
|
|
187
|
+
.project-health {
|
|
188
|
+
padding: 3px 8px;
|
|
189
|
+
border-radius: 999px;
|
|
190
|
+
font-size: 10px;
|
|
191
|
+
font-weight: 700;
|
|
192
|
+
text-transform: uppercase;
|
|
193
|
+
}
|
|
194
|
+
.project-health.available {
|
|
195
|
+
color: var(--done);
|
|
196
|
+
background: rgba(63, 185, 80, 0.1);
|
|
197
|
+
}
|
|
198
|
+
.project-health.missing {
|
|
199
|
+
color: var(--blocked);
|
|
200
|
+
background: rgba(248, 81, 73, 0.1);
|
|
201
|
+
}
|
|
202
|
+
.project-placeholder,
|
|
203
|
+
.missing-config {
|
|
204
|
+
padding: 28px 20px;
|
|
205
|
+
}
|
|
206
|
+
.project-path-form,
|
|
207
|
+
.config-form {
|
|
208
|
+
padding: 16px 20px;
|
|
209
|
+
border-bottom: 1px solid var(--line);
|
|
210
|
+
}
|
|
211
|
+
.project-path-form label,
|
|
212
|
+
.config-label label {
|
|
213
|
+
color: var(--muted);
|
|
214
|
+
font-size: 11px;
|
|
215
|
+
font-weight: 600;
|
|
216
|
+
}
|
|
217
|
+
.project-path-form > div {
|
|
218
|
+
display: flex;
|
|
219
|
+
gap: 8px;
|
|
220
|
+
margin-top: 7px;
|
|
221
|
+
}
|
|
222
|
+
.project-path-form input {
|
|
223
|
+
flex: 1;
|
|
224
|
+
min-width: 0;
|
|
225
|
+
padding: 8px 10px;
|
|
226
|
+
border: 1px solid var(--line);
|
|
227
|
+
border-radius: 8px;
|
|
228
|
+
background: var(--bg);
|
|
229
|
+
color: var(--text);
|
|
230
|
+
}
|
|
231
|
+
.config-label {
|
|
232
|
+
display: flex;
|
|
233
|
+
justify-content: space-between;
|
|
234
|
+
margin-bottom: 7px;
|
|
235
|
+
}
|
|
236
|
+
.config-form textarea {
|
|
237
|
+
width: 100%;
|
|
238
|
+
min-height: 430px;
|
|
239
|
+
resize: vertical;
|
|
240
|
+
padding: 14px;
|
|
241
|
+
border: 1px solid var(--line);
|
|
242
|
+
border-radius: 9px;
|
|
243
|
+
background: #0b0d11;
|
|
244
|
+
color: #d9e1ea;
|
|
245
|
+
font:
|
|
246
|
+
12px / 1.55 ui-monospace,
|
|
247
|
+
monospace;
|
|
248
|
+
tab-size: 2;
|
|
249
|
+
}
|
|
250
|
+
.project-actions {
|
|
251
|
+
display: flex;
|
|
252
|
+
justify-content: flex-end;
|
|
253
|
+
margin-top: 10px;
|
|
254
|
+
}
|
|
255
|
+
.project-error {
|
|
256
|
+
margin: 8px 0 0;
|
|
257
|
+
color: #ff8078;
|
|
258
|
+
font-size: 12px;
|
|
259
|
+
}
|
|
260
|
+
.text-button {
|
|
261
|
+
border: 0;
|
|
262
|
+
background: transparent;
|
|
263
|
+
color: var(--accent);
|
|
264
|
+
cursor: pointer;
|
|
265
|
+
}
|
|
266
|
+
.danger-zone {
|
|
267
|
+
margin: 20px;
|
|
268
|
+
padding: 14px;
|
|
269
|
+
border: 1px solid rgba(248, 81, 73, 0.35);
|
|
270
|
+
border-radius: 10px;
|
|
271
|
+
}
|
|
272
|
+
.danger-zone p {
|
|
273
|
+
font-size: 12px;
|
|
274
|
+
}
|
|
275
|
+
.button.danger {
|
|
276
|
+
border-color: var(--bug);
|
|
277
|
+
color: #ff8078;
|
|
278
|
+
}
|
|
279
|
+
.is-pending {
|
|
280
|
+
opacity: 0.65;
|
|
281
|
+
cursor: wait;
|
|
282
|
+
}
|
|
283
|
+
@media (max-width: 900px) {
|
|
284
|
+
.projects-shell {
|
|
285
|
+
grid-template-columns: 1fr;
|
|
286
|
+
}
|
|
287
|
+
.project-row {
|
|
288
|
+
grid-template-columns: 10px minmax(0, 1fr) auto;
|
|
289
|
+
}
|
|
290
|
+
.project-id {
|
|
291
|
+
display: none;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
93
295
|
.chip {
|
|
94
296
|
font-size: 12px;
|
|
95
297
|
padding: 5px 10px;
|
|
@@ -4,7 +4,16 @@ import path from 'node:path';
|
|
|
4
4
|
import { gitRefs } from '../../git.mjs';
|
|
5
5
|
import { publicDir } from '../../paths.mjs';
|
|
6
6
|
import { loadRepoAsync } from '../../repo.mjs';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
changeStatus,
|
|
9
|
+
readProjectConfig,
|
|
10
|
+
repairProjectPath,
|
|
11
|
+
resolveProjects,
|
|
12
|
+
saveProjectConfig,
|
|
13
|
+
searchProjects,
|
|
14
|
+
serialize,
|
|
15
|
+
unregisterProject,
|
|
16
|
+
} from '../domain.mjs';
|
|
8
17
|
import { isAuthorizedWrite, isLocalHost } from './security.mjs';
|
|
9
18
|
|
|
10
19
|
const require = createRequire(import.meta.url);
|
|
@@ -78,7 +87,12 @@ export function createRequestListener(cwd, localOnly, token) {
|
|
|
78
87
|
const route = url.pathname;
|
|
79
88
|
const params = url.searchParams;
|
|
80
89
|
|
|
81
|
-
if (
|
|
90
|
+
if (
|
|
91
|
+
req.method === 'POST' &&
|
|
92
|
+
['/api/status', '/api/project-config', '/api/project-path', '/api/project-remove'].includes(
|
|
93
|
+
route,
|
|
94
|
+
)
|
|
95
|
+
) {
|
|
82
96
|
if (!isAuthorizedWrite(req, token)) {
|
|
83
97
|
send(res, 403, MIME['.json'], JSON.stringify({ error: 'unauthorized write' }));
|
|
84
98
|
req.destroy();
|
|
@@ -97,22 +111,47 @@ export function createRequestListener(cwd, localOnly, token) {
|
|
|
97
111
|
});
|
|
98
112
|
req.on('end', () => {
|
|
99
113
|
if (aborted) return;
|
|
100
|
-
let payload;
|
|
101
114
|
try {
|
|
102
|
-
payload
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
115
|
+
let payload;
|
|
116
|
+
try {
|
|
117
|
+
payload = JSON.parse(raw || '{}');
|
|
118
|
+
} catch {
|
|
119
|
+
send(res, 400, MIME['.json'], JSON.stringify({ error: 'invalid JSON body' }));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const { projects } = resolveProjects(cwd, localOnly);
|
|
123
|
+
const options = { localOnly };
|
|
124
|
+
const result =
|
|
125
|
+
route === '/api/status'
|
|
126
|
+
? changeStatus(projects, payload)
|
|
127
|
+
: route === '/api/project-config'
|
|
128
|
+
? saveProjectConfig(projects, payload, options)
|
|
129
|
+
: route === '/api/project-path'
|
|
130
|
+
? repairProjectPath(projects, payload, options)
|
|
131
|
+
: unregisterProject(projects, payload, options);
|
|
132
|
+
const { code, body } = result;
|
|
133
|
+
send(res, code, MIME['.json'], JSON.stringify(body));
|
|
134
|
+
} catch (error) {
|
|
135
|
+
process.stderr.write(`[changeledger-viewer] ${error.message}\n`);
|
|
136
|
+
send(res, 500, MIME['.json'], JSON.stringify({ error: 'Internal server error' }));
|
|
106
137
|
}
|
|
107
|
-
const { projects } = resolveProjects(cwd, localOnly);
|
|
108
|
-
const { code, body } = changeStatus(projects, payload);
|
|
109
|
-
send(res, code, MIME['.json'], JSON.stringify(body));
|
|
110
138
|
});
|
|
111
139
|
return;
|
|
112
140
|
}
|
|
113
141
|
|
|
114
142
|
if (route === '/api/projects') {
|
|
115
|
-
send(
|
|
143
|
+
send(
|
|
144
|
+
res,
|
|
145
|
+
200,
|
|
146
|
+
MIME['.json'],
|
|
147
|
+
JSON.stringify({ ...resolveProjects(cwd, localOnly), localOnly }),
|
|
148
|
+
);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (route === '/api/project-config') {
|
|
152
|
+
const { projects } = resolveProjects(cwd, localOnly);
|
|
153
|
+
const { code, body } = readProjectConfig(projects, params.get('project'));
|
|
154
|
+
send(res, code, MIME['.json'], JSON.stringify(body));
|
|
116
155
|
return;
|
|
117
156
|
}
|
|
118
157
|
if (route === '/api/git') {
|