dsh-arcvista 0.1.6 → 0.2.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 +4 -0
- package/README.md +59 -72
- package/index.js +15 -0
- package/lib/analyze/index.js +23 -76
- package/lib/analyze/walk.js +1 -1
- package/lib/jobs.js +2 -2
- package/lib/local-git.js +121 -0
- package/lib/store.js +579 -0
- package/locale/zh.json +2 -2
- package/package.json +2 -2
- package/public/js/api.js +2 -0
- package/public/js/app.js +3 -3
- package/public/js/pages/compare.js +17 -39
- package/public/js/pages/dashboard.js +24 -34
- package/public/js/pages/products.js +61 -29
- package/public/js/pages/settings.js +3 -3
- package/server.js +80 -72
- package/lib/db.js +0 -608
- package/lib/git.js +0 -584
package/lib/store.js
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON storage for ArcVista.
|
|
3
|
+
*
|
|
4
|
+
* Global plugin state (project list, jobs, settings) lives in the plugin data
|
|
5
|
+
* directory. Analysis artifacts live next to the analysed project so a project
|
|
6
|
+
* remains portable:
|
|
7
|
+
*
|
|
8
|
+
* <dataDir>/projects.json
|
|
9
|
+
* <dataDir>/jobs.json
|
|
10
|
+
* <dataDir>/settings.json
|
|
11
|
+
* <dataDir>/backups/arcvista-*.json
|
|
12
|
+
*
|
|
13
|
+
* <project>/.arcvista/project.json
|
|
14
|
+
* <project>/.arcvista/snapshots/index.json
|
|
15
|
+
* <project>/.arcvista/snapshots/<snapshotId>.json
|
|
16
|
+
* <project>/.arcvista/drill/<snapshotId>/<hash>.json
|
|
17
|
+
*/
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import crypto from 'node:crypto';
|
|
21
|
+
|
|
22
|
+
const STORE_VERSION = 1;
|
|
23
|
+
const DEFAULT_BACKUP_KEEP = 12;
|
|
24
|
+
|
|
25
|
+
export function nowIso() {
|
|
26
|
+
return new Date().toISOString();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function newId(prefix) {
|
|
30
|
+
return `${prefix}_${crypto.randomBytes(6).toString('hex')}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const SETTING_DEFAULTS = {
|
|
34
|
+
llm_enabled: 'auto',
|
|
35
|
+
llm_temperature: '0.2',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function ensureDir(dir, mode = 0o700) {
|
|
39
|
+
fs.mkdirSync(dir, { recursive: true, mode });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readJson(file, fallback) {
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
45
|
+
} catch {
|
|
46
|
+
return fallback;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let writeCounter = 0;
|
|
51
|
+
|
|
52
|
+
/** Atomic JSON write: same-directory temp file + rename, mode 0600. */
|
|
53
|
+
function writeJson(file, value) {
|
|
54
|
+
const dir = path.dirname(file);
|
|
55
|
+
ensureDir(dir);
|
|
56
|
+
const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.${writeCounter++}.tmp`);
|
|
57
|
+
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
58
|
+
fs.renameSync(tmp, file);
|
|
59
|
+
try {
|
|
60
|
+
fs.chmodSync(file, 0o600);
|
|
61
|
+
} catch {
|
|
62
|
+
/* best effort on non-POSIX filesystems */
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function artifactDir(projectPath) {
|
|
67
|
+
return path.join(path.resolve(projectPath), '.arcvista');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function snapshotsDir(projectPath) {
|
|
71
|
+
return path.join(artifactDir(projectPath), 'snapshots');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function snapshotIndexFile(projectPath) {
|
|
75
|
+
return path.join(snapshotsDir(projectPath), 'index.json');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function drillDir(projectPath) {
|
|
79
|
+
return path.join(artifactDir(projectPath), 'drill');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function drillFile(projectPath, snapshotId, modulePath) {
|
|
83
|
+
const hash = crypto.createHash('sha1').update(String(modulePath)).digest('hex').slice(0, 20);
|
|
84
|
+
return path.join(drillDir(projectPath), snapshotId, `${hash}.json`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function stamp(date = new Date()) {
|
|
88
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
89
|
+
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function backupFiles(backupDir) {
|
|
93
|
+
let names = [];
|
|
94
|
+
try {
|
|
95
|
+
names = fs.readdirSync(backupDir);
|
|
96
|
+
} catch {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
return names
|
|
100
|
+
.filter((name) => /^arcvista-[\w-]+\.json$/.test(name))
|
|
101
|
+
.map((name) => {
|
|
102
|
+
const file = path.join(backupDir, name);
|
|
103
|
+
try {
|
|
104
|
+
const stat = fs.statSync(file);
|
|
105
|
+
return { name, file, size: stat.size, mtimeMs: stat.mtimeMs, at: new Date(stat.mtimeMs).toISOString() };
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
.filter(Boolean)
|
|
111
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function normalizePath(value) {
|
|
115
|
+
return String(value || '').trim();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function normalizeProject(project) {
|
|
119
|
+
if (!project || typeof project !== 'object') return null;
|
|
120
|
+
const projectPath = normalizePath(project.path || project.repoUrl);
|
|
121
|
+
if (!projectPath) return null;
|
|
122
|
+
const timestamp = project.createdAt || nowIso();
|
|
123
|
+
return {
|
|
124
|
+
id: project.id || newId('prj'),
|
|
125
|
+
name: String(project.name || path.basename(projectPath)).slice(0, 120),
|
|
126
|
+
description: String(project.description || '').slice(0, 500),
|
|
127
|
+
path: projectPath,
|
|
128
|
+
repoUrl: projectPath,
|
|
129
|
+
mirrorUrl: null,
|
|
130
|
+
defaultBranch: String(project.defaultBranch || ''),
|
|
131
|
+
createdAt: timestamp,
|
|
132
|
+
updatedAt: project.updatedAt || timestamp,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function openStore(dataDir, options = {}) {
|
|
137
|
+
const root = path.resolve(dataDir);
|
|
138
|
+
ensureDir(root);
|
|
139
|
+
const backupDir = path.resolve(
|
|
140
|
+
options.backupDir || process.env.ARCVISTA_BACKUP_DIR || path.join(root, 'backups'),
|
|
141
|
+
);
|
|
142
|
+
ensureDir(backupDir);
|
|
143
|
+
const backupKeep = Number(options.backupKeep || process.env.ARCVISTA_BACKUP_KEEP || DEFAULT_BACKUP_KEEP);
|
|
144
|
+
|
|
145
|
+
const projectsFile = path.join(root, 'projects.json');
|
|
146
|
+
const jobsFile = path.join(root, 'jobs.json');
|
|
147
|
+
const settingsFile = path.join(root, 'settings.json');
|
|
148
|
+
|
|
149
|
+
const projectState = readJson(projectsFile, { version: STORE_VERSION, projects: [] });
|
|
150
|
+
let projects = Array.isArray(projectState.projects)
|
|
151
|
+
? projectState.projects.map(normalizeProject).filter(Boolean)
|
|
152
|
+
: [];
|
|
153
|
+
const jobState = readJson(jobsFile, { version: STORE_VERSION, jobs: [] });
|
|
154
|
+
let jobs = Array.isArray(jobState.jobs) ? jobState.jobs : [];
|
|
155
|
+
const settingsState = readJson(settingsFile, { version: STORE_VERSION, values: {} });
|
|
156
|
+
let settingValues = settingsState.values && typeof settingsState.values === 'object' ? settingsState.values : {};
|
|
157
|
+
|
|
158
|
+
// Snapshot location cache: snapshotId -> { productPath, file }.
|
|
159
|
+
const snapshotLocations = new Map();
|
|
160
|
+
|
|
161
|
+
function saveProjects() {
|
|
162
|
+
writeJson(projectsFile, { version: STORE_VERSION, projects });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function saveJobs() {
|
|
166
|
+
writeJson(jobsFile, { version: STORE_VERSION, jobs });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function saveSettings() {
|
|
170
|
+
writeJson(settingsFile, { version: STORE_VERSION, values: settingValues });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function projectById(id) {
|
|
174
|
+
return projects.find((item) => item.id === id) || null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function readSnapshotIndex(project) {
|
|
178
|
+
const data = readJson(snapshotIndexFile(project.path), {
|
|
179
|
+
version: STORE_VERSION,
|
|
180
|
+
snapshots: [],
|
|
181
|
+
});
|
|
182
|
+
return Array.isArray(data.snapshots) ? data.snapshots : [];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function writeSnapshotIndex(project, index) {
|
|
186
|
+
ensureDir(snapshotsDir(project.path));
|
|
187
|
+
writeJson(snapshotIndexFile(project.path), { version: STORE_VERSION, snapshots: index });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function refreshSnapshotLocations() {
|
|
191
|
+
snapshotLocations.clear();
|
|
192
|
+
for (const project of projects) {
|
|
193
|
+
for (const entry of readSnapshotIndex(project)) {
|
|
194
|
+
if (!entry?.id) continue;
|
|
195
|
+
snapshotLocations.set(entry.id, {
|
|
196
|
+
productPath: project.path,
|
|
197
|
+
file: path.join(snapshotsDir(project.path), `${entry.id}.json`),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function ensureSnapshotLocation(snapshotId) {
|
|
204
|
+
const cached = snapshotLocations.get(snapshotId);
|
|
205
|
+
if (cached && fs.existsSync(cached.file)) return cached;
|
|
206
|
+
for (const project of projects) {
|
|
207
|
+
const file = path.join(snapshotsDir(project.path), `${snapshotId}.json`);
|
|
208
|
+
if (fs.existsSync(file)) {
|
|
209
|
+
const location = { productPath: project.path, file };
|
|
210
|
+
snapshotLocations.set(snapshotId, location);
|
|
211
|
+
return location;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function snapshotFromFile(file, withAnalysis = true) {
|
|
218
|
+
const snapshot = readJson(file, null);
|
|
219
|
+
if (!snapshot) return null;
|
|
220
|
+
if (!withAnalysis) delete snapshot.analysis;
|
|
221
|
+
return snapshot;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const products = {
|
|
225
|
+
list() {
|
|
226
|
+
return [...projects].sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
|
|
227
|
+
},
|
|
228
|
+
get(id) {
|
|
229
|
+
return projectById(id);
|
|
230
|
+
},
|
|
231
|
+
findByName(name) {
|
|
232
|
+
return projects.find((item) => item.name === name) || null;
|
|
233
|
+
},
|
|
234
|
+
findByPath(projectPath) {
|
|
235
|
+
return projects.find((item) => item.path === projectPath) || null;
|
|
236
|
+
},
|
|
237
|
+
create(input) {
|
|
238
|
+
const projectPath = path.resolve(String(input.path || input.repoUrl || ''));
|
|
239
|
+
const timestamp = nowIso();
|
|
240
|
+
const project = {
|
|
241
|
+
id: newId('prj'),
|
|
242
|
+
name: String(input.name || path.basename(projectPath)).slice(0, 120),
|
|
243
|
+
description: String(input.description || '').slice(0, 500),
|
|
244
|
+
path: projectPath,
|
|
245
|
+
repoUrl: projectPath,
|
|
246
|
+
mirrorUrl: null,
|
|
247
|
+
defaultBranch: String(input.defaultBranch || ''),
|
|
248
|
+
createdAt: timestamp,
|
|
249
|
+
updatedAt: timestamp,
|
|
250
|
+
};
|
|
251
|
+
projects.push(project);
|
|
252
|
+
saveProjects();
|
|
253
|
+
ensureDir(artifactDir(projectPath));
|
|
254
|
+
writeJson(path.join(artifactDir(projectPath), 'project.json'), {
|
|
255
|
+
version: STORE_VERSION,
|
|
256
|
+
id: project.id,
|
|
257
|
+
name: project.name,
|
|
258
|
+
createdAt: project.createdAt,
|
|
259
|
+
});
|
|
260
|
+
refreshSnapshotLocations();
|
|
261
|
+
return project;
|
|
262
|
+
},
|
|
263
|
+
update(id, patch) {
|
|
264
|
+
const project = projectById(id);
|
|
265
|
+
if (!project) return null;
|
|
266
|
+
if (patch.name !== undefined) project.name = String(patch.name).slice(0, 120);
|
|
267
|
+
if (patch.description !== undefined) project.description = String(patch.description).slice(0, 500);
|
|
268
|
+
if (patch.defaultBranch !== undefined) project.defaultBranch = String(patch.defaultBranch || '');
|
|
269
|
+
if (patch.path !== undefined) {
|
|
270
|
+
const nextPath = path.resolve(String(patch.path));
|
|
271
|
+
project.path = nextPath;
|
|
272
|
+
ensureDir(artifactDir(nextPath));
|
|
273
|
+
}
|
|
274
|
+
project.updatedAt = nowIso();
|
|
275
|
+
saveProjects();
|
|
276
|
+
refreshSnapshotLocations();
|
|
277
|
+
return project;
|
|
278
|
+
},
|
|
279
|
+
touch(id) {
|
|
280
|
+
const project = projectById(id);
|
|
281
|
+
if (!project) return null;
|
|
282
|
+
project.updatedAt = nowIso();
|
|
283
|
+
saveProjects();
|
|
284
|
+
return project;
|
|
285
|
+
},
|
|
286
|
+
remove(id) {
|
|
287
|
+
const index = projects.findIndex((item) => item.id === id);
|
|
288
|
+
if (index < 0) return false;
|
|
289
|
+
projects.splice(index, 1);
|
|
290
|
+
saveProjects();
|
|
291
|
+
refreshSnapshotLocations();
|
|
292
|
+
return true;
|
|
293
|
+
},
|
|
294
|
+
count() {
|
|
295
|
+
return projects.length;
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const snapshots = {
|
|
300
|
+
insert(input) {
|
|
301
|
+
const project = projectById(input.productId);
|
|
302
|
+
if (!project) throw new Error(`项目不存在:${input.productId}`);
|
|
303
|
+
ensureDir(snapshotsDir(project.path));
|
|
304
|
+
const id = input.id || newId('snap');
|
|
305
|
+
const record = {
|
|
306
|
+
version: STORE_VERSION,
|
|
307
|
+
id,
|
|
308
|
+
productId: input.productId,
|
|
309
|
+
branch: input.branch || '',
|
|
310
|
+
commit: input.commit || { sha: '', short: '', subject: '', author: '', date: '' },
|
|
311
|
+
analyzedAt: input.analyzedAt || nowIso(),
|
|
312
|
+
engine: input.engine || 'heuristic',
|
|
313
|
+
durationMs: Number(input.durationMs) || 0,
|
|
314
|
+
summary: input.summary || {},
|
|
315
|
+
analysis: input.analysis || {},
|
|
316
|
+
};
|
|
317
|
+
const file = path.join(snapshotsDir(project.path), `${id}.json`);
|
|
318
|
+
writeJson(file, record);
|
|
319
|
+
const index = readSnapshotIndex(project).filter((entry) => entry.id !== id);
|
|
320
|
+
index.unshift({
|
|
321
|
+
id,
|
|
322
|
+
productId: record.productId,
|
|
323
|
+
branch: record.branch,
|
|
324
|
+
commitSha: record.commit.sha,
|
|
325
|
+
commitShort: record.commit.short,
|
|
326
|
+
commitSubject: record.commit.subject,
|
|
327
|
+
commitAuthor: record.commit.author,
|
|
328
|
+
commitDate: record.commit.date,
|
|
329
|
+
analyzedAt: record.analyzedAt,
|
|
330
|
+
engine: record.engine,
|
|
331
|
+
durationMs: record.durationMs,
|
|
332
|
+
summary: record.summary,
|
|
333
|
+
});
|
|
334
|
+
writeSnapshotIndex(project, index);
|
|
335
|
+
snapshotLocations.set(id, { productPath: project.path, file });
|
|
336
|
+
return id;
|
|
337
|
+
},
|
|
338
|
+
get(id) {
|
|
339
|
+
const location = ensureSnapshotLocation(id);
|
|
340
|
+
if (!location) return null;
|
|
341
|
+
return snapshotFromFile(location.file);
|
|
342
|
+
},
|
|
343
|
+
findByCommit(productId, sha) {
|
|
344
|
+
const project = projectById(productId);
|
|
345
|
+
if (!project) return null;
|
|
346
|
+
const entry = readSnapshotIndex(project).find((item) => item.commitSha === sha);
|
|
347
|
+
return entry ? snapshots.get(entry.id) : null;
|
|
348
|
+
},
|
|
349
|
+
listByProduct(productId) {
|
|
350
|
+
const project = projectById(productId);
|
|
351
|
+
if (!project) return [];
|
|
352
|
+
return readSnapshotIndex(project)
|
|
353
|
+
.slice()
|
|
354
|
+
.sort((a, b) => String(b.analyzedAt).localeCompare(String(a.analyzedAt)))
|
|
355
|
+
.map((entry) => ({
|
|
356
|
+
id: entry.id,
|
|
357
|
+
productId,
|
|
358
|
+
branch: entry.branch,
|
|
359
|
+
commitSha: entry.commitSha,
|
|
360
|
+
commitShort: entry.commitShort,
|
|
361
|
+
commitSubject: entry.commitSubject,
|
|
362
|
+
commitAuthor: entry.commitAuthor,
|
|
363
|
+
commitDate: entry.commitDate,
|
|
364
|
+
analyzedAt: entry.analyzedAt,
|
|
365
|
+
engine: entry.engine,
|
|
366
|
+
durationMs: entry.durationMs,
|
|
367
|
+
summary: entry.summary || {},
|
|
368
|
+
commit: {
|
|
369
|
+
sha: entry.commitSha,
|
|
370
|
+
short: entry.commitShort,
|
|
371
|
+
subject: entry.commitSubject,
|
|
372
|
+
author: entry.commitAuthor,
|
|
373
|
+
date: entry.commitDate,
|
|
374
|
+
},
|
|
375
|
+
}));
|
|
376
|
+
},
|
|
377
|
+
latestByBranch(productId, branch) {
|
|
378
|
+
const project = projectById(productId);
|
|
379
|
+
if (!project) return null;
|
|
380
|
+
const entry = readSnapshotIndex(project)
|
|
381
|
+
.filter((item) => item.branch === branch)
|
|
382
|
+
.sort((a, b) => String(b.analyzedAt).localeCompare(String(a.analyzedAt)))[0];
|
|
383
|
+
return entry ? snapshots.get(entry.id) : null;
|
|
384
|
+
},
|
|
385
|
+
latest(productId) {
|
|
386
|
+
const project = projectById(productId);
|
|
387
|
+
if (!project) return null;
|
|
388
|
+
const entry = readSnapshotIndex(project).sort((a, b) =>
|
|
389
|
+
String(b.analyzedAt).localeCompare(String(a.analyzedAt)),
|
|
390
|
+
)[0];
|
|
391
|
+
return entry ? snapshots.get(entry.id) : null;
|
|
392
|
+
},
|
|
393
|
+
count(productId) {
|
|
394
|
+
const project = projectById(productId);
|
|
395
|
+
if (!project) return 0;
|
|
396
|
+
return readSnapshotIndex(project).length;
|
|
397
|
+
},
|
|
398
|
+
removeByProduct(productId) {
|
|
399
|
+
const project = projectById(productId);
|
|
400
|
+
if (!project) return;
|
|
401
|
+
const dir = snapshotsDir(project.path);
|
|
402
|
+
try {
|
|
403
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
404
|
+
} catch {
|
|
405
|
+
/* ignore */
|
|
406
|
+
}
|
|
407
|
+
for (const [id, location] of snapshotLocations) {
|
|
408
|
+
if (location.productPath === project.path) snapshotLocations.delete(id);
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const settings = {
|
|
414
|
+
all() {
|
|
415
|
+
return { ...SETTING_DEFAULTS, ...settingValues };
|
|
416
|
+
},
|
|
417
|
+
get(key) {
|
|
418
|
+
return settingValues[key] ?? SETTING_DEFAULTS[key] ?? null;
|
|
419
|
+
},
|
|
420
|
+
set(key, value) {
|
|
421
|
+
settingValues[key] = String(value);
|
|
422
|
+
saveSettings();
|
|
423
|
+
},
|
|
424
|
+
setMany(patch) {
|
|
425
|
+
for (const [key, value] of Object.entries(patch || {})) {
|
|
426
|
+
if (value === undefined) continue;
|
|
427
|
+
settingValues[key] = String(value);
|
|
428
|
+
}
|
|
429
|
+
saveSettings();
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const jobsApi = {
|
|
434
|
+
upsert(job) {
|
|
435
|
+
const index = jobs.findIndex((item) => item.id === job.id);
|
|
436
|
+
const record = {
|
|
437
|
+
id: job.id,
|
|
438
|
+
productId: job.productId || null,
|
|
439
|
+
kind: job.kind,
|
|
440
|
+
branch: job.branch || null,
|
|
441
|
+
status: job.status,
|
|
442
|
+
progress: job.progress || 0,
|
|
443
|
+
message: job.message || '',
|
|
444
|
+
logs: Array.isArray(job.logs) ? job.logs : [],
|
|
445
|
+
error: job.error || null,
|
|
446
|
+
result: job.result ?? null,
|
|
447
|
+
createdAt: job.createdAt,
|
|
448
|
+
updatedAt: job.updatedAt,
|
|
449
|
+
};
|
|
450
|
+
if (index >= 0) jobs[index] = record;
|
|
451
|
+
else jobs.push(record);
|
|
452
|
+
if (jobs.length > 500) jobs = jobs.slice(-500);
|
|
453
|
+
saveJobs();
|
|
454
|
+
},
|
|
455
|
+
get(id) {
|
|
456
|
+
return jobs.find((item) => item.id === id) || null;
|
|
457
|
+
},
|
|
458
|
+
listByProduct(productId, limit = 30) {
|
|
459
|
+
return jobs
|
|
460
|
+
.filter((item) => item.productId === productId)
|
|
461
|
+
.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
|
|
462
|
+
.slice(0, limit);
|
|
463
|
+
},
|
|
464
|
+
latestByProduct(productId) {
|
|
465
|
+
return jobsApi.listByProduct(productId, 1)[0] || null;
|
|
466
|
+
},
|
|
467
|
+
activeByProduct(productId) {
|
|
468
|
+
return jobs
|
|
469
|
+
.filter((item) => item.productId === productId && ['queued', 'running'].includes(item.status))
|
|
470
|
+
.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
|
|
471
|
+
},
|
|
472
|
+
interruptActive(message = '服务重启,任务已中断,请重新发起') {
|
|
473
|
+
let changed = 0;
|
|
474
|
+
for (const job of jobs) {
|
|
475
|
+
if (!['queued', 'running'].includes(job.status)) continue;
|
|
476
|
+
job.status = 'error';
|
|
477
|
+
job.error = message;
|
|
478
|
+
job.message = message;
|
|
479
|
+
job.updatedAt = nowIso();
|
|
480
|
+
changed += 1;
|
|
481
|
+
}
|
|
482
|
+
if (changed) saveJobs();
|
|
483
|
+
return changed;
|
|
484
|
+
},
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const drill = {
|
|
488
|
+
get(snapshotId, modulePath) {
|
|
489
|
+
const location = ensureSnapshotLocation(snapshotId);
|
|
490
|
+
if (!location) return null;
|
|
491
|
+
const file = drillFile(location.productPath, snapshotId, modulePath);
|
|
492
|
+
const record = readJson(file, null);
|
|
493
|
+
if (!record) return null;
|
|
494
|
+
return { ...(record.analysis || {}), createdAt: record.createdAt };
|
|
495
|
+
},
|
|
496
|
+
put(input) {
|
|
497
|
+
const location = ensureSnapshotLocation(input.snapshotId);
|
|
498
|
+
if (!location) throw new Error(`快照不存在:${input.snapshotId}`);
|
|
499
|
+
const file = drillFile(location.productPath, input.snapshotId, input.modulePath);
|
|
500
|
+
ensureDir(path.dirname(file));
|
|
501
|
+
writeJson(file, {
|
|
502
|
+
version: STORE_VERSION,
|
|
503
|
+
productId: input.productId,
|
|
504
|
+
snapshotId: input.snapshotId,
|
|
505
|
+
modulePath: input.modulePath,
|
|
506
|
+
analysis: input.analysis || {},
|
|
507
|
+
createdAt: nowIso(),
|
|
508
|
+
});
|
|
509
|
+
},
|
|
510
|
+
removeByProduct(productId) {
|
|
511
|
+
const project = projectById(productId);
|
|
512
|
+
if (!project) return;
|
|
513
|
+
try {
|
|
514
|
+
fs.rmSync(drillDir(project.path), { recursive: true, force: true });
|
|
515
|
+
} catch {
|
|
516
|
+
/* ignore */
|
|
517
|
+
}
|
|
518
|
+
},
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
const backups = {
|
|
522
|
+
dir: backupDir,
|
|
523
|
+
restoredFrom: null,
|
|
524
|
+
info() {
|
|
525
|
+
const files = backupFiles(backupDir);
|
|
526
|
+
return {
|
|
527
|
+
dir: backupDir,
|
|
528
|
+
count: files.length,
|
|
529
|
+
latest: files[0] ? { file: files[0].file, at: files[0].at, size: files[0].size } : null,
|
|
530
|
+
restoredFrom: null,
|
|
531
|
+
};
|
|
532
|
+
},
|
|
533
|
+
list() {
|
|
534
|
+
return backupFiles(backupDir).map(({ name, at, size }) => ({ name, at, size }));
|
|
535
|
+
},
|
|
536
|
+
create(reason = 'manual') {
|
|
537
|
+
const file = path.join(backupDir, `arcvista-${stamp()}-${reason}.json`);
|
|
538
|
+
writeJson(file, {
|
|
539
|
+
version: STORE_VERSION,
|
|
540
|
+
createdAt: nowIso(),
|
|
541
|
+
reason,
|
|
542
|
+
projects,
|
|
543
|
+
jobs,
|
|
544
|
+
settings: settingValues,
|
|
545
|
+
});
|
|
546
|
+
const files = backupFiles(backupDir);
|
|
547
|
+
for (const old of files.slice(Math.max(0, backupKeep))) {
|
|
548
|
+
try {
|
|
549
|
+
fs.rmSync(old.file, { force: true });
|
|
550
|
+
} catch {
|
|
551
|
+
/* ignore */
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
const stat = fs.statSync(file);
|
|
555
|
+
return { file, at: new Date(stat.mtimeMs).toISOString(), size: stat.size };
|
|
556
|
+
},
|
|
557
|
+
maybeCreate(reason = 'auto', minIntervalMs = 120000) {
|
|
558
|
+
const latest = backupFiles(backupDir)[0];
|
|
559
|
+
if (latest && Date.now() - latest.mtimeMs < minIntervalMs) return null;
|
|
560
|
+
return backups.create(reason);
|
|
561
|
+
},
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
refreshSnapshotLocations();
|
|
565
|
+
|
|
566
|
+
return {
|
|
567
|
+
version: STORE_VERSION,
|
|
568
|
+
dataDir: root,
|
|
569
|
+
file: projectsFile,
|
|
570
|
+
projects,
|
|
571
|
+
products,
|
|
572
|
+
snapshots,
|
|
573
|
+
settings,
|
|
574
|
+
jobs: jobsApi,
|
|
575
|
+
drill,
|
|
576
|
+
backups,
|
|
577
|
+
close() {},
|
|
578
|
+
};
|
|
579
|
+
}
|
package/locale/zh.json
CHANGED
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-arcvista",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "ArcVista 观界 —— DSH
|
|
6
|
+
"description": "ArcVista 观界 —— DSH 内的架构观测台:本地项目目录、架构图、安全守护、业务知识图谱、复杂度与快照风险对比",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"author": "yonking305",
|
|
9
9
|
"homepage": "https://github.com/yonking305/arc-vista",
|
package/public/js/api.js
CHANGED
|
@@ -26,6 +26,8 @@ async function request(path, options = {}) {
|
|
|
26
26
|
export const api = {
|
|
27
27
|
health: () => request('/api/health'),
|
|
28
28
|
|
|
29
|
+
workspaces: () => request('/api/workspaces'),
|
|
30
|
+
|
|
29
31
|
products: {
|
|
30
32
|
list: () => request('/api/products'),
|
|
31
33
|
create: (payload) => request('/api/products', { method: 'POST', body: JSON.stringify(payload) }),
|
package/public/js/app.js
CHANGED
|
@@ -20,7 +20,7 @@ function rail() {
|
|
|
20
20
|
<div class="rail-word">ArcVista · 架构观测台</div>
|
|
21
21
|
<div class="rail-spacer"></div>
|
|
22
22
|
<button class="rail-btn" data-theme-picker type="button" title="切换皮肤" aria-label="切换皮肤">${icon('palette', 18)}</button>
|
|
23
|
-
<a class="rail-btn" href="#/settings" title="
|
|
23
|
+
<a class="rail-btn" href="#/settings" title="设置" aria-label="设置">${icon('settings', 18)}</a>
|
|
24
24
|
<span class="rail-status" title="本地服务在线"></span>
|
|
25
25
|
</aside>`;
|
|
26
26
|
}
|
|
@@ -33,7 +33,7 @@ function pageError(error) {
|
|
|
33
33
|
<div>
|
|
34
34
|
<div class="eyebrow">CONNECTION LOST</div>
|
|
35
35
|
<h1 class="page-title">观测台<em>离线</em></h1>
|
|
36
|
-
<p class="page-sub">${unreachable ? '
|
|
36
|
+
<p class="page-sub">${unreachable ? '无法连接 ArcVista 服务。请确认 DSH 插件已启用,或独立运行时在项目目录执行 <span class="mono">npm start</span>。' : '页面渲染失败,请查看服务端日志或重试。'}</p>
|
|
37
37
|
</div>
|
|
38
38
|
</div>
|
|
39
39
|
<div class="panel"><div class="panel-body">
|
|
@@ -133,7 +133,7 @@ async function boot() {
|
|
|
133
133
|
state.products = [];
|
|
134
134
|
})
|
|
135
135
|
.catch(() => {
|
|
136
|
-
toast('无法连接 ArcVista 服务', { type: 'error', detail: '请确认
|
|
136
|
+
toast('无法连接 ArcVista 服务', { type: 'error', detail: '请确认 DSH 插件已启用' });
|
|
137
137
|
});
|
|
138
138
|
await renderRoute();
|
|
139
139
|
}
|