Compare commits

..
4 Commits
24 changed files with 274 additions and 54 deletions
+1 -1
View File
@@ -91,7 +91,7 @@ and cleanup recovery where applicable.
### Collection limits and editing ### Collection limits and editing
Collections support up to 40 distinct game/map selections, 1–20 lobbies per map, Collections support up to 40 distinct game/map selections, 1–15 lobbies per map,
and 100 servers total. The host's configured port range and existing fleet may and 100 servers total. The host's configured port range and existing fleet may
allow fewer. A collection can be saved even when there is insufficient capacity allow fewer. A collection can be saved even when there is insufficient capacity
to launch it. Saving a collection does not capture or pin managed file versions. to launch it. Saving a collection does not capture or pin managed file versions.
+5 -1
View File
@@ -92,11 +92,15 @@ of inferring one. GUIDs stay strings.
| Highest round | Highest round reported with that player present, including players who joined mid-game. | | Highest round | Highest round reported with that player present, including players who joined mid-game. |
| Total kills, headshots, revives, downs | Sum of each player's highest reported counter in each game. Round snapshots are cumulative, so they are never added together within a game. | | Total kills, headshots, revives, downs | Sum of each player's highest reported counter in each game. Round snapshots are cumulative, so they are never added together within a game. |
| Total rounds | Round progression during observed participation, added across games. A player observed at game start and then round 8 receives seven rounds. A player first seen at round 8 gets a highest-round record of 8, but zero total rounds; reaching round 9 then adds one. | | Total rounds | Round progression during observed participation, added across games. A player observed at game start and then round 8 receives seven rounds. A player first seen at round 8 gets a highest-round record of 8, but zero total rounds; reaching round 9 then adds one. |
| Games played | Completed games in which a stats report at round 2 or later contains the player, even if they left before the end. Start-only and round 1 participation do not count. | | Games played | Completed games in which a stats report at round 2 or later contains the player, excluding players who left in the same round they joined. Staying through game end counts even without a round change. Legacy participation with an unknown joining round still counts. Start-only and round 1 participation do not count. |
| Games completed | Games whose `game_end` roster contains the player. Disconnected players retain earlier stats without receiving completion credit. | | Games completed | Games whose `game_end` roster contains the player. Disconnected players retain earlier stats without receiving completion credit. |
| Best game kills | Highest kill count recorded in any one game. | | Best game kills | Highest kill count recorded in any one game. |
| Highest reported score | Highest observed score balance. Spending points can lower the score, so this is not cumulative points earned. | | Highest reported score | Highest observed score balance. Spending points can lower the score, so this is not cumulative points earned. |
Same-round departures retain their recorded stats, personal bests, and match
history; only the games-played count excludes them. This also changes the game
count used for efficiency qualification and per-game averages.
Every ranking sorts highest first, including downs (a count, not an efficiency Every ranking sorts highest first, including downs (a count, not an efficiency
rating). Equal values share a rank; name and GUID provide a stable display order. rating). Equal values share a rank; name and GUID provide a stable display order.
Searching preserves the player's rank for the selected game/map filters, or Searching preserves the player's rank for the selected game/map filters, or
+3
View File
@@ -1,5 +1,8 @@
export type UserRole = 'admin' | 'operator' | 'viewer'; export type UserRole = 'admin' | 'operator' | 'viewer';
/** Reserved identity for work requested by the controller. */
export const systemUsername = 'Maxis';
export interface AuthUser { export interface AuthUser {
id: string; id: string;
username: string; username: string;
+25 -3
View File
@@ -3,11 +3,33 @@ import type { ProvisioningDeployment } from './provisioning-detail.js';
import type { ProvisioningSource } from './provisioning-detail.js'; import type { ProvisioningSource } from './provisioning-detail.js';
export const provisioningListStatuses = ['pending', 'running', 'awaiting-health', 'done', 'attention', 'failed', 'interrupted', 'warning', 'cancelled'] as const; export const provisioningListStatuses = ['pending', 'running', 'awaiting-health', 'done', 'attention', 'failed', 'interrupted', 'warning', 'cancelled'] as const;
export const provisioningListSources = ['manual', 'campaign', 'beta', 'maintenance', 'rebalance', 'system'] as const; export const provisioningListSources = ['manual', 'campaign', 'beta', 'maintenance', 'recovery', 'autoscaling', 'rebalance', 'system'] as const;
export type ProvisioningListSource = typeof provisioningListSources[number];
export const provisioningSourceLabels: Record<ProvisioningListSource, string> = {
manual: 'Manual', campaign: 'Campaign', beta: 'Beta', maintenance: 'Maintenance',
recovery: 'Recovery', autoscaling: 'Autoscaling', rebalance: 'Rebalance', system: 'System'
};
export const provisioningSourceKinds = {
campaign: ['campaign'], beta: ['beta'], maintenance: ['maintenance'],
recovery: ['watchdog'], autoscaling: ['autoscaling'], rebalance: ['rebalance']
} as const satisfies Record<Exclude<ProvisioningListSource, 'manual' | 'system'>, readonly ProvisioningSource['kind'][]>;
/** Record titles and incident summaries belong in expanded details, not the source column. */
export function provisioningSourceLabel(row: Pick<ProvisioningListRow, 'sources' | 'triggered_by'>): string {
const kind = row.sources?.[0]?.kind;
return provisioningSourceLabels[kind === 'watchdog' ? 'recovery' : kind ?? (row.triggered_by === 'ui' ? 'manual' : 'system')];
}
export function provisioningMatchesSource(row: Pick<ProvisioningListRow, 'sources' | 'triggered_by'>, source: ProvisioningListSource): boolean {
const sources = row.sources ?? [];
if (source === 'manual' || source === 'system') return !sources.length && (source === 'manual' ? row.triggered_by === 'ui' : row.triggered_by !== 'ui');
const kinds: readonly ProvisioningSource['kind'][] = provisioningSourceKinds[source];
return sources.some(item => kinds.includes(item.kind));
}
export type ProvisioningListRow = Pick<ProvisioningDeployment, export type ProvisioningListRow = Pick<ProvisioningDeployment,
'id' | 'server' | 'server_deleted_at' | 'action' | 'state' | 'stage' | 'started_at' | 'id' | 'server' | 'server_deleted_at' | 'action' | 'state' | 'stage' | 'started_at' |
'finished_at' | 'triggered_by' | 'error' | 'warnings' | 'server_exists'> & { 'finished_at' | 'triggered_by' | 'requested_by' | 'error' | 'warnings' | 'server_exists'> & {
canCancelRemoval: number; canCancel?: number; sources?: ProvisioningSource[]; canCancelRemoval: number; canCancel?: number; sources?: ProvisioningSource[];
phase?: string | null; phaseStartedAt?: string | null; waitCode?: string | null; phase?: string | null; phaseStartedAt?: string | null; waitCode?: string | null;
}; };
@@ -16,7 +38,7 @@ export interface ProvisioningListOptions {
view?: 'active' | 'history' | 'all'; view?: 'active' | 'history' | 'all';
action?: ProvisioningAction; action?: ProvisioningAction;
status?: typeof provisioningListStatuses[number]; status?: typeof provisioningListStatuses[number];
source?: typeof provisioningListSources[number]; source?: ProvisioningListSource;
server?: string; server?: string;
limit?: number; limit?: number;
offset?: number; offset?: number;
+4 -2
View File
@@ -2,7 +2,7 @@ import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypt
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { ensureAuthSchema } from './schema.js'; import { ensureAuthSchema } from './schema.js';
import { hashPassword, verifyPassword } from './passwords.js'; import { hashPassword, verifyPassword } from './passwords.js';
import type { AuthUser, UserRole } from '../../auth.js'; import { systemUsername, type AuthUser, type UserRole } from '../../auth.js';
export const SESSION_COOKIE = '__Host-ec_session'; export const SESSION_COOKIE = '__Host-ec_session';
export const SESSION_LIFETIME_MS = 12 * 60 * 60_000; export const SESSION_LIFETIME_MS = 12 * 60 * 60_000;
@@ -25,7 +25,9 @@ export function listUsers(): AuthUser[] { return (database().prepare('SELECT * F
export function setupRequired(): boolean { return !(database().prepare('SELECT 1 FROM auth_users LIMIT 1').get()); } export function setupRequired(): boolean { return !(database().prepare('SELECT 1 FROM auth_users LIMIT 1').get()); }
function username(value: unknown): string { function username(value: unknown): string {
if (typeof value !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{2,63}$/.test(value)) throw new AuthError('Usernames need 3–64 letters, numbers, dots, underscores or hyphens.'); if (typeof value !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{2,63}$/.test(value)) throw new AuthError('Usernames need 3–64 letters, numbers, dots, underscores or hyphens.');
return value.toLowerCase(); const normalized = value.toLowerCase();
if (normalized === systemUsername.toLowerCase()) throw new AuthError(`${systemUsername} is reserved for system actions. Choose another username.`);
return normalized;
} }
function displayName(value: unknown, fallback: string): string { function displayName(value: unknown, fallback: string): string {
const name = value === undefined ? fallback : value; const name = value === undefined ? fallback : value;
+1 -1
View File
@@ -61,7 +61,7 @@ function buildEntries(input: unknown): BlueprintSelection[] {
const entries = input.map(entry => { const entries = input.map(entry => {
if (!entry || (entry.game !== 't5' && entry.game !== 't6') || typeof entry.mapSlug !== 'string' if (!entry || (entry.game !== 't5' && entry.game !== 't6') || typeof entry.mapSlug !== 'string'
|| !zombieMapBySlug(entry.game, entry.mapSlug)) throw new Error('Each selection needs a supported game and map.'); || !zombieMapBySlug(entry.game, entry.mapSlug)) throw new Error('Each selection needs a supported game and map.');
if (!Number.isSafeInteger(entry.count) || entry.count < 1 || entry.count > 20) throw new Error('Choose 1–20 lobbies for each map.'); if (!Number.isSafeInteger(entry.count) || entry.count < 1 || entry.count > 15) throw new Error('Choose 1–15 lobbies for each map.');
const key = `${entry.game}:${entry.mapSlug}`; const key = `${entry.game}:${entry.mapSlug}`;
if (seen.has(key)) throw new Error('Each game/map can appear once. Increase its lobby count instead.'); if (seen.has(key)) throw new Error('Each game/map can appear once. Increase its lobby count instead.');
seen.add(key); seen.add(key);
@@ -0,0 +1,4 @@
// Keep legacy participation countable when its joining round is unknown.
// A player who stays through game end counts even without a round change.
export const countsAsGamePlayed = `CASE WHEN mp.completed = 1 OR mp.first_observed_round IS NULL
OR mp.highest_round > mp.first_observed_round THEN 1 ELSE 0 END`;
+3 -1
View File
@@ -1,6 +1,7 @@
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { MIN_RECORDED_ROUND } from '../../match-history.js'; import { MIN_RECORDED_ROUND } from '../../match-history.js';
import { getPersonalBestRecords } from './leaderboard-records.js'; import { getPersonalBestRecords } from './leaderboard-records.js';
import { countsAsGamePlayed } from './leaderboard-participation.js';
import { EFFICIENCY_MIN_GAMES, EFFICIENCY_MIN_KILLS, EFFICIENCY_MIN_DOWNS, import { EFFICIENCY_MIN_GAMES, EFFICIENCY_MIN_KILLS, EFFICIENCY_MIN_DOWNS,
isEfficiencyMetric } from '../../leaderboard-efficiency.js'; isEfficiencyMetric } from '../../leaderboard-efficiency.js';
import { leaderboardMetrics, type LeaderboardEntry, type LeaderboardMap, type LeaderboardMetric, import { leaderboardMetrics, type LeaderboardEntry, type LeaderboardMap, type LeaderboardMetric,
@@ -74,6 +75,7 @@ function readLeaderboard(options: LeaderboardOptions, exactGuid?: string): Leade
const aggregated = `WITH observations AS ${observationStorage} ( const aggregated = `WITH observations AS ${observationStorage} (
SELECT mp.match_id, mp.guid, mp.highest_round, mp.kills, mp.rounds_survived, SELECT mp.match_id, mp.guid, mp.highest_round, mp.kills, mp.rounds_survived,
mp.headshots, mp.revives, mp.downs, mp.completed, mp.best_score, mp.last_seen_at, mp.headshots, mp.revives, mp.downs, mp.completed, mp.best_score, mp.last_seen_at,
${countsAsGamePlayed} AS counts_as_game,
m.started_at AS matchStartedAt, m.last_received_at AS matchUpdatedAt m.started_at AS matchStartedAt, m.last_received_at AS matchUpdatedAt
FROM leaderboard_match_players mp JOIN leaderboard_matches m ON m.id = mp.match_id FROM leaderboard_match_players mp JOIN leaderboard_matches m ON m.id = mp.match_id
WHERE ${conditions.join(' AND ')} WHERE ${conditions.join(' AND ')}
@@ -81,7 +83,7 @@ function readLeaderboard(options: LeaderboardOptions, exactGuid?: string): Leade
SELECT guid, MAX(highest_round) AS highestRound, SELECT guid, MAX(highest_round) AS highestRound,
SUM(kills) AS kills, SUM(rounds_survived) AS roundsSurvived, SUM(kills) AS kills, SUM(rounds_survived) AS roundsSurvived,
SUM(headshots) AS headshots, SUM(revives) AS revives, SUM(downs) AS downs, SUM(headshots) AS headshots, SUM(revives) AS revives, SUM(downs) AS downs,
COUNT(*) AS gamesPlayed, SUM(completed) AS gamesCompleted, SUM(counts_as_game) AS gamesPlayed, SUM(completed) AS gamesCompleted,
MAX(kills) AS bestKills, MAX(best_score) AS bestScore, MAX(last_seen_at) AS lastSeen MAX(kills) AS bestKills, MAX(best_score) AS bestScore, MAX(last_seen_at) AS lastSeen
FROM observations GROUP BY guid FROM observations GROUP BY guid
), measures AS ( ), measures AS (
+6 -4
View File
@@ -1,11 +1,12 @@
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { MIN_RECORDED_ROUND } from '../../match-history.js'; import { MIN_RECORDED_ROUND } from '../../match-history.js';
import { getPersonalBestRecords } from './leaderboard-records.js'; import { getPersonalBestRecords } from './leaderboard-records.js';
import { countsAsGamePlayed } from './leaderboard-participation.js';
import type { LeaderboardOptions, PlayerProfile, PlayerStats, PlayerMapRecord, PlayerMatch } from '../../leaderboard.js'; import type { LeaderboardOptions, PlayerProfile, PlayerStats, PlayerMapRecord, PlayerMatch } from '../../leaderboard.js';
const totals = `MAX(mp.highest_round) AS highestRound, SUM(mp.kills) AS kills, const totals = `MAX(mp.highest_round) AS highestRound, SUM(mp.kills) AS kills,
SUM(mp.rounds_survived) AS roundsSurvived, SUM(mp.headshots) AS headshots, SUM(mp.rounds_survived) AS roundsSurvived, SUM(mp.headshots) AS headshots,
SUM(mp.revives) AS revives, SUM(mp.downs) AS downs, COUNT(*) AS gamesPlayed, SUM(mp.revives) AS revives, SUM(mp.downs) AS downs, SUM(${countsAsGamePlayed}) AS gamesPlayed,
SUM(mp.completed) AS gamesCompleted, MAX(mp.kills) AS bestKills, MAX(mp.best_score) AS bestScore`; SUM(mp.completed) AS gamesCompleted, MAX(mp.kills) AS bestKills, MAX(mp.best_score) AS bestScore`;
const scope = `FROM leaderboard_match_players mp JOIN leaderboard_matches m ON m.id = mp.match_id const scope = `FROM leaderboard_match_players mp JOIN leaderboard_matches m ON m.id = mp.match_id
WHERE m.game IN ('t5', 't6') AND m.status = 'completed' AND mp.guid = ? AND mp.has_stats = 1 WHERE m.game IN ('t5', 't6') AND m.status = 'completed' AND mp.guid = ? AND mp.has_stats = 1
@@ -20,8 +21,9 @@ export function getPlayerProfile(guid: string, requestedOffset = 0,
WHERE game IN ('t5', 't6') AND guid = ? ORDER BY last_seen_at DESC, game LIMIT 1`) WHERE game IN ('t5', 't6') AND guid = ? ORDER BY last_seen_at DESC, game LIMIT 1`)
.get(guid) as Pick<PlayerProfile, 'guid' | 'name' | 'firstSeen' | 'lastSeen'> | undefined; .get(guid) as Pick<PlayerProfile, 'guid' | 'name' | 'firstSeen' | 'lastSeen'> | undefined;
if (!player) return null; if (!player) return null;
const stats = handle.prepare(`SELECT ${totals} ${scope}`).get(guid) as PlayerStats; const { matchCount, ...stats } = handle.prepare(`SELECT COUNT(*) AS matchCount, ${totals} ${scope}`)
if (!stats.gamesPlayed) return null; .get(guid) as PlayerStats & { matchCount: number };
if (!matchCount) return null;
const historyParameters: Array<string | number> = [guid]; const historyParameters: Array<string | number> = [guid];
let historyScope = scope; let historyScope = scope;
if (options.game) { historyScope += ' AND m.game = ?'; historyParameters.push(options.game); } if (options.game) { historyScope += ' AND m.game = ?'; historyParameters.push(options.game); }
@@ -29,7 +31,7 @@ export function getPlayerProfile(guid: string, requestedOffset = 0,
if (options.players) { historyScope += ' AND m.start_players = ?'; historyParameters.push(options.players); } if (options.players) { historyScope += ' AND m.start_players = ?'; historyParameters.push(options.players); }
const matchesTotal = options.game || options.map || options.players const matchesTotal = options.game || options.map || options.players
? (handle.prepare(`SELECT COUNT(*) AS total ${historyScope}`).get(...historyParameters) as { total: number }).total ? (handle.prepare(`SELECT COUNT(*) AS total ${historyScope}`).get(...historyParameters) as { total: number }).total
: stats.gamesPlayed; : matchCount;
const limit = 25; const limit = 25;
const lastOffset = Math.max(0, Math.floor((matchesTotal - 1) / limit) * limit); const lastOffset = Math.max(0, Math.floor((matchesTotal - 1) / limit) * limit);
const offset = Number.isSafeInteger(requestedOffset) ? Math.min(lastOffset, Math.max(0, requestedOffset)) : 0; const offset = Number.isSafeInteger(requestedOffset) ? Math.min(lastOffset, Math.max(0, requestedOffset)) : 0;
+6 -9
View File
@@ -18,7 +18,7 @@ import { ensureHostSchema } from '../hosts/schema.js';
import { safetyOverride, safetyOverrideAvailability } from './safety-override.js'; import { safetyOverride, safetyOverrideAvailability } from './safety-override.js';
import { serverTitle } from '../../ui/server-labels.js'; import { serverTitle } from '../../ui/server-labels.js';
import { provisioningQueueStatus } from '../../ui/provisioning-queue.js'; import { provisioningQueueStatus } from '../../ui/provisioning-queue.js';
import { provisioningPageSize, provisioningPageOffset, type ProvisioningListOptions, type ProvisioningListPage, type ProvisioningListRow } from '../../provisioning-list.js'; import { provisioningPageSize, provisioningPageOffset, provisioningSourceKinds, type ProvisioningListOptions, type ProvisioningListPage, type ProvisioningListRow } from '../../provisioning-list.js';
export function beginDeployment(server: string, action: ProvisioningAction, triggeredBy = 'ui'): string { export function beginDeployment(server: string, action: ProvisioningAction, triggeredBy = 'ui'): string {
const id = randomUUID(); const id = randomUUID();
@@ -303,7 +303,7 @@ const reviewDeployment = `d.state IN ('failed', 'interrupted', 'warning') AND NO
SELECT 1 FROM retention_controls c WHERE c.category='provisioning' AND c.record_id=d.id SELECT 1 FROM retention_controls c WHERE c.category='provisioning' AND c.record_id=d.id
AND c.resolved_at >= COALESCE(d.finished_at,d.started_at))`; AND c.resolved_at >= COALESCE(d.finished_at,d.started_at))`;
const deploymentListColumns = `d.id, d.server, d.server_deleted_at, d.action, d.state, d.stage, const deploymentListColumns = `d.id, d.server, d.server_deleted_at, d.action, d.state, d.stage,
d.started_at, d.finished_at, d.triggered_by, d.error, d.warnings, d.started_at, d.finished_at, d.triggered_by, d.requested_by, d.error, d.warnings,
d.phase_key AS phase, d.phase_started_at AS phaseStartedAt, d.phase_key AS phase, d.phase_started_at AS phaseStartedAt,
CASE WHEN json_valid(d.observation_json) THEN json_extract(d.observation_json,'$.blocker.code') END AS waitCode, CASE WHEN json_valid(d.observation_json) THEN json_extract(d.observation_json,'$.blocker.code') END AS waitCode,
(d.server_deleted_at IS NULL AND EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server AND s.destroyed_at IS NULL)) AS server_exists, (d.server_deleted_at IS NULL AND EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server AND s.destroyed_at IS NULL)) AS server_exists,
@@ -320,15 +320,12 @@ function deploymentListQuery(options: ProvisioningListOptions) {
if (options.status === 'attention') conditions.push(reviewDeployment); if (options.status === 'attention') conditions.push(reviewDeployment);
else if (options.status === 'pending') conditions.push("d.state IN ('queued','waiting')"); else if (options.status === 'pending') conditions.push("d.state IN ('queued','waiting')");
else if (options.status) { conditions.push('d.state=?'); parameters.push(options.status); } else if (options.status) { conditions.push('d.state=?'); parameters.push(options.status); }
const linked = (kinds: string[]) => `(EXISTS (SELECT 1 FROM provisioning_sources src WHERE src.operation_id=d.id AND src.kind IN (${kinds.map(kind => `'${kind}'`).join(',')})) const linked = (kinds: readonly string[]) => `(EXISTS (SELECT 1 FROM provisioning_sources src WHERE src.operation_id=d.id AND src.kind IN (${kinds.map(kind => `'${kind}'`).join(',')}))
OR ${kinds.map(kind => `d.triggered_by LIKE '${kind}:%'`).join(' OR ')})`; OR ${kinds.map(kind => `d.triggered_by LIKE '${kind}:%'`).join(' OR ')})`;
if (options.source === 'campaign' || options.source === 'beta') conditions.push(linked([options.source])); if (options.source === 'manual' || options.source === 'system') {
else if (options.source === 'maintenance') conditions.push(linked(['maintenance', 'watchdog'])); conditions.push(`NOT ${linked(Object.values(provisioningSourceKinds).flat())}`);
else if (options.source === 'rebalance') conditions.push("d.triggered_by LIKE 'rebalance:%'");
else if (options.source === 'manual' || options.source === 'system') {
conditions.push(`NOT ${linked(['campaign', 'beta', 'maintenance', 'watchdog', 'rebalance'])}`);
conditions.push(options.source === 'manual' ? "d.triggered_by='ui'" : "d.triggered_by!='ui'"); conditions.push(options.source === 'manual' ? "d.triggered_by='ui'" : "d.triggered_by!='ui'");
} } else if (options.source) conditions.push(linked(provisioningSourceKinds[options.source]));
const search = (options.server ?? '').slice(0, 100).toLowerCase(); const search = (options.server ?? '').slice(0, 100).toLowerCase();
if (search) { if (search) {
// Read only distinct names to preserve friendly map/lobby searches, including deleted servers. // Read only distinct names to preserve friendly map/lobby searches, including deleted servers.
@@ -16,8 +16,8 @@
let error = $state(''); let error = $state('');
let total = $derived(rows.reduce((sum, row) => sum + (Number(row.count) || 0), 0)); let total = $derived(rows.reduce((sum, row) => sum + (Number(row.count) || 0), 0));
const duplicate = (row: typeof rows[number]) => rows.some(other => other.key !== row.key && other.game === row.game && other.mapSlug === row.mapSlug); 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 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 <= 15 && !duplicate(row)));
let reason = $derived(!name.trim() ? 'Enter a collection 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 reason = $derived(!name.trim() ? 'Enter a collection 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–15 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 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 wasOpen = false;
let session = 0; let session = 0;
@@ -83,7 +83,7 @@
<span class="number">{String(index + 1).padStart(2, '0')}</span> <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>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}`} 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>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> <label>Target lobbies<input aria-label={`Lobbies ${index + 1}`} aria-invalid={!(Number.isInteger(row.count) && row.count >= 1 && row.count <= 15) || undefined} aria-describedby={`count-hint-${row.key}`} type="number" min="1" max="15" step="1" bind:value={row.count} required disabled={busy} /><span id={`count-hint-${row.key}`}>1–15</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> <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> </div>
{/each} {/each}
+6 -1
View File
@@ -1,5 +1,10 @@
import { provisioningPhases, type ProvisioningDetail, type ProvisioningEvent, type ProvisioningEventPage, type ProvisioningPhase } from '$lib/provisioning-detail.js'; import { provisioningPhases, type ProvisioningDeployment, type ProvisioningDetail, type ProvisioningEvent, type ProvisioningEventPage, type ProvisioningPhase } from '$lib/provisioning-detail.js';
import { actionCompleted, actionLabels, provisioningActions, type ProvisioningAction } from '$lib/provisioning.js'; import { actionCompleted, actionLabels, provisioningActions, type ProvisioningAction } from '$lib/provisioning.js';
import { systemUsername } from '$lib/auth.js';
export function provisioningRequester(operation: Pick<ProvisioningDeployment, 'requested_by' | 'triggered_by'>): string {
return operation.requested_by ?? (operation.triggered_by === 'ui' ? 'Not recorded' : systemUsername);
}
export const activeProvisioningStates = ['queued', 'waiting', 'running', 'awaiting-health']; export const activeProvisioningStates = ['queued', 'waiting', 'running', 'awaiting-health'];
export type ProgressState = 'done' | 'current' | 'waiting' | 'failed' | 'pending' | 'cancelled' | 'unknown'; export type ProgressState = 'done' | 'current' | 'waiting' | 'failed' | 'pending' | 'cancelled' | 'unknown';
+1 -1
View File
@@ -28,7 +28,7 @@
headshots: 'Cumulative headshots across recorded games.', headshots: 'Cumulative headshots across recorded games.',
revives: 'Cumulative teammates revived across recorded games.', revives: 'Cumulative teammates revived across recorded games.',
downs: 'Cumulative downs across recorded games, ranked from most to fewest.', downs: 'Cumulative downs across recorded games, ranked from most to fewest.',
gamesPlayed: 'Finished games with recorded player stats, including players who left before the end.', gamesPlayed: 'Finished games with recorded player stats, excluding players who left in the same round they joined.',
gamesCompleted: 'Games where the player appears in the final game-end report.', gamesCompleted: 'Games where the player appears in the final game-end report.',
bestKills: 'The most kills reported for a player in a single game.', bestKills: 'The most kills reported for a player in a single game.',
bestScore: 'The highest reported points balance. Spending points reduces this balance; it is not total points earned.' bestScore: 'The highest reported points balance. Spending points reduces this balance; it is not total points earned.'
+12 -15
View File
@@ -16,13 +16,13 @@
import RelativeTime from '$ui/components/RelativeTime.svelte'; import RelativeTime from '$ui/components/RelativeTime.svelte';
import LifecycleDialog from '$ui/components/LifecycleDialog.svelte'; import LifecycleDialog from '$ui/components/LifecycleDialog.svelte';
import { provisioningListStatuses, provisioningListSources, provisioningPageSize, provisioningPageOffset, type ProvisioningListRow as Deployment, type ProvisioningListPage } from '$lib/provisioning-list.js'; import { provisioningListStatuses, provisioningListSources, provisioningSourceLabels, provisioningSourceLabel, provisioningMatchesSource, provisioningPageSize, provisioningPageOffset, type ProvisioningListSource, type ProvisioningListRow as Deployment, type ProvisioningListPage } from '$lib/provisioning-list.js';
import { provisioningActions, actionLabels } from '$lib/provisioning.js'; import { provisioningActions, actionLabels } from '$lib/provisioning.js';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import { confirmAction } from '$ui/confirm.js'; import { confirmAction } from '$ui/confirm.js';
import { serverTitle } from '$ui/server-labels.js'; import { serverTitle } from '$ui/server-labels.js';
import { elapsedTime } from '$ui/provisioning-presentation.js'; import { elapsedTime, provisioningRequester } from '$ui/provisioning-presentation.js';
import { provisioningQueueStatus, provisioningWarnings } from '$ui/provisioning-queue.js'; import { provisioningQueueStatus, provisioningWarnings } from '$ui/provisioning-queue.js';
let lifecycleOpen = $state(false); let lifecycleOpen = $state(false);
@@ -40,7 +40,7 @@
let limit = $state(25); let limit = $state(25);
let offset = $state(0); let offset = $state(0);
let historyState = $state('all'); let historyState = $state('all');
let source = $state('all'); let source = $state<ProvisioningListSource | 'all'>('all');
let expanded = $state<Record<string, boolean>>({}); let expanded = $state<Record<string, boolean>>({});
let fleetActionsOpen = $state(false); let fleetActionsOpen = $state(false);
let now = $state(Date.now()); let now = $state(Date.now());
@@ -55,7 +55,7 @@
tab = ['active', 'history'].includes(params.get('view') ?? '') ? params.get('view')! : 'active'; tab = ['active', 'history'].includes(params.get('view') ?? '') ? params.get('view')! : 'active';
action = provisioningActions.some(value => value === params.get('action')) ? params.get('action')! : 'all'; action = provisioningActions.some(value => value === params.get('action')) ? params.get('action')! : 'all';
historyState = provisioningListStatuses.some(value => value === params.get('status')) ? params.get('status')! : 'all'; historyState = provisioningListStatuses.some(value => value === params.get('status')) ? params.get('status')! : 'all';
source = provisioningListSources.some(value => value === params.get('source')) ? params.get('source')! : 'all'; source = provisioningListSources.find(value => value === params.get('source')) ?? 'all';
search = params.get('server') ?? ''; search = params.get('server') ?? '';
limit = provisioningPageSize(Number(params.get('limit') ?? 25)); limit = provisioningPageSize(Number(params.get('limit') ?? 25));
offset = provisioningPageOffset(Number(params.get('offset') ?? 0)); offset = provisioningPageOffset(Number(params.get('offset') ?? 0));
@@ -141,12 +141,7 @@
if (action !== 'all' && row.action !== action) return false; if (action !== 'all' && row.action !== action) return false;
if (historyState !== 'all' && (historyState === 'pending' ? !['queued','waiting'].includes(row.state) : row.state !== historyState)) return false; if (historyState !== 'all' && (historyState === 'pending' ? !['queued','waiting'].includes(row.state) : row.state !== historyState)) return false;
if (search && !`${row.server} ${serverTitle(row.server)}`.toLowerCase().includes(search.toLowerCase())) return false; if (search && !`${row.server} ${serverTitle(row.server)}`.toLowerCase().includes(search.toLowerCase())) return false;
if (source !== 'all') { if (source !== 'all' && !provisioningMatchesSource(row, source)) return false;
const sources = row.sources ?? [];
if (source === 'manual' && (sources.length || row.triggered_by !== 'ui')) return false;
if (source === 'system' && (sources.length || row.triggered_by === 'ui')) return false;
if (['campaign','beta','maintenance','rebalance'].includes(source) && !sources.some(item => source === 'maintenance' ? ['maintenance','watchdog'].includes(item.kind) : item.kind === source)) return false;
}
return true; return true;
})); }));
const queueSummary = $derived.by(() => { const queueSummary = $derived.by(() => {
@@ -175,7 +170,7 @@
<label class="search">Server<input type="search" bind:value={search} oninput={() => saveContext(true)} placeholder="Search server or lobby" maxlength="100" /></label> <label class="search">Server<input type="search" bind:value={search} oninput={() => saveContext(true)} placeholder="Search server or lobby" maxlength="100" /></label>
<label>Operation<select bind:value={action} onchange={() => saveContext(true)}><option value="all">All operations</option>{#each provisioningActions as value}<option value={value}>{actionLabels[value]}</option>{/each}</select></label> <label>Operation<select bind:value={action} onchange={() => saveContext(true)}><option value="all">All operations</option>{#each provisioningActions as value}<option value={value}>{actionLabels[value]}</option>{/each}</select></label>
<label>Status<select bind:value={historyState} onchange={() => saveContext(true)}><option value="all">All statuses</option><option value="pending">Waiting / queued</option><option value="running">In progress</option><option value="awaiting-health">Verifying health</option><option value="done">Complete</option><option value="attention">Needs attention</option><option value="failed">Failed</option><option value="interrupted">Interrupted</option><option value="warning">Warnings</option><option value="cancelled">Cancelled</option></select></label> <label>Status<select bind:value={historyState} onchange={() => saveContext(true)}><option value="all">All statuses</option><option value="pending">Waiting / queued</option><option value="running">In progress</option><option value="awaiting-health">Verifying health</option><option value="done">Complete</option><option value="attention">Needs attention</option><option value="failed">Failed</option><option value="interrupted">Interrupted</option><option value="warning">Warnings</option><option value="cancelled">Cancelled</option></select></label>
<label>Source<select bind:value={source} onchange={() => saveContext(true)}><option value="all">All sources</option><option value="manual">Manual</option><option value="campaign">Campaign</option><option value="beta">Beta</option><option value="maintenance">Maintenance</option><option value="rebalance">Rebalance</option><option value="system">System</option></select></label> <label>Source<select bind:value={source} onchange={() => saveContext(true)}><option value="all">All sources</option>{#each provisioningListSources as value}<option value={value}>{provisioningSourceLabels[value]}</option>{/each}</select></label>
{#if filtered}<Button variant="ghost" onclick={clearFilters}>Clear filters</Button>{/if} {#if filtered}<Button variant="ghost" onclick={clearFilters}>Clear filters</Button>{/if}
</div> </div>
<LifecycleDialog bind:open={lifecycleOpen} /> <LifecycleDialog bind:open={lifecycleOpen} />
@@ -215,7 +210,7 @@
{#snippet operationTable(rows: Deployment[])} {#snippet operationTable(rows: Deployment[])}
<div class="table-scroll"><table> <div class="table-scroll"><table>
<thead><tr><th scope="col">Server</th><th scope="col">Operation</th><th scope="col">Status</th><th scope="col">Source</th><th scope="col">Started</th><th scope="col" class="align-right">Details</th></tr></thead> <thead><tr><th scope="col">Server</th><th scope="col">Operation</th><th scope="col">Status</th><th scope="col">Source</th><th scope="col">Requester</th><th scope="col">Started</th><th scope="col" class="align-right">Details</th></tr></thead>
<tbody>{#each rows as row (row.id)} <tbody>{#each rows as row (row.id)}
{@const status = provisioningQueueStatus(row)} {@const status = provisioningQueueStatus(row)}
{@const primarySource = row.sources?.[0]} {@const primarySource = row.sources?.[0]}
@@ -225,15 +220,16 @@
<td class="server-cell"><HistoricalServerIdentity name={row.server} deletedAt={row.server_deleted_at} href={row.server_exists ? contextHref(`/servers/${encodeURIComponent(row.server)}`, $page.url) : undefined} /></td> <td class="server-cell"><HistoricalServerIdentity name={row.server} deletedAt={row.server_deleted_at} href={row.server_exists ? contextHref(`/servers/${encodeURIComponent(row.server)}`, $page.url) : undefined} /></td>
<td><a href={operationHref}>{actionLabels[row.action]}</a></td> <td><a href={operationHref}>{actionLabels[row.action]}</a></td>
<td><Badge variant={status.tone} size="md">{status.label}</Badge>{#if ['queued','waiting'].includes(status.group) && row.phaseStartedAt && now - Date.parse(row.phaseStartedAt) >= 600000}<small>In this stage {elapsedTime(row.phaseStartedAt, now)}</small>{/if}</td> <td><Badge variant={status.tone} size="md">{status.label}</Badge>{#if ['queued','waiting'].includes(status.group) && row.phaseStartedAt && now - Date.parse(row.phaseStartedAt) >= 600000}<small>In this stage {elapsedTime(row.phaseStartedAt, now)}</small>{/if}</td>
<td class="source-cell">{#if primarySource?.href}<a href={contextHref(primarySource.href, $page.url)}>{primarySource.label}</a>{:else}<span>{primarySource?.label ?? (row.triggered_by === 'ui' ? 'Manual' : 'System')}</span>{/if}{#if (row.sources?.length ?? 0) > 1}<small>+{row.sources!.length - 1} more</small>{/if}</td> <td class="source-cell">{#if primarySource?.href}<a href={contextHref(primarySource.href, $page.url)}>{provisioningSourceLabel(row)}</a>{:else}<span>{provisioningSourceLabel(row)}</span>{/if}{#if (row.sources?.length ?? 0) > 1}<small>+{row.sources!.length - 1} more</small>{/if}</td>
<td class="requester-cell">{provisioningRequester(row)}</td>
<td><RelativeTime value={row.started_at} /></td> <td><RelativeTime value={row.started_at} /></td>
<td class="align-right"><button class="text-button" aria-expanded={isExpanded} aria-controls={`operation-${row.id}`} aria-label={`${isExpanded ? 'Hide' : 'Show'} ${actionLabels[row.action].toLowerCase()} details for ${serverTitle(row.server)}`} onclick={() => expanded = { ...expanded, [row.id]: !isExpanded }}>{isExpanded ? 'Hide' : 'Details'} <span aria-hidden="true">{isExpanded ? '−' : '+'}</span></button></td> <td class="align-right"><button class="text-button" aria-expanded={isExpanded} aria-controls={`operation-${row.id}`} aria-label={`${isExpanded ? 'Hide' : 'Show'} ${actionLabels[row.action].toLowerCase()} details for ${serverTitle(row.server)}`} onclick={() => expanded = { ...expanded, [row.id]: !isExpanded }}>{isExpanded ? 'Hide' : 'Details'} <span aria-hidden="true">{isExpanded ? '−' : '+'}</span></button></td>
</tr> </tr>
<tr hidden={!isExpanded} id={`operation-${row.id}`} class="detail-row"><td colspan="6"> <tr hidden={!isExpanded} id={`operation-${row.id}`} class="detail-row"><td colspan="7">
<div class="operation-details"> <div class="operation-details">
<p>{row.stage}</p>{#if row.error}<p class="error">{row.error}</p>{/if} <p>{row.stage}</p>{#if row.error}<p class="error">{row.error}</p>{/if}
{#each provisioningWarnings(row.warnings) as warning}<p class="warning">{warning}</p>{/each} {#each provisioningWarnings(row.warnings) as warning}<p class="warning">{warning}</p>{/each}
<dl><div><dt>Started</dt><dd>{fmt(row.started_at)}</dd></div>{#if row.finished_at}<div><dt>Finished</dt><dd>{fmt(row.finished_at)}</dd></div>{/if}<div><dt>{row.finished_at ? 'Duration' : 'Elapsed'}</dt><dd>{elapsedTime(row.started_at, row.finished_at ? Date.parse(row.finished_at) : now)}</dd></div><div><dt>Operation ID</dt><dd class="mono">{row.id}</dd></div></dl> <dl><div><dt>Requester</dt><dd>{provisioningRequester(row)}</dd></div><div><dt>Started</dt><dd>{fmt(row.started_at)}</dd></div>{#if row.finished_at}<div><dt>Finished</dt><dd>{fmt(row.finished_at)}</dd></div>{/if}<div><dt>{row.finished_at ? 'Duration' : 'Elapsed'}</dt><dd>{elapsedTime(row.started_at, row.finished_at ? Date.parse(row.finished_at) : now)}</dd></div><div><dt>Operation ID</dt><dd class="mono">{row.id}</dd></div></dl>
{#if row.sources?.length}<div class="detail-links">{#each row.sources as item}{#if item.href}<a href={contextHref(item.href, $page.url)}>{item.label}</a>{:else}<span class="muted">{item.label}</span>{/if}{/each}</div>{/if} {#if row.sources?.length}<div class="detail-links">{#each row.sources as item}{#if item.href}<a href={contextHref(item.href, $page.url)}>{item.label}</a>{:else}<span class="muted">{item.label}</span>{/if}{/each}</div>{/if}
<div class="detail-links"><a href={operationHref}>Open operation</a>{#if row.server_exists && !row.server_deleted_at}<a href={contextHref(`/servers/${encodeURIComponent(row.server)}?tab=diagnostics`, $page.url)}>Diagnostics</a><a href={contextHref(`/servers/${encodeURIComponent(row.server)}?tab=logs`, $page.url)}>Open logs</a>{/if}{#if row.canCancel && !row.server_deleted_at}<Button variant="danger" size="sm" disabled={cancelling} onclick={() => cancelRequests([row])}>Cancel operation</Button>{/if}</div> <div class="detail-links"><a href={operationHref}>Open operation</a>{#if row.server_exists && !row.server_deleted_at}<a href={contextHref(`/servers/${encodeURIComponent(row.server)}?tab=diagnostics`, $page.url)}>Diagnostics</a><a href={contextHref(`/servers/${encodeURIComponent(row.server)}?tab=logs`, $page.url)}>Open logs</a>{/if}{#if row.canCancel && !row.server_deleted_at}<Button variant="danger" size="sm" disabled={cancelling} onclick={() => cancelRequests([row])}>Cancel operation</Button>{/if}</div>
</div> </div>
@@ -254,6 +250,7 @@
th { padding:10px 12px; color:var(--text-tertiary); font-size:var(--text-xs); font-weight:var(--weight-medium); border-bottom:1px solid var(--border-strong); } th { padding:10px 12px; color:var(--text-tertiary); font-size:var(--text-xs); font-weight:var(--weight-medium); border-bottom:1px solid var(--border-strong); }
td { padding:16px 12px; border-bottom:1px solid var(--border-subtle); vertical-align:middle; }tbody tr:hover { background:var(--surface); } td { padding:16px 12px; border-bottom:1px solid var(--border-subtle); vertical-align:middle; }tbody tr:hover { background:var(--surface); }
a { color:var(--accent-primary-hover); }.server-cell { min-width:160px; font-weight:var(--weight-semibold); }.source-cell { max-width:240px; overflow-wrap:anywhere; } a { color:var(--accent-primary-hover); }.server-cell { min-width:160px; font-weight:var(--weight-semibold); }.source-cell { max-width:240px; overflow-wrap:anywhere; }
.requester-cell { max-width:180px; overflow-wrap:anywhere; }
small { display:block; margin-top:6px; color:var(--text-tertiary); font-size:var(--text-xs); }.align-right { text-align:right; } small { display:block; margin-top:6px; color:var(--text-tertiary); font-size:var(--text-xs); }.align-right { text-align:right; }
.text-button { border:0; background:transparent; color:var(--text-secondary); font:inherit; padding:6px 0; cursor:pointer; white-space:nowrap; } .text-button { border:0; background:transparent; color:var(--text-secondary); font:inherit; padding:6px 0; cursor:pointer; white-space:nowrap; }
.detail-row { background:var(--surface); }.operation-details { display:grid; gap:16px; line-height:1.6; overflow-wrap:anywhere; } .detail-row { background:var(--surface); }.operation-details { display:grid; gap:16px; line-height:1.6; overflow-wrap:anywhere; }
+2 -2
View File
@@ -9,7 +9,7 @@
import { serverTitle } from '$ui/server-labels.js'; import { serverTitle } from '$ui/server-labels.js';
import { createProvisioningRefresh, type ProvisioningRefreshState } from '$ui/provisioning-refresh.js'; import { createProvisioningRefresh, type ProvisioningRefreshState } from '$ui/provisioning-refresh.js';
import { confirmAction } from '$ui/confirm.js'; import { confirmAction } from '$ui/confirm.js';
import { elapsedTime, isProvisioningDetail, provisioningOutcome, provisioningReturn } from '$ui/provisioning-presentation.js'; import { elapsedTime, isProvisioningDetail, provisioningOutcome, provisioningRequester, provisioningReturn } from '$ui/provisioning-presentation.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte'; import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte'; import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
@@ -171,7 +171,7 @@
{/if} {/if}
<Card as="section" padding="md" ariaLabel="Operation details"> <Card as="section" padding="md" ariaLabel="Operation details">
<div class="context-section"><h2>Operation details</h2><dl class="metadata"> <div class="context-section"><h2>Operation details</h2><dl class="metadata">
<div><dt>Requested by</dt><dd>{deployment.requested_by ?? deployment.triggered_by}</dd></div> <div><dt>Requester</dt><dd>{provisioningRequester(deployment)}</dd></div>
<div><dt>Requested at</dt><dd><RelativeTime value={deployment.started_at} /></dd></div> <div><dt>Requested at</dt><dd><RelativeTime value={deployment.started_at} /></dd></div>
<div><dt>Submission attempted</dt><dd><RelativeTime value={deployment.submitted_at} missing="Not recorded" /></dd></div> <div><dt>Submission attempted</dt><dd><RelativeTime value={deployment.submitted_at} missing="Not recorded" /></dd></div>
<div><dt>Finished</dt><dd><RelativeTime value={deployment.finished_at} missing={outcome.active ? 'In progress' : 'Not recorded'} /></dd></div> <div><dt>Finished</dt><dd><RelativeTime value={deployment.finished_at} missing={outcome.active ? 'In progress' : 'Not recorded'} /></dd></div>
+24
View File
@@ -54,6 +54,16 @@ function gate<T>() {
} }
describe('initial administrator setup', () => { describe('initial administrator setup', () => {
it('reserves Maxis for system actions regardless of username case', async () => {
for (const username of ['Maxis', 'maxis', 'MAXIS', 'mAxIs']) {
await expect(auth.bootstrapAdmin({ username, password, token: setupToken }, '192.0.2.1'))
.rejects.toMatchObject({ status: 400, message: 'Maxis is reserved for system actions. Choose another username.' });
}
expect(auth.setupRequired()).toBe(true);
expect(auth.listUsers()).toEqual([]);
expect(database.db().prepare('SELECT COUNT(*) AS count FROM auth_sessions').get()).toEqual({ count: 0 });
});
it('requires an owner token and never creates an account from missing or wrong credentials', async () => { it('requires an owner token and never creates an account from missing or wrong credentials', async () => {
const input = { username: 'Admin', password, token: setupToken }; const input = { username: 'Admin', password, token: setupToken };
vi.stubEnv('EC_SETUP_TOKEN', undefined); vi.stubEnv('EC_SETUP_TOKEN', undefined);
@@ -136,6 +146,20 @@ describe('password storage and verification', () => {
}); });
describe('administrator user management', () => { describe('administrator user management', () => {
it('rejects the reserved system username for every role while allowing distinct usernames', async () => {
const admin = seedUser();
for (const role of ['admin', 'operator', 'viewer'] as const) {
for (const username of ['Maxis', 'maxis', 'MAXIS', 'mAxIs']) {
await expect(auth.createUser(admin.id, { username, role, password }))
.rejects.toMatchObject({ status: 400, message: 'Maxis is reserved for system actions. Choose another username.' });
}
}
expect(auth.listUsers()).toHaveLength(1);
expect(auth.listAuthAudit()).toEqual([]);
await expect(auth.createUser(admin.id, { username: 'maxis-operator', role: 'operator', password }))
.resolves.toMatchObject({ username: 'maxis-operator' });
});
it('creates each supported role with a forced initial password change and rejects case-insensitive duplicate names', async () => { it('creates each supported role with a forced initial password change and rejects case-insensitive duplicate names', async () => {
const admin = seedUser(); const admin = seedUser();
for (const role of ['admin', 'operator', 'viewer'] as const) { for (const role of ['admin', 'operator', 'viewer'] as const) {
@@ -62,7 +62,8 @@ describe('large native collection admission', () => {
const { previewCollection, queueCollection, getCollection } = await import('../../src/lib/server/fleet/collections.js'); const { previewCollection, queueCollection, getCollection } = await import('../../src/lib/server/fleet/collections.js');
const { db } = await import('../../src/lib/server/db/index.js'); const { db } = await import('../../src/lib/server/db/index.js');
const blueprint = saveBlueprint({ name: 'One hundred lobbies', entries: const blueprint = saveBlueprint({ name: 'One hundred lobbies', entries:
['theater', 'cosmodrome', 'coast', 'temple', 'moon'].map(mapSlug => ({ game: 't5', mapSlug, count: 20 })) }); ['theater', 'cosmodrome', 'coast', 'temple', 'moon', 'pentagon', 'prototype']
.map((mapSlug, index) => ({ game: 't5', mapSlug, count: index === 6 ? 10 : 15 })) });
const parse = JSON.parse; const parse = JSON.parse;
let manifestParses = 0; let manifestParses = 0;
+6 -5
View File
@@ -72,15 +72,16 @@ describe('multi-server blueprints', () => {
expect(old.entries).toEqual([{ count: 1, game: 't5', mapSlug: 'theater', mapName: 'Kino Der Toten' }]); expect(old.entries).toEqual([{ count: 1, game: 't5', mapSlug: 'theater', mapName: 'Kino Der Toten' }]);
expect(old).not.toHaveProperty('recipe'); expect(old).not.toHaveProperty('recipe');
expect(previewCreation({ blueprintId: 'legacy' }).recipe.files).toContainEqual(expect.objectContaining({ path: 'telemetry.gsc', versionId: current.id })); expect(previewCreation({ blueprintId: 'legacy' }).recipe.files).toContainEqual(expect.objectContaining({ path: 'telemetry.gsc', versionId: current.id }));
const edited = updateBlueprint('legacy', { name: 'Whole lineup', entries }); const updatedEntries = [{ ...entries[0], count: 15 }, ...entries.slice(1)];
expect(edited.totalServers).toBe(5); const edited = updateBlueprint('legacy', { name: 'Whole lineup', entries: updatedEntries });
expect(edited.entries[0]).toEqual({ ...old.entries[0], count: 2 }); expect(edited.totalServers).toBe(18);
expect(edited.entries[0]).toEqual({ ...old.entries[0], count: 15 });
const stored = db().prepare('SELECT recipe FROM server_blueprints WHERE id = ?').get('legacy') as { recipe: string }; const stored = db().prepare('SELECT recipe FROM server_blueprints WHERE id = ?').get('legacy') as { recipe: string };
expect(JSON.parse(stored.recipe)).toEqual({ format: 3, entries }); expect(JSON.parse(stored.recipe)).toEqual({ format: 3, entries: updatedEntries });
expect(listBlueprints()).toHaveLength(1); expect(listBlueprints()).toHaveLength(1);
}); });
it.each([[], [{ game: 't5', mapSlug: 'theater', count: 0 }], [{ game: 't5', mapSlug: 'theater', count: 21 }], it.each([[], [{ game: 't5', mapSlug: 'theater', count: 0 }], [{ game: 't5', mapSlug: 'theater', count: 16 }],
[{ game: 't5', mapSlug: 'theater', count: 1.5 }], [{ game: 't5', mapSlug: 'theater', count: '2' }], [{ game: 't5', mapSlug: 'theater', count: 1.5 }], [{ game: 't5', mapSlug: 'theater', count: '2' }],
[{ game: 't5', mapSlug: 'town', count: 1 }], [entries[0], entries[0]]].map(input => ({ input })))('rejects an invalid lineup: $input', async ({ input }) => { [{ game: 't5', mapSlug: 'town', count: 1 }], [entries[0], entries[0]]].map(input => ({ input })))('rejects an invalid lineup: $input', async ({ input }) => {
const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js'); const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js');
@@ -141,3 +141,15 @@ it('accepts each efficiency metric through the API when the qualified group is e
summary: { players: 1, matches: 4, kills: 80 } }); summary: { players: 1, matches: 4, kills: 80 } });
} }
}); });
it('does not use same-round departures to qualify for efficiency rankings', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
addSamples(db(), 'short-visit', [...samples(4), { completed: 0 }]);
db().prepare(`UPDATE leaderboard_match_players SET first_observed_round = highest_round
WHERE completed = 0`).run();
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
expect(getLeaderboard().entries[0]).toMatchObject({ gamesPlayed: 4, gamesCompleted: 4,
kills: 100, averageKills: 25, completionRate: 1 });
const { efficiencyMetrics } = await import('../../src/lib/leaderboard-efficiency.js');
for (const { key } of efficiencyMetrics) expect(getLeaderboard({ metric: key }).entries).toEqual([]);
});
+34
View File
@@ -269,6 +269,40 @@ it('keeps disconnected player records while completing only the final roster', a
roundsSurvived: 3, gamesPlayed: 1, gamesCompleted: 0 }, matches: [{ status: 'completed', completed: 0 }] }); roundsSurvived: 3, gamesPlayed: 1, gamesCompleted: 0 }, matches: [{ status: 'completed', completed: 0 }] });
}); });
it.each([
[server, 'zombie_theater', 't5'],
['t6_zombie_town_1', 'zm_transit', 't6']
])('does not count same-round departures as games played on %s', async (source, map, game) => {
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
const departed = player('departed', { kills: 10 });
const advanced = player('advanced', { kills: 20 });
const finished = player('finished', { kills: 30 });
await report('game_start', 0, [], source, map);
await report('heartbeat', 5, [departed, advanced], source, map);
await report('heartbeat', 5, [player(departed.guid, { kills: 15 }), advanced], source, map);
await report('heartbeat', 5, [advanced], source, map);
await report('round_change', 6, [advanced, finished], source, map);
await report('game_end', 6, [finished], source, map);
const { closeDb } = await import('../../src/lib/server/db/index.js');
closeDb();
const board = getLeaderboard({ game });
expect(board.entries.find(entry => entry.guid === departed.guid)).toMatchObject({
gamesPlayed: 0, gamesCompleted: 0, kills: 15, highestRound: 5,
averageKills: null, averageRounds: null, completionRate: null
});
expect(board.entries.find(entry => entry.guid === advanced.guid)).toMatchObject({ gamesPlayed: 1, gamesCompleted: 0 });
expect(board.entries.find(entry => entry.guid === finished.guid)).toMatchObject({ gamesPlayed: 1, gamesCompleted: 1 });
const profile = getPlayerProfile(departed.guid)!;
expect(profile).toMatchObject({ stats: { gamesPlayed: 0, kills: 15 },
maps: [{ gamesPlayed: 0, kills: 15 }], matchesTotal: 1,
matches: [{ kills: 15, completed: 0 }], records: { bestKills: { value: 15 } } });
expect(getLeaderboard({ game, map: profile.maps[0].key, metric: 'gamesPlayed' }).entries)
.toContainEqual(expect.objectContaining({ guid: departed.guid, gamesPlayed: 0 }));
expect(getPlayerProfile(departed.guid, 0, { game, map: profile.maps[0].key })?.matchesTotal).toBe(1);
});
it('starts separate matches for game starts, map changes, round resets, and new rounds after an end', async () => { it('starts separate matches for game starts, map changes, round resets, and new rounds after an end', async () => {
await report('game_start'); await report('game_start');
await report('round_change', 5, [player(undefined, { kills: 50 })]); await report('round_change', 5, [player(undefined, { kills: 50 })]);
+8
View File
@@ -387,6 +387,14 @@ describe('player profiles', () => {
for (const offset of [-1, NaN, 0.5]) expect(getPlayerProfile('101', offset)?.offset).toBe(0); for (const offset of [-1, NaN, 0.5]) expect(getPlayerProfile('101', offset)?.offset).toBe(0);
const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js'); const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
expect((await load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?offset=25') })).profile).toEqual(second); expect((await load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?offset=25') })).profile).toEqual(second);
// Historical same-round departures retain both pages even with no counted games.
handle.prepare(`UPDATE leaderboard_match_players SET first_observed_round = highest_round,
completed = 0 WHERE guid = '101'`).run();
const departures = getPlayerProfile('101', 25)!;
expect(departures).toMatchObject({ stats: { gamesPlayed: 0, kills: 770 }, matchesTotal: 34, offset: 25 });
expect(departures.matches).toHaveLength(9);
expect(departures.maps.every(map => map.gamesPlayed === 0)).toBe(true);
}); });
it('loads canonical profile URLs, encodes GUIDs and returns 404 for missing players', async () => { it('loads canonical profile URLs, encodes GUIDs and returns 404 for missing players', async () => {
+76
View File
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js'; import { newFixture, type Fixture } from '../helpers/fixture.js';
import { provisioningListSources, provisioningMatchesSource, provisioningSourceLabel, type ProvisioningListSource } from '../../src/lib/provisioning-list.js';
let fixture: Fixture; let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); }); beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
@@ -19,6 +20,79 @@ async function setup() {
} }
describe('bounded provisioning lists', () => { describe('bounded provisioning lists', () => {
it('keeps recovery sources concise as incident summaries change, including after retention', async () => {
const { add, handle, deploymentListPage } = await setup();
add('recovery', 'waiting', 'rebuild');
handle.prepare("INSERT INTO provisioning_sources VALUES ('recovery','watchdog','incident','t5_zombie_theater_1')").run();
handle.prepare(`INSERT INTO watchdog_incidents (id,server,kind,state,summary,first_seen_at,last_seen_at)
VALUES ('incident','t5_zombie_theater_1','health','recovering',?,'2026-01-01','2026-01-01')`)
.run('Rebuild queued with the latest promoted setup');
for (const summary of ['Rebuild queued with the latest promoted setup', 'Recovered game service']) {
handle.prepare('UPDATE watchdog_incidents SET summary=? WHERE id=?').run(summary, 'incident');
const row = deploymentListPage({ view: 'active' }).deployments[0];
expect(provisioningSourceLabel(row)).toBe('Recovery');
expect(row.sources).toEqual([{ kind: 'watchdog', source_id: 'incident', label: summary, href: '/maintenance/incident' }]);
}
handle.prepare("DELETE FROM watchdog_incidents WHERE id='incident'").run();
const retained = deploymentListPage({ view: 'active' }).deployments[0];
expect(provisioningSourceLabel(retained)).toBe('Recovery');
expect(retained.sources?.[0].href).toBeNull();
});
it.each(['active', 'history'] as const)('keeps %s source categories and client filters aligned with API filters', async view => {
const { add, handle, deploymentListPage } = await setup();
const categories: [ProvisioningListSource, string, string][] = [
['campaign', 'campaign', 'Campaign'], ['beta', 'beta', 'Beta'], ['maintenance', 'maintenance', 'Maintenance'],
['recovery', 'watchdog', 'Recovery'], ['autoscaling', 'autoscaling', 'Autoscaling'], ['rebalance', 'rebalance', 'Rebalance']
];
const state = view === 'active' ? 'waiting' : 'done';
for (const [source, kind] of categories) {
// Rebalance ownership is recorded only in triggered_by.
if (source !== 'rebalance') {
add(`${source}-linked`, state);
handle.prepare('INSERT INTO provisioning_sources VALUES (?,?,?,?)').run(`${source}-linked`, kind, 'expired', 't5_zombie_theater_1');
}
add(`${source}-legacy`, state);
handle.prepare('UPDATE server_deployments SET triggered_by=? WHERE id=?').run(`${kind}:expired`, `${source}-legacy`);
}
add('manual', state);
handle.prepare("UPDATE server_deployments SET triggered_by='ui' WHERE id='manual'").run();
add('system', state);
// A shared operation must be discoverable through each of its sources, even when initiated manually.
add('shared', state);
handle.prepare("UPDATE server_deployments SET triggered_by='ui' WHERE id='shared'").run();
handle.exec(`INSERT INTO provisioning_sources VALUES ('shared','campaign','expired','t5_zombie_theater_1');
INSERT INTO provisioning_sources VALUES ('shared','watchdog','expired','t5_zombie_theater_1');`);
const rows = deploymentListPage({ view }).deployments;
expect(rows).toHaveLength(14);
for (const [source, , label] of categories) {
for (const suffix of source === 'rebalance' ? ['legacy'] : ['linked', 'legacy']) {
expect(provisioningSourceLabel(rows.find(row => row.id === `${source}-${suffix}`)!)).toBe(label);
}
}
expect(provisioningSourceLabel(rows.find(row => row.id === 'manual')!)).toBe('Manual');
expect(provisioningSourceLabel(rows.find(row => row.id === 'system')!)).toBe('System');
const { GET } = await import('../../src/routes/api/provisioning/+server.js');
for (const source of provisioningListSources) {
const expected = source === 'manual' || source === 'system' ? [source]
: source === 'rebalance' ? ['rebalance-legacy']
: [`${source}-linked`, `${source}-legacy`, ...(['campaign', 'recovery'].includes(source) ? ['shared'] : [])];
expect(rows.filter(row => provisioningMatchesSource(row, source)).map(row => row.id).sort()).toEqual(expected.sort());
const result = await GET({ url: new URL(`http://localhost/api/provisioning?view=${view}&source=${source}`) }).json();
expect(result.total).toBe(expected.length);
expect(result.deployments.map((row: { id: string }) => row.id).sort()).toEqual(expected);
if (view === 'history') {
const page = deploymentListPage({ view, source, limit: 1, offset: expected.length - 1 });
expect(page.total).toBe(expected.length);
expect(page.deployments).toHaveLength(1);
expect(provisioningMatchesSource(page.deployments[0], source)).toBe(true);
}
}
});
it('filters readable sources before pagination and retains expired source references without broken links', async () => { it('filters readable sources before pagination and retains expired source references without broken links', async () => {
const { add, handle, deploymentListPage } = await setup(); const { add, handle, deploymentListPage } = await setup();
handle.exec(`INSERT INTO scripts(id,name,target_rel_path) VALUES(1,'Common scripts','common.gsc'); handle.exec(`INSERT INTO scripts(id,name,target_rel_path) VALUES(1,'Common scripts','common.gsc');
@@ -41,6 +115,8 @@ describe('bounded provisioning lists', () => {
expect(second.deployments[0]).toMatchObject({ id: 'old-campaign', sources: [{ label: 'Promotion · Beta #75', href: '/campaigns/campaign' }] }); expect(second.deployments[0]).toMatchObject({ id: 'old-campaign', sources: [{ label: 'Promotion · Beta #75', href: '/campaigns/campaign' }] });
const beta = deploymentListPage({ view: 'active', source: 'beta', status: 'pending' }); const beta = deploymentListPage({ view: 'active', source: 'beta', status: 'pending' });
expect(beta.deployments).toEqual([expect.objectContaining({ id: 'linked-beta', sources: [{ kind: 'beta', source_id: '75', label: 'Beta #75: Gameplay checks', href: '/betas/75' }] })]); expect(beta.deployments).toEqual([expect.objectContaining({ id: 'linked-beta', sources: [{ kind: 'beta', source_id: '75', label: 'Beta #75: Gameplay checks', href: '/betas/75' }] })]);
expect(provisioningSourceLabel(second.deployments[0])).toBe('Campaign');
expect(provisioningSourceLabel(beta.deployments[0])).toBe('Beta');
expect(deploymentListPage({ view: 'history', source: 'manual' }).deployments.map(row => row.id)).toEqual(['manual']); expect(deploymentListPage({ view: 'history', source: 'manual' }).deployments.map(row => row.id)).toEqual(['manual']);
expect(deploymentListPage({ view: 'history', source: 'system' }).total).toBe(30); expect(deploymentListPage({ view: 'history', source: 'system' }).total).toBe(30);
const { GET } = await import('../../src/routes/api/provisioning/+server.js'); const { GET } = await import('../../src/routes/api/provisioning/+server.js');
+14 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { ProvisioningDetail, ProvisioningProgress } from '../../src/lib/provisioning-detail.js'; import type { ProvisioningDetail, ProvisioningProgress } from '../../src/lib/provisioning-detail.js';
import { provisioningActions } from '../../src/lib/provisioning.js'; import { provisioningActions } from '../../src/lib/provisioning.js';
import { isProvisioningDetail, isProvisioningEventPage, provisioningGroups, provisioningOutcome, provisioningReturn } from '../../src/lib/ui/provisioning-presentation.js'; import { isProvisioningDetail, isProvisioningEventPage, provisioningGroups, provisioningOutcome, provisioningRequester, provisioningReturn } from '../../src/lib/ui/provisioning-presentation.js';
const at = '2026-09-28T12:00:00Z'; const at = '2026-09-28T12:00:00Z';
function progress(patch: Partial<ProvisioningProgress> = {}): ProvisioningProgress { function progress(patch: Partial<ProvisioningProgress> = {}): ProvisioningProgress {
@@ -19,6 +19,19 @@ function fixture(): ProvisioningDetail {
} }
describe('provisioning outcome and evidence presentation', () => { describe('provisioning outcome and evidence presentation', () => {
it('shows recorded usernames and preserves unknown manual requesters', () => {
expect(provisioningRequester({ triggered_by: 'ui', requested_by: 'alice' })).toBe('alice');
expect(provisioningRequester({ triggered_by: 'rebalance:run', requested_by: 'bob' })).toBe('bob');
expect(provisioningRequester({ triggered_by: 'ui', requested_by: null })).toBe('Not recorded');
expect(provisioningRequester({ triggered_by: 'ui' })).toBe('Not recorded');
});
it.each(['watchdog:incident', 'maintenance:job', 'autoscaling:policy', 'campaign:run', 'beta:75', 'rebalance:run', 'host-drain'])(
'shows Maxis for system work from %s', triggered_by => {
expect(provisioningRequester({ triggered_by, requested_by: null })).toBe('Maxis');
expect(provisioningRequester({ triggered_by })).toBe('Maxis');
});
it('distinguishes a failed creation retry from readiness verification and its linked recovery', () => { it('distinguishes a failed creation retry from readiness verification and its linked recovery', () => {
const detail = fixture(); const detail = fixture();
detail.deployment.state = 'failed'; detail.deployment.state = 'failed';
+16 -3
View File
@@ -9,17 +9,30 @@ afterEach(async () => { await fixture.cleanup(); });
it('persists the manual requester while preserving source filtering and historical records', async () => { it('persists the manual requester while preserving source filtering and historical records', async () => {
const { withRequestUsername } = await import('../../src/lib/server/auth/request-context.js'); const { withRequestUsername } = await import('../../src/lib/server/auth/request-context.js');
const { beginDeployment, getDeployment, listDeployments } = await import('../../src/lib/server/provisioning/history.js'); const { beginDeployment, getDeployment, listDeployments } = await import('../../src/lib/server/provisioning/history.js');
const { closeDb } = await import('../../src/lib/server/db/index.js'); const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const legacy = beginDeployment('legacy', 'restart'); const legacy = beginDeployment('legacy', 'restart');
const [manual, automatic] = await withRequestUsername('alice', async () => [ const [manual, automatic, rebalance] = await withRequestUsername('alice', async () => [
beginDeployment('manual', 'restart'), beginDeployment('automatic', 'restart', 'watchdog:incident') beginDeployment('manual', 'restart'), beginDeployment('automatic', 'restart', 'watchdog:incident'),
beginDeployment('rebalance', 'restart', 'rebalance:run')
]); ]);
closeDb(); closeDb();
expect(getDeployment(manual)?.deployment).toMatchObject({ triggered_by: 'ui', requested_by: 'alice' }); expect(getDeployment(manual)?.deployment).toMatchObject({ triggered_by: 'ui', requested_by: 'alice' });
expect(getDeployment(automatic)?.deployment).toMatchObject({ triggered_by: 'watchdog:incident', requested_by: null }); expect(getDeployment(automatic)?.deployment).toMatchObject({ triggered_by: 'watchdog:incident', requested_by: null });
expect(getDeployment(rebalance)?.deployment).toMatchObject({ triggered_by: 'rebalance:run', requested_by: 'alice' });
expect(getDeployment(legacy)?.deployment).toMatchObject({ triggered_by: 'ui', requested_by: null }); expect(getDeployment(legacy)?.deployment).toMatchObject({ triggered_by: 'ui', requested_by: null });
expect(listDeployments({ source: 'manual' }).map(row => row.id)).toEqual(expect.arrayContaining([manual, legacy])); expect(listDeployments({ source: 'manual' }).map(row => row.id)).toEqual(expect.arrayContaining([manual, legacy]));
expect(listDeployments({ source: 'manual' }).map(row => row.id)).not.toContain(automatic); expect(listDeployments({ source: 'manual' }).map(row => row.id)).not.toContain(automatic);
const { GET } = await import('../../src/routes/api/provisioning/+server.js');
for (const view of ['active', 'history']) {
if (view === 'history') db().prepare("UPDATE server_deployments SET state='done'").run();
const result = await GET({ url: new URL(`http://localhost/api/provisioning?view=${view}`) }).json();
expect(result.deployments).toEqual(expect.arrayContaining([
expect.objectContaining({ id: manual, requested_by: 'alice' }),
expect.objectContaining({ id: automatic, requested_by: null }),
expect.objectContaining({ id: rebalance, requested_by: 'alice' }),
expect.objectContaining({ id: legacy, requested_by: null })
]));
}
}); });
it('isolates overlapping users and stops attributing deferred work after the request returns', async () => { it('isolates overlapping users and stops attributing deferred work after the request returns', async () => {