Standardize UI typography, charts and empty states
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user