Standardize UI typography, charts and empty states
This commit is contained in:
@@ -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