Standardize UI typography, charts and empty states

This commit is contained in:
2026-10-07 19:40:02 -04:00
parent 05bdeaf624
commit 0ed3cd6b47
57 changed files with 417 additions and 382 deletions
+8
View File
@@ -63,3 +63,11 @@ operations represent their host receipts and maintenance verification so a singl
user action does not appear multiple times. Routine successful host checks stay
in the technical Host operation history. Keep detail links contextual so their
parent breadcrumb restores filters and pagination.
### Type and numbers
Use the shared `--text-*` scale, with 12 px as the minimum. Mobile inputs use
`--text-input` (16 px) to avoid browser zoom. `npm run check:styles`, also included
in `npm run check`, rejects raw pixel font sizes. Use Inter with tabular figures
for counts, scores, durations and charts; reserve mono for code, IDs, paths and
logs. Static values use text or state colors; the accent marks links and controls.
+3 -2
View File
@@ -8,7 +8,7 @@
"dev": "vite dev",
"build": "vite build && npm run build:reports -- --outDir build/reports && npm run build:launch -- --outDir build/launch",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check": "npm run check:styles && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run",
"test:watch": "vitest",
"benchmark:leaderboard": "node scripts/benchmark-leaderboard.mjs",
@@ -21,7 +21,8 @@
"build:launch": "vite build --config vite.launch.config.ts",
"predev": "npm run build:reports && npm run build:launch",
"pretest": "npm run build:reports && npm run build:launch",
"pretest:watch": "npm run build:reports && npm run build:launch"
"pretest:watch": "npm run build:reports && npm run build:launch",
"check:styles": "node scripts/check-ui-styles.mjs"
},
"dependencies": {
"@fontsource/alfa-slab-one": "^5.3.0",
+26
View File
@@ -0,0 +1,26 @@
import { readdir, readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const root = fileURLToPath(new URL('../src/', import.meta.url));
const failures = [];
async function visit(directory) {
for (const entry of await readdir(directory, { withFileTypes: true })) {
const file = path.join(directory, entry.name);
if (entry.isDirectory()) await visit(file);
else if (/\.(svelte|css)$/.test(entry.name) && entry.name !== 'tokens.css') {
const source = await readFile(file, 'utf8');
for (const match of source.matchAll(/font-size\s*:\s*[\d.]+px\b/g)) {
const line = source.slice(0, match.index).split('\n').length;
failures.push(`${path.relative(process.cwd(), file)}:${line}: use a shared type token (12 px minimum): ${match[0]}`);
}
}
}
}
await visit(root);
const tokens = await readFile(path.join(root, 'lib/ui/styles/tokens.css'), 'utf8');
for (const match of tokens.matchAll(/--text-(?:xs|sm|base|md|lg|xl|2xl|3xl|input):\s*([\d.]+)px/g)) {
if (Number(match[1]) < 12) failures.push(`Type tokens must be at least 12 px: ${match[0]}`);
}
if (failures.length) { console.error(failures.join('\n')); process.exitCode = 1; }
else console.log('UI styles: shared font sizes and 12 px minimum verified.');
+6 -1
View File
@@ -6,11 +6,13 @@ import { runningVersions } from '../integrations/plutonium.js';
import { listHosts } from '../hosts/index.js';
import type { NativeInstance } from '../backends/native.js';
import { serverOperations } from './overview.js';
import { hostListStatus } from '../../ui/host-overview.js';
export function fleetSnapshot(serverName?: string) {
const bindings = serverName === undefined ? [] : [serverName];
const rconHost = config.rcon.host;
const hosts = listHosts();
const serverNow = Date.now();
const hostsById = new Map(hosts.map(({ inventory: _inventory, ...host }) => [host.id, host]));
const versions = new Map(runningVersions(serverName).map(version => [version.server, version]));
const instances = new Map((db().prepare(`SELECT * FROM native_instances WHERE retired_at IS NULL ${serverName === undefined ? '' : 'AND server = ?'}`)
@@ -41,7 +43,10 @@ export function fleetSnapshot(serverName?: string) {
});
return {
servers,
serverNow: Date.now(),
serverNow,
// Include hosts without assigned servers. Re-evaluate heartbeat age on
// every existing snapshot refresh rather than adding a navigation poll.
hostAttention: hosts.filter(host => ['attention', 'offline'].includes(hostListStatus(host, serverNow).key)).length,
reservedNames: serverName === undefined ? (db().prepare("SELECT j.server FROM server_creation_jobs j JOIN server_deployments d ON d.id = j.deployment_id WHERE d.state IN ('queued', 'running', 'awaiting-health')").all() as { server: string }[]).map(row => row.server) : [],
integrations: {
native: true,
+18 -18
View File
@@ -218,9 +218,9 @@
<style>
.builder { display: grid; gap: 16px; }
h4, h5, p, dl, dd { margin: 0; }
h4 { font-size: 19px; font-weight: 600; }
h5 { font-size: 15px; font-weight: 550; line-height: 1.5; }
p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
h4 { font-size: var(--text-lg); font-weight: 600; }
h5 { font-size: var(--text-base); font-weight: 550; line-height: 1.5; }
p { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.eyebrow { display: block; font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .1em; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; }
.pill { padding: 5px 9px; border-radius: 99px; font-size: var(--text-xs); color: var(--text-secondary); border: 1px solid var(--border-strong); white-space: nowrap; }
@@ -229,32 +229,32 @@
.preset:hover { border-color: var(--accent-primary-hover); }
.preset.selected { border-color: var(--accent-primary); background: color-mix(in oklab, var(--accent-primary) 10%, var(--surface-sunken)); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent-primary) 30%, transparent); }
.preset-top { display: flex; align-items: center; justify-content: space-between; }
.preset-number, .step-number { font-family: var(--font-mono); color: var(--text-secondary); font-size: 12px; }
.choice { width: 19px; height: 19px; border: 1px solid var(--border-strong); border-radius: 50%; display: grid; place-items: center; font-size: 12px; }
.preset-number, .step-number { font-family: var(--font-numeric); color: var(--text-secondary); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
.choice { width: 19px; height: 19px; border: 1px solid var(--border-strong); border-radius: 50%; display: grid; place-items: center; font-size: var(--text-xs); }
.selected .choice { background: var(--accent-primary); color: var(--text-on-accent); border-color: var(--accent-primary); }
.preset > strong { font-size: 15px; }
.preset-description { font-size: 12px; line-height: 1.6; color: var(--text-secondary); }
.preset ul { padding-left: 16px; margin: 2px 0 10px; font-size: 12px; line-height: 1.65; flex: 1; }
.preset > strong { font-size: var(--text-base); }
.preset-description { font-size: var(--text-xs); line-height: 1.6; color: var(--text-secondary); }
.preset ul { padding-left: 16px; margin: 2px 0 10px; font-size: var(--text-xs); line-height: 1.65; flex: 1; }
.preset li + li { margin-top: 5px; }
.preset li::marker { color: var(--accent-primary-hover); }
.preset-footer { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; border-top: 1px solid var(--border-subtle); padding-top: 12px; font-size: var(--text-xs); color: var(--text-secondary); }
.preset-footer > span { color: var(--accent-primary-hover); }
.feedback { color: var(--accent-primary-hover); font-size: 12px; min-height: 18px; }
.feedback { color: var(--accent-primary-hover); font-size: var(--text-xs); min-height: 18px; }
.error { color: var(--state-danger); }
.active-plan { display: grid; gap: 18px; padding: 22px; background: color-mix(in oklab, var(--surface-raised) 25%, var(--surface)); border: 1px solid var(--border-strong); border-radius: var(--radius-md); }
.plan-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; padding: 16px 0; border-top: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle); }
dt { color: var(--text-secondary); font-size: var(--text-xs); margin-bottom: 6px; }
dd { font-size: 13px; font-weight: 550; line-height: 1.5; }
dd { font-size: var(--text-sm); font-weight: 550; line-height: 1.5; }
dd small { font-size: var(--text-xs); font-weight: 400; color: var(--text-secondary); }
.plan-caption { font-size: 12px; }
.telemetry-note { border-left: 2px solid var(--accent-primary); background: color-mix(in oklab, var(--accent-primary) 6%, transparent); padding: 10px 12px; font-size: 12px; }
.plan-caption { font-size: var(--text-xs); }
.telemetry-note { border-left: 2px solid var(--accent-primary); background: color-mix(in oklab, var(--accent-primary) 6%, transparent); padding: 10px 12px; font-size: var(--text-xs); }
.telemetry-note span { color: var(--accent-primary-hover); margin-right: 5px; }
.requirements { display: grid; gap: 10px; }
.requirement { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface-sunken); min-width: 0; overflow: hidden; }
.requirement-heading { display: flex; align-items: center; gap: 14px; padding: 16px; }
.requirement-copy { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.requirement-copy .eyebrow { font-size: var(--text-xs); margin-bottom: 4px; }
.remove { padding: 3px 8px; font-size: 21px; background: transparent; border: 0; color: var(--text-secondary); }
.remove { padding: 3px 8px; font-size: var(--text-lg); background: transparent; border: 0; color: var(--text-secondary); }
.remove:hover { color: var(--state-danger); }
.editor { border-top: 1px solid var(--border-subtle); }
.editor > summary { padding: 10px 16px; font-size: var(--text-xs); color: var(--accent-primary-hover); }
@@ -262,24 +262,24 @@
.editor-fields { display: grid; gap: 12px; padding: 6px 16px 16px; }
.fields, .add-row { display: flex; flex-wrap: wrap; gap: 12px; align-items: end; }
.add-row label { flex: 1 1 220px; }
label { display: grid; gap: 7px; flex: 1 1 180px; min-width: 0; font-size: 12px; color: var(--text-secondary); }
label { display: grid; gap: 7px; flex: 1 1 180px; min-width: 0; font-size: var(--text-xs); color: var(--text-secondary); }
input, select, button { color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-strong); border-radius: 6px; padding: 10px 12px; min-width: 0; font: inherit; }
input, select { width: 100%; font-size: 13px; min-height: 40px; box-sizing: border-box; border-color: var(--border-control); }
input, select { width: 100%; font-size: var(--text-sm); min-height: 40px; box-sizing: border-box; border-color: var(--border-control); }
button { cursor: pointer; }
button:disabled { opacity: .5; cursor: default; }
button:focus-visible, summary:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 3px; }
.secondary { background: var(--surface-raised); font-size: 12px; min-height: 40px; }
.secondary { background: var(--surface-raised); font-size: var(--text-xs); min-height: 40px; }
.secondary:hover:not(:disabled) { border-color: var(--accent-primary-hover); }
.policy-settings { display: grid; gap: 8px; }
.policy { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
.policy > summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 18px; font-size: 13px; font-weight: 550; }
.policy > summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 18px; font-size: var(--text-sm); font-weight: 550; }
.policy small { display: block; font-size: var(--text-xs); color: var(--text-secondary); font-weight: 400; line-height: 1.6; margin-top: 5px; }
.edit-hint { color: var(--accent-primary-hover); font-size: var(--text-xs); }
.policy-body { padding: 0 18px 18px; display: grid; gap: 12px; }
.policy-body.fields { display: flex; }
summary { cursor: pointer; }
.empty-plan { padding: 22px; text-align: center; border: 1px dashed var(--border-strong); border-radius: var(--radius-md); }
.empty-plan strong { display: block; margin-bottom: 8px; font-size: 14px; }
.empty-plan strong { display: block; margin-bottom: 8px; font-size: var(--text-base); }
@media (max-width: 850px) { .presets { grid-template-columns: 1fr; } .preset { padding: 16px; } .plan-facts { grid-template-columns: 1fr; gap: 12px; } }
@media (max-width: 480px) { .active-plan { padding: 14px; } .requirement-heading { padding: 12px; gap: 10px; } .editor > summary > span { display: block; margin: 5px 0 0; } .add-row { align-items: stretch; } .add-row button { width: 100%; } }
@media (prefers-reduced-motion: reduce) { .preset { transition: none; } }
@@ -95,6 +95,6 @@
<style>
.files, label { display:grid; gap:12px; } .files { margin:20px 0; }
.files label { padding:16px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); }
p { line-height:1.6; } code, .hash { overflow-wrap:anywhere; } .hash, label span { color:var(--text-secondary); font-size:13px; }
p { line-height:1.6; } code, .hash { overflow-wrap:anywhere; } .hash, label span { color:var(--text-secondary); font-size: var(--text-sm); }
li { margin:8px 0; } textarea { width:100%; } section { margin-top:24px; }
</style>
@@ -157,16 +157,16 @@
.server-detail { background:var(--surface); }
.deployment-details,.deployment-links { display:flex; flex-wrap:wrap; gap:8px 20px; margin:12px 0; font-size:var(--text-sm); }
@media(max-width:720px) { table { min-width:620px; } }
section { display:grid; gap:16px; } h2,p { margin:0; } h2 { font-size:18px; }
p,span,time { font-size:13px; line-height:1.6; color:var(--text-secondary); } a { color:var(--accent-primary-hover); }
section { display:grid; gap:16px; } h2,p { margin:0; } h2 { font-size: var(--text-md); }
p,span,time { font-size: var(--text-sm); line-height:1.6; color:var(--text-secondary); } a { color:var(--accent-primary-hover); }
.heading,.actions,.filters { display:flex; flex-wrap:wrap; align-items:center; gap:12px; } .heading { justify-content:space-between; }
.server { border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:16px; }
.server > summary { display:flex; flex-wrap:wrap; gap:8px 16px; } summary { cursor:pointer; font-size:14px; } .server > summary::before { content:'▸'; } .server[open] > summary::before { content:'▾'; }
.server > summary { display:flex; flex-wrap:wrap; gap:8px 16px; } summary { cursor:pointer; font-size: var(--text-base); } .server > summary::before { content:'▸'; } .server[open] > summary::before { content:'▾'; }
.requirement { display:grid; gap:8px; margin-top:20px; border-top:1px solid var(--border-subtle); padding-top:16px; } .result { font-weight:600; margin-right:6px; color:inherit; }
progress { width:100%; height:8px; accent-color:var(--state-online); } .failed { color:var(--state-danger); }
.observation,.guidance { margin-top:12px; } .evidence-link { justify-self:start; padding:6px 0; color:var(--accent-primary-hover); border:0; background:transparent; cursor:pointer; font-size:12px; }
.observation,.guidance { margin-top:12px; } .evidence-link { justify-self:start; padding:6px 0; color:var(--accent-primary-hover); border:0; background:transparent; cursor:pointer; font-size: var(--text-xs); }
.signoff { display:flex; gap:12px; align-items:end; flex-wrap:wrap; } .signoff label { flex:1 1 240px; }
label { display:grid; gap:6px; font-size:13px; color:var(--text-secondary); } input,select,textarea { min-height:40px; padding:8px 10px; border:1px solid var(--border-control); border-radius:6px; background:var(--surface-sunken); color:var(--text-primary); font:inherit; max-width:100%; } textarea { resize:vertical; width:100%; box-sizing:border-box; }
label { display:grid; gap:6px; font-size: var(--text-sm); color:var(--text-secondary); } input,select,textarea { min-height:40px; padding:8px 10px; border:1px solid var(--border-control); border-radius:6px; background:var(--surface-sunken); color:var(--text-primary); font:inherit; max-width:100%; } textarea { resize:vertical; width:100%; box-sizing:border-box; }
.filters { margin:16px 0; align-items:end; } .filters label { flex:1 1 180px; min-width:0; }
.evidence { display:grid; gap:8px; padding:16px 0; border-bottom:1px solid var(--border-subtle); } .evidence-section { scroll-margin-top:80px; } pre { white-space:pre-wrap; overflow-wrap:anywhere; font-size:12px; } .evidence-section > p { margin-top:12px; }
.evidence { display:grid; gap:8px; padding:16px 0; border-bottom:1px solid var(--border-subtle); } .evidence-section { scroll-margin-top:80px; } pre { white-space:pre-wrap; overflow-wrap:anywhere; font-size: var(--text-xs); } .evidence-section > p { margin-top:12px; }
</style>
@@ -98,20 +98,20 @@
</dialog>
<style>
.validation { padding: 12px; border-left: 3px solid var(--state-warning); color: var(--text-primary); font-size: 14px; line-height: 1.6; }
.validation { padding: 12px; border-left: 3px solid var(--state-warning); color: var(--text-primary); font-size: var(--text-base); line-height: 1.6; }
dialog { width: min(820px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); }
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }.builder { padding: 28px; display: grid; gap: 20px; }
header, .section-title, .actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }.actions { justify-content: flex-end; }
.eyebrow { display: block; color: var(--text-secondary); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .12em; margin-bottom: 8px; } h2, h3, p { margin: 0; } h2 { font-size: 25px; letter-spacing: -.03em; } h3 { font-size: 16px; }.muted { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
form, .lineup { display: grid; gap: 18px; }.identity { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } label { display: grid; gap: 7px; font-size: 12px; min-width: 0; } input, select { width: 100%; min-width: 0; box-sizing: border-box; padding: 11px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: inherit; font: inherit; }
.lineup { border-block: 1px solid var(--border-subtle); padding: 20px 0; }.map-row { display: grid; grid-template-columns: 24px 1fr 1.5fr 80px 24px; gap: 12px; align-items: end; padding: 14px; background: var(--surface-sunken); border-radius: var(--radius-md); }.number { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-secondary); align-self: center; }.count { font-size: 12px; padding: 6px 10px; border: 1px solid var(--border-strong); border-radius: 99px; }.close, .remove { background: none; border: 0; color: var(--text-secondary); font-size: 25px; cursor: pointer; }.remove { padding: 7px 0; }.error { color: var(--state-danger); font-size: 13px; }button:disabled { opacity: .5; cursor: default; }
.eyebrow { display: block; color: var(--text-secondary); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .12em; margin-bottom: 8px; } h2, h3, p { margin: 0; } h2 { font-size: var(--text-xl); letter-spacing: -.03em; } h3 { font-size: var(--text-md); }.muted { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; }
form, .lineup { display: grid; gap: 18px; }.identity { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } label { display: grid; gap: 7px; font-size: var(--text-xs); min-width: 0; } input, select { width: 100%; min-width: 0; box-sizing: border-box; padding: 11px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: inherit; font: inherit; }
.lineup { border-block: 1px solid var(--border-subtle); padding: 20px 0; }.map-row { display: grid; grid-template-columns: 24px 1fr 1.5fr 80px 24px; gap: 12px; align-items: end; padding: 14px; background: var(--surface-sunken); border-radius: var(--radius-md); }.number { font-family: var(--font-numeric); font-size: var(--text-xs); color: var(--text-secondary); align-self: center; font-variant-numeric: tabular-nums; }.count { font-size: var(--text-xs); padding: 6px 10px; border: 1px solid var(--border-strong); border-radius: 99px; }.close, .remove { background: none; border: 0; color: var(--text-secondary); font-size: var(--text-xl); cursor: pointer; }.remove { padding: 7px 0; }.error { color: var(--state-danger); font-size: var(--text-sm); }button:disabled { opacity: .5; cursor: default; }
@media (max-width: 620px) {
.builder { padding: 20px; }
header { display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: start; }
.close, .remove { min-width: 44px; }
.identity { grid-template-columns: 1fr; }
.map-row { grid-template-columns: minmax(0, 1fr) 44px; }
.number { display: none; }
.number { display: none; font-variant-numeric: tabular-nums; }
.map-row label:first-of-type, .map-row label:nth-of-type(2) { grid-column: 1 / -1; }
.actions { align-items: stretch; flex-direction: column; }
}
+2 -2
View File
@@ -162,7 +162,7 @@
.messages:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--accent-primary); border-radius: var(--radius-sm); }
ol { list-style: none; padding: 0; margin: 0; }
.message { display: grid; grid-template-columns: 6rem minmax(0, 1fr); align-items: baseline; column-gap: var(--space-4); padding: var(--space-3) 0; }
.message-clock, .number { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.message-clock, .number { font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }
.message-clock { font-size: var(--text-xs); color: var(--text-secondary); white-space: nowrap; }
.message-content { min-width: 0; }
.message-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--space-1) var(--space-3); }
@@ -170,7 +170,7 @@
.context { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--space-1) var(--space-3); margin: 0; color: var(--text-secondary); font-size: var(--text-xs); font-weight: var(--weight-regular); }
.context > div { display: flex; align-items: baseline; gap: var(--space-1); white-space: nowrap; }
.context > div + div::before { content: '·'; margin-right: var(--space-1); color: var(--text-tertiary); }
.context dd { margin: 0; font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.context dd { margin: 0; font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }
.message p { margin: var(--space-1) 0 0; font-size: var(--text-base); line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
.match-events { padding: var(--space-1) 0; }
.match-divider { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) 0; color: var(--text-secondary); font-size: var(--text-xs); }
+1 -1
View File
@@ -144,7 +144,7 @@
.setting summary::-webkit-details-marker { display: none; }
.setting-main { display: flex; gap: 12px; justify-content: space-between; flex-wrap: wrap; }
code, strong { font: var(--text-xs)/1.6 var(--font-mono); overflow-wrap: anywhere; white-space: pre-wrap; }
strong { color: var(--accent-primary-hover); }
strong { color: var(--text-primary); }
.source-label { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: var(--text-xs); color: var(--text-secondary); margin-top: 6px; }
.source-label::before { content: '▸'; } .setting[open] .source-label::before { content: '▾'; }
b { font-weight: 400; color: var(--state-warning); }
+8 -7
View File
@@ -106,7 +106,7 @@
<div class="page">
<SectionHeader title="Configs" subtitle="Manage game baselines and scoped changes.">
{#snippet actions()}<Button variant="primary" onclick={() => openCreate()}>New config</Button>{/snippet}
{#snippet actions()}{#if view === 'inspect' || loading || artifacts.length || error}<Button variant="primary" onclick={() => openCreate()}>New config</Button>{/if}{/snippet}
</SectionHeader>
<div class="navigation"><Tabs tabs={[{id:'library',label:'Library'},{id:'inspect',label:'Inspect a server'}]} bind:value={view} ariaLabel="Config views" idPrefix="config-tabs" panelId="config-panel" /><Button variant="ghost" size="sm" onclick={load} loading={loading}>Refresh</Button></div>
<div id="config-panel" role="tabpanel" aria-labelledby={'config-tabs-' + view} class="config-panel">
@@ -119,12 +119,16 @@
<select aria-label="Filter by game" bind:value={game}><option value="">All games</option>{#each gameOptions as option}<option value={option}>{option.toUpperCase()}</option>{/each}</select>
<select aria-label="Filter by scope" bind:value={scope}><option value="">All scopes</option><option value="game">Game</option><option value="map">Map</option></select>
<select aria-label="Filter by status" bind:value={status}><option value="">All statuses</option><option value="pending">Awaiting promotion</option><option value="promoted">Has promoted version</option><option value="beta">Active beta</option><option value="failed">Test failed</option></select>
{#if query || game || scope || status}<Button size="sm" variant="ghost" onclick={resetFilters}>Clear</Button>{/if}
{#if filtered.length && (query || game || scope || status)}<Button size="sm" variant="ghost" onclick={resetFilters}>Clear</Button>{/if}
</div>{/if}
{#if loading}<div class="loading" role="status">Loading your config library…</div>
{:else if !artifacts.length && !error}
<div class="welcome"><span class="eyebrow">Start with a foundation</span><h2>Your fleet, configured your way.</h2><p>Add a baseline for each game, then layer on small, focused changes for a game or map. Every change gets its own version.</p><Button variant="primary" onclick={() => openCreate('baseline')}>Create your first baseline</Button></div>
{:else if !filtered.length && !error}<EmptyState title="No matching configs" description="Try a different search or clear your filters." />
<EmptyState title="No configs yet" description="Configs store game baselines and scoped changes. Create a baseline to start managing configuration versions.">
{#snippet actions()}<Button variant="primary" onclick={() => openCreate('baseline')}>Create baseline</Button>{/snippet}
</EmptyState>
{:else if !filtered.length && !error}<EmptyState title="No matching configs" description="Try a different search or clear your filters.">
{#snippet actions()}<Button onclick={resetFilters}>Clear filters</Button>{/snippet}
</EmptyState>
{:else}
{#if filteredBaselines.length}
<section class="section"><h2>Baselines <span>{filteredBaselines.length}</span></h2>{@render configTable(filteredBaselines)}</section>
@@ -181,8 +185,6 @@
.section { display: flex; flex-direction: column; gap: 14px; }
h2 { font-size: var(--text-md); margin: 0; letter-spacing: var(--tracking-tight); } h2 span { font-size: var(--text-xs); color: var(--text-secondary); font-weight: 400; margin-left: 8px; }
.loading { padding: 60px 0; text-align: center; color: var(--text-secondary); }
.welcome { padding: 56px 32px; border-radius: var(--radius-lg); background: var(--surface); border: 1px solid var(--border-strong); text-align: center; }
.welcome h2 { margin-top: 12px; font-size: var(--text-2xl); } .welcome p { color: var(--text-secondary); max-width: 540px; margin: 16px auto 24px; }
.eyebrow { color: var(--accent-primary-hover); font-size: var(--text-xs); }
.error { color: var(--state-danger); padding: 12px; background: var(--surface-raised); border-radius: var(--radius-sm); font-size: var(--text-sm); }
.warning { color: var(--state-warning); font-size: var(--text-sm); }
@@ -199,7 +201,6 @@
.dialog-actions { flex-wrap: wrap; } .dialog-actions p { flex: 1 1 240px; font-size: var(--text-xs); color: var(--text-secondary); }
@media (max-width:600px) { .form-grid { grid-template-columns:1fr; } form { padding:18px; } .navigation { gap:4px; flex-wrap:wrap; } }
.config-panel { display:grid; gap:20px; }
.welcome { padding:24px; }
.loading { padding:24px 0; }
input:not([type='checkbox']):not([type='file']), select { border-color: var(--border-control); }
</style>
@@ -286,7 +286,7 @@
fieldset label.selected { background: var(--surface-raised); border-color: var(--accent-primary); }
fieldset input { margin: 0; }
.change-summary { display: grid; gap: 8px; padding: 14px 16px; background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
.change-summary > strong { font-size: 17px; line-height: 1.4; }
.change-summary > strong { font-size: var(--text-md); line-height: 1.4; }
.readiness { display: flex; flex-wrap: wrap; gap: 6px 16px; color: var(--text-secondary); font-size: var(--text-xs); }
.readiness:empty { display: none; }
p { margin: 0; font-size: var(--text-sm); line-height: 1.5; }
@@ -220,7 +220,7 @@
label { display: grid; gap: 8px; color: var(--text-secondary); font-size: var(--text-sm); }
select { width: 100%; }
.summary { display: grid; gap: 8px; padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface-sunken); }
.summary > strong { font-size: 17px; line-height: 1.4; }
.summary > strong { font-size: var(--text-md); line-height: 1.4; }
p { margin: 0; font-size: var(--text-sm); line-height: 1.5; }
.muted { color: var(--text-secondary); font-size: var(--text-xs); }
.readiness { display: flex; flex-wrap: wrap; gap: 8px 14px; font-size: var(--text-xs); color: var(--text-secondary); }
@@ -46,5 +46,5 @@
p { margin: 0; line-height: 1.6; color: var(--text-secondary); }
.notice { padding: 16px; background: var(--surface-raised); border-radius: var(--radius-md); line-height: 1.6; }
.notice { color: var(--text-primary); }
.irreversible { font-size: 13px; } .error { color: var(--state-danger); }
.irreversible { font-size: var(--text-sm); } .error { color: var(--state-danger); }
</style>
+14 -14
View File
@@ -51,9 +51,9 @@
<style>
.diff {
background: var(--bg-page);
border: 1px solid var(--border);
border-radius: var(--r-sm);
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
margin: 10px 0;
overflow: hidden;
}
@@ -61,14 +61,14 @@
display: flex;
gap: 12px;
padding: 10px 14px;
background: var(--bg-nav);
border-bottom: 1px solid var(--border);
background: var(--surface);
border-bottom: 1px solid var(--border-subtle);
font-size: var(--text-sm);
align-items: center;
}
.verb {
padding: 3px 10px;
border-radius: var(--r-sm);
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: var(--text-xs);
text-transform: uppercase;
@@ -78,35 +78,35 @@
.verb-added {
background: rgba(124, 212, 255, 0.10);
border-color: rgba(124, 212, 255, 0.3);
color: var(--spark-500);
color: var(--state-online);
}
.verb-updated {
background: rgba(124, 192, 245, 0.10);
border-color: rgba(124, 192, 245, 0.28);
color: var(--cream);
color: var(--text-primary);
}
.verb-removed {
background: rgba(255, 48, 80, 0.1);
border-color: rgba(255, 48, 80, 0.3);
color: var(--danger-500);
color: var(--state-danger);
}
.verb-held {
background: rgba(255, 176, 32, 0.1);
border-color: rgba(255, 176, 32, 0.3);
color: var(--amber-500);
color: var(--state-warning);
}
.path { color: var(--ink-100); font-family: var(--font-mono); font-size: var(--text-mono); }
.path { color: var(--text-primary); font-family: var(--font-mono); font-size: var(--text-sm); }
pre {
margin: 0;
padding: 14px 16px;
font-family: var(--font-mono);
font-size: var(--text-mono);
font-size: var(--text-sm);
line-height: var(--leading-mono);
color: var(--ink-300);
color: var(--text-secondary);
max-height: 540px;
overflow: auto;
}
.ln { display: block; }
.ln-add { background: rgba(124, 212, 255, 0.08); color: var(--spark-400); }
.ln-add { background: color-mix(in srgb, var(--state-online) 8%, transparent); color: var(--state-online); }
.ln-del { background: rgba(255, 48, 80, 0.08); color: #ff8a9a; }
</style>
@@ -68,9 +68,9 @@
<style>
dialog { width: min(620px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); color: var(--text-primary); }
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }
.content { display: grid; gap: 20px; padding: 28px; } h2, p { margin: 0; } h2 { font-size: 25px; }
p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; } .error { color: var(--state-danger); } .caption { font-size: 12px; }
.content { display: grid; gap: 20px; padding: 28px; } h2, p { margin: 0; } h2 { font-size: var(--text-xl); }
p { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; } .error { color: var(--state-danger); } .caption { font-size: var(--text-xs); }
ul { margin: 0; padding: 12px 12px 12px 30px; max-height: 220px; overflow: auto; background: var(--surface-sunken); border-radius: var(--radius-md); }
li { padding: 5px 0; font-family: var(--font-body); font-size: 12px; overflow-wrap: anywhere; }
li { padding: 5px 0; font-family: var(--font-body); font-size: var(--text-xs); overflow-wrap: anywhere; }
.actions { display: flex; gap: 12px; justify-content: flex-end; flex-wrap: wrap; }
</style>
+6 -6
View File
@@ -23,12 +23,12 @@
.recipe { display: grid; gap: 16px; min-width: 0; }
.recipe-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.eyebrow { display: block; font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .12em; color: var(--text-secondary); margin-bottom: 6px; }
h3 { margin: 0; font-size: 22px; letter-spacing: -.03em; }
.game { border: 1px solid var(--border-strong); border-radius: 99px; padding: 5px 10px; font-size: 12px; white-space: nowrap; }
dl { display: grid; gap: 12px; margin: 0; } dt { color: var(--text-secondary); font-size: 12px; } dd { margin: 4px 0 0; font-size: 13px; overflow-wrap: anywhere; line-height: 1.5; }
h3 { margin: 0; font-size: var(--text-xl); letter-spacing: -.03em; }
.game { border: 1px solid var(--border-strong); border-radius: 99px; padding: 5px 10px; font-size: var(--text-xs); white-space: nowrap; }
dl { display: grid; gap: 12px; margin: 0; } dt { color: var(--text-secondary); font-size: var(--text-xs); } dd { margin: 4px 0 0; font-size: var(--text-sm); overflow-wrap: anywhere; line-height: 1.5; }
.mono, code, .fingerprint { font-family: var(--font-mono); }
details { border-top: 1px solid var(--border-subtle); padding-top: 14px; } summary { cursor: pointer; font-size: 13px; }
details { border-top: 1px solid var(--border-subtle); padding-top: 14px; } summary { cursor: pointer; font-size: var(--text-sm); }
ul { list-style: none; padding: 0; margin: 12px 0; } li { display: grid; gap: 5px; padding: 12px 0; border-bottom: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
li > div { display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap; } strong { font-size: 13px; font-weight: 550; } span, code, small { font-size: var(--text-xs); } li span, small { color: var(--text-secondary); }
p { color: var(--text-secondary); font-size: 12px; line-height: 1.6; }
li > div { display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap; } strong { font-size: var(--text-sm); font-weight: 550; } span, code, small { font-size: var(--text-xs); } li span, small { color: var(--text-secondary); }
p { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.6; }
</style>
+31 -31
View File
@@ -185,7 +185,7 @@
flex-wrap: wrap;
gap: 6px;
align-items: center;
border-bottom: 1px solid var(--border);
border-bottom: 1px solid var(--border-subtle);
padding-bottom: 8px;
}
@@ -196,46 +196,46 @@
gap: 14px;
}
.meta {
color: var(--ink-500);
font-family: var(--font-mono);
font-size: var(--text-mono-sm);
color: var(--text-tertiary);
font-family: var(--font-body);
font-size: var(--text-xs);
}
.auto {
display: flex;
align-items: center;
gap: 6px;
color: var(--ink-500);
font-family: var(--font-mono);
font-size: var(--text-mono-sm);
color: var(--text-tertiary);
font-family: var(--font-body);
font-size: var(--text-xs);
cursor: pointer;
user-select: none;
}
.auto input { accent-color: var(--accent); }
.auto input { accent-color: var(--accent-primary); }
.refresh {
background: var(--bg-card);
color: var(--ink-100);
border: 1px solid var(--border);
border-radius: var(--r-sm);
background: var(--surface);
color: var(--text-primary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
padding: 6px 12px;
font-family: var(--font-body);
font-size: var(--text-sm);
cursor: pointer;
}
.refresh:hover:not(:disabled) {
border-color: var(--accent);
box-shadow: var(--accent-glow);
border-color: var(--accent-primary);
box-shadow: var(--focus-ring);
}
.refresh:disabled { opacity: 0.4; cursor: default; }
.body {
margin: 0;
padding: 16px 18px;
background: var(--bg-page);
border: 1px solid var(--border);
border-radius: var(--r-sm);
color: var(--ink-300);
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: var(--text-mono);
font-size: var(--text-sm);
line-height: var(--leading-mono);
max-height: 560px;
overflow: auto;
@@ -245,28 +245,28 @@
.empty, .error {
padding: 28px 16px;
text-align: center;
font-family: var(--font-mono);
font-size: var(--text-mono);
border-radius: var(--r-sm);
font-family: var(--font-body);
font-size: var(--text-sm);
border-radius: var(--radius-sm);
}
.empty {
color: var(--ink-500);
background: var(--bg-nav);
border: 1px dashed var(--border);
color: var(--text-tertiary);
background: var(--surface);
border: 1px dashed var(--border-subtle);
}
.empty code {
color: var(--cream-dim);
font-size: var(--text-mono-sm);
color: var(--text-secondary);
font-size: var(--text-xs);
}
.error {
color: var(--danger-500);
color: var(--state-danger);
background: rgba(255, 48, 80, 0.08);
border: 1px solid rgba(255, 48, 80, 0.25);
}
.foot {
color: var(--ink-500);
font-family: var(--font-mono);
font-size: var(--text-mono-sm);
color: var(--text-tertiary);
font-family: var(--font-body);
font-size: var(--text-xs);
text-align: right;
}
@media (max-width: 640px) {
@@ -122,11 +122,11 @@
{/if}
<style>
.game-files { display:grid; gap:5px; } .game-files strong { font-size:13px; font-weight:600; }
.game-files { display:grid; gap:5px; } .game-files strong { font-size: var(--text-sm); font-weight:600; }
.advanced { border-top:1px solid var(--border-subtle); padding-top:16px; }
.advanced summary { color:var(--text-secondary); }
.advanced-fields { padding-top:2px; }
fieldset { margin:18px 0 0; padding:0; border:0; min-width:0; }
legend { padding:0; font-size:13px; font-weight:600; }
.selection { display:grid; gap:14px; padding:20px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface-sunken); } .title { display:flex; gap:12px; justify-content:space-between; align-items:center; } h3 { font-size:var(--text-sm); margin:0; } label { display:grid; gap:7px; font-size:13px; } input { width:100%; box-sizing:border-box; padding:11px; color:var(--text-primary); background:var(--surface); border:1px solid var(--border-control); border-radius:var(--radius-sm); font:inherit; } p { color:var(--text-secondary); font-size:12px; line-height:1.6; margin:0; } a,button { color:var(--accent-primary-hover); } button { background:none; border:0; cursor:pointer; font:inherit; font-size:12px; } button:disabled { opacity:.5; cursor:default; } .error { color:var(--state-danger); } summary { cursor:pointer; font-size:13px; } details label { margin-top:14px; } details p { margin-top:12px; }
legend { padding:0; font-size: var(--text-sm); font-weight:600; }
.selection { display:grid; gap:14px; padding:20px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface-sunken); } .title { display:flex; gap:12px; justify-content:space-between; align-items:center; } h3 { font-size:var(--text-sm); margin:0; } label { display:grid; gap:7px; font-size: var(--text-sm); } input { width:100%; box-sizing:border-box; padding:11px; color:var(--text-primary); background:var(--surface); border:1px solid var(--border-control); border-radius:var(--radius-sm); font:inherit; } p { color:var(--text-secondary); font-size: var(--text-xs); line-height:1.6; margin:0; } a,button { color:var(--accent-primary-hover); } button { background:none; border:0; cursor:pointer; font:inherit; font-size: var(--text-xs); } button:disabled { opacity:.5; cursor:default; } .error { color:var(--state-danger); } summary { cursor:pointer; font-size: var(--text-sm); } details label { margin-top:14px; } details p { margin-top:12px; }
</style>
+1 -1
View File
@@ -14,7 +14,7 @@
const statuses = $fleetServers.map(server => ({ server, state: serverHealth(server, $uiNow).state }));
const servers = statuses.filter(({ server, state }) => !['online', 'starting', 'provisioning'].includes(state)
|| $fleet.operations.some(operation => operation.server === server.name && operation.state === 'attention')).length;
const hosts = new Set(statuses.filter(({ server, state }) => server.host && state === 'host-offline').map(({ server }) => server.host!.id)).size;
const hosts = $fleet.hostAttention ?? new Set(statuses.filter(({ server, state }) => server.host && state === 'host-offline').map(({ server }) => server.host!.id)).size;
const maintenance = $fleet.operations.filter(operation => operation.kind === 'maintenance' && operation.state === 'attention').length;
return { '/': servers, '/hosts': hosts, '/maintenance': maintenance } as Record<string, number>;
});
@@ -47,7 +47,7 @@
<style>
section { display: grid; gap: 16px; }
h2 { font-size: 18px; margin: 0; }
h2 { font-size: var(--text-md); margin: 0; }
details { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 16px; }
summary { cursor: pointer; font-size: var(--text-sm); font-weight: var(--weight-semibold); }
details[open] summary { margin-bottom: 16px; }
@@ -58,11 +58,11 @@
label { display: grid; gap: 6px; font-size: var(--text-xs); color: var(--text-secondary); }
label:first-child { flex: 1; min-width: 180px; }
input, select { background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); padding: 9px 12px; border-radius: var(--radius-sm); font: inherit; }
time, .muted { color: var(--text-secondary); font-size: 13px; }
time, .muted { color: var(--text-secondary); font-size: var(--text-sm); }
p { margin: 0; line-height: 1.6; }
.flow { display: flex; flex-wrap: wrap; gap: 12px; padding: 0; margin: 0; list-style: none; }
.flow li { display: flex; align-items: center; gap: 10px; background: var(--surface); border-radius: var(--radius-md); padding: 12px; font-size: 13px; border: 1px solid var(--border-subtle); color: var(--text-secondary); }
.flow span { color: var(--accent-primary-hover); font-family: var(--font-mono); }
.flow li { display: flex; align-items: center; gap: 10px; background: var(--surface); border-radius: var(--radius-md); padding: 12px; font-size: var(--text-sm); border: 1px solid var(--border-subtle); color: var(--text-secondary); }
.flow span { color: var(--text-primary); font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }
.flow .done { color: var(--text-primary); }
.flow .current { color: var(--text-primary); border-color: var(--accent-primary); }
.flow .failed { color: var(--state-danger); border-color: var(--state-danger); }
+27 -27
View File
@@ -45,10 +45,10 @@
<style>
.wrapper {
background: var(--bg-nav);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: var(--pad-card);
background: var(--surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: var(--space-5);
}
header {
display: flex;
@@ -63,66 +63,66 @@
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--ink-300);
color: var(--text-secondary);
}
.empty {
color: var(--ink-500);
font-family: var(--font-mono);
font-size: var(--text-mono);
color: var(--text-tertiary);
font-family: var(--font-body);
font-size: var(--text-sm);
margin: 0;
padding: 14px 0;
}
.rows { display: grid; gap: 10px; }
.row {
display: block;
color: var(--ink-100);
color: var(--text-primary);
text-decoration: none;
padding: 12px 14px;
background: var(--bg-card);
border-radius: var(--r-sm);
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--radius-sm);
border: 1px solid var(--border-subtle);
transition: border-color 0.15s;
}
.row:hover { border-color: var(--accent); }
.row:hover { border-color: var(--accent-primary); }
.row-head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 8px;
}
.action { font-family: var(--font-mono); font-size: var(--text-mono); color: var(--cream); }
.count { color: var(--ink-500); font-size: var(--text-mono-sm); font-family: var(--font-mono); }
.action { font-family: var(--font-body); font-size: var(--text-sm); color: var(--text-primary); }
.count { color: var(--text-tertiary); font-size: var(--text-xs); font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip {
padding: 3px 9px;
border-radius: var(--r-sm);
font-size: var(--text-mono-sm);
font-family: var(--font-mono);
border-radius: var(--radius-sm);
font-size: var(--text-xs);
font-family: var(--font-body);
border: 1px solid transparent;
}
.chip-done {
background: rgba(124, 212, 255, 0.10);
border-color: rgba(124, 212, 255, 0.3);
color: var(--spark-500);
background: color-mix(in srgb, var(--state-online) 10%, transparent);
border-color: color-mix(in srgb, var(--state-online) 30%, transparent);
color: var(--state-online);
}
.chip-failed {
background: rgba(255, 48, 80, 0.1);
border-color: rgba(255, 48, 80, 0.3);
color: var(--danger-500);
color: var(--state-danger);
}
.chip-pending {
background: rgba(255, 176, 32, 0.1);
border-color: rgba(255, 176, 32, 0.3);
color: var(--amber-500);
color: var(--state-warning);
}
.chip-held {
background: rgba(124, 192, 245, 0.10);
border-color: rgba(124, 192, 245, 0.28);
color: var(--cream);
color: var(--text-primary);
}
.chip-manual {
background: var(--bg-nav);
border-color: var(--border);
color: var(--ink-500);
background: var(--surface);
border-color: var(--border-subtle);
color: var(--text-tertiary);
}
</style>
@@ -51,7 +51,7 @@
<span class="eyebrow">{report.event === 'game_end' ? 'Final scoreboard' : fresh ? 'Live scoreboard' : 'Last reported scoreboard'}</span>
<div class="match-title">
<h2 title={report.map}>{mapLabel ?? mapTitle(report.map)}</h2>
{#if report.event !== 'game_start'}<Badge variant="accent" size="md">Round {report.round}</Badge>{/if}
{#if report.event !== 'game_start'}<Badge size="md">Round {report.round}</Badge>{/if}
{#if displayElapsed}<span class="match-time">Match {displayElapsed}{fresh ? '' : ' at last report'}</span>{/if}
{#if report.event === 'game_end'}<Badge>Game ended</Badge>
{:else if report.event === 'game_start'}<Badge>Game started</Badge>{/if}
@@ -140,8 +140,8 @@
.timing-note { margin: var(--space-2) 0 0; font-size: var(--text-xs); color: var(--text-tertiary); }
.guid { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); margin-top: var(--space-1); color: var(--text-tertiary); font-size: var(--text-xs); }
.stat { text-align: right; white-space: nowrap; }
td.stat { font: var(--text-sm) var(--font-mono); font-variant-numeric: tabular-nums; }
.score { color: var(--accent-primary-hover); font-weight: var(--weight-semibold); }
td.stat { font: var(--text-sm) var(--font-numeric); font-variant-numeric: tabular-nums; }
.score { color: var(--text-primary); font-weight: var(--weight-semibold); }
.unreported { color: var(--text-tertiary); }
tbody tr:last-child > * { border-bottom: none; }
tbody tr:hover > * { background: var(--surface-raised); }
@@ -93,7 +93,7 @@
summary { cursor: pointer; }
.phases summary { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-5); list-style: none; }
.phases summary::-webkit-details-marker { display: none; }
.phases summary::after { content: '+'; color: var(--text-tertiary); font-size: 18px; }
.phases summary::after { content: '+'; color: var(--text-tertiary); font-size: var(--text-md); }
.phases details[open] > summary::after { content: '−'; }
summary:focus-visible { outline: 2px solid var(--border-focus); outline-offset: -3px; border-radius: var(--radius-sm); }
.phase-icon { flex: 0 0 26px; display: grid; place-items: center; width: 26px; height: 26px; border: 1px solid var(--border-strong); border-radius: var(--radius-pill); font-size: var(--text-sm); }
+1 -1
View File
@@ -19,6 +19,6 @@
<style>
.round { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2); }
strong { color: var(--text-primary); font-family: var(--font-mono); }
strong { color: var(--text-primary); font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }
span { color: var(--text-tertiary); font-size: var(--text-xs); }
</style>
+1 -1
View File
@@ -7,6 +7,6 @@
<style>
.identity { display: inline-flex; align-items: baseline; flex-wrap: wrap; gap: 4px 8px; min-width: 0; }
.map { overflow-wrap: anywhere; }
.lobby { white-space: nowrap; font-size: .85em; color: var(--text-secondary); font-weight: var(--weight-medium); }
.lobby { white-space: nowrap; font-size: max(var(--text-xs), .85em); color: var(--text-secondary); font-weight: var(--weight-medium); }
small { font-size: var(--text-xs); color: var(--text-secondary); white-space: nowrap; font-weight: var(--weight-regular); }
</style>
-37
View File
@@ -1,37 +0,0 @@
<script lang="ts">
import type { Health } from '$ui/stores/servers.js';
interface Props {
health: Health;
size?: number;
label?: string | null;
}
let { health, size = 10, label = null }: Props = $props();
</script>
<span
class="dot dot-{health}"
style="width:{size}px;height:{size}px"
aria-label={label ?? `server ${health}`}
title={label ?? health}
></span>
<style>
.dot {
display: inline-block;
border-radius: 999px;
vertical-align: middle;
flex-shrink: 0;
}
.dot-online {
background: var(--spark-500);
animation: electric-pulse 1.6s ease-in-out infinite;
}
.dot-stale, .dot-starting, .dot-provisioning {
background: var(--amber-500);
box-shadow: var(--amber-glow);
}
.dot-offline {
background: var(--danger-500);
opacity: 0.6;
}
</style>
@@ -123,16 +123,17 @@
<style>
.panel { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--space-5); min-width: 0; }
h2 { margin: 0; font-size: var(--text-lg); } .heading { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-4); flex-wrap: wrap; }
.heading > div:first-child { flex:1 1 240px; min-width:0; }
.heading p { margin: var(--space-2) 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
.controls { display:flex; align-items:center; flex-wrap:wrap; gap:12px; }
.controls { display:flex; align-items:center; flex-wrap:wrap; gap:12px; min-width:0; max-width:100%; }
.scope-note { margin:8px 0 0; font-size:var(--text-xs); color:var(--text-secondary); }
.mode { display: flex; gap: 3px; padding: 3px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
.mode button { padding: 7px 14px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--text-xs); cursor: pointer; }
.mode button[aria-pressed='true'] { color: var(--text-primary); background: var(--surface-raised); }
.mode button:focus-visible, .plot:focus-visible, .table-wrap:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
.axis-title { margin-top: var(--space-4); color: var(--text-tertiary); font-size: 11px; }
.axis-title { margin-top: var(--space-4); color: var(--text-tertiary); font-size: var(--text-xs); }
.chart-grid { display: grid; grid-template-columns: 42px minmax(0, 1fr); gap: 10px; margin-top: 14px; }
.y-axis { position: relative; height: 176px; margin-top: 8px; color: var(--text-tertiary); font-size: 11px; }
.y-axis { position: relative; height: 176px; margin-top: 8px; color: var(--text-tertiary); font-size: var(--text-xs); }
.y-axis span { position: absolute; right: 0; transform: translateY(-50%); }
.plot { position: relative; height: 192px; border-radius: 3px; cursor: crosshair; touch-action: pan-y; }
svg { display: block; width: 100%; height: 100%; overflow: visible; }
@@ -141,7 +142,7 @@
.trend { fill: none; stroke: var(--accent-primary); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
.point, .selected-point { fill: var(--accent-primary-hover); } .selected-point { stroke: var(--surface); stroke-width: 2; }
.cursor { stroke: var(--text-tertiary); stroke-width: 1; stroke-dasharray: 4 5; }
.x-axis { grid-column: 2; display: flex; justify-content: space-between; gap: var(--space-4); color: var(--text-tertiary); font-size: 11px; }
.x-axis { grid-column: 2; display: flex; justify-content: space-between; gap: var(--space-4); color: var(--text-tertiary); font-size: var(--text-xs); }
.x-axis span:last-child { text-align: right; }
.empty-chart { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 10px; text-align: center; padding: var(--space-4); pointer-events: none; }
.empty-chart strong { font-size: var(--text-sm); font-weight: var(--weight-medium); } .empty-chart span { color: var(--text-tertiary); font-size: var(--text-xs); }
@@ -150,8 +151,8 @@
dl { display:flex; flex-wrap:wrap; gap:12px 24px; margin:0; } dl>div { display:flex; gap:6px; } dt { color:var(--text-secondary); } dd { margin:0; font-variant-numeric:tabular-nums; }
.note { color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.65; margin: var(--space-4) 0; max-width: 115ch; }
summary { cursor: pointer; color: var(--text-secondary); font-size: var(--text-xs); padding: 4px 0; }
.table-wrap { overflow: auto; max-height: 400px; margin-top: var(--space-3); } table { width: 100%; border-collapse: collapse; font-size: var(--text-xs); }
.table-wrap { position: relative; overflow: auto; max-height: 400px; margin-top: var(--space-3); } table { width: 100%; border-collapse: collapse; font-size: var(--text-xs); }
caption { text-align: left; color: var(--text-tertiary); margin-bottom: var(--space-3); } th, td { text-align: right; padding: 12px; white-space: nowrap; border-bottom: 1px solid var(--border-subtle); font-variant-numeric: tabular-nums; }
th:first-child { text-align: left; } tbody th { font-weight: var(--weight-regular); } thead th { color: var(--text-secondary); }
@media (max-width: 640px) { .panel { padding: var(--space-4); } .chart-grid { grid-template-columns: 32px minmax(0, 1fr); gap: 8px; } .readout { gap: var(--space-4); } dl { gap: var(--space-4); } .x-axis { font-size: 10px; } }
@media (max-width: 640px) { .panel { padding: var(--space-4); } .chart-grid { grid-template-columns: 32px minmax(0, 1fr); gap: 8px; } .readout { gap: var(--space-4); } dl { gap: var(--space-4); } .x-axis { font-size: var(--text-xs); } }
</style>
@@ -14,7 +14,8 @@
}
</script>
<div class="table-wrap"><table>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
<div class="table-wrap" role="region" aria-label="Host capacity comparison" tabindex="0"><table>
<thead><tr><th>Host</th><th>Additional instances</th><th>Limited by</th><th>CPU available</th><th>Memory available</th><th>Free ports</th><th><span class="sr-only">Details</span></th></tr></thead>
<tbody>{#each hosts as host (host.id)}
<tr>
@@ -43,7 +44,8 @@
</table></div>
<style>
.table-wrap { overflow-x:auto; border:1px solid var(--border-subtle); border-radius:var(--radius-md); }
.table-wrap { position:relative; overflow-x:auto; border:1px solid var(--border-subtle); border-radius:var(--radius-md); }
.table-wrap:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
table { width:100%; min-width:800px; border-collapse:collapse; text-align:left; font-size:var(--text-sm); }
th { padding:12px 16px; color:var(--text-secondary); font-size:var(--text-xs); font-weight:500; background:var(--surface-sunken); }
td { padding:14px 16px; border-top:1px solid var(--border-subtle); background:var(--surface); font-variant-numeric:tabular-nums; }
@@ -3,7 +3,7 @@
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
import { createTimeFormatter } from '$lib/time-zone.js';
import { metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
import { metricDuration, metricNumber, metricPercent, metricPeriodLabels } from '$ui/metrics.js';
import Button from '$ui/components/primitives/Button.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import Select from '$ui/components/primitives/Select.svelte';
@@ -32,7 +32,7 @@
const tick = new Intl.NumberFormat('en-US', { maximumSignificantDigits: 3 });
const ticks = $derived([1, .75, .5, .25, 0].map(fraction => ({ fraction, value: `${tick.format(ceiling * fraction)}${mode === 'percent' ? '%' : ''}` })));
const periodOptions = $derived(points.map(point => ({ value: String(point.at), label: `${format.period(point.startAt)}${point.partial ? ' · partial' : ''}` })));
const middle = $derived(points[Math.floor(points.length / 2)]);
const periodLabels = $derived(metricPeriodLabels(points, days, timeZone));
const captured = $derived(selected ? selected.observed + selected.unknown : 0);
function setMode(value: 'hours' | 'percent') {
@@ -65,7 +65,7 @@
</script>
<section class="panel" aria-labelledby="metrics-trend-title">
<div class="heading"><div><h2 id="metrics-trend-title">Instance activity over time</h2><p>{mode === 'percent' ? 'Share of captured server time, including unknown status.' : `Server-hours per ${days === 1 ? 'hour' : '24-hour period'}.`} Partial periods contain less elapsed time.</p></div>
<div class="heading"><div><h2 id="metrics-trend-title">Server activity over time</h2><p>{mode === 'percent' ? 'Share of captured server time, including unknown status.' : `Server-hours per ${days === 1 ? 'hour' : '24-hour period'}.`} Partial periods contain less elapsed time.</p></div>
<div class="mode" role="group" aria-label="Timeline measure"><button type="button" aria-pressed={mode === 'percent'} onclick={() => setMode('percent')}>Percent</button><button type="button" aria-pressed={mode === 'hours'} onclick={() => setMode('hours')}>Hours</button></div>
</div>
<div class="legend"><span><i class="occupied"></i>With players</span><span><i class="idle"></i>Idle</span><span><i class="unknown"></i>Unknown</span><span class="partial-key">* Partial period</span></div>
@@ -88,8 +88,9 @@
</button>
{/each}
</div>
<div class="x-axis" aria-hidden="true"><span>{points[0] ? format.period(points[0].startAt) : ''}</span>{#if middle}<span class="middle">{format.period(middle.startAt)}</span>{/if}<span>{points.length ? format.period(points[points.length - 1].endAt) : ''}</span></div>
<div class="x-axis" class:with-dates={days === 30} aria-hidden="true">{#each periodLabels as item}<span style:left={`${item.position}%`} title={format.period(points[item.index].startAt)}>{item.label}{#if item.date}<small>{item.date}</small>{/if}</span>{/each}</div>
</div>
<p class="period-note">{days === 1 ? 'Hours' : 'Weekdays'} show when each period starts in {timeZone.replaceAll('_', ' ')}.{days === 1 ? '' : ' Daily periods are 24-hour windows.'}</p>
{#if selected}
<div class="selection-summary" aria-label="Selected period summary"><time>{format.period(selected.startAt)}{selected.partial ? ' · Partial period' : ''}</time><span>{captured ? `${metricDuration(selected.active)} with players · ${metricDuration(selected.idle)} idle · ${metricDuration(selected.unknown)} unknown · ${selected.observed ? metricNumber(selected.playerHours) : '—'} player-hours` : 'No captured time · not evidence of idle instances'}</span></div>
<details class="inspection"><summary>Inspect a period</summary>
@@ -125,6 +126,7 @@
.panel { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--space-5); min-width: 0; }
h2 { margin: 0; font-size: var(--text-lg); }
.heading { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
.heading > div:first-child { flex: 1 1 260px; min-width: 0; }
.mode { display: flex; border: 1px solid var(--border-subtle); padding: 3px; border-radius: var(--radius-md); }
.mode button { border: 0; border-radius: var(--radius-sm); padding: 7px 10px; background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--text-xs); cursor: pointer; }
.mode button[aria-pressed='true'] { background: var(--surface-raised); color: var(--text-primary); }
@@ -134,12 +136,12 @@
.legend span { display: flex; align-items: center; gap: 6px; }
.legend i { width: 10px; height: 10px; border-radius: 2px; }
.occupied { background: var(--chart-active); }
.idle { background: var(--chart-idle); }
.idle { background: repeating-linear-gradient(0deg, transparent 0 5px, color-mix(in srgb, var(--text-secondary) 24%, transparent) 5px 6px), var(--chart-idle); }
.unknown { background: repeating-linear-gradient(135deg, var(--chart-unknown-base) 0 3px, var(--chart-unknown-stripe) 3px 5px); }
.partial-key { color: var(--text-secondary); }
.axis-title { margin-top: var(--space-5); font-size: 11px; color: var(--text-tertiary); }
.axis-title { margin-top: var(--space-5); font-size: var(--text-xs); color: var(--text-tertiary); }
.chart-grid { display: grid; grid-template-columns: 44px minmax(0, 1fr); column-gap: 8px; margin-top: 12px; }
.y-axis { position: relative; text-align: right; font-size: 11px; color: var(--text-tertiary); height: 210px; }
.y-axis { position: relative; text-align: right; font-size: var(--text-xs); color: var(--text-tertiary); height: 210px; }
.y-axis span { position: absolute; right: 0; transform: translateY(-50%); }
.plot { display: flex; position: relative; height: 210px; gap: 4px; isolation: isolate; border-bottom: 1px solid var(--border-strong); }
.grid-lines { position: absolute; inset: 0; pointer-events: none; z-index: -1; }
@@ -149,13 +151,14 @@
.bar:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 2px; }
.bar:hover { background: color-mix(in oklab, var(--accent-primary) 8%, transparent); }
.segment { display: block; width: 100%; flex-shrink: 0; pointer-events: none; }
.no-time { color: var(--text-tertiary); font-size: 20px; line-height: 1; }
.no-time { color: var(--text-tertiary); font-size: var(--text-lg); line-height: 1; }
.bar.partial { border-inline: 1px dashed var(--border-strong); }
.partial-marker { position: absolute; top: -17px; left: 50%; transform: translateX(-50%); font-size: 15px; color: var(--text-secondary); }
.x-axis { grid-column: 2; display: flex; justify-content: space-between; gap: 16px; color: var(--text-tertiary); font-size: 10px; margin-top: 10px; }
.x-axis span { max-width: 25ch; }
.x-axis span:last-child { text-align: right; }
.x-axis .middle { text-align: center; }
.partial-marker { position: absolute; top: -17px; left: 50%; transform: translateX(-50%); font-size: var(--text-base); color: var(--text-secondary); }
.x-axis { grid-column: 2; position: relative; min-width: 0; height: 1.6em; color: var(--text-secondary); font-size: var(--text-xs); margin-top: 10px; }
.x-axis.with-dates { height: 3em; }
.x-axis > span { position: absolute; transform: translateX(-50%); text-align: center; white-space: nowrap; }
.x-axis small { margin: 0; }
.period-note { color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); margin: var(--space-2) 0 0; }
.period-controls { display: flex; align-items: flex-end; gap: var(--space-3); flex-wrap: wrap; margin-top: var(--space-5); }
.period-select { flex: 1 1 250px; max-width: 450px; }
.step-buttons { display: flex; gap: var(--space-2); padding-bottom: 3px; }
@@ -165,13 +168,13 @@
dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-4); margin: var(--space-3) 0 0; }
dt { color: var(--text-tertiary); font-size: var(--text-xs); }
dd { margin: var(--space-1) 0 0; font-variant-numeric: tabular-nums; font-size: var(--text-md); }
small { display: block; color: var(--text-tertiary); font-size: 11px; font-weight: normal; line-height: 1.5; }
small { display: block; color: var(--text-tertiary); font-size: var(--text-xs); font-weight: normal; line-height: 1.5; }
.empty { margin: var(--space-3) 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
.denominator { color: var(--text-tertiary); font-size: 11px; line-height: 1.5; margin: var(--space-3) 0 0; }
.denominator { color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.5; margin: var(--space-3) 0 0; }
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
details { margin-top: var(--space-4); font-size: var(--text-sm); }
summary { cursor: pointer; color: var(--text-secondary); padding: 4px 0; }
.table-wrap { max-width: 100%; overflow-x: auto; margin-top: var(--space-3); }
.table-wrap { position: relative; max-width: 100%; overflow-x: auto; margin-top: var(--space-3); }
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
table { width: 100%; border-collapse: collapse; font-size: var(--text-xs); }
caption { text-align: left; color: var(--text-tertiary); margin-bottom: 8px; }
@@ -179,5 +182,5 @@
thead th { color: var(--text-secondary); }
th:first-child { text-align: left; }
tbody th { font-weight: var(--weight-regular); }
@media (max-width: 640px) { .panel { padding: var(--space-4); } .legend { gap: 10px; } .chart-grid { grid-template-columns: 32px minmax(0, 1fr); gap: 6px; } .plot { gap: 3px; } .x-axis .middle { display: none; } .x-axis { font-size: 11px; } dl { grid-template-columns: repeat(2, minmax(0, 1fr)); } .readout { padding: var(--space-3); } }
@media (max-width: 640px) { .panel { padding: var(--space-4); } .legend { gap: 10px; } .chart-grid { grid-template-columns: 32px minmax(0, 1fr); gap: 6px; } .plot { gap: 3px; } dl { grid-template-columns: repeat(2, minmax(0, 1fr)); } .readout { padding: var(--space-3); } .step-buttons { flex-wrap: wrap; } }
</style>
@@ -8,6 +8,7 @@
type Heatmap = MetricsData['heatmap'];
type Cell = Heatmap['cells'][number];
let { heatmap, disabled = false, onTimeZoneChange }: { heatmap: Heatmap; disabled?: boolean; onTimeZoneChange: (value: string) => void } = $props();
const id = $props.id();
const weekdays = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const hours = Array.from({ length: 24 }, (_, hour) => hour);
const zones = $derived([...new Set([...availableTimeZones(), heatmap.timeZone])].sort().map(zone => ({ value: zone, label: zone.replaceAll('_', ' ') })));
@@ -34,8 +35,8 @@
}
</script>
<div class="heatmap">
<div class="heading"><div><h3>Activity by weekday and hour</h3><p>Time with players as a share of known server time, grouped in {heatmap.timeZone.replaceAll('_', ' ')}.</p></div>
<section class="heatmap" aria-labelledby={`${id}-heatmap-title`}>
<div class="heading"><div><h2 id={`${id}-heatmap-title`}>Activity by weekday and hour</h2><p>Time with players as a share of known server time, grouped in {heatmap.timeZone.replaceAll('_', ' ')}.</p></div>
<div class="zone"><Field label="Report timezone"><Select value={heatmap.timeZone} options={zones} {disabled} onchange={event => onTimeZoneChange((event.currentTarget as HTMLSelectElement).value)} /></Field></div>
</div>
<div class="legend"><span>0% <i class="scale"></i> 100% with players</span><span><i class="unknown-example"></i>Unknown only</span><span><i class="missing-example">·</i>No captured time</span></div>
@@ -65,24 +66,25 @@
</div>
{/if}
<span class="sr-only" role="status" aria-live="polite">{announcement}</span>
</div>
</section>
<style>
.heatmap { min-width: 0; margin-top: var(--space-4); }
.heatmap { min-width: 0; padding: var(--space-5); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); }
.heading { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-4); flex-wrap: wrap; }
h3 { margin: 0; font-size: var(--text-md); }
.heading > div:first-child { flex: 1 1 260px; min-width: 0; }
h2 { margin: 0; font-size: var(--text-lg); }
p { font-size: var(--text-xs); color: var(--text-secondary); margin: var(--space-2) 0 0; line-height: 1.6; }
.zone { flex: 0 1 250px; min-width: 190px; }
.legend { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; margin: var(--space-4) 0 var(--space-3); font-size: 11px; color: var(--text-secondary); }
.zone { flex: 0 1 250px; min-width: 0; max-width: 100%; }
.legend { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; margin: var(--space-4) 0 var(--space-3); font-size: var(--text-xs); color: var(--text-secondary); }
.legend span { display: flex; gap: 5px; align-items: center; }
.scale { width: 64px; height: 9px; background: linear-gradient(to right, var(--surface-raised), var(--chart-active)); border-radius: 3px; }
.unknown-example, .missing-example { width: 14px; height: 14px; display: inline-flex; justify-content: center; align-items: center; font-style: normal; }
.unknown-example, .cell.unknown { background: repeating-linear-gradient(135deg, var(--chart-unknown-base) 0 3px, var(--chart-unknown-stripe) 3px 5px); }
.missing-example { border: 1px dashed var(--border-control); }
.table-wrap { overflow-x: auto; max-width: 100%; padding: 3px; }
.table-wrap { position: relative; overflow-x: auto; max-width: 100%; padding: 3px; }
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
table { width: 100%; min-width: 750px; border-collapse: separate; border-spacing: 3px; table-layout: fixed; }
th { color: var(--text-tertiary); font-weight: normal; font-size: 10px; text-align: center; padding: 0 0 3px; }
th { color: var(--text-tertiary); font-weight: normal; font-size: var(--text-xs); text-align: center; padding: 0 0 3px; }
th:first-child { width: 35px; position: sticky; left: 0; background: var(--surface); z-index: 2; text-align: left; }
td { padding: 0; }
.cell { position: relative; appearance: none; display: block; width: 100%; height: 29px; border: 1px solid var(--border-subtle); border-radius: 3px; background: var(--surface-raised); color: var(--text-secondary); font: inherit; cursor: pointer; overflow: hidden; }
@@ -91,14 +93,14 @@
.cell.mixed::after { content: ''; position: absolute; top: 0; right: 0; width: 7px; height: 7px; background: repeating-linear-gradient(135deg, var(--chart-unknown-base) 0 2px, var(--chart-unknown-stripe) 2px 4px); }
.cell[aria-pressed='true'] { outline: 1px solid var(--text-primary); outline-offset: 1px; }
.cell:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 1px; }
.note { color: var(--text-tertiary); font-size: 11px; }
.note { color: var(--text-tertiary); font-size: var(--text-xs); }
.selectors { display: grid; grid-template-columns: repeat(2, minmax(0, 190px)); gap: var(--space-3); margin-top: var(--space-4); }
.readout { margin-top: var(--space-3); padding: var(--space-4); background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
.readout > strong { font-size: var(--text-xs); font-weight: var(--weight-medium); color: var(--text-secondary); }
dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-3); margin: var(--space-3) 0 0; }
dt { font-size: 11px; color: var(--text-tertiary); }
dt { font-size: var(--text-xs); color: var(--text-tertiary); }
dd { margin: 5px 0 0; font-size: var(--text-md); font-variant-numeric: tabular-nums; }
small { display: block; margin-top: 4px; font-size: 11px; color: var(--text-tertiary); }
small { display: block; margin-top: 4px; font-size: var(--text-xs); color: var(--text-tertiary); }
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 640px) { .zone { flex-basis: 100%; } dl { grid-template-columns: repeat(2, minmax(0, 1fr)); } .readout { padding: var(--space-3); } .selectors { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 640px) { .heatmap { padding: var(--space-4); } .zone { flex-basis: 100%; } dl { grid-template-columns: repeat(2, minmax(0, 1fr)); } .readout { padding: var(--space-3); } .selectors { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
</style>
@@ -15,7 +15,7 @@
<div class="summary">
<Card><Stat label="Time with players" value={metricPercent(report.summary.active, report.summary.observed)} size="lg" tween={false} />
<p>{occupancyDelta === null ? 'Comparison unavailable' : `${signed(occupancyDelta, ' pp')} vs ${previousLabel}`}</p>
<p>{occupancyDelta === null ? 'Comparison unavailable' : `${signed(occupancyDelta, ' percentage points')} vs ${previousLabel}`}</p>
<small>{metricDuration(report.summary.active)} of {metricDuration(report.summary.observed)} observed instance time</small>
</Card>
<Card><Stat label="Player-hours" value={report.summary.observed > 0 ? metricNumber(report.summary.playerHours) : '—'} size="lg" tween={false} />
@@ -24,7 +24,7 @@
</Card>
<Card><Stat label="Time all lobbies were full" value={metricPercent(pressure.fullMapHours, pressure.knownMapHours)} size="lg" tween={false} />
<p>{pressure.knownMapHours > 0 ? `${pressure.coveredMaps} of ${pressure.totalMaps} maps have capacity data` : 'Capacity data unavailable'}</p>
<small>Pooled map time · not simultaneous fleet saturation</small>
<small>Combined map history · each map measured separately</small>
</Card>
</div>
{#if prior.partialHistory}<p class="notice">Partial previous history. Comparisons use available observations without extrapolation.</p>{/if}
@@ -117,7 +117,7 @@
.heading { padding: 20px; display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
h2 { font-size: var(--text-lg); margin: 0; }
.search { flex: 0 1 240px; }
.table-wrap { width: 100%; overflow-x: auto; }
.table-wrap { position: relative; width: 100%; overflow-x: auto; }
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: -3px; }
table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
th, td { padding: 12px 16px; border-bottom: 1px solid var(--border-subtle); text-align: right; font-variant-numeric: tabular-nums; vertical-align: top; }
+3 -3
View File
@@ -142,11 +142,11 @@
border-radius: var(--radius-pill);
background: var(--surface-overlay);
color: var(--text-secondary);
font-family: var(--font-mono);
font-family: var(--font-numeric);
font-size: var(--text-xs);
}
font-variant-numeric: tabular-nums; }
.tab.active .badge {
background: var(--accent-primary);
color: var(--text-on-accent);
}
font-variant-numeric: tabular-nums; }
</style>
+14
View File
@@ -1,5 +1,19 @@
const numberFormat = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
/** Label the actual bucket start in the chosen timezone; never imply local calendar-day buckets. */
export function metricPeriodLabels(points: readonly { startAt: number }[], days: 1 | 7 | 30, timeZone: string) {
const stride = days === 7 ? 1 : Math.max(1, Math.ceil(points.length / 6));
const weekday = new Intl.DateTimeFormat('en-US', { timeZone, weekday: 'short' });
const date = new Intl.DateTimeFormat('en-US', { timeZone, day: 'numeric' });
const clock = new Intl.DateTimeFormat('en-US', { timeZone, hour: 'numeric' });
return points.flatMap((point, index) => index % stride ? [] : [{
index,
position: (index + 0.5) / points.length * 100,
label: days === 1 ? clock.format(point.startAt) : weekday.format(point.startAt),
date: days === 30 ? date.format(point.startAt) : ''
}]);
}
export function metricNumber(value: number): string {
if (!Number.isFinite(value)) return '—';
if (value > 0 && value < 0.1) return '<0.1';
+5 -4
View File
@@ -8,7 +8,7 @@ import { type FleetServer, type FleetOperation } from '$lib/fleet-overview.js';
// Each server detail view owns a scoped snapshot; it must not replace the shared fleet.
export function createFleetStore(serverName?: string) {
const fleet = writable<{ servers: FleetServer[]; operations: FleetOperation[]; receivedAt: number }>({
const fleet = writable<{ servers: FleetServer[]; operations: FleetOperation[]; receivedAt: number; hostAttention?: number }>({
servers: [], operations: [], receivedAt: 0
});
const fleetError = writable('');
@@ -64,12 +64,13 @@ export function createFleetStore(serverName?: string) {
});
let snapshotServerNow = 0;
function hydrateFleet(snapshot: { servers: FleetServer[]; operations: FleetOperation[]; integrations: NonNullable<ReturnType<typeof getIntegrations>>; serverNow: number }) {
function hydrateFleet(snapshot: { servers: FleetServer[]; operations: FleetOperation[]; integrations: NonNullable<ReturnType<typeof getIntegrations>>; serverNow: number; hostAttention?: number }) {
if (!browser || snapshot.serverNow < snapshotServerNow) return;
snapshotServerNow = snapshot.serverNow;
syncServerTime(snapshot.serverNow);
fleetIntegrations.set(snapshot.integrations);
fleet.set({ servers: snapshot.servers, operations: snapshot.operations, receivedAt: Date.now() - Math.max(0, serverTimeNow() - snapshot.serverNow) });
fleet.set({ servers: snapshot.servers, operations: snapshot.operations, hostAttention: snapshot.hostAttention,
receivedAt: Date.now() - Math.max(0, serverTimeNow() - snapshot.serverNow) });
}
function getIntegrations() { return get(fleetIntegrations); }
@@ -92,7 +93,7 @@ export function createFleetStore(serverName?: string) {
if (servers.serverNow < snapshotServerNow) return;
snapshotServerNow = servers.serverNow;
syncServerTime(servers.serverNow); fleetIntegrations.set(servers.integrations);
fleet.update(value => ({ ...value, servers: servers.servers, receivedAt: requestedAt,
fleet.update(value => ({ ...value, servers: servers.servers, receivedAt: requestedAt, hostAttention: servers.hostAttention,
operations: serverName === undefined ? value.operations : servers.operations }));
})(),
(async () => {
+6 -85
View File
@@ -1,22 +1,18 @@
/* Electric Cherry — design tokens.
This file is the single source of truth for color, type, spacing, motion,
and elevation. It's organized in three layers:
and elevation. It's organized in two layers:
1. RAW RAMPS — the actual color/type/space values. Never referenced
directly by components.
2. SEMANTIC ALIASES — what components should reference (--surface,
--text-primary, --state-online, --space-4, etc.). Swapping the palette
or spacing rhythm later is a single-file edit.
3. LEGACY ALIASES — old names (--bg-page, --accent, --ink-300, --pad-card…)
mapped onto new semantic values. Kept alive so existing components render
during the phased redesign; each page rewrite removes its usage, and
this block shrinks over time.
Design principles this file encodes:
· One accent (blue). Cyan/amber/red/green mean state only.
· Depth by light, not lines. One ambient shadow + subtle translucency.
· One typeface (Inter). Mono for data. Display face lives inside BrandMark.
· One typeface (Inter). Inter tabular figures for metrics; mono for code and logs. Display face lives inside BrandMark.
· Motion tokens standardize easings + durations across the product. */
:root {
@@ -65,7 +61,7 @@
--text-primary: var(--gray-050);
--text-secondary: var(--gray-300);
--text-tertiary: #98a4b8;
--text-quaternary: var(--gray-400);
--text-quaternary: #8c96a9;
--text-on-accent: #08111f;
/* Border — hairlines, no more. */
@@ -82,11 +78,11 @@
/* Metrics: unknown uses a pattern as well as color. */
--chart-active: var(--accent-primary);
--chart-idle: #7b9baf;
--chart-idle: #2b3442;
--chart-unknown-base: #605b64;
--chart-unknown-stripe: #a7a0ad;
/* Typography — Inter everywhere, mono for data.
/* Typography — Inter for text and metrics; mono for code, IDs and logs.
Display face lives inside BrandMark; no global --font-display token. */
--font-body: 'Inter', system-ui, -apple-system, sans-serif;
--font-numeric: 'Inter', system-ui, -apple-system, sans-serif;
@@ -95,6 +91,7 @@
/* Type scale — cleaner ramp anchored at 15px body. */
--text-xs: 12px;
--text-sm: 13px;
--text-input: 16px; /* Prevent mobile browser zoom when a field receives focus. */
--text-base: 15px;
--text-md: 17px;
--text-lg: 20px;
@@ -156,72 +153,6 @@
/* Focus ring — 2px accent, 2px offset, everywhere. */
--focus-ring: 0 0 0 2px var(--surface), 0 0 0 4px var(--accent-primary);
/* ---------- 3. LEGACY ALIASES ----------
Old token names, mapped onto new semantic values so components that
haven't been rewritten yet keep rendering. Delete entries as pages
migrate; the block should be empty at the end of Phase 8. */
/* Old blue ramp names */
--blue-975: var(--gray-975);
--blue-925: var(--gray-950);
--blue-875: var(--gray-925);
--blue-825: var(--gray-850);
--blue-600: #256cae;
--blue-400: var(--blue-300);
/* Old spark/cherry/amber names */
--spark-500: var(--state-online);
--spark-400: #a8e5ff;
--spark-glow: 0 0 8px rgba(124, 212, 255, 0.45);
--spark-glow-lg: 0 0 14px rgba(124, 212, 255, 0.35);
--cherry-500: var(--state-danger);
--cherry-400: #ff7590;
--cherry-300: #ff9db0;
--amber-500: var(--state-warning);
--amber-glow: 0 0 8px rgba(255, 176, 32, 0.32);
--danger-500: var(--state-danger);
--success-500: #4ade80;
/* Old neutral names */
--cream: var(--text-primary);
--cream-dim: var(--gray-200);
--ink-100: var(--text-primary);
--ink-300: var(--text-secondary);
--ink-500: var(--text-tertiary);
--ink-700: var(--text-quaternary);
/* Old surface/border/accent aliases */
--bg-page: var(--surface-sunken);
--bg-nav: var(--surface);
--bg-card: var(--surface);
--bg-card-hi: var(--surface-raised);
--border: var(--border-subtle);
--border-hi: var(--border-strong);
--accent: var(--accent-primary);
--accent-hi: var(--accent-primary-hover);
--accent-glow: 0 0 0 1px rgba(46, 134, 209, 0.35);
--accent-glow-lg: 0 0 0 1px rgba(46, 134, 209, 0.28);
/* Old typography names */
--font-display: 'Alfa Slab One', 'Georgia', serif;
--text-mono: 13px;
--text-mono-sm: 12px;
--tracking-display: 0.12em;
--tracking-nav: 0.03em;
/* Old spacing/radii names */
--pad-card: var(--space-5);
--pad-panel: var(--space-6);
--pad-nav-y: var(--space-4);
--pad-nav-x: var(--space-8);
--gap-section: var(--space-8);
--gap-cards: var(--space-4);
--r-sm: var(--radius-sm);
--r-md: var(--radius-md);
--r-lg: var(--radius-lg);
--shadow-card: var(--shadow-ambient);
--shadow-panel: var(--shadow-raised);
}
/* Motion is reserved for active work and changes. */
@@ -231,16 +162,6 @@
to { background-position: 200% 0; }
}
/* Legacy — retained until ServerCard/BrandMark/StatusDot are replaced. */
@keyframes electric-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(124, 212, 255, 0.5); }
50% { box-shadow: 0 0 0 5px rgba(124, 212, 255, 0); }
}
@keyframes spark-slide {
from { background-position: -100% 0; }
to { background-position: 200% 0; }
}
/* Reduced motion — neutralize all animation. Components that respect this
token get calm behavior for free; the media query below handles the
ones that don't. */
+2 -2
View File
@@ -302,11 +302,11 @@
}
@media (max-width:720px) {
.app-header { padding-inline: var(--space-2); gap: var(--space-1); }
.brand-link :global(.mark-sm .word) { font-size:12px; letter-spacing:1px; }
.brand-link :global(.mark-sm .word) { font-size: var(--text-xs); letter-spacing:1px; }
.search-button { flex: 0 0 44px; width: 44px; height: 44px; padding: 0; justify-content: center; border: 0; }
.search-label, .help-button { display: none; }
main { padding:var(--space-4) 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(input:not([type='checkbox']):not([type='radio'])),:global(select),:global(textarea) { font-size: var(--text-input) !important; min-height:44px; }
:global(button),:global(.btn) { min-height:44px; }
}
@media (max-width:360px) { .brand-link :global(.mark-sm .word) { display: none; } .brand-link::after { content: 'EC'; margin-left: var(--space-2); color: var(--text-primary); font-size: var(--text-base); font-weight: var(--weight-semibold); } .header-actions { gap:4px; } }
+10 -5
View File
@@ -5,6 +5,7 @@
import { pollVisible } from '$ui/poll.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import PolicyCard from '$ui/components/autoscaling/PolicyCard.svelte';
import { zombieMaps, zombieMapBySlug } from '$lib/map-aliases.js';
import type { ScalingSettings, ScalingPolicy, ScalingReport } from '$lib/autoscaling.js';
@@ -85,7 +86,7 @@
<svelte:head><title>Autoscaling | Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Autoscaling" subtitle="Keep empty lobbies available for new groups, within the limits you choose.">
{#snippet actions()}<Button onclick={() => refresh()}>Refresh</Button>{#if canOperate}<Button disabled={busy} onclick={pauseAll}>{report.globalPaused ? 'Resume autoscaling' : 'Pause all'}</Button><Button variant="primary" disabled={busy} onclick={() => edit(null)}>Add policy</Button>{/if}{/snippet}
{#snippet actions()}<Button onclick={() => refresh()}>Refresh</Button>{#if canOperate && report.policies.length}<Button disabled={busy} onclick={pauseAll}>{report.globalPaused ? 'Resume autoscaling' : 'Pause all'}</Button><Button variant="primary" disabled={busy} onclick={() => edit(null)}>Add policy</Button>{/if}{/snippet}
</SectionHeader>
{#if report.globalPaused}<p class="banner">Autoscaling is paused globally. Outstanding jobs are still tracked and may finish.</p>{/if}
{#if refreshError}<p role="alert">{refreshError}</p>{/if}
@@ -112,15 +113,19 @@
</form>
</section>
{/if}
{#if !report.policies.length}<p class="panel">No autoscaling policies. Add a policy to manage empty lobbies for a map or a whole game.</p>{/if}
{#if !report.policies.length && !editorOpen}
<EmptyState title="No autoscaling policies" description={canOperate ? 'Policies keep empty lobbies available for a map or a whole game. Add one to choose its limits and spare lobby target.' : 'Policies keep empty lobbies available for a map or a whole game. An operator can add a policy.'}>
{#snippet actions()}{#if canOperate}<Button variant="primary" disabled={busy} onclick={() => edit(null)}>Add policy</Button>{/if}{/snippet}
</EmptyState>
{/if}
{#each report.policies as view (view.policy.id)}
<PolicyCard {view} globallyPaused={report.globalPaused} {canOperate} {busy} onedit={edit} ontoggle={togglePolicy} ondelete={remove} />
{/each}
</div>
<style>
.page { max-width: 1200px; margin: 0 auto; padding: 24px; }
.panel { margin: 20px 0; padding: 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow-wrap: anywhere; }
.page { display: grid; gap: var(--space-5); min-width: 0; }
.panel { padding: 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow-wrap: anywhere; }
.actions { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
h2 { font-size: var(--text-lg); margin: 0; }
p { line-height: 1.6; }
@@ -129,5 +134,5 @@
input, select { padding: 10px; min-width: 0; color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); font: inherit; }
fieldset { border: 0; padding: 0; margin: 16px 0; min-width: 0; }
.banner { padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
@media (max-width: 500px) { .page { padding: 16px; } .panel { padding: 16px; } }
@media (max-width: 500px) { .panel { padding: 16px; } }
</style>
+5 -3
View File
@@ -93,7 +93,7 @@
<svelte:head><title>Betas · Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Betas">
{#snippet actions()}<Button variant="primary" href="/betas/new">New beta</Button>{/snippet}
{#snippet actions()}{#if loading || betas.length || error}<Button variant="primary" href="/betas/new">New beta</Button>{/if}{/snippet}
</SectionHeader>
<div class="toolbar">
<div class="view-tabs"><Tabs {tabs} bind:value={tab} onchange={() => { pageNumber = 1; status = ''; saveContext(); }} idPrefix="beta-tabs" panelId="beta-list" ariaLabel="Beta views" /></div>
@@ -103,12 +103,14 @@
{#if tab === 'active'}<option value="ready">Ready to promote</option><option value="testing">Testing</option><option value="attention">Needs attention</option><option value="paused">Paused</option>
{:else}<option value="promoted">Promoted</option><option value="rolled-back">Rolled back</option>{/if}
</select></label>
{#if search || status}<Button variant="ghost" onclick={() => { search = ''; status = ''; pageNumber = 1; saveContext(); }}>Clear filters</Button>{/if}
{#if betas.length && (search || status)}<Button variant="ghost" onclick={() => { search = ''; status = ''; pageNumber = 1; saveContext(); }}>Clear filters</Button>{/if}
</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}
<div id="beta-list" role="tabpanel" aria-labelledby={'beta-tabs-' + tab}>
{#if loading}<EmptyState title="Loading betas…" />
{:else if !betas.length && !error}<EmptyState title={search || status ? 'No matching betas' : tab === 'active' ? 'No active betas' : 'No beta history'} description={search || status ? 'Try another search or clear the filters.' : tab === 'active' ? 'Start a beta when you have a version ready to test.' : undefined} />
{:else if !betas.length && !error}<EmptyState title={search || status ? 'No matching betas' : tab === 'active' ? 'No active betas' : 'No beta history'} description={search || status ? 'Try another search or clear the filters.' : tab === 'active' ? 'Betas test file versions on selected servers before promotion. Choose the versions and servers to begin.' : 'Completed and rolled-back betas appear here. Start a beta to test a file version before promotion.'}>
{#snippet actions()}{#if search || status}<Button onclick={() => { search = ''; status = ''; pageNumber = 1; saveContext(); }}>Clear filters</Button>{:else}<Button variant="primary" href="/betas/new">New beta</Button>{/if}{/snippet}
</EmptyState>
{:else}
<div class="table-scroll"><table>
<thead><tr><th scope="col">Beta</th><th scope="col">Files</th><th scope="col">Status</th><th scope="col" class="started">Started</th></tr></thead>
+3 -3
View File
@@ -256,14 +256,14 @@
<style>
.revision-list { display:grid; gap:20px; margin-top:16px; }
.page { display:grid; gap:24px; min-width:0; }
h2 { margin:0; font-size:18px; } p { line-height:1.6; overflow-wrap:anywhere; }
h2 { margin:0; font-size: var(--text-md); } p { line-height:1.6; overflow-wrap:anywhere; }
a { color:var(--accent-primary-hover); }
.decision { background:var(--surface); border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:20px 24px; }
.decision > p { margin:12px 0 0; }
.summary-heading,.actions,.timing { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
.summary-heading { justify-content:space-between; }
.summary-count { color:var(--text-secondary); font-size:var(--text-sm); margin:6px 0 0; }
.timing,.muted { color:var(--text-secondary); font-size:13px; }.timing { margin-top:16px; }
.timing,.muted { color:var(--text-secondary); font-size: var(--text-sm); }.timing { margin-top:16px; }
.reason { border-left:3px solid var(--state-warning); padding-left:12px; }
.deadline { margin-top:12px; font-size:var(--text-sm); color:var(--text-secondary); }.deadline p { max-width:760px; }.deadline.urgent > summary { color:var(--state-warning); }
.more-actions { position:relative; }.more-actions > summary { padding:10px 12px; color:var(--text-secondary); font-size:var(--text-sm); }
@@ -272,7 +272,7 @@
.text-button { background:transparent; border:0; color:var(--text-secondary); font:inherit; cursor:pointer; }
.file-list { display:grid; gap:12px; margin-top:16px; }
.file-list > div { display:flex; flex-wrap:wrap; gap:12px; align-items:center; }
.file-list code { overflow-wrap:anywhere; } .file-list li { margin:10px 0; } .file-list li span { margin-left:12px; font-size:12px; color:var(--text-secondary); }
.file-list code { overflow-wrap:anywhere; } .file-list li { margin:10px 0; } .file-list li span { margin-left:12px; font-size: var(--text-xs); color:var(--text-secondary); }
details > summary { cursor:pointer; font-weight:600; } #test-servers { scroll-margin-top:80px; min-width:0; }
@media(max-width:720px) { table { min-width:620px; }.decision { padding:16px; }.timing { align-items:flex-start; flex-direction:column; }.action-menu { right:auto; left:0; } }
</style>
+6 -6
View File
@@ -266,13 +266,13 @@
<style>
.beta-form input:not([type='checkbox']):not([type='radio']), .beta-form select { border-color:var(--border-control); }
.page,.beta-form { display:grid; gap:24px; } h2,h3,p { margin:0; } h2 { font-size:20px; } h3 { font-size:16px; } p,li { font-size:14px; line-height:1.6; } .muted,.draft-status { color:var(--text-secondary); font-size:13px; }
.page,.beta-form { display:grid; gap:24px; } h2,h3,p { margin:0; } h2 { font-size: var(--text-lg); } h3 { font-size: var(--text-md); } p,li { font-size: var(--text-base); line-height:1.6; } .muted,.draft-status { color:var(--text-secondary); font-size: var(--text-sm); }
.steps { display:flex; list-style:none; gap:16px; padding:0; margin:0; flex-wrap:wrap; } .steps li { display:flex; align-items:center; gap:8px; color:var(--text-secondary); } .steps li span { border:1px solid var(--border-strong); border-radius:50%; padding:3px 9px; } .steps .current { color:var(--text-primary); font-weight:600; } .steps .current span { background:var(--accent-primary); color:var(--text-on-accent); }
.picks { display:grid; gap:16px; } .pick-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto; gap:12px; align-items:end; padding:16px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); } .scope { grid-column:1/-1; font-size:13px; color:var(--text-secondary); }
label { display:grid; gap:6px; font-size:13px; color:var(--text-secondary); min-width:0; } input:not([type='checkbox']):not([type='radio']),select { width:100%; min-height:40px; padding:9px 12px; box-sizing:border-box; border:1px solid var(--border-strong); border-radius:6px; color:var(--text-primary); background:var(--surface-sunken); font:inherit; } a,.text-action { color:var(--accent-primary-hover); } .text-action { background:none; border:0; text-decoration:underline; cursor:pointer; }
.automatic-selection,.advanced-body { display:grid; gap:16px; } .instance-count { max-width:400px; } .advanced-selection { border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:16px; } .advanced-selection summary { cursor:pointer; font-size:14px; } .advanced-body { margin-top:16px; } .selection-mode { display:grid; gap:12px; } .selection-mode label { display:flex; align-items:center; gap:8px; cursor:pointer; } .selection-mode input { accent-color:var(--accent-primary); }
.picks { display:grid; gap:16px; } .pick-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto; gap:12px; align-items:end; padding:16px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); } .scope { grid-column:1/-1; font-size: var(--text-sm); color:var(--text-secondary); }
label { display:grid; gap:6px; font-size: var(--text-sm); color:var(--text-secondary); min-width:0; } input:not([type='checkbox']):not([type='radio']),select { width:100%; min-height:40px; padding:9px 12px; box-sizing:border-box; border:1px solid var(--border-strong); border-radius:6px; color:var(--text-primary); background:var(--surface-sunken); font:inherit; } a,.text-action { color:var(--accent-primary-hover); } .text-action { background:none; border:0; text-decoration:underline; cursor:pointer; }
.automatic-selection,.advanced-body { display:grid; gap:16px; } .instance-count { max-width:400px; } .advanced-selection { border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:16px; } .advanced-selection summary { cursor:pointer; font-size: var(--text-base); } .advanced-body { margin-top:16px; } .selection-mode { display:grid; gap:12px; } .selection-mode label { display:flex; align-items:center; gap:8px; cursor:pointer; } .selection-mode input { accent-color:var(--accent-primary); }
.filters,.actions,.draft-status,.review-heading { display:flex; align-items:center; flex-wrap:wrap; gap:12px; } .filters { align-items:end; } .filters label { flex:1 1 180px; } .draft-status,.review-heading { justify-content:space-between; } .draft { display:grid; gap:16px; }
fieldset { border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:16px; min-width:0; } legend { font-size:14px; padding:0 8px; } .server-picker { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(300px,100%),1fr)); gap:12px; } .server-choice { display:flex; align-items:center; min-height:48px; gap:10px; color:var(--text-primary); cursor:pointer; } .server-choice small { display:block; color:var(--text-secondary); font-size:12px; margin-top:4px; } .server-choice input { accent-color:var(--accent-primary); width:18px; height:18px; flex-shrink:0; }
fieldset { border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:16px; min-width:0; } legend { font-size: var(--text-base); padding:0 8px; } .server-picker { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(300px,100%),1fr)); gap:12px; } .server-choice { display:flex; align-items:center; min-height:48px; gap:10px; color:var(--text-primary); cursor:pointer; } .server-choice small { display:block; color:var(--text-secondary); font-size: var(--text-xs); margin-top:4px; } .server-choice input { accent-color:var(--accent-primary); width:18px; height:18px; flex-shrink:0; }
.review { display:grid; gap:16px; } .review ul { margin:0; } .review li { margin-bottom:8px; } .notice { padding:12px; border:1px solid var(--border-strong); border-radius:var(--radius-md); } .ui-sticky-actions p { margin-right:auto; flex:1 1 200px; }
@media(max-width:700px) { .pick-row { grid-template-columns:minmax(0,1fr) auto; } .pick-row label:first-child { grid-column:1/-1; } .steps { gap:10px; } .steps li { font-size:12px; } }
@media(max-width:700px) { .pick-row { grid-template-columns:minmax(0,1fr) auto; } .pick-row label:first-child { grid-column:1/-1; } .steps { gap:10px; } .steps li { font-size: var(--text-xs); } }
</style>
+1 -1
View File
@@ -384,7 +384,7 @@
.file-row:hover:not(:disabled) { background: var(--surface-raised); }
.file-row:disabled { cursor: default; }
.path { font-family: var(--font-mono); font-size: var(--text-sm); overflow-wrap: anywhere; }
.sizes { margin-left: auto; font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-tertiary); white-space: nowrap; }
.sizes { margin-left: auto; font-family: var(--font-numeric); font-size: var(--text-xs); color: var(--text-tertiary); white-space: nowrap; font-variant-numeric: tabular-nums; }
.loading { text-align: center; padding: var(--space-16); color: var(--text-tertiary); }
@media (max-width: 720px) {
.outcome { padding: 16px; }
+11 -8
View File
@@ -12,6 +12,7 @@
import Select from '$ui/components/primitives/Select.svelte';
import CapacityHosts from '$ui/components/capacity/CapacityHosts.svelte';
import CapacityChart from '$ui/components/capacity/CapacityChart.svelte';
import Freshness from '$ui/components/Freshness.svelte';
let { data }: { data: CapacityData } = $props();
let snapshot = $state<CapacityData | null>(null);
@@ -19,6 +20,7 @@
let navigating = $state(false);
let refreshError = $state('');
let navigationError = $state('');
let checkedAt = $state(untrack(() => Date.now()));
let request: AbortController | null = null;
let sequence = 0;
const report = $derived(snapshot ?? data);
@@ -26,7 +28,7 @@
const scopeName = $derived(report.query.hostId ? report.current.hostOptions.find(host => host.id === report.query.hostId)?.name ?? 'Selected host' : 'All hosts');
const hostOptions = $derived([{ value: '', label: 'All hosts' }, ...report.current.hostOptions.map(host => ({ value: host.id, label: host.name })),
...(report.query.hostId && !report.current.hostOptions.some(host => host.id === report.query.hostId) ? [{ value: report.query.hostId, label: 'Unavailable host' }] : [])]);
const gameOptions = [{ value: 'all', label: 'All games' }, { value: 't5', label: 'Black Ops I (T5)' }, { value: 't6', label: 'Black Ops II (T6)' }];
const gameOptions = [{ value: 'all', label: 'All games' }, { value: 't5', label: 'Black Ops I' }, { value: 't6', label: 'Black Ops II' }];
const ranges: { value: CapacityRange; label: string }[] = [{ value: '1h', label: 'Last hour' }, { value: '24h', label: '24 hours' }, { value: '7d', label: '7 days' }];
const numbers = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
function count(value: number | null) { return value === null ? '—' : numbers.format(value); }
@@ -54,7 +56,7 @@
});
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not refresh capacity.');
if (current === sequence) { snapshot = body; refreshError = ''; }
if (current === sequence) { snapshot = body; refreshError = ''; checkedAt = Date.now(); }
} catch (cause) {
if (current === sequence && !signal?.aborted && !request?.signal.aborted) refreshError = (cause as Error).message;
return false;
@@ -67,7 +69,7 @@
}
beforeNavigate(() => { cancel(); navigating = true; });
afterNavigate(() => { navigating = false; });
$effect(() => { void data; untrack(() => { cancel(); snapshot = null; refreshError = ''; }); });
$effect(() => { void data; untrack(() => { cancel(); snapshot = null; refreshError = ''; checkedAt = Date.now(); }); });
onMount(() => { const stop = pollVisible(refresh, 30_000, false); return () => { stop(); cancel(); }; });
</script>
@@ -76,11 +78,11 @@
<div class="page" aria-busy={navigating}>
<SectionHeader title="Capacity" />
{#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>{#if refreshError}<Button size="sm" disabled={refreshing || navigating} onclick={() => void refresh()}>Retry</Button>{/if}</div>{/if}
<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>
<Field label="Launch game"><Select value={report.query.game} options={gameOptions} disabled={navigating} onchange={event => void changeScope({ game: (event.currentTarget as HTMLSelectElement).value as CapacityGame })} /></Field></div>
<div class="freshness"><span>{report.current.updatedAt ? `Updated ${$timeFormat.minuteTime(report.current.updatedAt)}` : 'Waiting for host reports'}</span><Button variant="ghost" size="sm" disabled={refreshing || navigating} onclick={() => void refresh()}>{refreshing ? 'Refreshing…' : 'Refresh'}</Button></div>
<div class="freshness">{#if navigating}<span role="status">Updating capacity…</span>{:else if !refreshError}<Freshness updatedAt={checkedAt} intervalLabel="Updates every 30 seconds" />{/if}</div>
</div>
<section class="overview" aria-labelledby="capacity-now-title">
<div class="overview-heading"><div>
@@ -94,7 +96,7 @@
<div><dt>CPU available</dt><dd>{count(summary.cpuCoresAvailable)} cores</dd></div>
<div><dt>Memory available</dt><dd>{memory(summary.memoryBytesAvailable)} GiB</dd></div>
<div><dt>Free ports</dt><dd>{count(summary.portsAvailable)}</dd></div>
<div><dt>Startup slots · Fleet-wide</dt><dd>{summary.startupSlots === null ? 'Unavailable' : `${count(summary.startupSlots)}${summary.startupLimit === null ? '' : ` of ${summary.startupLimit}`} available`}</dd></div>
<div><dt>Concurrent starts · Fleet-wide</dt><dd>{summary.startupSlots === null ? 'Unavailable' : `${count(summary.startupSlots)}${summary.startupLimit === null ? '' : ` of ${summary.startupLimit}`} available`}</dd></div>
</dl>
</section>
{#if report.current.warnings.length}<aside class="warnings" aria-label="Capacity notices">{#each report.current.warnings as warning}<p>{warning}</p>{/each}</aside>{/if}
@@ -135,9 +137,10 @@
.empty-hosts h3 { margin:0; font-size:var(--text-md); } .empty-hosts p { color:var(--text-secondary); font-size:var(--text-sm); }
summary { cursor:pointer; color:var(--text-secondary); font-size:var(--text-xs); padding:4px 0; }
.methodology p { color:var(--text-secondary); font-size:var(--text-xs); line-height:1.7; max-width:110ch; }
.ranges { display:flex; gap:3px; padding:3px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); }
.ranges { display:flex; flex-wrap:wrap; gap:3px; padding:3px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); }
.ranges a { padding:7px 12px; border-radius:var(--radius-sm); color:var(--text-secondary); text-decoration:none; font-size:var(--text-xs); white-space:nowrap; }
.ranges a[aria-current] { color:var(--text-primary); background:var(--surface-raised); }
a:focus-visible,summary:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
@media(max-width:600px) { .filters { width:100%; grid-template-columns:1fr 1fr; } .overview { padding:16px; } .resources { gap:16px 24px; } }
@media(max-width:600px) { .filters { width:100%; grid-template-columns:repeat(2,minmax(0,1fr)); } .overview { padding:16px; } .resources { gap:16px 24px; } }
@media(max-width:360px) { .filters { grid-template-columns:minmax(0,1fr); } }
</style>
+14 -10
View File
@@ -107,7 +107,7 @@
<svelte:head><title>Collections | Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Collections" subtitle="Save your lineup, preview fleet changes, and follow launch progress and history.">
{#snippet actions()}{#if canOperate}<Button variant="primary" onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create collection</Button>{/if}{/snippet}
{#snippet actions()}{#if canOperate && (blueprintsLoading || blueprints.length || blueprintError)}<Button variant="primary" onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create collection</Button>{/if}{/snippet}
</SectionHeader>
{#if canOperate}<BlueprintBuilder bind:open={builderOpen} blueprint={editingBlueprint} onsaved={blueprintSaved} />
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />{/if}
@@ -118,7 +118,9 @@
<h2>Your collections <span>{blueprints.length}</span></h2>
{#if blueprintError}<p role="alert" class="error">{blueprintError} <button onclick={loadBlueprints}>Retry</button></p>{/if}
{#if blueprintsLoading}<p>Loading collections…</p>
{:else if !blueprints.length}<div class="blueprint-empty"><strong>No collections yet.</strong><p>Collections define the maps and lobby counts for a fleet. Operators can create and launch them.</p>{#if canOperate}<button onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create your first collection →</button>{/if}</div>
{:else if !blueprints.length && !blueprintError}<EmptyState headingLevel={3} title="No collections yet" description={canOperate ? 'Collections save maps and lobby counts for your fleet. Create one to preview and launch that lineup.' : 'Collections save maps and lobby counts for your fleet. An operator can create and launch them.'}>
{#snippet actions()}{#if canOperate}<Button variant="primary" onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create collection</Button>{/if}{/snippet}
</EmptyState>
{:else}
<div class="blueprints">
{#each blueprints.slice(0,limit) as blueprint (blueprint.id)}
@@ -156,7 +158,9 @@
</a>
{/each}</div>
{:else if !launchError}
<EmptyState title={tab === 'active' ? 'No collection launches in flight' : 'No collection launch history'} description={tab === 'active' ? 'Preview fleet changes from Your collections to start a launch.' : 'Completed and cancelled launches, including those needing attention, appear here.'} />
<EmptyState headingLevel={3} title={tab === 'active' ? 'No collection launches in flight' : 'No collection launch history'} description={tab === 'active' ? 'Preview fleet changes from Your collections to start a launch.' : 'Completed and cancelled launches, including those needing attention, appear here.'}>
{#snippet actions()}<Button onclick={() => { tab = 'collections'; saveContext(); }}>View collections</Button>{/snippet}
</EmptyState>
{/if}
<ListFooter total={visibleLaunches.length} bind:limit={launchLimit} onchange={() => saveContext()} step={10} label="launches" />
</section>
@@ -167,7 +171,7 @@
.page { display: flex; flex-direction: column; gap: 28px; }
.panel { display: grid; gap: 16px; }
section { display: grid; gap: 16px; }
h2 { font-size: 18px; margin: 0; } h2 span { opacity: .6; margin-left: 8px; }
h2 { font-size: var(--text-md); margin: 0; } h2 span { opacity: .6; margin-left: 8px; }
p { margin: 8px 0; }
.error { color: var(--state-danger); }
.launches { display: grid; gap: 12px; }
@@ -175,15 +179,15 @@
.collection-row:hover { border-color: var(--border-strong); background: var(--surface-raised); }
.collection-row:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; }
.row-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.collection-counts { display: flex; flex-wrap: wrap; gap: 10px 20px; font-size: 13px; color: var(--text-secondary); }
.collection-counts { display: flex; flex-wrap: wrap; gap: 10px 20px; font-size: var(--text-sm); color: var(--text-secondary); }
.collection-counts strong { color: var(--state-online); font-weight: 600; font-variant-numeric: tabular-nums; }
.collection-meter { display: flex; height: 4px; background: var(--surface-raised); overflow: hidden; border-radius: var(--radius-pill); }
.collection-meter .verified { background: var(--state-online); }.collection-meter .unsuccessful { background: var(--state-danger); }.collection-meter .cancelled { background: var(--text-tertiary); }
.collection-footer { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 8px 16px; font-size: 12px; }
.collection-footer { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 8px 16px; font-size: var(--text-xs); }
.meta { color: var(--text-secondary); }.collection-link { color: var(--accent-primary-hover); }
.blueprint p, .blueprint-empty p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
.blueprint-lineup { list-style: none; padding: 0; margin: 8px 0; display: grid; gap: 9px; font-size: 13px; }.blueprint-lineup strong { color: var(--accent-primary-hover); margin-right: 5px; }.blueprint-lineup small { color: var(--text-secondary); margin-left: 6px; }.edit { background: none; border: 0; color: var(--accent-primary-hover); font: inherit; font-size: 12px; cursor: pointer; }.blueprint-footer { flex-wrap: wrap; }
.blueprint p { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; }
.blueprint-lineup { list-style: none; padding: 0; margin: 8px 0; display: grid; gap: 9px; font-size: var(--text-sm); }.blueprint-lineup strong { color: var(--accent-primary-hover); margin-right: 5px; }.blueprint-lineup small { color: var(--text-secondary); margin-left: 6px; }.edit { background: none; border: 0; color: var(--accent-primary-hover); font: inherit; font-size: var(--text-xs); cursor: pointer; }.blueprint-footer { flex-wrap: wrap; }
.eyebrow { display: block; text-transform: uppercase; letter-spacing: .1em; font-size: var(--text-xs); color: var(--text-secondary); margin-bottom: 8px; }
.blueprints { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(290px, 100%), 1fr)); gap: 16px; }.blueprint { padding: 22px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); display: flex; flex-direction: column; }.blueprint h3 { margin: 8px 0 0; font-size: 19px; overflow-wrap: anywhere; }.blueprint > p { flex: 1; overflow-wrap: anywhere; }.blueprint-top, .blueprint-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; }.blueprint-top .eyebrow { margin: 0; }.file-count { font-size: var(--text-xs); color: var(--text-secondary); white-space: nowrap; }.blueprint-footer { border-top: 1px solid var(--border-subtle); padding-top: 16px; margin-top: 16px; }
.remove, .blueprint-empty button { background: none; color: var(--accent-primary-hover); border: 0; font: inherit; font-size: 12px; cursor: pointer; }.remove { color: var(--text-secondary); }.blueprint-empty { border: 1px dashed var(--border-strong); border-radius: var(--radius-md); padding: 24px; }.blueprint-empty strong { font-size: 15px; }.blueprint-empty button { padding: 8px 0 0; }
.blueprints { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(290px, 100%), 1fr)); gap: 16px; }.blueprint { padding: 22px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); display: flex; flex-direction: column; }.blueprint h3 { margin: 8px 0 0; font-size: var(--text-lg); overflow-wrap: anywhere; }.blueprint > p { flex: 1; overflow-wrap: anywhere; }.blueprint-top, .blueprint-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; }.blueprint-top .eyebrow { margin: 0; }.file-count { font-size: var(--text-xs); color: var(--text-secondary); white-space: nowrap; }.blueprint-footer { border-top: 1px solid var(--border-subtle); padding-top: 16px; margin-top: 16px; }
.remove { background: none; color: var(--text-secondary); border: 0; font: inherit; font-size: var(--text-xs); cursor: pointer; }
</style>
@@ -192,16 +192,16 @@
<style>
.page { display: grid; gap: 28px; }
h2, p { margin: 0; }h2 { font-size: 21px; letter-spacing: var(--tracking-tight); }.eyebrow { display: block; font-size: 11px; text-transform: uppercase; letter-spacing: .1em; color: var(--text-secondary); margin-bottom: 9px; }
a { color: var(--accent-primary-hover); }a:focus-visible, button:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; }.muted { color: var(--text-secondary); font-size: 12px; line-height: 1.6; }.error { color: var(--state-danger); }
.summary { display: grid; gap: 24px; padding: 28px; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); }.summary-heading, .queue-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }.completion { color: var(--text-secondary); font-size: 13px; white-space: nowrap; font-variant-numeric: tabular-nums; }.completion strong { color: var(--text-primary); font-weight: 600; }
h2, p { margin: 0; }h2 { font-size: var(--text-lg); letter-spacing: var(--tracking-tight); }.eyebrow { display: block; font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .1em; color: var(--text-secondary); margin-bottom: 9px; }
a { color: var(--accent-primary-hover); }a:focus-visible, button:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; }.muted { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.6; }.error { color: var(--state-danger); }
.summary { display: grid; gap: 24px; padding: 28px; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); }.summary-heading, .queue-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }.completion { color: var(--text-secondary); font-size: var(--text-sm); white-space: nowrap; font-variant-numeric: tabular-nums; }.completion strong { color: var(--text-primary); font-weight: 600; }
.progress { display: flex; height: 6px; background: var(--surface-raised); overflow: hidden; border-radius: var(--radius-pill); }.progress span { transition: width var(--dur-base) var(--ease-standard); }.progress-ready { background: var(--state-online); }.progress-failed { background: var(--state-danger); }.progress-cancelled { background: var(--text-tertiary); }
.counts.has-cancellations { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.cancellation-note, .cancel-action p, .cleanup-summary { font-size: 13px; line-height: 1.6; color: var(--text-secondary); }.cancel-action { display: grid; gap: 10px; justify-items: start; }.cleanup-summary { display: grid; gap: 8px; }
.cleanup-link { display: grid; gap: 6px; margin: 0 24px 20px 68px; padding: 14px 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); color: var(--text-secondary); text-decoration: none; font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }.cleanup-link:hover { background: var(--surface-raised); }.cleanup-link strong { color: var(--text-primary); }.cleanup-link.error, .cleanup-link.error strong { color: var(--state-danger); }.cleanup-operation { color: var(--accent-primary-hover); }
.counts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; margin: 0; }.counts > div + div { padding-left: 24px; border-left: 1px solid var(--border-subtle); }dt { color: var(--text-secondary); font-size: 12px; }dd { font-variant-numeric: tabular-nums; font-size: 28px; font-weight: 600; margin: 6px 0 0; letter-spacing: -.025em; }.ready-count dd { color: var(--state-online); }.attention-count dd { color: var(--state-danger); }
.current-launches { display: grid; gap: 12px; }.current-launches > .eyebrow { margin: 0; color: var(--accent-primary-hover); }.current-launch { display: flex; align-items: center; gap: 16px; color: inherit; text-decoration: none; padding: 20px 24px; border: 1px solid color-mix(in srgb, var(--accent-primary) 35%, transparent); border-radius: var(--radius-md); background: color-mix(in srgb, var(--accent-primary) 7%, var(--surface)); }.current-launch > div { min-width: 0; flex: 1; }.current-launch strong { font-size: 16px; }.current-launch p { margin-top: 7px; color: var(--text-secondary); font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }.current-launch:hover { border-color: var(--accent-primary); }.operation-link { font-size: 12px; color: var(--accent-primary-hover); white-space: nowrap; }.activity { flex: 0 0 9px; height: 9px; border-radius: 50%; background: var(--accent-primary-hover); box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent-primary) 12%, transparent); }
.waiting-note { display: flex; gap: 16px; padding: 0 4px; }.waiting-icon { color: var(--text-secondary); font-size: 26px; line-height: 1; }.waiting-note strong { font-size: 14px; }.waiting-note p { margin-top: 6px; color: var(--text-secondary); font-size: 13px; line-height: 1.6; }.other-launches { display: grid; gap: 10px; margin-top: 12px; }.other-launches a { display: flex; align-items: baseline; gap: 10px; width: fit-content; font-size: 13px; text-decoration: none; }.other-launches a:hover { text-decoration: underline; }
.queue { display: grid; gap: 16px; }.queue-heading h2 { font-size: 17px; }.servers { padding: 0; margin: 0; list-style: none; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); overflow: hidden; background: var(--surface); }.servers li + li { border-top: 1px solid var(--border-subtle); }.servers li { position: relative; }.servers li.launching::before, .servers li.attention::before { content: ''; width: 2px; position: absolute; inset: 0 auto 0 0; background: var(--accent-primary); pointer-events: none; }.servers li.attention::before { background: var(--state-danger); }.server { display: flex; gap: 20px; padding: 22px 24px; color: inherit; text-decoration: none; }.server:hover { background: var(--surface-raised); }.server:focus-visible { outline-offset: -3px; }.order { padding-top: 2px; color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); flex: 0 0 24px; }.order.ready { color: var(--state-online); font-family: var(--font-body); font-size: 17px; padding-top: 0; }.server-content { flex: 1; min-width: 0; }.server-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; }.server-heading strong { font-size: 14px; }.server p { margin-top: 9px; color: var(--text-secondary); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }.server p.error { color: var(--state-danger); }.server-meta { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-top: 12px; color: var(--text-tertiary); font-size: 11px; }.server-meta span:last-child { color: var(--accent-primary-hover); }.screen-reader { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
@media (max-width: 600px) { .page { gap: 24px; }.summary { padding: 20px; gap: 20px; }h2 { font-size: 19px; }.counts, .counts.has-cancellations { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }.counts > div + div { padding-left: 0; border-left: 0; }.counts > div:nth-child(even) { padding-left: 20px; border-left: 1px solid var(--border-subtle); }dd { font-size: 25px; }.current-launch { padding: 20px; flex-wrap: wrap; gap: 12px; }.current-launch > div { flex-basis: calc(100% - 24px); }.operation-link { margin-left: 21px; }.server { gap: 12px; padding: 20px 16px; }.order { flex-basis: 20px; }.server-heading { row-gap: 10px; }.server-heading strong { flex-basis: 100%; }.completion { font-size: 12px; }.cleanup-link { margin: 0 16px 16px; } }
.cancellation-note, .cancel-action p, .cleanup-summary { font-size: var(--text-sm); line-height: 1.6; color: var(--text-secondary); }.cancel-action { display: grid; gap: 10px; justify-items: start; }.cleanup-summary { display: grid; gap: 8px; }
.cleanup-link { display: grid; gap: 6px; margin: 0 24px 20px 68px; padding: 14px 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); color: var(--text-secondary); text-decoration: none; font-size: var(--text-xs); line-height: 1.6; overflow-wrap: anywhere; }.cleanup-link:hover { background: var(--surface-raised); }.cleanup-link strong { color: var(--text-primary); }.cleanup-link.error, .cleanup-link.error strong { color: var(--state-danger); }.cleanup-operation { color: var(--accent-primary-hover); }
.counts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; margin: 0; }.counts > div + div { padding-left: 24px; border-left: 1px solid var(--border-subtle); }dt { color: var(--text-secondary); font-size: var(--text-xs); }dd { font-variant-numeric: tabular-nums; font-size: var(--text-2xl); font-weight: 600; margin: 6px 0 0; letter-spacing: -.025em; }.ready-count dd { color: var(--state-online); }.attention-count dd { color: var(--state-danger); }
.current-launches { display: grid; gap: 12px; }.current-launches > .eyebrow { margin: 0; color: var(--accent-primary-hover); }.current-launch { display: flex; align-items: center; gap: 16px; color: inherit; text-decoration: none; padding: 20px 24px; border: 1px solid color-mix(in srgb, var(--accent-primary) 35%, transparent); border-radius: var(--radius-md); background: color-mix(in srgb, var(--accent-primary) 7%, var(--surface)); }.current-launch > div { min-width: 0; flex: 1; }.current-launch strong { font-size: var(--text-md); }.current-launch p { margin-top: 7px; color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; overflow-wrap: anywhere; }.current-launch:hover { border-color: var(--accent-primary); }.operation-link { font-size: var(--text-xs); color: var(--accent-primary-hover); white-space: nowrap; }.activity { flex: 0 0 9px; height: 9px; border-radius: 50%; background: var(--accent-primary-hover); box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent-primary) 12%, transparent); }
.waiting-note { display: flex; gap: 16px; padding: 0 4px; }.waiting-icon { color: var(--text-secondary); font-size: var(--text-xl); line-height: 1; }.waiting-note strong { font-size: var(--text-base); }.waiting-note p { margin-top: 6px; color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; }.other-launches { display: grid; gap: 10px; margin-top: 12px; }.other-launches a { display: flex; align-items: baseline; gap: 10px; width: fit-content; font-size: var(--text-sm); text-decoration: none; }.other-launches a:hover { text-decoration: underline; }
.queue { display: grid; gap: 16px; }.queue-heading h2 { font-size: var(--text-md); }.servers { padding: 0; margin: 0; list-style: none; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); overflow: hidden; background: var(--surface); }.servers li + li { border-top: 1px solid var(--border-subtle); }.servers li { position: relative; }.servers li.launching::before, .servers li.attention::before { content: ''; width: 2px; position: absolute; inset: 0 auto 0 0; background: var(--accent-primary); pointer-events: none; }.servers li.attention::before { background: var(--state-danger); }.server { display: flex; gap: 20px; padding: 22px 24px; color: inherit; text-decoration: none; }.server:hover { background: var(--surface-raised); }.server:focus-visible { outline-offset: -3px; }.order { padding-top: 2px; color: var(--text-tertiary); font-size: var(--text-xs); font-family: var(--font-numeric); flex: 0 0 24px; font-variant-numeric: tabular-nums; }.order.ready { color: var(--state-online); font-family: var(--font-body); font-size: var(--text-md); padding-top: 0; }.server-content { flex: 1; min-width: 0; }.server-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; }.server-heading strong { font-size: var(--text-base); }.server p { margin-top: 9px; color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.6; overflow-wrap: anywhere; }.server p.error { color: var(--state-danger); }.server-meta { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-top: 12px; color: var(--text-tertiary); font-size: var(--text-xs); }.server-meta span:last-child { color: var(--accent-primary-hover); }.screen-reader { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
@media (max-width: 600px) { .page { gap: 24px; }.summary { padding: 20px; gap: 20px; }h2 { font-size: var(--text-lg); }.counts, .counts.has-cancellations { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }.counts > div + div { padding-left: 0; border-left: 0; }.counts > div:nth-child(even) { padding-left: 20px; border-left: 1px solid var(--border-subtle); }dd { font-size: var(--text-xl); }.current-launch { padding: 20px; flex-wrap: wrap; gap: 12px; }.current-launch > div { flex-basis: calc(100% - 24px); }.operation-link { margin-left: 21px; }.server { gap: 12px; padding: 20px 16px; }.order { flex-basis: 20px; font-variant-numeric: tabular-nums; }.server-heading { row-gap: 10px; }.server-heading strong { flex-basis: 100%; }.completion { font-size: var(--text-xs); }.cleanup-link { margin: 0 16px 16px; } }
</style>
+10 -9
View File
@@ -20,6 +20,7 @@
import MetricsChart from '$ui/components/metrics/MetricsChart.svelte';
import MetricsExplore from '$ui/components/metrics/MetricsExplore.svelte';
import MetricsTables from '$ui/components/metrics/MetricsTables.svelte';
import Freshness from '$ui/components/Freshness.svelte';
import type { MetricsData } from '$lib/metrics.js';
import type { PageData } from './$types';
@@ -31,6 +32,7 @@
let scopeNavigating = $state(false);
let mounted = $state(false);
let attemptedPreference = $state<string | null>(null);
let checkedAt = $state(untrack(() => Date.now()));
const report = $derived(snapshot ?? data);
const format = $derived(createTimeFormatter(report.scope.timeZone));
const captured = $derived(report.summary.observed + report.summary.unknown);
@@ -55,7 +57,7 @@
if (data.scope.map) params.set('map', data.scope.map);
return params.toString();
},
onData: value => { snapshot = value; },
onData: value => { snapshot = value; checkedAt = Date.now(); },
onError: value => { refreshError = value; },
onLoading: value => { refreshing = value; }
});
@@ -64,7 +66,7 @@
$effect(() => {
// Navigation owns the new report; an older background request cannot replace it.
void data;
untrack(() => { refresh.cancel(); snapshot = null; refreshError = null; });
untrack(() => { refresh.cancel(); snapshot = null; refreshError = null; checkedAt = Date.now(); });
});
$effect(() => {
// Layout initializes the saved preference on mount; react when it arrives.
@@ -118,7 +120,7 @@
<Field label="Game"><Select value={report.scope.game ?? ''} options={gameOptions} disabled={scopeNavigating} onchange={event => changeScope({ game: (event.currentTarget as HTMLSelectElement).value, map: '' })} /></Field>
<Field label="Map"><Select value={mapValue} options={mapOptions} disabled={scopeNavigating} onchange={event => changeMap((event.currentTarget as HTMLSelectElement).value)} /></Field>
<div class="refresh-controls"><span role="status">{scopeNavigating ? 'Updating report…' : `Updated ${format.minuteTime(report.now)}`}</span><Button size="sm" variant="ghost" loading={refreshing} disabled={scopeNavigating} onclick={() => refresh.refresh()}>Refresh</Button></div>
<div class="refresh-controls">{#if scopeNavigating}<span role="status">Updating report…</span>{:else if !refreshError}<Freshness updatedAt={checkedAt} intervalLabel="Updates every minute" />{/if}</div>
</div>
{#if scopeError}<p class="notice" role="alert">{scopeError}</p>{/if}
{#if refreshError}
@@ -153,7 +155,7 @@
<MetricsChart points={report.points} days={report.days} timeZone={report.scope.timeZone} />
{/if}
{#if captured > 0}
<details class="weekly"><summary>Activity by weekday and hour</summary><MetricsHeatmap heatmap={report.heatmap} disabled={scopeNavigating} onTimeZoneChange={value => changeScope({ timeZone: value })} /></details>
<MetricsHeatmap heatmap={report.heatmap} disabled={scopeNavigating} onTimeZoneChange={value => changeScope({ timeZone: value })} />
<Tabs tabs={[{ id: 'maps', label: 'Maps' }, { id: 'instances', label: 'Instances' }, { id: 'insights', label: 'Hosting insights' }]} value={view} onchange={changeView} ariaLabel="Metrics breakdown" idPrefix="metrics-views" panelId="metrics-breakdown" />
<div id="metrics-breakdown" role="tabpanel" aria-labelledby={`metrics-views-${view}`}>
{#if view === 'insights'}<MetricsExplore {report} />{:else}<MetricsTables maps={report.maps} servers={report.servers} {view} />{/if}
@@ -168,7 +170,7 @@
<span>Report refreshed {format.time(report.now)} · refreshes every minute while visible</span>
{#if report.scopeFirstSampleAt !== null}<span>Scope retained samples from {format.dateTime(report.scopeFirstSampleAt)}</span>{/if}
</div>
<p>Previous window: {format.period(report.previous.since)} – {format.period(report.previous.until)}. Known time: {metricDuration(report.previous.summary.observed)} previous / {metricDuration(report.summary.observed)} current. pp means percentage points. Equal windows do not guarantee equal observation coverage.</p>
<p>Previous window: {format.period(report.previous.since)} – {format.period(report.previous.until)}. Known time: {metricDuration(report.previous.summary.observed)} previous / {metricDuration(report.summary.observed)} current. Percentage points show the difference between two percentages. Equal windows do not guarantee equal observation coverage.</p>
<p>Time with players is occupied server time divided by known server time. Known time includes occupied and idle observations; unknown status and collection gaps are excluded from that percentage.</p>
<p>Each periodic observation covers at most {ONLINE_MAX_MS / 1000} seconds. Longer collection gaps are excluded. History is retained for 31 days. Player-hours include all players reported by the server and are estimates, not individual session records.</p>
<p>Timeline buckets are aligned to UTC, with labels shown in {report.scope.timeZone.replaceAll('_', ' ')}. Daily buckets are 24-hour periods, not local calendar days. The first and latest buckets can be partial. Percentage bars divide occupied, idle, and unknown time by captured time; the headline occupancy percentage uses known time only.</p>
@@ -177,8 +179,9 @@
</div>
<style>
.page { display: grid; gap: var(--space-5); min-width: 0; }
.page { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-5); min-width: 0; }
.toolbar { display: flex; align-items: end; gap: var(--space-3); flex-wrap: wrap; }
.toolbar :global(.field) { flex: 1 1 180px; max-width: 300px; }
nav { display: flex; gap: var(--space-1); padding: 4px; border: 1px solid var(--border-subtle); background: var(--surface); border-radius: var(--radius-md); }
nav a { padding: 9px 12px; color: var(--text-secondary); border-radius: var(--radius-sm); text-decoration: none; white-space: nowrap; font-size: var(--text-sm); }
nav a[aria-current] { background: var(--surface-raised); color: var(--text-primary); }
@@ -186,8 +189,6 @@
.range, .context, .methodology, .notice { color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.6; }
.range { margin: 0; }
.refresh-controls { display: flex; align-items: center; gap: 8px; margin-left: auto; font-size: var(--text-xs); color: var(--text-secondary); }
.weekly { font-size: var(--text-sm); color: var(--text-secondary); }
.weekly summary { cursor: pointer; }
.context { display: flex; flex-wrap: wrap; column-gap: var(--space-5); row-gap: var(--space-1); padding: 0 2px; }
.refresh-error { display: flex; justify-content: space-between; align-items: center; gap: var(--space-4); border: 1px solid var(--state-warning); padding: var(--space-4); border-radius: var(--radius-md); font-size: var(--text-sm); }
.refresh-error small { display: block; margin-top: var(--space-1); color: var(--text-secondary); }
@@ -195,5 +196,5 @@
.methodology { padding: var(--space-3) 2px; }
.methodology summary { cursor: pointer; color: var(--text-secondary); }
.methodology p { max-width: 110ch; margin: var(--space-3) 0 0; }
@media (max-width: 540px) { .toolbar { align-items: stretch; } .refresh-controls { margin-left: 0; } .refresh-error { flex-wrap: wrap; } }
@media (max-width: 540px) { .toolbar { align-items: stretch; } .toolbar :global(.field) { flex-basis: 100%; max-width: none; } .refresh-controls { margin-left: 0; } .refresh-error { flex-wrap: wrap; } }
</style>
+5 -3
View File
@@ -171,7 +171,7 @@
<div class="page">
<SectionHeader title="Plugins" subtitle="Manage plugin versions and test changes before promotion.">
{#snippet actions()}{#if plugins.length}<Button variant="ghost" onclick={() => updatesOpen = !updatesOpen}>{updatesOpen ? 'Hide update details' : 'Check for updates'}</Button>{/if}<Button variant="primary" onclick={() => addOpen = true}>Add plugin</Button>{/snippet}
{#snippet actions()}{#if plugins.length}<Button variant="ghost" onclick={() => updatesOpen = !updatesOpen}>{updatesOpen ? 'Hide update details' : 'Check for updates'}</Button>{/if}{#if loading || plugins.length || flash}<Button variant="primary" onclick={() => addOpen = true}>Add plugin</Button>{/if}{/snippet}
</SectionHeader>
{#if importComplete}<div class="banner" role="status"><strong>{importComplete.alreadyImported ? 'This plugin version was already imported.' : 'Plugin imported.'}</strong><span><a href={'/plugins/' + importComplete.pluginId}>View version</a> · <a href={importComplete.betaUrl}>Start beta</a></span></div>{/if}
{#if successBanner}
@@ -191,11 +191,13 @@
{#if game}<p class="filter-hint">Game results include unscoped plugins that can use explicitly selected beta servers.</p>{/if}
{#if updatesOpen}<details open ontoggle={event => { if (!event.currentTarget.open) updatesOpen = false; }}><summary>Update check details</summary>{#key importCount}<PluginUpdates onchecked={results => updates = results} />{/key}</details>{/if}
<section class="section">
<div class="list-heading"><span>{visible.length} of {plugins.length} plugins</span>{#if search || game || status}<Button variant="ghost" size="sm" onclick={() => { search = ''; game = ''; status = ''; }}>Clear filters</Button>{/if}</div>
<div class="list-heading"><span>{visible.length} of {plugins.length} plugins</span>{#if visible.length && (search || game || status)}<Button variant="ghost" size="sm" onclick={() => { search = ''; game = ''; status = ''; }}>Clear filters</Button>{/if}</div>
{#if loading}
<div class="loading">Loading…</div>
{:else if visible.length === 0 && !flash}
<EmptyState title={plugins.length ? "No matching plugins" : "No plugins yet"} description={plugins.length ? "Try different filters." : "Add a plugin to upload a file or import a GitHub release."} />
<EmptyState title={plugins.length ? 'No matching plugins' : 'No plugins yet'} description={plugins.length ? 'Try another name, game, or status.' : 'Plugins add server features. Upload a file or import a GitHub release to test it in a beta.'}>
{#snippet actions()}{#if plugins.length}<Button onclick={() => { search = ''; game = ''; status = ''; }}>Clear filters</Button>{:else}<Button variant="primary" onclick={() => addOpen = true}>Add plugin</Button>{/if}{/snippet}
</EmptyState>
{:else}
<div class="library-table"><table>
<thead><tr><th>Plugin</th><th>Game / scope</th><th>Version / status</th><th>Eligible servers</th><th><span class="sr-only">Next action</span></th></tr></thead>
+9 -6
View File
@@ -96,7 +96,7 @@
<div class="page">
<SectionHeader title="Scripts" subtitle="Review versions, prepare a beta, and keep releases moving.">
{#snippet actions()}<Button variant="primary" onclick={() => uploader.open()}>Upload scripts</Button>{/snippet}
{#snippet actions()}{#if loading || scripts.length || error}<Button variant="primary" onclick={() => uploader.open()}>Upload scripts</Button>{/if}{/snippet}
</SectionHeader>
<section class="library" aria-label="Script library">
{#if loading || scripts.length || error}<div class="library-toolbar">
@@ -109,16 +109,19 @@
<div class="list-heading">
<span>{visible.length} of {scripts.length} scripts</span>
{#if failedCount && status !== 'failed'}<button class="text-button" onclick={() => { resetFilters(); status = 'failed'; }}>{failedCount} test{failedCount === 1 ? '' : 's'} failed</button>{/if}
{#if search || status !== 'all' || game || map}<button class="text-button" onclick={resetFilters}>Clear filters</button>{/if}
{#if visible.length && (search || status !== 'all' || game || map)}<button class="text-button" onclick={resetFilters}>Clear filters</button>{/if}
</div>{/if}
{#if game}<p class="hint">Game results include unscoped scripts that can use explicitly selected beta servers.</p>{/if}
{#if error}<div class="error" role="alert">{error} <button class="text-button" onclick={load}>Retry</button></div>{/if}
{#if loading}<p class="loading" role="status">Loading your library…</p>
{:else if !scripts.length && !error}
<EmptyState title="Your next release starts here" description="Drop script files anywhere, or upload your first script to prepare a beta." />
<div class="empty-action"><Button onclick={() => uploader.open()}>Upload scripts</Button></div>
<EmptyState title="No scripts yet" description="Upload script files to keep versions and test changes in a beta before promotion.">
{#snippet actions()}<Button variant="primary" onclick={() => uploader.open()}>Upload scripts</Button>{/snippet}
</EmptyState>
{:else if !visible.length && !error}
<EmptyState title="No scripts match these filters" description="Try another name, game, map, or status." />
<EmptyState title="No scripts match these filters" description="Try another name, game, map, or status.">
{#snippet actions()}<Button onclick={resetFilters}>Clear filters</Button>{/snippet}
</EmptyState>
{:else}
<div class="library-table"><table>
<thead><tr><th class="select-cell"><input type="checkbox" aria-label="Select visible scripts eligible for a beta" checked={allSelected} disabled={!selectable.length} onchange={selectVisible} /></th><th>Script</th><th>Game / scope</th><th>Version / status</th><th>Eligible servers</th><th><span class="sr-only">Next action</span></th></tr></thead>
@@ -164,6 +167,6 @@
.selection-review li { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 6px 0; }
@media (max-width: 640px) { .selection-bar { padding: 12px; gap: 8px; bottom: 12px; } .selection-bar > strong { flex-basis: 100%; font-size: var(--text-sm); } }
.drop-overlay { position: fixed; inset: 16px; z-index: 100; pointer-events: none; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 16px; padding: 24px; text-align: center; background: color-mix(in srgb, var(--surface) 95%, transparent); border: 2px dashed var(--accent-primary); border-radius: var(--radius-md); }
.drop-overlay strong { font-size: var(--text-2xl); } .loading, .empty-action { text-align: center; padding: 24px; } .error { padding: 16px; color: var(--state-danger); }
.drop-overlay strong { font-size: var(--text-2xl); } .loading { text-align: center; padding: 24px; } .error { padding: 16px; color: var(--state-danger); }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
</style>
+2 -2
View File
@@ -145,13 +145,13 @@
h2 { font-size: var(--text-md); }
p { color: var(--text-secondary); }
form, label { display: grid; gap: var(--space-4); }
label { gap: 8px; font-size: 14px; }
label { gap: 8px; font-size: var(--text-base); }
select { padding: 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: inherit; }
.actions { display: flex; gap: var(--space-2); }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; text-align: left; }
th, td { padding: var(--space-4); border-bottom: 1px solid var(--border-subtle); }
th { color: var(--text-tertiary); font-size: var(--text-sm); }
code { display: block; font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); margin-top: 6px; }
code { display: block; font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-secondary); margin-top: 6px; }
.error { color: var(--state-danger); }
</style>
+1 -1
View File
@@ -431,7 +431,7 @@
letter-spacing: var(--tracking-tight);
}
.panel-count {
font-family: var(--font-mono);
font-family: var(--font-numeric);
font-size: var(--text-sm);
color: var(--text-tertiary);
font-variant-numeric: tabular-nums;
+12
View File
@@ -35,6 +35,18 @@ async function setup() {
}
describe('fleet event refreshes', () => {
it('hydrates and refreshes the all-host attention count without an additional request', async () => {
const store = await import('../../src/lib/ui/stores/fleet.js');
const integrations = { rcon: true, rconTimeoutMs: 2000, defaultRconHost: '' };
store.hydrateFleet({ servers: [], operations: [], integrations, serverNow: Date.now(), hostAttention: 2 });
expect(get(store.fleet).hostAttention).toBe(2);
vi.mocked(fetch).mockImplementation(async url => Response.json(String(url) === '/api/servers'
? { serverNow: Date.now(), servers: [], integrations, hostAttention: 0 } : { operations: [] }));
await store.refreshFleet();
expect(get(store.fleet).hostAttention).toBe(0);
expect(fetch).toHaveBeenCalledTimes(2);
expect(vi.mocked(fetch).mock.calls.map(([url]) => url).sort()).toEqual(['/api/fleet', '/api/servers']);
});
it('refreshes server history through one scoped request without replacing the fleet snapshot', async () => {
const store = await import('../../src/lib/ui/stores/fleet.js');
const integrations = { rcon: true, rconTimeoutMs: 2000, defaultRconHost: '' };
+21
View File
@@ -30,6 +30,27 @@ beforeEach(async () => {
afterEach(async () => { await fixture.cleanup(); vi.useRealTimers(); vi.restoreAllMocks(); });
describe('fleet and server response scope', () => {
it('counts unattached hosts needing attention and rechecks heartbeat age with each snapshot', async () => {
const hosts = await import('../../src/lib/server/hosts/index.js');
const { fleetSnapshot } = await import('../../src/lib/server/fleet/snapshot.js');
const base: import('../../src/lib/hosts.js').Host = { id: 'empty-host', name: 'Empty host', hostname: 'empty-host',
portMin: 28960, portMax: 28980, bindAddress: '0.0.0.0', publicAddress: '', minimumFreeBytes: 1024, createdAt: iso(),
connected: true, connectionState: 'online', lastHeartbeat: iso(),
enrolled: true, credentialActive: true, mode: 'active', readiness: 'ready', readinessReasons: [],
inventory: {}, decommission: null };
vi.spyOn(hosts, 'listHosts').mockReturnValue([
base,
{ ...base, id: 'offline', connected: false },
{ ...base, id: 'degraded', readiness: 'degraded' },
{ ...base, id: 'disabled', connected: false, mode: 'disabled' },
{ ...base, id: 'unenrolled', connected: false, enrolled: false }
]);
expect(fleetSnapshot().hostAttention).toBe(2);
vi.setSystemTime(now + 91_000);
expect(fleetSnapshot().hostAttention).toBe(3);
// None of these hosts has an assigned instance in this fixture.
expect(fleetSnapshot().servers.every(server => !server.host_id)).toBe(true);
});
it('omits completed history from fleet REST and SSR responses', async () => {
const { GET } = await import('../../src/routes/api/fleet/+server.js');
const { load } = await import('../../src/routes/+page.server.js');
+3
View File
@@ -39,6 +39,8 @@ it('keeps current estimates and host comparison before history with scoped histo
expect(body).toContain('No queued instances');
expect(body).not.toContain('href="/provisioning"');
expect(body).not.toContain('Manage hosts');
expect(body).toContain('Updates every 30 seconds');
expect(body).not.toContain('>Refresh<');
});
it('keeps fleet-wide queue scope explicit and preserves unavailable estimates', () => {
@@ -61,6 +63,7 @@ it('keeps ineligible hosts and their reasons visible while collapsing detailed a
expect(body).toMatch(/<tr hidden[^>]*id="capacity-host-ec-1"/);
expect(body).toContain('aria-expanded="false"');
expect(body).toContain('Running instance');
expect(body).toContain('aria-label="Host capacity comparison" tabindex="0"');
});
it('preserves chart gaps, keyboard inspection and reporting coverage in the compact chart', () => {
+9 -1
View File
@@ -41,12 +41,17 @@ it('shows a summary and one paginated breakdown, preserving scope on range links
expect(body).toContain('Partial previous history');
expect(body).toContain('About this report');
expect(body).not.toContain('Explore this period');
expect(body).toContain('Updates every minute');
expect(body).not.toContain('>Refresh<');
const heatmap = body.slice(body.indexOf('class="heatmap'), body.indexOf('aria-label="Metrics breakdown"'));
expect(heatmap).toContain('<h2'); expect(heatmap).toContain('Activity by weekday and hour');
expect(body).not.toContain('class="weekly');
});
it('keeps percentage points, relative changes and unavailable capacity distinct', () => {
const data = report();
let body = render(MetricsSummary, { props: { report: data } }).body;
expect(body).toContain('+10 pp'); expect(body).toContain('+100%');
expect(body).toContain('+10 percentage points'); expect(body).toContain('+100%');
data.pooledPressure = { value: null, fullMapHours: 0, knownMapHours: 0, coveredMaps: 0, totalMaps: 24 };
data.maps = [map(1, { capacityObserved: 0 })];
data.previous.summary.playerHours = 0;
@@ -116,6 +121,9 @@ it('keeps accessible chart selection, partial periods and gaps while collapsing
expect(body).toContain('Inspect a period');
expect(body).toContain('Partial period');
expect(body).not.toMatch(/<details[^>]*open/);
expect(body).toContain('Weekdays show when each period starts in America/New York.');
expect(body).toContain('Daily periods are 24-hour windows.');
expect(body).toContain('Server activity over time');
});
it('uses shallow URL state for the selected breakdown while retaining the common summary', () => {
+17 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { compareMetricValues, metricDuration, metricNumber, metricPercent } from '../../src/lib/ui/metrics.js';
import { compareMetricValues, metricDuration, metricNumber, metricPercent, metricPeriodLabels } from '../../src/lib/ui/metrics.js';
describe('metric presentation', () => {
it('distinguishes unavailable evidence, zero, and tiny nonzero values', () => {
@@ -18,4 +18,20 @@ describe('metric presentation', () => {
expect([...values].sort((a, b) => compareMetricValues(a, b, 'desc'))).toEqual([100, 10, 2, 0, null]);
expect([...values].sort((a, b) => compareMetricValues(a, b, 'asc'))).toEqual([0, 2, 10, 100, null]);
});
it('labels daily buckets by their actual starting weekday in the report timezone', () => {
const points = Array.from({ length: 8 }, (_, day) => ({ startAt: Date.parse('2026-10-01T00:00:00Z') + day * 86_400_000 }));
expect(metricPeriodLabels(points, 7, 'America/New_York').map(item => item.label)).toEqual(['Wed', 'Thu', 'Fri', 'Sat', 'Sun', 'Mon', 'Tue', 'Wed']);
expect(metricPeriodLabels(points, 7, 'UTC')[0].label).toBe('Thu');
expect(metricPeriodLabels(points, 7, 'UTC')[0].position).toBe(6.25);
expect(metricPeriodLabels([], 7, 'UTC')).toEqual([]);
});
it('keeps hourly and long-range labels sparse without relabelling UTC buckets as local days', () => {
const points = Array.from({ length: 31 }, (_, day) => ({ startAt: Date.parse('2026-10-01T00:00:00Z') + day * 86_400_000 }));
const labels = metricPeriodLabels(points, 30, 'UTC');
expect(labels.length).toBeLessThanOrEqual(6); expect(labels[0]).toMatchObject({ label: 'Thu', date: '1' });
const hourly = metricPeriodLabels(Array.from({ length: 25 }, (_, hour) => ({ startAt: Date.parse('2026-11-01T04:00:00Z') + hour * 3_600_000 })), 1, 'America/New_York');
expect(hourly[0].label).toBe('12 AM'); expect(hourly[1].label).toBe('4 AM'); expect(hourly).toHaveLength(5);
});
});
+7
View File
@@ -34,6 +34,13 @@ describe('sidebar navigation', () => {
}] });
expect(render(Navigation, { props: { role: 'admin' } }).body).toContain('aria-label="Servers, 1 needs attention"');
});
it('counts hosts without servers and clears the badge when the next host summary is healthy', () => {
fleet.set({ receivedAt: Date.now(), operations: [], servers: [], hostAttention: 2 });
expect(render(Navigation, { props: { role: 'admin' } }).body).toContain('aria-label="Hosts, 2 need attention"');
fleet.update(value => ({ ...value, hostAttention: 0 }));
expect(render(Navigation, { props: { role: 'admin' } }).body).not.toMatch(/aria-label="Hosts, \d/);
});
});
const user: AuthUser = { id: '1', username: 'operator', displayName: 'Operator', role: 'operator', disabled: false,