Improve operational UX, recovery flows, and action safety
Implement the approved UX review across Fleet, Betas, Library, deployments, and Settings. Add reviewed promotion and reconciliation plans, atomic queued-removal cancellation, recoverable beta drafts and launch inputs, accessible controls, and mobile improvements. Validated with 473 tests, a clean typecheck, production build, and isolated desktop/mobile browser walkthroughs. Include the review and implementation coverage notes.
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { DriftPlan, DriftPlanFile } from '$lib/drift-plan.js';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join, posix, relative, sep } from 'node:path';
|
||||
import { configFileOf, destOfKind, type ArtifactKind } from '../config.js';
|
||||
@@ -244,6 +246,40 @@ export interface RunDriftInput {
|
||||
servers?: string[]; // if omitted, scans the whole fleet
|
||||
dryRun?: boolean;
|
||||
triggeredBy?: 'ui' | 'sweep' | 'cron';
|
||||
expectedFingerprint?: string;
|
||||
}
|
||||
|
||||
export class DriftPlanChanged extends Error {
|
||||
constructor(public plan: DriftPlan) { super('The files or target servers changed. Nothing was changed. Review the updated plan before continuing.'); }
|
||||
}
|
||||
|
||||
function driftTargets(servers?: string[]): string[] {
|
||||
const registered = listServers().map(server => server.name);
|
||||
if (servers && (!servers.length || servers.some(name => !registered.includes(name)))) throw new Error('Select at least one registered server. Refresh the fleet if a server was removed.');
|
||||
return [...new Set(servers ?? registered)].sort();
|
||||
}
|
||||
|
||||
async function describePlan(servers: string[], entries: DriftEntry[]): Promise<DriftPlan> {
|
||||
const files: DriftPlanFile[] = [];
|
||||
for (const entry of entries) {
|
||||
const before = await readFile(join(destOfKind(entry.server, entry.kind), entry.relPath)).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT') return null;
|
||||
throw error;
|
||||
});
|
||||
const after = entry.driftKind === 'untracked' ? null : await resolveManagedEntry(entry.server, entry);
|
||||
files.push({ server: entry.server, relPath: entry.relPath, kind: entry.kind, driftKind: entry.driftKind,
|
||||
beforeSha256: before ? sha256Buffer(before) : null, afterSha256: after ? sha256Buffer(after) : null,
|
||||
beforeSize: before?.byteLength ?? null, afterSize: after?.byteLength ?? null });
|
||||
}
|
||||
files.sort((a, b) => `${a.server}:${a.kind}:${a.relPath}`.localeCompare(`${b.server}:${b.kind}:${b.relPath}`));
|
||||
return { servers, files, scannedAt: new Date().toISOString(), fingerprint: sha256Buffer(Buffer.from(JSON.stringify({ servers, files }))) };
|
||||
}
|
||||
|
||||
export async function previewDrift(servers?: string[]): Promise<DriftPlan> {
|
||||
const targets = driftTargets(servers);
|
||||
const entries: DriftEntry[] = [];
|
||||
for (const server of targets) entries.push(...await scanServerDrift(server));
|
||||
return describePlan(targets, entries);
|
||||
}
|
||||
|
||||
export interface RunDriftResult {
|
||||
@@ -259,7 +295,7 @@ export interface RunDriftResult {
|
||||
|
||||
function deploymentIdNow(): string {
|
||||
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
||||
return `dep-${stamp}-drift`;
|
||||
return `dep-${stamp}-drift-${randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -271,9 +307,7 @@ function deploymentIdNow(): string {
|
||||
export async function runDriftDeployment(input: RunDriftInput = {}): Promise<RunDriftResult> {
|
||||
const { dryRun = false, triggeredBy = 'ui' } = input;
|
||||
|
||||
const scanTargets = input.servers && input.servers.length > 0
|
||||
? input.servers
|
||||
: listServers().map((s) => s.name);
|
||||
const scanTargets = driftTargets(input.servers);
|
||||
|
||||
const perServer: Array<{
|
||||
server: string;
|
||||
@@ -287,6 +321,9 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
|
||||
perServer.push({ server: name, removed: [], restored: [], skipped: [], entries });
|
||||
}
|
||||
|
||||
// Recheck identities, existing bytes, and replacement bytes before any write.
|
||||
const plan = input.expectedFingerprint ? await describePlan(scanTargets, perServer.flatMap(server => server.entries)) : null;
|
||||
if (plan && plan.fingerprint !== input.expectedFingerprint) throw new DriftPlanChanged(plan);
|
||||
const totalDrift = perServer.reduce((sum, s) => sum + s.entries.length, 0);
|
||||
if (totalDrift === 0 || dryRun) {
|
||||
return {
|
||||
@@ -335,6 +372,14 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
|
||||
const dest = destOfKind(s.server, entry.kind);
|
||||
const abs = join(dest, entry.relPath);
|
||||
try {
|
||||
const reviewed = plan?.files.find(file => file.server === s.server && file.kind === entry.kind && file.relPath === entry.relPath);
|
||||
if (reviewed) {
|
||||
const current = await readFile(abs).catch((error: NodeJS.ErrnoException) => { if (error.code === 'ENOENT') return null; throw error; });
|
||||
if ((current ? sha256Buffer(current) : null) !== reviewed.beforeSha256) {
|
||||
s.skipped.push(`${entry.relPath}: changed after review; scan and review again`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (entry.driftKind === 'untracked') {
|
||||
// Delete a file inventory doesn't know about.
|
||||
const content = await readFile(abs);
|
||||
@@ -349,6 +394,10 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
|
||||
s.skipped.push(`${entry.relPath}: missing artifact could not be resolved from the DB`);
|
||||
continue;
|
||||
}
|
||||
if (reviewed && sha256Buffer(written) !== reviewed.afterSha256) {
|
||||
s.skipped.push(`${entry.relPath}: promoted content changed after review; scan and review again`);
|
||||
continue;
|
||||
}
|
||||
const before = await readFile(abs).catch((err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'ENOENT') return null;
|
||||
throw err;
|
||||
|
||||
Reference in New Issue
Block a user