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:
@@ -0,0 +1,27 @@
|
||||
import { requirementKinds, type BetaPlan } from './beta-plan.js';
|
||||
|
||||
export const betaDraftKey = 'electric-cherry:beta-draft:v1';
|
||||
export interface BetaDraft {
|
||||
version: 1; savedAt: string; step: number;
|
||||
picks: Array<{ scriptId: number | null; versionId: number | null }>;
|
||||
servers: string[]; testPlan: BetaPlan;
|
||||
label?: string;
|
||||
}
|
||||
/** Validate draft structure, preserving incomplete numeric fields for correction. */
|
||||
export function readBetaDraft(raw: string | null): BetaDraft | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const d = JSON.parse(raw);
|
||||
if (d?.version !== 1 || !Array.isArray(d.picks) || !d.picks.length || d.picks.length > 100
|
||||
|| !d.picks.every((p: BetaDraft['picks'][number]) => p && [p.scriptId, p.versionId].every(v => v === null || Number.isSafeInteger(v) && v! > 0))
|
||||
|| !Array.isArray(d.servers) || !d.servers.every((s: unknown) => typeof s === 'string')
|
||||
|| d.testPlan?.version !== 1 || !Array.isArray(d.testPlan.requirements) || d.testPlan.requirements.length > 30
|
||||
|| !d.testPlan.requirements.every((r: BetaPlan['requirements'][number]) => r && requirementKinds.includes(r.kind) && typeof r.id === 'string' && /^[a-zA-Z0-9_-]{1,64}$/.test(r.id) && typeof r.label === 'string')) return null;
|
||||
return { ...d, step: Math.max(0, Math.min(3, Number(d.step) || 0)) };
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function compatibleBetaServers(selected: Iterable<string>, candidates: readonly string[]): { kept: Set<string>; removed: string[] } {
|
||||
const allowed = new Set(candidates), names = [...selected];
|
||||
return { kept: new Set(names.filter(name => allowed.has(name))), removed: names.filter(name => !allowed.has(name)) };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { BetaRequirement, BetaTestView } from './beta-plan.js';
|
||||
import { needsGameTelemetry } from './beta-plan-summary.js';
|
||||
|
||||
export function betaWaitingCondition(requirement: BetaRequirement, observation?: BetaTestView['servers'][number]['observation']): string {
|
||||
if (requirement.kind === 'manual') return 'Waiting for a tester to record their name and observation.';
|
||||
if (requirement.kind === 'recovery') return 'The other checks must pass before the next restart when empty; each cycle needs fresh evidence.';
|
||||
if (requirement.kind === 'healthy-minutes') return observation?.healthy === true
|
||||
? 'Collecting consecutive fresh healthy observations; an outage or monitoring gap resets this duration.'
|
||||
: 'Waiting for fresh container and RCON health observations. Unhealthy or stale observations reset this duration.';
|
||||
if (requirement.kind === 'failure-budget') return 'An observed health outage exceeded the budget. Investigate before starting a fresh attempt.';
|
||||
if (needsGameTelemetry(requirement)) {
|
||||
if (!observation?.lastGameplayAt) return 'No gameplay report received for this attempt. Check the game reporter and Telemetry API requests.';
|
||||
if (observation.gameEnded) return 'Waiting for the next game and its qualifying reports.';
|
||||
if (requirement.kind === 'population') return `Waiting for a game report containing at least ${requirement.target} players.`;
|
||||
const players = `at least ${requirement.minPlayers} player${requirement.minPlayers === 1 ? '' : 's'}`;
|
||||
if ((observation.players ?? 0) < requirement.minPlayers) return `The latest report has ${observation.players ?? 0} players; this check needs ${players}.`;
|
||||
if (requirement.kind === 'matches') return requirement.minMinutes && !observation.matchStarted
|
||||
? 'This match has no game-start report, so its duration cannot qualify. Waiting for a new game start and game end.'
|
||||
: `Waiting for game end${requirement.minRound ? ` after reaching round ${requirement.minRound}` : ''} with ${players}${requirement.minMinutes ? ` and at least ${requirement.minMinutes} minutes since game start` : ''}.`;
|
||||
if (requirement.kind === 'round') return `Waiting for a round change or game end reporting round ${requirement.target} or higher with ${players}.`;
|
||||
return `Time is credited when the next report arrives, with ${players} at both ends of the interval. It does not advance continuously between reports.`;
|
||||
}
|
||||
return 'Waiting for qualifying evidence.';
|
||||
}
|
||||
|
||||
export function betaEvidenceSummary(event: BetaTestView['evidence'][number]): string {
|
||||
let payload: Record<string, unknown>;
|
||||
try { payload = JSON.parse(event.payload); } catch { return event.kind.replaceAll('-', ' ').replaceAll('_', ' '); }
|
||||
if (!payload || typeof payload !== 'object') return event.kind;
|
||||
if (['game_start', 'round_change', 'game_end'].includes(event.kind)) {
|
||||
const label = event.kind === 'game_start' ? 'Game started' : event.kind === 'game_end' ? 'Game ended' : 'Round changed';
|
||||
return `${label}${typeof payload.round === 'number' ? ` at round ${payload.round}` : ''}${Array.isArray(payload.players) ? ` with ${payload.players.length} players` : ''}${typeof payload.map === 'string' ? ` on ${payload.map}` : ''}.`;
|
||||
}
|
||||
if (event.kind === 'manual-sign-off') return `${payload.requirement ?? 'Checklist'}: ${payload.note ?? 'Signed off'}`;
|
||||
if (event.kind === 'health' || event.kind === 'status-sample') return `${payload.healthy ? 'Healthy observation' : 'Unhealthy observation'}${payload.reason ? `: ${payload.reason}` : '.'}`;
|
||||
return event.kind.replaceAll('-', ' ').replaceAll('_', ' ');
|
||||
}
|
||||
+25
-1
@@ -82,6 +82,30 @@ export interface BetaTestView {
|
||||
plan: BetaPlan;
|
||||
outcome: string;
|
||||
reason: string | null;
|
||||
servers: Array<{ server: string; phase: number; passed: boolean; progress: RequirementProgress[] }>;
|
||||
servers: Array<{ server: string; phase: number; passed: boolean; progress: RequirementProgress[]; observingAt?: string | null;
|
||||
observation?: { lastGameplayAt: string | null; lastEvent: string | null; players: number | null; round: number | null; matchStarted: boolean; gameEnded: boolean; healthy: boolean | null; healthCheckedAt: string | null } }>;
|
||||
evidence: Array<{ id: number; server: string; received_at: string; kind: string; payload: string }>;
|
||||
}
|
||||
|
||||
export interface BetaPlanIssue { field: string; message: string; requirementId?: string }
|
||||
export function betaPlanIssues(plan: BetaPlan, serverCount?: number): BetaPlanIssue[] {
|
||||
const issues: BetaPlanIssue[] = [];
|
||||
const check = (field: string, value: number, min: number, max: number, integer: boolean, message: string, requirementId?: string) => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max || (integer && !Number.isInteger(value))) issues.push({ field, message, requirementId });
|
||||
};
|
||||
check('timeoutHours', plan.timeoutHours, .01, 8760, false, 'Enter a testing window between 0.01 and 8,760 hours.');
|
||||
check('waveSize', plan.waveSize, 0, 1000, true, 'Enter a whole number from 0 to 1,000; 0 deploys to all selected servers.');
|
||||
if (plan.scope === 'quorum') check('quorum', plan.quorum, 1, serverCount ?? 1000, true, `Choose between 1 and ${serverCount ?? 1000} qualifying servers.`);
|
||||
if (!plan.requirements.length) issues.push({ field: 'requirements', message: 'Add at least one requirement to continue.' });
|
||||
for (const r of plan.requirements) {
|
||||
if (!r.label?.trim()) issues.push({ field: `${r.id}-label`, message: 'Describe what this requirement checks.', requirementId: r.id });
|
||||
const integral = ['round', 'matches', 'population', 'failure-budget', 'recovery', 'manual'].includes(r.kind);
|
||||
check(`${r.id}-target`, r.target, r.kind === 'failure-budget' ? 0 : .000001, 1_000_000, integral, `Enter ${integral ? 'a whole number' : 'a number'} ${r.kind === 'failure-budget' ? 'of zero or more' : 'greater than zero'} for ${requirementLabels[r.kind].toLowerCase()}.`, r.id);
|
||||
if (['round', 'matches', 'populated-minutes', 'player-hours', 'sustained-population'].includes(r.kind)) check(`${r.id}-minPlayers`, r.minPlayers, 1, 1_000_000, true, 'Enter at least one player, using a whole number.', r.id);
|
||||
if (r.kind === 'matches') {
|
||||
check(`${r.id}-minRound`, r.minRound, 0, 1_000_000, true, 'Enter a whole round number; 0 allows any round.', r.id);
|
||||
check(`${r.id}-minMinutes`, r.minMinutes, 0, 1_000_000, false, 'Enter a duration of zero or more minutes.', r.id);
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface BetaPromotionPreview {
|
||||
betaId: number;
|
||||
token: string;
|
||||
createdAt: string;
|
||||
files: Array<{
|
||||
id: number; name: string; kind: string; path: string; versionId: number;
|
||||
version: number; release_version: string | null; sha256: string;
|
||||
scope: string; servers: string[];
|
||||
}>;
|
||||
servers: Array<{ name: string; tested: boolean; game: string | null; map: string | null }>;
|
||||
configWrites: Array<{ server: string; path: string; sha256: string | null; error: string | null }>;
|
||||
blockers: string[];
|
||||
restartPolicy: 'additional-when-empty';
|
||||
}
|
||||
@@ -45,6 +45,17 @@ export function changeConfigValue(text: string, index: number, value: string): s
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
/** Add one assignment without changing existing commands or line endings. */
|
||||
export function addConfigSetting(text: string, key: string, value: string): string {
|
||||
key = key.trim(); value = value.trim();
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) throw new Error('Use a setting name containing letters, numbers, and underscores.');
|
||||
if (!value || /[\r\n]/.test(value) || value.startsWith('//')) throw new Error('Enter a value on one line.');
|
||||
if (configLines(text).some(entry => entry.key.toLowerCase() === key.toLowerCase())) throw new Error('This setting already exists. Find it above to edit its value.');
|
||||
const newline = text.includes('\r\n') ? '\r\n' : '\n';
|
||||
const formatted = /\s/.test(value) && !value.startsWith('"') ? JSON.stringify(value) : value;
|
||||
return text + (text && !text.endsWith('\n') ? newline : '') + `set ${key} ${formatted}` + newline;
|
||||
}
|
||||
|
||||
export function configWarnings(text: string, overlay: boolean): string[] {
|
||||
const warnings: string[] = [];
|
||||
const seen = new Map<string, number>();
|
||||
|
||||
@@ -37,7 +37,8 @@ export interface CollectionPreview {
|
||||
expiresAt: string;
|
||||
blueprintName: string;
|
||||
launchConcurrency: number;
|
||||
entries: { count: number; existing: number; missing: number; existingServers: string[]; recipe: LaunchRecipeSummary }[];
|
||||
entries: { count: number; existing: number; missing: number; existingServers: string[];
|
||||
coverage: { name: string; health: string; href: string }[]; recipe: LaunchRecipeSummary }[];
|
||||
servers: { entryIndex: number; name: string | null; port: number | null; hasSavedKey: boolean }[];
|
||||
blockers: string[];
|
||||
ready: boolean;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface DriftPlanFile {
|
||||
server: string;
|
||||
relPath: string;
|
||||
kind: string;
|
||||
driftKind: 'untracked' | 'missing' | 'replace';
|
||||
beforeSha256: string | null;
|
||||
afterSha256: string | null;
|
||||
beforeSize: number | null;
|
||||
afterSize: number | null;
|
||||
}
|
||||
|
||||
export interface DriftPlan {
|
||||
fingerprint: string;
|
||||
scannedAt: string;
|
||||
servers: string[];
|
||||
files: DriftPlanFile[];
|
||||
}
|
||||
|
||||
export interface DriftResult {
|
||||
deploymentId: string | null;
|
||||
perServer: { server: string; removed: string[]; restored: string[]; skipped: string[] }[];
|
||||
totalRemoved: number;
|
||||
totalRestored: number;
|
||||
totalServers: number;
|
||||
dryRun: boolean;
|
||||
}
|
||||
@@ -73,8 +73,15 @@ export function testView(betaId: number): BetaTestView | null {
|
||||
const runs = db().prepare('SELECT * FROM beta_test_runs WHERE beta_id = ? AND retired_at IS NULL ORDER BY server').all(betaId) as TestRun[];
|
||||
return { plan, outcome: stored.outcome, reason: stored.reason,
|
||||
servers: runs.map(run => {
|
||||
const progress = evaluateRequirements(plan, readMetrics(run), run.phase);
|
||||
return { server: run.server, phase: run.phase, passed: progress.every(r => r.passed), progress };
|
||||
const metrics = readMetrics(run);
|
||||
const progress = evaluateRequirements(plan, metrics, run.phase);
|
||||
const latest = db().prepare("SELECT received_at, kind, payload FROM beta_test_evidence WHERE run_id = ? AND kind IN ('game_start','round_change','game_end') ORDER BY id DESC LIMIT 1").get(run.id) as { received_at: string; kind: string; payload: string } | undefined;
|
||||
const event = latest ? JSON.parse(latest.payload) : null;
|
||||
return { server: run.server, phase: run.phase, passed: progress.every(r => r.passed), progress, observingAt: run.observing_at,
|
||||
observation: { lastGameplayAt: latest?.received_at ?? null, lastEvent: latest?.kind ?? null,
|
||||
players: metrics.match?.players ?? null, round: event?.round ?? null,
|
||||
matchStarted: metrics.match?.started !== undefined, gameEnded: metrics.match?.ended ?? false,
|
||||
healthy: metrics.wasHealthy ?? null, healthCheckedAt: metrics.lastSample ? new Date(metrics.lastSample).toISOString() : null } };
|
||||
}),
|
||||
evidence: db().prepare(`SELECT e.id, r.server, e.received_at, e.kind, e.payload FROM beta_test_evidence e
|
||||
JOIN beta_test_runs r ON r.id = e.run_id WHERE r.beta_id = ? ORDER BY e.id DESC LIMIT 200`).all(betaId) as BetaTestView['evidence'] };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { parseBetaPlan, type BetaPlan } from '../../beta-plan.js';
|
||||
import { storedPlan } from './beta-tests.js';
|
||||
import type { BetaPromotionPreview } from '../../beta-promotion.js';
|
||||
|
||||
const operations = new Set<number>();
|
||||
export const betaOperationInFlight = (id: number) => operations.has(id);
|
||||
@@ -192,6 +193,7 @@ export class BetaError extends Error {
|
||||
| 'dest-fail'
|
||||
| 'invalid-state'
|
||||
| 'duplicate-script'
|
||||
| 'preview-changed'
|
||||
| 'duplicate-target',
|
||||
public readonly details?: unknown
|
||||
) {
|
||||
@@ -721,6 +723,54 @@ export interface PromoteResult {
|
||||
deploymentId: string;
|
||||
landed: string[]; // unique servers that received at least one file
|
||||
scriptIds: number[];
|
||||
awaitingActivation: number;
|
||||
failedServers: string[];
|
||||
}
|
||||
|
||||
/** Snapshot exactly the scope expansion and composed configuration reviewed by the operator. */
|
||||
function promotionPlan(betaId: number): { preview: BetaPromotionPreview; configContent: Map<string, Buffer> } {
|
||||
const beta = getBeta(betaId);
|
||||
if (!beta) throw new BetaError('This beta no longer exists.', 'no-such-beta');
|
||||
const bundle = listBetaScripts(betaId).map(row => {
|
||||
const script = getScript(row.script_id), version = getVersion(row.script_version_id);
|
||||
if (!script || !version) throw new BetaError('A selected file version is no longer available.', 'no-such-version');
|
||||
return { script, version };
|
||||
});
|
||||
if (!bundle.length) throw new BetaError('This beta has no files.', 'no-scripts');
|
||||
const tested = new Set(listBetaServers(betaId).map(row => row.server));
|
||||
const targets = (script: ScriptRow) => [...new Set([...tested, ...(script.scope_type && script.scope_value
|
||||
? serversMatchingScope(script.scope_type, script.scope_value) : [])])].sort();
|
||||
const files = bundle.map(({ script, version }) => ({ id: script.id, name: script.name, kind: script.kind,
|
||||
path: script.target_rel_path, versionId: version.id, version: version.version,
|
||||
release_version: version.release_version, sha256: version.sha256,
|
||||
scope: script.scope_type ? `${script.scope_type}: ${script.scope_value}` : 'Selected test servers only', servers: targets(script) }));
|
||||
const names = [...new Set(files.flatMap(file => file.servers))].sort();
|
||||
const blockers: string[] = [];
|
||||
const servers = names.map(name => {
|
||||
const server = getServer(name);
|
||||
if (!server || server.destroyed_at) blockers.push(`${name}: server is no longer available.`);
|
||||
return { name, tested: tested.has(name), game: server?.game ?? null, map: server?.map ?? null };
|
||||
});
|
||||
const collapsing = bundle.filter(({ script }) => isCollapsingKind(script.kind));
|
||||
const configContent = new Map<string, Buffer>();
|
||||
const configWrites = [...new Set(collapsing.flatMap(({ script }) => targets(script)))].sort().map(server => {
|
||||
const row = getServer(server);
|
||||
const composed = row && composeCfgForServer(server, row.game, row.map, {
|
||||
inBetaBaseline: collapsing.find(({ script }) => script.kind === 'baseline')?.version,
|
||||
inBetaOverlays: collapsing.filter(({ script }) => script.kind === 'config')
|
||||
});
|
||||
const error = composed ? null : `${server}: no baseline is available to compose its configuration.`;
|
||||
if (error) blockers.push(error);
|
||||
const content = composed ? Buffer.from(composed.text, 'utf8') : null;
|
||||
if (content) configContent.set(server, content);
|
||||
return { server, path: configFileOf(server), sha256: content ? sha256Buffer(content) : null, error };
|
||||
});
|
||||
const impact = { betaId, files, servers, configWrites, blockers, restartPolicy: 'additional-when-empty' as const };
|
||||
return { preview: { ...impact, token: sha256Buffer(Buffer.from(JSON.stringify(impact))), createdAt: nowIso() }, configContent };
|
||||
}
|
||||
|
||||
export function previewBetaPromotion(betaId: number): BetaPromotionPreview {
|
||||
return promotionPlan(betaId).preview;
|
||||
}
|
||||
|
||||
function promoteDeploymentId(betaId: number): string {
|
||||
@@ -728,14 +778,14 @@ function promoteDeploymentId(betaId: number): string {
|
||||
return `dep-${stamp}-promote-${betaId}`;
|
||||
}
|
||||
|
||||
export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
||||
export async function promoteBeta(betaId: number, reviewedToken?: string): Promise<PromoteResult> {
|
||||
if (storedPlan(betaId)) {
|
||||
const { plannedBetaTick } = await import('../pipeline/beta-plan-runner.js');
|
||||
await plannedBetaTick();
|
||||
}
|
||||
return exclusive(betaId, () => promoteBetaInternal(betaId));
|
||||
return exclusive(betaId, () => promoteBetaInternal(betaId, reviewedToken));
|
||||
}
|
||||
async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
|
||||
async function promoteBetaInternal(betaId: number, reviewedToken?: string): Promise<PromoteResult> {
|
||||
const test = storedPlan(betaId);
|
||||
if (test && test.outcome !== 'passed') throw new BetaError('Test plan has not passed', 'not-ready-promote');
|
||||
const beta = getBeta(betaId);
|
||||
@@ -743,6 +793,15 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
|
||||
if (beta.state !== 'ready-promote') {
|
||||
throw new BetaError(`beta must be ready-promote (is ${beta.state})`, 'not-ready-promote');
|
||||
}
|
||||
const reviewed = promotionPlan(betaId);
|
||||
const checkReview = () => {
|
||||
const current = previewBetaPromotion(betaId);
|
||||
if (current.token !== (reviewedToken ?? reviewed.preview.token)) {
|
||||
throw new BetaError('Promotion targets or versions changed. Review the updated plan before promoting.', 'preview-changed', current);
|
||||
}
|
||||
if (current.blockers.length) throw new BetaError(current.blockers.join(' '), 'invalid-state');
|
||||
};
|
||||
checkReview();
|
||||
const bundleRows = listBetaScripts(betaId);
|
||||
if (bundleRows.length === 0) throw new BetaError('beta has no scripts', 'no-scripts');
|
||||
const bundle = bundleRows.map((br) => ({
|
||||
@@ -755,8 +814,6 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
|
||||
// Split bundle into non-collapsing (per-file) and collapsing (composed cfg).
|
||||
const nonCollapsing = bundle.filter((b) => !isCollapsingKind(b.script.kind));
|
||||
const collapsing = bundle.filter((b) => isCollapsingKind(b.script.kind));
|
||||
const collapsingBaseline = collapsing.find((b) => b.script.kind === 'baseline');
|
||||
const collapsingOverlays = collapsing.filter((b) => b.script.kind === 'config');
|
||||
|
||||
// Build a per-(script, server) plan for non-collapsing artifacts.
|
||||
interface Plan {
|
||||
@@ -776,10 +833,7 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
|
||||
);
|
||||
|
||||
for (const { script, version } of nonCollapsing) {
|
||||
const scopeServers = script.scope_type && script.scope_value
|
||||
? serversMatchingScope(script.scope_type, script.scope_value)
|
||||
: [];
|
||||
const targets = new Set<string>([...betaServerSet, ...scopeServers]);
|
||||
const targets = reviewed.preview.files.find(file => file.id === script.id)!.servers;
|
||||
|
||||
for (const name of targets) {
|
||||
const dest = destOfKind(name, script.kind);
|
||||
@@ -830,23 +884,6 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
|
||||
// Write files. Track per-plan success and per-server first-error.
|
||||
const planWritten = new Map<Plan, boolean>();
|
||||
const serverErr = new Map<string, string>();
|
||||
for (const p of plans) {
|
||||
const dest = destOfKind(p.server, p.script.kind);
|
||||
const targetPath = join(dest, p.script.target_rel_path);
|
||||
try {
|
||||
await mkdir(join(dest, dirname(p.script.target_rel_path)), { recursive: true });
|
||||
// Beta servers already have the version bytes at targetPath from
|
||||
// openBeta; scope-expansion servers need the write. Idempotent
|
||||
// either way — we just overwrite with the promoted content.
|
||||
await writeFile(targetPath, p.version.content);
|
||||
planWritten.set(p, true);
|
||||
} catch (err) {
|
||||
planWritten.set(p, false);
|
||||
const msg = `${p.script.name}: ${(err as Error).message}`;
|
||||
if (!serverErr.has(p.server)) serverErr.set(p.server, msg);
|
||||
console.error(`[promote] ${p.server}/${p.script.name} failed:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Collapsing config layer: one composed cfg per server. Union of beta
|
||||
// servers + scope-matching servers for every collapsing artifact.
|
||||
@@ -860,29 +897,18 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
|
||||
}
|
||||
const cfgPlans: CfgPlan[] = [];
|
||||
if (collapsing.length > 0) {
|
||||
const cfgTargets = new Set<string>(betaServerSet);
|
||||
for (const { script } of collapsing) {
|
||||
if (script.scope_type && script.scope_value) {
|
||||
for (const s of serversMatchingScope(script.scope_type, script.scope_value)) {
|
||||
cfgTargets.add(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
const cfgTargets = reviewed.preview.configWrites.map(write => write.server);
|
||||
for (const name of cfgTargets) {
|
||||
const srv = getServer(name);
|
||||
if (!srv) {
|
||||
serverErr.set(name, 'unknown server during config promote');
|
||||
continue;
|
||||
}
|
||||
const composedResult = composeCfgForServer(name, srv.game, srv.map, {
|
||||
inBetaBaseline: collapsingBaseline?.version,
|
||||
inBetaOverlays: collapsingOverlays
|
||||
});
|
||||
if (!composedResult) {
|
||||
const composedBuf = reviewed.configContent.get(name);
|
||||
if (!composedBuf) {
|
||||
serverErr.set(name, `no baseline available for ${name} (game=${srv.game ?? '?'})`);
|
||||
continue;
|
||||
}
|
||||
const composedBuf = Buffer.from(composedResult.text, 'utf8');
|
||||
const composedSha = sha256Buffer(composedBuf);
|
||||
const dest = destOfKind(name, 'config');
|
||||
const targetRel = configFileOf(name);
|
||||
@@ -913,6 +939,20 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
|
||||
}
|
||||
}
|
||||
|
||||
// Recheck after asynchronous snapshot reads, before any mutation. Execution uses
|
||||
// the captured target lists/content so later membership changes never expand it.
|
||||
checkReview();
|
||||
for (const p of plans) {
|
||||
const dest = destOfKind(p.server, p.script.kind);
|
||||
try {
|
||||
await mkdir(join(dest, dirname(p.script.target_rel_path)), { recursive: true });
|
||||
await writeFile(join(dest, p.script.target_rel_path), p.version.content);
|
||||
planWritten.set(p, true);
|
||||
} catch (err) {
|
||||
planWritten.set(p, false);
|
||||
if (!serverErr.has(p.server)) serverErr.set(p.server, `${p.script.name}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
// Write composed cfg to each target.
|
||||
const cfgWritten = new Map<CfgPlan, boolean>();
|
||||
for (const cp of cfgPlans) {
|
||||
@@ -1061,7 +1101,9 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
|
||||
betaId,
|
||||
deploymentId: depId,
|
||||
landed,
|
||||
scriptIds: bundle.map((b) => b.script.id)
|
||||
scriptIds: bundle.map((b) => b.script.id),
|
||||
awaitingActivation: reviewed.preview.servers.filter(server => !server.tested && !serverErr.has(server.name)).length,
|
||||
failedServers: [...serverErr.keys()]
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import { config, komodoConfigured, rconConfigured } from '../config.js';
|
||||
import { blueprintEntries, summarizeRecipe, type BlueprintEntry } from './launch-recipes.js';
|
||||
import { creationQueue, nextAvailablePort, nextLobbyNumber, queueCreateServer } from './create.js';
|
||||
import { getServerKey } from './server-keys.js';
|
||||
import { classifyHealth } from './health.js';
|
||||
import { getServer } from './servers.js';
|
||||
import { parseServerName, zombieMapBySlug, zombieMaps } from './map-aliases.js';
|
||||
|
||||
function coverage(entries: BlueprintEntry[]) {
|
||||
@@ -29,7 +31,15 @@ function coverage(entries: BlueprintEntry[]) {
|
||||
const matches = zombieMaps(game).filter(map => map.canonical === server.map);
|
||||
return matches.length === 1 && matches[0].slug === entry.recipe.mapSlug;
|
||||
}).map(server => server.name).sort();
|
||||
return { existingServers, existing: existingServers.length, missing: Math.max(0, entry.count - existingServers.length) };
|
||||
const coverage = existingServers.map(name => {
|
||||
const operation = db().prepare("SELECT id, state FROM server_deployments WHERE server = ? AND action = 'create' AND state IN ('queued', 'running', 'awaiting-health') ORDER BY rowid DESC LIMIT 1").get(name) as { id: string; state: string } | undefined;
|
||||
const server = getServer(name);
|
||||
return { name, health: operation ? operation.state === 'queued' ? 'queued' : 'starting'
|
||||
: server?.container_state && !['running', 'starting', 'restarting'].includes(server.container_state) ? 'stopped'
|
||||
: classifyHealth(server?.last_seen_at ?? null, server?.last_error ?? null, server?.container_state),
|
||||
href: operation ? `/deployments/${encodeURIComponent(operation.id)}` : `/servers/${encodeURIComponent(name)}` };
|
||||
});
|
||||
return { existingServers, coverage, existing: existingServers.length, missing: Math.max(0, entry.count - existingServers.length) };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { rconGetPlayerCount, rconStatusResult } from '../integrations/rcon.js';
|
||||
import { beginDeployment, deploymentStage, finishDeployment, recordDeploymentEvent, REMOVING_SERVER, resumeCleanup, resumeVerification } from './deployments.js';
|
||||
import { beginDeployment, deploymentStage, finishDeployment, recordDeploymentEvent, REMOVING_SERVER, resumeCleanup, resumeVerification, claimRemoval } from './deployments.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { LaunchPreview } from '$lib/deployment-plan.js';
|
||||
import { blueprintRecipe, captureRecipe, recipeForDeployment, summarizeRecipe, type LaunchRecipe } from './launch-recipes.js';
|
||||
@@ -461,6 +461,7 @@ async function runDestructions(): Promise<void> {
|
||||
const queued = db().prepare("SELECT id, server, stage FROM server_deployments WHERE action = 'destroy' AND state = 'waiting' ORDER BY started_at").all() as { id: string; server: string; stage: string }[];
|
||||
for (const operation of queued) {
|
||||
if (creating || destroying.has(operation.server)) continue;
|
||||
if (!db().prepare("SELECT id FROM server_deployments WHERE id = ? AND state = 'waiting'").get(operation.id)) continue;
|
||||
destroying.add(operation.server);
|
||||
try {
|
||||
if (operation.stage === REMOVING_SERVER) {
|
||||
@@ -470,7 +471,7 @@ async function runDestructions(): Promise<void> {
|
||||
finishDeployment(operation.id, { ok: false, message: 'A newer creation exists; local cleanup cannot safely resume.' });
|
||||
continue;
|
||||
}
|
||||
db().prepare("UPDATE server_deployments SET state = 'running' WHERE id = ?").run(operation.id);
|
||||
if (!claimRemoval(operation.id)) continue;
|
||||
finishDeployment(operation.id, await cleanupDestroyedServer(operation.server, operation.id));
|
||||
continue;
|
||||
}
|
||||
@@ -484,20 +485,21 @@ async function runDestructions(): Promise<void> {
|
||||
}
|
||||
// A fresh successful zero is required; cached occupancy and timeouts are not evidence of emptiness.
|
||||
const players = await rconGetPlayerCount(operation.server);
|
||||
if (!db().prepare("SELECT id FROM server_deployments WHERE id = ? AND state = 'waiting'").get(operation.id)) continue;
|
||||
if (players !== 0) {
|
||||
deploymentStage(operation.id, players === null
|
||||
? 'Waiting for player count: server could not be reached'
|
||||
: 'Waiting for server to empty: ' + players + ' player(s) online');
|
||||
continue;
|
||||
}
|
||||
if (!claimRemoval(operation.id)) continue;
|
||||
deploymentStage(operation.id, 'Server is empty (0 players verified)');
|
||||
db().prepare("UPDATE server_deployments SET state = 'running' WHERE id = ?").run(operation.id);
|
||||
const result = await destroyServerUnlocked(operation.server, operation.id);
|
||||
finishDeployment(operation.id, result);
|
||||
} catch (err) {
|
||||
const row = db().prepare('SELECT state FROM server_deployments WHERE id = ?').get(operation.id) as { state: string };
|
||||
if (row.state === 'waiting') deploymentStage(operation.id, 'Waiting for player count: ' + (err as Error).message);
|
||||
else finishDeployment(operation.id, { ok: false, message: (err as Error).message });
|
||||
else if (row.state !== 'cancelled') finishDeployment(operation.id, { ok: false, message: (err as Error).message });
|
||||
} finally { destroying.delete(operation.server); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,12 +30,31 @@ export function getDeployment(id: string) {
|
||||
const collection = db().prepare(`SELECT c.id, c.name FROM server_collection_members m
|
||||
JOIN server_launch_collections c ON c.id = m.collection_id WHERE m.deployment_id = ?`).get(id) ?? null;
|
||||
return { deployment, events, recipe: launch ? summarizeRecipe(launch.recipe) : null, blueprintName: launch?.blueprintName ?? null,
|
||||
canResumeVerification: canResumeVerification(id), canResumeCleanup: canResumeCleanup(id), collection };
|
||||
canResumeVerification: canResumeVerification(id), canResumeCleanup: canResumeCleanup(id), canCancelRemoval: canCancelRemoval(id), collection };
|
||||
}
|
||||
|
||||
// This stage is written only after Komodo has confirmed deployment deletion.
|
||||
export const REMOVING_SERVER = 'Removing server files and registry';
|
||||
|
||||
export function canCancelRemoval(id: string): boolean {
|
||||
return !!db().prepare("SELECT id FROM server_deployments WHERE id = ? AND action = 'destroy' AND state = 'waiting' AND stage != ?").get(id, REMOVING_SERVER);
|
||||
}
|
||||
|
||||
/** Competes atomically with the worker's claim, before any external removal. */
|
||||
export function cancelRemoval(id: string): boolean {
|
||||
return db().transaction(() => {
|
||||
const result = db().prepare(`UPDATE server_deployments SET state = 'cancelled', stage = 'Removal cancelled', finished_at = ?, error = NULL
|
||||
WHERE id = ? AND action = 'destroy' AND state = 'waiting' AND stage != ?`).run(new Date().toISOString(), id, REMOVING_SERVER);
|
||||
if (!result.changes) return false;
|
||||
recordDeploymentEvent(id, 'Queued removal cancelled. The server and its files were kept.');
|
||||
return true;
|
||||
})();
|
||||
}
|
||||
|
||||
export function claimRemoval(id: string): boolean {
|
||||
return db().prepare("UPDATE server_deployments SET state = 'running' WHERE id = ? AND action = 'destroy' AND state = 'waiting'").run(id).changes > 0;
|
||||
}
|
||||
|
||||
export function canResumeCleanup(id: string): boolean {
|
||||
return !!db().prepare(`SELECT d.id FROM server_deployments d
|
||||
WHERE d.id = ? AND d.action = 'destroy' AND d.state IN ('failed', 'interrupted') AND d.stage = ?
|
||||
@@ -81,8 +100,9 @@ export function finishDeployment(id: string, result: { ok: boolean; message?: st
|
||||
}
|
||||
|
||||
export function listDeployments() {
|
||||
return db().prepare(`SELECT d.*, EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server) AS server_exists
|
||||
FROM server_deployments d ORDER BY started_at DESC, rowid DESC`).all();
|
||||
return db().prepare(`SELECT d.*, EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server) AS server_exists,
|
||||
(d.action = 'destroy' AND d.state = 'waiting' AND d.stage != ?) AS canCancelRemoval
|
||||
FROM server_deployments d ORDER BY started_at DESC, rowid DESC`).all(REMOVING_SERVER);
|
||||
}
|
||||
|
||||
export function interruptDeployments(): void {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -35,17 +35,17 @@ export function fleetOperations(now = Date.now()): FleetOperation[] {
|
||||
// Only the newest lifecycle operation can still require action on a server.
|
||||
const lifecycle = handle.prepare(`SELECT d.* FROM server_deployments d
|
||||
WHERE d.state IN ('queued', 'running', 'waiting', 'awaiting-health')
|
||||
OR (d.state IN ('done', 'warning') AND d.finished_at >= ?)
|
||||
OR (d.state IN ('done', 'warning', 'cancelled') AND d.finished_at >= ?)
|
||||
OR (d.state IN ('failed', 'interrupted', 'warning') AND NOT EXISTS
|
||||
(SELECT 1 FROM server_deployments n WHERE n.server = d.server AND n.rowid > d.rowid))`)
|
||||
.all(since) as { id: string; server: string; action: string; state: string; stage: string;
|
||||
error: string | null; started_at: string }[];
|
||||
for (const row of lifecycle) {
|
||||
const state = row.state === 'done' || row.state === 'warning' ? 'done'
|
||||
const state = ['done', 'warning', 'cancelled'].includes(row.state) ? 'done'
|
||||
: ['failed', 'interrupted'].includes(row.state) ? 'attention' : 'active';
|
||||
operations.push({ key: `deployment:${row.id}`, group: `deployment:${row.id}`, kind: 'deployment',
|
||||
server: row.server, state, startedAt: row.started_at,
|
||||
label: state === 'done' ? row.state === 'warning' ? 'Completed with warnings' : 'Complete'
|
||||
label: state === 'done' ? row.state === 'cancelled' ? 'Removal cancelled' : row.state === 'warning' ? 'Completed with warnings' : 'Complete'
|
||||
: state === 'attention' ? `${row.action === 'create' ? 'Launch' : 'Removal'} needs attention` : row.stage,
|
||||
detail: row.error ?? row.stage, href: `/deployments/${encodeURIComponent(row.id)}` });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface SetupCheck {
|
||||
id: string;
|
||||
title: string;
|
||||
state: 'ready' | 'attention' | 'unconfigured' | 'unverified';
|
||||
detail: string;
|
||||
href?: string;
|
||||
action?: string;
|
||||
}
|
||||
export interface SystemStatus {
|
||||
checkedAt: string;
|
||||
checks: SetupCheck[];
|
||||
readyServers: number;
|
||||
totalServers: number;
|
||||
savedKeys: number;
|
||||
}
|
||||
@@ -50,6 +50,13 @@
|
||||
let artifact = $state<Artifact | null>(null);
|
||||
let versions = $state<Version[]>([]);
|
||||
let matchedServers = $state<string[]>([]);
|
||||
let installedVersions = $state<Array<{ server: string; version: number; versionId: number; recordedAt: string }>>([]);
|
||||
interface ScopeImpact { before: string[]; after: string[]; added: string[]; removed: string[]; affected: string[]; composed: boolean; detached: string[]; }
|
||||
let scopeReviewOpen = $state(false);
|
||||
let scopeImpact = $state<ScopeImpact | null>(null);
|
||||
let scopePreviewError = $state('');
|
||||
let scopePreviewLoading = $state(false);
|
||||
let scopeComplete = $state<{ count: number; composed: boolean; detached: number; href: string } | null>(null);
|
||||
let runningBetaId = $state<number | null>(null);
|
||||
let loading = $state(true);
|
||||
let flash = $state<string | null>(null);
|
||||
@@ -84,9 +91,24 @@
|
||||
: null
|
||||
);
|
||||
const latestVersion = $derived(versions[0] ?? null);
|
||||
const showLatestSeparately = $derived(
|
||||
latestVersion !== null && currentVersion !== null && latestVersion.id !== currentVersion.id
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
const type = scopeType; const value = editedScopeValue; const artifactId = id;
|
||||
if (!scopeReviewOpen) return;
|
||||
scopeImpact = null; scopePreviewError = '';
|
||||
if (type !== 'none' && !value) { scopePreviewLoading = false; return; }
|
||||
const controller = new AbortController(); scopePreviewLoading = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/scripts/${artifactId}/scope?${new URLSearchParams({ type, value })}`, { signal: controller.signal });
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not preview scope.');
|
||||
if (!controller.signal.aborted) scopeImpact = body;
|
||||
} catch (error) { if (!controller.signal.aborted) scopePreviewError = (error as Error).message; }
|
||||
finally { if (!controller.signal.aborted) scopePreviewLoading = false; }
|
||||
})();
|
||||
return () => controller.abort();
|
||||
});
|
||||
|
||||
function scopeLabel(s: Artifact): string {
|
||||
if (!s.scope_type) return 'unscoped';
|
||||
@@ -105,7 +127,7 @@
|
||||
const body = await detailRes.json();
|
||||
const scopes = scopeRes?.ok ? await scopeRes.json() : null;
|
||||
if (requestedId !== id || current !== request) return;
|
||||
artifact = body.script; versions = body.versions; matchedServers = body.matchedServers; runningBetaId = body.runningBetaId;
|
||||
artifact = body.script; versions = body.versions; matchedServers = body.matchedServers; runningBetaId = body.runningBetaId; installedVersions = body.installedVersions ?? [];
|
||||
if (scopes) scopeOptions = scopes;
|
||||
if (seedForm) seedScopeForm();
|
||||
loadError = '';
|
||||
@@ -135,10 +157,12 @@
|
||||
if (runningBetaId !== null) return;
|
||||
seedScopeForm();
|
||||
flash = null;
|
||||
scopeReviewOpen = true;
|
||||
scopeDialog?.showModal();
|
||||
}
|
||||
function cancelScopeModal(): void {
|
||||
seedScopeForm();
|
||||
scopeReviewOpen = false;
|
||||
scopeDialog?.close();
|
||||
}
|
||||
function openUploadModal(): void {
|
||||
@@ -192,7 +216,7 @@
|
||||
|
||||
async function saveScope(e: Event): Promise<void> {
|
||||
e.preventDefault();
|
||||
if (updatingScope) return;
|
||||
if (updatingScope || !scopeImpact || scopePreviewLoading) return;
|
||||
if (scopeType !== 'none' && !editedScopeValue) {
|
||||
flash = `Pick a ${scopeType}`;
|
||||
return;
|
||||
@@ -207,12 +231,13 @@
|
||||
scopeValue: scopeType === 'none' ? null : editedScopeValue
|
||||
})
|
||||
});
|
||||
const body = (await res.json()) as { ok: boolean; error?: string; kind?: string };
|
||||
const body = (await res.json()) as { ok: boolean; error?: string; kind?: string; detached?: string[] };
|
||||
if (!res.ok || !body.ok) {
|
||||
flash = body.error ?? `HTTP ${res.status}`;
|
||||
return;
|
||||
}
|
||||
scopeDialog?.close();
|
||||
scopeComplete = { count: scopeImpact.affected.length, composed: scopeImpact.composed, detached: body.detached?.length ?? 0, href: scopeImpact.affected.length === 1 ? '/campaigns?server=' + encodeURIComponent(scopeImpact.affected[0]) : '/campaigns' };
|
||||
scopeReviewOpen = false; scopeDialog?.close();
|
||||
await load();
|
||||
} catch (error) { flash = (error as Error).message; } finally {
|
||||
updatingScope = false;
|
||||
@@ -244,6 +269,8 @@
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{artifact?.name ?? crumbLabel} · Electric Cherry</title></svelte:head>
|
||||
|
||||
{#if loadError}<div class="ui-error" role="alert">{loadError}<Button size="sm" onclick={() => load()}>Retry</Button></div>{/if}
|
||||
{#if loading}
|
||||
<div class="loading">Loading…</div>
|
||||
@@ -258,6 +285,9 @@
|
||||
{/snippet}
|
||||
</SectionHeader>
|
||||
|
||||
{#if scopeComplete}<div class="scope-complete" role="status"><strong>Scope saved.</strong> {scopeComplete.composed ? `Intended configuration may change on ${scopeComplete.count} eligible servers.` : scopeComplete.detached ? `Inventory tracking detached on ${scopeComplete.detached} servers; live files remain.` : `${scopeComplete.count} servers are in the old or new scope.`} Running servers have not been changed. <a href={scopeComplete.href}>Review reconciliation →</a></div>{/if}
|
||||
{#if latestVersion}<Card padding="lg"><div class="release-summary"><div><h2>Latest {artifactVersionLabel(latestVersion)}</h2><StatusBadge state={latestVersion.id === a.current_version_id ? 'promoted' : latestVersion.state} /><p>{latestVersion.notes || 'No version notes provided.'}</p></div>{#if runningBetaId !== null}<Button variant="primary" href={`/betas/${runningBetaId}`}>View active beta</Button>{:else if latestVersion.id !== a.current_version_id}<Button variant="primary" href={betaSelectionHref([{ scriptId: id, versionId: latestVersion.id }])}>Test latest version</Button>{:else}<span class="dim">Latest version is promoted</span>{/if}</div></Card>{/if}
|
||||
|
||||
{#if runningBetaId}
|
||||
<div class="warn-banner">
|
||||
Beta <a href="/betas/{runningBetaId}"><strong>#{runningBetaId}</strong></a> is running for this {family}.
|
||||
@@ -278,7 +308,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kv-grid">
|
||||
<details class="file-settings"><summary>Target file, scope, and eligible servers</summary><div class="kv-grid">
|
||||
<div class="kv">
|
||||
<span class="k">Target file</span>
|
||||
<span class="v mono">{a.target_rel_path}</span>
|
||||
@@ -295,7 +325,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="kv">
|
||||
<span class="k">Applies to</span>
|
||||
<span class="k">Eligible servers</span>
|
||||
<span class="v mono">
|
||||
{#if !a.scope_type}
|
||||
<span class="dim">Unscoped — deploy from a beta</span>
|
||||
@@ -307,28 +337,21 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="kv">
|
||||
<span class="k">Current version</span>
|
||||
<span class="k">Promoted version</span>
|
||||
<span class="v mono">
|
||||
{#if currentVersion}
|
||||
{artifactVersionLabel(currentVersion)} · {currentVersion.sha256.slice(0, 8)}
|
||||
<Badge variant="online" size="sm">Promoted</Badge>
|
||||
{:else}
|
||||
<span class="dim">Never promoted — open a <a href="/betas">beta</a></span>
|
||||
<span class="dim">Never promoted — test a version first</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if showLatestSeparately && latestVersion}
|
||||
<div class="kv">
|
||||
<span class="k">Latest version</span>
|
||||
<span class="v mono">
|
||||
{artifactVersionLabel(latestVersion)} · {latestVersion.sha256.slice(0, 8)}
|
||||
<StatusBadge state={latestVersion.state} size="sm" />
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div></details>
|
||||
</Card>
|
||||
|
||||
{#if installedVersions.length}<details class="installed"><summary>Recorded installed files · {installedVersions.length} servers</summary><p>Last recorded file writes, not a verification of the currently running process.</p><ul>{#each installedVersions as installed}<li><a href={'/servers/' + encodeURIComponent(installed.server)}>{installed.server}</a> · v{installed.version} · {new Date(installed.recordedAt).toLocaleString()}</li>{/each}</ul></details>{:else if family !== 'config'}<p class="dim">No installed file versions recorded. Eligibility does not mean a file is installed.</p>{/if}
|
||||
|
||||
{#if a.kind === 'config' || a.kind === 'baseline'}
|
||||
<ConfigEditor {id} kind={a.kind} {versions} currentVersionId={a.current_version_id}
|
||||
{runningBetaId} scopeType={a.scope_type} scopeValue={a.scope_value} onsaved={() => load({ seedForm: false })} />
|
||||
@@ -346,8 +369,6 @@
|
||||
{#each versions.slice(0,limit) as v (v.id)}
|
||||
<div class="version-row" class:current={a.current_version_id === v.id}>
|
||||
<span class="v-num">{artifactVersionLabel(v)}</span>
|
||||
<span class="v-sha">{v.sha256.slice(0, 12)}</span>
|
||||
<span class="v-size">{v.size} B</span>
|
||||
<span class="v-notes">{v.notes ?? ''}
|
||||
{#if v.source}<a href={v.source.release_url} target="_blank" rel="noreferrer">GitHub · {v.source.asset_name} ↗</a>{/if}
|
||||
</span>
|
||||
@@ -358,6 +379,7 @@
|
||||
<StatusBadge state={v.state} size="sm" />
|
||||
{#if runningBetaId === null}<Button size="sm" href={betaSelectionHref([{scriptId:id,versionId:v.id}])}>Test version</Button>{/if}
|
||||
{/if}
|
||||
<details class="version-technical"><summary>File details</summary><span>SHA-256: {v.sha256}</span><span>Size: {v.size.toLocaleString()} bytes</span></details>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -374,10 +396,10 @@
|
||||
<Button variant="danger" onclick={deleteArtifact}>Delete {family}</Button>
|
||||
</Card>
|
||||
|
||||
{#if flash}<div class="alert">{flash}</div>{/if}
|
||||
{#if flash}<div class="alert" role="alert">{flash}</div>{/if}
|
||||
|
||||
<!-- Scope dialog -->
|
||||
<dialog aria-label="Edit scope" oncancel={event => { if (updatingScope) event.preventDefault(); }} bind:this={scopeDialog} onclick={(e) => onDialogClick(scopeDialog, e)}>
|
||||
<dialog aria-label="Edit scope" onclose={() => scopeReviewOpen = false} oncancel={event => { if (updatingScope) event.preventDefault(); }} bind:this={scopeDialog} onclick={(e) => onDialogClick(scopeDialog, e)}>
|
||||
<form class="dlg" onsubmit={saveScope}>
|
||||
<header class="dlg-head">
|
||||
<h3>Edit scope</h3>
|
||||
@@ -387,20 +409,21 @@
|
||||
<div class="scope-row">
|
||||
<Select ariaLabel="Scope type" bind:value={scopeType} options={scopeTypeOptions} disabled={updatingScope} />
|
||||
{#if scopeType === 'game' || scopeType === 'map'}
|
||||
<Select ariaLabel="Scope game" bind:value={scopeGame} options={gameOptions} disabled={updatingScope} />
|
||||
<Select ariaLabel="Scope game" bind:value={scopeGame} onchange={() => scopeMap = ''} options={gameOptions} disabled={updatingScope} />
|
||||
{/if}
|
||||
{#if scopeType === 'map'}
|
||||
<Select ariaLabel="Scope map" bind:value={scopeMap} options={mapOptions} disabled={updatingScope || !scopeGame} />
|
||||
{/if}
|
||||
</div>
|
||||
</Field>
|
||||
{#if scopePreviewLoading}<p role="status">Reviewing eligible servers…</p>{:else if scopePreviewError}<p role="alert">{scopePreviewError}</p>{:else if scopeImpact}<section class="scope-impact" aria-label="Scope change review"><strong>{scopeImpact.after.length} eligible · {scopeImpact.added.length} added · {scopeImpact.removed.length} removed</strong>{#if scopeImpact.added.length}<p>Added: {scopeImpact.added.join(', ')}</p>{/if}{#if scopeImpact.removed.length}<p>Removed: {scopeImpact.removed.join(', ')}</p>{/if}<p>{scopeImpact.composed ? 'This changes intended configuration composition. Live configuration files remain unchanged until a deployment or reconciliation.' : scopeType === 'none' ? `${scopeImpact.detached.length} recorded installations will become untracked. Files remain on servers; review reconciliation to remove orphaned files.` : 'Scope describes eligibility. Saving does not install files or restart servers; review reconciliation for existing promoted files.'}</p></section>{/if}
|
||||
<div class="dlg-actions">
|
||||
<Button variant="ghost" onclick={cancelScopeModal} disabled={updatingScope}>Cancel</Button>
|
||||
<Button type="submit" variant="primary" disabled={updatingScope} loading={updatingScope}>
|
||||
Save scope
|
||||
<Button type="submit" variant="primary" disabled={updatingScope || scopePreviewLoading || !scopeImpact} loading={updatingScope}>
|
||||
Save reviewed scope
|
||||
</Button>
|
||||
</div>
|
||||
{#if flash}<div class="alert alert-danger">{flash}</div>{/if}
|
||||
{#if flash}<div class="alert alert-danger" role="alert">{flash}</div>{/if}
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
@@ -433,7 +456,7 @@
|
||||
Upload version
|
||||
</Button>
|
||||
</div>
|
||||
{#if flash}<div class="alert alert-danger">{flash}</div>{/if}
|
||||
{#if flash}<div class="alert alert-danger" role="alert">{flash}</div>{/if}
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
@@ -499,7 +522,7 @@
|
||||
.version-list { display: flex; flex-direction: column; gap: var(--space-1); }
|
||||
.version-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(100px, 1fr) 130px 80px 1fr 220px auto;
|
||||
grid-template-columns: minmax(100px, auto) minmax(0, 1fr) auto auto auto;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
@@ -514,7 +537,17 @@
|
||||
background: rgba(124, 212, 255, 0.04);
|
||||
}
|
||||
.v-num { color: var(--text-primary); font-weight: var(--weight-semibold); overflow-wrap: anywhere; }
|
||||
.v-sha, .v-size, .v-when { color: var(--text-tertiary); font-variant-numeric: tabular-nums; }
|
||||
.v-when { color: var(--text-tertiary); font-variant-numeric: tabular-nums; }
|
||||
.version-technical { grid-column: 1 / -1; color: var(--text-secondary); overflow-wrap: anywhere; }
|
||||
.version-technical span { display: block; margin-top: 8px; }
|
||||
.scope-complete { padding: 16px; border: 1px solid var(--state-online); border-radius: var(--radius-md); font-size: var(--text-sm); }
|
||||
.release-summary { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px; }
|
||||
.release-summary h2 { font-size: var(--text-lg); margin: 0 0 8px; }
|
||||
.release-summary p, .installed p, .scope-impact p { color: var(--text-secondary); font-size: var(--text-sm); overflow-wrap: anywhere; }
|
||||
.scope-impact { border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 12px; font-size: var(--text-sm); }
|
||||
.installed { font-size: var(--text-sm); }
|
||||
.file-settings { font-size: var(--text-sm); }
|
||||
.file-settings .kv-grid { margin-top: 16px; }
|
||||
.v-notes { color: var(--text-secondary); font-family: var(--font-body); font-size: var(--text-sm); }
|
||||
.v-notes { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 160px; overflow-y: auto; }
|
||||
.v-notes a { display: block; color: var(--accent-primary-hover); margin-top: var(--space-2); }
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import { builtInPlans, requirement, requirementKinds, requirementLabels, parseBetaPlan, type BetaPlan, type RequirementKind } from '$lib/beta-plan.js';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { builtInPlans, requirement, requirementKinds, requirementLabels, parseBetaPlan, betaPlanIssues, type BetaPlan, type RequirementKind } from '$lib/beta-plan.js';
|
||||
import { coverageSummary, needsGameTelemetry, presetDescriptions, requirementCategory, requirementSummary } from '$lib/beta-plan-summary.js';
|
||||
let { plan = $bindable<BetaPlan>() }: { plan: BetaPlan } = $props();
|
||||
let { plan = $bindable<BetaPlan>(), serverCount }: { plan: BetaPlan; serverCount?: number } = $props();
|
||||
let validationRequested = $state(false), coverageOpen = $state(false), deadlineOpen = $state(false);
|
||||
const issues = $derived(validationRequested ? betaPlanIssues(plan, serverCount) : []);
|
||||
const fieldError = (field: string) => issues.find(issue => issue.field === field)?.message;
|
||||
export async function validate(): Promise<boolean> {
|
||||
validationRequested = true;
|
||||
const issue = betaPlanIssues(plan, serverCount)[0];
|
||||
if (!issue) return true;
|
||||
if (issue.requirementId) editingId = issue.requirementId;
|
||||
if (['quorum', 'waveSize'].includes(issue.field)) coverageOpen = true;
|
||||
if (issue.field === 'timeoutHours') deadlineOpen = true;
|
||||
await tick();
|
||||
document.getElementById(`plan-${issue.field}`)?.focus();
|
||||
return false;
|
||||
}
|
||||
const targetLabel = (kind: RequirementKind) => ({ round:'Target round', matches:'Matches required', 'populated-minutes':'Populated minutes', 'player-hours':'Player-hours required', population:'Players required', 'sustained-population':'Consecutive minutes', 'healthy-minutes':'Consecutive healthy minutes', 'failure-budget':'Maximum health outages', recovery:'Recovery restarts', manual:'Checklist items' })[kind];
|
||||
let presets = $state(builtInPlans);
|
||||
let presetName = $state('');
|
||||
let addKind = $state<RequirementKind>('round');
|
||||
@@ -45,6 +59,7 @@
|
||||
message = `${requirementLabels[addKind]} added.`;
|
||||
}
|
||||
async function savePreset() {
|
||||
if (!await validate()) return;
|
||||
message = ''; error = ''; saving = true;
|
||||
try {
|
||||
const validated = parseBetaPlan(plan);
|
||||
@@ -61,13 +76,13 @@
|
||||
|
||||
<section class="builder" aria-label="Beta test plan">
|
||||
<header class="section-heading">
|
||||
<div><span class="eyebrow">Validation</span><h3>Build your test plan</h3></div>
|
||||
<p>Define the checks each qualifying server must pass.</p>
|
||||
<span class="pill">Manual promotion</span>
|
||||
<button type="button" class="secondary" aria-expanded={choosingPreset} aria-controls="beta-presets" onclick={() => choosingPreset = !choosingPreset}>{choosingPreset ? 'Close presets' : 'Change preset'}</button>
|
||||
</header>
|
||||
<div class="section-heading"><p>Tailor what each server needs to prove.</p><Button size="sm" onclick={() => choosingPreset = !choosingPreset}>{choosingPreset ? 'Close presets' : 'Change preset'}</Button></div>
|
||||
|
||||
{#if choosingPreset}
|
||||
<div class="presets" role="group" aria-label="Start from a preset">
|
||||
<div class="presets" id="beta-presets" role="group" aria-label="Start from a preset">
|
||||
{#each builtInPlans as preset, index (preset.name)}
|
||||
<button type="button" class="preset" class:selected={matchingPreset?.name === preset.name}
|
||||
aria-pressed={matchingPreset?.name === preset.name} aria-label={`Use ${preset.name} preset`} onclick={() => applyPreset(preset.name)}>
|
||||
@@ -90,6 +105,7 @@
|
||||
{/if}
|
||||
{#if message}<div class="feedback" role="status" aria-live="polite">{message}</div>{/if}
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
{#if issues.length}<p class="error" role="alert">Correct {issues.length} highlighted {issues.length === 1 ? 'field' : 'fields'} before continuing.</p>{/if}
|
||||
|
||||
<div class="active-plan">
|
||||
<div class="section-heading">
|
||||
@@ -118,20 +134,21 @@
|
||||
<summary>Edit requirement <span>{requirementLabels[r.kind]}</span></summary>
|
||||
<div class="editor-fields">
|
||||
<div class="fields">
|
||||
<label>Description<input bind:value={r.label} maxlength="200" required /></label>
|
||||
<label>Description<input id={`plan-${r.id}-label`} aria-invalid={!!fieldError(`${r.id}-label`)} aria-describedby={`error-${r.id}-label`} bind:value={r.label} maxlength="200" required /><span class="error" id={`error-${r.id}-label`}>{fieldError(`${r.id}-label`) ?? ''}</span></label>
|
||||
{#if r.kind !== 'manual'}
|
||||
<label>{r.kind === 'failure-budget' ? 'Maximum allowed' : 'Target'}
|
||||
<input type="number" min={r.kind === 'failure-budget' ? 0 : ['healthy-minutes', 'populated-minutes', 'player-hours', 'sustained-population'].includes(r.kind) ? 0.01 : 1} step={['healthy-minutes', 'populated-minutes', 'player-hours', 'sustained-population'].includes(r.kind) ? 'any' : 1} bind:value={r.target} required />
|
||||
<label>{targetLabel(r.kind)}
|
||||
<input id={`plan-${r.id}-target`} aria-invalid={!!fieldError(`${r.id}-target`)} aria-describedby={`error-${r.id}-target`} type="number" min={r.kind === 'failure-budget' ? 0 : ['healthy-minutes', 'populated-minutes', 'player-hours', 'sustained-population'].includes(r.kind) ? 0.01 : 1} step={['healthy-minutes', 'populated-minutes', 'player-hours', 'sustained-population'].includes(r.kind) ? 'any' : 1} bind:value={r.target} required />
|
||||
<span class="error" id={`error-${r.id}-target`}>{fieldError(`${r.id}-target`) ?? ''}</span>
|
||||
</label>
|
||||
{/if}
|
||||
{#if ['round', 'matches', 'populated-minutes', 'player-hours', 'sustained-population'].includes(r.kind)}
|
||||
<label>Minimum players<input type="number" min="1" step="1" bind:value={r.minPlayers} required /></label>
|
||||
<label>Minimum players<input id={`plan-${r.id}-minPlayers`} aria-invalid={!!fieldError(`${r.id}-minPlayers`)} aria-describedby={`error-${r.id}-minPlayers`} type="number" min="1" step="1" bind:value={r.minPlayers} required /><span class="error" id={`error-${r.id}-minPlayers`}>{fieldError(`${r.id}-minPlayers`) ?? ''}</span></label>
|
||||
{/if}
|
||||
{#if r.kind === 'matches'}
|
||||
<label>Minimum round in the same game<input type="number" min="0" step="1" bind:value={r.minRound} required /></label>
|
||||
<label>Minimum round in the same game<input id={`plan-${r.id}-minRound`} aria-invalid={!!fieldError(`${r.id}-minRound`)} aria-describedby={`error-${r.id}-minRound`} type="number" min="0" step="1" bind:value={r.minRound} required /><span class="error" id={`error-${r.id}-minRound`}>{fieldError(`${r.id}-minRound`) ?? ''}</span></label>
|
||||
{/if}
|
||||
{#if r.kind === 'matches'}
|
||||
<label>Minimum match duration (minutes)<input type="number" min="0" step="any" bind:value={r.minMinutes} required /></label>
|
||||
<label>Minimum match duration (minutes)<input id={`plan-${r.id}-minMinutes`} aria-invalid={!!fieldError(`${r.id}-minMinutes`)} aria-describedby={`error-${r.id}-minMinutes`} type="number" min="0" step="any" bind:value={r.minMinutes} required /><span class="error" id={`error-${r.id}-minMinutes`}>{fieldError(`${r.id}-minMinutes`) ?? ''}</span></label>
|
||||
{/if}
|
||||
</div>
|
||||
{#if needsGameTelemetry(r)}
|
||||
@@ -157,7 +174,7 @@
|
||||
</div>
|
||||
|
||||
<div class="policy-settings">
|
||||
<details class="policy">
|
||||
<details class="policy" bind:open={coverageOpen}>
|
||||
<summary><span>Coverage & rollout<small>{coverageSummary(plan)} · {rolloutSummary}</small></span><span class="edit-hint">Configure</span></summary>
|
||||
<div class="policy-body">
|
||||
<div class="fields">
|
||||
@@ -166,21 +183,21 @@
|
||||
<option value="map">At least one per selected map</option><option value="game">At least one per selected game</option>
|
||||
<option value="map-game">At least one per selected game/map combination</option>
|
||||
</select></label>
|
||||
{#if plan.scope === 'quorum'}<label>Required servers<input type="number" min="1" step="1" bind:value={plan.quorum} required /></label>{/if}
|
||||
<label>Servers per wave (0 = all at once)<input type="number" min="0" step="1" bind:value={plan.waveSize} required /></label>
|
||||
{#if plan.scope === 'quorum'}<label>Required servers<input id="plan-quorum" aria-invalid={!!fieldError('quorum')} aria-describedby="error-quorum" type="number" min="1" max={serverCount ?? 1000} step="1" bind:value={plan.quorum} required /><span class="error" id="error-quorum">{fieldError('quorum') ?? `${serverCount ?? 0} servers selected`}</span></label>{/if}
|
||||
<label>Servers per wave (0 = all at once)<input id="plan-waveSize" aria-invalid={!!fieldError('waveSize')} aria-describedby="error-waveSize" type="number" min="0" step="1" bind:value={plan.waveSize} required /><span class="error" id="error-waveSize">{fieldError('waveSize') ?? ''}</span></label>
|
||||
</div>
|
||||
<p>Each wave must fully pass before files reach the next wave. Coverage rules apply after all selected servers have started testing. Restarts always wait for empty servers.</p>
|
||||
</div>
|
||||
</details>
|
||||
<details class="policy">
|
||||
<details class="policy" bind:open={deadlineOpen}>
|
||||
<summary><span>Deadline & failure handling<small>{plan.timeoutHours}-hour window · {failureSummary}</small></span><span class="edit-hint">Configure</span></summary>
|
||||
<div class="policy-body">
|
||||
<div class="fields">
|
||||
<label>Testing window (hours from opening)<input type="number" min="0.01" max="8760" step="any" bind:value={plan.timeoutHours} required /></label>
|
||||
<label>Testing window (hours from opening)<input id="plan-timeoutHours" aria-invalid={!!fieldError('timeoutHours')} aria-describedby="error-timeoutHours" type="number" min="0.01" max="8760" step="any" bind:value={plan.timeoutHours} required /><span class="error" id="error-timeoutHours">{fieldError('timeoutHours') ?? ''}</span></label>
|
||||
<label>On test failure<select bind:value={plan.failureAction}><option value="fail">Mark failed and hold</option><option value="pause">Pause for investigation</option><option value="rollback">Roll back automatically</option></select></label>
|
||||
<label>Rollback restarts<select bind:value={plan.rollbackRestart}><option value="when-empty">Rolling restart when empty</option><option value="manual">Restore files; restart manually</option></select></label>
|
||||
</div>
|
||||
<p>Missing evidence at the deadline is inconclusive. Health and error checks continue until you manually promote.</p>
|
||||
<p>Waiting for empty servers uses this window. Pausing and retrying do not extend it. Missing evidence at the deadline is inconclusive. Health checks continue until you manually promote.</p>
|
||||
</div>
|
||||
</details>
|
||||
<details class="policy save-policy">
|
||||
@@ -195,8 +212,7 @@
|
||||
|
||||
<style>
|
||||
.builder { display: grid; gap: 16px; }
|
||||
h3, h4, h5, p, dl, dd { margin: 0; }
|
||||
h3 { font-size: 21px; font-weight: 650; letter-spacing: -.025em; }
|
||||
h4, h5, p, dl, dd { margin: 0; }
|
||||
h4 { font-size: 19px; font-weight: 600; }
|
||||
h5 { font-size: 15px; font-weight: 550; line-height: 1.5; }
|
||||
p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
|
||||
@@ -243,7 +259,7 @@
|
||||
.add-row label { flex: 1 1 220px; }
|
||||
label { display: grid; gap: 7px; flex: 1 1 180px; min-width: 0; font-size: 12px; color: var(--text-secondary); }
|
||||
input, select, button { color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-strong); border-radius: 6px; padding: 10px 12px; min-width: 0; font: inherit; }
|
||||
input, select { width: 100%; font-size: 13px; min-height: 40px; box-sizing: border-box; }
|
||||
input, select { width: 100%; font-size: 13px; min-height: 40px; box-sizing: border-box; border-color: var(--border-control); }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
button:focus-visible, summary:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 3px; }
|
||||
|
||||
@@ -1,91 +1,103 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import ServerIdentity from './ServerIdentity.svelte';
|
||||
import StatusBadge from './primitives/StatusBadge.svelte';
|
||||
import ListFooter from './primitives/ListFooter.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import { coverageSummary } from '$lib/beta-plan-summary.js';
|
||||
import type { BetaTestView } from '$lib/beta-plan.js';
|
||||
let { test, betaId, closed, onUpdate }: { test: BetaTestView; betaId: number; closed: boolean; onUpdate: () => Promise<void> } = $props();
|
||||
let notes = $state<Record<string, string>>({});
|
||||
let evidenceOpen = $state(false);
|
||||
let evidenceLimit = $state(25);
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
async function action(action: string, extra: Record<string, string> = {}) {
|
||||
busy = true; error = '';
|
||||
import { confirmAction } from '$ui/confirm.js';
|
||||
import { coverageSummary, requirementSummary, needsGameTelemetry } from '$lib/beta-plan-summary.js';
|
||||
import { betaWaitingCondition, betaEvidenceSummary } from '$lib/beta-evidence.js';
|
||||
import type { BetaRequirement, BetaTestView } from '$lib/beta-plan.js';
|
||||
let { test, betaId, closed, deadline, retryBlocked, onUpdate }: { test:BetaTestView; betaId:number; closed:boolean; deadline?:string; retryBlocked?:string; onUpdate:()=>Promise<void> } = $props();
|
||||
let notes = $state<Record<string,string>>({});
|
||||
let evidenceOpen = $state(false), evidenceLimit = $state(25), busy = $state(false), error = $state(''), message = $state('');
|
||||
let evidenceServer = $state(''), evidenceKind = $state(''), evidenceQuery = $state('');
|
||||
let evidenceSection = $state<HTMLDetailsElement>();
|
||||
const canSign = $derived(!closed && ['active','passed'].includes(test.outcome));
|
||||
const expired = $derived(test.outcome === 'inconclusive' || (!!deadline && test.outcome !== 'passed' && Date.parse(deadline) <= Date.now()));
|
||||
const ranked = $derived([...test.servers].sort((a,b) => (a.progress.some(r => r.failed) ? 0 : a.passed ? 2 : 1) - (b.progress.some(r => r.failed) ? 0 : b.passed ? 2 : 1) || a.server.localeCompare(b.server)));
|
||||
const filteredEvidence = $derived(test.evidence.filter(event => (!evidenceServer || event.server === evidenceServer)
|
||||
&& (!evidenceKind || (evidenceKind === 'gameplay' ? ['game_start','round_change','game_end'].includes(event.kind) : evidenceKind === 'health' ? ['health','status-sample'].includes(event.kind) : event.kind === evidenceKind))
|
||||
&& (!evidenceQuery || `${betaEvidenceSummary(event)} ${event.payload}`.toLowerCase().includes(evidenceQuery.toLowerCase()))));
|
||||
$effect(() => { evidenceServer; evidenceKind; evidenceQuery; evidenceLimit = 25; });
|
||||
function reportAge(at:string) { const minutes=Math.max(0,Math.floor((Date.now()-Date.parse(at))/60000));return minutes<1?'less than a minute ago':minutes<60?`${minutes}m ago`:`${Math.floor(minutes/60)}h ${minutes%60}m ago`; }
|
||||
async function action(action:string, extra:Record<string,string>={}) {
|
||||
if (action === 'retry' && !await confirmAction({title:'Start a fresh test attempt?',message:`Reset current progress and checklist sign-offs on ${test.servers.length} deployed servers. They will restart when empty and must collect fresh evidence. Previous evidence remains in history. The deadline stays ${deadline ? new Date(deadline).toLocaleString() : 'unchanged'}.`,confirmLabel:'Reset and restart testing'})) return;
|
||||
busy = true; error = ''; message = '';
|
||||
try {
|
||||
const response = await fetch(`/api/betas/${betaId}/test`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, ...extra }) });
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error);
|
||||
const response = await fetch(`/api/betas/${betaId}/test`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action,...extra})}), body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not update the test.');
|
||||
await onUpdate();
|
||||
} catch (e) { error = (e as Error).message; }
|
||||
message = action === 'sign-off' ? 'Observation recorded.' : action === 'retry' ? 'Fresh attempt queued. Deployed servers restart when empty; the deadline is unchanged.' : action === 'pause' ? 'Testing paused. The original deadline still applies.' : 'Testing resumed. Continuous timing checks restart from fresh observations.';
|
||||
} catch(e) { error = (e as Error).message; }
|
||||
finally { busy = false; }
|
||||
}
|
||||
async function showEvidence(server:string, requirement:BetaRequirement) {
|
||||
evidenceServer = server; evidenceKind = needsGameTelemetry(requirement) ? 'gameplay' : requirement.kind === 'manual' ? 'manual-sign-off' : ['failure-budget','healthy-minutes'].includes(requirement.kind) ? 'health' : '';
|
||||
evidenceQuery = ''; evidenceOpen = true; await tick(); evidenceSection?.scrollIntoView({block:'start',behavior:'smooth'}); evidenceSection?.querySelector('summary')?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section aria-label="Test plan progress">
|
||||
<div class="heading"><h2>Test plan</h2><StatusBadge state={test.outcome} /></div>
|
||||
{#if test.reason}<p>{test.reason}</p>{/if}
|
||||
<p>{coverageSummary(test.plan)} · {test.plan.timeoutHours}-hour testing window · Manual promotion</p>
|
||||
<details><summary>Test policy</summary><p>{test.plan.waveSize ? test.plan.waveSize + ' servers per wave.' : 'All servers deployed together.'} {test.plan.failureAction === 'rollback' ? 'Failed checks trigger an automatic rollback.' : test.plan.failureAction === 'pause' ? 'Failed checks pause the test for investigation.' : 'Failed checks hold the test for review.'} {test.plan.rollbackRestart === 'when-empty' ? 'Rollback restarts wait for empty servers.' : 'Rollback restores files; servers must be restarted manually.'}</p></details>
|
||||
<div class="heading"><h2>Test results</h2><StatusBadge state={test.outcome}/></div>
|
||||
<p>{coverageSummary(test.plan)} · Every requirement must pass on each qualifying server.</p>
|
||||
{#if !closed}
|
||||
<div class="actions">
|
||||
{#if test.outcome === 'paused'}<Button size="sm" disabled={busy} onclick={() => action('resume')}>Resume</Button>{/if}
|
||||
{#if ['active', 'passed'].includes(test.outcome)}<Button size="sm" disabled={busy} onclick={() => action('pause')}>Pause test</Button>{/if}
|
||||
{#if ['paused', 'failed'].includes(test.outcome)}<Button size="sm" disabled={busy} onclick={() => action('retry')}>Restart testing with fresh evidence</Button>{/if}
|
||||
</div>
|
||||
<div class="actions">
|
||||
{#if test.outcome === 'paused' && !expired}<Button size="sm" disabled={busy} onclick={()=>action('resume')}>Resume testing</Button>{/if}
|
||||
{#if ['active','passed'].includes(test.outcome)}<Button size="sm" disabled={busy} onclick={()=>action('pause')}>Pause testing</Button>{/if}
|
||||
{#if ['paused','failed'].includes(test.outcome) && !expired}<Button size="sm" disabled={busy || !!retryBlocked} onclick={()=>action('retry')}>Start fresh attempt</Button>{/if}
|
||||
</div>
|
||||
{#if retryBlocked && ['paused','failed'].includes(test.outcome)}<p>{retryBlocked}</p>{/if}
|
||||
{#if expired}<p class="guidance">The testing window ended. Roll back this beta first, then choose “Reuse files and test plan” to open a new beta with a longer window.</p>
|
||||
{:else if test.outcome === 'paused'}<p class="guidance">Resume retains completed milestones; continuous health and in-progress timing intervals start fresh. Pausing does not extend the deadline.</p>{/if}
|
||||
{/if}
|
||||
{#if error}<p role="alert" class="failed">{error}</p>{/if}
|
||||
{#if test.servers.length === 0}<p>Waiting for the first server restart. Evidence collection begins with that run.</p>{/if}
|
||||
{#each test.servers as server (server.server)}
|
||||
<article>
|
||||
<h3><ServerIdentity name={server.server} /> <small>Recovery cycles: {server.phase}</small></h3>
|
||||
{#each server.progress as progress (progress.id)}
|
||||
{@const requirement = test.plan.requirements.find(r => r.id === progress.id)!}
|
||||
<div class="requirement" class:failed={progress.failed}>
|
||||
<div><strong>{progress.passed ? '✓' : progress.failed ? '✕' : '○'} {progress.label}</strong><span>{progress.detail}</span></div>
|
||||
{#if ['round', 'matches', 'populated-minutes', 'player-hours', 'sustained-population'].includes(requirement.kind)}
|
||||
<span>Minimum {requirement.minPlayers} player(s){requirement.minRound ? `, round ${requirement.minRound} in the same game` : ''}{requirement.minMinutes ? `, ${requirement.minMinutes} minutes per match` : ''}</span>
|
||||
{/if}
|
||||
{#if !['manual', 'failure-budget'].includes(requirement.kind)}
|
||||
<progress aria-label={progress.label} value={Math.min(progress.current, progress.target)} max={progress.target}></progress>
|
||||
{/if}
|
||||
{#if requirement.kind === 'manual' && !progress.passed && !closed}
|
||||
<div class="actions">
|
||||
<input aria-label={`Tester and observation for ${progress.label} on ${server.server}`} placeholder="Tester name and observation" maxlength="1000" bind:value={notes[`${server.server}:${progress.id}`]} />
|
||||
<Button size="sm" disabled={busy || !notes[`${server.server}:${progress.id}`]?.trim()} onclick={() => action('sign-off', { server: server.server, requirementId: progress.id, note: notes[`${server.server}:${progress.id}`] })}>Sign off</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</article>
|
||||
{/each}
|
||||
<details ontoggle={event => evidenceOpen = event.currentTarget.open}>
|
||||
<summary>Evidence ({test.evidence.length}{test.evidence.length === 200 ? ' most recent' : ''})</summary>
|
||||
{#if evidenceOpen}
|
||||
{#each test.evidence.slice(0,evidenceLimit) as event (event.id)}
|
||||
<div class="evidence"><strong>{event.server} · {event.kind}</strong><time>{new Date(event.received_at).toLocaleString()}</time><pre>{event.payload}</pre></div>
|
||||
{#if message}<p role="status">{message}</p>{/if}
|
||||
{#if !test.servers.length}<p>Waiting for the first server restart. Evidence collection begins with that run. The testing window is already running.</p>{/if}
|
||||
{#each ranked as server (server.server)}
|
||||
<details class="server" open={!server.passed}>
|
||||
<summary><ServerIdentity name={server.server}/><span>{server.progress.filter(p=>p.passed).length} / {server.progress.length} checks passed</span>{#if test.plan.requirements.some(r=>r.kind==='recovery')}<span>Recovery cycles: {server.phase}</span>{/if}</summary>
|
||||
{#if test.plan.requirements.some(needsGameTelemetry)}<p class="observation">{#if server.observation?.lastGameplayAt}Latest gameplay: {server.observation.lastEvent?.replaceAll('_',' ')} · <time datetime={server.observation.lastGameplayAt} title={new Date(server.observation.lastGameplayAt).toLocaleString()}>{reportAge(server.observation.lastGameplayAt)}</time> · {server.observation.players ?? 'Unknown'} players{#if server.observation.round !== null} · Round {server.observation.round}{/if}{:else}No gameplay reports in this attempt yet.{/if} <a href={`/servers/${encodeURIComponent(server.server)}?tab=diagnostics#telemetry`}>Telemetry diagnostics</a></p>{/if}
|
||||
{#each server.progress as progress (progress.id)}
|
||||
{@const requirement = test.plan.requirements.find(r=>r.id===progress.id)}
|
||||
{#if requirement}<div class="requirement" class:failed={progress.failed}>
|
||||
<div class="heading"><strong><span class="result">{progress.passed ? 'Passed' : progress.failed ? 'Failed' : 'Waiting'}</span> {requirementSummary(requirement)}</strong><span>{progress.detail}</span></div>
|
||||
{#if !progress.passed && !closed}<p>{betaWaitingCondition(requirement,server.observation)}</p>{/if}
|
||||
{#if !['manual','failure-budget'].includes(requirement.kind)}<progress aria-label={requirementSummary(requirement)} value={Math.min(progress.current,progress.target)} max={progress.target}></progress>{/if}
|
||||
{#if requirement.kind === 'manual' && !progress.passed && !closed}
|
||||
{#if canSign && server.observingAt}<div class="signoff"><label>Tester name and observation<textarea aria-label={`Tester and observation for ${progress.label} on ${server.server}`} rows="2" maxlength="1000" bind:value={notes[`${server.server}:${progress.id}`]} placeholder="Name · what you checked and observed"></textarea></label><Button size="sm" disabled={busy || !notes[`${server.server}:${progress.id}`]?.trim()} onclick={()=>action('sign-off',{server:server.server,requirementId:progress.id,note:notes[`${server.server}:${progress.id}`]})}>Sign off</Button></div>
|
||||
{:else if canSign}<p>Waiting for this server's restart and health verification to finish before tester sign-off.</p>
|
||||
{:else}<p>Sign-off is unavailable while this test is {test.outcome}. {test.outcome === 'paused' ? 'Resume testing first.' : 'Use the recovery instructions above.'}</p>{/if}
|
||||
{/if}
|
||||
<button class="evidence-link" onclick={()=>showEvidence(server.server,requirement)}>View relevant evidence</button>
|
||||
</div>{/if}
|
||||
{/each}
|
||||
<ListFooter total={test.evidence.length} bind:limit={evidenceLimit} label="events" />
|
||||
{#if !test.evidence.length}<p>No evidence recorded yet.</p>{/if}
|
||||
</details>
|
||||
{/each}
|
||||
<details><summary>Test policy</summary><p>{test.plan.waveSize ? `${test.plan.waveSize} servers per wave.` : 'All selected servers deployed together.'} {test.plan.failureAction === 'rollback' ? 'Failed checks trigger automatic rollback.' : test.plan.failureAction === 'pause' ? 'Failed checks pause testing for investigation.' : 'Failed checks hold testing for review.'} {test.plan.rollbackRestart === 'when-empty' ? 'Rollback restarts wait for empty servers.' : 'Rollback restores files; restart servers manually.'} Health checks continue after passing until promotion.</p></details>
|
||||
<details bind:this={evidenceSection} bind:open={evidenceOpen} class="evidence-section">
|
||||
<summary>Evidence ({test.evidence.length}{test.evidence.length===200 ? ' most recent' : ''})</summary>
|
||||
{#if evidenceOpen}
|
||||
<p>Newest first. Includes retained evidence from earlier attempts and recovery cycles; current progress uses only the current attempt.</p>
|
||||
<div class="filters"><label>Server<select bind:value={evidenceServer}><option value="">All servers</option>{#each [...new Set(test.evidence.map(event=>event.server))] as server}<option value={server}>{server}</option>{/each}</select></label><label>Event type<select bind:value={evidenceKind}><option value="">All events</option><option value="gameplay">Gameplay reports</option><option value="health">Health observations</option><option value="manual-sign-off">Tester sign-offs</option></select></label><label>Search evidence<input type="search" bind:value={evidenceQuery} placeholder="Round, map, tester, observation…"/></label></div>
|
||||
{#each filteredEvidence.slice(0,evidenceLimit) as event(event.id)}<article class="evidence"><div class="heading"><ServerIdentity name={event.server}/><time datetime={event.received_at}>{new Date(event.received_at).toLocaleString()}</time></div><p>{betaEvidenceSummary(event)}</p><details><summary>Raw payload · {event.kind}</summary><pre>{event.payload}</pre></details></article>{/each}
|
||||
<ListFooter total={filteredEvidence.length} bind:limit={evidenceLimit} label="events"/>
|
||||
{#if !filteredEvidence.length}<p>{test.evidence.length ? 'No evidence matches these filters.' : 'No evidence recorded yet. Check deployment progress and telemetry diagnostics.'}</p>{/if}
|
||||
{/if}
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section { display: grid; gap: 16px; }
|
||||
h2, h3, p { margin: 0; }
|
||||
h2 { font-size: 18px; } h3 { font-size: 15px; }
|
||||
p, small, span, time { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
|
||||
.heading, .actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
|
||||
.heading { justify-content: space-between; }
|
||||
article { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 16px; }
|
||||
.requirement { display: grid; gap: 8px; margin-top: 16px; }
|
||||
.requirement > div:first-child { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; }
|
||||
progress { width: 100%; height: 8px; accent-color: var(--state-online); }
|
||||
input { padding: 10px; border: 1px solid var(--border-subtle); border-radius: 6px; background: var(--surface-sunken); color: var(--text-primary); font: inherit; }
|
||||
input { flex: 1; min-width: 160px; } summary { cursor: pointer; }
|
||||
.failed { color: var(--state-danger); }
|
||||
.evidence { display: grid; gap: 4px; padding: 12px 0; border-bottom: 1px solid var(--border-subtle); }
|
||||
pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; margin: 0; }
|
||||
section { display:grid; gap:16px; } h2,p { margin:0; } h2 { font-size:18px; }
|
||||
p,span,time { font-size:13px; line-height:1.6; color:var(--text-secondary); } a { color:var(--accent-primary-hover); }
|
||||
.heading,.actions,.filters { display:flex; flex-wrap:wrap; align-items:center; gap:12px; } .heading { justify-content:space-between; }
|
||||
.server { border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:16px; }
|
||||
.server > summary { display:flex; flex-wrap:wrap; gap:8px 16px; } summary { cursor:pointer; font-size:14px; } .server > summary::before { content:'▸'; } .server[open] > summary::before { content:'▾'; }
|
||||
.requirement { display:grid; gap:8px; margin-top:20px; border-top:1px solid var(--border-subtle); padding-top:16px; } .result { font-weight:600; margin-right:6px; color:inherit; }
|
||||
progress { width:100%; height:8px; accent-color:var(--state-online); } .failed { color:var(--state-danger); }
|
||||
.observation,.guidance { margin-top:12px; } .evidence-link { justify-self:start; padding:6px 0; color:var(--accent-primary-hover); border:0; background:transparent; cursor:pointer; font-size:12px; }
|
||||
.signoff { display:flex; gap:12px; align-items:end; flex-wrap:wrap; } .signoff label { flex:1 1 240px; }
|
||||
label { display:grid; gap:6px; font-size:13px; color:var(--text-secondary); } input,select,textarea { min-height:40px; padding:8px 10px; border:1px solid var(--border-control); border-radius:6px; background:var(--surface-sunken); color:var(--text-primary); font:inherit; max-width:100%; } textarea { resize:vertical; width:100%; box-sizing:border-box; }
|
||||
.filters { margin:16px 0; align-items:end; } .filters label { flex:1 1 180px; min-width:0; }
|
||||
.evidence { display:grid; gap:8px; padding:16px 0; border-bottom:1px solid var(--border-subtle); } .evidence-section { scroll-margin-top:80px; } pre { white-space:pre-wrap; overflow-wrap:anywhere; font-size:12px; } .evidence-section > p { margin-top:12px; }
|
||||
</style>
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
let total = $derived(rows.reduce((sum, row) => sum + (Number(row.count) || 0), 0));
|
||||
let valid = $derived(name.trim().length > 0 && rows.length > 0 && total <= 100 && rows.every(row => row.mapSlug && Number.isInteger(row.count) && row.count >= 1 && row.count <= 20));
|
||||
const duplicate = (row: typeof rows[number]) => rows.some(other => other.key !== row.key && other.game === row.game && other.mapSlug === row.mapSlug);
|
||||
let valid = $derived(name.trim().length > 0 && rows.length > 0 && total <= 100 && rows.every(row => row.mapSlug && Number.isInteger(row.count) && row.count >= 1 && row.count <= 20 && !duplicate(row)));
|
||||
let reason = $derived(!name.trim() ? 'Enter a blueprint name to save.' : total > 100 ? `Reduce the total by ${total - 100} to stay within 100 target lobbies.` : rows.some(duplicate) ? 'Choose each game and map once; increase its lobby count instead.' : !valid ? 'Choose a map and 1–20 target lobbies for every row.' : '');
|
||||
let canAdd = $derived((['t5', 't6'] as const).some(game => catalogs[game].some(map => !rows.some(row => row.game === game && row.mapSlug === map.slug))));
|
||||
let wasOpen = false;
|
||||
let session = 0;
|
||||
let nextKey = 0;
|
||||
@@ -74,19 +77,20 @@
|
||||
<form onsubmit={event => { event.preventDefault(); void save(true); }}>
|
||||
<div class="identity"><label>Blueprint name<input bind:value={name} maxlength="80" placeholder="Friday Zombies" required disabled={busy} /></label><label>Description (optional)<input bind:value={description} maxlength="500" placeholder="Our weekend lineup" disabled={busy} /></label></div>
|
||||
<div class="lineup">
|
||||
<div class="section-title"><h3>Your lineup</h3><span class="count">{total} server{total === 1 ? '' : 's'}</span></div>
|
||||
<div class="section-title"><h3>Your lineup</h3><span class="count" class:error={total > 100} role="status">{total} / 100 target lobbies</span></div>
|
||||
{#each rows as row, index (row.key)}
|
||||
<div class="map-row">
|
||||
<span class="number">{String(index + 1).padStart(2, '0')}</span>
|
||||
<label>Game<select aria-label={`Game ${index + 1}`} bind:value={row.game} disabled={busy} onchange={event => row.mapSlug = catalogs[event.currentTarget.value as ZombieGame][0]?.slug ?? ''}><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
|
||||
<label>Map<select aria-label={`Map ${index + 1}`} bind:value={row.mapSlug} disabled={busy}>{#each catalogs[row.game] as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label>
|
||||
<label>Target lobbies<input aria-label={`Lobbies ${index + 1}`} type="number" min="1" max="20" step="1" bind:value={row.count} required disabled={busy} /></label>
|
||||
<label>Map<select aria-label={`Map ${index + 1}`} aria-invalid={duplicate(row) || undefined} aria-describedby={duplicate(row) ? `map-error-${row.key}` : undefined} bind:value={row.mapSlug} disabled={busy}>{#each catalogs[row.game] as map}<option value={map.slug} disabled={rows.some(other => other.key !== row.key && other.game === row.game && other.mapSlug === map.slug)}>{map.friendly}</option>{/each}</select>{#if duplicate(row)}<span class="error" id={`map-error-${row.key}`}>Already selected. Choose another map.</span>{/if}</label>
|
||||
<label>Target lobbies<input aria-label={`Lobbies ${index + 1}`} aria-invalid={!(Number.isInteger(row.count) && row.count >= 1 && row.count <= 20) || undefined} aria-describedby={`count-hint-${row.key}`} type="number" min="1" max="20" step="1" bind:value={row.count} required disabled={busy} /><span id={`count-hint-${row.key}`}>1–20</span></label>
|
||||
<button type="button" class="remove" aria-label={`Remove map ${index + 1}`} disabled={busy || rows.length === 1} onclick={() => rows = rows.filter(item => item.key !== row.key)}>×</button>
|
||||
</div>
|
||||
{/each}
|
||||
<Button disabled={busy || rows.length >= 40} onclick={addMap}>+ Add another map</Button>
|
||||
<Button disabled={busy || !canAdd} onclick={addMap}>+ Add another map</Button>
|
||||
</div>
|
||||
<p class="muted">Blueprints save your maps and target lobby counts. Every new server uses the latest promoted scripts, plugins, and configuration. Before launch, you’ll review every new server ID, port, and key.</p>
|
||||
{#if reason}<p class="validation" role="status">{reason}</p>{/if}
|
||||
<div class="actions"><Button disabled={busy || !valid} onclick={() => save(false)}>Save blueprint</Button><Button type="submit" variant="primary" disabled={busy || !valid} loading={busy}>Save & preview missing servers →</Button></div>
|
||||
</form>
|
||||
{/if}
|
||||
@@ -94,11 +98,12 @@
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.validation { padding: 12px; border-left: 3px solid var(--state-warning); color: var(--text-primary); font-size: 14px; line-height: 1.6; }
|
||||
dialog { width: min(820px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); }
|
||||
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }.builder { padding: 28px; display: grid; gap: 20px; }
|
||||
header, .section-title, .actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }.actions { justify-content: flex-end; }
|
||||
.eyebrow { display: block; color: var(--text-secondary); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .12em; margin-bottom: 8px; } h2, h3, p { margin: 0; } h2 { font-size: 25px; letter-spacing: -.03em; } h3 { font-size: 16px; }.muted { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
|
||||
form, .lineup { display: grid; gap: 18px; }.identity { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } label { display: grid; gap: 7px; font-size: 12px; min-width: 0; } input, select { width: 100%; min-width: 0; box-sizing: border-box; padding: 11px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: var(--surface-sunken); color: inherit; font: inherit; }
|
||||
form, .lineup { display: grid; gap: 18px; }.identity { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } label { display: grid; gap: 7px; font-size: 12px; min-width: 0; } input, select { width: 100%; min-width: 0; box-sizing: border-box; padding: 11px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: inherit; font: inherit; }
|
||||
.lineup { border-block: 1px solid var(--border-subtle); padding: 20px 0; }.map-row { display: grid; grid-template-columns: 24px 1fr 1.5fr 80px 24px; gap: 12px; align-items: end; padding: 14px; background: var(--surface-sunken); border-radius: var(--radius-md); }.number { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-secondary); align-self: center; }.count { font-size: 12px; padding: 6px 10px; border: 1px solid var(--border-strong); border-radius: 99px; }.close, .remove { background: none; border: 0; color: var(--text-secondary); font-size: 25px; cursor: pointer; }.remove { padding: 7px 0; }.error { color: var(--state-danger); font-size: 13px; }button:disabled { opacity: .5; cursor: default; }
|
||||
@media (max-width: 620px) {
|
||||
.builder { padding: 20px; }
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import ConfigTextEditor from './ConfigTextEditor.svelte';
|
||||
import ConfigInspector from './ConfigInspector.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import { configWarnings } from '$lib/config-workbench.js';
|
||||
interface Version { id: number; version: number; notes: string | null; }
|
||||
let { id, kind, versions, currentVersionId, runningBetaId, scopeType, scopeValue, onsaved }: {
|
||||
id: number; kind: 'baseline' | 'config'; versions: Version[]; currentVersionId: number | null;
|
||||
@@ -23,6 +24,7 @@
|
||||
let loadedVersion = $state('');
|
||||
let recovered = $state<{ version: string; text: string; notes: string } | null>(null);
|
||||
const dirty = $derived(text !== original || notes.trim().length > 0);
|
||||
const ignoredWarnings = $derived(configWarnings(text, kind === 'config').filter(warning => warning.includes('ignored') || warning.includes('no value')));
|
||||
const storageKey = $derived(`electric-cherry.config-draft.${id}`);
|
||||
onMount(() => {
|
||||
selected = String(versions[0]?.id ?? ''); ready = true;
|
||||
@@ -117,7 +119,7 @@
|
||||
{:else if loadedVersion}
|
||||
<ConfigTextEditor bind:text overlay={kind === 'config'} disabled={saving || runningBetaId !== null} />
|
||||
<label class="notes-label">Version notes<input bind:value={notes} placeholder="What changed, and why?" disabled={saving || runningBetaId !== null} /></label>
|
||||
<div class="actions ui-sticky-actions"><span class="muted">{runningBetaId !== null ? 'Finish the active beta to save another version.' : 'A new version must pass through a beta before promotion.'}</span>
|
||||
<div class="actions ui-sticky-actions"><span class="muted">{runningBetaId !== null ? 'Finish the active beta to save another version.' : 'A new version must pass through a beta before promotion.'}{#if ignoredWarnings.length}<strong class="save-warning">{ignoredWarnings.length} ignored or incomplete lines — review the open configuration notes before saving.</strong>{/if}</span>
|
||||
<Button variant="ghost" size="sm" onclick={discard} disabled={!dirty || saving}>Discard</Button>
|
||||
<Button variant="primary" onclick={save} loading={saving} disabled={!dirty || !text.trim() || runningBetaId !== null}>Save new version</Button>
|
||||
</div>
|
||||
@@ -144,8 +146,10 @@
|
||||
.actions { margin-top: 16px; }
|
||||
.actions > span { flex: 1 1 200px; }
|
||||
.muted { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.save-warning { display: block; color: var(--state-warning); font-weight: 500; margin-top: 6px; }
|
||||
.recovery, .success, .error { font-size: var(--text-sm); padding: 12px; border-radius: var(--radius-sm); background: var(--surface-raised); margin-bottom: 16px; }
|
||||
.recovery { color: var(--state-warning); } .success { color: var(--state-online); } .error { color: var(--state-danger); }
|
||||
@media (max-width: 1100px) { .workbench { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 600px) { .editor-panel { padding: 16px; } }
|
||||
input:not([type='checkbox']):not([type='file']), select { border-color: var(--border-control); }
|
||||
</style>
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import type { ConfigInspection, ConfigServer, ConfigSource } from '$lib/config-workbench.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import { saveLibraryContext } from '$ui/library-context.js';
|
||||
let { artifactId, draftText, scopeType, scopeValue, fixedServer }: { fixedServer?: string; artifactId?: number; draftText?: string; scopeType?: string | null; scopeValue?: string | null } = $props();
|
||||
let servers = $state<ConfigServer[]>([]);
|
||||
let selected = $state('');
|
||||
let inspection = $state<ConfigInspection | null>(null);
|
||||
let promoted = $state<ConfigInspection | null>(null);
|
||||
let expanded = $state<string[]>([]);
|
||||
let inspectedServer = '';
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let query = $state('');
|
||||
@@ -14,10 +18,15 @@
|
||||
let raw = $state(false);
|
||||
let reload = $state(0);
|
||||
let ready = $state(false);
|
||||
let desiredServer = '';
|
||||
const eligible = $derived(servers.filter(s => (!fixedServer || s.name === fixedServer) && (!artifactId || (scopeType === 'game' ? s.game === scopeValue : scopeType === 'map' ? s.map === scopeValue : false))));
|
||||
const settings = $derived((inspection?.settings ?? []).filter(s => (!onlyOverrides || s.history.length > 1) && `${s.key} ${s.value} ${s.source.name}`.toLowerCase().includes(query.toLowerCase())));
|
||||
const overlaps = $derived(inspection?.settings.filter(s => s.history.length > 1).length ?? 0);
|
||||
const previousValues = $derived(new Map((promoted?.settings ?? []).map(s => [s.key, s.value])));
|
||||
const changes = $derived(promoted && inspection ? inspection.settings.filter(s => previousValues.get(s.key) !== s.value) : []);
|
||||
const removed = $derived(promoted?.settings.filter(s => inspection && !inspection.settings.some(current => current.key === s.key)) ?? []);
|
||||
function label(source: ConfigSource): string { return `${source.name}${source.draft ? ' · draft' : source.version ? ` · v${source.version}` : ''}`; }
|
||||
function overriddenCount(source: ConfigSource) { return inspection?.settings.filter(s => (s.source.id !== source.id || s.source.kind !== source.kind) && s.history.some(h => h.source.id === source.id && h.source.kind === source.kind)).length ?? 0; }
|
||||
|
||||
async function loadServers() {
|
||||
loading = true; error = '';
|
||||
@@ -30,8 +39,9 @@
|
||||
} catch (err) { error = (err as Error).message; }
|
||||
finally { loading = false; }
|
||||
}
|
||||
onMount(loadServers);
|
||||
$effect(() => { if (!eligible.some(s => s.name === selected)) selected = eligible[0]?.name ?? ''; });
|
||||
onMount(() => { desiredServer = new URLSearchParams(window.location.search).get('server') ?? ''; void loadServers(); });
|
||||
$effect(() => { if (!eligible.some(s => s.name === selected)) selected = eligible.find(s => s.name === desiredServer)?.name ?? eligible[0]?.name ?? ''; });
|
||||
$effect(() => { if (ready && selected && !fixedServer) saveLibraryContext({ server: selected }); });
|
||||
$effect(() => {
|
||||
const server = selected;
|
||||
const text = draftText;
|
||||
@@ -39,7 +49,8 @@
|
||||
const refresh = reload;
|
||||
if (!ready || !server) { inspection = null; if (ready) loading = false; return; }
|
||||
const controller = new AbortController();
|
||||
loading = true; error = ''; inspection = null;
|
||||
loading = true; error = '';
|
||||
if (inspectedServer !== server) { inspection = null; promoted = null; expanded = []; inspectedServer = server; }
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(id !== undefined && text !== undefined ? '/api/configs/inspect' : `/api/configs/inspect?server=${encodeURIComponent(server)}`, {
|
||||
@@ -48,7 +59,7 @@
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not inspect this config.');
|
||||
if (!controller.signal.aborted) inspection = body.inspection;
|
||||
if (!controller.signal.aborted) { inspection = body.inspection; promoted = body.promoted ?? null; }
|
||||
} catch (err) { if (!controller.signal.aborted) error = (err as Error).message; }
|
||||
finally { if (!controller.signal.aborted) loading = false; }
|
||||
}, text === undefined ? 0 : 350);
|
||||
@@ -71,26 +82,26 @@
|
||||
{#each eligible as server}<option value={server.name}>{serverTitle(server.name)}</option>{/each}
|
||||
{#if !eligible.length}<option value="">No matching servers</option>{/if}
|
||||
</select></label>
|
||||
{#if error}<div class="error" role="alert">{error}</div>
|
||||
{:else if loading}<p class="empty" role="status">Composing configuration…</p>
|
||||
{:else if !eligible.length}<p class="empty">{artifactId ? 'No registered servers match this scope.' : 'Register a server to inspect its configuration.'}</p>
|
||||
{#if error}<div class="error" role="alert">{error}{inspection ? ' Showing the last completed preview.' : ''}</div>{/if}
|
||||
<p class="preview-status" role="status">{loading ? inspection ? 'Updating preview… Showing the last completed result.' : 'Composing configuration…' : inspection ? 'Preview up to date.' : ''}</p>
|
||||
{#if !eligible.length && !loading}<p class="empty">{artifactId ? 'No registered servers match this scope.' : 'Register a server to inspect its configuration.'}</p>
|
||||
{:else if inspection?.reason}<p class="empty">{inspection.reason}</p>
|
||||
{:else if inspection}
|
||||
<div class="sources" aria-label="Composition sources">
|
||||
<ol class="sources" aria-label="Composition order, later layers take precedence">
|
||||
{#each inspection.sources as source, index}
|
||||
{#if index}<span class="arrow" aria-hidden="true">→</span>{/if}
|
||||
{#if source.id}<a class:draft={source.draft} href="/configs/{source.id}">{label(source)}</a>
|
||||
{:else}<span class="map-source">{source.name}</span>{/if}
|
||||
<li><span class="layer-number">{index + 1}.</span>{#if source.id}<a class:draft={source.draft} href="/configs/{source.id}">{label(source)}</a>
|
||||
{:else}<span class="map-source">{source.name}</span>{/if}<span>{overriddenCount(source)} settings superseded later</span></li>
|
||||
{/each}
|
||||
</div>
|
||||
</ol>
|
||||
<p class="hint">Overlays apply in creation order; later overlays win for the same key. The selected map takes precedence over map rotation.</p>
|
||||
{#if promoted}<p class="change-summary">Compared with promoted settings: {changes.length} changed or added, {removed.length} removed.</p>{#if removed.length}<details><summary>Removed settings</summary><ul>{#each removed as setting}<li><code>{setting.key}</code> · previously {setting.value}</li>{/each}</ul></details>{/if}{/if}
|
||||
<div class="toolbar"><div class="switch"><button class:active={!raw} aria-pressed={!raw} onclick={() => raw = false}>Settings <span>{inspection.settings.length}</span></button><button class:active={raw} aria-pressed={raw} onclick={() => raw = true}>Composed .cfg</button></div><Button size="sm" variant="ghost" onclick={download}>Download</Button></div>
|
||||
{#if raw}<textarea class="composed" readonly aria-label="Composed config" value={inspection.text} rows="18"></textarea>
|
||||
{:else}
|
||||
<div class="filters"><input aria-label="Search effective settings" bind:value={query} placeholder="Search settings or sources…" /><label class="check"><input type="checkbox" bind:checked={onlyOverrides} /> Overrides ({overlaps})</label></div>
|
||||
<div class="settings">
|
||||
{#each settings as setting (setting.key)}
|
||||
<details class="setting"><summary><div class="setting-main"><code>{setting.key}</code><strong>{setting.value}</strong></div><span class="source-label">{label(setting.source)} {#if setting.history.length > 1}<b>{setting.history.length - 1} overridden</b>{/if}</span></summary>
|
||||
<details class="setting" open={expanded.includes(setting.key)} ontoggle={event => { const open = event.currentTarget.open; expanded = open ? [...new Set([...expanded, setting.key])] : expanded.filter(key => key !== setting.key); }}><summary><div class="setting-main"><code>{setting.key}</code><strong>{setting.value}</strong></div><span class="source-label">{label(setting.source)} {#if setting.history.length > 1}<b>{setting.history.length - 1} overridden</b>{/if}</span>{#if promoted && previousValues.get(setting.key) !== setting.value}<span class="change-summary">{previousValues.has(setting.key) ? `Changed from ${previousValues.get(setting.key)}` : 'Added by draft'}</span>{/if}</summary>
|
||||
<ol>{#each setting.history as entry, index}<li class:winner={index === setting.history.length - 1}><div><span>{label(entry.source)}</span><small>{entry.source.kind === 'map' ? 'Automatic' : `Line ${entry.line}`} · {index === setting.history.length - 1 ? 'Final value' : 'Overridden'}</small></div><code>{entry.value}</code></li>{/each}</ol>
|
||||
</details>
|
||||
{:else}<p class="empty">No matching settings.</p>{/each}
|
||||
@@ -110,10 +121,14 @@
|
||||
.server-label { display: grid; gap: 8px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
select, input:not([type='checkbox']) { width: 100%; box-sizing: border-box; background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 10px 12px; font: var(--text-sm) var(--font-body); }
|
||||
select:focus-visible, input:focus-visible, button:focus-visible, summary:focus-visible, a:focus-visible, .composed:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||
.sources { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 20px; font-size: var(--text-xs); }
|
||||
.sources { display: grid; gap: 8px; margin: 20px 0 0; padding: 0; border: 0; font-size: var(--text-xs); list-style: none; }
|
||||
.sources li { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding: 0; }
|
||||
.layer-number { color: var(--text-primary); font-weight: 600; }
|
||||
.preview-status { font-size: var(--text-xs); color: var(--text-secondary); min-height: 20px; }
|
||||
.change-summary { display: block; font-size: var(--text-xs); color: var(--state-warning); margin-top: 8px; }
|
||||
.sources a, .map-source { background: var(--surface-raised); border-radius: 6px; padding: 6px 9px; text-decoration: none; }
|
||||
.sources a.draft { color: var(--state-warning); }
|
||||
.map-source, .arrow { color: var(--text-secondary); }
|
||||
.map-source { color: var(--text-secondary); }
|
||||
.hint { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.6; }
|
||||
.toolbar { border-top: 1px solid var(--border-subtle); padding-top: 12px; margin-top: 20px; }
|
||||
.switch { display: flex; gap: 4px; }
|
||||
@@ -142,4 +157,5 @@
|
||||
.empty { color: var(--text-secondary); font-size: var(--text-sm); padding: 24px 0; }
|
||||
.error { color: var(--state-danger); margin-top: 16px; font-size: var(--text-sm); }
|
||||
@media (max-width: 600px) { .inspector { padding: 16px; } }
|
||||
input:not([type='checkbox']):not([type='file']), select, textarea { border-color: var(--border-control); }
|
||||
</style>
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
import Tabs from './primitives/Tabs.svelte';
|
||||
import ListFooter from './primitives/ListFooter.svelte';
|
||||
import { mapLabel } from '$lib/script-library.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { afterNavigate, goto } from '$app/navigation';
|
||||
import SectionHeader from './primitives/SectionHeader.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import Badge from './primitives/Badge.svelte';
|
||||
import EmptyState from './primitives/EmptyState.svelte';
|
||||
import ConfigTextEditor from './ConfigTextEditor.svelte';
|
||||
import ConfigInspector from './ConfigInspector.svelte';
|
||||
import { libraryLimit, saveLibraryContext } from '$ui/library-context.js';
|
||||
|
||||
interface Version { id: number; version: number; state: 'untested' | 'tested' | 'test-failed'; uploaded_at: string; }
|
||||
interface Artifact {
|
||||
@@ -27,7 +28,11 @@
|
||||
let status = $state('');
|
||||
let view = $state('library');
|
||||
let limit = $state(25);
|
||||
$effect(() => { query; game; scope; status; limit = 25; });
|
||||
let mounted = $state(false);
|
||||
afterNavigate(() => { mounted = true; });
|
||||
let lastFilters = '';
|
||||
$effect(() => { const key = JSON.stringify([query, game, scope, status]); if (!mounted) return; if (lastFilters && lastFilters !== key) limit = 25; lastFilters = key; });
|
||||
$effect(() => { if (mounted) saveLibraryContext({ q: query, game, scope, status, view, limit }, { view: 'library', limit: 25 }); });
|
||||
let dialog: HTMLDialogElement;
|
||||
let kind = $state<'baseline' | 'config'>('config');
|
||||
let name = $state('');
|
||||
@@ -67,7 +72,7 @@
|
||||
} catch (err) { error = (err as Error).message; }
|
||||
finally { loading = false; }
|
||||
}
|
||||
onMount(load);
|
||||
onMount(() => { const params = new URLSearchParams(window.location.search); query = params.get('q') ?? ''; game = params.get('game') ?? ''; scope = params.get('scope') ?? ''; status = params.get('status') ?? ''; view = params.get('view') === 'inspect' ? 'inspect' : 'library'; limit = libraryLimit(params); void load(); });
|
||||
function openCreate(type: 'config' | 'baseline' = 'config') { kind = type; createError = ''; dialog.showModal(); }
|
||||
function resetFilters() { query = ''; game = ''; scope = ''; status = ''; }
|
||||
async function importFile(event: Event) {
|
||||
@@ -102,20 +107,20 @@
|
||||
<SectionHeader title="Configs" subtitle="Manage game baselines and scoped changes. Inspect the settings applied to a server.">
|
||||
{#snippet actions()}<Button variant="primary" onclick={() => openCreate()}>+ New config</Button>{/snippet}
|
||||
</SectionHeader>
|
||||
<div class="ui-summary"><span><strong>{baselines.length}</strong> baselines</span><span><strong>{overlays.length}</strong> overlays</span><span><strong>{pending}</strong> awaiting promotion</span></div>
|
||||
{#if artifacts.length}<div class="ui-summary"><span><strong>{baselines.length}</strong> baselines</span><span><strong>{overlays.length}</strong> overlays</span><span><strong>{pending}</strong> awaiting promotion</span></div>{/if}
|
||||
<div class="navigation"><Tabs tabs={[{id:'library',label:'Library'},{id:'inspect',label:'Inspect a server'}]} bind:value={view} ariaLabel="Config views" idPrefix="config-tabs" panelId="config-panel" /><Button variant="ghost" size="sm" onclick={load} loading={loading}>Refresh</Button></div>
|
||||
<div id="config-panel" role="tabpanel" aria-labelledby={'config-tabs-' + view} class="config-panel">
|
||||
{#if error}<div class="error" role="alert">{error} <Button size="sm" onclick={load}>Retry</Button></div>{/if}
|
||||
{#if view === 'inspect'}
|
||||
<ConfigInspector />
|
||||
{:else}
|
||||
<div class="filters">
|
||||
{#if loading || artifacts.length || error}<div class="filters">
|
||||
<input class="search" aria-label="Search configs" bind:value={query} placeholder="Search configs, maps, or servers…" />
|
||||
<select aria-label="Filter by game" bind:value={game}><option value="">All games</option>{#each gameOptions as option}<option value={option}>{option.toUpperCase()}</option>{/each}</select>
|
||||
<select aria-label="Filter by scope" bind:value={scope}><option value="">All scopes</option><option value="game">Game</option><option value="map">Map</option></select>
|
||||
<select aria-label="Filter by status" bind:value={status}><option value="">All statuses</option><option value="pending">Awaiting promotion</option><option value="promoted">Has promoted version</option><option value="beta">Active beta</option><option value="failed">Test failed</option></select>
|
||||
{#if query || game || scope || status}<Button size="sm" variant="ghost" onclick={resetFilters}>Clear</Button>{/if}
|
||||
</div>
|
||||
</div>{/if}
|
||||
{#if loading}<div class="loading" role="status">Loading your config library…</div>
|
||||
{:else if !artifacts.length && !error}
|
||||
<div class="welcome"><span class="eyebrow">Start with a foundation</span><h2>Your fleet, configured your way.</h2><p>Add a baseline for each game, then layer on small, focused changes for a game or map. Every change gets its own version.</p><Button variant="primary" onclick={() => openCreate('baseline')}>Create your first baseline</Button></div>
|
||||
@@ -218,4 +223,5 @@
|
||||
.card-footer { padding-top:12px; margin-top:12px; }
|
||||
.welcome { padding:24px; }
|
||||
.loading { padding:24px 0; }
|
||||
input:not([type='checkbox']):not([type='file']), select { border-color: var(--border-control); }
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { changeConfigValue, configLines, configWarnings } from '$lib/config-workbench.js';
|
||||
import { addConfigSetting, changeConfigValue, configLines, configWarnings } from '$lib/config-workbench.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
let { text = $bindable(''), overlay = false, disabled = false }: { text?: string; overlay?: boolean; disabled?: boolean } = $props();
|
||||
let mode = $state<'raw' | 'settings'>('raw');
|
||||
let search = $state('');
|
||||
let newKey = $state('');
|
||||
let newValue = $state('');
|
||||
let addError = $state('');
|
||||
function addSetting() {
|
||||
try { text = addConfigSetting(text, newKey, newValue); search = ''; newKey = ''; newValue = ''; addError = ''; }
|
||||
catch (error) { addError = (error as Error).message; }
|
||||
}
|
||||
function addOnEnter(event: KeyboardEvent) { if (event.key === 'Enter') { event.preventDefault(); if (!disabled && newKey.trim() && newValue.trim()) addSetting(); } }
|
||||
const entries = $derived(configLines(text));
|
||||
const visible = $derived(entries.filter(e => `${e.key} ${e.value}`.toLowerCase().includes(search.toLowerCase())));
|
||||
const warnings = $derived(configWarnings(text, overlay));
|
||||
@@ -26,12 +35,14 @@
|
||||
<label class="setting"><span><code>{entry.key}</code><small>Line {entry.index + 1}</small></span>
|
||||
<input value={entry.value} {disabled} aria-label={`${entry.key}, line ${entry.index + 1}`} oninput={e => text = changeConfigValue(text, entry.index, e.currentTarget.value)} />
|
||||
</label>
|
||||
{:else}<p class="hint">{entries.length ? 'No matching settings.' : 'Add set KEY VALUE lines in the raw editor to get started.'}</p>{/each}
|
||||
{:else}<p class="hint">{entries.length ? 'No matching settings.' : 'Add your first setting below.'}</p>{/each}
|
||||
<div class="add-setting"><label>Setting name<input bind:value={newKey} onkeydown={addOnEnter} placeholder="g_inactivity" {disabled} /></label><label>Value<input bind:value={newValue} onkeydown={addOnEnter} placeholder="0" {disabled} /></label><Button size="sm" onclick={addSetting} disabled={disabled || !newKey.trim() || !newValue.trim()}>Add setting</Button></div>
|
||||
{#if addError}<p class="warnings" role="alert">{addError}</p>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if warnings.length}
|
||||
<details class="warnings"><summary>{warnings.length} configuration {warnings.length === 1 ? 'note' : 'notes'}</summary>
|
||||
<details class="warnings" open={warnings.some(w => w.includes('ignored') || w.includes('no value'))}><summary>{warnings.length} configuration {warnings.length === 1 ? 'note' : 'notes'} — review before saving</summary>
|
||||
<ul>{#each warnings as warning}<li>{warning}</li>{/each}</ul>
|
||||
</details>
|
||||
{/if}
|
||||
@@ -50,6 +61,8 @@
|
||||
input { box-sizing: border-box; background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 9px 12px; font: var(--text-sm) var(--font-mono); width: 100%; }
|
||||
.setting { display: grid; grid-template-columns: minmax(140px, 1fr) minmax(0, 1.4fr); align-items: center; gap: 16px; padding: 12px 0; border-top: 1px solid var(--border-subtle); }
|
||||
.setting span { min-width: 0; overflow-wrap: anywhere; }
|
||||
.add-setting { display: flex; align-items: end; flex-wrap: wrap; gap: 12px; border-top: 1px solid var(--border-strong); padding-top: 16px; }
|
||||
.add-setting label { flex: 1 1 140px; display: grid; gap: 6px; font-size: var(--text-sm); }
|
||||
code { font: var(--text-sm) var(--font-mono); }
|
||||
small { display: block; color: var(--text-secondary); font-size: var(--text-xs); margin-top: 4px; }
|
||||
.hint { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
@@ -57,4 +70,5 @@
|
||||
summary { cursor: pointer; }
|
||||
li { margin: 6px 0; }
|
||||
@media (max-width: 600px) { .setting { grid-template-columns: 1fr; gap: 8px; } .toolbar > .hint { display: none; } }
|
||||
input:not([type='checkbox']):not([type='file']), textarea { border-color: var(--border-control); }
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { isDialogBackdrop } from '$ui/dialog.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import Button from './primitives/Button.svelte';
|
||||
@@ -14,28 +15,41 @@
|
||||
let error = $state('');
|
||||
let wasOpen = false;
|
||||
let session = 0;
|
||||
let keyNotice = $state('');
|
||||
let now = $state(Date.now());
|
||||
const expired = $derived(!!preview && new Date(preview.expiresAt).getTime() <= now);
|
||||
const keysReady = $derived(preview?.servers.filter(server => server.name && (server.hasSavedKey || keys[server.name]?.trim())).length ?? 0);
|
||||
onMount(() => { const timer = setInterval(() => { now = Date.now(); }, 10_000); return () => clearInterval(timer); });
|
||||
let canLaunch = $derived(preview?.ready && preview.servers.length > 0 && preview.servers.every(server => server.name && (server.hasSavedKey || keys[server.name]?.trim())));
|
||||
|
||||
$effect(() => {
|
||||
if (!dialog) return;
|
||||
if (open && !wasOpen) { wasOpen = true; dialog.showModal(); void review(); }
|
||||
if (open && !wasOpen) { wasOpen = true; keys = {}; preview = null; keyNotice = ''; dialog.showModal(); void review(); }
|
||||
else if (!open && wasOpen) { wasOpen = false; session++; dialog.close(); }
|
||||
});
|
||||
|
||||
async function review() {
|
||||
const current = ++session;
|
||||
busy = true; error = ''; preview = null; keys = {};
|
||||
const previous = preview;
|
||||
busy = true; error = ''; keyNotice = '';
|
||||
try {
|
||||
const res = await fetch('/api/deployment-collections/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ blueprintId }) });
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body.error ?? 'Could not preview the collection.');
|
||||
if (current === session) preview = body;
|
||||
if (current === session) {
|
||||
const next = body as CollectionPreview;
|
||||
const names = new Set(next.servers.map(server => server.name));
|
||||
keys = Object.fromEntries(next.servers.filter(server => server.name).map(server => [server.name!, keys[server.name!] ?? '']));
|
||||
if (previous && (previous.servers.length !== next.servers.length || previous.servers.some(server => !names.has(server.name)))) keyNotice = 'Server assignments changed. Keys were retained only for the exact IDs still shown below. Review the new assignments.';
|
||||
else if (previous) keyNotice = 'Preview refreshed. Entered keys are retained for these same server IDs.';
|
||||
preview = next; now = Date.now();
|
||||
}
|
||||
} catch (err) { if (current === session) error = (err as Error).message; }
|
||||
finally { if (current === session) busy = false; }
|
||||
}
|
||||
|
||||
async function launch() {
|
||||
if (!preview || !canLaunch || busy) return;
|
||||
if (!preview || !canLaunch || expired || busy) return;
|
||||
busy = true; error = '';
|
||||
try {
|
||||
const res = await fetch('/api/deployment-collections', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ previewId: preview.id, keys }) });
|
||||
@@ -46,6 +60,11 @@
|
||||
} catch (err) { error = (err as Error).message; }
|
||||
finally { busy = false; }
|
||||
}
|
||||
|
||||
async function nextMissing() {
|
||||
await tick();
|
||||
dialog?.querySelector<HTMLInputElement>('input[data-missing="true"]')?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} aria-label="Launch collection" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
|
||||
@@ -54,9 +73,12 @@
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
{#if !preview}<p class="muted">{busy ? 'Finding a lobby and port for every server…' : 'Refresh to prepare another preview.'}</p>{#if !busy}<Button onclick={review}>Refresh preview</Button>{/if}
|
||||
{:else}
|
||||
{#if keyNotice}<p role="status" class="muted">{keyNotice}</p>{/if}
|
||||
{#if expired}<p class="error" role="status">This preview expired. Refresh before launch; keys remain entered for unchanged IDs.</p>{/if}
|
||||
<div class="lineup"><strong>{preview.servers.length ? `${preview.servers.length} missing server${preview.servers.length === 1 ? '' : 's'} to create` : 'Blueprint already satisfied'}</strong>
|
||||
<table><thead><tr><th>Map</th><th>Target</th><th>Existing / queued</th><th>To create</th></tr></thead><tbody>{#each preview.entries as entry}<tr><th>{entry.recipe.mapName} <small>{entry.recipe.game.toUpperCase()}</small></th><td>{entry.count}</td><td title={entry.existingServers.join(', ')}>{entry.existing}</td><td class="quantity">{entry.missing}</td></tr>{/each}</tbody></table>
|
||||
<p class="muted">Existing and queued servers count toward each map’s target, including stopped servers. Extra servers are kept. Servers awaiting removal do not count.</p>
|
||||
<p class="muted">Targets count inventory, including stopped servers; they do not mean every server is ready to play. Only missing servers are created. Extra servers remain and existing servers are not updated.</p>
|
||||
{#if preview.entries.some(entry => entry.existing)}<details class="coverage" open={!preview.servers.length}><summary>Review existing servers</summary>{#each preview.entries.filter(entry => entry.existing) as entry}<div class="coverage-map"><strong>{entry.recipe.mapName}: {entry.existing} existing</strong><p class="muted">{[...new Set((entry.coverage ?? []).map(server => server.health))].map(health => `${entry.coverage.filter(server => server.health === health).length} ${health}`).join(', ')}</p><ul>{#each entry.coverage ?? [] as server}<li><a href={server.href}><ServerIdentity name={server.name} /></a><span>{server.health === 'online' ? 'Healthy' : server.health}</span></li>{/each}</ul></div>{/each}</details>{/if}
|
||||
</div>
|
||||
{#if preview.servers.length}
|
||||
<div class="pacing-note">
|
||||
@@ -70,15 +92,15 @@
|
||||
<p class="muted">Add a key for each new lobby below, or reuse its saved key. Fleet counts and available slots are checked again when you queue the launch.</p>
|
||||
<form onsubmit={event => { event.preventDefault(); void launch(); }}>
|
||||
<ol class="servers" aria-label="Server launch order">
|
||||
{#each preview.servers as server, index}
|
||||
{#each preview.servers as server, index (server.name ?? `unavailable-${index}`)}
|
||||
<li class="server-row">
|
||||
<span class="order" aria-hidden="true">{String(index + 1).padStart(2, '0')}</span>
|
||||
<div class="server-identity"><strong>{#if server.name}<ServerIdentity name={server.name} />{:else}{preview.entries[server.entryIndex].recipe.mapName}{/if}</strong><span class="server-id">{server.name ?? 'No available lobby'} · {server.port === null ? 'No available port' : `Port ${server.port}`}</span></div>
|
||||
{#if server.name}<label><span>{server.hasSavedKey ? 'Saved key ready' : 'Server key required'}</span><input aria-label={`Key for ${server.name}`} type="password" bind:value={keys[server.name]} disabled={busy} maxlength="512" autocomplete="off" placeholder={server.hasSavedKey ? 'Leave blank to reuse' : 'Paste this lobby’s key'} /></label>{/if}
|
||||
<div class="server-identity">{#if index === 0 || preview.servers[index - 1].entryIndex !== server.entryIndex}<span class="map-group">{preview.entries[server.entryIndex].recipe.game.toUpperCase()} · {preview.entries[server.entryIndex].recipe.mapName}</span>{/if}<strong>{#if server.name}<ServerIdentity name={server.name} />{:else}{preview.entries[server.entryIndex].recipe.mapName}{/if}</strong><span class="server-id">{server.name ?? 'No available lobby'} · {server.port === null ? 'No available port' : `Port ${server.port}`}</span></div>
|
||||
{#if server.name}<label><span>{server.hasSavedKey ? 'Saved key available' : 'Server key required'}</span><input aria-label={`Key for ${server.name}`} data-missing={!server.hasSavedKey && !keys[server.name]?.trim()} type="password" bind:value={keys[server.name]} disabled={busy} maxlength="512" autocomplete="off" placeholder={server.hasSavedKey ? 'Leave blank to reuse' : 'Paste this server’s key'} /></label>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
<div class="launch-footer"><p class="caption">Follow every server from queued to ready.<br />Preview expires at {new Date(preview.expiresAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}.</p><div class="actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || !canLaunch}>Queue {preview.servers.length} server{preview.servers.length === 1 ? '' : 's'} →</Button></div></div>
|
||||
<div class="launch-footer"><div><p role="status"><strong>{keysReady} / {preview.servers.length} keys available</strong></p><p class="caption">Validity is checked at startup. Preview expires at {new Date(preview.expiresAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}.</p>{#if keysReady < preview.servers.length}<button class="text-button" type="button" onclick={nextMissing}>Jump to next missing key</button>{/if}</div><div class="actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || expired || !canLaunch}>Queue {preview.servers.length} server{preview.servers.length === 1 ? '' : 's'} →</Button></div></div>
|
||||
</form>
|
||||
{:else}<p class="muted">Your fleet already meets or exceeds every target in this blueprint. No servers will be created or removed.</p><div class="actions"><Button onclick={() => open = false}>Done</Button></div>{/if}
|
||||
<details class="recipes"><summary>Setup and launch requirements</summary><p class="muted requirements">Each server uses the latest promoted setup when its turn begins. The host must have the selected games and DLC. This preview checks settings and capacity; it does not probe game files, images, credentials, or network access.</p>{#each preview.entries as entry}<div class="recipe"><LaunchRecipe recipe={entry.recipe} /></div>{/each}</details>
|
||||
@@ -87,10 +109,15 @@
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
input { scroll-margin-block: 32px 190px; }
|
||||
.launch-footer { position: sticky; bottom: -28px; background: var(--surface); z-index: 2; padding: 16px 0 20px; border-top: 1px solid var(--border-strong); box-shadow: 0 -12px 16px var(--surface); }
|
||||
.map-group { font-size: 12px; color: var(--accent-primary-hover); }
|
||||
.coverage { margin-top: 16px; font-size: 14px; }.coverage summary { cursor: pointer; }.coverage-map { padding-top: 14px; }.coverage ul { list-style: none; padding: 0; }.coverage li { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 10px; }.coverage a { color: var(--accent-primary-hover); }.coverage li span { color: var(--text-secondary); }
|
||||
@media (max-width: 600px) { .launch-footer { bottom: -20px; } }
|
||||
dialog { width: min(820px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); color: var(--text-primary); }dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }.composer { display: grid; gap: 20px; padding: 28px; }
|
||||
header, .section-title, .actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }h2, h3, p { margin: 0; }h2 { font-size: 25px; overflow-wrap: anywhere; }h3 { font-size: 16px; }.eyebrow { display: block; font-size: var(--text-xs); color: var(--text-secondary); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 7px; }.close { border: 0; background: none; color: var(--text-secondary); font-size: 26px; cursor: pointer; }.muted, .caption { font-size: 13px; color: var(--text-secondary); line-height: 1.6; }.caption { font-size: var(--text-xs); }.error { color: var(--state-danger); font-size: 13px; line-height: 1.6; }
|
||||
.lineup { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 20px; }.lineup > strong { font-size: 18px; }.quantity { color: var(--accent-primary-hover); font-weight: 600; }.lineup small { color: var(--text-secondary); }.lineup table { width: 100%; margin: 16px 0; border-collapse: collapse; font-size: 12px; table-layout: fixed; }.lineup th, .lineup td { text-align: center; padding: 10px 4px; border-bottom: 1px solid var(--border-subtle); overflow-wrap: anywhere; }.lineup th:first-child { text-align: left; width: 34%; }.lineup thead { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
form { display: grid; gap: 20px; }.servers { margin: 0; padding: 0; list-style: none; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); overflow: hidden; }.server-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) 220px; align-items: center; gap: 16px; padding: 20px 16px; }.server-row + .server-row { border-top: 1px solid var(--border-subtle); }.order { font-family: var(--font-mono); color: var(--text-tertiary); font-size: 12px; align-self: start; padding-top: 3px; }.server-identity { display: grid; gap: 8px; min-width: 0; }.server-row strong { font-size: 14px; overflow-wrap: anywhere; }.server-id { font-family: var(--font-mono); font-size: 11px; line-height: 1.6; color: var(--text-secondary); overflow-wrap: anywhere; }label { display: grid; gap: 7px; font-size: var(--text-xs); color: var(--text-secondary); }input { width: 100%; box-sizing: border-box; border: 1px solid var(--border-strong); border-radius: 7px; background: var(--surface-sunken); color: var(--text-primary); padding: 10px; font-size: 13px; }.actions { justify-content: flex-end; }.text-button { border: 0; background: none; color: var(--accent-primary-hover); cursor: pointer; font-size: 12px; }.recipes { border-top: 1px solid var(--border-subtle); padding-top: 20px; }.recipes summary { cursor: pointer; font-size: 13px; }.recipe { padding: 20px 0; border-bottom: 1px solid var(--border-subtle); }
|
||||
form { display: grid; gap: 20px; }.servers { margin: 0; padding: 0; list-style: none; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); overflow: hidden; }.server-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) 220px; align-items: center; gap: 16px; padding: 20px 16px; }.server-row + .server-row { border-top: 1px solid var(--border-subtle); }.order { font-family: var(--font-mono); color: var(--text-tertiary); font-size: 12px; align-self: start; padding-top: 3px; }.server-identity { display: grid; gap: 8px; min-width: 0; }.server-row strong { font-size: 14px; overflow-wrap: anywhere; }.server-id { font-family: var(--font-mono); font-size: 11px; line-height: 1.6; color: var(--text-secondary); overflow-wrap: anywhere; }label { display: grid; gap: 7px; font-size: var(--text-xs); color: var(--text-secondary); }input { width: 100%; box-sizing: border-box; border: 1px solid var(--border-control); border-radius: 7px; background: var(--surface-sunken); color: var(--text-primary); padding: 10px; font-size: 13px; }.actions { justify-content: flex-end; }.text-button { border: 0; background: none; color: var(--accent-primary-hover); cursor: pointer; font-size: 12px; }.recipes { border-top: 1px solid var(--border-subtle); padding-top: 20px; }.recipes summary { cursor: pointer; font-size: 13px; }.recipe { padding: 20px 0; border-bottom: 1px solid var(--border-subtle); }
|
||||
.pacing-note { display: flex; align-items: flex-start; gap: 14px; background: color-mix(in srgb, var(--accent-primary) 8%, transparent); border-left: 2px solid var(--accent-primary); padding: 16px; border-radius: 0 var(--radius-sm) var(--radius-sm) 0; }.pacing-note svg { flex: 0 0 24px; width: 24px; height: 24px; color: var(--accent-primary-hover); }.pacing-note strong { font-size: 14px; }.pacing-note p { margin-top: 6px; color: var(--text-secondary); font-size: 12px; line-height: 1.6; }.launch-footer { display: flex; justify-content: space-between; align-items: center; gap: 20px; flex-wrap: wrap; }.requirements { margin-top: 16px; }button:focus-visible, input:focus-visible, summary:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; }
|
||||
@media (max-width: 600px) { .composer { padding: 20px; }.lineup { padding: 16px; }.server-row { grid-template-columns: 24px minmax(0, 1fr); gap: 12px; padding: 16px; }.server-row label { grid-column: 2; }.launch-footer { align-items: stretch; flex-direction: column; gap: 16px; }.actions { justify-content: space-between; }h2 { font-size: 22px; } }
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { isDialogBackdrop } from '$ui/dialog.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import Button from './primitives/Button.svelte';
|
||||
@@ -29,6 +30,11 @@
|
||||
let saving = $state(false);
|
||||
let wasOpen = false;
|
||||
let session = 0;
|
||||
let keyNotice = $state('');
|
||||
let now = $state(Date.now());
|
||||
let heading: HTMLHeadingElement;
|
||||
const expired = $derived(!!preview && new Date(preview.expiresAt).getTime() <= now);
|
||||
onMount(() => { const timer = setInterval(() => { now = Date.now(); }, 10_000); return () => clearInterval(timer); });
|
||||
|
||||
$effect(() => {
|
||||
if (!dialog) return;
|
||||
@@ -38,7 +44,7 @@
|
||||
|
||||
async function initialize() {
|
||||
const current = ++session;
|
||||
preview = null; plutoKey = ''; error = ''; loaded = false; busy = false;
|
||||
preview = null; plutoKey = ''; error = ''; loaded = false; busy = false; keyNotice = '';
|
||||
blueprintName = ''; description = ''; savedName = ''; saving = false;
|
||||
blueprintId = initialBlueprintId;
|
||||
game = initialGame;
|
||||
@@ -63,19 +69,24 @@
|
||||
open = false; collectionOpen = true; return;
|
||||
}
|
||||
const current = session;
|
||||
busy = true; error = ''; plutoKey = ''; preview = null; savedName = '';
|
||||
const previousName = preview?.name;
|
||||
busy = true; error = ''; savedName = ''; keyNotice = '';
|
||||
try {
|
||||
const res = await fetch('/api/deployments/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(sourceDeploymentId ? { deploymentId: sourceDeploymentId } : blueprintId ? { blueprintId } : { game, mapSlug }) });
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body.error ?? 'Could not prepare launch preview.');
|
||||
if (current === session) preview = body;
|
||||
if (current === session) {
|
||||
if (previousName && previousName !== body.name) { plutoKey = ''; keyNotice = 'The available server ID changed. Enter the key for the new ID below; the previous key was not transferred.'; }
|
||||
preview = body; now = Date.now();
|
||||
await tick(); heading?.focus();
|
||||
}
|
||||
} catch (err) { if (current === session) error = (err as Error).message; }
|
||||
finally { if (current === session) busy = false; }
|
||||
}
|
||||
|
||||
async function launch() {
|
||||
if (!preview || !preview.ready || busy) return;
|
||||
if (!preview || !preview.ready || expired || busy) return;
|
||||
busy = true; error = '';
|
||||
try {
|
||||
const res = await fetch('/api/servers', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
@@ -106,22 +117,25 @@
|
||||
|
||||
<dialog bind:this={dialog} aria-label="Create server" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy && !saving) open = false; }} oncancel={event => { if (busy || saving) event.preventDefault(); }} onclose={() => open = false}>
|
||||
<div class="composer">
|
||||
<header><div><span class="eyebrow">{sourceDeploymentId ? 'Launch from history' : 'A new place to play'}</span><h2>{preview ? 'Review your next lobby' : 'Create your next server'}</h2></div><button class="close" disabled={busy || saving} onclick={() => open = false} aria-label="Close deployment composer">×</button></header>
|
||||
<header><div><h2 bind:this={heading} tabindex="-1">{preview ? 'Review server launch' : 'Create server'}</h2></div><button class="close" disabled={busy || saving} onclick={() => open = false} aria-label="Close deployment composer">×</button></header>
|
||||
<ol class="steps" aria-label="Launch steps"><li class:active={!preview} aria-current={!preview ? 'step' : undefined}>01 <span>Choose a starting point</span></li><li class:active={!!preview} aria-current={preview ? 'step' : undefined}>02 <span>Review & launch</span></li></ol>
|
||||
{#if error}<div class="error" role="alert">{error}</div>{/if}
|
||||
{#if !loaded}
|
||||
<p class="muted">{error ? 'Your launch choices could not be loaded.' : 'Loading your maps and blueprints…'}</p>
|
||||
{#if error}<Button onclick={initialize}>Try again</Button>{/if}
|
||||
{:else if preview}
|
||||
{#if keyNotice}<p role="status" class="muted">{keyNotice}</p>{/if}
|
||||
{#if expired}<p class="error" role="status">This preview expired. Refresh it to recheck capacity. Keys stay entered when the server ID is unchanged.</p>{/if}
|
||||
<section class="recipe-panel"><LaunchRecipe recipe={preview.recipe} />{#if preview.blueprintName}<p class="muted">From blueprint: {preview.blueprintName}</p>{/if}</section>
|
||||
<section class="preflight"><div class="section-title"><h3>Before you launch</h3><button class="text-button" disabled={busy || saving} onclick={review}>Refresh preview</button></div>
|
||||
<ul>{#each preview.checks as check}<li class:blocked={check.state === 'blocked'}><span class="check" aria-label={check.state}>{check.state === 'pass' ? '✓' : check.state === 'blocked' ? '!' : 'i'}</span><div><strong>{check.label}</strong><p>{check.detail}</p></div></li>{/each}</ul>
|
||||
</section>
|
||||
<form onsubmit={event => { event.preventDefault(); void launch(); }}>
|
||||
<div class="identity"><span class="eyebrow">Your new server</span><strong>{preview.name ?? 'Waiting for an available lobby'}</strong><p>{preview.port ? `Port ${preview.port} · ` : ''}Fresh identity, with its own server key.</p></div>
|
||||
<label>Plutonium server key<input type="password" bind:value={plutoKey} disabled={busy || saving || !preview.name} maxlength="512" autocomplete="off" placeholder={preview.hasSavedKey ? 'Saved key ready — leave blank to reuse' : 'Paste the key for this server ID'} /></label>
|
||||
<p class="muted">{preview.hasSavedKey ? 'The key saved for this exact lobby will be reused. Enter a replacement to update it.' : 'Your key is saved for this exact lobby when you launch, including if provisioning fails.'} <a href="/server-keys" onclick={() => open = false}>Manage keys</a></p>
|
||||
<div class="launch-actions">{#if !sourceDeploymentId}<Button variant="ghost" disabled={busy || saving} onclick={() => { preview = null; plutoKey = ''; error = ''; }}>Back</Button>{/if}<Button type="submit" variant="primary" loading={busy} disabled={busy || saving || !preview.ready || (!preview.hasSavedKey && !plutoKey.trim())}>Launch server</Button></div>
|
||||
<label>Plutonium server key<input type="password" bind:value={plutoKey} disabled={busy || saving || !preview.name} maxlength="512" autocomplete="off" placeholder={preview.hasSavedKey ? 'Saved key available — leave blank to reuse' : 'Paste the key for this server ID'} /></label>
|
||||
<p class="muted">{preview.hasSavedKey ? 'A key is stored for this exact server ID; its validity is checked when the game starts. Enter a replacement here to update it at launch.' : 'Add the key here. It is saved for this exact server ID when you launch, including if provisioning fails.'} <a href={`/server-keys?server=${encodeURIComponent(preview.name ?? '')}`} target="_blank" rel="noopener">Manage saved keys in a new tab</a>; your launch stays open here.</p>
|
||||
<div class="launch-actions">{#if !sourceDeploymentId}<Button variant="ghost" disabled={busy || saving} onclick={() => { preview = null; plutoKey = ''; error = ''; }}>Back</Button>{/if}<Button type="submit" variant="primary" loading={busy} disabled={busy || saving || expired || !preview.ready || (!preview.hasSavedKey && !plutoKey.trim())}>Launch server</Button></div>
|
||||
{#if !preview.hasSavedKey && !plutoKey.trim()}<p class="muted">Enter this server’s key to enable launch.</p>{/if}
|
||||
<p class="caption">Preview valid until {new Date(preview.expiresAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}. Lobby and port availability are checked again at launch.</p>
|
||||
</form>
|
||||
<details class="save"><summary>Make this a reusable blueprint</summary><p class="muted">Save this game and map with a target of one lobby. Future launches use the latest promoted setup and create only missing lobbies.</p>
|
||||
@@ -152,7 +166,7 @@
|
||||
h2 { font-size: 25px; letter-spacing: -.03em; margin: 0; } h3 { font-size: 15px; margin: 0; }
|
||||
.close { background: none; color: var(--text-secondary); border: 0; font-size: 27px; cursor: pointer; }
|
||||
.steps { list-style: none; padding: 0 0 20px; margin: 0; display: flex; gap: 24px; border-bottom: 1px solid var(--border-subtle); font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); }.steps span { font-family: var(--font-sans); margin-left: 6px; }.steps .active { color: var(--accent-primary-hover); }
|
||||
form { display: grid; gap: 16px; } label { display: grid; gap: 8px; font-size: 13px; } input, select { width: 100%; box-sizing: border-box; background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 12px; font: inherit; } input:focus, select:focus { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||
form { display: grid; gap: 16px; } label { display: grid; gap: 8px; font-size: 13px; } input, select { width: 100%; box-sizing: border-box; background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); border-radius: var(--radius-md); padding: 12px; font: inherit; } input:focus, select:focus { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||
.fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } p { font-size: 13px; line-height: 1.6; margin: 0; } .muted, .caption { color: var(--text-secondary); } .caption { font-size: var(--text-xs); }
|
||||
.recipe-panel, .promise { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 20px; display: grid; gap: 12px; }.promise strong { font-size: 14px; } .promise p { color: var(--text-secondary); }
|
||||
.preflight { display: grid; gap: 16px; } .preflight ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 14px; } .preflight li { display: flex; gap: 12px; align-items: flex-start; }.preflight strong { font-size: 13px; font-weight: 550; }.preflight p { color: var(--text-secondary); font-size: 12px; }.check { display: grid; place-items: center; flex: 0 0 22px; height: 22px; border: 1px solid var(--border-strong); border-radius: 50%; font-size: 12px; color: var(--accent-primary-hover); }.blocked .check { color: var(--state-danger); border-color: var(--state-danger); }
|
||||
|
||||
@@ -30,18 +30,18 @@
|
||||
<dialog bind:this={dialog} aria-labelledby="destroy-title" onclose={() => open = false}
|
||||
oncancel={(event) => { if (submitting) event.preventDefault(); }}>
|
||||
<form onsubmit={(event) => { event.preventDefault(); void destroy(); }}>
|
||||
<h2 id="destroy-title">Destroy container?</h2>
|
||||
<h2 id="destroy-title">Queue server removal?</h2>
|
||||
<p class="name">{name}</p>
|
||||
<p>This stops the container, deletes its Komodo deployment, and removes its server files and rollout details. Deployment history and the saved server key are retained.</p>
|
||||
<div class="notice">
|
||||
{#if players !== null && players > 0}<strong>{players} player{players === 1 ? '' : 's'} currently online.</strong>{/if}
|
||||
Destruction waits until a live check confirms the server is empty. You can follow its progress on the deployment page.
|
||||
Removal waits until a live check confirms the server is empty. Follow progress or cancel the waiting request on the deployment page.
|
||||
</div>
|
||||
<p class="irreversible">Once destruction begins, it cannot be undone.</p>
|
||||
<p class="irreversible">You can cancel while waiting. Once removal begins, it cannot be undone.</p>
|
||||
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
||||
<footer>
|
||||
<Button variant="ghost" disabled={submitting} onclick={() => open = false}>Cancel</Button>
|
||||
<Button variant="danger" type="submit" loading={submitting}>Destroy container</Button>
|
||||
<Button variant="danger" type="submit" loading={submitting}>Queue server removal</Button>
|
||||
</footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
@@ -47,9 +47,10 @@
|
||||
|
||||
<dialog bind:this={dialog} aria-label="Destroy fleet" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
|
||||
<div class="content">
|
||||
<header><span class="eyebrow">House Party Protocol</span><h2>Destroy fleet</h2></header>
|
||||
<header><h2>Queue fleet removal</h2></header>
|
||||
<p>Queue destruction of every server, including servers being created. Each waits until a fresh player count confirms it is empty. Unreachable servers stay queued.</p>
|
||||
<p>Destruction removes containers, server files, and fleet registrations. Saved keys and blueprints are kept.</p>
|
||||
<p>In Active deployments, cancel individual waiting requests or all waiting removals. Once a server begins removal, that removal cannot be cancelled or undone.</p>
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
{#if servers}
|
||||
<strong>{servers.length ? `${servers.length} server${servers.length === 1 ? '' : 's'} to destroy` : 'The house is already empty.'}</strong>
|
||||
@@ -67,7 +68,6 @@
|
||||
dialog { width: min(620px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); color: var(--text-primary); }
|
||||
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }
|
||||
.content { display: grid; gap: 20px; padding: 28px; } h2, p { margin: 0; } h2 { font-size: 25px; }
|
||||
.eyebrow { display: block; color: var(--state-danger); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 8px; }
|
||||
p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; } .error { color: var(--state-danger); } .caption { font-size: 12px; }
|
||||
ul { margin: 0; padding: 12px 12px 12px 30px; max-height: 220px; overflow: auto; background: var(--surface-sunken); border-radius: var(--radius-md); }
|
||||
li { padding: 5px 0; font-family: var(--font-mono); font-size: 12px; overflow-wrap: anywhere; }
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { tick, untrack } from 'svelte';
|
||||
import { tick, untrack, onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { toast } from '$ui/toast.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import Tabs from './primitives/Tabs.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
@@ -30,14 +33,15 @@
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
let active = $state<string>('');
|
||||
const active = $derived(tabs.find(tab => tab.key === $page.url.searchParams.get('log'))?.key ?? tabs[0]?.key ?? '');
|
||||
let data = $state<Record<string, LogResponse | null>>({});
|
||||
|
||||
// Default to the first tab once props are populated; don't overwrite
|
||||
// the user's later selection.
|
||||
$effect(() => {
|
||||
if (!tabs.some(tab => tab.key === active)) active = tabs[0]?.key ?? '';
|
||||
});
|
||||
function chooseLog(key: string) {
|
||||
const url = new URL($page.url); url.searchParams.set('log', key);
|
||||
void goto(url, { noScroll: true, keepFocus: true, replaceState: true });
|
||||
}
|
||||
let query = $state('');
|
||||
onMount(() => { query = $page.url.searchParams.get('logQuery') ?? ''; });
|
||||
let loading = $state(false);
|
||||
let autoRefresh = $state(true);
|
||||
let refreshError = $state('');
|
||||
@@ -62,7 +66,7 @@
|
||||
} finally {
|
||||
pending.delete(requestKey);
|
||||
if (server === currentServer && key === active) loading = false;
|
||||
if (stickToBottom && server === currentServer && key === active) {
|
||||
if (stickToBottom && !query && server === currentServer && key === active) {
|
||||
await tick();
|
||||
if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
|
||||
}
|
||||
@@ -78,6 +82,33 @@
|
||||
}
|
||||
|
||||
function toggleAuto() { autoRefresh = !autoRefresh; }
|
||||
async function jumpToLatest() {
|
||||
query = ''; stickToBottom = true;
|
||||
await tick(); if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
|
||||
}
|
||||
async function copy(value: string, message: string) {
|
||||
try { await navigator.clipboard.writeText(value); toast(message); }
|
||||
catch { toast('Could not copy. Select the log text to copy manually.', { variant: 'danger' }); }
|
||||
}
|
||||
function copyLink() {
|
||||
const url = new URL(window.location.href); url.searchParams.set('tab', 'logs'); url.searchParams.set('log', active);
|
||||
if (query) url.searchParams.set('logQuery', query); else url.searchParams.delete('logQuery');
|
||||
void copy(url.href, 'Log link copied.');
|
||||
}
|
||||
function download() {
|
||||
const blob = new Blob([(current?.lines ?? []).join('\n')], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a'); link.href = url; link.download = `${server}-${active}.log`; link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
function highlight(line: string) {
|
||||
if (!query) return [{ text: line, match: false }];
|
||||
const parts: { text: string; match: boolean }[] = []; let start = 0; let index: number;
|
||||
while ((index = line.toLowerCase().indexOf(query.toLowerCase(), start)) !== -1) {
|
||||
parts.push({ text: line.slice(start, index), match: false }, { text: line.slice(index, index + query.length), match: true }); start = index + query.length;
|
||||
}
|
||||
parts.push({ text: line.slice(start), match: false }); return parts;
|
||||
}
|
||||
$effect(() => { server; data = {}; refreshError = ''; });
|
||||
$effect(() => {
|
||||
server;
|
||||
@@ -109,24 +140,27 @@
|
||||
}
|
||||
|
||||
let current = $derived(data[active] ?? null);
|
||||
const visibleLines = $derived((current?.lines ?? []).filter(line => !query || line.toLowerCase().includes(query.toLowerCase())));
|
||||
</script>
|
||||
|
||||
<div class="log-panel">
|
||||
<div class="tabs">
|
||||
<Tabs tabs={tabs.map(t => ({id:t.key,label:t.label}))} bind:value={active} idPrefix={uid} panelId={uid + '-panel'} ariaLabel="Log files" size="sm" />
|
||||
<Tabs tabs={tabs.map(t => ({id:t.key,label:t.label}))} value={active} onchange={chooseLog} idPrefix={uid} panelId={uid + '-panel'} ariaLabel="Log files" size="sm" />
|
||||
<div class="right">
|
||||
{#if current?.mtime}
|
||||
<span class="meta">{fmtBytes(current.size)} · updated {ago(current.mtime)}</span>
|
||||
{/if}
|
||||
<label class="auto">
|
||||
<input type="checkbox" checked={autoRefresh} onchange={toggleAuto} />
|
||||
auto {pollMs / 1000}s
|
||||
Auto-refresh · {pollMs / 1000}s
|
||||
</label>
|
||||
<button class="refresh" onclick={() => fetchLog(active)} disabled={loading}>
|
||||
{loading ? '…' : 'refresh'}
|
||||
{loading ? 'Refreshing…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-tools"><label class="log-search">Search loaded lines<input type="search" bind:value={query} placeholder="Find text in this log" /></label><div class="log-actions"><Button size="sm" onclick={copyLink}>Copy link</Button><Button size="sm" disabled={!current?.lines?.length} onclick={() => copy(visibleLines.join('\n'), 'Log text copied.')}>Copy {query ? 'matches' : 'log'}</Button><Button size="sm" disabled={!current?.lines?.length} onclick={download}>Download tail</Button></div></div>
|
||||
<div class="follow-state"><span>{!autoRefresh ? 'Refresh paused' : 'Auto-refresh on'} · {query ? `${visibleLines.length} matching lines` : stickToBottom ? 'Following latest lines' : 'Reading earlier lines'}</span>{#if !stickToBottom || query}<Button size="sm" variant="ghost" onclick={jumpToLatest}>Jump to latest</Button>{/if}</div>
|
||||
|
||||
<div id={uid + '-panel'} role="tabpanel" aria-labelledby={uid + '-' + active}>
|
||||
{#if refreshError}<div class="ui-error" role="alert">{refreshError} {current ? 'Showing the last loaded log.' : ''}<Button size="sm" onclick={() => fetchLog(active)} disabled={loading}>Retry</Button></div>{/if}
|
||||
@@ -142,7 +176,9 @@
|
||||
{:else if current && (!current.lines || current.lines.length === 0)}
|
||||
<div class="empty">log is empty</div>
|
||||
{:else if current?.lines}
|
||||
<pre class="body" bind:this={bodyEl} onscroll={onScroll}>{current.lines.join('\n')}</pre>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (The log is keyboard-scrollable.) -->
|
||||
<pre class="body" bind:this={bodyEl} onscroll={onScroll} tabindex="0" aria-label="{active} log contents">{#each visibleLines as line}{#each highlight(line) as part}{#if part.match}<mark>{part.text}</mark>{:else}{part.text}{/if}{/each}{'\n'}{/each}</pre>
|
||||
{#if query && !visibleLines.length}<p class="empty">No matches in the loaded tail. Try another search or download the tail.</p>{/if}
|
||||
{#if current.truncated}
|
||||
<div class="foot">showing last {current.lines.length} lines (file is larger)</div>
|
||||
{:else}
|
||||
@@ -153,6 +189,12 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.log-tools, .log-actions, .follow-state { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.log-tools { align-items: end; }
|
||||
.log-search { display: grid; gap: 6px; flex: 1; min-width: 180px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.log-search input { background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); border-radius: var(--radius-sm); padding: 9px 12px; font: inherit; }
|
||||
.follow-state { justify-content: space-between; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
mark { background: var(--state-warning); color: var(--surface-sunken); }
|
||||
.log-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import Dialog from './primitives/Dialog.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import { notificationHistory, markNotificationsRead, clearNotifications } from '$ui/toast.js';
|
||||
let { open = $bindable(false) }: { open?: boolean } = $props();
|
||||
$effect(() => { if (open) markNotificationsRead(); });
|
||||
</script>
|
||||
|
||||
<Dialog bind:open title="Recent notifications" description="Recent action results and completed lobby watches in this browser tab. Watches run while the app is open.">
|
||||
{#if $notificationHistory.length}
|
||||
<ol>{#each $notificationHistory as item (item.id)}<li class:attention={item.variant === 'danger' || item.variant === 'warning'}><p>{item.message}</p><time datetime={item.createdAt}>{new Date(item.createdAt).toLocaleString()}</time>{#if item.href}<a href={item.href} onclick={() => open = false}>View details →</a>{/if}</li>{/each}</ol>
|
||||
{:else}<p class="empty">No recent notifications. Completed lobby watches and action results will appear here.</p>{/if}
|
||||
{#snippet actions()}<Button disabled={!$notificationHistory.length} onclick={clearNotifications}>Clear history</Button><Button onclick={() => open = false}>Close</Button>{/snippet}
|
||||
</Dialog>
|
||||
|
||||
<style>
|
||||
ol { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; }
|
||||
li { border: 1px solid var(--border-strong); padding: 12px; border-radius: var(--radius-md); }
|
||||
li.attention { border-color: var(--state-warning); }
|
||||
p { margin: 0 0 8px; font-size: var(--text-sm); }
|
||||
time { display: block; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
a { display: inline-block; margin-top: 8px; font-size: var(--text-sm); }
|
||||
.empty { color: var(--text-secondary); }
|
||||
</style>
|
||||
@@ -3,6 +3,11 @@
|
||||
import Button from './primitives/Button.svelte';
|
||||
let { steps, events, active = false }: { steps: OperationStep[]; events: ActivityEvent[]; active?: boolean } = $props();
|
||||
let limit = $state(25);
|
||||
let query = $state('');
|
||||
let newestFirst = $state(true);
|
||||
const latest = $derived(events.at(-1));
|
||||
const matching = $derived(events.filter(event => event.message.toLowerCase().includes(query.toLowerCase())));
|
||||
const visible = $derived(newestFirst ? matching.slice(-limit).reverse() : matching.slice(0, limit));
|
||||
</script>
|
||||
|
||||
<section>
|
||||
@@ -17,23 +22,37 @@
|
||||
</ol>
|
||||
</section>
|
||||
<section>
|
||||
<div class="top"><h2>Activity</h2>{#if active}<span class="muted">Updates automatically</span>{/if}</div>
|
||||
{#if events.length}
|
||||
{#if events.length > limit}<Button size="sm" onclick={() => limit += 25}>Show earlier activity ({events.length - limit})</Button>{/if}
|
||||
{#if latest}<p class="latest"><strong>Latest update</strong> {latest.message}<time datetime={latest.created_at}>{new Date(latest.created_at).toLocaleString()}</time></p>{/if}
|
||||
<details>
|
||||
<summary>Activity history · {events.length} events{active ? ' · updates automatically' : ''}</summary>
|
||||
{#if events.length}
|
||||
<div class="tools"><label>Search activity<input type="search" bind:value={query} oninput={() => limit = 25} placeholder="Server, stage, or message" /></label><label>Order<select bind:value={newestFirst}><option value={true}>Newest first</option><option value={false}>Oldest first</option></select></label></div>
|
||||
<ol class="timeline">
|
||||
{#each events.slice(-limit) as event (event.id)}
|
||||
{#each visible as event (event.id)}
|
||||
<li><time datetime={event.created_at}>{new Date(event.created_at).toLocaleString()}</time><p>{event.message}</p></li>
|
||||
{/each}
|
||||
</ol>
|
||||
{#if !matching.length}<p class="muted">No matching activity.</p>{/if}
|
||||
{#if matching.length > limit}<Button size="sm" onclick={() => limit += 25}>Show more activity ({matching.length - limit})</Button>{/if}
|
||||
{:else}
|
||||
<p class="muted">This operation predates detailed activity tracking. Its recorded status and timestamps are shown on this page.</p>
|
||||
{/if}
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section { display: grid; gap: 16px; }
|
||||
h2 { font-size: 18px; margin: 0; }
|
||||
.top { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
|
||||
details { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 16px; }
|
||||
summary { cursor: pointer; font-size: var(--text-sm); font-weight: var(--weight-semibold); }
|
||||
details[open] summary { margin-bottom: 16px; }
|
||||
.latest { padding: 12px 16px; background: var(--surface); border-radius: var(--radius-md); font-size: var(--text-sm); }
|
||||
.latest strong { display: block; margin-bottom: 4px; }
|
||||
.latest time { display: block; margin-top: 4px; }
|
||||
.tools { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 20px; }
|
||||
label { display: grid; gap: 6px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
label:first-child { flex: 1; min-width: 180px; }
|
||||
input, select { background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); padding: 9px 12px; border-radius: var(--radius-sm); font: inherit; }
|
||||
time, .muted { color: var(--text-secondary); font-size: 13px; }
|
||||
p { margin: 0; line-height: 1.6; }
|
||||
.flow { display: flex; flex-wrap: wrap; gap: 12px; padding: 0; margin: 0; list-style: none; }
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { GithubPluginRelease, PluginUpdate } from '$lib/plugin-github.js';
|
||||
import Card from './primitives/Card.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import Badge from './primitives/Badge.svelte';
|
||||
import { toast } from '$ui/toast.js';
|
||||
|
||||
let { pluginId, targetPath, disabled = false, onimport }: {
|
||||
pluginId?: number; targetPath?: string; disabled?: boolean; onimport?: () => void | Promise<void>;
|
||||
let { pluginId, targetPath, disabled = false, busy = $bindable(false), onimport }: {
|
||||
pluginId?: number; targetPath?: string; disabled?: boolean; busy?: boolean; onimport?: (result: { pluginId: number; betaUrl: string; alreadyImported: boolean }) => void | Promise<void>;
|
||||
} = $props();
|
||||
let url = $state('');
|
||||
let name = $state('');
|
||||
@@ -15,10 +16,11 @@
|
||||
let release = $state<GithubPluginRelease | null>(null);
|
||||
let assetId = $state('');
|
||||
let update = $state<PluginUpdate | null>(null);
|
||||
let busy = $state(false);
|
||||
let checking = $state(false);
|
||||
let error = $state('');
|
||||
let success = $state<{ pluginId: number; betaUrl: string; sha256: string; alreadyImported: boolean } | null>(null);
|
||||
let active = true;
|
||||
onDestroy(() => { active = false; });
|
||||
|
||||
function showRelease(value: GithubPluginRelease) {
|
||||
release = value;
|
||||
@@ -58,6 +60,7 @@
|
||||
|
||||
async function importRelease(startBeta: boolean) {
|
||||
if (!release || !assetId || busy || disabled) return;
|
||||
const startPath = window.location.pathname;
|
||||
busy = true; error = ''; success = null;
|
||||
try {
|
||||
const response = await fetch('/api/plugins/github/import', {
|
||||
@@ -67,11 +70,12 @@
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Import failed.');
|
||||
if (!active) { toast('Plugin imported. Review its version when ready.', { variant: 'success', href: `/plugins/${body.pluginId}` }); return; }
|
||||
success = body;
|
||||
await onimport?.();
|
||||
if (startBeta) await goto(body.betaUrl);
|
||||
await onimport?.(body);
|
||||
if (startBeta && window.location.pathname === startPath) await goto(body.betaUrl);
|
||||
else if (pluginId) await checkUpdates();
|
||||
} catch (e) { error = (e as Error).message; }
|
||||
} catch (e) { error = (e as Error).message; if (!active) toast(`Plugin import failed: ${error}`, { variant: 'danger', href: '/plugins' }); }
|
||||
finally { busy = false; }
|
||||
}
|
||||
</script>
|
||||
@@ -172,4 +176,5 @@
|
||||
.success { display: block; padding: var(--space-3); background: var(--surface-sunken); border-radius: var(--radius-md); font-size: var(--text-sm); }
|
||||
.success strong, .checksum { display: block; margin-bottom: var(--space-2); }
|
||||
.checksum { overflow-wrap: anywhere; font: var(--text-xs) var(--font-mono); margin-top: var(--space-2); }
|
||||
input:not([type='checkbox']):not([type='file']), select { border-color: var(--border-control); }
|
||||
</style>
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
let message = $state('');
|
||||
let preferredTarget = $state<number | null>(null);
|
||||
let nextKey = 0;
|
||||
let activeFile = $state('');
|
||||
const checkedCount = $derived(queue.filter(q => q.inspected).length);
|
||||
const pending = $derived(queue.filter(q => !q.result));
|
||||
const inspecting = $derived(queue.some(q => q.inspecting));
|
||||
const hasNew = $derived(pending.some(q => q.target === 'new'));
|
||||
@@ -56,19 +58,20 @@
|
||||
return scripts.filter(s => s.target_rel_path.replaceAll('\\', '/').split('/').pop()?.toLowerCase() === file.name.toLowerCase());
|
||||
}
|
||||
export async function addFiles(files: File[]) {
|
||||
if (busy || !files.length) return;
|
||||
if (busy || inspecting || !files.length) return;
|
||||
if (!dialog.open) open();
|
||||
for (const file of files) {
|
||||
const items = files.map(file => {
|
||||
const matches = matchingScripts(file);
|
||||
const item: Upload = { key: nextKey++, file, target: preferredTarget ?? (matches.length === 1 ? matches[0].id : matches.length > 1 ? '' : 'new'),
|
||||
name: file.name.replace(/\.[^.]+$/, ''), duplicates: [], inspecting: true, inspected: false, error: '', result: null };
|
||||
queue = [...queue, item];
|
||||
// Work with the reactive proxy, rather than the original object.
|
||||
await inspect(queue.find(q => q.key === item.key)!);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
queue = [...queue, ...items];
|
||||
for (const item of items) await inspect(queue.find(q => q.key === item.key)!);
|
||||
preferredTarget = null;
|
||||
}
|
||||
async function inspect(item: Upload) {
|
||||
activeFile = item.file.name;
|
||||
item.inspecting = true; item.error = '';
|
||||
try {
|
||||
const form = new FormData(); form.append('content', item.file);
|
||||
@@ -77,7 +80,7 @@
|
||||
if (!res.ok) throw new Error(body.error ?? 'Could not inspect this file.');
|
||||
item.duplicates = body.duplicates; item.inspected = true;
|
||||
} catch (e) { item.error = (e as Error).message; }
|
||||
finally { item.inspecting = false; }
|
||||
finally { item.inspecting = false; activeFile = ''; }
|
||||
}
|
||||
function duplicate(item: Upload) { return item.duplicates.find(d => d.scriptId === item.target); }
|
||||
function validation(item: Upload): string {
|
||||
@@ -99,6 +102,7 @@
|
||||
busy = true; message = '';
|
||||
try {
|
||||
for (const item of queue.filter(q => !q.result)) {
|
||||
activeFile = item.file.name;
|
||||
item.error = '';
|
||||
try {
|
||||
const form = new FormData();
|
||||
@@ -126,7 +130,7 @@
|
||||
if (prepareBeta && testable.length === results.length && testable.length) { dialog.close(); await goto(betaSelectionHref(testable)); }
|
||||
}
|
||||
} catch (e) { message = (e as Error).message; }
|
||||
finally { busy = false; }
|
||||
finally { busy = false; activeFile = ''; }
|
||||
}
|
||||
function close(event?: Event) { if (busy || inspecting) { event?.preventDefault(); return; } dialog.close(); }
|
||||
</script>
|
||||
@@ -183,6 +187,7 @@
|
||||
{/if}
|
||||
{#if message}<p class="notice" role="status">{message}</p>{/if}
|
||||
<footer>
|
||||
{#if inspecting || busy}<p class="batch-progress" role="status">{inspecting ? `Checked ${checkedCount} of ${queue.length} files` : `Uploaded ${results.length} of ${queue.length} files`}{activeFile ? ` · ${activeFile}` : ''}</p>{:else if queue.length}<p class="batch-progress">{results.length} saved · {pending.length} remaining{queue.some(q => q.error) ? ` · ${queue.filter(q => q.error).length} need attention` : ''}</p>{/if}
|
||||
{#if pending.length}<Button disabled={!ready} loading={busy} onclick={() => upload(false)}>Upload{results.length ? ' remaining' : ''}</Button><Button variant="primary" disabled={!ready} onclick={() => upload(true)}>Upload & prepare beta →</Button>
|
||||
{:else if testable.length}<Button variant="primary" href={betaSelectionHref(testable)}>Prepare beta with {testable.length} script{testable.length === 1 ? '' : 's'} →</Button>{/if}
|
||||
</footer>
|
||||
@@ -205,6 +210,8 @@
|
||||
input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||
.success, .warning, .error { margin: 0; font-size: var(--text-sm); line-height: 1.5; } .success { color: var(--state-online); } .warning { color: var(--state-warning); } .error { color: var(--state-danger); } a { color: inherit; }
|
||||
.notice { padding: 14px; background: var(--surface-raised); border-radius: var(--radius-md); font-size: var(--text-sm); line-height: 1.6; }
|
||||
footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; margin-top: auto; padding-top: 20px; border-top: 1px solid var(--border-subtle); }
|
||||
footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; margin-top: auto; padding: 16px 0; border-top: 1px solid var(--border-subtle); position: sticky; bottom: 0; z-index: 3; background: var(--surface); }
|
||||
.batch-progress { flex-basis: 100%; margin: 0; color: var(--text-secondary); font-size: var(--text-sm); overflow-wrap: anywhere; }
|
||||
@media (max-width: 640px) { .drawer { padding: 20px; } }
|
||||
input:not([type='checkbox']):not([type='file']), select, textarea { border-color: var(--border-control); }
|
||||
</style>
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
let gameInProgress = $derived(!!gameStartedAt && !gameEndedAt && !!activeSince);
|
||||
let gameElapsed = $derived(gameInProgress ? elapsedSince(gameStartedAt, $uiNow) : null);
|
||||
let showTiming = $derived(
|
||||
showRoster && (activeElapsed !== null || inactiveElapsed !== null || gameElapsed !== null)
|
||||
health === 'online' && (activeElapsed !== null || inactiveElapsed !== null || gameElapsed !== null)
|
||||
);
|
||||
|
||||
// Shared-element transition to /servers/{name} — Chromium only, others
|
||||
@@ -109,6 +109,8 @@
|
||||
<Badge variant="info" uppercase>Starting</Badge>
|
||||
{:else if health === 'offline'}
|
||||
<Badge variant="danger" uppercase>offline</Badge>
|
||||
{:else if health === 'stale'}
|
||||
<Badge variant="warning">Stale</Badge>
|
||||
{:else}
|
||||
<div class="players" aria-live="polite">
|
||||
<Stat
|
||||
@@ -123,6 +125,11 @@
|
||||
|
||||
{#if gtLabel || showMap}<div class="meta">{#if showMap}<span>{health === 'online' ? 'Current map' : 'Last map'}: {mapLabel}</span>{/if}{#if gtLabel}<span class="gt">{gtLabel}</span>{/if}</div>{/if}
|
||||
|
||||
{#if health === 'stale'}
|
||||
<div class="stale-note"><strong>Players unknown</strong><span>Last known: {players ?? 'unknown'}{players !== null ? ' players' : ''}{lastSeenAt ? ` · ${ago(lastSeenAt, $uiNow)}` : ''}</span></div>
|
||||
{#if topRoster.length}<div class="roster-heading">Last reported players</div>{/if}
|
||||
{/if}
|
||||
|
||||
{#if health === 'online' && players !== null && players > 0 && gameInProgress}<RoundTelemetry {name} since={gameStartedAt} />{/if}
|
||||
|
||||
{#if showTiming && !(compactEmpty && players === 0)}
|
||||
@@ -185,6 +192,9 @@
|
||||
</Card>
|
||||
|
||||
<style>
|
||||
.stale-note { display: grid; gap: 4px; margin-top: 14px; font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
.stale-note strong { color: var(--text-primary); font-weight: var(--weight-medium); }
|
||||
.roster-heading { margin-top: 14px; font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||
/* Global tweaks scoped through :global() since Card is a child component. */
|
||||
:global(.card.restarting) {
|
||||
outline: 1px solid rgba(255, 176, 32, 0.28);
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
|
||||
/* Loading */
|
||||
.is-loading .label,
|
||||
.is-loading .slot { visibility: hidden; }
|
||||
.is-loading .slot { opacity: 0; }
|
||||
.spinner {
|
||||
position: absolute;
|
||||
width: 14px; height: 14px;
|
||||
|
||||
@@ -18,14 +18,28 @@
|
||||
get messageId() { return error || hint ? `${id}-message` : undefined; },
|
||||
get invalid() { return !!error; }
|
||||
});
|
||||
|
||||
function connectLabel(node: HTMLDivElement) {
|
||||
const connect = () => {
|
||||
const label = node.querySelector<HTMLLabelElement>('label.label');
|
||||
const control = node.querySelector<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>('input:not([type="hidden"]), select, textarea');
|
||||
if (!label || !control || forId) return;
|
||||
if (!control.id) control.id = `${id}-control`;
|
||||
label.htmlFor = control.id;
|
||||
};
|
||||
connect();
|
||||
const observer = new MutationObserver(connect);
|
||||
observer.observe(node, { childList: true, subtree: true });
|
||||
return { destroy: () => observer.disconnect() };
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="field">
|
||||
<div class="field" use:connectLabel>
|
||||
{#if label}
|
||||
<svelte:element this={forId ? 'label' : 'div'} class="label" id={`${id}-label`} for={forId}>
|
||||
<label class="label" id={`${id}-label`} for={forId ?? `${id}-control`}>
|
||||
{label}
|
||||
{#if required}<span class="req" aria-hidden="true">*</span>{/if}
|
||||
</svelte:element>
|
||||
</label>
|
||||
{/if}
|
||||
<div class="control" class:has-error={!!error}>
|
||||
{@render children?.()}
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
@@ -77,7 +77,7 @@
|
||||
.input.mono { font-family: var(--font-mono); }
|
||||
.input::placeholder { color: var(--text-quaternary); }
|
||||
|
||||
.input:hover { border-color: var(--border-strong); }
|
||||
.input:hover { border-color: var(--border-control-hover); }
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import Button from './Button.svelte';
|
||||
let { total, limit = $bindable(25), step = 25, label = 'items' }: { total: number; limit?: number; step?: number; label?: string } = $props();
|
||||
let { total, limit = $bindable(25), step = 25, label = 'items', onchange }: { total: number; limit?: number; step?: number; label?: string; onchange?: (limit: number) => void } = $props();
|
||||
</script>
|
||||
{#if total > step}
|
||||
<div class="list-footer"><span role="status">Showing {Math.min(limit, total)} of {total} {label}</span>{#if limit < total}<Button size="sm" onclick={() => limit += step}>Show {Math.min(step, total - limit)} more</Button>{/if}</div>
|
||||
<div class="list-footer"><span role="status">Showing {Math.min(limit, total)} of {total} {label}</span>{#if limit < total}<Button size="sm" onclick={() => { limit += step; onchange?.(limit); }}>Show {Math.min(step, total - limit)} more</Button>{/if}</div>
|
||||
{/if}
|
||||
<style>.list-footer { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding-block: var(--space-3); color: var(--text-secondary); font-size: var(--text-sm); }</style>
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
appearance: none;
|
||||
padding: 9px 32px 9px 12px;
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
@@ -81,7 +81,7 @@
|
||||
background-color var(--dur-fast) var(--ease-standard),
|
||||
box-shadow var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
.select:hover { border-color: var(--border-strong); }
|
||||
.select:hover { border-color: var(--border-control-hover); }
|
||||
.select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
interface Tab {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -18,6 +19,32 @@
|
||||
|
||||
const uid = $props.id();
|
||||
let { tabs, value = $bindable(), onchange, ariaLabel, size = 'md', panelId, idPrefix = uid }: Props = $props();
|
||||
let tablist = $state<HTMLDivElement | null>(null);
|
||||
|
||||
// Keep a deep-linked or keyboard-selected tab visible without scrolling the page.
|
||||
$effect(() => {
|
||||
const selected = value;
|
||||
const node = tablist;
|
||||
if (!node) return;
|
||||
let cancelled = false;
|
||||
let observer: ResizeObserver | undefined;
|
||||
void tick().then(() => {
|
||||
if (cancelled) return;
|
||||
const tab = document.getElementById(`${idPrefix}-${selected}`);
|
||||
if (!tab) return;
|
||||
const reveal = () => {
|
||||
const bounds = node.getBoundingClientRect();
|
||||
const target = tab.getBoundingClientRect();
|
||||
if (target.left < bounds.left + 4) node.scrollLeft -= bounds.left + 4 - target.left;
|
||||
else if (target.right > bounds.right - 4) node.scrollLeft += target.right - bounds.right + 4;
|
||||
};
|
||||
reveal();
|
||||
observer = new ResizeObserver(reveal);
|
||||
observer.observe(node);
|
||||
observer.observe(tab);
|
||||
});
|
||||
return () => { cancelled = true; observer?.disconnect(); };
|
||||
});
|
||||
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
@@ -38,7 +65,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tabs s-{size}" role="tablist" aria-label={ariaLabel}>
|
||||
<div class="tabs s-{size}" role="tablist" aria-label={ariaLabel} bind:this={tablist}>
|
||||
{#each tabs as t (t.id)}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-3);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
@@ -68,7 +68,7 @@
|
||||
}
|
||||
.textarea.mono { font-family: var(--font-mono); font-size: var(--text-xs); line-height: var(--leading-mono); }
|
||||
.textarea::placeholder { color: var(--text-quaternary); }
|
||||
.textarea:hover { border-color: var(--border-strong); }
|
||||
.textarea:hover { border-color: var(--border-control-hover); }
|
||||
.textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { replaceState } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
/** Keep library working views shareable and restore them on browser Back. */
|
||||
export function saveLibraryContext(values: Record<string, string | number>, defaults: Record<string, string | number> = {}) {
|
||||
const url = new URL(window.location.href);
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === '' || value === defaults[key]) url.searchParams.delete(key);
|
||||
else url.searchParams.set(key, String(value));
|
||||
}
|
||||
if (url.href !== window.location.href) replaceState(url, get(page).state);
|
||||
}
|
||||
|
||||
export function libraryLimit(params: URLSearchParams): number {
|
||||
return Math.max(25, Math.min(10000, Number.parseInt(params.get('limit') ?? '25', 10) || 25));
|
||||
}
|
||||
@@ -8,7 +8,7 @@ const states: Record<string, [string, StatusTone]> = {
|
||||
'awaiting-health': ['Verifying health', 'info'], 'awaiting-match': ['Waiting for a match', 'neutral'],
|
||||
ready: ['Ready', 'online'], 'ready-promote': ['Ready to promote', 'online'],
|
||||
done: ['Completed', 'online'], passed: ['Test passed', 'online'], tested: ['Test passed', 'online'],
|
||||
promoted: ['Promoted', 'online'], 'rolled-back': ['Rolled back', 'neutral'],
|
||||
promoted: ['Promoted', 'online'], 'rolled-back': ['Rolled back', 'neutral'], cancelled: ['Cancelled', 'neutral'],
|
||||
failed: ['Failed', 'danger'], 'test-failed': ['Test failed', 'danger'], interrupted: ['Interrupted', 'danger'],
|
||||
warning: ['Completed with warnings', 'warning'], held: ['Held by beta', 'neutral'],
|
||||
manual: ['Manual action needed', 'warning'], paused: ['Paused', 'warning'], inconclusive: ['Missing evidence', 'warning'],
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
:where(input:not([type='checkbox']):not([type='radio']), select, textarea) {
|
||||
min-width: 0; max-width: 100%; min-height: 40px; padding: 10px 12px;
|
||||
color: var(--text-primary); background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-strong); border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-control); border-radius: var(--radius-md);
|
||||
}
|
||||
:where(input, textarea)::placeholder { color: var(--text-tertiary); opacity: 1; }
|
||||
:where(button, summary) { cursor: pointer; }
|
||||
|
||||
@@ -70,6 +70,8 @@
|
||||
--border-subtle: rgba(255, 255, 255, 0.06);
|
||||
--border-strong: rgba(255, 255, 255, 0.12);
|
||||
--border-focus: var(--blue-500);
|
||||
--border-control: #576176;
|
||||
--border-control-hover: #78869f;
|
||||
|
||||
/* Accent — one color, one voice. */
|
||||
--accent-primary: var(--blue-500);
|
||||
|
||||
@@ -18,6 +18,27 @@ export interface Toast {
|
||||
}
|
||||
|
||||
export const toasts = writable<Toast[]>([]);
|
||||
export interface Notice extends Toast { createdAt: string; read: boolean; }
|
||||
export const notificationHistory = writable<Notice[]>([]);
|
||||
const historyKey = 'electric-cherry:notifications';
|
||||
|
||||
export function startNotificationHistory(): () => void {
|
||||
try {
|
||||
const saved: unknown = JSON.parse(sessionStorage.getItem(historyKey) ?? '[]');
|
||||
if (Array.isArray(saved)) {
|
||||
const valid = saved.filter((item): item is Notice => item && typeof item.id === 'number' && Number.isSafeInteger(item.id)
|
||||
&& typeof item.message === 'string' && ['info', 'success', 'warning', 'danger'].includes(item.variant)
|
||||
&& typeof item.createdAt === 'string' && Number.isFinite(Date.parse(item.createdAt))
|
||||
&& typeof item.read === 'boolean' && (!item.href || (typeof item.href === 'string' && item.href.startsWith('/') && !item.href.startsWith('//')))).slice(0, 50);
|
||||
notificationHistory.set(valid);
|
||||
nextId = Math.max(nextId, ...valid.map(item => item.id + 1));
|
||||
}
|
||||
} catch { /* Notifications still work when browser storage is unavailable. */ }
|
||||
return notificationHistory.subscribe(items => { try { sessionStorage.setItem(historyKey, JSON.stringify(items)); } catch { /* Optional persistence. */ } });
|
||||
}
|
||||
|
||||
export function markNotificationsRead() { notificationHistory.update(items => items.map(item => ({ ...item, read: true }))); }
|
||||
export function clearNotifications() { notificationHistory.set([]); }
|
||||
|
||||
let nextId = 1;
|
||||
|
||||
@@ -33,6 +54,9 @@ export function toast(
|
||||
href: opts.href
|
||||
};
|
||||
toasts.update((arr) => [...arr, t]);
|
||||
if (t.href || t.ttl === 0 || t.variant !== 'info') {
|
||||
notificationHistory.update(items => [{ ...t, createdAt: new Date().toISOString(), read: false }, ...items].slice(0, 50));
|
||||
}
|
||||
if (t.ttl > 0) {
|
||||
setTimeout(() => dismiss(t.id), t.ttl);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user