Compare commits
3
Commits
b5037a70fe
...
2e6eb0bdb0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e6eb0bdb0 | ||
|
|
d93a766578 | ||
|
|
ba9bed6a0d |
@@ -1,3 +1,5 @@
|
|||||||
|
import type { LaunchFileSummary } from './deployment-plan.js';
|
||||||
|
|
||||||
/** Keep the upload number visible: two builds may share a publisher's release tag. */
|
/** Keep the upload number visible: two builds may share a publisher's release tag. */
|
||||||
export function artifactVersionLabel(
|
export function artifactVersionLabel(
|
||||||
version: { version: number; release_version?: string | null } | null | undefined,
|
version: { version: number; release_version?: string | null } | null | undefined,
|
||||||
@@ -10,3 +12,14 @@ export function artifactVersionLabel(
|
|||||||
? `Unversioned (upload ${version.version})`
|
? `Unversioned (upload ${version.version})`
|
||||||
: `v${version.version}`;
|
: `v${version.version}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function launchFileVersionLabel(
|
||||||
|
file: Pick<LaunchFileSummary, 'version' | 'releaseVersion' | 'kind'>
|
||||||
|
): string {
|
||||||
|
return file.version === null
|
||||||
|
? 'Generated'
|
||||||
|
: artifactVersionLabel(
|
||||||
|
{ version: file.version, release_version: file.releaseVersion },
|
||||||
|
file.kind
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export interface LaunchFileSummary {
|
|||||||
path: string;
|
path: string;
|
||||||
versionId: number | null;
|
versionId: number | null;
|
||||||
version: number | null;
|
version: number | null;
|
||||||
|
/** Missing on snapshots recorded before publisher release versions were retained. */
|
||||||
|
releaseVersion?: string | null;
|
||||||
sha256: string;
|
sha256: string;
|
||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ function buildRecipe(
|
|||||||
path: script.target_rel_path,
|
path: script.target_rel_path,
|
||||||
versionId: version.id,
|
versionId: version.id,
|
||||||
version: version.version,
|
version: version.version,
|
||||||
|
releaseVersion: version.release_version,
|
||||||
sha256: version.sha256,
|
sha256: version.sha256,
|
||||||
size: version.size
|
size: version.size
|
||||||
});
|
});
|
||||||
@@ -187,6 +188,7 @@ function buildRecipe(
|
|||||||
kind: 'config',
|
kind: 'config',
|
||||||
versionId: null,
|
versionId: null,
|
||||||
version: null,
|
version: null,
|
||||||
|
releaseVersion: null,
|
||||||
sha256: sha256Buffer(content),
|
sha256: sha256Buffer(content),
|
||||||
size: content.length,
|
size: content.length,
|
||||||
...(includeContent ? { content: content.toString('base64') } : {})
|
...(includeContent ? { content: content.toString('base64') } : {})
|
||||||
@@ -249,18 +251,41 @@ export function recipeSummaryForDeployment(
|
|||||||
)
|
)
|
||||||
.get(id) as { recipe_summary: string | null; blueprint_name: string | null } | undefined;
|
.get(id) as { recipe_summary: string | null; blueprint_name: string | null } | undefined;
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
if (row.recipe_summary !== null)
|
if (row.recipe_summary !== null) {
|
||||||
return { recipe: JSON.parse(row.recipe_summary), blueprintName: row.blueprint_name };
|
const saved: LaunchRecipeSummary = JSON.parse(row.recipe_summary);
|
||||||
|
const summary = withHistoricalReleaseVersions(saved);
|
||||||
|
if (summary !== saved)
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
'UPDATE server_launch_recipes SET recipe_summary = ? WHERE deployment_id = ?'
|
||||||
|
)
|
||||||
|
.run(JSON.stringify(summary), id);
|
||||||
|
return { recipe: summary, blueprintName: row.blueprint_name };
|
||||||
|
}
|
||||||
// Old snapshots remain readable. Populate on first access, without making
|
// Old snapshots remain readable. Populate on first access, without making
|
||||||
// controller startup scan and decode every retained plugin in its history.
|
// controller startup scan and decode every retained plugin in its history.
|
||||||
const launch = recipeForDeployment(id)!;
|
const launch = recipeForDeployment(id)!;
|
||||||
const summary = summarizeRecipe(launch.recipe);
|
const summary = withHistoricalReleaseVersions(summarizeRecipe(launch.recipe));
|
||||||
db()
|
db()
|
||||||
.prepare('UPDATE server_launch_recipes SET recipe_summary = ? WHERE deployment_id = ?')
|
.prepare('UPDATE server_launch_recipes SET recipe_summary = ? WHERE deployment_id = ?')
|
||||||
.run(JSON.stringify(summary), id);
|
.run(JSON.stringify(summary), id);
|
||||||
return { recipe: summary, blueprintName: row.blueprint_name };
|
return { recipe: summary, blueprintName: row.blueprint_name };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Restore display metadata from the exact retained upload, preserving the captured recipe. */
|
||||||
|
function withHistoricalReleaseVersions(summary: LaunchRecipeSummary): LaunchRecipeSummary {
|
||||||
|
const files = summary.files.map((file) => {
|
||||||
|
if (file.releaseVersion !== undefined || file.versionId === null) return file;
|
||||||
|
const version = getVersionMetadata(file.versionId);
|
||||||
|
if (!version || version.version !== file.version || version.sha256 !== file.sha256)
|
||||||
|
return file;
|
||||||
|
return { ...file, releaseVersion: version.release_version };
|
||||||
|
});
|
||||||
|
return files.some((file, index) => file !== summary.files[index])
|
||||||
|
? { ...summary, files }
|
||||||
|
: summary;
|
||||||
|
}
|
||||||
|
|
||||||
export function recipeForDeployment(
|
export function recipeForDeployment(
|
||||||
id: string
|
id: string
|
||||||
): { recipe: LaunchRecipePlan; blueprintName: string | null } | null {
|
): { recipe: LaunchRecipePlan; blueprintName: string | null } | null {
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ function readOperations(now: number, server?: string): FleetOperation[] {
|
|||||||
? 'Completed with warnings'
|
? 'Completed with warnings'
|
||||||
: 'Complete'
|
: 'Complete'
|
||||||
: state === 'attention'
|
: state === 'attention'
|
||||||
? `${actionLabels[row.action as ProvisioningAction]} needs attention`
|
? `${actionLabels[row.action as ProvisioningAction]} operation needs attention`
|
||||||
: row.stage,
|
: row.stage,
|
||||||
detail: row.error ?? row.stage,
|
detail: row.error ?? row.stage,
|
||||||
href: `/provisioning/${encodeURIComponent(row.id)}`
|
href: `/provisioning/${encodeURIComponent(row.id)}`
|
||||||
|
|||||||
@@ -192,12 +192,17 @@ export function getVersion(id: number): ScriptVersionRow | undefined {
|
|||||||
ScriptVersionRow | undefined;
|
ScriptVersionRow | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScriptVersionMetadata = Pick<ScriptVersionRow, 'id' | 'version' | 'sha256' | 'size'>;
|
export type ScriptVersionMetadata = Pick<
|
||||||
|
ScriptVersionRow,
|
||||||
|
'id' | 'version' | 'release_version' | 'sha256' | 'size'
|
||||||
|
>;
|
||||||
|
|
||||||
/** Planning a launch never needs to load potentially large script or plugin bytes. */
|
/** Planning a launch never needs to load potentially large script or plugin bytes. */
|
||||||
export function getVersionMetadata(id: number): ScriptVersionMetadata | undefined {
|
export function getVersionMetadata(id: number): ScriptVersionMetadata | undefined {
|
||||||
return db()
|
return db()
|
||||||
.prepare('SELECT id, version, sha256, size FROM script_versions WHERE id = ?')
|
.prepare(
|
||||||
|
'SELECT id, version, release_version, sha256, size FROM script_versions WHERE id = ?'
|
||||||
|
)
|
||||||
.get(id) as ScriptVersionMetadata | undefined;
|
.get(id) as ScriptVersionMetadata | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function applyActivationFiles(
|
|||||||
): void {
|
): void {
|
||||||
const changes = db()
|
const changes = db()
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT f.*, ${blobSize('f.content_hash')} AS size, v.version FROM staged_file_changes f
|
`SELECT f.*, ${blobSize('f.content_hash')} AS size, v.version, v.release_version FROM staged_file_changes f
|
||||||
JOIN provisioning_sources s ON s.kind=f.source_kind AND s.source_id=f.source_id AND s.server=f.server
|
JOIN provisioning_sources s ON s.kind=f.source_kind AND s.source_id=f.source_id AND s.server=f.server
|
||||||
LEFT JOIN script_versions v ON v.id=f.script_version_id
|
LEFT JOIN script_versions v ON v.id=f.script_version_id
|
||||||
WHERE s.operation_id=? AND f.state='pending' ORDER BY f.id`
|
WHERE s.operation_id=? AND f.state='pending' ORDER BY f.id`
|
||||||
@@ -21,6 +21,7 @@ export function applyActivationFiles(
|
|||||||
content_hash: string | null;
|
content_hash: string | null;
|
||||||
size: number;
|
size: number;
|
||||||
version: number | null;
|
version: number | null;
|
||||||
|
release_version: string | null;
|
||||||
})[];
|
})[];
|
||||||
for (const change of changes) {
|
for (const change of changes) {
|
||||||
assertPublicFileTarget(change.server, change.kind, change.rel_path);
|
assertPublicFileTarget(change.server, change.kind, change.rel_path);
|
||||||
@@ -36,6 +37,7 @@ export function applyActivationFiles(
|
|||||||
path: change.rel_path,
|
path: change.rel_path,
|
||||||
versionId: change.script_version_id,
|
versionId: change.script_version_id,
|
||||||
version: change.version,
|
version: change.version,
|
||||||
|
releaseVersion: change.release_version,
|
||||||
sha256: change.content_hash,
|
sha256: change.content_hash,
|
||||||
size: change.size,
|
size: change.size,
|
||||||
...(includeContent
|
...(includeContent
|
||||||
|
|||||||
@@ -38,11 +38,17 @@ export function captureMoveRecipe(server: string): LaunchRecipePlan {
|
|||||||
? undefined
|
? undefined
|
||||||
: (db()
|
: (db()
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT v.version,v.kind,s.name
|
`SELECT v.version,v.release_version,v.kind,s.name
|
||||||
FROM script_versions v JOIN scripts s ON s.id=v.script_id WHERE v.id=?`
|
FROM script_versions v JOIN scripts s ON s.id=v.script_id WHERE v.id=?`
|
||||||
)
|
)
|
||||||
.get(file.script_version_id) as
|
.get(file.script_version_id) as
|
||||||
{ version: number; kind: string; name: string } | undefined);
|
| {
|
||||||
|
version: number;
|
||||||
|
release_version: string | null;
|
||||||
|
kind: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
| undefined);
|
||||||
const kind = version?.kind === 'baseline' ? 'config' : (version?.kind ?? before?.kind);
|
const kind = version?.kind === 'baseline' ? 'config' : (version?.kind ?? before?.kind);
|
||||||
if (!['script', 'plugin', 'config'].includes(kind ?? ''))
|
if (!['script', 'plugin', 'config'].includes(kind ?? ''))
|
||||||
throw new Error(`The deployed file type is unavailable for ${file.rel_path}.`);
|
throw new Error(`The deployed file type is unavailable for ${file.rel_path}.`);
|
||||||
@@ -59,6 +65,7 @@ export function captureMoveRecipe(server: string): LaunchRecipePlan {
|
|||||||
path: file.rel_path,
|
path: file.rel_path,
|
||||||
versionId: file.script_version_id,
|
versionId: file.script_version_id,
|
||||||
version: version?.version ?? null,
|
version: version?.version ?? null,
|
||||||
|
releaseVersion: version?.release_version ?? null,
|
||||||
sha256: file.sha256,
|
sha256: file.sha256,
|
||||||
size: file.size
|
size: file.size
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1149,7 +1149,8 @@ function sameLaunchFiles(first: LaunchRecipePlan, second: LaunchRecipePlan): boo
|
|||||||
expectedMap: recipe.expectedMap,
|
expectedMap: recipe.expectedMap,
|
||||||
configFile: recipe.configFile,
|
configFile: recipe.configFile,
|
||||||
configSource: recipe.configSource,
|
configSource: recipe.configSource,
|
||||||
files: recipe.files
|
// Older frozen recipes lack release labels; display metadata does not change a launch.
|
||||||
|
files: recipe.files.map(({ releaseVersion: _releaseVersion, ...file }) => file)
|
||||||
});
|
});
|
||||||
return JSON.stringify(selected(first)) === JSON.stringify(selected(second));
|
return JSON.stringify(selected(first)) === JSON.stringify(selected(second));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
interface AuditEntry {
|
||||||
|
action: string;
|
||||||
|
targetId: string | null;
|
||||||
|
details: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionLabels: Record<string, string> = {
|
||||||
|
setup: 'Administrator created',
|
||||||
|
login: 'Signed in',
|
||||||
|
'login.failed': 'Sign-in failed',
|
||||||
|
logout: 'Signed out',
|
||||||
|
'sessions.revoked': 'Sessions ended',
|
||||||
|
'user.created': 'User created',
|
||||||
|
'user.updated': 'Access updated',
|
||||||
|
'password.changed': 'Password changed',
|
||||||
|
request: 'Action requested'
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestActions: Record<string, string> = {
|
||||||
|
'POST /api/configs/inspect': 'Inspect config',
|
||||||
|
'POST /api/scripts/inspect': 'Inspect script',
|
||||||
|
'POST /api/servers': 'Create server',
|
||||||
|
'DELETE /api/servers/[name]': 'Remove server',
|
||||||
|
'POST /api/servers/[name]/probe-rcon': 'Check server connection',
|
||||||
|
'POST /api/users': 'Create user',
|
||||||
|
'PATCH /api/users/[id]': 'Update user access',
|
||||||
|
'POST /api/users/[id]/revoke-sessions': 'Sign out sessions',
|
||||||
|
'POST /api/auth/logout': 'Sign out',
|
||||||
|
'POST /api/auth/password': 'Change password',
|
||||||
|
'PUT /api/settings': 'Save settings',
|
||||||
|
'PATCH /api/settings': 'Save settings',
|
||||||
|
'POST /api/settings/retention/preview': 'Preview data cleanup',
|
||||||
|
'POST /api/plugins/github/import': 'Import plugin',
|
||||||
|
'PUT /api/server-keys': 'Save server key',
|
||||||
|
'DELETE /api/server-keys': 'Remove server key'
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestAreas: Record<string, string> = {
|
||||||
|
auth: 'account',
|
||||||
|
autoscaling: 'autoscaling',
|
||||||
|
bank: 'bank',
|
||||||
|
betas: 'beta',
|
||||||
|
blueprints: 'blueprint',
|
||||||
|
campaigns: 'campaign',
|
||||||
|
configs: 'config',
|
||||||
|
'deployment-collections': 'server collection',
|
||||||
|
deployments: 'campaign',
|
||||||
|
'game-files': 'game files',
|
||||||
|
hosts: 'host',
|
||||||
|
maintenance: 'maintenance',
|
||||||
|
moderation: 'moderation',
|
||||||
|
plugins: 'plugin',
|
||||||
|
plutonium: 'game update',
|
||||||
|
provisioning: 'server operation',
|
||||||
|
rebalancing: 'rebalancing',
|
||||||
|
scripts: 'script',
|
||||||
|
'server-keys': 'server key',
|
||||||
|
servers: 'server',
|
||||||
|
settings: 'settings',
|
||||||
|
users: 'user',
|
||||||
|
watchdog: 'recovery'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function auditPresentation(event: AuditEntry): {
|
||||||
|
label: string;
|
||||||
|
requestAction: string | null;
|
||||||
|
details: string;
|
||||||
|
} {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(event.details);
|
||||||
|
} catch {
|
||||||
|
parsed = null;
|
||||||
|
}
|
||||||
|
const fields =
|
||||||
|
parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||||
|
? (parsed as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const request = event.action === 'request';
|
||||||
|
const method = typeof fields?.method === 'string' ? fields.method.toUpperCase() : '';
|
||||||
|
const route = event.targetId ?? (typeof fields?.path === 'string' ? fields.path : '');
|
||||||
|
const area = route.startsWith('/api/') ? requestAreas[route.split('/')[2]] : undefined;
|
||||||
|
// Older entries only recorded the route and HTTP method, so body-driven
|
||||||
|
// actions cannot safely be inferred more precisely than their feature area.
|
||||||
|
const requestAction = request
|
||||||
|
? (requestActions[`${method} ${route}`] ?? (area ? `Manage ${area}` : 'Other action'))
|
||||||
|
: null;
|
||||||
|
const details = request
|
||||||
|
? JSON.stringify(
|
||||||
|
{ ...(fields ?? { recordedDetails: event.details }), routeId: event.targetId },
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
: fields
|
||||||
|
? Object.keys(fields).length
|
||||||
|
? JSON.stringify(fields, null, 2)
|
||||||
|
: ''
|
||||||
|
: event.details;
|
||||||
|
return { label: actionLabels[event.action] ?? event.action, requestAction, details };
|
||||||
|
}
|
||||||
+37
-10
@@ -1,30 +1,57 @@
|
|||||||
import { statusPresentation, type StatusTone } from './status.js';
|
import { statusPresentation, type StatusTone } from './status.js';
|
||||||
import { plural } from './plural.js';
|
import { plural } from './plural.js';
|
||||||
|
import { artifactVersionLabel } from '../artifact-version.js';
|
||||||
|
|
||||||
export type CampaignServerGroup = 'attention' | 'processing' | 'queued' | 'complete' | 'inactive';
|
export type CampaignServerGroup = 'attention' | 'processing' | 'queued' | 'complete' | 'inactive';
|
||||||
|
|
||||||
export interface CampaignSummary {
|
interface CampaignScriptSummary {
|
||||||
|
script_name: string | null;
|
||||||
|
script_version: number | null;
|
||||||
|
script_release_version: string | null;
|
||||||
|
script_kind: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CampaignSummary extends CampaignScriptSummary {
|
||||||
action: string;
|
action: string;
|
||||||
beta_id: number | null;
|
beta_id: number | null;
|
||||||
rolled_back_by: string | null;
|
rolled_back_by: string | null;
|
||||||
script_name: string | null;
|
scripts: CampaignScriptSummary[];
|
||||||
script_version: number | null;
|
|
||||||
scripts: { script_name: string | null; script_version: number | null }[];
|
|
||||||
servers: { restart_state: string; server_deleted_at?: string | null }[];
|
servers: { restart_state: string; server_deleted_at?: string | null }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function campaignActionLabel(action: string): string {
|
||||||
|
switch (action) {
|
||||||
|
case 'drift':
|
||||||
|
return 'Maintenance repair';
|
||||||
|
case 'promote':
|
||||||
|
return 'Promote';
|
||||||
|
case 'rollback':
|
||||||
|
return 'Rollback';
|
||||||
|
default:
|
||||||
|
return 'Update';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function campaignTitle(campaign: CampaignSummary): string {
|
export function campaignTitle(campaign: CampaignSummary): string {
|
||||||
const action =
|
const action = campaignActionLabel(campaign.action);
|
||||||
campaign.action === 'drift'
|
|
||||||
? 'Maintenance repair'
|
|
||||||
: campaign.action.charAt(0).toUpperCase() + campaign.action.slice(1);
|
|
||||||
if (campaign.action === 'drift') return action;
|
if (campaign.action === 'drift') return action;
|
||||||
if (campaign.action === 'rollback' && campaign.beta_id)
|
if (campaign.action === 'rollback' && campaign.beta_id)
|
||||||
return `${action} · Beta #${campaign.beta_id}`;
|
return `${action} · Beta #${campaign.beta_id}`;
|
||||||
if (campaign.scripts.length > 1) return `${action} · ${campaign.scripts.length} scripts`;
|
if (campaign.scripts.length > 1) return `${action} · ${campaign.scripts.length} scripts`;
|
||||||
const script = campaign.scripts[0] ?? campaign;
|
const script = campaign.scripts[0] ?? campaign;
|
||||||
if (script.script_name)
|
if (script.script_name) {
|
||||||
return `${action} · ${script.script_name}${script.script_version !== null ? ` v${script.script_version}` : ''}`;
|
const version =
|
||||||
|
script.script_version === null
|
||||||
|
? ''
|
||||||
|
: ` ${artifactVersionLabel(
|
||||||
|
{
|
||||||
|
version: script.script_version,
|
||||||
|
release_version: script.script_release_version
|
||||||
|
},
|
||||||
|
script.script_kind ?? 'script'
|
||||||
|
)}`;
|
||||||
|
return `${action} · ${script.script_name}${version}`;
|
||||||
|
}
|
||||||
return campaign.beta_id ? `${action} · Beta #${campaign.beta_id}` : action;
|
return campaign.beta_id ? `${action} · Beta #${campaign.beta_id}` : action;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
} from '$ui/collection-summary.js';
|
} from '$ui/collection-summary.js';
|
||||||
import { serverTitle } from '$ui/server-labels.js';
|
import { serverTitle } from '$ui/server-labels.js';
|
||||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
|
import { launchFileVersionLabel } from '$lib/artifact-version.js';
|
||||||
import type { NativeHostSelectionStatus } from '$ui/native-game-files.js';
|
import type { NativeHostSelectionStatus } from '$ui/native-game-files.js';
|
||||||
import type { NativeDeploymentSelection } from '$lib/hosts.js';
|
import type { NativeDeploymentSelection } from '$lib/hosts.js';
|
||||||
import type { ZombieGame } from '$lib/map-aliases.js';
|
import type { ZombieGame } from '$lib/map-aliases.js';
|
||||||
@@ -609,9 +610,7 @@
|
|||||||
<ul class="file-list">
|
<ul class="file-list">
|
||||||
{#each entry.recipe.files as file}<li>
|
{#each entry.recipe.files as file}<li>
|
||||||
<strong>{file.name}</strong><span
|
<strong>{file.name}</strong><span
|
||||||
>{file.version === null
|
>{launchFileVersionLabel(file)} · {file.kind}</span
|
||||||
? 'Generated'
|
|
||||||
: `v${file.version}`} · {file.kind}</span
|
|
||||||
><code>{file.path}</code>
|
><code>{file.path}</code>
|
||||||
</li>{/each}
|
</li>{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { createServerLaunchController, type ServerLaunchState } from '$ui/server-launch.js';
|
import { createServerLaunchController, type ServerLaunchState } from '$ui/server-launch.js';
|
||||||
import { serverTitle } from '$ui/server-labels.js';
|
import { serverTitle } from '$ui/server-labels.js';
|
||||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
|
import { launchFileVersionLabel } from '$lib/artifact-version.js';
|
||||||
import type { NativeHostSelectionStatus } from '$ui/native-game-files.js';
|
import type { NativeHostSelectionStatus } from '$ui/native-game-files.js';
|
||||||
import type { NativeDeploymentSelection } from '$lib/hosts.js';
|
import type { NativeDeploymentSelection } from '$lib/hosts.js';
|
||||||
import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js';
|
import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js';
|
||||||
@@ -378,7 +379,7 @@
|
|||||||
{#each preview.recipe.files as file}<li>
|
{#each preview.recipe.files as file}<li>
|
||||||
<div>
|
<div>
|
||||||
<strong>{file.name}</strong><span
|
<strong>{file.name}</strong><span
|
||||||
>{file.version === null ? 'Generated' : `v${file.version}`} ·
|
>{launchFileVersionLabel(file)} ·
|
||||||
{file.kind}</span
|
{file.kind}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||||
|
import { launchFileVersionLabel } from '$lib/artifact-version.js';
|
||||||
import type { LaunchRecipeSummary } from '$lib/deployment-plan.js';
|
import type { LaunchRecipeSummary } from '$lib/deployment-plan.js';
|
||||||
let { recipe, historical = false }: { recipe: LaunchRecipeSummary; historical?: boolean } =
|
let { recipe, historical = false }: { recipe: LaunchRecipeSummary; historical?: boolean } =
|
||||||
$props();
|
$props();
|
||||||
@@ -36,7 +37,7 @@
|
|||||||
{#each recipe.files as file}<li>
|
{#each recipe.files as file}<li>
|
||||||
<div>
|
<div>
|
||||||
<strong>{file.name}</strong><span
|
<strong>{file.name}</strong><span
|
||||||
>{file.version !== null ? `v${file.version}` : 'Generated'} · {file.kind}
|
>{launchFileVersionLabel(file)} · {file.kind}
|
||||||
· {file.size.toLocaleString()} bytes</span
|
· {file.size.toLocaleString()} bytes</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ function healthEvidence(
|
|||||||
if (hostOffline)
|
if (hostOffline)
|
||||||
return {
|
return {
|
||||||
state: 'host-offline',
|
state: 'host-offline',
|
||||||
reason: `${host.name ? `${host.name}'s host agent` : 'The host agent'} is unavailable. The game may still be running.`,
|
reason: `The host agent${host.name ? ` on ${host.name}` : ''} is unavailable. The game may still be running.`,
|
||||||
actionLabel: hostId ? 'Check host' : 'Open diagnostics',
|
actionLabel: hostId ? 'Check host' : 'Open diagnostics',
|
||||||
actionHref: hostId ? `/hosts/${encodeURIComponent(hostId)}` : diagnostics,
|
actionHref: hostId ? `/hosts/${encodeURIComponent(hostId)}` : diagnostics,
|
||||||
since: observedAt(host.lastHeartbeat, now)
|
since: observedAt(host.lastHeartbeat, now)
|
||||||
|
|||||||
@@ -70,8 +70,9 @@ export async function GET({ url }: { url: URL }) {
|
|||||||
.prepare(
|
.prepare(
|
||||||
`SELECT d.id, d.action, d.started_at, d.finished_at, d.triggered_by,
|
`SELECT d.id, d.action, d.started_at, d.finished_at, d.triggered_by,
|
||||||
d.script_id, d.script_version_id, d.prior_version_id, d.beta_id, d.rolled_back_by,
|
d.script_id, d.script_version_id, d.prior_version_id, d.beta_id, d.rolled_back_by,
|
||||||
s.name AS script_name, sv.version AS script_version,
|
s.name AS script_name, s.kind AS script_kind,
|
||||||
pv.version AS prior_version
|
sv.version AS script_version, sv.release_version AS script_release_version,
|
||||||
|
pv.version AS prior_version, pv.release_version AS prior_release_version
|
||||||
FROM deployments d
|
FROM deployments d
|
||||||
LEFT JOIN scripts s ON s.id = d.script_id
|
LEFT JOIN scripts s ON s.id = d.script_id
|
||||||
LEFT JOIN script_versions sv ON sv.id = d.script_version_id
|
LEFT JOIN script_versions sv ON sv.id = d.script_version_id
|
||||||
@@ -92,8 +93,11 @@ export async function GET({ url }: { url: URL }) {
|
|||||||
beta_id: number | null;
|
beta_id: number | null;
|
||||||
rolled_back_by: string | null;
|
rolled_back_by: string | null;
|
||||||
script_name: string | null;
|
script_name: string | null;
|
||||||
|
script_kind: string | null;
|
||||||
script_version: number | null;
|
script_version: number | null;
|
||||||
|
script_release_version: string | null;
|
||||||
prior_version: number | null;
|
prior_version: number | null;
|
||||||
|
prior_release_version: string | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
const ids = deployments.map((deployment) => deployment.id);
|
const ids = deployments.map((deployment) => deployment.id);
|
||||||
@@ -126,8 +130,9 @@ export async function GET({ url }: { url: URL }) {
|
|||||||
|
|
||||||
const scriptRows = db()
|
const scriptRows = db()
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT ds.deployment_id, ds.script_id, s.name AS script_name,
|
`SELECT ds.deployment_id, ds.script_id, s.name AS script_name, ds.kind AS script_kind,
|
||||||
sv.version AS script_version, pv.version AS prior_version
|
sv.version AS script_version, sv.release_version AS script_release_version,
|
||||||
|
pv.version AS prior_version, pv.release_version AS prior_release_version
|
||||||
FROM deployment_scripts ds
|
FROM deployment_scripts ds
|
||||||
LEFT JOIN scripts s ON s.id = ds.script_id
|
LEFT JOIN scripts s ON s.id = ds.script_id
|
||||||
LEFT JOIN script_versions sv ON sv.id = ds.script_version_id
|
LEFT JOIN script_versions sv ON sv.id = ds.script_version_id
|
||||||
@@ -138,8 +143,11 @@ export async function GET({ url }: { url: URL }) {
|
|||||||
deployment_id: string;
|
deployment_id: string;
|
||||||
script_id: number;
|
script_id: number;
|
||||||
script_name: string | null;
|
script_name: string | null;
|
||||||
|
script_kind: string | null;
|
||||||
script_version: number | null;
|
script_version: number | null;
|
||||||
|
script_release_version: string | null;
|
||||||
prior_version: number | null;
|
prior_version: number | null;
|
||||||
|
prior_release_version: string | null;
|
||||||
}>;
|
}>;
|
||||||
const scriptsByDep = new Map<string, typeof scriptRows>();
|
const scriptsByDep = new Map<string, typeof scriptRows>();
|
||||||
for (const s of scriptRows) {
|
for (const s of scriptRows) {
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ export async function GET({ params }: { params: { id: string } }) {
|
|||||||
|
|
||||||
const scripts = db()
|
const scripts = db()
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT ds.script_id, s.name AS script_name, s.target_rel_path,
|
`SELECT ds.script_id, s.name AS script_name, s.target_rel_path, ds.kind AS script_kind,
|
||||||
ds.script_version_id, sv.version AS script_version,
|
ds.script_version_id, sv.version AS script_version, sv.release_version AS script_release_version,
|
||||||
ds.prior_version_id, pv.version AS prior_version
|
ds.prior_version_id, pv.version AS prior_version, pv.release_version AS prior_release_version
|
||||||
FROM deployment_scripts ds
|
FROM deployment_scripts ds
|
||||||
LEFT JOIN scripts s ON s.id = ds.script_id
|
LEFT JOIN scripts s ON s.id = ds.script_id
|
||||||
LEFT JOIN script_versions sv ON sv.id = ds.script_version_id
|
LEFT JOIN script_versions sv ON sv.id = ds.script_version_id
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ export async function GET({ params, url }: { params: { id: string }; url?: URL }
|
|||||||
|
|
||||||
const scripts = db()
|
const scripts = db()
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT ds.script_id, s.name AS script_name, s.target_rel_path,
|
`SELECT ds.script_id, s.name AS script_name, s.target_rel_path, ds.kind AS script_kind,
|
||||||
ds.script_version_id, sv.version AS script_version,
|
ds.script_version_id, sv.version AS script_version, sv.release_version AS script_release_version,
|
||||||
ds.prior_version_id, pv.version AS prior_version
|
ds.prior_version_id, pv.version AS prior_version, pv.release_version AS prior_release_version
|
||||||
FROM deployment_scripts ds
|
FROM deployment_scripts ds
|
||||||
LEFT JOIN scripts s ON s.id = ds.script_id
|
LEFT JOIN scripts s ON s.id = ds.script_id
|
||||||
LEFT JOIN script_versions sv ON sv.id = ds.script_version_id
|
LEFT JOIN script_versions sv ON sv.id = ds.script_version_id
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { pollVisible } from '$ui/poll.js';
|
import { pollVisible } from '$ui/poll.js';
|
||||||
|
import { plural } from '$ui/plural.js';
|
||||||
import {
|
import {
|
||||||
campaignOutcome,
|
campaignOutcome,
|
||||||
campaignTitle,
|
campaignTitle,
|
||||||
@@ -221,7 +222,7 @@
|
|||||||
{#if showProgress}<progress
|
{#if showProgress}<progress
|
||||||
max={campaign.servers.length || 1}
|
max={campaign.servers.length || 1}
|
||||||
value={outcome.completed}
|
value={outcome.completed}
|
||||||
aria-label={`${outcome.completed} of ${campaign.servers.length} servers complete`}
|
aria-label={`${outcome.completed} of ${campaign.servers.length} ${plural(campaign.servers.length, 'server')} complete`}
|
||||||
></progress>{/if}
|
></progress>{/if}
|
||||||
</div></td
|
</div></td
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -19,7 +19,11 @@
|
|||||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||||
import OperationHistory from '$ui/components/OperationHistory.svelte';
|
import OperationHistory from '$ui/components/OperationHistory.svelte';
|
||||||
import type { ActivityEvent } from '$lib/operation-flow.js';
|
import type { ActivityEvent } from '$lib/operation-flow.js';
|
||||||
import { campaignServerStatus, type CampaignServerGroup } from '$ui/campaigns.js';
|
import {
|
||||||
|
campaignActionLabel,
|
||||||
|
campaignServerStatus,
|
||||||
|
type CampaignServerGroup
|
||||||
|
} from '$ui/campaigns.js';
|
||||||
import { serverIdentity } from '$ui/server-labels.js';
|
import { serverIdentity } from '$ui/server-labels.js';
|
||||||
|
|
||||||
interface FileRow {
|
interface FileRow {
|
||||||
@@ -86,6 +90,9 @@
|
|||||||
let filter = $state<'all' | CampaignServerGroup>('all');
|
let filter = $state<'all' | CampaignServerGroup>('all');
|
||||||
let expandedServers = $state<string[]>([]);
|
let expandedServers = $state<string[]>([]);
|
||||||
let copyStatus = $state('');
|
let copyStatus = $state('');
|
||||||
|
const title = $derived(
|
||||||
|
detail ? `${campaignActionLabel(detail.deployment.action)} campaign` : 'Campaign'
|
||||||
|
);
|
||||||
const serverRows = $derived(
|
const serverRows = $derived(
|
||||||
(detail?.servers ?? []).map((server) => ({
|
(detail?.servers ?? []).map((server) => ({
|
||||||
server,
|
server,
|
||||||
@@ -275,12 +282,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head
|
<svelte:head><title>{title} | Electric Cherry</title></svelte:head>
|
||||||
><title
|
|
||||||
>{detail ? `${detail.deployment.action} campaign · ${detail.deployment.id}` : 'Campaign'} | Electric
|
|
||||||
Cherry</title
|
|
||||||
></svelte:head
|
|
||||||
>
|
|
||||||
{#if loadError}<p role="alert" class="err">
|
{#if loadError}<p role="alert" class="err">
|
||||||
{loadError} <button onclick={loadDetail}>Retry</button>
|
{loadError} <button onclick={loadDetail}>Retry</button>
|
||||||
</p>{/if}
|
</p>{/if}
|
||||||
@@ -288,10 +290,10 @@
|
|||||||
{#if detail}
|
{#if detail}
|
||||||
{@const action = detail.deployment.action}
|
{@const action = detail.deployment.action}
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<SectionHeader title={`${action.charAt(0).toUpperCase() + action.slice(1)} campaign`}>
|
<SectionHeader {title}>
|
||||||
{#snippet crumbs()}
|
{#snippet crumbs()}
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
items={[{ label: 'Campaigns', href: '/campaigns' }, { label: action }]}
|
items={[{ label: 'Campaigns', href: '/campaigns' }, { label: title }]}
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
|
|||||||
@@ -542,10 +542,15 @@
|
|||||||
>{/if}
|
>{/if}
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
Published revision: <strong
|
{data.runtime.release.error && data.runtime.release.revision !== null
|
||||||
|
? 'Last known published revision'
|
||||||
|
: 'Published revision'}:
|
||||||
|
<strong
|
||||||
>{data.runtime.release.revision
|
>{data.runtime.release.revision
|
||||||
? `r${data.runtime.release.revision}`
|
? `r${data.runtime.release.revision}`
|
||||||
: 'Unknown'}</strong
|
: data.runtime.release.error
|
||||||
|
? 'Unavailable'
|
||||||
|
: 'Not recorded'}</strong
|
||||||
>
|
>
|
||||||
·
|
·
|
||||||
<a href={PLUTONIUM_MANIFEST_URL} target="_blank" rel="noreferrer"
|
<a href={PLUTONIUM_MANIFEST_URL} target="_blank" rel="noreferrer"
|
||||||
@@ -559,11 +564,26 @@
|
|||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
{#if data.runtime.release.checkedAt}<p class="muted">
|
{#if data.runtime.release.checkedAt}<p class="muted">
|
||||||
Checked <RelativeTime value={data.runtime.release.checkedAt} />
|
{data.runtime.release.error ? 'Last attempted' : 'Checked'}
|
||||||
</p>{/if}
|
<RelativeTime value={data.runtime.release.checkedAt} />
|
||||||
{#if data.runtime.release.error}<p class="ui-error" role="alert">
|
|
||||||
{data.runtime.release.error} Displayed release information is cached.
|
|
||||||
</p>{/if}
|
</p>{/if}
|
||||||
|
{#if data.runtime.release.error}
|
||||||
|
<p class="ui-error" role="alert">
|
||||||
|
Could not refresh the published Plutonium release.
|
||||||
|
{data.runtime.release.revision !== null
|
||||||
|
? 'Showing the last known release; it may be out of date.'
|
||||||
|
: 'The published revision is unavailable. No previous release information is available.'}
|
||||||
|
{#if administrator}<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={() => act({ action: 'scan' })}>Retry checks</Button
|
||||||
|
>{/if}
|
||||||
|
</p>
|
||||||
|
<details>
|
||||||
|
<summary>Technical details</summary>
|
||||||
|
<pre>{data.runtime.release.error}</pre>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
Plutainer applies the available release at startup when auto-update is
|
Plutainer applies the available release at startup when auto-update is
|
||||||
enabled. Maintenance verifies the running revision after recovery.
|
enabled. Maintenance verifies the running revision after recovery.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
|
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
|
||||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||||
import ProvisioningStatusBadge from '$ui/components/ProvisioningStatusBadge.svelte';
|
import ProvisioningStatusBadge from '$ui/components/ProvisioningStatusBadge.svelte';
|
||||||
|
import CopyValue from '$ui/components/CopyValue.svelte';
|
||||||
|
|
||||||
let detail = $state<WatchdogDetail | null>(null);
|
let detail = $state<WatchdogDetail | null>(null);
|
||||||
let loadError = $state('');
|
let loadError = $state('');
|
||||||
@@ -160,7 +161,6 @@
|
|||||||
title={incident
|
title={incident
|
||||||
? `${incident.kind === 'drift' ? 'File drift' : 'Server health'} · ${serverTitle(incident.server)}`
|
? `${incident.kind === 'drift' ? 'File drift' : 'Server health'} · ${serverTitle(incident.server)}`
|
||||||
: 'Maintenance finding'}
|
: 'Maintenance finding'}
|
||||||
subtitle={incident?.id}
|
|
||||||
>
|
>
|
||||||
{#snippet crumbs()}<Breadcrumbs
|
{#snippet crumbs()}<Breadcrumbs
|
||||||
items={[{ label: 'Maintenance', href: '/maintenance' }, { label: 'Incident' }]}
|
items={[{ label: 'Maintenance', href: '/maintenance' }, { label: 'Incident' }]}
|
||||||
@@ -253,6 +253,15 @@
|
|||||||
)}>Open server logs</a
|
)}>Open server logs</a
|
||||||
>
|
>
|
||||||
</div>{/if}
|
</div>{/if}
|
||||||
|
<details>
|
||||||
|
<summary>Technical details</summary>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>Incident ID</dt>
|
||||||
|
<dd><CopyValue value={incident.id} label="incident ID" /></dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-labelledby="recovery-title">
|
<section aria-labelledby="recovery-title">
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
import Input from '$ui/components/primitives/Input.svelte';
|
import Input from '$ui/components/primitives/Input.svelte';
|
||||||
import Select from '$ui/components/primitives/Select.svelte';
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
import { libraryLimit, saveLibraryContext } from '$ui/library-context.js';
|
import { libraryLimit, saveLibraryContext } from '$ui/library-context.js';
|
||||||
|
import { plural } from '$ui/plural.js';
|
||||||
const editable = $derived(canOperate($page.data?.user?.role));
|
const editable = $derived(canOperate($page.data?.user?.role));
|
||||||
|
|
||||||
type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline';
|
type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline';
|
||||||
@@ -353,7 +354,7 @@
|
|||||||
</details>{/if}
|
</details>{/if}
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="list-heading">
|
<div class="list-heading">
|
||||||
<span>{visible.length} of {plugins.length} plugins</span
|
<span>{visible.length} of {plugins.length} {plural(plugins.length, 'plugin')}</span
|
||||||
>{#if visible.length && (search || game || status)}<Button
|
>{#if visible.length && (search || game || status)}<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
} from '$lib/script-library.js';
|
} from '$lib/script-library.js';
|
||||||
import { zombieMaps } from '$lib/map-aliases.js';
|
import { zombieMaps } from '$lib/map-aliases.js';
|
||||||
import { libraryLimit, saveLibraryContext } from '$ui/library-context.js';
|
import { libraryLimit, saveLibraryContext } from '$ui/library-context.js';
|
||||||
|
import { plural } from '$ui/plural.js';
|
||||||
const editable = $derived(canOperate($page.data?.user?.role));
|
const editable = $derived(canOperate($page.data?.user?.role));
|
||||||
|
|
||||||
let limit = $state(25);
|
let limit = $state(25);
|
||||||
@@ -257,7 +258,7 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-heading">
|
<div class="list-heading">
|
||||||
<span>{visible.length} of {scripts.length} scripts</span>
|
<span>{visible.length} of {scripts.length} {plural(scripts.length, 'script')}</span>
|
||||||
{#if failedCount && status !== 'failed'}<button
|
{#if failedCount && status !== 'failed'}<button
|
||||||
class="text-button"
|
class="text-button"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { invalidateAll } from '$app/navigation';
|
import { invalidateAll } from '$app/navigation';
|
||||||
import type { AuthUser, UserRole } from '$lib/auth.js';
|
import type { AuthUser, UserRole } from '$lib/auth.js';
|
||||||
import { confirmAction } from '$ui/confirm.js';
|
import { confirmAction } from '$ui/confirm.js';
|
||||||
|
import { auditPresentation } from '$ui/audit.js';
|
||||||
import Button from '$ui/components/primitives/Button.svelte';
|
import Button from '$ui/components/primitives/Button.svelte';
|
||||||
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||||
@@ -101,24 +102,6 @@
|
|||||||
auditLoading = false;
|
auditLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const actionLabels: Record<string, string> = {
|
|
||||||
setup: 'Administrator created',
|
|
||||||
login: 'Signed in',
|
|
||||||
'login.failed': 'Sign-in failed',
|
|
||||||
logout: 'Signed out',
|
|
||||||
'sessions.revoked': 'Sessions ended',
|
|
||||||
'user.created': 'User created',
|
|
||||||
'user.updated': 'Access updated',
|
|
||||||
'password.changed': 'Password changed'
|
|
||||||
};
|
|
||||||
function auditDetails(value: string): string {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(value);
|
|
||||||
return Object.keys(parsed).length ? JSON.stringify(parsed, null, 2) : '';
|
|
||||||
} catch {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (canViewUsers) void Promise.all([load(), loadAudit()]);
|
if (canViewUsers) void Promise.all([load(), loadAudit()]);
|
||||||
else {
|
else {
|
||||||
@@ -339,7 +322,7 @@
|
|||||||
<div class="audit-heading">
|
<div class="audit-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2 id="audit-title">Account activity</h2>
|
<h2 id="audit-title">Account activity</h2>
|
||||||
<p class="muted">The latest 200 sign-in and account events.</p>
|
<p class="muted">The latest 200 sign-in, account, and action events.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="ghost" disabled={auditLoading} onclick={loadAudit}
|
<Button size="sm" variant="ghost" disabled={auditLoading} onclick={loadAudit}
|
||||||
>Refresh activity</Button
|
>Refresh activity</Button
|
||||||
@@ -354,27 +337,27 @@
|
|||||||
<table>
|
<table>
|
||||||
<thead
|
<thead
|
||||||
><tr
|
><tr
|
||||||
><th>Time</th><th>Event</th><th>By</th><th>Account</th><th
|
><th>Time</th><th>Event</th><th>By</th><th>Account or action</th><th
|
||||||
>Details</th
|
>Details</th
|
||||||
></tr
|
></tr
|
||||||
></thead
|
></thead
|
||||||
><tbody>
|
><tbody>
|
||||||
{#each events as event (event.id)}{@const details = auditDetails(
|
{#each events as event (event.id)}{@const presentation =
|
||||||
event.details
|
auditPresentation(event)}<tr
|
||||||
)}<tr
|
|
||||||
><td class="event-time"
|
><td class="event-time"
|
||||||
><RelativeTime value={event.createdAt} /></td
|
><RelativeTime value={event.createdAt} /></td
|
||||||
><td>{actionLabels[event.action] ?? event.action}</td><td
|
><td>{presentation.label}</td><td
|
||||||
>{event.actor ? `@${event.actor}` : 'Unauthenticated'}</td
|
>{event.actor ? `@${event.actor}` : 'Unauthenticated'}</td
|
||||||
><td
|
><td
|
||||||
>{event.targetId
|
>{presentation.requestAction ??
|
||||||
|
(event.targetId
|
||||||
? (users.find((user) => user.id === event.targetId)
|
? (users.find((user) => user.id === event.targetId)
|
||||||
?.username ?? event.targetId)
|
?.username ?? event.targetId)
|
||||||
: '—'}</td
|
: '—')}</td
|
||||||
><td
|
><td
|
||||||
>{#if details}<details>
|
>{#if presentation.details}<details>
|
||||||
<summary>View details</summary>
|
<summary>Technical details</summary>
|
||||||
<pre>{details}</pre>
|
<pre>{presentation.details}</pre>
|
||||||
</details>{:else}—{/if}</td
|
</details>{:else}—{/if}</td
|
||||||
></tr
|
></tr
|
||||||
>{/each}
|
>{/each}
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { afterEach, expect, it, vi } from 'vitest';
|
||||||
|
import { flushSync, mount, unmount } from 'svelte';
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import MaintenancePage from '../../src/routes/maintenance/+page.svelte';
|
||||||
|
import type { MaintenanceOverview } from '../../src/lib/maintenance.js';
|
||||||
|
import type { PlutoniumRelease } from '../../src/lib/plutonium.js';
|
||||||
|
import type { UserRole } from '../../src/lib/auth.js';
|
||||||
|
|
||||||
|
const currentPage = writable({
|
||||||
|
url: new URL('http://localhost/maintenance'),
|
||||||
|
data: { user: { role: 'admin' as UserRole } }
|
||||||
|
});
|
||||||
|
vi.mock('$app/stores', () => ({
|
||||||
|
page: {
|
||||||
|
subscribe: (run: Parameters<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
|
vi.mock('../../src/lib/ui/poll.js', () => ({
|
||||||
|
pollVisible: (task: () => Promise<void>) => {
|
||||||
|
void task();
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let component: ReturnType<typeof mount> | undefined;
|
||||||
|
afterEach(async () => {
|
||||||
|
if (component) await unmount(component);
|
||||||
|
component = undefined;
|
||||||
|
document.body.replaceChildren();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function overview(release: Partial<PlutoniumRelease> = {}): MaintenanceOverview {
|
||||||
|
return {
|
||||||
|
policies: { paused: false, health: true, drift: true, updates: false },
|
||||||
|
recovery: { enabled: true, lastScanAt: null, scanError: null, incidents: [] },
|
||||||
|
runtime: {
|
||||||
|
release: {
|
||||||
|
revision: null,
|
||||||
|
checkedAt: '2026-10-17T12:00:00Z',
|
||||||
|
error: 'Updater manifest request failed (HTTP 503).',
|
||||||
|
...release
|
||||||
|
},
|
||||||
|
schedule: { enabled: false, time: '06:00', timeZone: 'UTC', nextAt: null },
|
||||||
|
servers: [],
|
||||||
|
jobs: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function show(role: UserRole = 'admin') {
|
||||||
|
currentPage.update((value) => ({ ...value, data: { user: { role } } }));
|
||||||
|
component = mount(MaintenancePage, { target: document.body });
|
||||||
|
flushSync();
|
||||||
|
await vi.waitFor(() => expect(document.querySelector('#updates')).not.toBeNull());
|
||||||
|
return document.querySelector<HTMLElement>('#updates')!;
|
||||||
|
}
|
||||||
|
const button = (panel: HTMLElement, label: string) =>
|
||||||
|
[...panel.querySelectorAll('button')].find((node) => node.textContent?.trim() === label);
|
||||||
|
|
||||||
|
it('shows unavailable release information on the first failed check and keeps diagnostics closed', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json(overview())));
|
||||||
|
const panel = await show();
|
||||||
|
const alert = panel.querySelector('[role="alert"]')!;
|
||||||
|
expect(alert.textContent).toContain('Could not refresh the published Plutonium release.');
|
||||||
|
expect(alert.textContent).toContain('The published revision is unavailable.');
|
||||||
|
expect(alert.textContent).toContain('No previous release information is available.');
|
||||||
|
expect(panel.querySelector('p strong')?.textContent).toBe('Unavailable');
|
||||||
|
expect(panel.textContent).not.toMatch(/cached|Showing the last known release/);
|
||||||
|
const details = panel.querySelector('details')!;
|
||||||
|
expect(details.open).toBe(false);
|
||||||
|
expect(details.textContent).toContain('Updater manifest request failed (HTTP 503).');
|
||||||
|
const primary = panel.cloneNode(true) as HTMLElement;
|
||||||
|
primary.querySelectorAll('details').forEach((node) => node.remove());
|
||||||
|
expect(primary.textContent).not.toContain('HTTP 503');
|
||||||
|
expect(panel.textContent).toContain('Last attempted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels a retained revision honestly and clears the failure after retry succeeds', async () => {
|
||||||
|
let resolve!: (response: Response) => void;
|
||||||
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(Response.json(overview({ revision: 5354 })))
|
||||||
|
.mockImplementationOnce(() => new Promise<Response>((done) => (resolve = done)));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
const panel = await show();
|
||||||
|
expect(panel.textContent).toContain('Last known published revision');
|
||||||
|
expect(panel.textContent).toContain('r5354');
|
||||||
|
expect(panel.querySelector('[role="alert"]')!.textContent).toContain(
|
||||||
|
'Showing the last known release; it may be out of date.'
|
||||||
|
);
|
||||||
|
button(panel, 'Retry checks')!.click();
|
||||||
|
flushSync();
|
||||||
|
expect(fetch).toHaveBeenLastCalledWith('/api/maintenance', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'scan' })
|
||||||
|
});
|
||||||
|
expect(button(panel, 'Retry checks')!.disabled).toBe(true);
|
||||||
|
resolve(
|
||||||
|
Response.json({ ...overview({ revision: 5355, error: null }), notice: 'Checks finished.' })
|
||||||
|
);
|
||||||
|
await vi.waitFor(() => expect(panel.querySelector('[role="alert"]')).toBeNull());
|
||||||
|
expect(panel.textContent).toContain('r5355');
|
||||||
|
expect(panel.textContent).toContain('Checked');
|
||||||
|
expect(panel.textContent).not.toMatch(/Last known|Last attempted|HTTP 503/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps release failure details available without offering an administrator check to viewers', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json(overview())));
|
||||||
|
const panel = await show('viewer');
|
||||||
|
expect(panel.querySelector('[role="alert"]')!.textContent).toContain(
|
||||||
|
'Could not refresh the published Plutonium release.'
|
||||||
|
);
|
||||||
|
expect(panel.querySelector('details')?.textContent).toContain('HTTP 503');
|
||||||
|
expect(button(panel, 'Retry checks')).toBeUndefined();
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { afterEach, expect, it, vi } from 'vitest';
|
||||||
|
import { flushSync, mount, unmount } from 'svelte';
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import UsersPage from '../../src/routes/settings/users/+page.svelte';
|
||||||
|
|
||||||
|
const page = writable({
|
||||||
|
data: { user: { id: 'admin', username: 'administrator', role: 'admin' } }
|
||||||
|
});
|
||||||
|
vi.mock('$app/stores', () => ({
|
||||||
|
page: { subscribe: (run: Parameters<typeof page.subscribe>[0]) => page.subscribe(run) }
|
||||||
|
}));
|
||||||
|
vi.mock('$app/navigation', () => ({ invalidateAll: vi.fn() }));
|
||||||
|
|
||||||
|
let component: ReturnType<typeof mount> | undefined;
|
||||||
|
afterEach(async () => {
|
||||||
|
if (component) await unmount(component);
|
||||||
|
component = undefined;
|
||||||
|
document.body.replaceChildren();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes requests without presenting routes as accounts and preserves their diagnostic evidence', async () => {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
action: 'request',
|
||||||
|
targetId: '/api/configs/inspect',
|
||||||
|
details: JSON.stringify({ method: 'POST', status: 200, path: '/api/configs/inspect' })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: 'request',
|
||||||
|
targetId: '/api/servers/[name]',
|
||||||
|
details: JSON.stringify({ method: 'DELETE', status: 202, path: '/api/servers/demo' })
|
||||||
|
},
|
||||||
|
{ action: 'request', targetId: '/api/provisioning', details: '{old malformed details' },
|
||||||
|
{ action: 'request', targetId: '/api/future/action', details: 'null' },
|
||||||
|
{ action: 'login', targetId: 'admin', details: '{}' }
|
||||||
|
].map((event, index) => ({
|
||||||
|
...event,
|
||||||
|
id: index + 1,
|
||||||
|
actorId: 'admin',
|
||||||
|
actor: 'administrator',
|
||||||
|
createdAt: Date.now()
|
||||||
|
}));
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async (path: string) =>
|
||||||
|
Response.json(
|
||||||
|
path.endsWith('/audit')
|
||||||
|
? { events }
|
||||||
|
: {
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
id: 'admin',
|
||||||
|
username: 'administrator',
|
||||||
|
displayName: 'Administrator',
|
||||||
|
role: 'admin',
|
||||||
|
disabled: false,
|
||||||
|
mustChangePassword: false,
|
||||||
|
lastLoginAt: null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
component = mount(UsersPage, { target: document.body });
|
||||||
|
flushSync();
|
||||||
|
await vi.waitFor(() => expect(document.querySelectorAll('tbody tr')).toHaveLength(5));
|
||||||
|
const rows = Array.from(document.querySelectorAll<HTMLTableRowElement>('tbody tr'));
|
||||||
|
expect(rows.map((row) => row.cells[1].textContent)).toEqual([
|
||||||
|
'Action requested',
|
||||||
|
'Action requested',
|
||||||
|
'Action requested',
|
||||||
|
'Action requested',
|
||||||
|
'Signed in'
|
||||||
|
]);
|
||||||
|
expect(rows.map((row) => row.cells[3].textContent)).toEqual([
|
||||||
|
'Inspect config',
|
||||||
|
'Remove server',
|
||||||
|
'Manage server operation',
|
||||||
|
'Other action',
|
||||||
|
'administrator'
|
||||||
|
]);
|
||||||
|
expect(document.querySelector('thead')?.textContent).toContain('Account or action');
|
||||||
|
expect(rows[1].querySelector('details')?.open).toBe(false);
|
||||||
|
expect(rows[1].querySelector('summary')?.textContent).toBe('Technical details');
|
||||||
|
expect(JSON.parse(rows[1].querySelector('pre')!.textContent!)).toEqual({
|
||||||
|
method: 'DELETE',
|
||||||
|
status: 202,
|
||||||
|
path: '/api/servers/demo',
|
||||||
|
routeId: '/api/servers/[name]'
|
||||||
|
});
|
||||||
|
expect(rows[2].querySelector('pre')?.textContent).toContain('{old malformed details');
|
||||||
|
expect(rows[3].querySelector('pre')?.textContent).toContain('/api/future/action');
|
||||||
|
});
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
import { campaignOutcome, type CampaignSummary } from '../../src/lib/ui/campaigns.js';
|
import {
|
||||||
|
campaignOutcome,
|
||||||
|
campaignTitle,
|
||||||
|
type CampaignSummary
|
||||||
|
} from '../../src/lib/ui/campaigns.js';
|
||||||
|
|
||||||
let fixture: Fixture;
|
let fixture: Fixture;
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -61,6 +65,48 @@ it('searches script names, campaign IDs and friendly server names across all ret
|
|||||||
expect((await get('q=not-found')).total).toBe(0);
|
expect((await get('q=not-found')).total).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('exposes release versions for campaign titles and both campaign detail endpoints', async () => {
|
||||||
|
const { handle, get, add } = await setup();
|
||||||
|
add('aggregate-script', ['done']);
|
||||||
|
add('legacy-script', ['done']);
|
||||||
|
add('config', ['done']);
|
||||||
|
handle.exec(`
|
||||||
|
INSERT INTO scripts(id,name,target_rel_path,kind)
|
||||||
|
VALUES(1,'Double Tap','double.gsc','script'),(2,'Server settings','settings.cfg','config');
|
||||||
|
INSERT INTO script_versions(id,script_id,version,release_version,kind,sha256,size)
|
||||||
|
VALUES(1,1,2,'1.3.0','script','old',0),(2,1,11,'2.4.1','script','new',0),
|
||||||
|
(3,2,4,NULL,'config','settings',0);
|
||||||
|
INSERT INTO deployment_scripts(deployment_id,script_id,script_version_id,prior_version_id,kind)
|
||||||
|
VALUES('aggregate-script',1,2,1,'script'),('config',2,3,NULL,'config');
|
||||||
|
UPDATE deployments SET script_id=1,script_version_id=2,prior_version_id=1 WHERE id='legacy-script';
|
||||||
|
`);
|
||||||
|
const { deployments } = await get();
|
||||||
|
const versions = {
|
||||||
|
script_kind: 'script',
|
||||||
|
script_version: 11,
|
||||||
|
script_release_version: '2.4.1',
|
||||||
|
prior_version: 2,
|
||||||
|
prior_release_version: '1.3.0'
|
||||||
|
};
|
||||||
|
const aggregate = deployments.find((row) => row.id === 'aggregate-script')!;
|
||||||
|
const legacy = deployments.find((row) => row.id === 'legacy-script')!;
|
||||||
|
expect(aggregate.scripts).toEqual([expect.objectContaining(versions)]);
|
||||||
|
expect(legacy).toMatchObject(versions);
|
||||||
|
expect(campaignTitle(aggregate)).toBe('Promote · Double Tap 2.4.1 (upload 11)');
|
||||||
|
expect(campaignTitle(legacy)).toBe('Promote · Double Tap 2.4.1 (upload 11)');
|
||||||
|
expect(campaignTitle(deployments.find((row) => row.id === 'config')!)).toBe(
|
||||||
|
'Promote · Server settings v4'
|
||||||
|
);
|
||||||
|
|
||||||
|
const { GET: campaignDetail } = await import('../../src/routes/api/campaigns/[id]/+server.js');
|
||||||
|
const { GET: deploymentDetail } =
|
||||||
|
await import('../../src/routes/api/deployments/[id]/+server.js');
|
||||||
|
for (const detail of [campaignDetail, deploymentDetail]) {
|
||||||
|
const response = await (await detail({ params: { id: 'aggregate-script' } })).json();
|
||||||
|
expect(response.scripts).toEqual([expect.objectContaining(versions)]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps API status filters and active/history partitions consistent with displayed outcomes', async () => {
|
it('keeps API status filters and active/history partitions consistent with displayed outcomes', async () => {
|
||||||
const { handle, get, add } = await setup();
|
const { handle, get, add } = await setup();
|
||||||
add('complete', ['done']);
|
add('complete', ['done']);
|
||||||
|
|||||||
@@ -15,6 +15,95 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('persisted launch recipe summaries', () => {
|
describe('persisted launch recipe summaries', () => {
|
||||||
|
it.each([false, true])(
|
||||||
|
'restores exact historical release labels with cached summary=%s without rewriting the recipe',
|
||||||
|
async (cached) => {
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const { createScript, uploadNewVersion } =
|
||||||
|
await import('../../src/lib/server/fleet/scripts.js');
|
||||||
|
const {
|
||||||
|
captureRecipe,
|
||||||
|
summarizeRecipe,
|
||||||
|
recipeSummaryForDeployment,
|
||||||
|
recipeForDeployment
|
||||||
|
} = await import('../../src/lib/server/fleet/launch-recipes.js');
|
||||||
|
const saved = createScript({
|
||||||
|
name: 'Historical script',
|
||||||
|
scopeType: 'game',
|
||||||
|
scopeValue: 't5',
|
||||||
|
targetRelPath: 'history.gsc',
|
||||||
|
content: Buffer.from('historical bytes'),
|
||||||
|
releaseVersion: '2.3.4'
|
||||||
|
});
|
||||||
|
db()
|
||||||
|
.prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?')
|
||||||
|
.run(saved.version.id, saved.script.id);
|
||||||
|
const recipe = captureRecipe('t5', 'theater');
|
||||||
|
const original = recipe.files[0];
|
||||||
|
delete original.releaseVersion;
|
||||||
|
const unversioned = createScript({
|
||||||
|
name: 'Legacy script',
|
||||||
|
targetRelPath: 'legacy.gsc',
|
||||||
|
content: Buffer.from('historical bytes'),
|
||||||
|
releaseVersion: '1.0.0'
|
||||||
|
});
|
||||||
|
db()
|
||||||
|
.prepare('UPDATE script_versions SET release_version = NULL WHERE id = ?')
|
||||||
|
.run(unversioned.version.id);
|
||||||
|
recipe.files.push(
|
||||||
|
{ ...original, path: 'recorded.gsc', releaseVersion: '1.8.0' },
|
||||||
|
{ ...original, path: 'legacy.gsc', versionId: unversioned.version.id },
|
||||||
|
{ ...original, path: 'missing.gsc', versionId: 9999 },
|
||||||
|
{ ...original, path: 'mismatched.gsc', sha256: 'different' }
|
||||||
|
);
|
||||||
|
const latest = uploadNewVersion(
|
||||||
|
saved.script.id,
|
||||||
|
Buffer.from('newer bytes'),
|
||||||
|
undefined,
|
||||||
|
'9.0.0'
|
||||||
|
);
|
||||||
|
db()
|
||||||
|
.prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?')
|
||||||
|
.run(latest.id, saved.script.id);
|
||||||
|
const encoded = JSON.stringify(recipe);
|
||||||
|
const summary = summarizeRecipe(recipe);
|
||||||
|
db()
|
||||||
|
.exec(`INSERT INTO server_deployments(id,server,action,state,stage,started_at,triggered_by)
|
||||||
|
VALUES('historical','retired-server','create','done','Complete','2025-01-01','fixture')`);
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO server_launch_recipes(deployment_id,recipe,recipe_summary) VALUES(?,?,?)'
|
||||||
|
)
|
||||||
|
.run('historical', encoded, cached ? JSON.stringify(summary) : null);
|
||||||
|
|
||||||
|
const restored = recipeSummaryForDeployment('historical')!.recipe;
|
||||||
|
expect(restored.fingerprint).toBe(summary.fingerprint);
|
||||||
|
expect(restored.files.find((file) => file.path === 'history.gsc')).toMatchObject({
|
||||||
|
version: 1,
|
||||||
|
releaseVersion: '2.3.4'
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
restored.files.find((file) => file.path === 'recorded.gsc')!.releaseVersion
|
||||||
|
).toBe('1.8.0');
|
||||||
|
expect(
|
||||||
|
restored.files.find((file) => file.path === 'legacy.gsc')!.releaseVersion
|
||||||
|
).toBeNull();
|
||||||
|
for (const path of ['missing.gsc', 'mismatched.gsc'])
|
||||||
|
expect(restored.files.find((file) => file.path === path)).not.toHaveProperty(
|
||||||
|
'releaseVersion'
|
||||||
|
);
|
||||||
|
expect(recipeSummaryForDeployment('historical')!.recipe).toEqual(restored);
|
||||||
|
expect(recipeForDeployment('historical')!.recipe).toEqual(recipe);
|
||||||
|
const stored = db()
|
||||||
|
.prepare(
|
||||||
|
'SELECT recipe,recipe_summary FROM server_launch_recipes WHERE deployment_id = ?'
|
||||||
|
)
|
||||||
|
.get('historical') as { recipe: string; recipe_summary: string };
|
||||||
|
expect(stored.recipe).toBe(encoded);
|
||||||
|
expect(JSON.parse(stored.recipe_summary)).toEqual(restored);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
it('lazily upgrades legacy snapshots once without changing fingerprints or retained bytes', async () => {
|
it('lazily upgrades legacy snapshots once without changing fingerprints or retained bytes', async () => {
|
||||||
const recipe: LaunchRecipe = {
|
const recipe: LaunchRecipe = {
|
||||||
format: 1,
|
format: 1,
|
||||||
|
|||||||
@@ -82,12 +82,62 @@ describe('launch recipes and previews', () => {
|
|||||||
expect(second.name).toBe(first.name);
|
expect(second.name).toBe(first.name);
|
||||||
expect(second.port).toBe(first.port);
|
expect(second.port).toBe(first.port);
|
||||||
expect(first.recipe.files).toHaveLength(3);
|
expect(first.recipe.files).toHaveLength(3);
|
||||||
|
expect(first.recipe.files[0]).toMatchObject({ version: 1, releaseVersion: '1.0.0' });
|
||||||
expect(first.recipe.files[0]).not.toHaveProperty('content');
|
expect(first.recipe.files[0]).not.toHaveProperty('content');
|
||||||
expect(db().prepare('SELECT * FROM server_keys').all()).toEqual([]);
|
expect(db().prepare('SELECT * FROM server_keys').all()).toEqual([]);
|
||||||
expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toEqual([]);
|
expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toEqual([]);
|
||||||
expect(JSON.stringify(first)).not.toContain('private-');
|
expect(JSON.stringify(first)).not.toContain('private-');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('retains the selected script and plugin release in both planned and captured files', async () => {
|
||||||
|
const { script } = await seedArtifacts();
|
||||||
|
const { createScript, uploadNewVersion } =
|
||||||
|
await import('../../src/lib/server/fleet/scripts.js');
|
||||||
|
const { captureRecipe, captureRecipePlan } =
|
||||||
|
await import('../../src/lib/server/fleet/launch-recipes.js');
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const selected = uploadNewVersion(
|
||||||
|
script.script.id,
|
||||||
|
Buffer.from('updated perks'),
|
||||||
|
undefined,
|
||||||
|
'2.3.4'
|
||||||
|
);
|
||||||
|
const plugin = createScript({
|
||||||
|
name: 'Native perks',
|
||||||
|
kind: 'plugin',
|
||||||
|
scopeType: 'game',
|
||||||
|
scopeValue: 't5',
|
||||||
|
targetRelPath: 'perks.dll',
|
||||||
|
content: Buffer.from('plugin'),
|
||||||
|
releaseVersion: 'v4.5.6'
|
||||||
|
});
|
||||||
|
for (const [id, versionId] of [
|
||||||
|
[script.script.id, selected.id],
|
||||||
|
[plugin.script.id, plugin.version.id]
|
||||||
|
])
|
||||||
|
db()
|
||||||
|
.prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?')
|
||||||
|
.run(versionId, id);
|
||||||
|
for (const capture of [captureRecipePlan, captureRecipe]) {
|
||||||
|
const recipe = capture('t5', 'theater');
|
||||||
|
expect(recipe.files).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
path: 'perks.gsc',
|
||||||
|
version: 2,
|
||||||
|
releaseVersion: '2.3.4'
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
path: 'perks.dll',
|
||||||
|
version: 1,
|
||||||
|
releaseVersion: 'v4.5.6'
|
||||||
|
}),
|
||||||
|
expect.objectContaining({ kind: 'config', version: null, releaseVersion: null })
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('returns the same operation for repeated submission, even after expiry and restart', async () => {
|
it('returns the same operation for repeated submission, even after expiry and restart', async () => {
|
||||||
const { previewCreation, queueCreateServer } =
|
const { previewCreation, queueCreateServer } =
|
||||||
await import('../../src/lib/server/fleet/create.js');
|
await import('../../src/lib/server/fleet/create.js');
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ async function moveTarget() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('rebalance instance migrations', () => {
|
describe('rebalance instance migrations', () => {
|
||||||
it('preserves deployed configuration, patched file versions, identity and credentials on an active source', async () => {
|
it.each(['current', 'legacy'])('preserves deployed files (%s)', async (recipeFormat) => {
|
||||||
const { config } = await import('../../src/lib/server/config.js');
|
const { config } = await import('../../src/lib/server/config.js');
|
||||||
Object.assign(config.rcon, { password: '' });
|
Object.assign(config.rcon, { password: '' });
|
||||||
const { createScript, uploadNewVersion } =
|
const { createScript, uploadNewVersion } =
|
||||||
@@ -196,7 +196,16 @@ describe('rebalance instance migrations', () => {
|
|||||||
scopeValue: 't5',
|
scopeValue: 't5',
|
||||||
content: Buffer.from('original deployed script')
|
content: Buffer.from('original deployed script')
|
||||||
});
|
});
|
||||||
for (const artifact of [baseline, script])
|
const plugin = createScript({
|
||||||
|
releaseVersion: 'v2.4.0',
|
||||||
|
name: 'Deployed plugin',
|
||||||
|
kind: 'plugin',
|
||||||
|
targetRelPath: 'preserved.dll',
|
||||||
|
scopeType: 'game',
|
||||||
|
scopeValue: 't5',
|
||||||
|
content: Buffer.from('deployed plugin')
|
||||||
|
});
|
||||||
|
for (const artifact of [baseline, script, plugin])
|
||||||
db()
|
db()
|
||||||
.prepare('UPDATE scripts SET current_version_id=? WHERE id=?')
|
.prepare('UPDATE scripts SET current_version_id=? WHERE id=?')
|
||||||
.run(artifact.version.id, artifact.script.id);
|
.run(artifact.version.id, artifact.script.id);
|
||||||
@@ -243,10 +252,36 @@ describe('rebalance instance migrations', () => {
|
|||||||
const original = nativeInstance(job.name)!;
|
const original = nativeInstance(job.name)!;
|
||||||
const recipe = captureMoveRecipe(job.name);
|
const recipe = captureMoveRecipe(job.name);
|
||||||
expect(recipe.files.every((file) => file.content === undefined)).toBe(true);
|
expect(recipe.files.every((file) => file.content === undefined)).toBe(true);
|
||||||
|
expect(recipe.files.find((file) => file.path === 'preserved.gsc')).toMatchObject({
|
||||||
|
versionId: patched.id,
|
||||||
|
version: patched.version,
|
||||||
|
releaseVersion: '1.0.1'
|
||||||
|
});
|
||||||
|
expect(recipe.files.find((file) => file.path === 'preserved.dll')).toMatchObject({
|
||||||
|
versionId: plugin.version.id,
|
||||||
|
releaseVersion: 'v2.4.0'
|
||||||
|
});
|
||||||
const id = requestInstanceMove(job.name, target.id, {
|
const id = requestInstanceMove(job.name, target.id, {
|
||||||
triggeredBy: 'rebalance:test',
|
triggeredBy: 'rebalance:test',
|
||||||
recipe
|
recipe
|
||||||
});
|
});
|
||||||
|
if (recipeFormat === 'legacy') {
|
||||||
|
const stored = db()
|
||||||
|
.prepare(
|
||||||
|
'SELECT recipe, recipe_summary FROM server_launch_recipes WHERE deployment_id=?'
|
||||||
|
)
|
||||||
|
.get(id) as { recipe: string; recipe_summary: string };
|
||||||
|
const legacy = (encoded: string) => {
|
||||||
|
const value = JSON.parse(encoded);
|
||||||
|
for (const file of value.files) delete file.releaseVersion;
|
||||||
|
return JSON.stringify(value);
|
||||||
|
};
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
'UPDATE server_launch_recipes SET recipe=?,recipe_summary=? WHERE deployment_id=?'
|
||||||
|
)
|
||||||
|
.run(legacy(stored.recipe), legacy(stored.recipe_summary), id);
|
||||||
|
}
|
||||||
await advance();
|
await advance();
|
||||||
const removal = hostMigration(id)!.remove_operation_id!;
|
const removal = hostMigration(id)!.remove_operation_id!;
|
||||||
expect(removal).toBeTruthy();
|
expect(removal).toBeTruthy();
|
||||||
@@ -304,7 +339,14 @@ describe('rebalance instance migrations', () => {
|
|||||||
generation: 2
|
generation: 2
|
||||||
});
|
});
|
||||||
expect(getHost(hostId)?.mode).toBe('active');
|
expect(getHost(hostId)?.mode).toBe('active');
|
||||||
expect(JSON.stringify(getDeployment(id)?.recipe)).not.toContain(launch.rconPassword);
|
const displayedRecipe = getDeployment(id)?.recipe;
|
||||||
|
expect(displayedRecipe?.files.find((file) => file.path === 'preserved.gsc')).toMatchObject({
|
||||||
|
releaseVersion: '1.0.1'
|
||||||
|
});
|
||||||
|
expect(displayedRecipe?.files.find((file) => file.path === 'preserved.dll')).toMatchObject({
|
||||||
|
releaseVersion: 'v2.4.0'
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(displayedRecipe)).not.toContain(launch.rconPassword);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rechecks occupancy, target capacity and run admission before removing the source', async () => {
|
it('rechecks occupancy, target capacity and run admission before removing the source', async () => {
|
||||||
@@ -1773,17 +1815,21 @@ describe('native instance provisioning', () => {
|
|||||||
expect(getHost(hostId)?.mode).toBe('active');
|
expect(getHost(hostId)?.mode).toBe('active');
|
||||||
});
|
});
|
||||||
it('builds campaign replacements with requested plugins, configuration, additions and removals', async () => {
|
it('builds campaign replacements with requested plugins, configuration, additions and removals', async () => {
|
||||||
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
|
const { createScript, uploadNewVersion } =
|
||||||
|
await import('../../src/lib/server/fleet/scripts.js');
|
||||||
const { db } = await import('../../src/lib/server/db/index.js');
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
let replacementPluginId = 0;
|
||||||
for (const path of ['replace.dll', 'remove.dll']) {
|
for (const path of ['replace.dll', 'remove.dll']) {
|
||||||
const file = createScript({
|
const file = createScript({
|
||||||
name: path,
|
name: path,
|
||||||
kind: 'plugin',
|
kind: 'plugin',
|
||||||
|
releaseVersion: 'v2.0.0',
|
||||||
targetRelPath: path,
|
targetRelPath: path,
|
||||||
scopeType: 'game',
|
scopeType: 'game',
|
||||||
scopeValue: 't5',
|
scopeValue: 't5',
|
||||||
content: Buffer.from('promoted plugin')
|
content: Buffer.from('promoted plugin')
|
||||||
});
|
});
|
||||||
|
if (path === 'replace.dll') replacementPluginId = file.script.id;
|
||||||
db()
|
db()
|
||||||
.prepare('UPDATE scripts SET current_version_id=? WHERE id=?')
|
.prepare('UPDATE scripts SET current_version_id=? WHERE id=?')
|
||||||
.run(file.version.id, file.script.id);
|
.run(file.version.id, file.script.id);
|
||||||
@@ -1791,6 +1837,21 @@ describe('native instance provisioning', () => {
|
|||||||
const job = await queue();
|
const job = await queue();
|
||||||
await advance();
|
await advance();
|
||||||
await acknowledge(job.deploymentId);
|
await acknowledge(job.deploymentId);
|
||||||
|
const replacement = uploadNewVersion(
|
||||||
|
replacementPluginId,
|
||||||
|
Buffer.from('requested plugin'),
|
||||||
|
undefined,
|
||||||
|
'v2.1.0'
|
||||||
|
);
|
||||||
|
const addition = createScript({
|
||||||
|
name: 'New script',
|
||||||
|
kind: 'script',
|
||||||
|
releaseVersion: '3.2.1',
|
||||||
|
targetRelPath: 'new.gsc',
|
||||||
|
scopeType: 'game',
|
||||||
|
scopeValue: 't5',
|
||||||
|
content: Buffer.from('new script')
|
||||||
|
});
|
||||||
db()
|
db()
|
||||||
.prepare(
|
.prepare(
|
||||||
"INSERT INTO deployments(id,action,started_at) VALUES('files-rebuild','promote',?)"
|
"INSERT INTO deployments(id,action,started_at) VALUES('files-rebuild','promote',?)"
|
||||||
@@ -1803,9 +1864,19 @@ describe('native instance provisioning', () => {
|
|||||||
.run(job.name);
|
.run(job.name);
|
||||||
const { queueFileChange } = await import('../../src/lib/server/fleet/staged-files.js');
|
const { queueFileChange } = await import('../../src/lib/server/fleet/staged-files.js');
|
||||||
const changes = [
|
const changes = [
|
||||||
{ kind: 'plugin', relPath: 'replace.dll', content: Buffer.from('requested plugin') },
|
{
|
||||||
|
kind: 'plugin',
|
||||||
|
relPath: 'replace.dll',
|
||||||
|
content: Buffer.from('requested plugin'),
|
||||||
|
scriptVersionId: replacement.id
|
||||||
|
},
|
||||||
{ kind: 'plugin', relPath: 'remove.dll', content: null },
|
{ kind: 'plugin', relPath: 'remove.dll', content: null },
|
||||||
{ kind: 'script', relPath: 'new.gsc', content: Buffer.from('new script') },
|
{
|
||||||
|
kind: 'script',
|
||||||
|
relPath: 'new.gsc',
|
||||||
|
content: Buffer.from('new script'),
|
||||||
|
scriptVersionId: addition.version.id
|
||||||
|
},
|
||||||
{ kind: 'config', relPath: 'custom.cfg', content: Buffer.from('set custom 1') }
|
{ kind: 'config', relPath: 'custom.cfg', content: Buffer.from('set custom 1') }
|
||||||
] as const;
|
] as const;
|
||||||
for (const change of changes)
|
for (const change of changes)
|
||||||
@@ -1839,6 +1910,21 @@ describe('native instance provisioning', () => {
|
|||||||
if (change.content === null) expect(actual).toBeUndefined();
|
if (change.content === null) expect(actual).toBeUndefined();
|
||||||
else expect(Buffer.from(actual!.content!, 'base64')).toEqual(change.content);
|
else expect(Buffer.from(actual!.content!, 'base64')).toEqual(change.content);
|
||||||
}
|
}
|
||||||
|
expect(recipe.files.find((file) => file.path === 'replace.dll')).toMatchObject({
|
||||||
|
versionId: replacement.id,
|
||||||
|
version: replacement.version,
|
||||||
|
releaseVersion: 'v2.1.0'
|
||||||
|
});
|
||||||
|
expect(recipe.files.find((file) => file.path === 'new.gsc')).toMatchObject({
|
||||||
|
versionId: addition.version.id,
|
||||||
|
version: addition.version.version,
|
||||||
|
releaseVersion: '3.2.1'
|
||||||
|
});
|
||||||
|
expect(recipe.files.find((file) => file.path === 'custom.cfg')).toMatchObject({
|
||||||
|
versionId: null,
|
||||||
|
version: null,
|
||||||
|
releaseVersion: null
|
||||||
|
});
|
||||||
const files = getHostOperation(staged.stage_operation_id)!.payload.files as Array<{
|
const files = getHostOperation(staged.stage_operation_id)!.payload.files as Array<{
|
||||||
id: number;
|
id: number;
|
||||||
path: string;
|
path: string;
|
||||||
|
|||||||
@@ -15,13 +15,25 @@ it('names campaigns with their script or beta and gives maintenance a readable t
|
|||||||
rolled_back_by: null,
|
rolled_back_by: null,
|
||||||
script_name: null,
|
script_name: null,
|
||||||
script_version: null,
|
script_version: null,
|
||||||
|
script_release_version: null,
|
||||||
|
script_kind: null,
|
||||||
scripts: [],
|
scripts: [],
|
||||||
servers: []
|
servers: []
|
||||||
};
|
};
|
||||||
expect(campaignTitle(campaign)).toBe('Promote · Beta #74');
|
expect(campaignTitle(campaign)).toBe('Promote · Beta #74');
|
||||||
expect(
|
expect(
|
||||||
campaignTitle({ ...campaign, scripts: [{ script_name: 'Double Tap', script_version: 2 }] })
|
campaignTitle({
|
||||||
).toBe('Promote · Double Tap v2');
|
...campaign,
|
||||||
|
scripts: [
|
||||||
|
{
|
||||||
|
script_name: 'Double Tap',
|
||||||
|
script_version: 2,
|
||||||
|
script_release_version: '1.3.0',
|
||||||
|
script_kind: 'script'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
).toBe('Promote · Double Tap 1.3.0 (upload 2)');
|
||||||
expect(
|
expect(
|
||||||
campaignTitle({
|
campaignTitle({
|
||||||
...campaign,
|
...campaign,
|
||||||
@@ -32,6 +44,38 @@ it('names campaigns with their script or beta and gives maintenance a readable t
|
|||||||
expect(campaignTitle({ ...campaign, action: 'drift' })).toBe('Maintenance repair');
|
expect(campaignTitle({ ...campaign, action: 'drift' })).toBe('Maintenance repair');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses release labels for legacy campaigns and preserves config version labels', () => {
|
||||||
|
const campaign = {
|
||||||
|
action: 'promote',
|
||||||
|
beta_id: null,
|
||||||
|
rolled_back_by: null,
|
||||||
|
script_name: 'Double Tap',
|
||||||
|
script_version: 11,
|
||||||
|
script_release_version: '2.4.1',
|
||||||
|
script_kind: 'script',
|
||||||
|
scripts: [],
|
||||||
|
servers: []
|
||||||
|
};
|
||||||
|
expect(campaignTitle(campaign)).toBe('Promote · Double Tap 2.4.1 (upload 11)');
|
||||||
|
expect(campaignTitle({ ...campaign, script_release_version: null })).toBe(
|
||||||
|
'Promote · Double Tap Unversioned (upload 11)'
|
||||||
|
);
|
||||||
|
expect(campaignTitle({ ...campaign, script_version: null })).toBe('Promote · Double Tap');
|
||||||
|
expect(
|
||||||
|
campaignTitle({
|
||||||
|
...campaign,
|
||||||
|
scripts: [
|
||||||
|
{
|
||||||
|
script_name: 'Server settings',
|
||||||
|
script_version: 4,
|
||||||
|
script_release_version: null,
|
||||||
|
script_kind: 'config'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
).toBe('Promote · Server settings v4');
|
||||||
|
});
|
||||||
|
|
||||||
it('summarizes progress without letting deleted servers create false alarms or false completion', () => {
|
it('summarizes progress without letting deleted servers create false alarms or false completion', () => {
|
||||||
const campaign = {
|
const campaign = {
|
||||||
rolled_back_by: null,
|
rolled_back_by: null,
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { expect, it } from 'vitest';
|
||||||
|
import { render } from 'svelte/server';
|
||||||
|
import LaunchRecipe from '../../src/lib/ui/components/LaunchRecipe.svelte';
|
||||||
|
import type { LaunchFileSummary, LaunchRecipeSummary } from '../../src/lib/deployment-plan.js';
|
||||||
|
|
||||||
|
const file = (overrides: Partial<LaunchFileSummary>): LaunchFileSummary => ({
|
||||||
|
name: 'Script',
|
||||||
|
kind: 'script',
|
||||||
|
path: 'script.gsc',
|
||||||
|
versionId: 20,
|
||||||
|
version: 11,
|
||||||
|
sha256: 'a'.repeat(64),
|
||||||
|
size: 1200,
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
const recipe: LaunchRecipeSummary = {
|
||||||
|
game: 't5',
|
||||||
|
mapSlug: 'theater',
|
||||||
|
mapName: 'Kino der Toten',
|
||||||
|
expectedMap: 'zombie_theater',
|
||||||
|
capturedAt: '2026-10-17T12:00:00Z',
|
||||||
|
image: 'runtime:latest',
|
||||||
|
configFile: 'dedicated_sp.cfg',
|
||||||
|
configSource: 'Production',
|
||||||
|
fingerprint: 'b'.repeat(64),
|
||||||
|
files: [
|
||||||
|
file({ releaseVersion: '1.2.3' }),
|
||||||
|
file({ kind: 'plugin', releaseVersion: 'v3.1.0' }),
|
||||||
|
file({ version: 2 }),
|
||||||
|
file({ kind: 'config', version: 3 }),
|
||||||
|
file({ kind: 'config', versionId: null, version: null })
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
it.each([false, true])(
|
||||||
|
'shows release versions and preserves legacy/generated labels in historical=%s launch files',
|
||||||
|
(historical) => {
|
||||||
|
const body = render(LaunchRecipe, { props: { recipe, historical } }).body.replace(
|
||||||
|
/\s+/g,
|
||||||
|
' '
|
||||||
|
);
|
||||||
|
expect(body).toContain('1.2.3 (upload 11) · script');
|
||||||
|
expect(body).toContain('v3.1.0 (upload 11) · plugin');
|
||||||
|
expect(body).toContain('Unversioned (upload 2) · script');
|
||||||
|
expect(body).toContain('v3 · config');
|
||||||
|
expect(body).toContain('Generated · config');
|
||||||
|
expect(body).not.toContain('v11 ·');
|
||||||
|
expect(body).not.toContain('v2 ·');
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -61,7 +61,7 @@ describe('server health presentation', () => {
|
|||||||
).toEqual({
|
).toEqual({
|
||||||
state: 'host-offline',
|
state: 'host-offline',
|
||||||
label: 'Host offline',
|
label: 'Host offline',
|
||||||
reason: "Dallas 1's host agent is unavailable. The game may still be running.",
|
reason: 'The host agent on Dallas 1 is unavailable. The game may still be running.',
|
||||||
shortReason: 'Dallas 1 unavailable; game may still run.',
|
shortReason: 'Dallas 1 unavailable; game may still run.',
|
||||||
actionLabel: 'Check host',
|
actionLabel: 'Check host',
|
||||||
actionHref: '/hosts/host-1',
|
actionHref: '/hosts/host-1',
|
||||||
|
|||||||
Reference in New Issue
Block a user