@@ -94,7 +96,7 @@
CPU available {count(summary.cpuCoresAvailable)} cores
Memory available {memory(summary.memoryBytesAvailable)} GiB
Free ports {count(summary.portsAvailable)}
-
Startup slots · Fleet-wide {summary.startupSlots === null ? 'Unavailable' : `${count(summary.startupSlots)}${summary.startupLimit === null ? '' : ` of ${summary.startupLimit}`} available`}
+
Concurrent starts · Fleet-wide {summary.startupSlots === null ? 'Unavailable' : `${count(summary.startupSlots)}${summary.startupLimit === null ? '' : ` of ${summary.startupLimit}`} available`}
{#if report.current.warnings.length}
{#each report.current.warnings as warning}{warning}
{/each} {/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); } }
diff --git a/src/routes/collections/+page.svelte b/src/routes/collections/+page.svelte
index 11a257e..bd640e9 100644
--- a/src/routes/collections/+page.svelte
+++ b/src/routes/collections/+page.svelte
@@ -107,7 +107,7 @@
Collections | Electric Cherry
- {#snippet actions()}{#if canOperate} { editingBlueprint = null; builderOpen = true; }}>Create collection {/if}{/snippet}
+ {#snippet actions()}{#if canOperate && (blueprintsLoading || blueprints.length || blueprintError)} { editingBlueprint = null; builderOpen = true; }}>Create collection {/if}{/snippet}
{#if canOperate}
{/if}
@@ -118,7 +118,9 @@
Your collections {blueprints.length}
{#if blueprintError}
{blueprintError} Retry
{/if}
{#if blueprintsLoading}
Loading collections…
- {:else if !blueprints.length}
No collections yet. Collections define the maps and lobby counts for a fleet. Operators can create and launch them.
{#if canOperate}
{ editingBlueprint = null; builderOpen = true; }}>Create your first collection → {/if}
+ {:else if !blueprints.length && !blueprintError}
+ {#snippet actions()}{#if canOperate} { editingBlueprint = null; builderOpen = true; }}>Create collection {/if}{/snippet}
+
{:else}
{#each blueprints.slice(0,limit) as blueprint (blueprint.id)}
@@ -156,7 +158,9 @@
{/each}
{:else if !launchError}
-
+
+ {#snippet actions()} { tab = 'collections'; saveContext(); }}>View collections {/snippet}
+
{/if}
saveContext()} step={10} label="launches" />
@@ -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; }
diff --git a/src/routes/collections/launches/[id]/+page.svelte b/src/routes/collections/launches/[id]/+page.svelte
index bf29eb3..d4ea9ff 100644
--- a/src/routes/collections/launches/[id]/+page.svelte
+++ b/src/routes/collections/launches/[id]/+page.svelte
@@ -192,16 +192,16 @@
diff --git a/src/routes/metrics/+page.svelte b/src/routes/metrics/+page.svelte
index edb91b1..e479098 100644
--- a/src/routes/metrics/+page.svelte
+++ b/src/routes/metrics/+page.svelte
@@ -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(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 @@
changeScope({ game: (event.currentTarget as HTMLSelectElement).value, map: '' })} />
changeMap((event.currentTarget as HTMLSelectElement).value)} />
- {scopeNavigating ? 'Updating report…' : `Updated ${format.minuteTime(report.now)}`} refresh.refresh()}>Refresh
+ {#if scopeNavigating}Updating report… {:else if !refreshError} {/if}
{#if scopeError}
{scopeError}
{/if}
{#if refreshError}
@@ -153,7 +155,7 @@
{/if}
{#if captured > 0}
-
Activity by weekday and hour changeScope({ timeZone: value })} />
+
changeScope({ timeZone: value })} />
{#if view === 'insights'} {:else} {/if}
@@ -168,7 +170,7 @@
Report refreshed {format.time(report.now)} · refreshes every minute while visible
{#if report.scopeFirstSampleAt !== null}Scope retained samples from {format.dateTime(report.scopeFirstSampleAt)} {/if}
- 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.
+ 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.
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.
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.
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.
@@ -177,8 +179,9 @@
diff --git a/src/routes/plugins/+page.svelte b/src/routes/plugins/+page.svelte
index 1263d46..3f12e3a 100644
--- a/src/routes/plugins/+page.svelte
+++ b/src/routes/plugins/+page.svelte
@@ -171,7 +171,7 @@
- {#snippet actions()}{#if plugins.length} updatesOpen = !updatesOpen}>{updatesOpen ? 'Hide update details' : 'Check for updates'} {/if} addOpen = true}>Add plugin {/snippet}
+ {#snippet actions()}{#if plugins.length} updatesOpen = !updatesOpen}>{updatesOpen ? 'Hide update details' : 'Check for updates'} {/if}{#if loading || plugins.length || flash} addOpen = true}>Add plugin {/if}{/snippet}
{#if importComplete}
{importComplete.alreadyImported ? 'This plugin version was already imported.' : 'Plugin imported.'} View version · Start beta {/if}
{#if successBanner}
@@ -191,11 +191,13 @@
{#if game}
Game results include unscoped plugins that can use explicitly selected beta servers.
{/if}
{#if updatesOpen}
{ if (!event.currentTarget.open) updatesOpen = false; }}>Update check details {#key importCount} updates = results} />{/key} {/if}
- {visible.length} of {plugins.length} plugins {#if search || game || status} { search = ''; game = ''; status = ''; }}>Clear filters {/if}
+ {visible.length} of {plugins.length} plugins {#if visible.length && (search || game || status)} { search = ''; game = ''; status = ''; }}>Clear filters {/if}
{#if loading}
Loading…
{:else if visible.length === 0 && !flash}
-
+
+ {#snippet actions()}{#if plugins.length} { search = ''; game = ''; status = ''; }}>Clear filters {:else} addOpen = true}>Add plugin {/if}{/snippet}
+
{:else}
Plugin Game / scope Version / status Eligible servers Next action
diff --git a/src/routes/scripts/+page.svelte b/src/routes/scripts/+page.svelte
index 14b267d..fa5ebda 100644
--- a/src/routes/scripts/+page.svelte
+++ b/src/routes/scripts/+page.svelte
@@ -96,7 +96,7 @@
- {#snippet actions()} uploader.open()}>Upload scripts {/snippet}
+ {#snippet actions()}{#if loading || scripts.length || error} uploader.open()}>Upload scripts {/if}{/snippet}
{#if loading || scripts.length || error}