Compare commits

..
8 Commits
56 changed files with 1292 additions and 373 deletions
+19 -3
View File
@@ -17,7 +17,7 @@ docker compose up -d --build
Open EC and create the first administrator using `EC_SETUP_TOKEN` (at least 32 Open EC and create the first administrator using `EC_SETUP_TOKEN` (at least 32
characters; generate it with `openssl rand -hex 32`). After setup, remove that characters; generate it with `openssl rand -hex 32`). After setup, remove that
token from the environment. Administrators can add administrators, operators and token from the environment. Administrators can add administrators, operators and
viewers from **Settings → Users**. See [Users and access](docs/authentication.md) viewers from **Administration → Users**. See [Users and access](docs/authentication.md)
for permissions, sessions, password recovery and upgrade instructions. for permissions, sessions, password recovery and upgrade instructions.
Operator pages and management APIs require login. Enrolled agents authenticate Operator pages and management APIs require login. Enrolled agents authenticate
@@ -32,11 +32,11 @@ including SQLite metadata and published game-file storage.
including its DLC files. There are no library names or version labels to enter. including its DLC files. There are no library names or version labels to enter.
Each upload becomes the latest files for that game after verification. Each upload becomes the latest files for that game after verification.
Uploads work before any host is enrolled. Uploads work before any host is enrolled.
2. **Hosts → Add Host**: configure a Debian 12/13 amd64 machine and run its 2. **Fleet → Hosts → Add Host**: configure a Debian 12/13 amd64 machine and run its
generated enrollment command. The host automatically prepares its Plutainer generated enrollment command. The host automatically prepares its Plutainer
runtime and the latest T5 and T6 files, when available. The host page runtime and the latest T5 and T6 files, when available. The host page
shows setup progress. Automatic agent updates are enabled by default. shows setup progress. Automatic agent updates are enabled by default.
3. Add the exact Plutonium server keys in **Keys**, or supply missing keys when 3. Add the exact Plutonium server keys in **Administration → Keys**, or supply missing keys when
launching. launching.
4. Launch a server or collection. Collection previews show cached capacity 4. Launch a server or collection. Collection previews show cached capacity
estimates; Apply saves the queue and opens its progress page. Provisioning estimates; Apply saves the queue and opens its progress page. Provisioning
@@ -61,6 +61,22 @@ continue. The host page shows update status and the last check.
## Fleet and operations ## Fleet and operations
The sidebar groups pages into **Fleet**, **Library**, **Operations**, **Analytics**,
and **Administration** (administrators only). On small screens, open the navigation
menu in the header. The account menu contains **My account**, including your
browser's display timezone; keyboard shortcuts are available through **?**.
**Operations → Overview** combines work that needs attention, running workflows,
and recent results. Related provisioning attempts and host commands stay grouped
under their workflow. Normal waits for empty lobbies remain under **Running**;
successful routine checks remain in **Host operation history**. Full history is
available on each workflow page.
Server details use **Live**, **Matches**, **Configuration**, **Diagnostics**, and
**History** sections. Hosts use **Overview**, **Servers**, **Setup**, and **Settings**.
Contextual Back links preserve the originating filters and views, while breadcrumbs
show each page's place in the navigation. Existing URLs and server tab links work.
The fleet registry lives in SQLite, independently of host filesystems. Status The fleet registry lives in SQLite, independently of host filesystems. Status
combines agent container health, fresh game telemetry and agent-local RCON. combines agent container health, fresh game telemetry and agent-local RCON.
Unreachable hosts report unknown instance state; they do not trigger duplicate Unreachable hosts report unknown instance state; they do not trigger duplicate
+1 -1
View File
@@ -20,7 +20,7 @@ their access checks. HTTPS requirements still apply. See the
password. Only the holder of the token can create the first administrator. password. Only the holder of the token can create the first administrator.
4. Remove `EC_SETUP_TOKEN` after setup and restart/recreate the controller. 4. Remove `EC_SETUP_TOKEN` after setup and restart/recreate the controller.
Setup remains closed once any user exists, even if the token is still set. Setup remains closed once any user exists, even if the token is still set.
5. Use **Settings → Users** to create additional accounts. Share temporary 5. Use **Administration → Users** to create additional accounts. Share temporary
passwords privately; each user must change theirs on first login. passwords privately; each user must change theirs on first login.
Existing installations become protected on upgrade. Existing game servers and Existing installations become protected on upgrade. Existing game servers and
+1 -1
View File
@@ -1,6 +1,6 @@
# Metrics # Metrics
Metrics is a primary navigation tab at `/metrics` (keyboard shortcut: `g m`). Metrics is under **Analytics** at `/metrics` (keyboard shortcut: `g m`).
Choose the past 24 hours, 7 days, or 30 days. The page refreshes every minute. Choose the past 24 hours, 7 days, or 30 days. The page refreshes every minute.
The overview shows **Time with players** (occupied server-hours divided by known The overview shows **Time with players** (occupied server-hours divided by known
+9
View File
@@ -113,6 +113,15 @@ export interface HostOperationSummary extends Omit<HostOperation, 'payload' | 'r
routine: boolean; routine: boolean;
} }
export type HostOperationFilter = 'important' | 'routine' | 'all'; export type HostOperationFilter = 'important' | 'routine' | 'all';
export interface HostOperationScope { host?: string; server?: string }
export function hostOperationScope(params: URLSearchParams): HostOperationScope {
const scope: HostOperationScope = {};
for (const key of ['host', 'server'] as const) {
const value = params.get(key);
if (value && value.length <= 512 && !/[\x00-\x1f\x7f]/.test(value)) scope[key] = value;
}
return scope;
}
export function isHostOperationFilter(value: unknown): value is HostOperationFilter { export function isHostOperationFilter(value: unknown): value is HostOperationFilter {
return typeof value === 'string' && ['important', 'routine', 'all'].includes(value); return typeof value === 'string' && ['important', 'routine', 'all'].includes(value);
} }
+35
View File
@@ -0,0 +1,35 @@
const origin = 'https://electric-cherry.invalid';
const pagePath = /^\/(?:$|operations|account|capacity|metrics|server-keys|settings(?:\/users)?|servers\/[^/]+|hosts(?:\/[^/]+)?|collections(?:\/launches\/[^/]+)?|(?:scripts|configs|plugins|betas|campaigns|maintenance|provisioning|matches)(?:\/[^/]+)?|game-files|leaderboard(?:\/players\/[^/]+(?:\/[^/]+)?)?)$/;
/** Only return to application pages, never an external URL or a mutation/API endpoint. */
export function safeReturnHref(value: string | null | undefined, fallback: string): string {
if (!value || value.length > 12000 || !value.startsWith('/') || value.startsWith('//') || /[\\\x00-\x1f\x7f]/.test(value)) return fallback;
try {
const url = new URL(value, origin);
if (url.origin !== origin || !pagePath.test(url.pathname)) return fallback;
return url.pathname + url.search + url.hash;
} catch { return fallback; }
}
export function contextHref(destination: string, source: URL | string): string {
const current = source instanceof URL ? source.pathname + source.search + source.hash : source;
const url = new URL(destination, origin);
const returnTo = safeReturnHref(current, '/');
url.searchParams.set('returnTo', returnTo);
return url.pathname + url.search + url.hash;
}
export function returnLabel(href: string): string {
const url = new URL(href, origin);
const path = url.pathname;
if (path.startsWith('/servers/')) return 'Back to server';
if (path.startsWith('/leaderboard/players/')) return 'Back to player';
if (path.startsWith('/collections/launches/')) return 'Back to collection launch';
if (path === '/collections' && url.searchParams.get('view') === 'active') return 'Back to active launches';
if (path === '/collections' && url.searchParams.get('view') === 'history') return 'Back to launch history';
if (path.startsWith('/matches/')) return 'Back to match';
if (path === '/hosts/operations') return 'Back to host operation history';
if (path.startsWith('/hosts/')) return 'Back to host';
const names: Record<string, string> = { '': 'Fleet', operations: 'Operations', collections: 'Collections', hosts: 'Hosts', capacity: 'Capacity', scripts: 'Scripts', configs: 'Configs', plugins: 'Plugins', 'game-files': 'Game Files', betas: 'Betas', campaigns: 'Campaigns', maintenance: 'Maintenance', provisioning: 'Provisioning', leaderboard: 'Leaderboard', matches: 'Match History', metrics: 'Metrics' };
return `Back to ${names[path.split('/')[1]] ?? 'previous page'}`;
}
+53
View File
@@ -0,0 +1,53 @@
import type { UserRole } from './auth.js';
export interface NavigationItem { label: string; href: string; shortcut?: string; matches?: (path: string) => boolean }
export interface NavigationGroup { id: string; label: string; admin?: boolean; items: NavigationItem[] }
export const navigationGroups: NavigationGroup[] = [
{ id: 'fleet', label: 'Fleet', items: [
{ label: 'Overview', href: '/', shortcut: 'f', matches: path => path === '/' || path.startsWith('/servers/') },
{ label: 'Collections', href: '/collections' },
{ label: 'Hosts', href: '/hosts', shortcut: 'o', matches: path => path === '/hosts' || /^\/hosts\/(?!operations(?:\/|$))/.test(path) },
{ label: 'Capacity', href: '/capacity' }
] },
{ id: 'library', label: 'Library', items: [
{ label: 'Scripts', href: '/scripts', shortcut: 's' },
{ label: 'Configs', href: '/configs', shortcut: 'c' },
{ label: 'Plugins', href: '/plugins', shortcut: 'p' },
{ label: 'Game Files', href: '/game-files' }
] },
{ id: 'operations', label: 'Operations', items: [
{ label: 'Overview', href: '/operations' },
{ label: 'Provisioning', href: '/provisioning', shortcut: 'd', matches: path => /^\/(provisioning|deployments)(\/|$)/.test(path) },
{ label: 'Betas', href: '/betas', shortcut: 'b' },
{ label: 'Campaigns', href: '/campaigns', shortcut: 'a' },
{ label: 'Maintenance', href: '/maintenance', shortcut: 'w', matches: path => /^\/(maintenance|watchdog)(\/|$)/.test(path) },
{ label: 'Host operation history', href: '/hosts/operations' }
] },
{ id: 'analytics', label: 'Analytics', items: [
{ label: 'Metrics', href: '/metrics', shortcut: 'm' },
{ label: 'Leaderboard', href: '/leaderboard', shortcut: 'l' },
{ label: 'Match History', href: '/matches' }
] },
{ id: 'administration', label: 'Administration', admin: true, items: [
{ label: 'Settings', href: '/settings', shortcut: 'h', matches: path => path === '/settings' },
{ label: 'Keys', href: '/server-keys', shortcut: 'k' },
{ label: 'Users', href: '/settings/users' }
] }
];
export function visibleNavigation(role: UserRole): NavigationGroup[] {
return navigationGroups.filter(group => !group.admin || role === 'admin');
}
export function navigationItemActive(item: NavigationItem, path: string): boolean {
return item.matches ? item.matches(path) : path === item.href || path.startsWith(item.href + '/');
}
export function navigationLocation(path: string, role: UserRole) {
for (const group of visibleNavigation(role)) {
const item = group.items.find(item => navigationItemActive(item, path));
if (item) return { group, item };
}
return null;
}
+28
View File
@@ -0,0 +1,28 @@
export type OperationView = 'attention' | 'running' | 'recent';
export type OperationKind = 'collection' | 'provisioning' | 'beta' | 'campaign' | 'maintenance' | 'host';
export interface OperationLink { key: string; title: string; href: string; status: string; detail: string }
export interface OperationItem extends OperationLink {
kind: OperationKind;
view: OperationView;
active: boolean;
updatedAt: string;
action: string;
progress: string | null;
resources: { label: string; href: string | null }[];
related: { label: string; href: string }[];
children: OperationLink[];
}
export interface OperationsOverview {
items: OperationItem[];
counts: Record<OperationView, number>;
generatedAt: string;
recentDays: number;
recentLimit: number;
}
export const operationKindLabels: Record<OperationKind, string> = {
collection: 'Collections', provisioning: 'Provisioning', beta: 'Betas', campaign: 'Campaigns', maintenance: 'Maintenance', host: 'Hosts'
};
export function operationView(value: string | null): OperationView {
return value === 'running' || value === 'recent' ? value : 'attention';
}
+13
View File
@@ -0,0 +1,13 @@
export type ServerSection = 'live' | 'matches' | 'configuration' | 'diagnostics' | 'history';
/** Legacy URLs still select their original content inside the new section. */
export function serverSection(tab: string | null): ServerSection {
if (tab === 'matches' || tab === 'history' || tab === 'diagnostics') return tab;
if (tab === 'config' || tab === 'inventory' || tab === 'configuration') return 'configuration';
if (tab === 'logs') return 'diagnostics';
return 'live';
}
export function serverSectionAnchor(tab: string | null): string | null {
return tab === 'chat' || tab === 'logs' || tab === 'inventory' ? tab : null;
}
+9 -5
View File
@@ -10,7 +10,7 @@ import { parseHostInventory } from './request-context.js';
import { fleetSettings } from '../settings.js'; import { fleetSettings } from '../settings.js';
import { recordInstanceResources, validateInstanceResources } from './calibration.js'; import { recordInstanceResources, validateInstanceResources } from './calibration.js';
import { refreshHostAvailability, scheduleAvailabilityRefresh } from './availability.js'; import { refreshHostAvailability, scheduleAvailabilityRefresh } from './availability.js';
import type { Host, HostDecommission, HostDeletion, HostInventory, HostMode, HostOperation, HostOperationFilter, HostOperationHistoryPage, HostOperationState } from '../../hosts.js'; import type { Host, HostDecommission, HostDeletion, HostInventory, HostMode, HostOperation, HostOperationFilter, HostOperationHistoryPage, HostOperationScope, HostOperationState } from '../../hosts.js';
export const AGENT_PROTOCOL_VERSION = 1; export const AGENT_PROTOCOL_VERSION = 1;
export const HEARTBEAT_MAX_AGE_MS = 90_000; export const HEARTBEAT_MAX_AGE_MS = 90_000;
@@ -395,12 +395,16 @@ export function listHostOperations(hostId: string, limit = 100, summary = false)
.all(hostId, Math.min(500, Math.max(1, limit))) as OperationRow[]).map(operationFromRow); .all(hostId, Math.min(500, Math.max(1, limit))) as OperationRow[]).map(operationFromRow);
} }
/** Browse retained history without loading command payloads or credential-bearing results. */ /** Browse retained history without loading command payloads or credential-bearing results. */
export function listHostOperationHistory(page = 1, filter: HostOperationFilter = 'important'): HostOperationHistoryPage { export function listHostOperationHistory(page = 1, filter: HostOperationFilter = 'important', scope: HostOperationScope = {}): HostOperationHistoryPage {
const handle = database(); const handle = database();
const pageSize = 25; const pageSize = 25;
const where = filter === 'all' ? '' : `WHERE ${filter === 'routine' ? 'o.routine=1' : importantOperationSql}`; const conditions = filter === 'all' ? [] : [filter === 'routine' ? 'o.routine=1' : `(${importantOperationSql})`];
const parameters: string[] = [];
if (scope.host) { conditions.push('o.host_id=?'); parameters.push(scope.host); }
if (scope.server) { conditions.push('o.server_name=?'); parameters.push(scope.server); }
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
return handle.transaction((): HostOperationHistoryPage => { return handle.transaction((): HostOperationHistoryPage => {
const { total } = handle.prepare(`SELECT COUNT(*) AS total FROM ec_host_operations o ${where}`).get() as { total: number }; const { total } = handle.prepare(`SELECT COUNT(*) AS total FROM ec_host_operations o ${where}`).get(...parameters) as { total: number };
const totalPages = Math.max(1, Math.ceil(total / pageSize)); const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(totalPages, Number.isSafeInteger(page) && page > 0 ? page : 1); const currentPage = Math.min(totalPages, Number.isSafeInteger(page) && page > 0 ? page : 1);
const operations = handle.prepare(`SELECT o.id,o.host_id AS hostId,h.name AS hostName,o.kind, const operations = handle.prepare(`SELECT o.id,o.host_id AS hostId,h.name AS hostName,o.kind,
@@ -411,7 +415,7 @@ export function listHostOperationHistory(page = 1, filter: HostOperationFilter =
FROM ec_host_operations o LEFT JOIN ec_hosts h ON h.id=o.host_id AND h.deleted_at IS NULL FROM ec_host_operations o LEFT JOIN ec_hosts h ON h.id=o.host_id AND h.deleted_at IS NULL
${where} ${where}
ORDER BY o.created_at DESC,o.rowid DESC LIMIT ? OFFSET ?`) ORDER BY o.created_at DESC,o.rowid DESC LIMIT ? OFFSET ?`)
.all(pageSize, (currentPage - 1) * pageSize) as HostOperationHistoryPage['operations']; .all(...parameters, pageSize, (currentPage - 1) * pageSize) as HostOperationHistoryPage['operations'];
return { operations: operations.map(operation => ({ ...operation, routine: !!operation.routine, return { operations: operations.map(operation => ({ ...operation, routine: !!operation.routine,
targetPath: safeOperationTarget(operation.kind, operation.targetPath) })), filter, targetPath: safeOperationTarget(operation.kind, operation.targetPath) })), filter,
page: currentPage, pageSize, total, totalPages }; page: currentPage, pageSize, total, totalPages };
+2
View File
@@ -110,6 +110,8 @@ export function ensureHostSchema(handle: Database.Database): void {
})(); })();
} }
handle.exec(`CREATE INDEX IF NOT EXISTS ec_host_operations_routine_history ON ec_host_operations(routine,created_at); handle.exec(`CREATE INDEX IF NOT EXISTS ec_host_operations_routine_history ON ec_host_operations(routine,created_at);
CREATE INDEX IF NOT EXISTS ec_host_operations_host_history ON ec_host_operations(host_id,created_at);
CREATE INDEX IF NOT EXISTS ec_host_operations_server_history ON ec_host_operations(server_name,created_at);
CREATE INDEX IF NOT EXISTS ec_host_operations_important_history ON ec_host_operations(created_at) CREATE INDEX IF NOT EXISTS ec_host_operations_important_history ON ec_host_operations(created_at)
WHERE ${importantOperationSql.replaceAll('o.', '')}`); WHERE ${importantOperationSql.replaceAll('o.', '')}`);
handle.exec('CREATE TABLE IF NOT EXISTS ec_host_schema_migrations (name TEXT PRIMARY KEY)'); handle.exec('CREATE TABLE IF NOT EXISTS ec_host_schema_migrations (name TEXT PRIMARY KEY)');
+214
View File
@@ -0,0 +1,214 @@
import { db } from './db/index.js';
import { ensureHostSchema } from './hosts/schema.js';
import { importantOperationSql } from './hosts/observations.js';
import { actionLabels, type ProvisioningAction } from '../provisioning.js';
import { serverTitle } from '../ui/server-labels.js';
import type { OperationItem, OperationsOverview, OperationView } from '../operations-overview.js';
const recentDays = 7;
const recentLimit = 100;
const inFlight = ['queued', 'waiting', 'running', 'awaiting-health'];
const failed = ['failed', 'interrupted', 'warning'];
interface Candidate { id: string; active: number; updatedAt: string }
interface Job extends Candidate { server: string; server_deleted_at: string | null; action: ProvisioningAction; state: string; stage: string; error: string | null; latest: number }
interface Member { parent: string; server: string; deleted: string | null; state: string; detail: string | null; players?: number | null }
interface ParentLink { parent: string; child: string }
/** A summary-only read: never load file contents, agent payloads, or observation snapshots. */
export function operationsOverview(now = Date.now()): OperationsOverview {
const handle = db();
ensureHostSchema(handle);
return handle.transaction(() => {
const since = new Date(now - recentDays * 86_400_000).toISOString();
// Active/unresolved work is retained regardless of age. Only finished history is capped.
function candidates<T extends Candidate>(query: string): T[] {
return handle.prepare(`WITH summaries AS (${query}) SELECT * FROM summaries
WHERE active=1 OR id IN (SELECT id FROM summaries WHERE active=0 AND updatedAt>=@since
ORDER BY updatedAt DESC,id DESC LIMIT ${recentLimit})
ORDER BY updatedAt DESC,id DESC`).all({ since }) as T[];
}
const items = new Map<string, OperationItem>();
const keyList = (prefix: string) => JSON.stringify([...items.keys()].filter(key => key.startsWith(prefix + ':')).map(key => key.slice(prefix.length + 1)));
const serverResource = (server: string, deleted: string | null) => ({ label: serverTitle(server) + (deleted ? ' · deleted' : ''), href: deleted ? null : `/servers/${encodeURIComponent(server)}` });
const make = (input: Omit<OperationItem, 'resources' | 'children' | 'related' | 'progress'> & Partial<Pick<OperationItem, 'resources' | 'children' | 'related' | 'progress'>>) => {
const item: OperationItem = { resources: [], children: [], related: [], progress: null, ...input };
items.set(item.key, item);
return item;
};
function addJob(job: Job) {
const active = !job.server_deleted_at && inFlight.includes(job.state);
const attention = !job.server_deleted_at && !!job.latest && failed.includes(job.state);
return make({ key: `provisioning:${job.id}`, kind: 'provisioning', title: `${actionLabels[job.action]} · ${serverTitle(job.server)}`,
href: `/provisioning/${encodeURIComponent(job.id)}`, active, view: attention ? 'attention' : active ? 'running' : 'recent',
status: attention ? 'Needs review' : active ? job.stage : job.state === 'done' ? 'Completed' : job.state,
detail: job.error || job.stage, updatedAt: job.updatedAt, action: attention ? 'Review operation' : active ? 'Follow progress' : 'View results',
resources: [serverResource(job.server, job.server_deleted_at)] });
}
const jobColumns = `d.id,d.server,d.server_deleted_at,d.action,d.state,d.stage,d.error,
COALESCE(d.finished_at,d.observed_at,d.started_at) AS updatedAt,
NOT EXISTS(SELECT 1 FROM server_deployments newer WHERE newer.server=d.server AND newer.rowid>d.rowid) AS latest`;
const jobs = candidates<Job>(`SELECT ${jobColumns},
(d.server_deleted_at IS NULL AND (d.state IN ('queued','waiting','running','awaiting-health') OR
(d.state IN ('failed','interrupted','warning') AND NOT EXISTS(SELECT 1 FROM server_deployments newer WHERE newer.server=d.server AND newer.rowid>d.rowid)))) AS active
FROM server_deployments d`);
jobs.forEach(addJob);
const membersSql = `SELECT collection_id,deployment_id FROM server_collection_members
UNION SELECT collection_id,deployment_id FROM server_collection_removals`;
const collections = candidates<Candidate & { name: string; total: number; done: number; errors: number; cancelled: number; inFlight: number }>(`SELECT c.id,c.name,COUNT(d.id) AS total,
COALESCE(SUM(d.state IN ('done','warning')),0) AS done,
COALESCE(SUM(d.server_deleted_at IS NULL AND d.state IN ('failed','interrupted','warning')
AND NOT EXISTS(SELECT 1 FROM server_deployments newer WHERE newer.server=d.server AND newer.rowid>d.rowid)),0) AS errors,
COALESCE(SUM(d.state='cancelled'),0) AS cancelled,
COALESCE(MAX(d.server_deleted_at IS NULL AND d.state IN ('queued','waiting','running','awaiting-health')),0) AS inFlight,
COALESCE(MAX(d.server_deleted_at IS NULL AND (d.state IN ('queued','waiting','running','awaiting-health') OR
(d.state IN ('failed','interrupted','warning') AND NOT EXISTS(SELECT 1 FROM server_deployments newer WHERE newer.server=d.server AND newer.rowid>d.rowid)))),0) AS active,
MAX(COALESCE(d.finished_at,d.observed_at,d.started_at,c.created_at)) AS updatedAt
FROM server_launch_collections c LEFT JOIN (${membersSql}) m ON m.collection_id=c.id
LEFT JOIN server_deployments d ON d.id=m.deployment_id GROUP BY c.id`);
for (const row of collections) make({ key: `collection:${row.id}`, kind: 'collection', title: row.name,
href: `/collections/launches/${encodeURIComponent(row.id)}`, active: !!row.inFlight,
view: row.errors ? 'attention' : row.active ? 'running' : 'recent', status: row.errors ? 'Needs review' : row.active ? 'In progress' : row.cancelled ? 'Finished with cancellations' : 'Completed',
detail: row.errors ? `${row.errors} operations reported issues. Open the launch to review the results.` : row.active ? 'Servers may wait for safe lobbies, capacity, or startup slots.' : row.cancelled ? `${row.cancelled} operations were cancelled.` : 'Collection launch finished.',
progress: `${row.done} / ${row.total} operations completed`, updatedAt: row.updatedAt, action: row.errors ? 'Review launch' : row.active ? 'Follow launch' : 'View results' });
const campaigns = candidates<Candidate & { action: string; beta_id: number | null; rolled_back_by: string | null; total: number; done: number; errors: number; waiting: number; held: number; inFlight: number }>(`SELECT d.id,d.action,d.beta_id,d.rolled_back_by,COUNT(s.server) AS total,
COALESCE(SUM(s.restart_state IN ('done','skipped','cancelled')),0) AS done,
COALESCE(SUM(s.server_deleted_at IS NULL AND s.restart_state IN ('failed','manual')),0) AS errors,
COALESCE(SUM(s.server_deleted_at IS NULL AND s.restart_state='pending' AND s.players>0),0) AS waiting,
COALESCE(SUM(s.server_deleted_at IS NULL AND s.restart_state='held'),0) AS held,
COALESCE(SUM(s.server_deleted_at IS NULL AND s.restart_state NOT IN ('done','skipped','cancelled','failed','manual')),0) AS inFlight,
(d.rolled_back_by IS NULL AND COALESCE(MAX(s.server_deleted_at IS NULL AND s.restart_state NOT IN ('done','skipped','cancelled')),0)) AS active,
MAX(COALESCE(s.checked_at,d.finished_at,d.started_at)) AS updatedAt
FROM deployments d LEFT JOIN deployment_servers s ON s.deployment_id=d.id GROUP BY d.id`);
for (const row of campaigns) make({ key: `campaign:${row.id}`, kind: 'campaign', title: `${row.action.charAt(0).toUpperCase() + row.action.slice(1)} campaign`,
href: `/campaigns/${encodeURIComponent(row.id)}`, active: !!row.active && row.inFlight > 0,
view: row.active ? row.errors ? 'attention' : 'running' : 'recent',
status: row.rolled_back_by ? 'Rolled back' : row.errors ? 'Needs attention' : row.held ? 'Held by a Beta' : row.waiting ? 'Waiting for empty lobbies' : row.active ? 'In progress' : 'Finished',
detail: row.errors ? 'Review failed or manually held activation before continuing.' : row.held ? 'A Beta holds one or more servers. Open the Campaign to review eligibility.' : row.waiting ? 'Occupied lobbies will finish before activation. No intervention is required.' : 'Files activate when servers are safe to restart.',
progress: `${row.done} / ${row.total} servers finished`, updatedAt: row.updatedAt, action: row.active && row.errors ? 'Review Campaign' : row.active ? 'Follow Campaign' : 'View results',
related: row.beta_id ? [{ label: `Beta #${row.beta_id}`, href: `/betas/${row.beta_id}` }] : [] });
const betas = candidates<Candidate & { state: string; notes: string | null; outcome: string | null; reason: string | null; total: number; ready: number; errors: number; waiting: number }>(`SELECT CAST(b.id AS TEXT) AS id,b.state,b.notes,p.outcome,p.reason,COUNT(s.server) AS total,
COALESCE(SUM(s.stage='ready'),0) AS ready,COALESCE(SUM(s.stage='failed' OR s.server_deleted_at IS NOT NULL),0) AS errors,
COALESCE(SUM(s.stage='awaiting-empty'),0) AS waiting,
(b.state IN ('running','ready-promote')) AS active,
MAX(COALESCE(b.closed_at,s.updated_at,b.opened_at)) AS updatedAt
FROM betas b LEFT JOIN beta_servers s ON s.beta_id=b.id LEFT JOIN beta_test_plans p ON p.beta_id=b.id GROUP BY b.id`);
for (const row of betas) {
const attention = !!row.active && (row.errors > 0 || row.state === 'ready-promote' || ['failed','paused','inconclusive'].includes(row.outcome ?? ''));
make({ key: `beta:${row.id}`, kind: 'beta', title: row.notes || `Beta #${row.id}`, href: `/betas/${row.id}`, active: !!row.active,
view: attention ? 'attention' : row.active ? 'running' : 'recent',
status: row.state === 'ready-promote' ? 'Ready for promotion review' : attention ? 'Needs attention' : row.waiting ? 'Waiting for empty lobbies' : row.active ? 'Testing' : row.state === 'promoted' ? 'Promoted' : 'Rolled back',
detail: row.reason || (row.state === 'ready-promote' ? 'Review the tested changes and promotion scope.' : row.errors ? 'Review the server results and recovery options.' : row.waiting ? 'Testing continues when the selected lobbies are empty.' : 'Open the Beta for its checks, files, and server results.'),
progress: `${row.ready} / ${row.total} servers ready`, updatedAt: row.updatedAt, action: attention ? 'Review Beta' : row.active ? 'Follow Beta' : 'View results' });
}
const incidents = candidates<Candidate & { server: string; server_deleted_at: string | null; state: string; kind: string; summary: string }>(`SELECT id,server,server_deleted_at,state,kind,summary,
(server_deleted_at IS NULL AND state IN ('open','recovering','attention')) AS active,
COALESCE(finished_at,last_seen_at,first_seen_at) AS updatedAt FROM watchdog_incidents`);
for (const row of incidents) make({ key: `incident:${row.id}`, kind: 'maintenance', title: row.kind === 'drift' ? 'Configuration drift recovery' : 'Server health recovery',
href: `/maintenance/${encodeURIComponent(row.id)}`, active: !!row.active, view: row.active ? row.state === 'attention' ? 'attention' : 'running' : 'recent',
status: row.state === 'attention' ? 'Needs attention' : row.state === 'open' ? 'Detected' : row.state, detail: row.summary,
updatedAt: row.updatedAt, action: row.state === 'attention' ? 'Review recovery' : row.active ? 'Follow recovery' : 'View results', resources: [serverResource(row.server, row.server_deleted_at)] });
const maintenance = candidates<Candidate & { server: string; server_deleted_at: string | null; state: string; kind: string; detail: string | null }>(`SELECT CAST(m.id AS TEXT) AS id,m.server,m.server_deleted_at,m.state,m.kind,m.detail,
(m.server_deleted_at IS NULL AND (m.state IN ('queued','restarting') OR (m.state='failed' AND NOT EXISTS(SELECT 1 FROM maintenance_jobs n WHERE n.server=m.server AND n.kind=m.kind AND n.id>m.id)))) AS active,
COALESCE(m.finished_at,m.restarted_at,m.requested_at) AS updatedAt FROM maintenance_jobs m`);
for (const row of maintenance) make({ key: `maintenance:${row.id}`, kind: 'maintenance', title: row.kind === 'update' ? 'Plutonium update' : row.kind === 'schedule' ? 'Scheduled restart' : 'Maintenance restart',
href: `/maintenance?view=activity&server=${encodeURIComponent(row.server)}#job-${row.id}`, active: !!row.active && row.state !== 'failed',
view: row.active ? row.state === 'failed' ? 'attention' : 'running' : 'recent', status: row.state === 'failed' ? 'Needs attention' : row.state,
detail: row.detail || 'Open Maintenance for progress and verification.', updatedAt: row.updatedAt, action: row.state === 'failed' ? 'Review Maintenance' : 'View Maintenance', resources: [serverResource(row.server, row.server_deleted_at)] });
// Batch child membership; include older attempts for each selected parent without loading unrelated history.
const relationships = handle.prepare(`
SELECT 'collection:'||collection_id AS parent,'provisioning:'||deployment_id AS child FROM (${membersSql}) WHERE collection_id IN (SELECT value FROM json_each(@collections))
UNION SELECT CASE kind WHEN 'watchdog' THEN 'incident:' ELSE kind||':' END||source_id,'provisioning:'||operation_id FROM provisioning_sources
WHERE CASE kind WHEN 'watchdog' THEN 'incident:' ELSE kind||':' END||source_id IN (SELECT value FROM json_each(@keys))
UNION SELECT 'campaign:'||deployment_id,'provisioning:'||provisioning_id FROM deployment_servers WHERE deployment_id IN (SELECT value FROM json_each(@campaigns)) AND provisioning_id IS NOT NULL
UNION SELECT 'beta:'||beta_id,'provisioning:'||provisioning_id FROM beta_servers WHERE beta_id IN (SELECT value FROM json_each(@betas)) AND provisioning_id IS NOT NULL
UNION SELECT 'maintenance:'||id,'provisioning:'||provisioning_id FROM maintenance_jobs WHERE id IN (SELECT value FROM json_each(@maintenance)) AND provisioning_id IS NOT NULL
UNION SELECT 'incident:'||incident_id,'provisioning:'||operation_id FROM watchdog_actions WHERE incident_id IN (SELECT value FROM json_each(@incidents))
UNION SELECT 'incident:'||incident_id,'campaign:'||campaign_id FROM watchdog_actions WHERE incident_id IN (SELECT value FROM json_each(@incidents)) AND campaign_id IS NOT NULL
UNION SELECT 'provisioning:'||creation_id,'provisioning:'||cleanup_id FROM native_creation_cancellations WHERE creation_id IN (SELECT value FROM json_each(@provisioning))
`).all({ collections: keyList('collection'), campaigns: keyList('campaign'), betas: keyList('beta'), maintenance: keyList('maintenance'), incidents: keyList('incident'), provisioning: keyList('provisioning'), keys: JSON.stringify([...items.keys()]) }) as ParentLink[];
const missingJobs = [...new Set(relationships.filter(link => link.child.startsWith('provisioning:') && !items.has(link.child)).map(link => link.child.slice(13)))];
if (missingJobs.length) {
(handle.prepare(`SELECT ${jobColumns},0 AS active FROM server_deployments d WHERE d.id IN (SELECT value FROM json_each(?))`).all(JSON.stringify(missingJobs)) as Job[]).forEach(addJob);
}
// A recovery can outlive the recent-history window of its repair Campaign.
const missingCampaigns = [...new Set(relationships.filter(link => link.child.startsWith('campaign:') && !items.has(link.child)).map(link => link.child.slice(9)))];
if (missingCampaigns.length) {
const rows = handle.prepare(`SELECT id,action,COALESCE(finished_at,started_at) AS updatedAt FROM deployments WHERE id IN (SELECT value FROM json_each(?))`).all(JSON.stringify(missingCampaigns)) as { id: string; action: string; updatedAt: string }[];
for (const row of rows) make({ key: `campaign:${row.id}`, kind: 'campaign', title: `${row.action} campaign`, href: `/campaigns/${encodeURIComponent(row.id)}`,
view: 'recent', active: false, status: 'Finished', detail: 'Open the Campaign to review its server results.', updatedAt: row.updatedAt, action: 'View results' });
}
const members = handle.prepare(`SELECT 'campaign:'||deployment_id AS parent,server,server_deleted_at AS deleted,restart_state AS state,error AS detail,players FROM deployment_servers
WHERE deployment_id IN (SELECT value FROM json_each(@campaigns))
UNION ALL SELECT 'beta:'||beta_id,server,server_deleted_at,stage,error,NULL FROM beta_servers WHERE beta_id IN (SELECT value FROM json_each(@betas))`)
.all({ campaigns: keyList('campaign'), betas: keyList('beta') }) as Member[];
for (const member of members) {
const parent = items.get(member.parent)!;
parent.resources.push(serverResource(member.server, member.deleted));
if (member.detail && !member.deleted && (member.state === 'failed' || member.state === 'manual')) parent.detail = member.detail;
}
const hostOperations = candidates<Candidate & { hostId: string; hostName: string | null; server: string | null; kind: string; state: string; error: string | null; routine: number; observationSuccess: number }>(`SELECT o.id,o.host_id AS hostId,h.name AS hostName,o.server_name AS server,o.kind,o.state,o.error,o.routine,o.observation_success AS observationSuccess,
(o.routine=0 AND o.state IN ('pending','accepted','running')) AS active,o.updated_at AS updatedAt
FROM ec_host_operations o LEFT JOIN ec_hosts h ON h.id=o.host_id AND h.deleted_at IS NULL WHERE ${importantOperationSql}
AND (o.routine=0 OR NOT EXISTS(SELECT 1 FROM ec_host_operations newer WHERE newer.host_id=o.host_id AND newer.kind=o.kind AND newer.instance_id IS o.instance_id AND newer.rowid>o.rowid))`);
const agentParents = handle.prepare(`SELECT operation_id AS id,'provisioning:'||deployment_id AS parent FROM native_provisioning WHERE operation_id IS NOT NULL
UNION SELECT stage_operation_id,'provisioning:'||deployment_id FROM native_provisioning WHERE stage_operation_id IS NOT NULL
UNION SELECT remove_operation_id,'provisioning:'||deployment_id FROM ec_host_migrations WHERE remove_operation_id IS NOT NULL`).all() as { id: string; parent: string }[];
const represented = new Map(agentParents.map(row => [row.id, row.parent]));
for (const row of hostOperations) {
const attention = ['failed','unknown'].includes(row.state) || !!row.error || !!row.routine && row.state === 'completed' && !row.observationSuccess;
const item = make({ key: `host:${row.id}`, kind: 'host', title: `${row.kind} · ${row.hostName ?? 'Deleted host'}`,
href: `/hosts/operations?host=${encodeURIComponent(row.hostId)}`, view: attention ? 'attention' : row.active ? 'running' : 'recent', active: !!row.active,
status: attention ? 'Needs attention' : row.state, detail: row.error || (attention ? 'The latest host check reported a problem.' : 'Open host operation history for details.'),
updatedAt: row.updatedAt, action: 'Review host activity', resources: [{ label: row.hostName ?? 'Deleted host', href: row.hostName === null ? null : `/hosts/${encodeURIComponent(row.hostId)}` }] });
const parent = represented.get(row.id);
if (parent && items.has(parent)) relationships.push({ parent, child: item.key });
}
// Preserve source semantics: an old failed attempt must not turn resolved recovery back into an alert.
const nested = new Set<string>();
// Completed parents outside the history window also own their failed attempts.
const historicalParents = handle.prepare(`WITH parents AS (
SELECT 'provisioning:'||s.operation_id AS child FROM provisioning_sources s WHERE
(s.kind='watchdog' AND EXISTS(SELECT 1 FROM watchdog_incidents p WHERE p.id=s.source_id)) OR
(s.kind='campaign' AND EXISTS(SELECT 1 FROM deployments p WHERE p.id=s.source_id)) OR
(s.kind='beta' AND EXISTS(SELECT 1 FROM betas p WHERE p.id=s.source_id)) OR
(s.kind='maintenance' AND EXISTS(SELECT 1 FROM maintenance_jobs p WHERE p.id=s.source_id))
UNION SELECT 'provisioning:'||operation_id FROM watchdog_actions
) SELECT child FROM parents WHERE child IN (SELECT value FROM json_each(?))
`).all(JSON.stringify([...items.keys()].filter(key => key.startsWith('provisioning:')))) as { child: string }[];
const representedChildren = new Set(relationships.map(link => link.child));
for (const { child: childKey } of historicalParents) {
const child = items.get(childKey);
if (child && !child.active && !representedChildren.has(childKey)) nested.add(childKey);
}
for (const { parent: parentKey, child: childKey } of relationships) {
const parent = items.get(parentKey), child = items.get(childKey);
if (!parent || !child || parent === child) continue;
if (!parent.children.some(item => item.key === child.key)) parent.children.push({ key: child.key, title: child.title, href: child.href, status: child.status, detail: child.detail });
parent.resources.push(...child.resources);
if (parent.view === 'recent' && child.active) { parent.view = 'running'; parent.active = true; parent.status = 'Related work still running'; }
nested.add(child.key);
}
function relatedResources(item: OperationItem, seen = new Set<string>()): OperationItem['resources'] {
if (seen.has(item.key)) return [];
seen.add(item.key);
return [...item.resources, ...item.children.flatMap(link => {
const child = items.get(link.key);
return child ? relatedResources(child, seen) : [];
})];
}
const result = [...items.values()].filter(item => !nested.has(item.key)).map(item => ({ ...item,
resources: [...new Map(relatedResources(item).map(resource => [resource.label + resource.href, resource])).values()],
children: item.children.sort((a,b) => a.key.localeCompare(b.key))
})).sort((a,b) => b.updatedAt.localeCompare(a.updatedAt) || a.key.localeCompare(b.key));
const counts: Record<OperationView, number> = { attention: 0, running: 0, recent: 0 };
for (const item of result) counts[item.view]++;
return { items: result, counts, generatedAt: new Date(now).toISOString(), recentDays, recentLimit };
})();
}
+7 -3
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js';
import BackLink from '$ui/components/BackLink.svelte';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
@@ -285,13 +288,14 @@
<Breadcrumbs items={[{ label: crumbLabel, href: basePath }, { label: a.name }]} /> <Breadcrumbs items={[{ label: crumbLabel, href: basePath }, { label: a.name }]} />
{/snippet} {/snippet}
</SectionHeader> </SectionHeader>
<BackLink fallback={basePath} />
{#if scopeComplete}<div class="scope-complete" role="status"><strong>Scope saved.</strong> {scopeComplete.composed ? `Intended configuration may change on ${scopeComplete.count} eligible servers.` : scopeComplete.detached ? `Inventory tracking detached on ${scopeComplete.detached} servers; live files remain.` : `${scopeComplete.count} servers are in the old or new scope.`} Maintenance detects resulting drift and rebuilds eligible servers when automation is enabled. <a href={scopeComplete.href}>View drift recovery →</a></div>{/if} {#if scopeComplete}<div class="scope-complete" role="status"><strong>Scope saved.</strong> {scopeComplete.composed ? `Intended configuration may change on ${scopeComplete.count} eligible servers.` : scopeComplete.detached ? `Inventory tracking detached on ${scopeComplete.detached} servers; live files remain.` : `${scopeComplete.count} servers are in the old or new scope.`} Maintenance detects resulting drift and rebuilds eligible servers when automation is enabled. <a href={scopeComplete.href}>View drift recovery →</a></div>{/if}
{#if latestVersion}<Card padding="lg"><div class="release-summary"><div><h2>Latest {artifactVersionLabel(latestVersion)}</h2><StatusBadge state={latestVersion.id === a.current_version_id ? 'promoted' : latestVersion.state} /><p>{latestVersion.notes || 'No version notes provided.'}</p></div>{#if runningBetaId !== null}<Button variant="primary" href={`/betas/${runningBetaId}`}>View active beta</Button>{:else if latestVersion.id !== a.current_version_id}<Button variant="primary" href={betaSelectionHref([{ scriptId: id, versionId: latestVersion.id }])}>Test latest version</Button>{:else}<span class="dim">Latest version is promoted</span>{/if}</div></Card>{/if} {#if latestVersion}<Card padding="lg"><div class="release-summary"><div><h2>Latest {artifactVersionLabel(latestVersion)}</h2><StatusBadge state={latestVersion.id === a.current_version_id ? 'promoted' : latestVersion.state} /><p>{latestVersion.notes || 'No version notes provided.'}</p></div>{#if runningBetaId !== null}<Button variant="primary" href={contextHref(`/betas/${runningBetaId}`, $page.url)}>View active beta</Button>{:else if latestVersion.id !== a.current_version_id}<Button variant="primary" href={contextHref(betaSelectionHref([{ scriptId: id, versionId: latestVersion.id }]), $page.url)}>Test latest version</Button>{:else}<span class="dim">Latest version is promoted</span>{/if}</div></Card>{/if}
{#if runningBetaId} {#if runningBetaId}
<div class="warn-banner"> <div class="warn-banner">
Beta <a href="/betas/{runningBetaId}"><strong>#{runningBetaId}</strong></a> is running for this {family}. Beta <a href={contextHref(`/betas/${runningBetaId}`, $page.url)}><strong>#{runningBetaId}</strong></a> is running for this {family}.
Rollback or promote it before making changes. Rollback or promote it before making changes.
</div> </div>
{/if} {/if}
@@ -378,7 +382,7 @@
<Badge variant="online" size="sm">Promoted</Badge> <Badge variant="online" size="sm">Promoted</Badge>
{:else} {:else}
<StatusBadge state={v.state} size="sm" /> <StatusBadge state={v.state} size="sm" />
{#if runningBetaId === null}<Button size="sm" href={betaSelectionHref([{scriptId:id,versionId:v.id}])}>Test version</Button>{/if} {#if runningBetaId === null}<Button size="sm" href={contextHref(betaSelectionHref([{scriptId:id,versionId:v.id}]), $page.url)}>Test version</Button>{/if}
{/if} {/if}
<details class="version-technical"><summary>File details</summary><span>SHA-256: {v.sha256}</span><span>Size: {v.size.toLocaleString()} bytes</span></details> <details class="version-technical"><summary>File details</summary><span>SHA-256: {v.sha256}</span><span>Size: {v.size.toLocaleString()} bytes</span></details>
</div> </div>
+13
View File
@@ -0,0 +1,13 @@
<script lang="ts">
import { page } from '$app/stores';
import { returnLabel, safeReturnHref } from '$lib/navigation-context.js';
let { fallback }: { fallback: string } = $props();
const href = $derived(safeReturnHref($page.url.searchParams.get('returnTo'), fallback));
</script>
<a class="back-link" {href}>← {returnLabel(href)}</a>
<style>
.back-link { display: inline-block; align-self: start; width: fit-content; font-size: var(--text-sm); color: var(--accent-primary-hover); text-decoration: none; }
.back-link:hover { text-decoration: underline; }
</style>
+4 -2
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Tabs from './primitives/Tabs.svelte'; import Tabs from './primitives/Tabs.svelte';
import ListFooter from './primitives/ListFooter.svelte'; import ListFooter from './primitives/ListFooter.svelte';
@@ -129,14 +131,14 @@
{#if filteredBaselines.length} {#if filteredBaselines.length}
<section class="section"><div class="section-heading"><h2>Baselines <span>{filteredBaselines.length}</span></h2><p>One foundation per game</p></div> <section class="section"><div class="section-heading"><h2>Baselines <span>{filteredBaselines.length}</span></h2><p>One foundation per game</p></div>
<div class="baseline-grid">{#each filteredBaselines as artifact (artifact.id)} <div class="baseline-grid">{#each filteredBaselines as artifact (artifact.id)}
<a class="baseline-card" href="/configs/{artifact.id}"><div class="card-top"><span class="game-label">{artifact.scope_value?.toUpperCase() ?? 'UNSCOPED'}</span><span class="open-arrow" aria-hidden="true">↗</span></div><h3>{artifact.name}</h3><div class="versions">{@render versionBadges(artifact)}</div><div class="card-footer"><span>{artifact.servers.length} matching server{artifact.servers.length === 1 ? '' : 's'}</span><span>{artifact.versionCount} version{artifact.versionCount === 1 ? '' : 's'}</span></div></a> <a class="baseline-card" href={contextHref(`/configs/${artifact.id}`, $page.url)}><div class="card-top"><span class="game-label">{artifact.scope_value?.toUpperCase() ?? 'UNSCOPED'}</span><span class="open-arrow" aria-hidden="true">↗</span></div><h3>{artifact.name}</h3><div class="versions">{@render versionBadges(artifact)}</div><div class="card-footer"><span>{artifact.servers.length} matching server{artifact.servers.length === 1 ? '' : 's'}</span><span>{artifact.versionCount} version{artifact.versionCount === 1 ? '' : 's'}</span></div></a>
{/each}</div> {/each}</div>
</section> </section>
{/if} {/if}
{#if filteredOverlays.length} {#if filteredOverlays.length}
<section class="section"><div class="section-heading"><h2>Overlays <span>{filteredOverlays.length}</span></h2><p>Small changes with a clear scope</p></div> <section class="section"><div class="section-heading"><h2>Overlays <span>{filteredOverlays.length}</span></h2><p>Small changes with a clear scope</p></div>
<div class="overlay-list">{#each filteredOverlays.slice(0,limit) as artifact (artifact.id)} <div class="overlay-list">{#each filteredOverlays.slice(0,limit) as artifact (artifact.id)}
<a class="overlay-row" href="/configs/{artifact.id}"><div class="overlay-main"><span class="overlay-icon" aria-hidden="true">≋</span><div><h3>{artifact.name}</h3><span class="scope-label">{artifact.scope_type ?? 'Unscoped'}{artifact.scope_value ? ` · ${artifact.scope_type === 'map' ? mapLabel(artifact.scope_value) : artifact.scope_value.toUpperCase()}` : ''}</span></div></div><div class="versions">{@render versionBadges(artifact)}</div><span class="servers">{artifact.servers.length} server{artifact.servers.length === 1 ? '' : 's'}</span><span class="open-arrow" aria-hidden="true">↗</span></a> <a class="overlay-row" href={contextHref(`/configs/${artifact.id}`, $page.url)}><div class="overlay-main"><span class="overlay-icon" aria-hidden="true">≋</span><div><h3>{artifact.name}</h3><span class="scope-label">{artifact.scope_type ?? 'Unscoped'}{artifact.scope_value ? ` · ${artifact.scope_type === 'map' ? mapLabel(artifact.scope_value) : artifact.scope_value.toUpperCase()}` : ''}</span></div></div><div class="versions">{@render versionBadges(artifact)}</div><span class="servers">{artifact.servers.length} server{artifact.servers.length === 1 ? '' : 's'}</span><span class="open-arrow" aria-hidden="true">↗</span></a>
{/each}</div> {/each}</div>
</section> </section>
{/if} {/if}
+11 -2
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js';
import { goto } from '$app/navigation';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import type { ServerMatch } from '$lib/match-history.js'; import type { ServerMatch } from '$lib/match-history.js';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
@@ -13,6 +16,12 @@
let matches = $state<ServerMatch[]>([]); let matches = $state<ServerMatch[]>([]);
let total = $state(0); let total = $state(0);
let limit = $state(25); let limit = $state(25);
$effect(() => { limit = Math.min(10000, Math.max(25, Number($page.url.searchParams.get('matches')) || 25)); });
function saveContext() {
const url = new URL($page.url);
if (limit > 25) url.searchParams.set('matches', String(limit)); else url.searchParams.delete('matches');
void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
}
let loading = $state(true); let loading = $state(true);
let error = $state(''); let error = $state('');
@@ -63,7 +72,7 @@
{#if server === undefined}<td><HistoricalServerIdentity name={match.server} deletedAt={match.server_deleted_at} href={`/servers/${encodeURIComponent(match.server)}?tab=matches`} /></td>{/if} {#if server === undefined}<td><HistoricalServerIdentity name={match.server} deletedAt={match.server_deleted_at} href={`/servers/${encodeURIComponent(match.server)}?tab=matches`} /></td>{/if}
<td> <td>
{#if match.status === 'completed'} {#if match.status === 'completed'}
<a class="match-link" href={`/matches/${match.id}`} aria-label={`View ${match.map} match from ${$timeFormat.dateTime(match.startedAt)}`}> <a class="match-link" href={contextHref(`/matches/${match.id}`, $page.url)} aria-label={`View ${match.map} match from ${$timeFormat.dateTime(match.startedAt)}`}>
{match.map}<small>View match →</small> {match.map}<small>View match →</small>
</a> </a>
{:else}{match.map}{/if} {:else}{match.map}{/if}
@@ -76,7 +85,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<ListFooter total={Math.min(total, 10000)} bind:limit label="matches" /> <ListFooter total={Math.min(total, 10000)} bind:limit onchange={saveContext} label="matches" />
{#if total > 10000}<p>Showing the latest 10,000 matches.</p>{/if} {#if total > 10000}<p>Showing the latest 10,000 matches.</p>{/if}
{/if} {/if}
</Card> </Card>
+81
View File
@@ -0,0 +1,81 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { page } from '$app/stores';
import type { UserRole } from '$lib/auth.js';
import { navigationItemActive, navigationLocation, visibleNavigation } from '$lib/navigation.js';
let { role, open = $bindable(false) }: { role: UserRole; open?: boolean } = $props();
let collapsed = $state<Record<string, boolean>>({});
let drawer = $state<HTMLDialogElement>();
const groups = $derived(visibleNavigation(role));
const location = $derived(navigationLocation($page.url.pathname, role));
const storageKey = 'electric-cherry:navigation';
function save() {
try { localStorage.setItem(storageKey, JSON.stringify(collapsed)); } catch { /* Navigation works without storage. */ }
}
function toggle(id: string) { collapsed = { ...collapsed, [id]: !collapsed[id] }; save(); }
$effect(() => {
const path = $page.url.pathname;
const currentRole = role;
untrack(() => {
const current = navigationLocation(path, currentRole);
if (current) collapsed = { ...collapsed, [current.group.id]: false };
});
});
$effect(() => {
if (open && drawer && !drawer.open) drawer.showModal();
else if (!open && drawer?.open) drawer.close();
});
onMount(() => {
try {
const saved = JSON.parse(localStorage.getItem(storageKey) ?? '{}');
if (saved && typeof saved === 'object') collapsed = Object.fromEntries(groups.map(group => [group.id, saved[group.id] === true]));
} catch { /* Ignore invalid or unavailable preferences. */ }
if (location) collapsed = { ...collapsed, [location.group.id]: false };
const desktop = matchMedia('(min-width: 1000px)');
const resized = () => { if (desktop.matches) open = false; };
desktop.addEventListener('change', resized);
return () => desktop.removeEventListener('change', resized);
});
</script>
{#snippet links(prefix: string)}
<nav aria-label="Primary">
{#each groups as group (group.id)}
<section class:current={location?.group.id === group.id}>
<button class="group" aria-expanded={!collapsed[group.id]} aria-controls={`${prefix}-${group.id}`} onclick={() => toggle(group.id)}>
{group.label}<span aria-hidden="true">{collapsed[group.id] ? '+' : '−'}</span>
</button>
<div id={`${prefix}-${group.id}`} hidden={collapsed[group.id]}>
{#each group.items as item (item.href)}
<a href={item.href} aria-current={navigationItemActive(item, $page.url.pathname) ? 'page' : undefined}>{item.label}</a>
{/each}
</div>
</section>
{/each}
</nav>
{/snippet}
<aside class="sidebar">{@render links('desktop-navigation')}</aside>
<dialog class="drawer" id="navigation-drawer" bind:this={drawer} aria-label="Navigation" onclose={() => open = false}
onclick={event => { if (event.target === drawer) { const box = drawer!.getBoundingClientRect(); if (event.clientX > box.right || event.clientX < box.left || event.clientY < box.top || event.clientY > box.bottom) open = false; } }}>
<div class="drawer-heading"><strong>Navigation</strong><button aria-label="Close navigation" onclick={() => open = false}>×</button></div>
{@render links('mobile-navigation')}
</dialog>
<style>
.sidebar { position: fixed; inset: 60px auto 0 0; width: 232px; overflow-y: auto; overscroll-behavior: contain; background: var(--surface); border-right: 1px solid var(--border-subtle); padding: 16px 12px 32px; z-index: 15; }
nav { display: grid; gap: 12px; }
.group { display: flex; justify-content: space-between; align-items: center; width: 100%; padding: 8px 12px; color: var(--text-secondary); font: inherit; font-size: var(--text-xs); font-weight: 600; background: none; border: 0; border-radius: var(--radius-sm); cursor: pointer; }
.current .group { color: var(--text-primary); }
a { display: block; padding: 8px 12px; border-radius: var(--radius-sm); color: var(--text-secondary); text-decoration: none; font-size: var(--text-sm); line-height: 1.5; }
a:hover, .group:hover { background: var(--surface-raised); color: var(--text-primary); }
a[aria-current] { color: var(--accent-primary-hover); background: color-mix(in oklab, var(--accent-primary) 12%, var(--surface)); box-shadow: inset 3px 0 var(--accent-primary); }
.drawer { position: fixed; inset: 0 auto 0 0; margin: 0; width: min(320px, 90vw); height: 100dvh; max-height: 100dvh; border: 0; border-right: 1px solid var(--border-strong); border-radius: 0; padding: max(16px, env(safe-area-inset-top)) 16px max(24px, env(safe-area-inset-bottom)); color: var(--text-primary); background: var(--surface); }
.drawer::backdrop { background: #0008; }
.drawer-heading { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.drawer-heading button { width: 44px; height: 44px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface-raised); color: inherit; font: inherit; font-size: 24px; cursor: pointer; }
.drawer a, .drawer .group { min-height: 44px; display: flex; align-items: center; }
@media (max-width: 999px) { .sidebar { display: none; } }
</style>
+3 -1
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js';
import RoundTelemetry from './RoundTelemetry.svelte'; import RoundTelemetry from './RoundTelemetry.svelte';
import ServerIdentity from './ServerIdentity.svelte'; import ServerIdentity from './ServerIdentity.svelte';
import type { RosterPlayer, Health } from '$ui/stores/servers.js'; import type { RosterPlayer, Health } from '$ui/stores/servers.js';
@@ -93,7 +95,7 @@
</script> </script>
<Card <Card
href="/servers/{name}" href={contextHref(`/servers/${encodeURIComponent(name)}`, $page.url)}
elevation="interactive" elevation="interactive"
padding="md" padding="md"
class={restarting ? 'restarting' : health === 'offline' ? 'offline' : ''} class={restarting ? 'restarting' : health === 'offline' ? 'offline' : ''}
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores';
import { navigationLocation } from '$lib/navigation.js';
interface Crumb { interface Crumb {
label: string; label: string;
href?: string | null; href?: string | null;
@@ -9,18 +11,20 @@
} }
let { items }: Props = $props(); let { items }: Props = $props();
const group = $derived(navigationLocation($page.url.pathname, $page.data.user?.role ?? 'viewer')?.group);
const trail = $derived(group && group.label !== items[0]?.label ? [{ label: group.label }, ...items] : items);
</script> </script>
<nav class="crumbs" aria-label="Breadcrumb"> <nav class="crumbs" aria-label="Breadcrumb">
<ol> <ol>
{#each items as c, i (i + c.label)} {#each trail as c, i (i + c.label)}
<li> <li>
{#if c.href && i < items.length - 1} {#if c.href && i < trail.length - 1}
<a href={c.href}>{c.label}</a> <a href={c.href}>{c.label}</a>
{:else} {:else}
<span aria-current={i === items.length - 1 ? 'page' : undefined}>{c.label}</span> <span aria-current={i === trail.length - 1 ? 'page' : undefined}>{c.label}</span>
{/if} {/if}
{#if i < items.length - 1}<span class="sep" aria-hidden="true">›</span>{/if} {#if i < trail.length - 1}<span class="sep" aria-hidden="true">›</span>{/if}
</li> </li>
{/each} {/each}
</ol> </ol>
+11 -7
View File
@@ -2,8 +2,7 @@
// browser's View Transitions API so route changes cross-fade cleanly and // browser's View Transitions API so route changes cross-fade cleanly and
// shared-element transitions (Fleet card → Server detail hero) morph. // shared-element transitions (Fleet card → Server detail hero) morph.
// //
// Chromium ships the API today; Firefox and Safari fall through to a plain // Browsers without the API use ordinary navigation.
// navigation — same behavior as before, just without the transition.
// Structural type — accepts whatever SvelteKit passes to onNavigate without // Structural type — accepts whatever SvelteKit passes to onNavigate without
// coupling to a specific @sveltejs/kit version's type name. // coupling to a specific @sveltejs/kit version's type name.
@@ -13,15 +12,20 @@ export function enableViewTransitions(navigation: NavigationLike): Promise<void>
// Feature-detect without a custom Document extension — the DOM lib type // Feature-detect without a custom Document extension — the DOM lib type
// varies between TS versions, and any narrowing has to survive both. // varies between TS versions, and any narrowing has to survive both.
const start = (document as unknown as { const start = (document as unknown as {
startViewTransition?: (cb: () => void | Promise<void>) => unknown; startViewTransition?: (cb: () => void | Promise<void>) => { ready: Promise<void>; finished: Promise<void> };
}).startViewTransition; }).startViewTransition;
if (typeof start !== 'function') return; if (typeof start !== 'function') return;
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
start.call(document, async () => { try {
resolve(); const transition = start.call(document, async () => {
await navigation.complete; resolve();
}); // Another navigation can supersede this one during its animation.
await navigation.complete.catch(() => {});
});
void transition.ready.catch(() => {});
void transition.finished.catch(() => {}).finally(resolve);
} catch { resolve(); }
}); });
} }
+49 -198
View File
@@ -6,6 +6,8 @@
import { startServerStream } from '$ui/stores/servers.js'; import { startServerStream } from '$ui/stores/servers.js';
import { startFleetMonitor, hydrateFleet } from '$ui/stores/fleet.js'; import { startFleetMonitor, hydrateFleet } from '$ui/stores/fleet.js';
import { page } from '$app/stores'; import { page } from '$app/stores';
import Navigation from '$ui/components/Navigation.svelte';
import { navigationLocation, visibleNavigation } from '$lib/navigation.js';
import BrandMark from '$ui/components/BrandMark.svelte'; import BrandMark from '$ui/components/BrandMark.svelte';
import Confirmation from '$ui/components/Confirmation.svelte'; import Confirmation from '$ui/components/Confirmation.svelte';
import Dialog from '$ui/components/primitives/Dialog.svelte'; import Dialog from '$ui/components/primitives/Dialog.svelte';
@@ -80,12 +82,7 @@
onMount(() => { onMount(() => {
const stopTimeZone = startTimeZonePreference(); const stopTimeZone = startTimeZonePreference();
// Two-key nav shortcuts (Gmail-style): `g f` fleet, `g d` provisioning, const shortcuts = Object.fromEntries(visibleNavigation(data.user?.role ?? 'viewer').flatMap(group => group.items).filter(item => item.shortcut).map(item => [item.shortcut!, item.href]));
// `g b` betas, `g s` scripts. Ignored while typing in inputs.
const shortcuts: Record<string, string> = {
f: '/', d: '/provisioning', a: '/campaigns', w: '/maintenance', b: '/betas',
k: '/server-keys', s: '/scripts', c: '/configs', p: '/plugins', l: '/leaderboard', h: '/settings', m: '/metrics', o: '/hosts'
};
let armed = false; let armed = false;
let armTimer: ReturnType<typeof setTimeout> | null = null; let armTimer: ReturnType<typeof setTimeout> | null = null;
@@ -125,49 +122,26 @@
// Non-Chromium browsers fall through to plain navigation. // Non-Chromium browsers fall through to plain navigation.
onNavigate(enableViewTransitions); onNavigate(enableViewTransitions);
const tabs = [ const location = $derived(navigationLocation($page.url.pathname, data.user?.role ?? 'viewer'));
{ href: '/', label: 'Fleet', match: (p: string) => p === '/' || p.startsWith('/servers/') }, const shortcuts = $derived(visibleNavigation(data.user?.role ?? 'viewer').flatMap(group => group.items).filter(item => item.shortcut));
{ href: '/hosts', label: 'Hosts', match: (p: string) => /^\/hosts(\/|$)/.test(p) },
{ href: '/capacity', label: 'Capacity', match: (p: string) => p === '/capacity' },
{ href: '/scripts', label: 'Library', match: (p: string) => /^\/(scripts|configs|plugins|game-files)(\/|$)/.test(p) },
{ href: '/provisioning', label: 'Operations', match: (p: string) => /^\/(provisioning|deployments|collections|campaigns|maintenance|watchdog|server-keys)(\/|$)/.test(p) },
{ href: '/betas', label: 'Betas', match: (p: string) => p.startsWith('/betas') },
{ href: '/settings', label: 'Settings', match: (p: string) => p.startsWith('/settings') },
{ href: '/leaderboard', label: 'Leaderboard', match: (p: string) => p.startsWith('/leaderboard') },
{ href: '/matches', label: 'Match History', match: (p: string) => p.startsWith('/matches') },
{ href: '/metrics', label: 'Metrics', match: (p: string) => p === '/metrics' }
];
const visibleTabs = $derived(tabs.filter(tab => tab.label !== 'Settings' || data.user?.role === 'admin'));
const secondary = $derived(tabs.find(tab => tab.label === 'Library')!.match($page.url.pathname)
? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }, { href: '/game-files', label: 'Game Files' }]
: tabs.find(tab => tab.label === 'Hosts')!.match($page.url.pathname)
? [{ href: '/hosts', label: 'All hosts' }, { href: '/hosts/operations', label: 'Operation history' }]
: tabs.find(tab => tab.label === 'Operations')!.match($page.url.pathname)
? [{ href: '/provisioning', label: 'Provisioning' }, { href: '/collections', label: 'Collections' }, { href: '/campaigns', label: 'Campaigns' }, { href: '/maintenance', label: 'Maintenance' }, ...(data.user?.role === 'admin' ? [{ href: '/server-keys', label: 'Keys' }] : [])]
: tabs.find(tab => tab.label === 'Settings')!.match($page.url.pathname) ? [{ href: '/settings', label: 'Preferences' }, ...(data.user?.role === 'admin' ? [{ href: '/settings/users', label: 'Users' }] : [])] : []);
function secondaryActive(href: string): boolean {
const path = $page.url.pathname;
if (href === '/settings') return path === href;
if (href === '/hosts') return path === href || /^\/hosts\/(?!operations(?:\/|$))/.test(path);
return path === href || path.startsWith(href + '/');
}
</script> </script>
<svelte:window onkeydown={closeMenu} onclick={dismissAccount} /> <svelte:window onkeydown={closeMenu} onclick={dismissAccount} />
<div class="shell"> <div class="shell" class:with-navigation={sessionReady}>
<a class="skip-link" href="#main-content">Skip to content</a> <a class="skip-link" href="#main-content">Skip to content</a>
{#if publicPage} {#if publicPage}
<header class="auth-brand"><BrandMark size="md" /></header> <header class="auth-brand"><BrandMark size="md" /></header>
{:else} {:else}
<nav aria-label="Primary"> <header class="app-header">
<a class="brand-link" href={sessionReady ? '/' : '/account'} aria-label="Electric Cherry home"> <a class="brand-link" href={sessionReady ? '/' : '/account'} aria-label="Electric Cherry home">
<BrandMark size="sm" /> <BrandMark size="sm" />
</a> </a>
{#if sessionReady && location}<div class="location"><span>{location.group.label}</span><span aria-hidden="true"> / </span><strong>{location.item.label}</strong></div>{/if}
<div class="header-actions"> <div class="header-actions">
{#if sessionReady}<button class="help-button" onclick={openHelp} aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)">?</button>{/if}
{#if sessionReady}<button class="menu-toggle" bind:this={menuButton} type="button" {#if sessionReady}<button class="menu-toggle" bind:this={menuButton} type="button"
aria-label={menuOpen ? 'Close menu' : 'Menu'} aria-expanded={menuOpen} aria-controls="primary-links" aria-label={menuOpen ? 'Close menu' : 'Menu'} aria-expanded={menuOpen} aria-controls="navigation-drawer"
onclick={() => { menuOpen = !menuOpen; accountOpen = false; }}> onclick={() => { menuOpen = !menuOpen; accountOpen = false; }}>
<span class="menu-label">{menuOpen ? 'Close' : 'Menu'}</span> <span class="menu-label">{menuOpen ? 'Close' : 'Menu'}</span>
<span aria-hidden="true">{menuOpen ? '×' : '☰'}</span> <span aria-hidden="true">{menuOpen ? '×' : '☰'}</span>
@@ -192,22 +166,8 @@
</div> </div>
{/if} {/if}
</div> </div>
{#if sessionReady} </header>
<div class="tabs" id="primary-links" class:expanded={menuOpen}> {#if sessionReady && data.user}<Navigation role={data.user.role} bind:open={menuOpen} />{/if}
{#each visibleTabs as t (t.href)}
{@const on = t.match($page.url.pathname)}
<a
href={t.href}
class:on
aria-current={on ? 'page' : undefined}
>
<span class="lbl">{t.label}</span>
</a>
{/each}
</div>
{/if}
</nav>
{#if sessionReady && secondary.length}<div class="subnav"><div role="navigation" aria-label="Section">{#each secondary as item}<a href={item.href} aria-current={secondaryActive(item.href) ? 'page' : undefined}>{item.label}</a>{/each}</div></div>{/if}
{/if} {/if}
<main id="main-content" tabindex="-1" class:auth-main={publicPage}> <main id="main-content" tabindex="-1" class:auth-main={publicPage}>
{#if sessionReady && data.user?.role === 'viewer'}<aside class="viewer-note"><strong>Viewer access</strong><span>You can read fleet status and summaries. Operating servers or viewing raw configs, file changes, logs, and telemetry requires an operator account.</span></aside>{/if} {#if sessionReady && data.user?.role === 'viewer'}<aside class="viewer-note"><strong>Viewer access</strong><span>You can read fleet status and summaries. Operating servers or viewing raw configs, file changes, logs, and telemetry requires an operator account.</span></aside>{/if}
@@ -215,7 +175,7 @@
</main> </main>
<Confirmation /> <Confirmation />
<Dialog bind:open={helpOpen} title="Keyboard shortcuts" description="Press g, then the destination key. Shortcuts are inactive while typing or using a dialog."> <Dialog bind:open={helpOpen} title="Keyboard shortcuts" description="Press g, then the destination key. Shortcuts are inactive while typing or using a dialog.">
<dl class="shortcuts">{#each [['g f','Fleet'],['g o','Hosts'],['g s','Scripts'],['g c','Configs'],['g p','Plugins'],['g d','Provisioning'],['g a','Campaigns'],['g w','Maintenance'],['g b','Betas'],...(data.user?.role === 'admin' ? [['g h','Settings'],['g k','Keys']] : []),['g l','Leaderboard'],['g m','Metrics'],['?','This help']] as [keys,label]}<div><dt><kbd>{keys}</kbd></dt><dd>{label}</dd></div>{/each}</dl> <dl class="shortcuts">{#each shortcuts as item}<div><dt><kbd>g {item.shortcut}</kbd></dt><dd>{item.href === '/' ? 'Fleet' : item.label}</dd></div>{/each}<div><dt><kbd>?</kbd></dt><dd>This help</dd></div></dl>
</Dialog> </Dialog>
</div> </div>
@@ -260,157 +220,48 @@
.shell { min-height: 100vh; } .shell { min-height: 100vh; }
.auth-brand { display:flex; justify-content:center; padding:48px 20px 20px; } .auth-brand { display:flex; justify-content:center; padding:48px 20px 20px; }
main { min-width:0; overflow-wrap:anywhere; padding:var(--space-6) var(--space-8) var(--space-16); max-width:1600px; margin:0 auto; view-transition-name:main; }
.with-navigation main { margin-left:232px; }
main.auth-main { max-width:520px; padding-top:16px; } main.auth-main { max-width:520px; padding-top:16px; }
.viewer-note { display:flex; gap:8px 16px; flex-wrap:wrap; padding:14px 18px; margin-bottom:24px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface); font-size:var(--text-sm); color:var(--text-secondary); } .viewer-note strong { color:var(--text-primary); } .viewer-note { display:flex; gap:8px 16px; flex-wrap:wrap; padding:14px 18px; margin-bottom:24px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface); font-size:var(--text-sm); color:var(--text-secondary); }
.viewer-note strong { color:var(--text-primary); }
.app-header { position:sticky; top:0; z-index:20; display:flex; align-items:center; gap:24px; height:60px; padding:0 20px; border-bottom:1px solid var(--border-subtle); background:var(--surface); }
.brand-link { flex-shrink:0; display:flex; align-items:center; text-decoration:none; }
.location { font-size:var(--text-sm); color:var(--text-secondary); min-width:0; }
.location strong { color:var(--text-primary); font-weight:500; }
.header-actions { display:flex; align-items:center; gap:8px; margin-left:auto; }
.account-menu { position:relative; } .account-menu { position:relative; }
.header-actions { order:2; display:flex; align-items:center; gap:var(--space-2); margin-left:auto; } .account-button,.help-button,.menu-toggle { display:inline-flex; align-items:center; justify-content:center; min-width:44px; height:44px; padding:8px 12px; border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); color:var(--text-primary); font:inherit; cursor:pointer; }
.account-button { display:grid; place-items:center; width:44px; height:44px; padding:0; border:1px solid var(--border-strong); border-radius:50%; background:color-mix(in oklab, var(--accent-primary) 12%, var(--surface)); color:var(--accent-primary-hover); font:inherit; font-weight:var(--weight-semibold); cursor:pointer; transition:background-color var(--dur-fast) var(--ease-standard), border-color var(--dur-fast) var(--ease-standard); } .account-button { border-radius:50%; color:var(--accent-primary-hover); background:color-mix(in oklab,var(--accent-primary) 12%,var(--surface)); font-weight:600; }
.account-button:hover,.account-button[aria-expanded='true'] { background:var(--surface-raised); border-color:var(--accent-primary-hover); }
.account-name { padding:8px 12px 12px; margin-bottom:4px; border-bottom:1px solid var(--border-subtle); color:var(--text-secondary); font-size:var(--text-sm); overflow-wrap:anywhere; }
.account-links { position:absolute; right:0; top:calc(100% + 8px); width:220px; max-width:calc(100vw - 32px); max-height:calc(100dvh - 84px); overflow-y:auto; z-index:30; display:grid; padding:8px; background:var(--surface); border:1px solid var(--border-strong); border-radius:var(--radius-md); box-shadow:0 12px 32px #0005; } .account-links { position:absolute; right:0; top:calc(100% + 8px); width:220px; max-width:calc(100vw - 32px); max-height:calc(100dvh - 84px); overflow-y:auto; z-index:30; display:grid; padding:8px; background:var(--surface); border:1px solid var(--border-strong); border-radius:var(--radius-md); box-shadow:0 12px 32px #0005; }
.account-name { padding:8px 12px 12px; margin-bottom:4px; border-bottom:1px solid var(--border-subtle); color:var(--text-secondary); font-size:var(--text-sm); overflow-wrap:anywhere; }
.account-links a,.account-links button { text-align:left; padding:10px 12px; color:var(--text-primary); background:none; border:0; border-radius:var(--radius-sm); font:inherit; font-size:var(--text-sm); cursor:pointer; text-decoration:none; } .account-links a,.account-links button { text-align:left; padding:10px 12px; color:var(--text-primary); background:none; border:0; border-radius:var(--radius-sm); font:inherit; font-size:var(--text-sm); cursor:pointer; text-decoration:none; }
.account-links a:hover,.account-links button:hover { background:var(--surface-raised); } .account-links a:hover,.account-links button:hover,.help-button:hover,.menu-toggle:hover { background:var(--surface-raised); }
.account-links p { padding:0 12px; font-size:var(--text-xs); color:var(--state-danger); } .account-links p { padding:0 12px; font-size:var(--text-xs); color:var(--state-danger); }
.menu-toggle { display:none; gap:8px; }
nav { .shortcuts { display:grid; gap:12px; margin:0; }
position: sticky; .shortcuts > div { display:flex; gap:20px; align-items:center; }
top: 0; .shortcuts dt { min-width:55px; }
z-index: 20; .shortcuts dd { margin:0; font-size:var(--text-sm); }
display: flex; kbd { font-family:var(--font-mono); padding:3px 8px; border:1px solid var(--border-strong); border-radius:5px; }
align-items: center; .skip-link { position:fixed; left:16px; top:-80px; z-index:300; padding:12px 16px; background:var(--surface-raised); border-radius:var(--radius-md); }
gap: var(--space-6); .skip-link:focus { top:12px; }
height: 60px; :global(input),:global(select),:global(textarea) { min-width:0; max-width:100%; }
padding: 0 var(--space-8); :global(input),:global(select),:global(textarea),:global(button),:global(a) { scroll-margin-block:90px 100px; }
background: color-mix(in oklab, var(--surface) 75%, transparent); :global(dialog) { max-height:calc(100dvh - 32px); overflow-y:auto; overscroll-behavior:contain; }
border-bottom: 1px solid var(--border-subtle); @media (max-width:999px) {
.with-navigation main { margin-inline:auto; }
.menu-toggle { display:inline-flex; }
.location { display:none; }
} }
@supports (backdrop-filter: blur(4px)) { @media (max-width:720px) {
nav { .app-header { padding-inline:12px; gap:8px; }
backdrop-filter: blur(20px) saturate(1.4);
-webkit-backdrop-filter: blur(20px) saturate(1.4);
background: color-mix(in oklab, var(--surface) 62%, transparent);
}
}
.brand-link {
flex-shrink: 0;
display: flex;
align-items: center;
text-decoration: none;
border-radius: var(--radius-md);
}
.brand-link:focus-visible { box-shadow: var(--focus-ring); }
.tabs {
order: 1;
display: flex;
align-items: center;
gap: 2px;
margin-left: var(--space-4);
}
.tabs a {
white-space: nowrap;
position: relative;
color: var(--text-tertiary);
text-decoration: none;
padding: 8px 14px;
border-radius: var(--radius-pill);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-normal);
transition:
color var(--dur-fast) var(--ease-standard),
background-color var(--dur-fast) var(--ease-standard);
}
.tabs a:hover { color: var(--text-primary); }
.tabs a:focus-visible { box-shadow: var(--focus-ring); }
.tabs a.on {
color: var(--text-primary);
background: var(--surface-raised);
/* Shared-element transition — pill slides smoothly between tabs
when navigating. Chromium only; falls back to instant swap. */
view-transition-name: nav-active;
}
.shortcuts { display: grid; gap: 12px; margin: 0; }
.shortcuts > div { display: flex; gap: 20px; align-items: center; }
.shortcuts dt { min-width: 55px; }
.shortcuts dd { margin: 0; font-size: var(--text-sm); color: var(--text-secondary); }
kbd { font-family: var(--font-mono); padding: 3px 8px; border: 1px solid var(--border-strong); border-radius: 5px; }
.menu-toggle { display: none; }
main {
min-width: 0;
overflow-wrap: anywhere;
padding: var(--space-6) var(--space-8) var(--space-16);
max-width: 1440px;
margin: 0 auto;
view-transition-name: main;
}
/* The full navigation needs more room than a phone or portrait tablet. */
@media (max-width: 1500px) {
nav {
flex-wrap: wrap;
justify-content: space-between;
gap: 0;
height: auto;
min-height: 60px;
padding: calc(8px + env(safe-area-inset-top)) max(var(--space-4), env(safe-area-inset-right)) 8px max(var(--space-4), env(safe-area-inset-left));
}
.header-actions { order:0; }
.menu-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-2);
min-height: 44px;
padding: 8px 12px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
background: var(--surface-raised);
color: var(--text-primary);
font: inherit;
font-size: var(--text-sm);
cursor: pointer;
}
.menu-toggle:focus-visible { box-shadow: var(--focus-ring); }
.tabs { display: none; width: 100%; margin: 0; }
.tabs.expanded { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-2); padding: var(--space-3) 0 4px; max-height:calc(100dvh - 72px - env(safe-area-inset-top)); overflow-y:auto; }
.tabs a { display: flex; align-items: center; min-height: 44px; border-radius: var(--radius-md); }
}
@media (max-width: 400px) {
.menu-label { display:none; }
.menu-toggle { justify-content:center; width:44px; padding:8px; }
.brand-link :global(.mark-sm .word) { font-size:12px; letter-spacing:1px; } .brand-link :global(.mark-sm .word) { font-size:12px; letter-spacing:1px; }
.menu-label { display:none; }
main { padding:var(--space-5) max(var(--space-4),env(safe-area-inset-right)) calc(var(--space-16) + env(safe-area-inset-bottom)) max(var(--space-4),env(safe-area-inset-left)); }
:global(input:not([type='checkbox']):not([type='radio'])),:global(select),:global(textarea) { font-size:16px !important; min-height:44px; }
:global(button),:global(.btn) { min-height:44px; }
} }
@media (max-width: 720px) { @media (max-width:360px) { .brand-link :global(.mark-sm .word) { font-size:10px; letter-spacing:0; } .header-actions { gap:4px; } }
main { padding: var(--space-6) max(var(--space-4), env(safe-area-inset-right)) calc(var(--space-16) + env(safe-area-inset-bottom)) max(var(--space-4), env(safe-area-inset-left)); } :global(::view-transition-old(root)),:global(::view-transition-new(root)),:global(::view-transition-old(main)),:global(::view-transition-new(main)) { animation-duration:var(--dur-base); animation-timing-function:var(--ease-standard); }
/* Keep native controls readable and prevent focus zoom on phones. */
:global(input:not([type='checkbox']):not([type='radio'])),
:global(select), :global(textarea) { font-size: 16px !important; min-height: 44px; }
:global(button), :global(.btn) { min-height: 44px; }
}
:global(input), :global(select), :global(textarea) { min-width: 0; max-width: 100%; }
:global(input), :global(select), :global(textarea), :global(button), :global(a) { scroll-margin-block: 90px 100px; }
:global(dialog) { max-height: calc(100dvh - 32px); overflow-y: auto; overscroll-behavior: contain; }
.skip-link { position: fixed; left: 16px; top: -80px; z-index: 300; padding: 12px 16px; background: var(--surface-raised); border-radius: var(--radius-md); }
.skip-link:focus { top: 12px; }
.subnav { border-bottom: 1px solid var(--border-subtle); background: var(--surface-sunken); }
.subnav > div { display: flex; gap: var(--space-6); max-width: 1440px; margin: 0 auto; padding: 0 var(--space-8); overflow-x: auto; }
.subnav a { text-decoration: none; white-space: nowrap; font-size: var(--text-sm); color: var(--text-secondary); padding: var(--space-3) 0; border-bottom: 2px solid transparent; }
.subnav a[aria-current] { color: var(--text-primary); border-bottom-color: var(--accent-primary); }
@media (max-width: 720px) { .subnav > div { padding-inline: var(--space-4); } }
/* View-transition tuning — cross-fade routes, ease the shared nav pill. */
:global(::view-transition-old(root)),
:global(::view-transition-new(root)) {
animation-duration: var(--dur-base);
animation-timing-function: var(--ease-standard);
}
:global(::view-transition-old(main)),
:global(::view-transition-new(main)) {
animation-duration: var(--dur-base);
animation-timing-function: var(--ease-standard);
}
</style> </style>
+4 -3
View File
@@ -3,6 +3,7 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import { contextHref } from '$lib/navigation-context.js';
let { data }: { data: import('./$types').PageData } = $props(); let { data }: { data: import('./$types').PageData } = $props();
const snapshot = $derived($fleet.receivedAt ? $fleet : data.fleetSnapshot); const snapshot = $derived($fleet.receivedAt ? $fleet : data.fleetSnapshot);
const visibleFleet = $derived($fleet.receivedAt ? $fleetServers : data.fleetSnapshot.servers); const visibleFleet = $derived($fleet.receivedAt ? $fleetServers : data.fleetSnapshot.servers);
@@ -181,7 +182,7 @@
{/if} {/if}
{#if spotlight} {#if spotlight}
<a class="spotlight" href="/servers/{encodeURIComponent(spotlight.name)}"> <a class="spotlight" href={contextHref(`/servers/${encodeURIComponent(spotlight.name)}`, $page.url)}>
<div><span class="eyebrow">Longest running match</span><strong>{serverTitle(spotlight.name)}</strong></div> <div><span class="eyebrow">Longest running match</span><strong>{serverTitle(spotlight.name)}</strong></div>
<span class="round">Round {currentRound(spotlight, $telemetryByServer.get(spotlight.name), $uiNow)}</span> <span class="round">Round {currentRound(spotlight, $telemetryByServer.get(spotlight.name), $uiNow)}</span>
<span>{spotlight.last_players} {spotlight.last_players === 1 ? 'survivor' : 'survivors'} · {elapsedSince(spotlight.game_started_at, $uiNow)} <span aria-hidden="true">→</span></span> <span>{spotlight.last_players} {spotlight.last_players === 1 ? 'survivor' : 'survivors'} · {elapsedSince(spotlight.game_started_at, $uiNow)} <span aria-hidden="true">→</span></span>
@@ -239,7 +240,7 @@
<div class="table-scroll"><table><caption class="sr-only">{group.title} servers</caption><thead><tr><th>Lobby</th><th>Status</th><th>Players</th><th>Round / match</th><th>Operation</th><th>Follow</th></tr></thead><tbody> <div class="table-scroll"><table><caption class="sr-only">{group.title} servers</caption><thead><tr><th>Lobby</th><th>Status</th><th>Players</th><th>Round / match</th><th>Operation</th><th>Follow</th></tr></thead><tbody>
{#each group.servers as server (server.name)} {#each group.servers as server (server.name)}
{@const round = currentRound(server, $telemetryByServer.get(server.name), $uiNow)} {@const round = currentRound(server, $telemetryByServer.get(server.name), $uiNow)}
<tr><td><a class="lobby-link" href="/servers/{encodeURIComponent(server.name)}"><ServerIdentity name={server.name} mapLabel={fleetIdentity(server).map} /></a></td> <tr><td><a class="lobby-link" href={contextHref(`/servers/${encodeURIComponent(server.name)}`, $page.url)}><ServerIdentity name={server.name} mapLabel={fleetIdentity(server).map} /></a></td>
<td><span class="health"><StatusPip health={server.health} />{server.health}</span></td><td>{freshServer(server, $uiNow) ? server.last_players ?? '—' : '—'}</td> <td><span class="health"><StatusPip health={server.health} />{server.health}</span></td><td>{freshServer(server, $uiNow) ? server.last_players ?? '—' : '—'}</td>
<td>{round !== null ? `Round ${round}` : '—'}{#if matchDuration(server) >= 0}<small>{elapsedSince(server.game_started_at, $uiNow)}</small>{/if}</td> <td>{round !== null ? `Round ${round}` : '—'}{#if matchDuration(server) >= 0}<small>{elapsedSince(server.game_started_at, $uiNow)}</small>{/if}</td>
<td>{@render serverOperations(server, true)}</td><td><div class="table-actions">{@render serverActions(server)}</div></td></tr> <td>{@render serverOperations(server, true)}</td><td><div class="table-actions">{@render serverActions(server)}</div></td></tr>
@@ -255,7 +256,7 @@
{#snippet serverOperations(server: FleetServer, showEmpty = false)} {#snippet serverOperations(server: FleetServer, showEmpty = false)}
{@const operations = activeOperations.filter(op => op.server === server.name)} {@const operations = activeOperations.filter(op => op.server === server.name)}
{#if operations.length}<div class="server-operations">{#each operations as operation (operation.key)} {#if operations.length}<div class="server-operations">{#each operations as operation (operation.key)}
<a href={operation.href} class:issue={operation.state === 'attention'} title={operation.detail}><span>{operation.label} →</span><small>{operation.detail}</small></a> <a href={contextHref(operation.href, $page.url)} class:issue={operation.state === 'attention'} title={operation.detail}><span>{operation.label} →</span><small>{operation.detail}</small></a>
{/each}</div>{:else if showEmpty}<span class="hint">No pending work</span>{/if} {/each}</div>{:else if showEmpty}<span class="hint">No pending work</span>{/if}
{/snippet} {/snippet}
+21 -1
View File
@@ -1,4 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { timeFormat, timeZone, timeZonePersistenceError } from '$ui/stores/time-zone.js';
import { availableTimeZones, DEFAULT_TIME_ZONE } from '$lib/time-zone.js';
let exampleDate = $state<Date | null>(null);
const zones = $derived([...new Set([...availableTimeZones(), $timeZone])].sort());
onMount(() => { exampleDate = new Date(); });
import { page } from '$app/stores'; import { page } from '$app/stores';
import { invalidateAll } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
@@ -30,7 +36,7 @@
</script> </script>
<svelte:head><title>My account · Electric Cherry</title></svelte:head> <svelte:head><title>My account · Electric Cherry</title></svelte:head>
<section class="page"><SectionHeader title="My account" subtitle="Manage your password and current session." /> <section class="page"><SectionHeader title="My account" subtitle="Manage your password, display preferences, and current session." />
{#if user} {#if user}
<section class="panel identity"><div><h2>{user.displayName}</h2><p>@{user.username} · <span class="role">{user.role}</span></p></div><Button disabled={busy || signingOut} loading={signingOut} onclick={logout}>Sign out</Button></section> <section class="panel identity"><div><h2>{user.displayName}</h2><p>@{user.username} · <span class="role">{user.role}</span></p></div><Button disabled={busy || signingOut} loading={signingOut} onclick={logout}>Sign out</Button></section>
{#if user.mustChangePassword}<p class="required" role="status">Your account uses a temporary password. Choose a new password before continuing to Electric Cherry.</p>{/if} {#if user.mustChangePassword}<p class="required" role="status">Your account uses a temporary password. Choose a new password before continuing to Electric Cherry.</p>{/if}
@@ -45,10 +51,24 @@
<div><Button type="submit" variant="primary" loading={busy} disabled={busy || signingOut}>{user.mustChangePassword ? 'Save password and continue' : 'Change password'}</Button></div> <div><Button type="submit" variant="primary" loading={busy} disabled={busy || signingOut}>{user.mustChangePassword ? 'Save password and continue' : 'Change password'}</Button></div>
</fieldset></form> </fieldset></form>
</section> </section>
<section class="panel" aria-labelledby="display-heading">
<h2 id="display-heading">Display preferences</h2>
<label class="timezone">Timezone
<select bind:value={$timeZone} aria-describedby="timezone-help">
{#each zones as zone}<option value={zone}>{zone.replaceAll('_', ' ')}{zone === DEFAULT_TIME_ZONE ? ' (default)' : ''}</option>{/each}
</select>
</label>
<p id="timezone-help">Dates and times throughout the app use this timezone, including daylight saving changes. Saved automatically in this browser.</p>
{#if exampleDate}<p>Example: {$timeFormat.dateTime(exampleDate)}</p>{/if}
{#if $timeZonePersistenceError}<p role="status">Browser storage is unavailable. Your timezone applies for this visit but cannot be saved.</p>{/if}
</section>
{/if} {/if}
</section> </section>
<style> <style>
.timezone { display: grid; gap: 8px; margin-top: 16px; font-size: var(--text-sm); max-width: 420px; }
.timezone select { width: 100%; padding: 10px 12px; font: inherit; color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-control); border-radius: var(--radius-sm); }
.timezone ~ p { font-size:var(--text-sm); color:var(--text-secondary); }
.page { max-width:760px; display:grid; gap:24px; } .panel { padding:24px; background:var(--surface); border:1px solid var(--border-strong); border-radius:var(--radius-md); } h2 { font-size:var(--text-md); margin:0; } .identity { display:flex; align-items:center; justify-content:space-between; gap:20px; } .identity p { margin:8px 0 0; color:var(--text-secondary); font-size:var(--text-sm); } .role { text-transform:capitalize; } .page { max-width:760px; display:grid; gap:24px; } .panel { padding:24px; background:var(--surface); border:1px solid var(--border-strong); border-radius:var(--radius-md); } h2 { font-size:var(--text-md); margin:0; } .identity { display:flex; align-items:center; justify-content:space-between; gap:20px; } .identity p { margin:8px 0 0; color:var(--text-secondary); font-size:var(--text-sm); } .role { text-transform:capitalize; }
.required { padding:16px; margin:0; color:var(--text-primary); background:var(--surface-raised); border-left:3px solid var(--accent-primary); font-size:var(--text-sm); line-height:1.6; } .required { padding:16px; margin:0; color:var(--text-primary); background:var(--surface-raised); border-left:3px solid var(--accent-primary); font-size:var(--text-sm); line-height:1.6; }
form { margin-top:24px; max-width:460px; } fieldset { border:0; margin:0; padding:0; display:grid; gap:18px; min-width:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; padding:12px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-md); font:inherit; } .password-username { position:absolute; width:1px; height:1px; clip-path:inset(50%); overflow:hidden; padding:0; border:0; } .hint { margin:-8px 0 0; color:var(--text-secondary); font-size:var(--text-xs); line-height:1.6; } .error { color:var(--state-danger); font-size:var(--text-sm); } .notice { color:var(--state-online); font-size:var(--text-sm); } form { margin-top:24px; max-width:460px; } fieldset { border:0; margin:0; padding:0; display:grid; gap:18px; min-width:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; padding:12px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-md); font:inherit; } .password-username { position:absolute; width:1px; height:1px; clip-path:inset(50%); overflow:hidden; padding:0; border:0; } .hint { margin:-8px 0 0; color:var(--text-secondary); font-size:var(--text-xs); line-height:1.6; } .error { color:var(--state-danger); font-size:var(--text-sm); } .notice { color:var(--state-online); font-size:var(--text-sm); }
+6 -2
View File
@@ -1,7 +1,7 @@
import { error, json } from '@sveltejs/kit'; import { error, json } from '@sveltejs/kit';
import { listHostOperationHistory } from '$lib/server/hosts/index.js'; import { listHostOperationHistory } from '$lib/server/hosts/index.js';
import { requireManagementOrigin } from '$lib/server/hosts/http.js'; import { requireManagementOrigin } from '$lib/server/hosts/http.js';
import { isHostOperationFilter } from '$lib/hosts.js'; import { hostOperationScope, isHostOperationFilter } from '$lib/hosts.js';
export function GET({ request }: { request: Request }) { export function GET({ request }: { request: Request }) {
requireManagementOrigin(request); requireManagementOrigin(request);
@@ -10,5 +10,9 @@ export function GET({ request }: { request: Request }) {
const filter = params.get('filter') ?? 'important'; const filter = params.get('filter') ?? 'important';
if (!Number.isSafeInteger(page) || page < 1) error(400, 'Invalid operation history page.'); if (!Number.isSafeInteger(page) || page < 1) error(400, 'Invalid operation history page.');
if (!isHostOperationFilter(filter)) error(400, 'Invalid operation history filter.'); if (!isHostOperationFilter(filter)) error(400, 'Invalid operation history filter.');
return json(listHostOperationHistory(page, filter), { headers: { 'Cache-Control': 'no-store' } }); const scope = hostOperationScope(params);
for (const key of ['host', 'server'] as const) {
if (params.has(key) && params.get(key) !== scope[key]) error(400, 'Invalid operation history scope.');
}
return json(listHostOperationHistory(page, filter, scope), { headers: { 'Cache-Control': 'no-store' } });
} }
+6
View File
@@ -0,0 +1,6 @@
import { json } from '@sveltejs/kit';
import { operationsOverview } from '$lib/server/operations-overview.js';
export function GET() {
return json(operationsOverview(), { headers: { 'Cache-Control': 'no-store' } });
}
+20 -5
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@@ -52,7 +54,19 @@
const waiting=beta.servers.find(server=>server.stage!=='ready'); const waiting=beta.servers.find(server=>server.stage!=='ready');
return waiting?`${serverTitle(waiting.server)}: ${statusPresentation(waiting.stage).label.toLowerCase()}.`:'Waiting for test evaluation.'; return waiting?`${serverTitle(waiting.server)}: ${statusPresentation(waiting.stage).label.toLowerCase()}.`:'Waiting for test evaluation.';
} }
$effect(() => { search; tab; limit = 25; }); $effect(() => {
const params = $page.url.searchParams;
tab = params.get('view') === 'history' ? 'history' : 'active';
search = params.get('q') ?? '';
limit = Math.min(10000, Math.max(25, Number(params.get('limit')) || 25));
});
function saveContext() {
const url = new URL($page.url);
if (tab === 'history') url.searchParams.set('view', tab); else url.searchParams.delete('view');
if (search) url.searchParams.set('q', search); else url.searchParams.delete('q');
if (limit > 25) url.searchParams.set('limit', String(limit)); else url.searchParams.delete('limit');
void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
}
async function load() { async function load() {
try { const res = await fetch('/api/betas'); if (!res.ok) throw new Error('Could not refresh betas.'); betas = (await res.json()).betas; now=Date.now(); error = ''; } try { const res = await fetch('/api/betas'); if (!res.ok) throw new Error('Could not refresh betas.'); betas = (await res.json()).betas; now=Date.now(); error = ''; }
catch (e) { error = (e as Error).message; } finally { loading = false; } catch (e) { error = (e as Error).message; } finally { loading = false; }
@@ -63,22 +77,23 @@
return pollVisible(load, 5000); return pollVisible(load, 5000);
}); });
</script> </script>
<svelte:head><title>Betas · Electric Cherry</title></svelte:head>
<div class="page"> <div class="page">
<SectionHeader title="Betas" subtitle="Test selected versions before promoting them to the fleet."> <SectionHeader title="Betas" subtitle="Test selected versions before promoting them to the fleet.">
{#snippet actions()}<Button variant="primary" href="/betas/new">New beta</Button>{/snippet} {#snippet actions()}<Button variant="primary" href="/betas/new">New beta</Button>{/snippet}
</SectionHeader> </SectionHeader>
<Tabs {tabs} bind:value={tab} idPrefix="beta-tabs" panelId="beta-list" ariaLabel="Beta views" /> <Tabs {tabs} bind:value={tab} onchange={() => { limit = 25; saveContext(); }} idPrefix="beta-tabs" panelId="beta-list" ariaLabel="Beta views" />
<div class="ui-toolbar"><input type="search" bind:value={search} aria-label="Search betas" placeholder="Search betas or files…" /></div> <div class="ui-toolbar"><input type="search" bind:value={search} oninput={event => { search = event.currentTarget.value; limit = 25; saveContext(); }} aria-label="Search betas" placeholder="Search betas or files…" /></div>
{#if error}<div class="ui-error" role="alert">{error} {betas.length ? 'Showing the last loaded results.' : ''}<Button size="sm" onclick={load}>Retry</Button></div>{/if} {#if error}<div class="ui-error" role="alert">{error} {betas.length ? 'Showing the last loaded results.' : ''}<Button size="sm" onclick={load}>Retry</Button></div>{/if}
<div id="beta-list" role="tabpanel" aria-labelledby={'beta-tabs-' + tab}> <div id="beta-list" role="tabpanel" aria-labelledby={'beta-tabs-' + tab}>
{#if loading}<EmptyState title="Loading betas…" /> {#if loading}<EmptyState title="Loading betas…" />
{:else if !filtered.length && !error}<EmptyState title={search ? 'No matching betas' : tab === 'active' ? 'No active betas' : 'No beta history'} description={tab === 'active' && !search ? 'Start a beta when you have a version ready to test.' : undefined} /> {:else if !filtered.length && !error}<EmptyState title={search ? 'No matching betas' : tab === 'active' ? 'No active betas' : 'No beta history'} description={tab === 'active' && !search ? 'Start a beta when you have a version ready to test.' : undefined} />
{:else}<div class="rows">{#each filtered.slice(0,limit) as beta (beta.id)} {:else}<div class="rows">{#each filtered.slice(0,limit) as beta (beta.id)}
<a href={'/betas/' + beta.id} class="row"> <a href={contextHref('/betas/' + beta.id, $page.url)} class="row">
<div class="identity"><strong>{beta.notes || `Beta #${beta.id}`}</strong>{#if beta.notes}<span class="beta-id"> · #{beta.id}</span>{/if}<span class="files">{beta.scripts.map(s => s.name + ' · ' + artifactVersionLabel(s.version)).join(', ')}</span><span class="blocker">{blocker(beta)}</span><span class="timing"><time datetime={beta.opened_at} title={$timeFormat.dateTime(beta.opened_at)}>Opened {age(beta.opened_at)}</time>{#if beta.deadlineAt && active(beta)} · Ends {$timeFormat.dateTime(beta.deadlineAt)}{:else if beta.closed_at} · Finished {$timeFormat.dateTime(beta.closed_at)}{/if}</span></div> <div class="identity"><strong>{beta.notes || `Beta #${beta.id}`}</strong>{#if beta.notes}<span class="beta-id"> · #{beta.id}</span>{/if}<span class="files">{beta.scripts.map(s => s.name + ' · ' + artifactVersionLabel(s.version)).join(', ')}</span><span class="blocker">{blocker(beta)}</span><span class="timing"><time datetime={beta.opened_at} title={$timeFormat.dateTime(beta.opened_at)}>Opened {age(beta.opened_at)}</time>{#if beta.deadlineAt && active(beta)} · Ends {$timeFormat.dateTime(beta.deadlineAt)}{:else if beta.closed_at} · Finished {$timeFormat.dateTime(beta.closed_at)}{/if}</span></div>
<div class="meta"><StatusBadge state={beta.state} />{#if beta.testOutcome && beta.testOutcome !== 'active'}<StatusBadge state={beta.testOutcome} />{/if}<span>{beta.servers.filter(server=>server.stage==='ready').length} / {beta.servers.length} servers passed</span></div> <div class="meta"><StatusBadge state={beta.state} />{#if beta.testOutcome && beta.testOutcome !== 'active'}<StatusBadge state={beta.testOutcome} />{/if}<span>{beta.servers.filter(server=>server.stage==='ready').length} / {beta.servers.length} servers passed</span></div>
</a> </a>
{/each}</div><ListFooter total={filtered.length} bind:limit label="betas" />{/if} {/each}</div><ListFooter total={filtered.length} bind:limit onchange={saveContext} label="betas" />{/if}
</div> </div>
</div> </div>
<style> <style>
+9 -6
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { contextHref } from '$lib/navigation-context.js';
import BackLink from '$ui/components/BackLink.svelte';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
@@ -131,6 +133,7 @@
{#snippet crumbs()}<Breadcrumbs items={[{label:'Betas',href:'/betas'},{label:`#${id}`}]} />{/snippet} {#snippet crumbs()}<Breadcrumbs items={[{label:'Betas',href:'/betas'},{label:`#${id}`}]} />{/snippet}
{#snippet actions()}<StatusBadge state={test && !closed ? test.outcome : beta?.state ?? 'running'} />{/snippet} {#snippet actions()}<StatusBadge state={test && !closed ? test.outcome : beta?.state ?? 'running'} />{/snippet}
</SectionHeader> </SectionHeader>
<BackLink fallback="/betas" />
<Card padding="lg"> <Card padding="lg">
<div class="summary-heading"><h2>{closed ? beta.state === 'promoted' ? 'Version promoted' : 'Beta rolled back' : `${passed} / ${servers.length} servers passed`}</h2>{#if test}<span>{coverageSummary(test.plan)}</span>{/if}</div> <div class="summary-heading"><h2>{closed ? beta.state === 'promoted' ? 'Version promoted' : 'Beta rolled back' : `${passed} / ${servers.length} servers passed`}</h2>{#if test}<span>{coverageSummary(test.plan)}</span>{/if}</div>
<p>{nextAction}</p> <p>{nextAction}</p>
@@ -142,9 +145,9 @@
{#if !closed && test}<p class="muted">Waiting for empty servers uses this window. Pausing or retrying does not extend the deadline.</p>{/if} {#if !closed && test}<p class="muted">Waiting for empty servers uses this window. Pausing or retrying does not extend the deadline.</p>{/if}
<div class="actions"> <div class="actions">
{#if !closed}<Button variant="primary" onclick={reviewPromotion} disabled={hasDeletedServers || beta.state !== 'ready-promote' || acting !== null}>Review promotion</Button><Button variant="danger" onclick={rollback} disabled={acting !== null} loading={acting === 'rollback'}>{beta.state === 'ready-promote' ? 'Reject beta' : 'Roll back'}</Button>{/if} {#if !closed}<Button variant="primary" onclick={reviewPromotion} disabled={hasDeletedServers || beta.state !== 'ready-promote' || acting !== null}>Review promotion</Button><Button variant="danger" onclick={rollback} disabled={acting !== null} loading={acting === 'rollback'}>{beta.state === 'ready-promote' ? 'Reject beta' : 'Roll back'}</Button>{/if}
{#if closed}<Button href={`/betas/new?reuse=${id}`}>Reuse files and test plan</Button>{/if} {#if closed}<Button href={contextHref(`/betas/new?reuse=${id}`, $page.url)}>Reuse files and test plan</Button>{/if}
{#if promotion}<Button href={`/campaigns/${encodeURIComponent(promotion.id)}`}>View campaign</Button>{/if} {#if promotion}<Button href={contextHref(`/campaigns/${encodeURIComponent(promotion.id)}`, $page.url)}>View campaign</Button>{/if}
{#if rollbackCampaign}<Button href={`/campaigns/${encodeURIComponent(rollbackCampaign.id)}`}>View rollback campaign</Button>{/if} {#if rollbackCampaign}<Button href={contextHref(`/campaigns/${encodeURIComponent(rollbackCampaign.id)}`, $page.url)}>View rollback campaign</Button>{/if}
</div> </div>
{#if promotion}<p class="muted">{promotion.pending} servers awaiting activation · {promotion.failed} need attention. Promoting a version does not mean it is already running everywhere.</p>{/if} {#if promotion}<p class="muted">{promotion.pending} servers awaiting activation · {promotion.failed} need attention. Promoting a version does not mean it is already running everywhere.</p>{/if}
{#if flash}<p role="alert" class="ui-error">{flash}</p>{/if} {#if flash}<p role="alert" class="ui-error">{flash}</p>{/if}
@@ -152,13 +155,13 @@
</Card> </Card>
{#if !closed && waiting.length} {#if !closed && waiting.length}
<section aria-label="Outstanding servers"><h2>Outstanding conditions</h2><div class="server-list"> <section aria-label="Outstanding servers"><h2>Outstanding conditions</h2><div class="server-list">
{#each waiting as server}<div class="server-row"><HistoricalServerIdentity name={server.server} deletedAt={server.server_deleted_at} href={`/servers/${encodeURIComponent(server.server)}`} /><StatusBadge state={server.stage} /><p>{server.error ?? (server.server_deleted_at ? 'This server was deleted. Recorded progress is retained.' : outstanding(server.server))}</p>{#if server.error && !server.server_deleted_at}<a href={`/servers/${encodeURIComponent(server.server)}?tab=diagnostics`}>Diagnose</a>{:else if test}<a href="#test-results">View checks</a>{/if}</div>{/each} {#each waiting as server}<div class="server-row"><HistoricalServerIdentity name={server.server} deletedAt={server.server_deleted_at} href={contextHref(`/servers/${encodeURIComponent(server.server)}`, $page.url)} /><StatusBadge state={server.stage} /><p>{server.error ?? (server.server_deleted_at ? 'This server was deleted. Recorded progress is retained.' : outstanding(server.server))}</p>{#if server.error && !server.server_deleted_at}<a href={contextHref(`/servers/${encodeURIComponent(server.server)}?tab=diagnostics`, $page.url)}>Diagnose</a>{:else if test}<a href="#test-results">View checks</a>{/if}</div>{/each}
</div></section> </div></section>
{/if} {/if}
{#if test}<div id="test-results"><Card padding="lg"><BetaTestProgress {test} betaId={id} {closed} deletedParticipants={hasDeletedServers} deadline={deadline?.toISOString()} retryBlocked={servers.some(server=>server.error?.startsWith('deploy:')) ? 'Deployment was incomplete. Roll back before opening a fresh beta.' : undefined} onUpdate={load} /></Card></div>{/if} {#if test}<div id="test-results"><Card padding="lg"><BetaTestProgress {test} betaId={id} {closed} deletedParticipants={hasDeletedServers} deadline={deadline?.toISOString()} retryBlocked={servers.some(server=>server.error?.startsWith('deploy:')) ? 'Deployment was incomplete. Roll back before opening a fresh beta.' : undefined} onUpdate={load} /></Card></div>{/if}
<details class="ui-panel"><summary>Files and versions ({scripts.length})</summary><div class="file-list">{#each scripts as file}<div><a href={`/${file.kind === 'baseline' ? 'configs' : file.kind + 's'}/${file.id}`}>{file.name}</a><strong>{artifactVersionLabel(file.version)}</strong><code>{file.target_rel_path}</code><span>{file.version?.sha256.slice(0,12)}</span></div>{/each}</div></details> <details class="ui-panel"><summary>Files and versions ({scripts.length})</summary><div class="file-list">{#each scripts as file}<div><a href={contextHref(`/${file.kind === 'baseline' ? 'configs' : file.kind + 's'}/${file.id}`, $page.url)}>{file.name}</a><strong>{artifactVersionLabel(file.version)}</strong><code>{file.target_rel_path}</code><span>{file.version?.sha256.slice(0,12)}</span></div>{/each}</div></details>
<details class="ui-panel"><summary>Deployment progress ({servers.length} servers)</summary><div class="server-list"> <details class="ui-panel"><summary>Deployment progress ({servers.length} servers)</summary><div class="server-list">
{#each ranked as server}<div class="server-row"><HistoricalServerIdentity name={server.server} deletedAt={server.server_deleted_at} href={`/servers/${encodeURIComponent(server.server)}`}/><StatusBadge state={server.stage}/><p>Files {server.deployed_at ? 'installed' : 'pending'} · Restart {server.restarted_at ? 'submitted' : 'pending'}{#if beta.wait_for_health} · Health {server.healthy_at ? 'verified' : 'pending'}{/if}{#if beta.wait_for_match} · Match {server.match_seen_at ? 'observed' : 'pending'}{/if}</p>{#if server.provisioning_id}<a href={`/provisioning/${encodeURIComponent(server.provisioning_id)}`}>View provisioning</a>{/if}{#if server.error}<p class="ui-error">{server.error}</p>{/if}{#if server.stage === 'failed' && !server.server_deleted_at && !test && !closed}<Button size="sm" onclick={() => retry(server.server)} disabled={acting !== null} loading={acting === `retry:${server.server}`}>Retry server</Button>{/if}</div>{/each} {#each ranked as server}<div class="server-row"><HistoricalServerIdentity name={server.server} deletedAt={server.server_deleted_at} href={contextHref(`/servers/${encodeURIComponent(server.server)}`, $page.url)}/><StatusBadge state={server.stage}/><p>Files {server.deployed_at ? 'installed' : 'pending'} · Restart {server.restarted_at ? 'submitted' : 'pending'}{#if beta.wait_for_health} · Health {server.healthy_at ? 'verified' : 'pending'}{/if}{#if beta.wait_for_match} · Match {server.match_seen_at ? 'observed' : 'pending'}{/if}</p>{#if server.provisioning_id}<a href={contextHref(`/provisioning/${encodeURIComponent(server.provisioning_id)}`, $page.url)}>View provisioning</a>{/if}{#if server.error}<p class="ui-error">{server.error}</p>{/if}{#if server.stage === 'failed' && !server.server_deleted_at && !test && !closed}<Button size="sm" onclick={() => retry(server.server)} disabled={acting !== null} loading={acting === `retry:${server.server}`}>Retry server</Button>{/if}</div>{/each}
</div></details> </div></details>
<OperationHistory {events} active={!closed} showOverview={false}/> <OperationHistory {events} active={!closed} showOverview={false}/>
</div> </div>
+8 -2
View File
@@ -1,4 +1,8 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores';
import { contextHref, safeReturnHref } from '$lib/navigation-context.js';
import BackLink from '$ui/components/BackLink.svelte';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import { beforeNavigate, goto } from '$app/navigation'; import { beforeNavigate, goto } from '$app/navigation';
@@ -167,14 +171,16 @@
const response=await fetch('/api/betas',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({scripts:picks.map(p=>({scriptId:p.scriptId,scriptVersionId:p.versionId})),servers:[...pickedServers],testPlan:parseBetaPlan(testPlan),notes:betaLabel.trim()||undefined,waitForHealth:true,waitForMatch:false})}), body=await response.json(); const response=await fetch('/api/betas',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({scripts:picks.map(p=>({scriptId:p.scriptId,scriptVersionId:p.versionId})),servers:[...pickedServers],testPlan:parseBetaPlan(testPlan),notes:betaLabel.trim()||undefined,waitForHealth:true,waitForMatch:false})}), body=await response.json();
if(!response.ok||!body.ok)throw new Error(body.error??'Could not start beta. Review the selected versions and servers.'); if(!response.ok||!body.ok)throw new Error(body.error??'Could not start beta. Review the selected versions and servers.');
draftReady=false;try{localStorage.removeItem(betaDraftKey);}catch{}allowLeave=true; draftReady=false;try{localStorage.removeItem(betaDraftKey);}catch{}allowLeave=true;
await goto(`/betas/${body.result.betaId}`); await goto(contextHref(`/betas/${body.result.betaId}`, safeReturnHref($page.url.searchParams.get('returnTo'), '/betas')));
}catch(error){flash=(error as Error).message;}finally{openingBeta=false;} }catch(error){flash=(error as Error).message;}finally{openingBeta=false;}
} }
</script> </script>
<svelte:head><title>New beta | Electric Cherry</title></svelte:head> <svelte:head><title>New beta | Electric Cherry</title></svelte:head>
<div class="page"> <div class="page">
<SectionHeader title="New beta" subtitle="Choose exact file versions and test servers, then review before starting.">{#snippet actions()}<Button href="/betas" variant="ghost">Back to betas</Button>{/snippet}</SectionHeader> <Breadcrumbs items={[{ label: 'Betas', href: '/betas' }, { label: 'New beta' }]} />
<BackLink fallback="/betas" />
<SectionHeader title="New beta" subtitle="Choose exact file versions and test servers, then review before starting." />
{#if flash}<div class="ui-error" role="alert">{flash}{#if !scripts.length}<Button size="sm" onclick={()=>initialize()}>Retry</Button>{/if}</div>{/if} {#if flash}<div class="ui-error" role="alert">{flash}{#if !scripts.length}<Button size="sm" onclick={()=>initialize()}>Retry</Button>{/if}</div>{/if}
{#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={() => refreshFleet(true)}>Retry servers</Button></div>{/if} {#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={() => refreshFleet(true)}>Retry servers</Button></div>{/if}
{#if notice}<p role="status" class="notice">{notice}</p>{/if} {#if notice}<p role="status" class="notice">{notice}</p>{/if}
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { contextHref } from '$lib/navigation-context.js';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
@@ -217,7 +218,7 @@
{#if flash && flashId === d.id}<div class="result" class:result-error={flashError} role={flashError ? 'alert' : 'status'}>{flash}</div>{/if} {#if flash && flashId === d.id}<div class="result" class:result-error={flashError} role={flashError ? 'alert' : 'status'}>{flash}</div>{/if}
<div class="row-head"> <div class="row-head">
<div class="row-head-l"> <div class="row-head-l">
<a class="action" href="/campaigns/{d.id}">{d.action} campaign <span class="sr-only">{d.id}</span></a> <a class="action" href={contextHref(`/campaigns/${d.id}`, $page.url)}>{d.action} campaign <span class="sr-only">{d.id}</span></a>
{#if d.action === 'promote' || d.action === 'rollback'} {#if d.action === 'promote' || d.action === 'rollback'}
<span class="target">{scriptSummary(d)}</span> <span class="target">{scriptSummary(d)}</span>
{/if} {/if}
+7 -3
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { contextHref } from '$lib/navigation-context.js';
import BackLink from '$ui/components/BackLink.svelte';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
@@ -31,7 +33,7 @@
interface Detail { interface Detail {
deployment: { deployment: {
id: string; action: string; started_at: string; id: string; action: string; started_at: string;
finished_at: string | null; triggered_by: string | null; finished_at: string | null; triggered_by: string | null; beta_id?: number | null;
}; };
servers: ServerRow[]; servers: ServerRow[];
files: FileRow[]; files: FileRow[];
@@ -136,6 +138,7 @@
]} /> ]} />
{/snippet} {/snippet}
</SectionHeader> </SectionHeader>
<BackLink fallback="/campaigns" />
<section class="outcome" aria-label="Campaign outcome"> <section class="outcome" aria-label="Campaign outcome">
<h2>{completed} / {detail.servers.length} servers activated{unresolved ? ` · ${unresolved} unresolved` : ''}{withdrawn ? ` · ${withdrawn} withdrawn` : ''}{deletedIncomplete ? ` · ${deletedIncomplete} deleted before activation` : ''}</h2> <h2>{completed} / {detail.servers.length} servers activated{unresolved ? ` · ${unresolved} unresolved` : ''}{withdrawn ? ` · ${withdrawn} withdrawn` : ''}{deletedIncomplete ? ` · ${deletedIncomplete} deleted before activation` : ''}</h2>
@@ -145,7 +148,8 @@
{:else}<p>Every recorded server completed activation.</p>{/if} {:else}<p>Every recorded server completed activation.</p>{/if}
</section> </section>
{#if detail.watchdogIncidentId}<p class="watchdog-source">Started by Maintenance · <a href={`/maintenance/${encodeURIComponent(detail.watchdogIncidentId)}`}>View incident and recovery history</a></p>{/if} {#if detail.watchdogIncidentId}<p class="watchdog-source">Started by Maintenance · <a href={contextHref(`/maintenance/${encodeURIComponent(detail.watchdogIncidentId)}`, $page.url)}>View incident and recovery history</a></p>{/if}
{#if detail.deployment.beta_id}<p class="watchdog-source">Started from <a href={contextHref(`/betas/${detail.deployment.beta_id}`, $page.url)}>Beta #{detail.deployment.beta_id}</a> · Review its files, checks, and test servers.</p>{/if}
{#if detail.servers.some(server => !server.server_deleted_at && ['held', 'manual'].includes(server.restart_state))} {#if detail.servers.some(server => !server.server_deleted_at && ['held', 'manual'].includes(server.restart_state))}
<div><Button onclick={resume} loading={resuming} disabled={resuming}>Resume when empty</Button><p>Apply held changes and restart after the safety checks pass.</p></div> <div><Button onclick={resume} loading={resuming} disabled={resuming}>Resume when empty</Button><p>Apply held changes and restart after the safety checks pass.</p></div>
@@ -170,7 +174,7 @@
{#each rankedServers as s (s.server)} {#each rankedServers as s (s.server)}
<div class="srv-row"> <div class="srv-row">
<div class="srv-l"> <div class="srv-l">
<span class="srv-name"><HistoricalServerIdentity name={s.server} deletedAt={s.server_deleted_at} href={`/servers/${encodeURIComponent(s.server)}`} /></span> <span class="srv-name"><HistoricalServerIdentity name={s.server} deletedAt={s.server_deleted_at} href={contextHref(`/servers/${encodeURIComponent(s.server)}`, $page.url)} /></span>
{#if s.error} {#if s.error}
<span class="err">{s.error}</span> <span class="err">{s.error}</span>
{/if} {/if}
+10 -2
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import BackLink from '$ui/components/BackLink.svelte';
import { contextHref } from '$lib/navigation-context.js';
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { beforeNavigate, afterNavigate, goto } from '$app/navigation'; import { beforeNavigate, afterNavigate, goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
@@ -35,7 +37,12 @@
if (query.hostId) params.set('host', query.hostId); if (query.hostId) params.set('host', query.hostId);
return params; return params;
} }
function scopeUrl(change: Partial<CapacityQuery>) { return `/capacity?${queryParams({ ...report.query, ...change })}`; } function scopeUrl(change: Partial<CapacityQuery>) {
const params = queryParams({ ...report.query, ...change });
const returnTo = page.url.searchParams.get('returnTo');
if (returnTo) params.set('returnTo', returnTo);
return `/capacity?${params}`;
}
function cancel() { sequence++; request?.abort(); request = null; refreshing = false; } function cancel() { sequence++; request?.abort(); request = null; refreshing = false; }
async function refresh(signal?: AbortSignal) { async function refresh(signal?: AbortSignal) {
if (navigating || refreshing) return; if (navigating || refreshing) return;
@@ -71,6 +78,7 @@
<SectionHeader title="Capacity" subtitle="See what your hosts can run, what is limiting them, and how headroom changes."> <SectionHeader title="Capacity" subtitle="See what your hosts can run, what is limiting them, and how headroom changes.">
{#snippet actions()}<Button href="/hosts">Manage hosts</Button><Button disabled={refreshing || navigating} onclick={() => void refresh()}>{refreshing ? 'Refreshing…' : 'Refresh'}</Button>{/snippet} {#snippet actions()}<Button href="/hosts">Manage hosts</Button><Button disabled={refreshing || navigating} onclick={() => void refresh()}>{refreshing ? 'Refreshing…' : 'Refresh'}</Button>{/snippet}
</SectionHeader> </SectionHeader>
{#if page.url.searchParams.has('returnTo')}<BackLink fallback="/hosts" />{/if}
{#if refreshError || navigationError}<div class="refresh-error" role="alert"><strong>{navigationError || refreshError}</strong><span>Showing the last successfully loaded capacity report.</span></div>{/if} {#if refreshError || navigationError}<div class="refresh-error" role="alert"><strong>{navigationError || refreshError}</strong><span>Showing the last successfully loaded capacity report.</span></div>{/if}
<div class="toolbar"> <div class="toolbar">
<div class="filters"><Field label="Host"><Select value={report.query.hostId ?? ''} options={hostOptions} disabled={navigating} onchange={event => void changeScope({ hostId: (event.currentTarget as HTMLSelectElement).value || null })} /></Field> <div class="filters"><Field label="Host"><Select value={report.query.hostId ?? ''} options={hostOptions} disabled={navigating} onchange={event => void changeScope({ hostId: (event.currentTarget as HTMLSelectElement).value || null })} /></Field>
@@ -95,7 +103,7 @@
<div class="section-heading"><div><h2 id="host-capacity-title">Host capacity</h2><p>Find the resource that limits the next launch.</p></div><span>{summary.eligibleHosts} / {summary.totalHosts} eligible</span></div> <div class="section-heading"><div><h2 id="host-capacity-title">Host capacity</h2><p>Find the resource that limits the next launch.</p></div><span>{summary.eligibleHosts} / {summary.totalHosts} eligible</span></div>
{#if !report.current.hosts.length}<div class="empty-hosts"><h3>{report.query.hostId ? 'This host is unavailable' : 'No hosts yet'}</h3><p>{report.query.hostId ? 'Choose another host to view its capacity.' : 'Connect a host to start seeing capacity and history.'}</p><Button href="/hosts">Open hosts</Button></div> {#if !report.current.hosts.length}<div class="empty-hosts"><h3>{report.query.hostId ? 'This host is unavailable' : 'No hosts yet'}</h3><p>{report.query.hostId ? 'Choose another host to view its capacity.' : 'Connect a host to start seeing capacity and history.'}</p><Button href="/hosts">Open hosts</Button></div>
{:else}<div class="host-grid">{#each report.current.hosts as host (host.id)}<article class="host-card"> {:else}<div class="host-grid">{#each report.current.hosts as host (host.id)}<article class="host-card">
<div class="host-title"><h3><a href={`/hosts/${host.id}`}>{host.name} <span aria-hidden="true">↗</span></a></h3><span class="status" class:eligible={host.eligible}>{!host.connected ? 'Offline' : hostReadinessLabel(host.readiness)}</span></div> <div class="host-title"><h3><a href={contextHref(`/hosts/${host.id}`, page.url)}>{host.name} <span aria-hidden="true">↗</span></a></h3><span class="status" class:eligible={host.eligible}>{!host.connected ? 'Offline' : hostReadinessLabel(host.readiness)}</span></div>
<div class="host-estimate"><strong>{count(host.estimatedServers)}</strong><span>additional servers{#if host.eligible && host.limitingResource}<small>Limited by {host.limitingResource.toLowerCase()}</small>{/if}</span></div> <div class="host-estimate"><strong>{count(host.estimatedServers)}</strong><span>additional servers{#if host.eligible && host.limitingResource}<small>Limited by {host.limitingResource.toLowerCase()}</small>{/if}</span></div>
<div class="game-tags">{#if host.games.length}{#each host.games as game}<span>{game === 't5' ? 'Black Ops I' : 'Black Ops II'}</span>{/each}{:else}<span>No games ready</span>{/if}</div> <div class="game-tags">{#if host.games.length}{#each host.games as game}<span>{game === 't5' ? 'Black Ops I' : 'Black Ops II'}</span>{/each}{:else}<span>No games ready</span>{/if}</div>
<dl class="host-resources"><div><dt>CPU</dt><dd>{count(host.cpuCoresAvailable)} <small>cores</small></dd><span>{count(host.limits.cpu)} servers fit</span></div><div><dt>Memory</dt><dd>{memory(host.memoryBytesAvailable)} <small>GiB</small></dd><span>{count(host.limits.memory)} servers fit</span></div><div><dt>Ports</dt><dd>{host.portsAvailable} <small>/ {host.totalPorts}</small></dd><span>{host.limits.ports} servers fit</span></div></dl> <dl class="host-resources"><div><dt>CPU</dt><dd>{count(host.cpuCoresAvailable)} <small>cores</small></dd><span>{count(host.limits.cpu)} servers fit</span></div><div><dt>Memory</dt><dd>{memory(host.memoryBytesAvailable)} <small>GiB</small></dd><span>{count(host.limits.memory)} servers fit</span></div><div><dt>Ports</dt><dd>{host.portsAvailable} <small>/ {host.totalPorts}</small></dd><span>{host.limits.ports} servers fit</span></div></dl>
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { contextHref } from '$lib/navigation-context.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@@ -134,7 +135,7 @@
{#if launchesLoading}<p>Loading collection launches…</p> {#if launchesLoading}<p>Loading collection launches…</p>
{:else if visibleLaunches.length} {:else if visibleLaunches.length}
<div class="launches">{#each visibleLaunches.slice(0,launchLimit) as collection (collection.id)} <div class="launches">{#each visibleLaunches.slice(0,launchLimit) as collection (collection.id)}
<a class="collection-row" href="/collections/launches/{collection.id}"> <a class="collection-row" href={contextHref(`/collections/launches/${collection.id}`, $page.url)}>
<div class="row-head"><strong>{collection.name}</strong><Badge variant={collection.active ? 'info' : collection.failed ? 'danger' : 'online'}>{collection.active ? collection.provisioning + collection.verifying ? collection.mode === 'exact' ? 'Applying collection' : 'Launching' : 'Queued' : collection.failed ? 'Needs attention' : 'Completed'}</Badge></div> <div class="row-head"><strong>{collection.name}</strong><Badge variant={collection.active ? 'info' : collection.failed ? 'danger' : 'online'}>{collection.active ? collection.provisioning + collection.verifying ? collection.mode === 'exact' ? 'Applying collection' : 'Launching' : 'Queued' : collection.failed ? 'Needs attention' : 'Completed'}</Badge></div>
<div class="collection-counts"><span><strong>{collection.done} / {collection.total}</strong> {collection.mode === 'exact' ? 'completed' : 'ready'}</span>{#if collection.provisioning + collection.verifying}<span>{collection.provisioning + collection.verifying} {collection.mode === 'exact' ? 'in progress' : 'launching'}</span>{/if}{#if collection.queued}<span>{collection.queued} queued</span>{/if}{#if collection.failed}<span class="error">{collection.failed} need attention</span>{/if}</div> <div class="collection-counts"><span><strong>{collection.done} / {collection.total}</strong> {collection.mode === 'exact' ? 'completed' : 'ready'}</span>{#if collection.provisioning + collection.verifying}<span>{collection.provisioning + collection.verifying} {collection.mode === 'exact' ? 'in progress' : 'launching'}</span>{/if}{#if collection.queued}<span>{collection.queued} queued</span>{/if}{#if collection.failed}<span class="error">{collection.failed} need attention</span>{/if}</div>
<div class="collection-meter" aria-hidden="true"><span class="verified" style:width={`${collection.done / (collection.total || 1) * 100}%`}></span><span class="unsuccessful" style:width={`${collection.failed / (collection.total || 1) * 100}%`}></span></div> <div class="collection-meter" aria-hidden="true"><span class="verified" style:width={`${collection.done / (collection.total || 1) * 100}%`}></span><span class="unsuccessful" style:width={`${collection.failed / (collection.total || 1) * 100}%`}></span></div>
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { contextHref } from '$lib/navigation-context.js';
import BackLink from '$ui/components/BackLink.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import ServerIdentity from '$ui/components/ServerIdentity.svelte'; import ServerIdentity from '$ui/components/ServerIdentity.svelte';
@@ -54,6 +56,7 @@
<SectionHeader title={collection?.name ?? 'Collection launch'}> <SectionHeader title={collection?.name ?? 'Collection launch'}>
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Collections', href: '/collections' }, { label: 'Collection launch' }]} />{/snippet} {#snippet crumbs()}<Breadcrumbs items={[{ label: 'Collections', href: '/collections' }, { label: 'Collection launch' }]} />{/snippet}
</SectionHeader> </SectionHeader>
<BackLink fallback="/collections?view=active" />
{#if error}<p class="error" role="alert">{error} <button onclick={load}>Retry</button></p>{/if} {#if error}<p class="error" role="alert">{error} <button onclick={load}>Retry</button></p>{/if}
{#if collection && detail} {#if collection && detail}
<section class="summary" aria-label="Collection progress"> <section class="summary" aria-label="Collection progress">
@@ -78,7 +81,7 @@
<section class="current-launches" aria-label="In progress"> <section class="current-launches" aria-label="In progress">
<span class="eyebrow">In progress</span> <span class="eyebrow">In progress</span>
{#each currentServers as server (server.id)} {#each currentServers as server (server.id)}
<a class="current-launch" href="/provisioning/{server.id}"> <a class="current-launch" href={contextHref(`/provisioning/${server.id}`, $page.url)}>
<span class="activity" aria-hidden="true"></span> <span class="activity" aria-hidden="true"></span>
<div><strong>{server.action === 'destroy' ? 'Removing: ' : ''}<HistoricalServerIdentity name={server.server} deletedAt={server.server_deleted_at} /></strong><p>{server.state === 'awaiting-health' ? 'Checking the container, connection, and expected map.' : server.stage}</p></div> <div><strong>{server.action === 'destroy' ? 'Removing: ' : ''}<HistoricalServerIdentity name={server.server} deletedAt={server.server_deleted_at} /></strong><p>{server.state === 'awaiting-health' ? 'Checking the container, connection, and expected map.' : server.stage}</p></div>
<span class="operation-link">Follow progress <span aria-hidden="true">→</span></span> <span class="operation-link">Follow progress <span aria-hidden="true">→</span></span>
@@ -92,7 +95,7 @@
<p>{queueHint(firstQueued)} Provisioning starts each server when its host and capacity are ready. Progress updates automatically.</p> <p>{queueHint(firstQueued)} Provisioning starts each server when its host and capacity are ready. Progress updates automatically.</p>
{#if otherLaunches.length} {#if otherLaunches.length}
<p>Other servers currently launching:</p> <p>Other servers currently launching:</p>
<div class="other-launches">{#each otherLaunches as active (active.id)}<a href="/provisioning/{active.id}"><ServerIdentity name={active.server} /> <span aria-hidden="true">→</span></a>{/each}</div> <div class="other-launches">{#each otherLaunches as active (active.id)}<a href={contextHref(`/provisioning/${active.id}`, $page.url)}><ServerIdentity name={active.server} /> <span aria-hidden="true">→</span></a>{/each}</div>
{/if} {/if}
</div> </div>
</aside> </aside>
@@ -108,7 +111,7 @@
<ol class="servers"> <ol class="servers">
{#each detail.servers as server, index (server.id)} {#each detail.servers as server, index (server.id)}
<li class:launching={launching(server)} class:attention={needsAttention(server)}> <li class:launching={launching(server)} class:attention={needsAttention(server)}>
<a class="server" href="/provisioning/{server.id}"> <a class="server" href={contextHref(`/provisioning/${server.id}`, $page.url)}>
<span class="order" class:ready={server.state === 'done'} aria-hidden="true">{server.state === 'done' ? '✓' : String(index + 1).padStart(2, '0')}</span> <span class="order" class:ready={server.state === 'done'} aria-hidden="true">{server.state === 'done' ? '✓' : String(index + 1).padStart(2, '0')}</span>
<div class="server-content"> <div class="server-content">
<div class="server-heading"><strong><HistoricalServerIdentity name={server.server} deletedAt={server.server_deleted_at} /></strong><Badge variant={needsAttention(server) ? 'danger' : server.state === 'done' ? 'online' : launching(server) ? 'info' : 'neutral'}>{stateLabel(server)}</Badge></div> <div class="server-heading"><strong><HistoricalServerIdentity name={server.server} deletedAt={server.server_deleted_at} /></strong><Badge variant={needsAttention(server) ? 'danger' : server.state === 'done' ? 'online' : launching(server) ? 'info' : 'neutral'}>{stateLabel(server)}</Badge></div>
+2
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import BackLink from '$ui/components/BackLink.svelte';
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { beforeNavigate } from '$app/navigation'; import { beforeNavigate } from '$app/navigation';
@@ -210,6 +211,7 @@
<svelte:head><title>Game Files | Electric Cherry</title></svelte:head> <svelte:head><title>Game Files | Electric Cherry</title></svelte:head>
<div class="page"> <div class="page">
<SectionHeader title="Game Files" subtitle="Upload your T5 and T6 game folders. Hosts automatically keep a verified copy of the latest files for each game." /> <SectionHeader title="Game Files" subtitle="Upload your T5 and T6 game folders. Hosts automatically keep a verified copy of the latest files for each game." />
{#if $page.url.searchParams.has('returnTo')}<BackLink fallback="/hosts" />{/if}
{#if error}<p class="ui-error" role="alert">{error}</p>{/if} {#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if notice}<p role="status">{notice}</p>{/if} {#if notice}<p role="status">{notice}</p>{/if}
{#if !canManage}<p class="muted">Administrators upload game files. Active hosts prepare verified copies automatically.</p>{/if} {#if !canManage}<p class="muted">Administrators upload game files. Active hosts prepare verified copies automatically.</p>{/if}
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { contextHref } from '$lib/navigation-context.js';
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
let { data }: { data: import('./$types').PageData } = $props(); let { data }: { data: import('./$types').PageData } = $props();
@@ -55,7 +56,7 @@
{#if enrollment}<div class="enrollment" role="status"><h3>Install the agent</h3><p>This single-use command expires {new Date(enrollment.expiresAt).toLocaleString()}. Run it with administrator access on your game host.</p><pre>{enrollment.command}</pre><a href={`/hosts/${enrollment.hostId}`}>Open host setup →</a></div>{/if} {#if enrollment}<div class="enrollment" role="status"><h3>Install the agent</h3><p>This single-use command expires {new Date(enrollment.expiresAt).toLocaleString()}. Run it with administrator access on your game host.</p><pre>{enrollment.command}</pre><a href={`/hosts/${enrollment.hostId}`}>Open host setup →</a></div>{/if}
</section>{:else}<p>Administrators enroll hosts and manage their settings and credentials.</p>{/if} </section>{:else}<p>Administrators enroll hosts and manage their settings and credentials.</p>{/if}
{#if loading}<p role="status">Loading hosts…</p>{:else if !hosts.length}<p>No hosts connected yet.{canManage ? ' Add a host to begin.' : ''}</p>{:else} {#if loading}<p role="status">Loading hosts…</p>{:else if !hosts.length}<p>No hosts connected yet.{canManage ? ' Add a host to begin.' : ''}</p>{:else}
<div class="host-grid">{#each hosts as host (host.id)}<article class="panel host"><div class="row"><h2><a href={`/hosts/${host.id}`}>{host.name}</a></h2><span class:ready={host.readiness === 'ready'} class="state">{hostReadinessLabel(host.readiness)}</span></div><p>{host.hostname || 'Awaiting enrollment'} · {host.connectionState}</p><p>{host.inventory.os ?? 'OS pending'} · {host.inventory.architecture ?? 'Architecture pending'}</p><p>Game ports {host.portMin}–{host.portMax}</p>{#if host.enrolled}<p>Agent {host.inventory.agentVersion ?? 'unknown'} · Auto updates: {hostAgentUpdateLabel(host.inventory)}</p>{/if}{#if host.decommission}<p class="reason">{hostDeletionMessage(host)}</p>{#if host.decommission.error}<p class="ui-error">{host.decommission.error}</p>{/if}{:else}{#if host.preparation}<p>Automatic setup: {host.preparation.state === 'preparing' ? 'In progress' : host.preparation.state === 'blocked' ? 'Needs attention' : host.preparation.state === 'ready' ? 'Ready' : 'Waiting'}</p>{#if host.preparation.state === 'blocked'}<p class="ui-error">{host.preparation.items.find(item => item.state === 'blocked')?.detail}</p>{/if}{/if}{#if host.readinessReasons.length}<p class="reason">{host.readinessReasons[0]}</p>{/if}{/if}<div class="host-actions"><Button size="sm" href={`/hosts/${host.id}`}>{host.decommission ? 'View cleanup' : 'Manage host'}</Button>{#if canManage && (!host.decommission || host.decommission.retryable)}<Button size="sm" variant="danger" onclick={() => { deletingHost = host; deleteOpen = true; }}>{host.decommission ? 'Retry cleanup' : 'Delete host'}</Button>{/if}</div></article>{/each}</div> <div class="host-grid">{#each hosts as host (host.id)}<article class="panel host"><div class="row"><h2><a href={contextHref(`/hosts/${host.id}`, $page.url)}>{host.name}</a></h2><span class:ready={host.readiness === 'ready'} class="state">{hostReadinessLabel(host.readiness)}</span></div><p>{host.hostname || 'Awaiting enrollment'} · {host.connectionState}</p><p>{host.inventory.os ?? 'OS pending'} · {host.inventory.architecture ?? 'Architecture pending'}</p><p>Game ports {host.portMin}–{host.portMax}</p>{#if host.enrolled}<p>Agent {host.inventory.agentVersion ?? 'unknown'} · Auto updates: {hostAgentUpdateLabel(host.inventory)}</p>{/if}{#if host.decommission}<p class="reason">{hostDeletionMessage(host)}</p>{#if host.decommission.error}<p class="ui-error">{host.decommission.error}</p>{/if}{:else}{#if host.preparation}<p>Automatic setup: {host.preparation.state === 'preparing' ? 'In progress' : host.preparation.state === 'blocked' ? 'Needs attention' : host.preparation.state === 'ready' ? 'Ready' : 'Waiting'}</p>{#if host.preparation.state === 'blocked'}<p class="ui-error">{host.preparation.items.find(item => item.state === 'blocked')?.detail}</p>{/if}{/if}{#if host.readinessReasons.length}<p class="reason">{host.readinessReasons[0]}</p>{/if}{/if}<div class="host-actions"><Button size="sm" href={contextHref(`/hosts/${host.id}`, $page.url)}>{host.decommission ? 'View cleanup' : 'Manage host'}</Button>{#if canManage && (!host.decommission || host.decommission.retryable)}<Button size="sm" variant="danger" onclick={() => { deletingHost = host; deleteOpen = true; }}>{host.decommission ? 'Retry cleanup' : 'Delete host'}</Button>{/if}</div></article>{/each}</div>
{/if} {/if}
</section> </section>
<DeleteHostDialog bind:open={deleteOpen} host={deletingHost} ondeleted={deleted} /> <DeleteHostDialog bind:open={deleteOpen} host={deletingHost} ondeleted={deleted} />
+52 -24
View File
@@ -1,4 +1,8 @@
<script lang="ts"> <script lang="ts">
import BackLink from '$ui/components/BackLink.svelte';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import Tabs from '$ui/components/primitives/Tabs.svelte';
import { contextHref } from '$lib/navigation-context.js';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
let requestSequence = 0; let requestSequence = 0;
@@ -23,6 +27,14 @@
let drainOpen = $state(false); let drainOpen = $state(false);
let name = $state(''); let portMin = $state(28960); let portMax = $state(28980); let bindAddress = $state('0.0.0.0'); let publicAddress = $state(''); let minimumFreeGB = $state(1); let name = $state(''); let portMin = $state(28960); let portMax = $state(28980); let bindAddress = $state('0.0.0.0'); let publicAddress = $state(''); let minimumFreeGB = $state(1);
let runtimeImage = $state('ghcr.io/ayymoss/plutainer:latest'); let enrollment = $state<{ command: string; expiresAt: string } | null>(null); let runtimeImage = $state('ghcr.io/ayymoss/plutainer:latest'); let enrollment = $state<{ command: string; expiresAt: string } | null>(null);
const tabs = [{ id: 'overview', label: 'Overview' }, { id: 'servers', label: 'Servers' }, { id: 'setup', label: 'Setup' }, { id: 'settings', label: 'Settings' }];
const tab = $derived(tabs.some(tab => tab.id === $page.url.searchParams.get('tab')) ? $page.url.searchParams.get('tab')! : 'overview');
function changeTab(value: string) {
const url = new URL($page.url);
url.searchParams.set('tab', value);
url.hash = '';
void goto(url, { noScroll: true, keepFocus: true });
}
const endpoint = $derived(`/api/hosts/${$page.params.id}`); const endpoint = $derived(`/api/hosts/${$page.params.id}`);
const canManage = $derived($page.data.user?.role === 'admin'); const canManage = $derived($page.data.user?.role === 'admin');
const cleaning = $derived(!!host?.decommission); const cleaning = $derived(!!host?.decommission);
@@ -49,6 +61,7 @@
$effect(() => { $effect(() => {
void endpoint; void endpoint;
return untrack(() => { return untrack(() => {
host = null; instances = []; calibration = null; loading = true; error = ''; notice = ''; enrollment = null;
let initialize = true; let initialize = true;
return pollVisible(async signal => { const result = await load(initialize, signal); if (result !== false) initialize = false; return result; }, 15000); return pollVisible(async signal => { const result = await load(initialize, signal); if (result !== false) initialize = false; return result; }, 15000);
}); });
@@ -56,17 +69,35 @@
</script> </script>
<svelte:head><title>{host?.name ?? 'Host'} · Electric Cherry</title></svelte:head> <svelte:head><title>{host?.name ?? 'Host'} · Electric Cherry</title></svelte:head>
<section class="page"><a href="/hosts">← All hosts</a><SectionHeader title={host?.name ?? 'Host'} subtitle="Running instances, readiness, inventory, settings, and credentials."> <section class="page"><SectionHeader title={host?.name ?? 'Host'} subtitle="Running instances, readiness, inventory, settings, and credentials.">
{#snippet actions()}<Button href="/hosts/operations">Operation history</Button>{#if canManage && host && (!cleaning || host.decommission?.retryable)}<Button variant="danger" disabled={busy} onclick={() => deleteOpen = true}>{cleaning ? 'Retry cleanup' : 'Delete host'}</Button>{/if}{/snippet} {#snippet crumbs()}<Breadcrumbs items={[{ label: 'Hosts', href: '/hosts' }, { label: host?.name ?? 'Host' }]} />{/snippet}
{#snippet actions()}<Button href={contextHref(`/capacity?host=${encodeURIComponent($page.params.id ?? "")}`, $page.url)}>Capacity</Button><Button href={contextHref(`/hosts/operations?host=${encodeURIComponent($page.params.id ?? "")}`, $page.url)}>Operation history</Button>{#if canManage && host && (!cleaning || host.decommission?.retryable)}<Button variant="danger" disabled={busy} onclick={() => deleteOpen = true}>{cleaning ? 'Retry cleanup' : 'Delete host'}</Button>{/if}{/snippet}
</SectionHeader> </SectionHeader>
<BackLink fallback="/hosts" />
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}{#if notice}<p role="status">{notice}</p>{/if} {#if error}<p class="ui-error" role="alert">{error}</p>{/if}{#if notice}<p role="status">{notice}</p>{/if}
{#if loading}<p role="status">Loading host…</p>{:else if host} {#if loading}<p role="status">Loading host…</p>{:else if host}
{#if host.decommission}<section class="panel" aria-live="polite"><h2>Host cleanup</h2><p>{hostDeletionMessage(host)}</p>{#if host.decommission.error}<p class="ui-error">{host.decommission.error}</p>{/if}<p>Requested {new Date(host.decommission.requestedAt).toLocaleString()}. This page closes only after the cleanup worker confirms removal.</p>{#if host.decommission.stage === 'cleaned'}<p>The agent is expected to go offline during uninstall. If final confirmation never arrives, inspect the machine; loss of contact alone does not prove completion.</p>{/if}</section>{/if} {#if host.decommission}<section class="panel" aria-live="polite"><h2>Host cleanup</h2><p>{hostDeletionMessage(host)}</p>{#if host.decommission.error}<p class="ui-error">{host.decommission.error}</p>{/if}<p>Requested {new Date(host.decommission.requestedAt).toLocaleString()}. This page closes only after the cleanup worker confirms removal.</p>{#if host.decommission.stage === 'cleaned'}<p>The agent is expected to go offline during uninstall. If final confirmation never arrives, inspect the machine; loss of contact alone does not prove completion.</p>{/if}</section>{/if}
<section class="panel"><div class="row"><h2>{host.connectionState} · {hostReadinessLabel(host.readiness)}</h2><span>Last heartbeat: {host.lastHeartbeat ? new Date(host.lastHeartbeat).toLocaleString() : 'Never'}</span></div> <section class="panel"><div class="row"><h2>{host.connectionState} · {hostReadinessLabel(host.readiness)}</h2><span>Last heartbeat: {host.lastHeartbeat ? new Date(host.lastHeartbeat).toLocaleString() : 'Never'}</span></div>
{#if host.readinessReasons.length}<ul>{#each host.readinessReasons as reason}<li>{reason}</li>{/each}</ul>{:else}<p>Connected and ready. Each deployment also checks its latest game files, runtime, and ports.</p>{/if} {#if host.readinessReasons.length}<ul>{#each host.readinessReasons as reason}<li>{reason}</li>{/each}</ul>{:else}<p>Connected and ready. Each deployment also checks its latest game files, runtime, and ports.</p>{/if}
{#if canManage && !cleaning}<div class="actions"><Button disabled={busy || host.mode !== 'disabled' || !!host.enablingSince} onclick={() => mode('active')}>{host.enablingSince ? 'Enabling…' : 'Enable'}</Button><Button disabled={busy || host.mode !== 'active'} onclick={() => drainOpen = true}>Drain host</Button></div>{/if} {#if host.readiness !== 'ready' && tab !== 'setup'}<Button size="sm" onclick={() => changeTab('setup')}>Review setup</Button>{/if}
<p>Draining blocks new deployments and migrates empty instances to other hosts with capacity. The host disables when migration is complete and it is empty. Enabling refreshes the runtime and verifies BO1 and BO2 game files before returning the host to the capacity pool.</p>
</section> </section>
<Tabs {tabs} value={tab} onchange={changeTab} ariaLabel="Host sections" idPrefix="host-tabs" panelId="host-panel" />
<div class="host-panel" id="host-panel" role="tabpanel" aria-labelledby={`host-tabs-${tab}`}>
{#if tab === 'overview'}
<div class="overview-actions"><Button onclick={() => changeTab('servers')}>View {instances.length} servers</Button></div>
<section class="panel"><h2>Inventory</h2><dl>
<dt>Hostname</dt><dd>{host.hostname || 'Pending'}</dd><dt>Agent</dt><dd>{host.inventory.agentVersion ?? 'Unknown'}</dd><dt>Platform</dt><dd>{host.inventory.os ?? 'Unknown'} · {host.inventory.architecture ?? 'Unknown'}</dd><dt>CPU</dt><dd>{host.inventory.cpu ?? 'Unknown'} ({host.inventory.cpuCount ?? '?'} cores)</dd><dt>CPU usage</dt><dd title="Average across all cores since the previous inventory sample">{host.inventory.cpuUsagePercent === undefined ? 'Not reported' : `${host.inventory.cpuUsagePercent.toFixed(1)}%`}{#if !host.connected && host.inventory.cpuUsagePercent !== undefined} (last reported){/if}</dd><dt>Memory available / total</dt><dd>{bytes(host.inventory.memoryAvailable)} / {bytes(host.inventory.memoryTotal)}</dd><dt>Game storage available / total</dt><dd>{bytes(host.inventory.storageAvailable)} / {bytes(host.inventory.storageTotal)}</dd><dt>Docker</dt><dd>{host.inventory.dockerVersion ?? 'Unknown'} · {host.inventory.dockerRunning ? 'Running' : 'Unavailable'}</dd><dt>Current operation</dt><dd>{host.inventory.currentOperation ?? 'None reported'}</dd>
</dl></section> {#if calibration}<section class="panel"><h2>Learned server capacity</h2>
<p>Allowances update from observed container usage. Hosts reserve 5% CPU and 500 MB of memory. Startup and running usage are tracked separately.</p>
<dl>{#each ['startup', 'running'] as phase}{@const allowance = calibration[phase as keyof CapacityCalibration]}
<dt>{phase === 'startup' ? 'Starting a server' : 'Running a server'}</dt>
<dd>{allowance.cpuCores.toFixed(2)} CPU cores · {bytes(allowance.memoryBytes)} · {allowance.source === 'host' ? 'Learned on this host' : allowance.source === 'fleet' ? 'Learned from the fleet' : 'Calibrating; temporary allowance'} ({allowance.samples} samples)</dd>
{/each}</dl>
{#if host.inventory.instanceResourcesError}<p role="status">{host.inventory.instanceResourcesError}</p>{/if}
{#if calibration.startup.source === 'bootstrap' || calibration.running.source === 'bootstrap'}<p>Use agent 1.4.6 or later for resource sampling. Measurements arrive with agent heartbeats and are combined into one peak sample per container per minute. The first five minutes after a container starts count toward startup; later measurements count toward running. Temporary allowances stay in use until enough measurements are available.</p>{/if}
{#key host.id}<CapacitySamples hostId={host.id} />{/key}
</section>{/if}
{:else if tab === 'servers'}
<section class="panel" aria-labelledby="running-instances-heading"> <section class="panel" aria-labelledby="running-instances-heading">
<h2 id="running-instances-heading">Running instances · {instances.length}</h2> <h2 id="running-instances-heading">Running instances · {instances.length}</h2>
<p>Current instances on this host, including any that are starting or unavailable.</p> <p>Current instances on this host, including any that are starting or unavailable.</p>
@@ -83,35 +114,31 @@
</div> </div>
{:else}<p>No instances are currently deployed on this host.</p>{/if} {:else}<p>No instances are currently deployed on this host.</p>{/if}
</section> </section>
{:else if tab === 'setup'}
{#if !cleaning}<section class="panel"><h2>Automatic host setup</h2><HostPreparation {host} /></section>{/if} {#if !cleaning}<section class="panel"><h2>Automatic host setup</h2><HostPreparation {host} /></section>{/if}
{#if calibration}<section class="panel"><h2>Learned server capacity</h2>
<p>Allowances update from observed container usage. Hosts reserve 5% CPU and 500 MB of memory. Startup and running usage are tracked separately.</p>
<dl>{#each ['startup', 'running'] as phase}{@const allowance = calibration[phase as keyof CapacityCalibration]}
<dt>{phase === 'startup' ? 'Starting a server' : 'Running a server'}</dt>
<dd>{allowance.cpuCores.toFixed(2)} CPU cores · {bytes(allowance.memoryBytes)} · {allowance.source === 'host' ? 'Learned on this host' : allowance.source === 'fleet' ? 'Learned from the fleet' : 'Calibrating; temporary allowance'} ({allowance.samples} samples)</dd>
{/each}</dl>
{#if host.inventory.instanceResourcesError}<p role="status">{host.inventory.instanceResourcesError}</p>{/if}
{#if calibration.startup.source === 'bootstrap' || calibration.running.source === 'bootstrap'}<p>Use agent 1.4.6 or later for resource sampling. Measurements arrive with agent heartbeats and are combined into one peak sample per container per minute. The first five minutes after a container starts count toward startup; later measurements count toward running. Temporary allowances stay in use until enough measurements are available.</p>{/if}
{#key host.id}<CapacitySamples hostId={host.id} />{/key}
</section>{/if}
<div class="grid"><section class="panel"><h2>Inventory</h2><dl>
<dt>Hostname</dt><dd>{host.hostname || 'Pending'}</dd><dt>Agent</dt><dd>{host.inventory.agentVersion ?? 'Unknown'}</dd><dt>Platform</dt><dd>{host.inventory.os ?? 'Unknown'} · {host.inventory.architecture ?? 'Unknown'}</dd><dt>CPU</dt><dd>{host.inventory.cpu ?? 'Unknown'} ({host.inventory.cpuCount ?? '?'} cores)</dd><dt>CPU usage</dt><dd title="Average across all cores since the previous inventory sample">{host.inventory.cpuUsagePercent === undefined ? 'Not reported' : `${host.inventory.cpuUsagePercent.toFixed(1)}%`}{#if !host.connected && host.inventory.cpuUsagePercent !== undefined} (last reported){/if}</dd><dt>Memory available / total</dt><dd>{bytes(host.inventory.memoryAvailable)} / {bytes(host.inventory.memoryTotal)}</dd><dt>Game storage available / total</dt><dd>{bytes(host.inventory.storageAvailable)} / {bytes(host.inventory.storageTotal)}</dd><dt>Docker</dt><dd>{host.inventory.dockerVersion ?? 'Unknown'} · {host.inventory.dockerRunning ? 'Running' : 'Unavailable'}</dd><dt>Current operation</dt><dd>{host.inventory.currentOperation ?? 'None reported'}</dd>
</dl></section><section class="panel"><h2>Host configuration</h2>{#if canManage}<form onsubmit={save}><fieldset disabled={busy || cleaning}>
<label>Friendly name<input required maxlength="200" bind:value={name} /></label><div class="fields"><label>First game port<input type="number" min="1" max="65535" required bind:value={portMin} /></label><label>Last game port<input type="number" min={portMin} max="65535" required bind:value={portMax} /></label></div><label>Local bind address<input required bind:value={bindAddress} /></label><label>Public / NAT address<input bind:value={publicAddress} placeholder="Optional public hostname or address" /></label><label>Minimum free game storage (GiB)<input type="number" min="0" step="0.1" required bind:value={minimumFreeGB} /></label><Button type="submit" disabled={busy}>Save settings</Button>
</fieldset></form>{:else}<dl><dt>Friendly name</dt><dd>{host.name}</dd><dt>Game ports</dt><dd>{host.portMin}–{host.portMax}</dd><dt>Local bind address</dt><dd>{host.bindAddress}</dd><dt>Public / NAT address</dt><dd>{host.publicAddress || 'Not configured'}</dd><dt>Minimum free storage</dt><dd>{bytes(host.minimumFreeBytes)}</dd></dl><p>Host configuration is managed by administrators.</p>{/if}<p>Public addressing is separate from local port reservations. Configure your firewall and NAT for the game-port range.</p></section></div>
<section class="panel" aria-live="polite"><h2>Automatic agent updates · {host.enrolled ? hostAgentUpdateLabel(host.inventory) : 'Awaiting installation'}</h2> <section class="panel" aria-live="polite"><h2>Automatic agent updates · {host.enrolled ? hostAgentUpdateLabel(host.inventory) : 'Awaiting installation'}</h2>
<dl><dt>Running version</dt><dd>{host.inventory.agentVersion ?? host.inventory.agentUpdate?.currentVersion ?? 'Not reported'}</dd><dt>Target version</dt><dd>{host.inventory.agentUpdate?.targetVersion ?? 'None reported'}</dd><dt>Last update check</dt><dd>{host.inventory.agentUpdate?.checkedAt ? new Date(host.inventory.agentUpdate.checkedAt).toLocaleString() : 'Not reported'}</dd></dl> <dl><dt>Running version</dt><dd>{host.inventory.agentVersion ?? host.inventory.agentUpdate?.currentVersion ?? 'Not reported'}</dd><dt>Target version</dt><dd>{host.inventory.agentUpdate?.targetVersion ?? 'None reported'}</dd><dt>Last update check</dt><dd>{host.inventory.agentUpdate?.checkedAt ? new Date(host.inventory.agentUpdate.checkedAt).toLocaleString() : 'Not reported'}</dd></dl>
{#if !host.connected && host.enrolled}<p>This is the last reported update status. The host is offline.</p>{/if} {#if !host.connected && host.enrolled}<p>This is the last reported update status. The host is offline.</p>{/if}
{#if host.inventory.agentUpdate?.error}<p class="ui-error">{host.inventory.agentUpdate.error}</p>{/if} {#if host.inventory.agentUpdate?.error}<p class="ui-error">{host.inventory.agentUpdate.error}</p>{/if}
{#if host.enrolled}<p>{hostAgentUpdateMessage(host.inventory)}</p>{:else}<p>The installer enables automatic updates when this host first enrolls.</p>{/if} {#if host.enrolled}<p>{hostAgentUpdateMessage(host.inventory)}</p>{:else}<p>The installer enables automatic updates when this host first enrolls.</p>{/if}
{#if host.enrolled && !hostAgentUpdatesEnabled(host.inventory)}<p>{canManage ? 'Generate an installer under Agent access below, run it on this host, and wait for a fresh heartbeat. The installer preserves the existing host identity and game data.' : 'An administrator can generate an installer under Agent access and run it on this host.'}</p>{:else if hostAgentUpdatesEnabled(host.inventory)}<p>The host checks this controller about every five minutes. Updates replace agent code between operations; running game containers continue.</p>{/if} {#if host.enrolled && !hostAgentUpdatesEnabled(host.inventory)}<p>{canManage ? 'Generate an installer under Agent access in Settings, run it on this host, and wait for a fresh heartbeat. The installer preserves the existing host identity and game data.' : 'An administrator can generate an installer under Agent access and run it on this host.'}</p>{:else if hostAgentUpdatesEnabled(host.inventory)}<p>The host checks this controller about every five minutes. Updates replace agent code between operations; running game containers continue.</p>{/if}
</section> </section>
<div class="grid"><section class="panel"><h2>Cached game files and runtimes</h2>{#if host.inventory.libraries?.length}<ul>{#each host.inventory.libraries as library}<li>{library.id}: {library.state}{library.progress !== undefined ? ` (${library.progress}%)` : ''}</li>{/each}</ul>{:else}<p>No cached game libraries reported yet.</p>{/if} <section class="panel"><h2>Cached game files and runtimes</h2>{#if host.inventory.libraries?.length}<ul>{#each host.inventory.libraries as library}<li>{library.id}: {library.state}{library.progress !== undefined ? ` (${library.progress}%)` : ''}</li>{/each}</ul>{:else}<p>No cached game libraries reported yet.</p>{/if}
<p>Cached images: {host.inventory.runtimeImages?.join(', ') || 'None reported yet'}</p> <p>Cached images: {host.inventory.runtimeImages?.join(', ') || 'None reported yet'}</p>
{#if canManage}<details><summary>Advanced: custom runtime or manual retry</summary><p>The configured default runtime is prepared automatically. Use this to prepare another image or retry a pull now.</p><form onsubmit={(event) => { event.preventDefault(); void action('/operations', 'POST', { kind: 'runtime.pull', image: runtimeImage }, 'Runtime download queued.'); }}><fieldset disabled={busy || cleaning}><label>Plutainer image<input required bind:value={runtimeImage} /></label><Button type="submit" disabled={busy || !host.connected}>Pull runtime image</Button></fieldset></form></details>{/if} {#if canManage}<details><summary>Advanced: custom runtime or manual retry</summary><p>The configured default runtime is prepared automatically. Use this to prepare another image or retry a pull now.</p><form onsubmit={(event) => { event.preventDefault(); void action('/operations', 'POST', { kind: 'runtime.pull', image: runtimeImage }, 'Runtime download queued.'); }}><fieldset disabled={busy || cleaning}><label>Plutainer image<input required bind:value={runtimeImage} /></label><Button type="submit" disabled={busy || !host.connected}>Pull runtime image</Button></fieldset></form></details>{/if}
</section><section class="panel"><h2>Agent access</h2><p>Credentials: {host.credentialActive ? 'Active' : 'Not active'}. Enrollment links expire after 15 minutes and work once.</p>{#if canManage}<div class="actions"><Button disabled={busy || (cleaning && !host.decommission?.retryable)} onclick={() => action('/enrollment', 'POST')}>Generate installer</Button><Button disabled={busy || cleaning || !host.credentialActive || !host.connected} onclick={() => action('/credentials', 'POST', undefined, 'Credential rotation queued.')}>Rotate credential</Button><Button disabled={busy || cleaning || !host.credentialActive} onclick={() => action('/credentials', 'DELETE', undefined, 'Host credentials revoked. Generate a new installer to enroll again.')}>Revoke credential</Button></div> </section>
<div class="actions"><Button href={contextHref('/game-files', $page.url)}>Game Files</Button><Button onclick={() => changeTab('settings')}>Agent access</Button></div>
{:else if tab === 'settings'}
<section class="panel"><h2>Host availability</h2>
{#if canManage && !cleaning}<div class="actions"><Button disabled={busy || host.mode !== 'disabled' || !!host.enablingSince} onclick={() => mode('active')}>{host.enablingSince ? 'Enabling…' : 'Enable'}</Button><Button disabled={busy || host.mode !== 'active'} onclick={() => drainOpen = true}>Drain host</Button></div>{/if}
<p>Draining blocks new deployments and migrates empty instances to other hosts with capacity. The host disables when migration is complete and it is empty. Enabling refreshes the runtime and verifies BO1 and BO2 game files before returning the host to the capacity pool.</p></section>
<section class="panel"><h2>Host configuration</h2>{#if canManage}<form onsubmit={save}><fieldset disabled={busy || cleaning}>
<label>Friendly name<input required maxlength="200" bind:value={name} /></label><div class="fields"><label>First game port<input type="number" min="1" max="65535" required bind:value={portMin} /></label><label>Last game port<input type="number" min={portMin} max="65535" required bind:value={portMax} /></label></div><label>Local bind address<input required bind:value={bindAddress} /></label><label>Public / NAT address<input bind:value={publicAddress} placeholder="Optional public hostname or address" /></label><label>Minimum free game storage (GiB)<input type="number" min="0" step="0.1" required bind:value={minimumFreeGB} /></label><Button type="submit" disabled={busy}>Save settings</Button>
</fieldset></form>{:else}<dl><dt>Friendly name</dt><dd>{host.name}</dd><dt>Game ports</dt><dd>{host.portMin}–{host.portMax}</dd><dt>Local bind address</dt><dd>{host.bindAddress}</dd><dt>Public / NAT address</dt><dd>{host.publicAddress || 'Not configured'}</dd><dt>Minimum free storage</dt><dd>{bytes(host.minimumFreeBytes)}</dd></dl><p>Host configuration is managed by administrators.</p>{/if}<p>Public addressing is separate from local port reservations. Configure your firewall and NAT for the game-port range.</p></section><section class="panel" id="agent-access"><h2>Agent access</h2><p>Credentials: {host.credentialActive ? 'Active' : 'Not active'}. Enrollment links expire after 15 minutes and work once.</p>{#if canManage}<div class="actions"><Button disabled={busy || (cleaning && !host.decommission?.retryable)} onclick={() => action('/enrollment', 'POST')}>Generate installer</Button><Button disabled={busy || cleaning || !host.credentialActive || !host.connected} onclick={() => action('/credentials', 'POST', undefined, 'Credential rotation queued.')}>Rotate credential</Button><Button disabled={busy || cleaning || !host.credentialActive} onclick={() => action('/credentials', 'DELETE', undefined, 'Host credentials revoked. Generate a new installer to enroll again.')}>Revoke credential</Button></div>
{#if enrollment}<p>Expires {new Date(enrollment.expiresAt).toLocaleString()}</p><pre>{enrollment.command}</pre>{/if}{:else}<p>Administrators manage enrollment and agent credentials.</p>{/if} {#if enrollment}<p>Expires {new Date(enrollment.expiresAt).toLocaleString()}</p><pre>{enrollment.command}</pre>{/if}{:else}<p>Administrators manage enrollment and agent credentials.</p>{/if}
</section></div> </section>
{/if}
</div>
{/if} {/if}
</section> </section>
<DeleteHostDialog bind:open={deleteOpen} {host} ondeleted={async (result) => { if (result.deleted) await goto('/hosts'); else { enrollment = null; await load(); } }} /> <DeleteHostDialog bind:open={deleteOpen} {host} ondeleted={async (result) => { if (result.deleted) await goto('/hosts'); else { enrollment = null; await load(); } }} />
@@ -124,5 +151,6 @@
<style> <style>
.instances { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr)); gap:16px; } .instances { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr)); gap:16px; }
.page { display:grid; gap:24px; } .panel { border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); padding:24px; min-width:0; } h2 { margin:0 0 16px; font-size:var(--text-md); text-transform:capitalize; } p, li, dd, dt, span { font-size:var(--text-sm); line-height:1.65; } p,li,dt,span { color:var(--text-secondary); } .grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:24px; } .row,.actions,.fields { display:flex; gap:12px; flex-wrap:wrap; } .row { justify-content:space-between; } .actions { margin-top:20px; } dl { display:grid; grid-template-columns:1fr 1.3fr; gap:12px; margin:0; } dd { margin:0; overflow-wrap:anywhere; } fieldset { display:grid; gap:16px; border:0; padding:0; margin:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; min-width:0; padding:10px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); } .fields>* { flex:1; min-width:120px; } pre { white-space:pre-wrap; overflow-wrap:anywhere; padding:16px; background:var(--surface-sunken); font-size:var(--text-xs); } @media(max-width:900px) { .grid { grid-template-columns:1fr; } } .host-panel { display:grid; gap:24px; min-width:0; } .overview-actions { display:flex; flex-wrap:wrap; gap:12px; }
.page { display:grid; gap:24px; } .panel { border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); padding:24px; min-width:0; } h2 { margin:0 0 16px; font-size:var(--text-md); text-transform:capitalize; } p, li, dd, dt, span { font-size:var(--text-sm); line-height:1.65; } p,li,dt,span { color:var(--text-secondary); } .row,.actions,.fields { display:flex; gap:12px; flex-wrap:wrap; } .row { justify-content:space-between; } .actions { margin-top:20px; } dl { display:grid; grid-template-columns:1fr 1.3fr; gap:12px; margin:0; } dd { margin:0; overflow-wrap:anywhere; } fieldset { display:grid; gap:16px; border:0; padding:0; margin:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; min-width:0; padding:10px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); } .fields>* { flex:1; min-width:120px; } pre { white-space:pre-wrap; overflow-wrap:anywhere; padding:16px; background:var(--surface-sunken); font-size:var(--text-xs); }
</style> </style>
+7 -5
View File
@@ -1,19 +1,21 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { listHostOperationHistory } from '$lib/server/hosts/index.js'; import { getHost, listHostOperationHistory } from '$lib/server/hosts/index.js';
import { isHostOperationFilter } from '$lib/hosts.js'; import { hostOperationScope, isHostOperationFilter } from '$lib/hosts.js';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ url }) => { export const load: PageServerLoad = ({ url }) => {
const requestedPage = url.searchParams.get('page'); const requestedPage = url.searchParams.get('page');
const requestedFilter = url.searchParams.get('filter'); const requestedFilter = url.searchParams.get('filter');
const filter = isHostOperationFilter(requestedFilter) ? requestedFilter : 'important'; const filter = isHostOperationFilter(requestedFilter) ? requestedFilter : 'important';
const history = listHostOperationHistory(Number(requestedPage ?? 1), filter); const scope = hostOperationScope(url.searchParams);
const history = listHostOperationHistory(Number(requestedPage ?? 1), filter, scope);
if ((requestedPage !== null && requestedPage !== String(history.page)) if ((requestedPage !== null && requestedPage !== String(history.page))
|| (requestedFilter !== null && requestedFilter !== filter)) { || (requestedFilter !== null && requestedFilter !== filter)) {
const params = new URLSearchParams(); const params = new URLSearchParams(url.searchParams);
params.delete('page');
if (requestedFilter !== null) params.set('filter', filter); if (requestedFilter !== null) params.set('filter', filter);
if (history.page !== 1) params.set('page', String(history.page)); if (history.page !== 1) params.set('page', String(history.page));
redirect(307, `/hosts/operations${params.size ? `?${params}` : ''}`); redirect(307, `/hosts/operations${params.size ? `?${params}` : ''}`);
} }
return { history }; return { history, scope, hostName: scope.host ? getHost(scope.host)?.name ?? scope.host : null };
}; };
+14 -8
View File
@@ -1,11 +1,13 @@
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { beforeNavigate } from '$app/navigation'; import { beforeNavigate } from '$app/navigation';
import { navigating } from '$app/stores'; import { navigating, page } from '$app/stores';
import type { HostOperationFilter, HostOperationHistoryPage } from '$lib/hosts.js'; import type { HostOperationFilter, HostOperationHistoryPage } from '$lib/hosts.js';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import BackLink from '$ui/components/BackLink.svelte';
import { contextHref } from '$lib/navigation-context.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
@@ -25,9 +27,10 @@
{ id: 'all', label: 'All operations', description: 'Every recorded remote operation.', empty: 'No remote operations yet.' } { id: 'all', label: 'All operations', description: 'Every recorded remote operation.', empty: 'No remote operations yet.' }
]; ];
const selectedFilter = $derived(filters.find(filter => filter.id === history.filter)!); const selectedFilter = $derived(filters.find(filter => filter.id === history.filter)!);
function pageHref(page: number, filter: HostOperationFilter = history.filter) { function pageHref(pageNumber: number, filter: HostOperationFilter = history.filter) {
const params = new URLSearchParams({ filter }); const params = new URLSearchParams($page.url.searchParams);
if (page !== 1) params.set('page', String(page)); params.set('filter', filter); params.delete('page');
if (pageNumber !== 1) params.set('page', String(pageNumber));
return `/hosts/operations?${params}`; return `/hosts/operations?${params}`;
} }
function cancel() { sequence++; request?.abort(); request = null; refreshing = false; } function cancel() { sequence++; request?.abort(); request = null; refreshing = false; }
@@ -40,7 +43,7 @@
request = controller; request = controller;
refreshing = true; refreshing = true;
try { try {
const params = new URLSearchParams({ page: String(history.page), filter: history.filter }); const params = new URLSearchParams({ ...data.scope, page: String(history.page), filter: history.filter });
const response = await fetch(`/api/hosts/operations?${params}`, { const response = await fetch(`/api/hosts/operations?${params}`, {
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(15000), ...(signal ? [signal] : [])]) signal: AbortSignal.any([controller.signal, AbortSignal.timeout(15000), ...(signal ? [signal] : [])])
}); });
@@ -60,12 +63,14 @@
onMount(() => { const stop = pollVisible(refresh, 15000, false); return () => { stop(); cancel(); }; }); onMount(() => { const stop = pollVisible(refresh, 15000, false); return () => { stop(); cancel(); }; });
</script> </script>
<svelte:head><title>Operation history · Hosts · Electric Cherry</title></svelte:head> <svelte:head><title>Host operation history · Electric Cherry</title></svelte:head>
<section class="page" aria-busy={changingPage}> <section class="page" aria-busy={changingPage}>
<SectionHeader title="Operation history" eyebrow="Hosts" subtitle="Remote operations across all hosts, newest first."> <SectionHeader title="Host operation history" eyebrow="Operations" subtitle="Remote operations, newest first. Filtered views retain their host and server context.">
{#snippet actions()}<Button disabled={refreshing || changingPage} onclick={() => void refresh()}>{refreshing ? 'Refreshing…' : 'Refresh'}</Button>{/snippet} {#snippet actions()}<Button disabled={refreshing || changingPage} onclick={() => void refresh()}>{refreshing ? 'Refreshing…' : 'Refresh'}</Button>{/snippet}
</SectionHeader> </SectionHeader>
{#if $page.url.searchParams.has('returnTo')}<BackLink fallback="/hosts" />{/if}
{#if data.scope.host || data.scope.server}<div class="scope" role="status"><span>Showing {data.hostName ? `host: ${data.hostName}` : ''}{data.scope.host && data.scope.server ? ' · ' : ''}{data.scope.server ? `server: ${data.scope.server}` : ''}</span><a href={`/hosts/operations?filter=${history.filter}`}>Clear scope</a></div>{/if}
<div class="history-filters"> <div class="history-filters">
<nav class="filter-links" aria-label="Operation history filters"> <nav class="filter-links" aria-label="Operation history filters">
{#each filters as filter} {#each filters as filter}
@@ -86,7 +91,7 @@
<tbody>{#each history.operations as operation (operation.id)} <tbody>{#each history.operations as operation (operation.id)}
<tr> <tr>
<td class="operation">{operation.kind}<small>{operation.id}</small></td> <td class="operation">{operation.kind}<small>{operation.id}</small></td>
<td>{#if operation.hostName !== null}<a href={`/hosts/${operation.hostId}`}>{operation.hostName}</a>{:else}Deleted host<small>{operation.hostId}</small>{/if}</td> <td>{#if operation.hostName !== null}<a href={contextHref(`/hosts/${operation.hostId}`, $page.url)}>{operation.hostName}</a>{:else}Deleted host<small>{operation.hostId}</small>{/if}</td>
<td class="target"> <td class="target">
<span>{operation.serverName ?? operation.instanceId ?? 'Host-level'}</span> <span>{operation.serverName ?? operation.instanceId ?? 'Host-level'}</span>
{#if operation.targetPath}<small class="target-path">{operation.targetPath}</small>{/if} {#if operation.targetPath}<small class="target-path">{operation.targetPath}</small>{/if}
@@ -112,6 +117,7 @@
</section> </section>
<style> <style>
.scope { display:flex; flex-wrap:wrap; gap:16px; color:var(--text-secondary); font-size:var(--text-sm); }
.page { display: grid; gap: 24px; min-width: 0; } .page { display: grid; gap: 24px; min-width: 0; }
.history-filters { min-width: 0; } .history-filters { min-width: 0; }
.history-filters p { margin: 10px 0 0; color: var(--text-secondary); font-size: var(--text-sm); } .history-filters p { margin: 10px 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import BackLink from '$ui/components/BackLink.svelte';
import { contextHref } from '$lib/navigation-context.js';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { goto, invalidateAll } from '$app/navigation'; import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
@@ -61,9 +64,10 @@
<section class="profile"> <section class="profile">
<SectionHeader title={profile.name} eyebrow="Player profile" subtitle="Black Ops I & Black Ops II · Lifetime Zombies stats"> <SectionHeader title={profile.name} eyebrow="Player profile" subtitle="Black Ops I & Black Ops II · Lifetime Zombies stats">
{#snippet crumbs()}<a href={data.returnTo}>← Leaderboard</a>{/snippet} {#snippet crumbs()}<Breadcrumbs items={[{ label: 'Leaderboard', href: '/leaderboard' }, { label: profile.name }]} />{/snippet}
{#snippet actions()}<Button size="sm" loading={refreshing} onclick={refresh}>Refresh stats</Button>{/snippet} {#snippet actions()}<Button size="sm" loading={refreshing} onclick={refresh}>Refresh stats</Button>{/snippet}
</SectionHeader> </SectionHeader>
<BackLink fallback={data.returnTo} />
<div class="identity"><CopyValue value={profile.guid} label="Player GUID" /><span>First seen {date(profile.firstSeen)}</span><span>Last seen {date(profile.lastSeen)}</span></div> <div class="identity"><CopyValue value={profile.guid} label="Player GUID" /><span>First seen {date(profile.firstSeen)}</span><span>Last seen {date(profile.lastSeen)}</span></div>
{#if refreshError}<p role="alert">{refreshError}</p>{/if} {#if refreshError}<p role="alert">{refreshError}</p>{/if}
@@ -125,7 +129,7 @@
<table> <table>
<thead><tr><th scope="col">Map / server</th><th scope="col">First observed</th><th scope="col">Match status</th>{#each historyColumns as key}<th scope="col">{playerStatMetrics.find(metric => metric.key === key)!.label}</th>{/each}</tr></thead> <thead><tr><th scope="col">Map / server</th><th scope="col">First observed</th><th scope="col">Match status</th>{#each historyColumns as key}<th scope="col">{playerStatMetrics.find(metric => metric.key === key)!.label}</th>{/each}</tr></thead>
<tbody>{#each profile.matches as match (match.id)} <tbody>{#each profile.matches as match (match.id)}
<tr><th scope="row">{#if match.status === 'completed'}<a href={`/matches/${match.id}`} aria-label={`View ${match.mapName} match from ${date(match.startedAt)}`}>{match.mapName}</a>{:else}{match.mapName}{/if}<small>{gameName(match.game)}</small><small>{match.server} {#if match.server_deleted_at}<Badge>Deleted</Badge>{/if}</small></th><td>{date(match.startedAt)}</td> <tr><th scope="row">{#if match.status === 'completed'}<a href={contextHref(`/matches/${match.id}`, $page.url)} aria-label={`View ${match.mapName} match from ${date(match.startedAt)}`}>{match.mapName}</a>{:else}{match.mapName}{/if}<small>{gameName(match.game)}</small><small>{match.server} {#if match.server_deleted_at}<Badge>Deleted</Badge>{/if}</small></th><td>{date(match.startedAt)}</td>
<td class="status">{match.status}<small>{match.completed ? 'Completion credited' : 'No completion credit'}</small></td> <td class="status">{match.status}<small>{match.completed ? 'Completion credited' : 'No completion credit'}</small></td>
{#each historyColumns as key}<td class:best={key === 'highestRound'}>{number.format(match[key])}</td>{/each} {#each historyColumns as key}<td class:best={key === 'highestRound'}>{number.format(match[key])}</td>{/each}
</tr> </tr>
+4 -1
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import BackLink from '$ui/components/BackLink.svelte';
import { contextHref } from '$lib/navigation-context.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@@ -129,6 +131,7 @@
{#if error}<p class="ui-error" role="alert">{error} {data ? 'Showing the last successful refresh.' : ''} <Button size="sm" onclick={load} disabled={busy}>Retry refresh</Button></p>{/if} {#if error}<p class="ui-error" role="alert">{error} {data ? 'Showing the last successful refresh.' : ''} <Button size="sm" onclick={load} disabled={busy}>Retry refresh</Button></p>{/if}
{#if notice}<p role="status">{notice}</p>{/if} {#if notice}<p role="status">{notice}</p>{/if}
{#if data?.policies.paused}<div class="paused"><strong>All maintenance is paused.</strong> Checks and verification continue. New actions, scheduled runs, and queued maintenance are held. Submitted operations finish recovery. Campaigns, betas, and manual Provisioning actions continue.</div>{/if} {#if data?.policies.paused}<div class="paused"><strong>All maintenance is paused.</strong> Checks and verification continue. New actions, scheduled runs, and queued maintenance are held. Submitted operations finish recovery. Campaigns, betas, and manual Provisioning actions continue.</div>{/if}
{#if $page.url.searchParams.has('returnTo')}<BackLink fallback="/operations" />{/if}
<Tabs {tabs} bind:value={view} onchange={() => saveContext()} ariaLabel="Maintenance views" idPrefix="maintenance-tabs" panelId="maintenance-panel" /> <Tabs {tabs} bind:value={view} onchange={() => saveContext()} ariaLabel="Maintenance views" idPrefix="maintenance-tabs" panelId="maintenance-panel" />
<div id="maintenance-panel" role="tabpanel" aria-labelledby={`maintenance-tabs-${view}`} class="panels"> <div id="maintenance-panel" role="tabpanel" aria-labelledby={`maintenance-tabs-${view}`} class="panels">
{#if data} {#if data}
@@ -190,7 +193,7 @@
<div class="row-heading"><h3>{item.title} · <HistoricalServerIdentity name={item.server} deletedAt={item.server_deleted_at} /></h3><Badge variant={item.state === 'attention' ? 'danger' : item.state === 'active' ? 'info' : 'neutral'}>{item.label}</Badge></div> <div class="row-heading"><h3>{item.title} · <HistoricalServerIdentity name={item.server} deletedAt={item.server_deleted_at} /></h3><Badge variant={item.state === 'attention' ? 'danger' : item.state === 'active' ? 'info' : 'neutral'}>{item.label}</Badge></div>
{#if item.detail}<p>{item.detail}</p>{/if} {#if item.detail}<p>{item.detail}</p>{/if}
<p class="muted">{$timeFormat.dateTime(item.at)}{item.job?.target_revision ? ` · Expected r${item.job.target_revision} or newer` : ''}</p> <p class="muted">{$timeFormat.dateTime(item.at)}{item.job?.target_revision ? ` · Expected r${item.job.target_revision} or newer` : ''}</p>
<div class="links">{#if item.href}<a href={item.href}>{item.job ? 'View restart operation' : 'View evidence and recovery'}</a>{/if} <div class="links">{#if item.href}<a href={contextHref(item.href, $page.url)}>{item.job ? 'View restart operation' : 'View evidence and recovery'}</a>{/if}
{#if item.job?.state === 'queued' && !item.server_deleted_at}<Button size="sm" disabled={busy} onclick={() => act({ action: 'cancel', id: item.job!.id })}>Cancel restart</Button>{/if} {#if item.job?.state === 'queued' && !item.server_deleted_at}<Button size="sm" disabled={busy} onclick={() => act({ action: 'cancel', id: item.job!.id })}>Cancel restart</Button>{/if}
{#if item.job?.state === 'failed' && !item.server_deleted_at && item.job.operation_state === 'done'}<Button size="sm" disabled={busy || data.policies.paused} onclick={() => act({ action: 'retry', id: item.job!.id })}>Retry after inspection</Button>{/if} {#if item.job?.state === 'failed' && !item.server_deleted_at && item.job.operation_state === 'done'}<Button size="sm" disabled={busy || data.policies.paused} onclick={() => act({ action: 'retry', id: item.job!.id })}>Retry after inspection</Button>{/if}
</div> </div>
+6 -3
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { contextHref } from '$lib/navigation-context.js';
import BackLink from '$ui/components/BackLink.svelte';
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import type { WatchdogDetail, WatchdogEvidence } from '$lib/watchdog.js'; import type { WatchdogDetail, WatchdogEvidence } from '$lib/watchdog.js';
@@ -101,6 +103,7 @@
{#if canDismiss}<Button variant="ghost" disabled={!!busy || activeOperation} loading={busy === 'dismiss'} onclick={() => update('dismiss')}>Dismiss incident</Button>{/if} {#if canDismiss}<Button variant="ghost" disabled={!!busy || activeOperation} loading={busy === 'dismiss'} onclick={() => update('dismiss')}>Dismiss incident</Button>{/if}
{/snippet} {/snippet}
</SectionHeader> </SectionHeader>
<BackLink fallback="/maintenance" />
{#if loadError}<div class="ui-error" role="alert">{loadError} {detail ? 'Showing the last successful refresh.' : ''}<Button size="sm" disabled={!!busy} onclick={load}>Retry refresh</Button></div>{/if} {#if loadError}<div class="ui-error" role="alert">{loadError} {detail ? 'Showing the last successful refresh.' : ''}<Button size="sm" disabled={!!busy} onclick={load}>Retry refresh</Button></div>{/if}
{#if actionError}<p class="ui-error" role="alert">{actionError}</p>{/if} {#if actionError}<p class="ui-error" role="alert">{actionError}</p>{/if}
@@ -108,7 +111,7 @@
{#if incident && detail} {#if incident && detail}
<section class="summary" aria-label="Incident status"> <section class="summary" aria-label="Incident status">
<div class="row-heading"><WatchdogStatus state={incident.state} />{#if incident.server_deleted_at}<Badge>Deleted</Badge>{:else}<a href={`/servers/${encodeURIComponent(incident.server)}`}>View server</a>{/if}</div> <div class="row-heading"><WatchdogStatus state={incident.state} />{#if incident.server_deleted_at}<Badge>Deleted</Badge>{:else}<a href={contextHref(`/servers/${encodeURIComponent(incident.server)}`, $page.url)}>View server</a>{/if}</div>
<h2>{incident.summary}</h2> <h2>{incident.summary}</h2>
<p>{explanation}</p> <p>{explanation}</p>
{#if activeOperation && (canRetry || canDismiss)}<p>Complete or cancel the active provisioning operation before retrying or dismissing this incident.</p>{/if} {#if activeOperation && (canRetry || canDismiss)}<p>Complete or cancel the active provisioning operation before retrying or dismissing this incident.</p>{/if}
@@ -120,7 +123,7 @@
{#if evidence.containerState}<div><dt>Observed container state</dt><dd>{evidence.containerState}</dd></div>{/if} {#if evidence.containerState}<div><dt>Observed container state</dt><dd>{evidence.containerState}</dd></div>{/if}
</dl> </dl>
{#if evidence.reason && evidence.reason !== incident.summary}<p class="observed-reason">Observed reason: {evidence.reason}</p>{/if} {#if evidence.reason && evidence.reason !== incident.summary}<p class="observed-reason">Observed reason: {evidence.reason}</p>{/if}
{#if !incident.server_deleted_at}<div class="links"><a href={`/servers/${encodeURIComponent(incident.server)}?tab=diagnostics`}>Diagnose connection</a><a href={`/servers/${encodeURIComponent(incident.server)}?tab=logs`}>Open server logs</a></div>{/if} {#if !incident.server_deleted_at}<div class="links"><a href={contextHref(`/servers/${encodeURIComponent(incident.server)}?tab=diagnostics`, $page.url)}>Diagnose connection</a><a href={contextHref(`/servers/${encodeURIComponent(incident.server)}?tab=logs`, $page.url)}>Open server logs</a></div>{/if}
</section> </section>
<section aria-labelledby="recovery-title"> <section aria-labelledby="recovery-title">
@@ -133,7 +136,7 @@
{#if action.operation_stage}<p>{action.operation_stage}</p>{/if} {#if action.operation_stage}<p>{action.operation_stage}</p>{/if}
{#if action.operation_error}<p class="error">{action.operation_error}</p>{/if} {#if action.operation_error}<p class="error">{action.operation_error}</p>{/if}
<time datetime={action.created_at}>{$timeFormat.dateTime(action.created_at)}</time> <time datetime={action.created_at}>{$timeFormat.dateTime(action.created_at)}</time>
<div class="links"><a href={`/provisioning/${encodeURIComponent(action.operation_id)}`}>View provisioning</a>{#if action.campaign_id}<a href={`/campaigns/${encodeURIComponent(action.campaign_id)}`}>View repair campaign</a>{/if}</div> <div class="links"><a href={contextHref(`/provisioning/${encodeURIComponent(action.operation_id)}`, $page.url)}>View provisioning</a>{#if action.campaign_id}<a href={contextHref(`/campaigns/${encodeURIComponent(action.campaign_id)}`, $page.url)}>View repair campaign</a>{/if}</div>
</article> </article>
{/each} {/each}
</div>{/if} </div>{/if}
+7 -2
View File
@@ -1,4 +1,8 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import BackLink from '$ui/components/BackLink.svelte';
import type { MatchDetail } from '$lib/match-history.js'; import type { MatchDetail } from '$lib/match-history.js';
import { playerProfileHref } from '$lib/leaderboard.js'; import { playerProfileHref } from '$lib/leaderboard.js';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
@@ -29,9 +33,10 @@
<div class="match-page"> <div class="match-page">
<SectionHeader title={match.map} eyebrow={`Match #${match.id}`} subtitle={`${gameName} · Recorded match results`}> <SectionHeader title={match.map} eyebrow={`Match #${match.id}`} subtitle={`${gameName} · Recorded match results`}>
{#snippet crumbs()}<a href="/matches">← Match History</a>{/snippet} {#snippet crumbs()}<Breadcrumbs items={[{ label: 'Match History', href: '/matches' }, { label: `Match #${match.id}` }]} />{/snippet}
{#snippet actions()}<Badge variant="online" size="md">Completed</Badge>{/snippet} {#snippet actions()}<Badge variant="online" size="md">Completed</Badge>{/snippet}
</SectionHeader> </SectionHeader>
<BackLink fallback="/matches" />
<section class="summary" aria-label="Match summary"> <section class="summary" aria-label="Match summary">
<Card><Stat label="Final round" value={match.finalRound} tone="accent" size="lg" tween={false} /></Card> <Card><Stat label="Final round" value={match.finalRound} tone="accent" size="lg" tween={false} /></Card>
@@ -67,7 +72,7 @@
{#each match.players as player (player.guid)} {#each match.players as player (player.guid)}
<tr> <tr>
<th scope="row"> <th scope="row">
{#if player.canViewProfile}<a href={playerProfileHref(player.guid)}>{player.name}</a>{:else}{player.name}{/if} {#if player.canViewProfile}<a href={contextHref(playerProfileHref(player.guid), $page.url)}>{player.name}</a>{:else}{player.name}{/if}
<small class="guid">{player.guid}</small> <small class="guid">{player.guid}</small>
</th> </th>
<td class="participation"><Badge variant={player.presentAtEnd ? 'online' : 'neutral'}>{player.presentAtEnd ? 'At match end' : 'Not at match end'}</Badge>{#if !player.hasStats}<small>No stats recorded</small>{/if}</td> <td class="participation"><Badge variant={player.presentAtEnd ? 'online' : 'neutral'}>{player.presentAtEnd ? 'At match end' : 'Not at match end'}</Badge>{#if !player.hasStats}<small>No stats recorded</small>{/if}</td>
+3
View File
@@ -0,0 +1,3 @@
import { operationsOverview } from '$lib/server/operations-overview.js';
export function load() { return { overview: operationsOverview() }; }
+138
View File
@@ -0,0 +1,138 @@
<script lang="ts">
import { onMount } from 'svelte';
import { beforeNavigate, goto } from '$app/navigation';
import { page, navigating } from '$app/stores';
import type { PageData } from './$types';
import { operationKindLabels, operationView, type OperationsOverview } from '$lib/operations-overview.js';
import { contextHref } from '$lib/navigation-context.js';
import { pollVisible } from '$ui/poll.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Tabs from '$ui/components/primitives/Tabs.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
let { data }: { data: PageData } = $props();
let snapshot = $state<{ source: PageData; overview: OperationsOverview } | null>(null);
let refreshing = $state(false);
let error = $state('');
let request: AbortController | null = null;
let sequence = 0;
let search = $state('');
let kind = $state('');
let limit = $state(25);
const overview = $derived(snapshot?.source === data ? snapshot.overview : data.overview);
const view = $derived(operationView($page.url.searchParams.get('view')));
const tabs = $derived([
{ id: 'attention', label: 'Needs attention', badge: overview.counts.attention },
{ id: 'running', label: 'Running', badge: overview.counts.running },
{ id: 'recent', label: 'Recent', badge: overview.counts.recent }
]);
const visible = $derived(overview.items.filter(item => item.view === view && (!kind || item.kind === kind)
&& [item.title, item.detail, item.status, ...item.resources.map(resource => resource.label)].join(' ').toLowerCase().includes(search.trim().toLowerCase())));
$effect(() => {
search = $page.url.searchParams.get('q') ?? '';
const value = $page.url.searchParams.get('kind') ?? '';
kind = Object.hasOwn(operationKindLabels, value) ? value : '';
limit = Math.max(25, Math.min(10000, Number($page.url.searchParams.get('limit')) || 25));
});
function contextUrl(nextView = view) {
const url = new URL($page.url);
for (const [key, value] of Object.entries({ view: nextView, q: search, kind, limit: limit > 25 ? String(limit) : '' })) {
if (value) url.searchParams.set(key, value); else url.searchParams.delete(key);
}
return url;
}
const origin = $derived(contextUrl());
function saveContext(nextView = view, reset = false) {
if (reset) limit = 25;
void goto(contextUrl(nextView), { replaceState: nextView === view, noScroll: true, keepFocus: true });
}
function cancel() { sequence++; request?.abort(); request = null; refreshing = false; }
async function refresh(signal?: AbortSignal) {
if (refreshing || $navigating) return;
const current = ++sequence, source = data;
const controller = new AbortController(); request = controller; refreshing = true;
try {
const response = await fetch('/api/operations', { cache: 'no-store', signal: AbortSignal.any([controller.signal, AbortSignal.timeout(15000), ...(signal ? [signal] : [])]) });
if (!response.ok) throw new Error('Could not refresh Operations. Showing the last successful update.');
const result: OperationsOverview = await response.json();
if (current === sequence && source === data && !signal?.aborted) { snapshot = { source, overview: result }; error = ''; }
} catch (cause) { if (current === sequence && !signal?.aborted && !controller.signal.aborted) error = (cause as Error).message; }
finally { if (current === sequence) { request = null; refreshing = false; } }
}
beforeNavigate(cancel);
onMount(() => { const stop = pollVisible(refresh, 10000, false); return () => { stop(); cancel(); }; });
</script>
<svelte:head><title>Operations · Electric Cherry</title></svelte:head>
<section class="page">
<SectionHeader title="Operations" subtitle="Follow fleet changes, review blockers, and return to the work that needs you.">
{#snippet actions()}<Button disabled={refreshing || !!$navigating} onclick={() => void refresh()}>{refreshing ? 'Refreshing…' : 'Refresh'}</Button>{/snippet}
</SectionHeader>
<Tabs {tabs} value={view} onchange={value => saveContext(operationView(value), true)} ariaLabel="Operations views" idPrefix="operations-tabs" panelId="operations-panel" />
<form class="filters" onsubmit={event => { event.preventDefault(); saveContext(view, true); }}>
<label>Find work<input type="search" bind:value={search} placeholder="Search servers, hosts, or work…" /></label>
<label>Workflow<select bind:value={kind} onchange={event => { kind = event.currentTarget.value; saveContext(view, true); }}><option value="">All workflows</option>{#each Object.entries(operationKindLabels) as [value, label]}<option {value}>{label}</option>{/each}</select></label>
<Button type="submit">Search</Button>
{#if search || kind}<Button variant="ghost" onclick={() => { search = ''; kind = ''; saveContext(view, true); }}>Clear filters</Button>{/if}
</form>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
<div class="freshness">Updated {$timeFormat.dateTime(overview.generatedAt)} · Refreshes automatically</div>
<div id="operations-panel" role="tabpanel" aria-labelledby={`operations-tabs-${view}`} class="rows">
{#if !visible.length}
<EmptyState title={search || kind ? 'No matching work' : view === 'attention' ? 'Nothing needs attention' : view === 'running' ? 'No work running' : 'No recent results'} description={view === 'attention' && !search && !kind ? 'Queued work and normal waits appear under Running.' : 'Choose another view or workflow to explore its activity.'} />
{:else}
<p class="result-count" role="status">{visible.length} {visible.length === 1 ? 'item' : 'items'} · Related operations are grouped together.</p>
{#each visible.slice(0, limit) as item (item.key)}
<article class:attention={item.view === 'attention'}>
<div class="row-heading"><div><span class="eyebrow">{operationKindLabels[item.kind]}</span><h2><a href={contextHref(item.href, origin)}>{item.title}</a></h2></div><Badge variant={item.view === 'attention' ? 'warning' : item.view === 'running' ? 'info' : 'neutral'}>{item.status}</Badge></div>
<p>{item.detail}</p>
{#if item.progress}<strong class="progress">{item.progress}</strong>{/if}
<div class="resources">{#each item.resources.slice(0, 4) as resource}{#if resource.href}<a href={contextHref(resource.href, origin)}>{resource.label}</a>{:else}<span>{resource.label}</span>{/if}{/each}{#if item.resources.length > 4}<span>+{item.resources.length - 4} more below</span>{/if}</div>
{#if item.related.length}<div class="resources">{#each item.related as related}<a href={contextHref(related.href, origin)}>{related.label}</a>{/each}</div>{/if}
<div class="row-footer"><time datetime={item.updatedAt}>{$timeFormat.dateTime(item.updatedAt)}</time><Button size="sm" href={contextHref(item.href, origin)}>{item.action} →</Button></div>
{#if item.children.length || item.resources.length > 4}<details>
<summary>{item.children.length ? `${item.children.length} related operations` : `${item.resources.length} affected resources`}</summary>
{#if item.resources.length > 4}<div class="resources">{#each item.resources as resource}{#if resource.href}<a href={contextHref(resource.href, origin)}>{resource.label}</a>{:else}<span>{resource.label}</span>{/if}{/each}</div>{/if}
<ul>{#each item.children.slice(0, 25) as child (child.key)}<li><a href={contextHref(child.href, origin)}>{child.title}</a><span>{child.status}</span><p>{child.detail}</p></li>{/each}</ul>
{#if item.children.length > 25}<a href={contextHref(item.href, origin)}>View all {item.children.length} related operations in the workflow →</a>{/if}
</details>{/if}
</article>
{/each}
<ListFooter total={visible.length} bind:limit onchange={() => saveContext()} label="items" />
{/if}
</div>
<p class="history-note">Running workflows stay visible. Recent activity includes up to {overview.recentLimit} finished records per workflow from the last {overview.recentDays} days. Full history remains on each workflow page. Routine successful agent checks are available in <a href="/hosts/operations?filter=routine">Host operation history</a>.</p>
</section>
<style>
.page,.rows { display:grid; gap:20px; min-width:0; }
.filters { display:flex; flex-wrap:wrap; align-items:end; gap:12px; }
label { display:grid; gap:8px; font-size:var(--text-sm); color:var(--text-secondary); }
label:first-child { flex:1; min-width:min(250px,100%); }
input,select { width:100%; min-height:42px; padding:10px 12px; background:var(--surface); border:1px solid var(--border-control); border-radius:var(--radius-sm); color:var(--text-primary); font:inherit; }
.freshness,.result-count,.history-note,time { font-size:var(--text-xs); color:var(--text-secondary); }
.result-count,.history-note { margin:0; line-height:1.7; }
article { min-width:0; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface); padding:20px; }
article.attention { border-left:3px solid var(--state-warning); }
.row-heading,.row-footer { display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:12px; }
.row-heading { align-items:flex-start; }
.eyebrow { font-size:var(--text-xs); color:var(--text-secondary); }
h2 { margin:6px 0 0; font-size:var(--text-lg); }
h2 a { color:var(--text-primary); text-decoration:none; }
article p { font-size:var(--text-sm); color:var(--text-secondary); line-height:1.65; }
.progress { font-size:var(--text-sm); }
.resources { display:flex; flex-wrap:wrap; gap:8px 16px; margin-top:12px; font-size:var(--text-sm); }
.resources span { color:var(--text-secondary); }
.row-footer { margin-top:16px; }
details { margin-top:16px; padding-top:16px; border-top:1px solid var(--border-subtle); font-size:var(--text-sm); }
summary { cursor:pointer; }
ul { list-style:none; padding:0; margin:16px 0 0; display:grid; gap:16px; }
li { min-width:0; padding:12px; background:var(--surface-sunken); border-radius:var(--radius-sm); }
li > span { display:block; margin-top:6px; color:var(--text-secondary); font-size:var(--text-xs); }
li p { margin-bottom:0; }
@media (max-width:600px) { article { padding:16px; } .filters label { flex:1 1 100%; } .row-footer { align-items:flex-start; } }
</style>
+3 -1
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { afterNavigate } from '$app/navigation'; import { afterNavigate } from '$app/navigation';
import Dialog from '$ui/components/primitives/Dialog.svelte'; import Dialog from '$ui/components/primitives/Dialog.svelte';
@@ -199,7 +201,7 @@
{:else} {:else}
<div class="rows"> <div class="rows">
{#each visible.slice(0,limit) as a (a.id)} {#each visible.slice(0,limit) as a (a.id)}
<a class="row" href="/plugins/{a.id}"> <a class="row" href={contextHref(`/plugins/${a.id}`, $page.url)}>
<div class="row-top"> <div class="row-top">
<span class="name">{a.name}</span> <span class="name">{a.name}</span>
<Badge <Badge
+2
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import BackLink from '$ui/components/BackLink.svelte';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
@@ -112,6 +113,7 @@
<SectionHeader title="Provisioning" subtitle="Create, start, restart, rebuild, stop, and destroy server instances."> <SectionHeader title="Provisioning" subtitle="Create, start, restart, rebuild, stop, and destroy server instances.">
{#snippet actions()}<Button onclick={() => lifecycleOpen = true}>Manage server</Button><Button variant="primary" onclick={() => open = true}>Create server</Button><details class="fleet-actions"><summary>Fleet actions</summary><Button variant="danger" onclick={() => housePartyOpen = true}>Destroy fleet…</Button></details>{/snippet} {#snippet actions()}<Button onclick={() => lifecycleOpen = true}>Manage server</Button><Button variant="primary" onclick={() => open = true}>Create server</Button><details class="fleet-actions"><summary>Fleet actions</summary><Button variant="danger" onclick={() => housePartyOpen = true}>Destroy fleet…</Button></details>{/snippet}
</SectionHeader> </SectionHeader>
{#if $page.url.searchParams.has('returnTo')}<BackLink fallback="/" />{/if}
<Tabs {tabs} bind:value={tab} onchange={() => saveContext(true)} ariaLabel="Provisioning views" idPrefix="deployment-tabs" panelId="deployment-panel" /> <Tabs {tabs} bind:value={tab} onchange={() => saveContext(true)} ariaLabel="Provisioning views" idPrefix="deployment-tabs" panelId="deployment-panel" />
<LifecycleDialog bind:open={lifecycleOpen} /> <LifecycleDialog bind:open={lifecycleOpen} />
<DeployServerDialog bind:open /> <DeployServerDialog bind:open />
+4 -3
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { safeReturnHref, returnLabel, contextHref } from '$lib/navigation-context.js';
import { onDestroy, onMount, untrack } from 'svelte'; import { onDestroy, onMount, untrack } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import type { PageData } from './$types'; import type { PageData } from './$types';
@@ -38,7 +39,7 @@
const outcome = $derived(detail ? provisioningOutcome(detail) : null); const outcome = $derived(detail ? provisioningOutcome(detail) : null);
const returnTo = $derived.by(() => { const returnTo = $derived.by(() => {
const requested = $page.url.searchParams.get('returnTo'); const requested = $page.url.searchParams.get('returnTo');
if (requested) return provisioningReturn(requested); if (requested) { const href = safeReturnHref(requested, '/provisioning'); return { href, label: returnLabel(href) }; }
if (detail?.collection) return { href: `/collections/launches/${encodeURIComponent(detail.collection.id)}`, label: `Back to ${detail.collection.name}` }; if (detail?.collection) return { href: `/collections/launches/${encodeURIComponent(detail.collection.id)}`, label: `Back to ${detail.collection.name}` };
if (detail?.sources.length === 1 && detail.sources[0].href) return { href: detail.sources[0].href, label: `Back to ${detail.sources[0].label}` }; if (detail?.sources.length === 1 && detail.sources[0].href) return { href: detail.sources[0].href, label: `Back to ${detail.sources[0].label}` };
return provisioningReturn(null); return provisioningReturn(null);
@@ -86,7 +87,7 @@
<div class="page"> <div class="page">
<SectionHeader title={deployment ? `${actionLabels[deployment.action]} · ${serverTitle(deployment.server)}` : 'Provisioning operation'} subtitle={deployment?.server}> <SectionHeader title={deployment ? `${actionLabels[deployment.action]} · ${serverTitle(deployment.server)}` : 'Provisioning operation'} subtitle={deployment?.server}>
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Provisioning', href: returnTo.href.startsWith('/provisioning') ? returnTo.href : '/provisioning' }, { label: 'Operation' }]} />{/snippet} {#snippet crumbs()}<Breadcrumbs items={[{ label: 'Provisioning', href: '/provisioning' }, { label: 'Operation' }]} />{/snippet}
{#snippet actions()}{#if serverHref}<Button href={serverHref}>View server</Button>{/if}{/snippet} {#snippet actions()}{#if serverHref}<Button href={serverHref}>View server</Button>{/if}{/snippet}
</SectionHeader> </SectionHeader>
<div class="identity-row"> <div class="identity-row">
@@ -144,7 +145,7 @@
</dl> </dl>
{#if detail.agentOperation.error}<p>{detail.agentOperation.error}</p>{/if} {#if detail.agentOperation.error}<p>{detail.agentOperation.error}</p>{/if}
{#if outcome.active && detail.agentOperation.state === 'completed'}<p>The agent finished its command. EC is verifying readiness.</p>{/if} {#if outcome.active && detail.agentOperation.state === 'completed'}<p>The agent finished its command. EC is verifying readiness.</p>{/if}
<a href="/hosts/operations">View host operation history</a> <a href={contextHref(`/hosts/operations?server=${encodeURIComponent(deployment.server)}`, $page.url)}>View host operation history</a>
</div></Card> </div></Card>
{/if} {/if}
{#if outcome.active && detail.workerActivity} {#if outcome.active && detail.workerActivity}
+5 -3
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { afterNavigate } from '$app/navigation'; import { afterNavigate } from '$app/navigation';
import ListFooter from '$ui/components/primitives/ListFooter.svelte'; import ListFooter from '$ui/components/primitives/ListFooter.svelte';
@@ -129,7 +131,7 @@
<article class="script-row" class:selected={selected.includes(s.id)}> <article class="script-row" class:selected={selected.includes(s.id)}>
<input type="checkbox" aria-label={`Select ${s.name} for a beta`} checked={selected.includes(s.id)} disabled={!canTest(s)} onchange={() => toggle(s.id)} /> <input type="checkbox" aria-label={`Select ${s.name} for a beta`} checked={selected.includes(s.id)} disabled={!canTest(s)} onchange={() => toggle(s.id)} />
<div class="script-info"> <div class="script-info">
<div class="name-row"><a class="name" href="/scripts/{s.id}">{s.name}</a><StatusBadge state={state} /></div> <div class="name-row"><a class="name" href={contextHref(`/scripts/${s.id}`, $page.url)}>{s.name}</a><StatusBadge state={state} /></div>
<div class="path">{s.target_rel_path}</div> <div class="path">{s.target_rel_path}</div>
<div class="meta"><span title={s.scope_value ?? 'Choose targets explicitly when opening a beta'}>{scopeLabel(s)}</span><span>{s.scope_type ? `${s.servers.length} scope matches` : 'Explicit beta targets'}</span><span>{s.versionCount} version{s.versionCount === 1 ? '' : 's'}</span></div> <div class="meta"><span title={s.scope_value ?? 'Choose targets explicitly when opening a beta'}>{scopeLabel(s)}</span><span>{s.scope_type ? `${s.servers.length} scope matches` : 'Explicit beta targets'}</span><span>{s.versionCount} version{s.versionCount === 1 ? '' : 's'}</span></div>
</div> </div>
@@ -138,8 +140,8 @@
{#if s.latestVersion && s.latestVersion.id !== s.current_version_id}<strong>Latest v{s.latestVersion.version} · {s.latestVersion.state === 'untested' ? 'awaiting test' : s.latestVersion.state.replaceAll('-', ' ')}</strong>{/if} {#if s.latestVersion && s.latestVersion.id !== s.current_version_id}<strong>Latest v{s.latestVersion.version} · {s.latestVersion.state === 'untested' ? 'awaiting test' : s.latestVersion.state.replaceAll('-', ' ')}</strong>{/if}
</div> </div>
<div class="row-action"> <div class="row-action">
{#if s.runningBetaId !== null}<Button size="sm" href="/betas/{s.runningBetaId}">View beta →</Button> {#if s.runningBetaId !== null}<Button size="sm" href={contextHref(`/betas/${s.runningBetaId}`, $page.url)}>View beta →</Button>
{:else if state === 'failed'}<Button size="sm" href="/scripts/{s.id}">Review version</Button> {:else if state === 'failed'}<Button size="sm" href={contextHref(`/scripts/${s.id}`, $page.url)}>Review version</Button>
{:else if canTest(s)}<Button size="sm" href={betaHref([s])}>Test version →</Button> {:else if canTest(s)}<Button size="sm" href={betaHref([s])}>Test version →</Button>
{:else}<Button size="sm" onclick={() => uploader.open(s.id)}>Upload version</Button>{/if} {:else}<Button size="sm" onclick={() => uploader.open(s.id)}>Upload version</Button>{/if}
</div> </div>
+73 -44
View File
@@ -1,4 +1,8 @@
<script lang="ts"> <script lang="ts">
import { tick } from 'svelte';
import { serverSection, serverSectionAnchor } from '$lib/server-sections.js';
import { contextHref } from '$lib/navigation-context.js';
import BackLink from '$ui/components/BackLink.svelte';
let { data }: { data: import('./$types').PageData } = $props(); let { data }: { data: import('./$types').PageData } = $props();
import RuntimeVersion from '$ui/components/RuntimeVersion.svelte'; import RuntimeVersion from '$ui/components/RuntimeVersion.svelte';
import MatchHistory from '$ui/components/MatchHistory.svelte'; import MatchHistory from '$ui/components/MatchHistory.svelte';
@@ -94,11 +98,22 @@
let rconProbe = $state<RconProbeResult | null>(null); let rconProbe = $state<RconProbeResult | null>(null);
let probingRcon = $state(false); let probingRcon = $state(false);
let destroyOpen = $state(false); let destroyOpen = $state(false);
const sectionIds = ['scoreboard', 'matches', 'chat', 'logs', 'config', 'inventory', 'diagnostics', 'history']; const sectionIds = ['live', 'scoreboard', 'matches', 'chat', 'logs', 'configuration', 'config', 'inventory', 'diagnostics', 'history'];
const activeTab = $derived(sectionIds.includes($page.url.searchParams.get('tab') ?? '') ? $page.url.searchParams.get('tab')! : 'scoreboard'); const activeTab = $derived(sectionIds.includes($page.url.searchParams.get('tab') ?? '') ? $page.url.searchParams.get('tab')! : 'live');
const activeSection = $derived(serverSection(activeTab));
const loadedServerName = $derived(server?.name);
$effect(() => {
const anchor = serverSectionAnchor(activeTab);
const currentName = loadedServerName;
if (!anchor || !currentName) return;
let cancelled = false;
void tick().then(() => { if (!cancelled) document.getElementById(anchor)?.scrollIntoView({ block: 'start' }); });
return () => { cancelled = true; };
});
function changeTab(tab: string) { function changeTab(tab: string) {
const url = new URL($page.url); const url = new URL($page.url);
url.searchParams.set('tab', tab); url.searchParams.set('tab', tab);
url.hash = '';
void goto(url, { noScroll: true, keepFocus: true }); void goto(url, { noScroll: true, keepFocus: true });
} }
let tabLoading = $state(false); let tabLoading = $state(false);
@@ -110,32 +125,32 @@
let tabSequence = 0; let tabSequence = 0;
async function loadTab(currentName: string, tab: string, signal?: AbortSignal) { async function loadTab(currentName: string, tab: string, signal?: AbortSignal) {
if (tab !== 'history' && tab !== 'inventory') return; if (tab !== 'history' && tab !== 'configuration') return;
const sequence = ++tabSequence; const sequence = ++tabSequence;
try { try {
if (tab === 'history') { if (tab === 'history') {
const body = await fetchCampaigns<DeploymentRow>({ server: currentName }, historyLimit, signal); const body = await fetchCampaigns<DeploymentRow>({ server: currentName }, historyLimit, signal);
if (sequence !== tabSequence || name !== currentName || activeTab !== tab || signal?.aborted) return; if (sequence !== tabSequence || name !== currentName || activeSection !== tab || signal?.aborted) return;
history = body.deployments; historyTotal = body.total; history = body.deployments; historyTotal = body.total;
} else { } else {
const response = await fetch('/api/servers/' + encodeURIComponent(currentName) + '/scripts', { signal }); const response = await fetch('/api/servers/' + encodeURIComponent(currentName) + '/scripts', { signal });
if (!response.ok) throw new Error('Could not refresh inventory.'); if (!response.ok) throw new Error('Could not refresh inventory.');
const body = await response.json(); const body = await response.json();
if (sequence !== tabSequence || name !== currentName || activeTab !== tab || signal?.aborted) return; if (sequence !== tabSequence || name !== currentName || activeSection !== tab || signal?.aborted) return;
inventory = body.scripts; inventory = body.scripts;
} }
tabError = ''; tabError = '';
return true; return true;
} catch (error) { } catch (error) {
if (sequence === tabSequence && name === currentName && activeTab === tab && !signal?.aborted) tabError = (error as Error).message; if (sequence === tabSequence && name === currentName && activeSection === tab && !signal?.aborted) tabError = (error as Error).message;
return false; return false;
} finally { if (sequence === tabSequence && name === currentName && activeTab === tab && !signal?.aborted) tabLoading = false; } } finally { if (sequence === tabSequence && name === currentName && activeSection === tab && !signal?.aborted) tabLoading = false; }
} }
$effect(() => { $effect(() => {
const currentName = name; const currentName = name;
const tab = activeTab; const tab = activeSection;
tabError = ''; history = []; inventory = []; historyLimit = 25; inventoryLimit = 25; tabError = ''; history = []; inventory = []; historyLimit = 25; inventoryLimit = 25;
tabLoading = tab === 'history' || tab === 'inventory'; tabLoading = tab === 'history' || tab === 'configuration';
return pollVisible(signal => loadTab(currentName, tab, signal), 10000); return pollVisible(signal => loadTab(currentName, tab, signal), 10000);
}); });
$effect(() => { name; rconProbe = null; }); $effect(() => { name; rconProbe = null; });
@@ -194,14 +209,11 @@
} }
// Tab config — Diagnostics gets a warning badge when there's an issue. // Tab config — Diagnostics gets a warning badge when there's an issue.
let tabs = $derived([ let tabs = $derived([
{ id: 'scoreboard', label: 'Scoreboard', badge: null }, { id: 'live', label: 'Live' },
{ id: 'chat', label: 'Chat' }, { id: 'matches', label: 'Matches' },
{ id: 'matches', label: 'Matches' }, { id: 'configuration', label: 'Configuration' },
{ id: 'logs', label: 'Logs' }, { id: 'diagnostics', label: 'Diagnostics', badge: hasDiagnosticIssue ? '!' : null },
{ id: 'config', label: 'Config' }, { id: 'history', label: 'History' }
{ id: 'inventory', label: 'Inventory', badge: inventory.length || null },
{ id: 'diagnostics',label: 'Diagnostics', badge: hasDiagnosticIssue ? '!' : null },
{ id: 'history', label: 'History', badge: history.length || null }
]); ]);
</script> </script>
@@ -221,6 +233,7 @@
<details class="server-actions"><summary>Server actions</summary>{#each runtimeActions as action}<Button onclick={() => { lifecycleAction = action; lifecycleOpen = true; }}>{actionLabels[action]}</Button>{/each}<Button variant="danger" onclick={() => destroyOpen = true}>Queue server removal</Button></details> <details class="server-actions"><summary>Server actions</summary>{#each runtimeActions as action}<Button onclick={() => { lifecycleAction = action; lifecycleOpen = true; }}>{actionLabels[action]}</Button>{/each}<Button variant="danger" onclick={() => destroyOpen = true}>Queue server removal</Button></details>
{/snippet} {/snippet}
</SectionHeader> </SectionHeader>
<BackLink fallback="/" />
<CopyValue value={name} label="Server ID" /> <CopyValue value={name} label="Server ID" />
<RuntimeVersion {name} version={server.plutonium} after={server.telemetry_after} ready={effectiveHealth === 'online'} /> <RuntimeVersion {name} version={server.plutonium} after={server.telemetry_after} ready={effectiveHealth === 'online'} />
@@ -282,36 +295,33 @@
{#if hasDiagnosticIssue} {#if hasDiagnosticIssue}
<section class="connection-issue" aria-label="Server connection issue"> <section class="connection-issue" aria-label="Server connection issue">
<div><strong>{effectiveHealth === 'stale' ? 'Server report is outdated' : 'Server is not responding'}</strong><p>Player count is unknown. {effectiveSeen ? `Last successful report ${ago(effectiveSeen, $uiNow)}.` : 'No successful report has been received.'} {native && !server.host?.connected ? 'The host agent is offline; the game may still be running.' : ''}</p></div> <div><strong>{effectiveHealth === 'stale' ? 'Server report is outdated' : 'Server is not responding'}</strong><p>Player count is unknown. {effectiveSeen ? `Last successful report ${ago(effectiveSeen, $uiNow)}.` : 'No successful report has been received.'} {native && !server.host?.connected ? 'The host agent is offline; the game may still be running.' : ''}</p></div>
<div class="issue-actions"><Button size="sm" onclick={() => changeTab('diagnostics')}>Diagnose connection</Button><Button size="sm" variant="ghost" onclick={() => changeTab('logs')}>Open logs</Button>{#if native && server.host_id}<Button size="sm" href={`/hosts/${server.host_id}`}>Check host</Button>{:else}<Button size="sm" href="/settings">Check system setup</Button>{/if}</div> <div class="issue-actions"><Button size="sm" onclick={() => changeTab('diagnostics')}>Diagnose connection</Button><Button size="sm" variant="ghost" onclick={() => changeTab('logs')}>Open logs</Button>{#if native && server.host_id}<Button size="sm" href={contextHref(`/hosts/${server.host_id}`, $page.url)}>Check host</Button>{:else}<Button size="sm" href="/settings">Check system setup</Button>{/if}</div>
</section> </section>
{/if} {/if}
<div class="tabs-row"> <div class="tabs-row">
<Tabs {tabs} value={activeTab} onchange={changeTab} ariaLabel="Server sections" idPrefix="server-tabs" panelId="server-panel" /> <Tabs {tabs} value={activeSection} onchange={changeTab} ariaLabel="Server sections" idPrefix="server-tabs" panelId="server-panel" />
</div> </div>
<div class="tab-panel" class:diagnostics={activeTab === 'diagnostics'} class:history-panel={activeTab === 'history'} role="tabpanel" id="server-panel" aria-labelledby={"server-tabs-" + activeTab}> <div class="tab-panel" class:diagnostics={activeSection === 'diagnostics'} class:history-panel={activeSection === 'history'} role="tabpanel" id="server-panel" aria-labelledby={"server-tabs-" + activeSection}>
{#if tabError}<div class="ui-error" role="alert">{tabError}<Button size="sm" onclick={() => loadTab(name, activeTab)}>Retry</Button></div>{/if} {#if tabError}<div class="ui-error" role="alert">{tabError}<Button size="sm" onclick={() => loadTab(name, activeSection)}>Retry</Button></div>{/if}
{#if activeTab === 'scoreboard'} {#if activeSection === 'live'}
<nav class="section-links" aria-label="Live sections"><a href="#scoreboard">Scoreboard</a><a href="#chat">Chat</a></nav>
<section id="scoreboard" aria-label="Scoreboard">
<Card padding="lg"> <Card padding="lg">
{#key name}<PlayerScoreboard current={fresh} after={server?.telemetry_after ?? null} report={scoreboard} mapLabel={scoreboard ? fleetIdentity({ ...server, last_map: scoreboard.map }).map : undefined} />{/key} {#key name}<PlayerScoreboard current={fresh} after={server?.telemetry_after ?? null} report={scoreboard} mapLabel={scoreboard ? fleetIdentity({ ...server, last_map: scoreboard.map }).map : undefined} />{/key}
</Card> </Card>
{:else if activeTab === 'chat'} </section>
<section id="chat" aria-label="Chat">
<Card padding="lg"> <Card padding="lg">
{#key name}<ChatPanel server={name} />{/key} {#key name}<ChatPanel server={name} />{/key}
</Card> </Card>
{:else if activeTab === 'logs'} </section>
<Card padding="lg"> {:else if activeSection === 'configuration'}
<LogPanel <nav class="section-links" aria-label="Configuration sections"><a href="#config">Config</a><a href="#inventory">Inventory</a></nav>
server={server.name} <section id="config" aria-label="Config">
tabs={[
{ key: 'console', label: 'console.log' },
server.game === 't6' ? { key: 'games_zm', label: 'games_zm.log' } : { key: 'games_sp', label: 'games_sp.log' }
]}
/>
</Card>
{:else if activeTab === 'config'}
{#key name}<ConfigInspector fixedServer={name} />{/key} {#key name}<ConfigInspector fixedServer={name} />{/key}
{:else if activeTab === 'inventory'} </section>
<section id="inventory" aria-label="Inventory">
<Card padding="lg"> <Card padding="lg">
<div class="panel-head"> <div class="panel-head">
<h2>Installed files</h2> <h2>Installed files</h2>
@@ -336,7 +346,10 @@
{/if} {/if}
</Card> </Card>
<ListFooter total={inventory.length} bind:limit={inventoryLimit} label="files" /> <ListFooter total={inventory.length} bind:limit={inventoryLimit} label="files" />
{:else if activeTab === 'diagnostics'} </section>
{:else if activeSection === 'diagnostics'}
<nav class="section-links" aria-label="Diagnostic sections"><a href="#connection">Connection health</a><a href="#logs">Logs</a></nav>
<section id="connection" aria-label="Connection health">
<Card padding="lg"> <Card padding="lg">
<div class="panel-head"> <div class="panel-head">
<h2>RCON diagnostic</h2> <h2>RCON diagnostic</h2>
@@ -382,7 +395,7 @@
{native ? 'Probe RCON through agent' : 'Probe RCON'} {native ? 'Probe RCON through agent' : 'Probe RCON'}
</Button> </Button>
{/if} {/if}
{#if server.host_id}<Button variant="secondary" size="sm" href={`/hosts/${server.host_id}`}>Inspect host</Button>{/if} {#if server.host_id}<Button variant="secondary" size="sm" href={contextHref(`/hosts/${server.host_id}`, $page.url)}>Inspect host</Button>{/if}
</div> </div>
{#if rconProbe?.ok} {#if rconProbe?.ok}
@@ -400,7 +413,7 @@
<h2>Configuration</h2> <h2>Configuration</h2>
</div> </div>
<div class="diag-grid"> <div class="diag-grid">
{#if native}<div class="kv"><span class="k">Host</span><span class="v"><a href={`/hosts/${server.host_id}`}>{server.host?.name ?? server.host_id}</a></span></div><div class="kv"><span class="k">Instance</span><span class="v mono">{server.native_instance_id}</span></div><div class="kv"><span class="k">Generation</span><span class="v mono">{server.instance_generation}</span></div><div class="kv"><span class="k">Game-file version</span><span class="v mono"><a href="/game-files">{server.game_library_id}</a></span></div>{#if server.network}<div class="kv"><span class="k">Local UDP binding</span><span class="v mono">{server.network.bindAddress}:{server.network.localPort}</span></div><div class="kv"><span class="k">Public / NAT endpoint</span><span class="v mono">{server.network.publicAddress || server.host?.publicAddress || server.host?.hostname || 'Not configured'}:{server.network.publicPort ?? server.network.localPort}</span></div>{/if} {#if native}<div class="kv"><span class="k">Host</span><span class="v"><a href={contextHref(`/hosts/${server.host_id}`, $page.url)}>{server.host?.name ?? server.host_id}</a></span></div><div class="kv"><span class="k">Instance</span><span class="v mono">{server.native_instance_id}</span></div><div class="kv"><span class="k">Generation</span><span class="v mono">{server.instance_generation}</span></div><div class="kv"><span class="k">Game-file version</span><span class="v mono"><a href="/game-files">{server.game_library_id}</a></span></div>{#if server.network}<div class="kv"><span class="k">Local UDP binding</span><span class="v mono">{server.network.bindAddress}:{server.network.localPort}</span></div><div class="kv"><span class="k">Public / NAT endpoint</span><span class="v mono">{server.network.publicAddress || server.host?.publicAddress || server.host?.hostname || 'Not configured'}:{server.network.publicPort ?? server.network.localPort}</span></div>{/if}
{/if} {/if}
<div class="kv"><span class="k">Game port</span><span class="v mono">{server.game_port ?? '(unresolved)'}</span></div> <div class="kv"><span class="k">Game port</span><span class="v mono">{server.game_port ?? '(unresolved)'}</span></div>
<div class="kv"><span class="k">Game</span><span class="v mono">{server.game ?? '(unset)'}</span></div> <div class="kv"><span class="k">Game</span><span class="v mono">{server.game ?? '(unset)'}</span></div>
@@ -411,9 +424,22 @@
</div> </div>
</Card> </Card>
<div id="telemetry">{#key name}<TelemetryDiagnostics server={name} />{/key}</div> <div id="telemetry">{#key name}<TelemetryDiagnostics server={name} />{/key}</div>
{:else if activeTab === 'matches'} </section>
<section id="logs" aria-label="Logs">
<Card padding="lg">
<LogPanel
server={server.name}
tabs={[
{ key: 'console', label: 'console.log' },
server.game === 't6' ? { key: 'games_zm', label: 'games_zm.log' } : { key: 'games_sp', label: 'games_sp.log' }
]}
/>
</Card>
</section>
{:else if activeSection === 'matches'}
{#key name}<MatchHistory server={name} />{/key} {#key name}<MatchHistory server={name} />{/key}
{:else if activeTab === 'history'} {:else if activeSection === 'history'}
<div class="history-links"><Button href={contextHref(`/provisioning?view=history&server=${encodeURIComponent(name)}`, $page.url)}>Provisioning history</Button><Button href={contextHref(`/hosts/operations?server=${encodeURIComponent(name)}`, $page.url)}>Host operation history</Button></div>
{#if operations.length} {#if operations.length}
<Card padding="lg"> <Card padding="lg">
<div class="panel-head"> <div class="panel-head">
@@ -422,7 +448,7 @@
</div> </div>
<div class="server-operations"> <div class="server-operations">
{#each operations as operation (operation.key)} {#each operations as operation (operation.key)}
<a href={operation.href}> <a href={contextHref(operation.href, $page.url)}>
<strong>{operation.label}</strong> <strong>{operation.label}</strong>
<span>{operation.detail}</span> <span>{operation.detail}</span>
<StatusBadge state={operation.state} /> <StatusBadge state={operation.state} />
@@ -445,7 +471,7 @@
<div class="history"> <div class="history">
{#each history.slice(0,historyLimit) as d (d.id)} {#each history.slice(0,historyLimit) as d (d.id)}
{@const mine = d.servers.find((s) => s.server === name)!} {@const mine = d.servers.find((s) => s.server === name)!}
<a class="hist-row s-{mine.restart_state}" href="/campaigns/{d.id}"> <a class="hist-row s-{mine.restart_state}" href={contextHref(`/campaigns/${d.id}`, $page.url)}>
<span class="hist-action">{d.action}</span> <span class="hist-action">{d.action}</span>
<span class="hist-state"> <span class="hist-state">
<StatusBadge state={mine.restart_state} /> <StatusBadge state={mine.restart_state} />
@@ -474,6 +500,9 @@
{/if} {/if}
<style> <style>
.section-links { display:flex; gap:20px; flex-wrap:wrap; font-size:var(--text-sm); }
.tab-panel > section { display:grid; gap:16px; min-width:0; scroll-margin-top:80px; }
.history-links { display:flex; flex-wrap:wrap; gap:12px; }
.server-actions { text-align: right; } .server-actions { text-align: right; }
.server-actions summary { cursor: pointer; padding: 9px 12px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); font-size: var(--text-sm); } .server-actions summary { cursor: pointer; padding: 9px 12px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); font-size: var(--text-sm); }
.server-actions[open] summary { margin-bottom: 10px; } .server-actions[open] summary { margin-bottom: 10px; }
@@ -522,8 +551,8 @@
} }
/* Tabs row */ /* Tabs row */
.tab-panel.diagnostics, .tab-panel {
.tab-panel.history-panel { min-width: 0;
display: grid; display: grid;
gap: var(--space-6); gap: var(--space-6);
} }
+3 -19
View File
@@ -1,18 +1,14 @@
<script lang="ts"> <script lang="ts">
import { timeFormat, timeZone, timeZonePersistenceError } from '$ui/stores/time-zone.js';
import { availableTimeZones, DEFAULT_TIME_ZONE } from '$lib/time-zone.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import type { FleetSettings } from '$lib/fleet-settings.js'; import type { FleetSettings } from '$lib/fleet-settings.js';
let exampleDate = $state<Date | null>(null);
let settings = $state<FleetSettings | null>(null); let settings = $state<FleetSettings | null>(null);
let loading = $state(true); let loading = $state(true);
let saving = $state(false); let saving = $state(false);
let error = $state(''); let error = $state('');
let notice = $state(''); let notice = $state('');
const zones = $derived([...new Set([...availableTimeZones(), $timeZone])].sort()); onMount(() => { void loadSettings(); });
onMount(() => { exampleDate = new Date(); void loadSettings(); });
async function loadSettings() { async function loadSettings() {
loading = true; error = ''; loading = true; error = '';
@@ -40,7 +36,7 @@
<svelte:head><title>Settings · Electric Cherry</title></svelte:head> <svelte:head><title>Settings · Electric Cherry</title></svelte:head>
<section class="page"> <section class="page">
<SectionHeader title="Settings" subtitle="Manage fleet limits, history retention, and display preferences." /> <SectionHeader title="Settings" subtitle="Manage shared fleet limits and history retention." />
<section class="guide" aria-labelledby="fleet-heading"> <section class="guide" aria-labelledby="fleet-heading">
<h2 id="fleet-heading">Fleet settings</h2> <h2 id="fleet-heading">Fleet settings</h2>
<p>Saved for everyone using this Electric Cherry instance. Changes apply without restarting the app.</p> <p>Saved for everyone using this Electric Cherry instance. Changes apply without restarting the app.</p>
@@ -74,17 +70,7 @@
<Button onclick={loadSettings}>Retry loading</Button> <Button onclick={loadSettings}>Retry loading</Button>
{/if} {/if}
</section> </section>
<section class="guide" aria-labelledby="display-heading">
<h2 id="display-heading">Display preferences</h2>
<label class="timezone">Timezone
<select bind:value={$timeZone} aria-describedby="timezone-help">
{#each zones as zone}<option value={zone}>{zone.replaceAll('_', ' ')}{zone === DEFAULT_TIME_ZONE ? ' (default)' : ''}</option>{/each}
</select>
</label>
<p id="timezone-help">Dates and times throughout the app use this timezone, including daylight saving changes. Saved automatically in this browser.</p>
{#if exampleDate}<p>Example: {$timeFormat.dateTime(exampleDate)}</p>{/if}
{#if $timeZonePersistenceError}<p role="status">Browser storage is unavailable. Your timezone applies for this visit but cannot be saved.</p>{/if}
</section>
</section> </section>
<style> <style>
@@ -92,8 +78,6 @@
.guide { padding: 20px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: var(--surface); } .guide { padding: 20px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: var(--surface); }
h2 { font-size: var(--text-md); margin: 0; } h2 { font-size: var(--text-md); margin: 0; }
p { font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.65; } p { font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.65; }
.timezone { display: grid; gap: 8px; margin-top: 16px; font-size: var(--text-sm); max-width: 420px; }
.timezone select { width: 100%; padding: 10px 12px; font: inherit; color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-control); border-radius: var(--radius-sm); }
fieldset { border: 0; padding: 0; margin: 20px 0 0; min-width: 0; } fieldset { border: 0; padding: 0; margin: 20px 0 0; min-width: 0; }
fieldset label { display: block; margin: 20px 0 8px; font-size: var(--text-sm); } fieldset label { display: block; margin: 20px 0 8px; font-size: var(--text-sm); }
fieldset input { width: 160px; padding: 10px 12px; font: inherit; color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-control); border-radius: var(--radius-sm); } fieldset input { width: 160px; padding: 10px 12px; font: inherit; color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-control); border-radius: var(--radius-sm); }
+1 -1
View File
@@ -12,7 +12,7 @@ describe('management route permissions', () => {
'/leaderboard/players/[game]/[guid]', '/api/campaigns/[id]', '/campaigns/[id]', '/leaderboard/players/[game]/[guid]', '/api/campaigns/[id]', '/campaigns/[id]',
'/api/provisioning/[id]', '/provisioning/[id]', '/api/provisioning/[id]/events', '/api/provisioning/[id]', '/provisioning/[id]', '/api/provisioning/[id]/events',
'/api/betas/[id]', '/api/scripts/[id]', '/api/configs', '/api/plugins', '/api/hosts/[id]', '/api/betas/[id]', '/api/scripts/[id]', '/api/configs', '/api/plugins', '/api/hosts/[id]',
'/hosts/operations', '/api/hosts/operations', '/hosts/operations', '/api/hosts/operations', '/operations', '/api/operations',
'/api/game-files/[id]', '/api/maintenance', '/api/events/servers', '/api/events/pipeline' '/api/game-files/[id]', '/api/maintenance', '/api/events/servers', '/api/events/pipeline'
])('lets viewers read the safe summary route %s', route => { ])('lets viewers read the safe summary route %s', route => {
expect(routePermission(route, 'GET')).toBe('viewer'); expect(routePermission(route, 'GET')).toBe('viewer');
+26
View File
@@ -43,6 +43,31 @@ async function operationHistory() {
} }
describe('host operation history', () => { describe('host operation history', () => {
it('scopes counts and pages to one host or server and preserves deleted-host history', async () => {
const api = await seedHistory(90);
api.handle.prepare("UPDATE ec_host_operations SET server_name='server-one' WHERE host_id=?").run(api.first.id);
const first = api.listHostOperationHistory(1, 'all', { host: api.first.id });
const second = api.listHostOperationHistory(2, 'all', { host: api.first.id });
expect(first).toMatchObject({ total: 30, totalPages: 2 });
expect([...first.operations, ...second.operations]).toHaveLength(30);
expect(first.operations.every(operation => operation.hostId === api.first.id)).toBe(true);
expect(api.listHostOperationHistory(1, 'all', { server: 'server-one' })).toEqual(first);
expect(api.listHostOperationHistory(1, 'all', { host: api.second.id, server: 'server-one' }).total).toBe(0);
expect(api.listHostOperationHistory(1, 'all', { host: api.deleted.id }).operations.every(operation => operation.hostName === null)).toBe(true);
const { GET } = await import('../../src/routes/api/hosts/operations/+server.js');
const response = GET({ request: new Request(`https://ec.test/api/hosts/operations?filter=all&host=${api.first.id}&page=2`) });
expect(await response.json()).toEqual(second);
});
it('applies host scope to failures as well as ordinary actions', async () => {
const api = await operationHistory();
const first = api.add('instance.restart');
const other = api.queueHostOperation(api.second.id, 'instance.inspect', {});
api.reportHostOperationStatus(api.second.id, other.id, { state: 'failed', error: 'Unreachable' });
expect(api.listHostOperationHistory(1, 'important', { host: api.first.id }).operations.map(operation => operation.id)).toEqual([first]);
expect(api.listHostOperationHistory(1, 'important', { host: api.second.id }).operations.map(operation => operation.id)).toEqual([other.id]);
});
it('pages all hosts in stable newest-first order, retaining deleted-host records', async () => { it('pages all hosts in stable newest-first order, retaining deleted-host records', async () => {
const api = await seedHistory(); const api = await seedHistory();
const pages = [1, 2, 3].map(page => api.listHostOperationHistory(page, 'all')); const pages = [1, 2, 3].map(page => api.listHostOperationHistory(page, 'all'));
@@ -199,6 +224,7 @@ describe('host operation history', () => {
const unsafe = api.add('files.read', { payload: { path: '../private-secret' } }); const unsafe = api.add('files.read', { payload: { path: '../private-secret' } });
api.handle.exec(`DROP INDEX ec_host_operations_routine_history; api.handle.exec(`DROP INDEX ec_host_operations_routine_history;
DROP INDEX ec_host_operations_important_history; DROP INDEX ec_host_operations_important_history;
DROP INDEX ec_host_operations_server_history;
ALTER TABLE ec_host_operations DROP COLUMN routine; ALTER TABLE ec_host_operations DROP COLUMN routine;
ALTER TABLE ec_host_operations DROP COLUMN server_name; ALTER TABLE ec_host_operations DROP COLUMN server_name;
ALTER TABLE ec_host_operations DROP COLUMN target_path; ALTER TABLE ec_host_operations DROP COLUMN target_path;
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { contextHref, returnLabel, safeReturnHref } from '../../src/lib/navigation-context.js';
describe('navigation context', () => {
it('retains nested origins, list scope, loaded results and anchors', () => {
const source = new URL('http://localhost/leaderboard/players/42?game=t5&offset=25&returnTo=%2Fleaderboard%3Fq%3DAce#history-title');
const match = new URL(contextHref('/matches/123', source), source);
expect(safeReturnHref(match.searchParams.get('returnTo'), '/matches')).toBe(source.pathname + source.search + source.hash);
expect(returnLabel(match.searchParams.get('returnTo')!)).toBe('Back to player');
expect(returnLabel('/collections?view=history&launches=30')).toBe('Back to launch history');
});
it('preserves the selected diagnostic tab on both sides of a contextual link', () => {
const url = new URL(contextHref('/servers/t5_zombie_theater_1?tab=logs&log=console', '/maintenance/incident?returnTo=%2Fmaintenance%3Fview%3Dactivity'), 'http://localhost');
expect(url.searchParams.get('tab')).toBe('logs');
expect(url.searchParams.get('log')).toBe('console');
expect(url.searchParams.get('returnTo')).toBe('/maintenance/incident?returnTo=%2Fmaintenance%3Fview%3Dactivity');
});
it.each(['//evil.test', 'https://evil.test/', '/api/auth/logout', '/login', '/hosts/../../api/hosts', '/\\evil.test', '/hosts\n/42', '/unknown', '/' + 'x'.repeat(12000)])('rejects unsafe or unsupported return paths: %s', value => {
expect(safeReturnHref(value, '/betas')).toBe('/betas');
});
});
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { navigationGroups, navigationItemActive, navigationLocation, visibleNavigation } from '../../src/lib/navigation.js';
describe('application navigation', () => {
it.each([
['/servers/t5_zombie_theater_1', 'fleet', '/'],
['/collections/launches/42', 'fleet', '/collections'],
['/hosts/42', 'fleet', '/hosts'],
['/hosts/operations', 'operations', '/hosts/operations'],
['/operations', 'operations', '/operations'],
['/betas/new', 'operations', '/betas'],
['/campaigns/42', 'operations', '/campaigns'],
['/configs/42', 'library', '/configs'],
['/leaderboard/players/42', 'analytics', '/leaderboard'],
['/settings/users', 'administration', '/settings/users'],
['/server-keys', 'administration', '/server-keys']
])('gives %s exactly one canonical home', (path, group, href) => {
expect(navigationLocation(path, 'admin')?.group.id).toBe(group);
expect(navigationGroups.flatMap(group => group.items).filter(item => navigationItemActive(item, path)).map(item => item.href)).toEqual([href]);
});
it('keeps administration and its shortcuts out of operator and viewer navigation', () => {
for (const role of ['operator', 'viewer'] as const) {
expect(visibleNavigation(role).map(group => group.id)).not.toContain('administration');
expect(navigationLocation('/server-keys', role)).toBeNull();
expect(visibleNavigation(role).flatMap(group => group.items).filter(item => ['h', 'k'].includes(item.shortcut ?? ''))).toEqual([]);
}
});
});
+168
View File
@@ -0,0 +1,168 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
const now = '2026-10-05T12:00:00.000Z';
const old = '2026-08-01T12:00:00.000Z';
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); });
async function setup() {
const { db } = await import('../../src/lib/server/db/index.js');
const { operationsOverview } = await import('../../src/lib/server/operations-overview.js');
const handle = db();
const overview = () => operationsOverview(Date.parse(now));
function job(id: string, state = 'done', server = id, timestamp = now) {
handle.prepare(`INSERT INTO server_deployments(id,server,action,state,stage,started_at,finished_at,triggered_by,observation_json)
VALUES(?,?,'restart',?,?,?,?,'test','{"private":"observation-secret"}')`).run(id, server, state, state, timestamp, ['done','failed','warning'].includes(state) ? timestamp : null);
}
function campaign(id: string, state = 'pending', timestamp = now) {
handle.prepare("INSERT INTO deployments(id,action,started_at) VALUES(?,'drift',?)").run(id, timestamp);
handle.prepare(`INSERT INTO deployment_servers(deployment_id,server,files_changed,restart_state,players,checked_at) VALUES(?,?,1,?,4,?)`).run(id, id, state, timestamp);
}
function incident(id: string, state = 'recovering', timestamp = now) {
handle.prepare(`INSERT INTO watchdog_incidents(id,server,kind,state,summary,details,first_seen_at,last_seen_at)
VALUES(?,?,'health',?,'Recovery summary','{"private":"incident-secret"}',?,?)`).run(id, id, state, timestamp, timestamp);
}
return { handle, overview, job, campaign, incident };
}
describe('Operations overview', () => {
it('returns an empty summary and an uncached read-only API response', async () => {
const api = await setup();
expect(api.overview()).toMatchObject({ items: [], counts: { attention: 0, running: 0, recent: 0 }, recentDays: 7, recentLimit: 100 });
const { GET } = await import('../../src/routes/api/operations/+server.js');
const response = GET();
expect(response.headers.get('cache-control')).toBe('no-store');
expect((await response.json()).items).toEqual([]);
});
it('separates empty-lobby waits, latest failures, and completed results', async () => {
const api = await setup();
api.campaign('occupied');
api.job('waiting', 'waiting');
api.job('failed', 'failed');
api.job('completed');
const result = api.overview();
expect(result.counts).toEqual({ attention: 1, running: 2, recent: 1 });
expect(result.items.find(item => item.key === 'campaign:occupied')).toMatchObject({ view: 'running', status: 'Waiting for empty lobbies' });
expect(result.items.find(item => item.key === 'provisioning:failed')).toMatchObject({ view: 'attention' });
expect(JSON.stringify(result)).not.toContain('observation-secret');
});
it('groups collection attempts, retaining old unresolved launches until superseded', async () => {
const api = await setup();
api.job('one', 'failed', 'one', old);
api.handle.prepare('INSERT INTO server_launch_collections(id,name,created_at) VALUES(?,?,?)').run('launch', 'Evening launch', old);
api.handle.exec("INSERT INTO server_collection_members(collection_id,deployment_id,position) VALUES('launch','one',0)");
const first = api.overview();
expect(first.items).toHaveLength(1);
expect(first.items[0]).toMatchObject({ key: 'collection:launch', view: 'attention', active: false, children: [{ key: 'provisioning:one' }] });
api.job('retry', 'done', 'one');
expect(api.overview().items.map(item => item.key)).toEqual(['provisioning:retry']);
});
it('keeps all recovery attempts together without reviving resolved incidents', async () => {
const api = await setup();
api.incident('recovery', 'resolved');
api.job('attempt', 'failed', 'recovery');
api.campaign('repair', 'failed');
api.handle.prepare(`INSERT INTO watchdog_actions(id,incident_id,action,operation_id,campaign_id,created_at) VALUES('a','recovery','rebuild','attempt','repair',?)`).run(now);
const result = api.overview();
expect(result.items).toHaveLength(1);
expect(result.items[0]).toMatchObject({ key: 'incident:recovery', view: 'recent', active: false });
expect(result.items[0].children.map(item => item.key)).toEqual(['campaign:repair', 'provisioning:attempt']);
expect(JSON.stringify(result)).not.toContain('incident-secret');
});
it('retains older repair Campaign links within an active incident', async () => {
const api = await setup();
api.incident('recovery', 'attention');
api.job('attempt', 'done', 'recovery', old);
api.campaign('repair', 'done', old);
api.handle.prepare(`INSERT INTO watchdog_actions(id,incident_id,action,operation_id,campaign_id,created_at) VALUES('a','recovery','rebuild','attempt','repair',?)`).run(old);
const result = api.overview();
expect(result.items).toHaveLength(1);
expect(result.items[0].children.map(item => item.key)).toEqual(['campaign:repair', 'provisioning:attempt']);
});
it('does not resurface failed attempts from resolved incidents outside recent history', async () => {
const api = await setup();
api.incident('recovery', 'resolved', old);
api.job('attempt', 'failed', 'recovery', old);
api.handle.prepare(`INSERT INTO watchdog_actions(id,incident_id,action,operation_id,created_at) VALUES('a','recovery','restart','attempt',?)`).run(old);
expect(api.overview().items).toEqual([]);
});
it('keeps source-less attempts accessible when a recorded parent is unavailable', async () => {
const api = await setup();
api.job('orphan', 'failed');
api.handle.exec("INSERT INTO provisioning_sources(operation_id,kind,source_id,server) VALUES('orphan','campaign','missing','orphan')");
expect(api.overview().items).toMatchObject([{ key: 'provisioning:orphan', view: 'attention' }]);
});
it('shows Beta review and test failures as attention while empty-lobby waits keep running', async () => {
const api = await setup();
const script = api.handle.prepare("INSERT INTO scripts(name,target_rel_path) VALUES('script','scripts/test.gsc')").run().lastInsertRowid;
const version = api.handle.prepare("INSERT INTO script_versions(script_id,version,sha256,size,content) VALUES(?,1,'hash',1,X'31')").run(script).lastInsertRowid;
for (const [id, state, stage, outcome] of [[1,'running','awaiting-empty','active'],[2,'ready-promote','ready','passed'],[3,'running','ready','paused'],[4,'promoted','ready','passed']] as const) {
api.handle.prepare("INSERT INTO betas(id,script_id,script_version_id,trigger_kind,state,opened_at) VALUES(?,?,?,'health',?,?)").run(id,script,version,state,now);
api.handle.prepare("INSERT INTO beta_servers(beta_id,server,had_original,stage,updated_at) VALUES(?,?,0,?,?)").run(id,`server-${id}`,stage,now);
api.handle.prepare("INSERT INTO beta_test_plans(beta_id,plan,outcome) VALUES(?,'{}',?)").run(id,outcome);
}
const result = api.overview();
expect(result.counts).toEqual({ attention: 2, running: 1, recent: 1 });
expect(result.items.find(item => item.key === 'beta:1')?.status).toBe('Waiting for empty lobbies');
api.campaign('promotion','done');
api.handle.exec("UPDATE deployments SET beta_id=4 WHERE id='promotion'");
expect(api.overview().items.find(item => item.key === 'campaign:promotion')?.related).toEqual([{ label: 'Beta #4', href: '/betas/4' }]);
});
it('omits healthy routine checks and superseded routine failures without loading private data', async () => {
const api = await setup();
const hosts = await import('../../src/lib/server/hosts/index.js');
const host = hosts.createHost({ name: 'Test host' });
const first = hosts.queueHostOperation(host.id,'instance.inspect',{ instanceId: 'instance', password: 'payload-secret' });
hosts.reportHostOperationStatus(host.id,first.id,{ state:'failed', error: 'Unreachable', result: { password: 'result-secret' } });
api.handle.prepare('UPDATE ec_host_operations SET updated_at=?').run(now);
expect(api.overview().items).toMatchObject([{ key: `host:${first.id}`, view:'attention' }]);
const next = hosts.queueHostOperation(host.id,'instance.inspect',{ instanceId: 'instance' });
hosts.reportHostOperationStatus(host.id,next.id,{ state:'completed', result: { healthy:true } });
api.handle.prepare('UPDATE ec_host_operations SET updated_at=?').run(now);
expect(api.overview().items).toEqual([]);
const action = hosts.queueHostOperation(host.id,'instance.restart',{ password: 'payload-secret' });
api.handle.prepare('UPDATE ec_host_operations SET updated_at=?').run(now);
const result = api.overview();
expect(result.items).toMatchObject([{ key: `host:${action.id}`, view:'running' }]);
expect(JSON.stringify(result)).not.toContain('secret');
});
it('bounds finished history while retaining older active work and removing deleted resource links', async () => {
const api = await setup();
api.job('old-result', 'done', 'old-result', old);
api.job('old-active', 'waiting', 'old-active', old);
for (let index = 0; index < 120; index++) api.job(`result-${String(index).padStart(3,'0')}`);
api.handle.prepare('UPDATE server_deployments SET server_deleted_at=? WHERE id=?').run(now,'result-119');
const result = api.overview();
expect(result.counts).toEqual({ attention:0,running:1,recent:100 });
expect(result.items.find(item => item.key === 'provisioning:old-active')?.view).toBe('running');
expect(result.items.find(item => item.key === 'provisioning:result-119')?.resources[0].href).toBeNull();
expect(result.items.some(item => item.key === 'provisioning:old-result')).toBe(false);
});
it('groups Maintenance and its provisioning and host work while keeping the workflow actionable', async () => {
const api = await setup();
const hosts = await import('../../src/lib/server/hosts/index.js');
const host = hosts.createHost({ name: 'Test host' });
const operation = hosts.queueHostOperation(host.id, 'instance.restart', {});
api.job('restart', 'running');
api.handle.prepare(`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at) VALUES('instance','restart',?,'library','image',4976,?)`).run(host.id, now);
api.handle.prepare(`INSERT INTO native_provisioning(deployment_id,instance_id,generation,operation_id) VALUES('restart','instance',1,?)`).run(operation.id);
api.handle.prepare(`INSERT INTO maintenance_jobs(server,state,requested_at,provisioning_id) VALUES('restart','restarting',?,'restart')`).run(now);
const result = api.overview();
expect(result.items).toHaveLength(1);
expect(result.items[0]).toMatchObject({ kind: 'maintenance', view: 'running', children: [{ key: 'provisioning:restart' }] });
expect(result.items[0].href).toContain('server=restart');
expect(result.items[0].resources).toContainEqual({ label: 'Test host', href: `/hosts/${host.id}` });
});
});
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { serverSection, serverSectionAnchor } from '../../src/lib/server-sections.js';
describe('server detail bookmarks', () => {
it.each([
['scoreboard', 'live', null], ['chat', 'live', 'chat'], ['matches', 'matches', null],
['config', 'configuration', null], ['inventory', 'configuration', 'inventory'],
['logs', 'diagnostics', 'logs'], ['diagnostics', 'diagnostics', null], ['history', 'history', null]
])('preserves the destination of tab=%s', (tab, section, anchor) => {
expect(serverSection(tab)).toBe(section);
expect(serverSectionAnchor(tab)).toBe(anchor);
});
it('defaults unsupported tabs to Live and accepts the new sections', () => {
expect(serverSection(null)).toBe('live');
expect(serverSection('missing')).toBe('live');
expect(serverSection('live')).toBe('live');
expect(serverSection('configuration')).toBe('configuration');
});
});
+23
View File
@@ -0,0 +1,23 @@
import { afterEach, expect, it, vi } from 'vitest';
import { enableViewTransitions } from '../../src/lib/ui/motion/viewTransition.js';
afterEach(() => vi.unstubAllGlobals());
it('allows navigation to continue when a transition is skipped or superseded', async () => {
let finish: () => void = () => {};
const finished = new Promise<void>(resolve => { finish = resolve; });
const complete = Promise.reject(new Error('navigation aborted'));
vi.stubGlobal('document', { startViewTransition: (update: () => Promise<void>) => {
void update().then(finish);
return { ready: Promise.reject(new DOMException('Skipped transition', 'AbortError')), finished };
} });
await enableViewTransitions({ complete });
await finished;
});
it('continues without animation when the browser cannot start a transition', async () => {
vi.stubGlobal('document', { startViewTransition: () => { throw new Error('Unavailable'); } });
await enableViewTransitions({ complete: Promise.resolve() });
vi.stubGlobal('document', {});
expect(enableViewTransitions({ complete: Promise.resolve() })).toBeUndefined();
});