Add timezone preference across time displays
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { startTimeZonePreference } from '$ui/stores/time-zone.js';
|
||||
import { afterNavigate, onNavigate, goto } from '$app/navigation';
|
||||
import { startPipelineStream } from '$ui/stores/pipeline.js';
|
||||
import { startServerStream } from '$ui/stores/servers.js';
|
||||
@@ -42,6 +43,7 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const stopTimeZone = startTimeZonePreference();
|
||||
const stopNotifications = startNotificationHistory();
|
||||
const stopPipeline = startPipelineStream();
|
||||
const stopServers = startServerStream();
|
||||
@@ -80,6 +82,7 @@
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
stopTimeZone();
|
||||
stopNotifications();
|
||||
stopFleetMonitor();
|
||||
stopPipeline?.();
|
||||
@@ -107,7 +110,7 @@
|
||||
? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }]
|
||||
: tabs[2].match($page.url.pathname)
|
||||
? [{ href: '/deployments', label: 'Server deployments' }, { href: '/campaigns', label: 'Campaigns' }]
|
||||
: tabs[4].match($page.url.pathname) ? [{ href: '/settings', label: 'System status' }, { href: '/server-keys', label: 'Server keys' }] : []);
|
||||
: tabs[4].match($page.url.pathname) ? [{ href: '/settings', label: 'Preferences & status' }, { href: '/server-keys', label: 'Server keys' }] : []);
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={closeMenu} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
@@ -74,7 +75,7 @@
|
||||
{:else if !filtered.length && !error}<EmptyState title={search ? 'No matching betas' : tab === 'active' ? 'No active betas' : 'No beta history'} description={tab === 'active' && !search ? 'Start a beta when you have a version ready to test.' : undefined} />
|
||||
{:else}<div class="rows">{#each filtered.slice(0,limit) as beta (beta.id)}
|
||||
<a href={'/betas/' + beta.id} class="row">
|
||||
<div class="identity"><strong>{beta.notes || `Beta #${beta.id}`}</strong>{#if beta.notes}<span class="beta-id"> · #{beta.id}</span>{/if}<span class="files">{beta.scripts.map(s => s.name + ' · ' + artifactVersionLabel(s.version)).join(', ')}</span><span class="blocker">{blocker(beta)}</span><span class="timing"><time datetime={beta.opened_at} title={new Date(beta.opened_at).toLocaleString()}>Opened {age(beta.opened_at)}</time>{#if beta.deadlineAt && active(beta)} · Ends {new Date(beta.deadlineAt).toLocaleString()}{:else if beta.closed_at} · Finished {new Date(beta.closed_at).toLocaleString()}{/if}</span></div>
|
||||
<div class="identity"><strong>{beta.notes || `Beta #${beta.id}`}</strong>{#if beta.notes}<span class="beta-id"> · #{beta.id}</span>{/if}<span class="files">{beta.scripts.map(s => s.name + ' · ' + artifactVersionLabel(s.version)).join(', ')}</span><span class="blocker">{blocker(beta)}</span><span class="timing"><time datetime={beta.opened_at} title={$timeFormat.dateTime(beta.opened_at)}>Opened {age(beta.opened_at)}</time>{#if beta.deadlineAt && active(beta)} · Ends {$timeFormat.dateTime(beta.deadlineAt)}{:else if beta.closed_at} · Finished {$timeFormat.dateTime(beta.closed_at)}{/if}</span></div>
|
||||
<div class="meta"><StatusBadge state={beta.state} />{#if beta.testOutcome && beta.testOutcome !== 'active'}<StatusBadge state={beta.testOutcome} />{/if}<span>{beta.servers.filter(server=>server.stage==='ready').length} / {beta.servers.length} servers passed</span></div>
|
||||
</a>
|
||||
{/each}</div><ListFooter total={filtered.length} bind:limit label="betas" />{/if}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
||||
@@ -129,9 +130,9 @@
|
||||
<div class="summary-heading"><h2>{closed ? beta.state === 'promoted' ? 'Version promoted' : 'Beta rolled back' : `${passed} / ${servers.length} servers passed`}</h2>{#if test}<span>{coverageSummary(test.plan)}</span>{/if}</div>
|
||||
<p>{nextAction}</p>
|
||||
{#if test?.reason}<p class="reason">{test.reason}</p>{/if}
|
||||
<div class="timing"><span>Started <time datetime={beta.opened_at}>{new Date(beta.opened_at).toLocaleString()}</time></span>
|
||||
{#if deadline && !closed}<strong>Ends <time datetime={deadline.toISOString()}>{deadline.toLocaleString()}</time> · {deadlineText}</strong>{/if}
|
||||
{#if beta.closed_at}<span>Finished {new Date(beta.closed_at).toLocaleString()}</span>{/if}
|
||||
<div class="timing"><span>Started <time datetime={beta.opened_at}>{$timeFormat.dateTime(beta.opened_at)}</time></span>
|
||||
{#if deadline && !closed}<strong>Ends <time datetime={deadline.toISOString()}>{$timeFormat.dateTime(deadline)}</time> · {deadlineText}</strong>{/if}
|
||||
{#if beta.closed_at}<span>Finished {$timeFormat.dateTime(beta.closed_at)}</span>{/if}
|
||||
</div>
|
||||
{#if !closed && test}<p class="muted">Waiting for empty servers uses this window. Pausing or retrying does not extend the deadline.</p>{/if}
|
||||
<div class="actions">
|
||||
@@ -163,7 +164,7 @@
|
||||
{#if preview}
|
||||
<p><strong>{preview.servers.filter(server => server.tested).length} tested servers</strong> and <strong>{preview.servers.filter(server => !server.tested).length} additional eligible servers</strong>.</p>
|
||||
<p>Files are installed when you confirm. Tested servers have already restarted. Additional servers restart when empty, then their health is checked. Eligibility is determined by each file's current scope.</p>
|
||||
<p class="muted">Reviewed {new Date(preview.createdAt).toLocaleString()}. If targets, versions, or composed settings change, you must review again.</p>
|
||||
<p class="muted">Reviewed {$timeFormat.dateTime(preview.createdAt)}. If targets, versions, or composed settings change, you must review again.</p>
|
||||
{#each preview.blockers as blocker}<p class="ui-error" role="alert">{blocker}</p>{/each}
|
||||
<div class="file-list">{#each preview.files as file}<details open><summary>{file.name} · {artifactVersionLabel(file)} · {file.servers.length} servers</summary><p><code>{file.path}</code> · {file.scope} · SHA {file.sha256.slice(0,12)}</p><ul>{#each file.servers as name}<li><ServerIdentity {name}/><span>{preview.servers.find(server => server.name === name)?.tested ? 'Already tested' : 'Additional target · restart when empty'}</span></li>{/each}</ul></details>{/each}</div>
|
||||
{#if preview.configWrites.length}<details><summary>Composed configuration files ({preview.configWrites.length})</summary>{#each preview.configWrites as write}<p>{write.server} · {write.path} · {write.sha256?.slice(0,12) ?? write.error}</p>{/each}</details>{/if}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { beforeNavigate, goto } from '$app/navigation';
|
||||
import { defaultPlan, parseBetaPlan, betaPlanIssues, type BetaPlan } from '$lib/beta-plan.js';
|
||||
@@ -178,7 +179,7 @@
|
||||
{#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={refreshFleet}>Retry servers</Button></div>{/if}
|
||||
{#if notice}<p role="status" class="notice">{notice}</p>{/if}
|
||||
{#if loading}<EmptyState title="Loading beta setup…"/>
|
||||
{:else if availableDraft}<section class="ui-panel draft"><h2>Resume unfinished beta?</h2><p>Saved {new Date(availableDraft.savedAt).toLocaleString()} · {availableDraft.picks.filter(p=>p.scriptId!==null).length} files · {availableDraft.servers.length} selected servers. Versions and eligibility will be rechecked.</p><div class="actions"><Button variant="primary" onclick={resumeDraft}>Resume draft</Button><Button onclick={discardDraft}>{hasLinkedSetup ? 'Discard draft and use linked files' : 'Discard draft'}</Button></div></section>
|
||||
{:else if availableDraft}<section class="ui-panel draft"><h2>Resume unfinished beta?</h2><p>Saved {$timeFormat.dateTime(availableDraft.savedAt)} · {availableDraft.picks.filter(p=>p.scriptId!==null).length} files · {availableDraft.servers.length} selected servers. Versions and eligibility will be rechecked.</p><div class="actions"><Button variant="primary" onclick={resumeDraft}>Resume draft</Button><Button onclick={discardDraft}>{hasLinkedSetup ? 'Discard draft and use linked files' : 'Discard draft'}</Button></div></section>
|
||||
{:else if !eligibleFiles.length && !flash}<EmptyState title="No files ready to test" description="Upload a file version or finish its active beta, then return here."/><Button href="/scripts">Open Library</Button>
|
||||
{:else if scripts.length}
|
||||
<ol class="steps" aria-label="Beta setup progress">{#each steps as label,index}<li aria-current={step===index?'step':undefined} class:current={step===index}><span>{index+1}</span>{label}</li>{/each}</ol>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
@@ -210,7 +211,7 @@
|
||||
let historyByDay = $derived.by(() => {
|
||||
const groups = new Map<string, Deployment[]>();
|
||||
for (const d of history.slice(0,limit)) {
|
||||
const key = new Date(d.started_at).toDateString();
|
||||
const key = $timeFormat.date(d.started_at);
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(d);
|
||||
}
|
||||
@@ -283,7 +284,7 @@
|
||||
|
||||
{#if driftOpen}
|
||||
<div class="drift-body" id="drift-body">
|
||||
{#if drift}<p class="scan-context">Scanned {new Date(drift.scannedAt).toLocaleString()}. Selection controls the action scope; search only changes the visible list.</p>{/if}
|
||||
{#if drift}<p class="scan-context">Scanned {$timeFormat.dateTime(drift.scannedAt)}. Selection controls the action scope; search only changes the visible list.</p>{/if}
|
||||
{#if drift && drift.totalDrift > 0}
|
||||
<input
|
||||
class="drift-filter" aria-label="Filter drift by server"
|
||||
@@ -385,7 +386,7 @@
|
||||
{:else if plan}
|
||||
<p><strong>{reviewAll ? 'Entire fleet' : 'Selected servers'}: {plan.servers.length} server{plan.servers.length === 1 ? '' : 's'}, {plan.files.length} file change{plan.files.length === 1 ? '' : 's'}.</strong></p>
|
||||
<p>Files change immediately. Each affected server restarts after a fresh check confirms it is empty, then waits for health verification. Untracked files will be removed.</p>
|
||||
<p class="scan-context">Reviewed {new Date(plan.scannedAt).toLocaleString()}. Changes to this plan require another review.</p>
|
||||
<p class="scan-context">Reviewed {$timeFormat.dateTime(plan.scannedAt)}. Changes to this plan require another review.</p>
|
||||
<div class="plan-files">{#each plan.servers as server}{@const files = plan.files.filter(file => file.server === server)}{#if files.length}<section><h3><ServerIdentity name={server} /></h3>{#each files as file}<div class="plan-file"><strong>{file.driftKind === 'untracked' ? 'Remove' : file.driftKind === 'missing' ? 'Restore' : 'Replace'} <code>{file.relPath}</code></strong><span>{file.beforeSize === null ? 'Absent' : `${file.beforeSize} bytes`} → {file.afterSize === null ? 'Removed' : `${file.afterSize} bytes`}</span><details><summary>File fingerprints</summary><p>Before: {file.beforeSha256 ?? 'Absent'}<br />After: {file.afterSha256 ?? 'Removed'}</p></details></div>{/each}</section>{/if}{/each}</div>
|
||||
{/if}
|
||||
{#snippet actions()}<Button variant="ghost" disabled={running || reviewing} onclick={() => reviewOpen = false}>Cancel</Button><Button variant="danger" disabled={running || reviewing || !plan?.files.length} loading={running} onclick={runDrift}>Apply {plan?.files.length ?? 0} reviewed changes</Button>{/snippet}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||||
@@ -101,11 +102,7 @@
|
||||
'neutral';
|
||||
}
|
||||
function fmt(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(undefined, {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||||
});
|
||||
return $timeFormat.dateTime(iso);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
@@ -121,7 +122,7 @@
|
||||
} catch (err) { blueprintError = (err as Error).message; }
|
||||
finally { removing = ''; }
|
||||
}
|
||||
function fmt(value: string) { return new Date(value).toLocaleString(); }
|
||||
function fmt(value: string) { return $timeFormat.dateTime(value); }
|
||||
function warnings(d: Deployment): string[] { try { return JSON.parse(d.warnings); } catch { return []; } }
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
@@ -106,7 +107,7 @@
|
||||
}
|
||||
$effect(() => { $page.params.id; detail = null; void load(); });
|
||||
onMount(() => pollVisible(() => { now = Date.now(); return load(); }, 5000, false));
|
||||
function fmt(iso: string) { return new Date(iso).toLocaleString(); }
|
||||
function fmt(iso: string) { return $timeFormat.dateTime(iso); }
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{deployment ? (deployment.action === 'destroy' ? 'Destroy ' : 'Create ') + deployment.server : 'Deployment'} | Electric Cherry</title></svelte:head>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
@@ -186,7 +187,7 @@
|
||||
<section class="rankings" class:compact={!fullTable} aria-labelledby="rankings-title" aria-busy={loading}>
|
||||
<div class="rankings-header">
|
||||
<div><h2 id="rankings-title">{metricLabel}</h2><p>{descriptions[metric]}</p></div>
|
||||
<div class="refresh-status" role="status">{loading ? 'Refreshing…' : checkedAt ? `Checked ${checkedAt.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', second: '2-digit' })}` : ''}<span>Refreshes every 15 seconds</span></div>
|
||||
<div class="refresh-status" role="status">{loading ? 'Refreshing…' : checkedAt ? `Checked ${$timeFormat.time(checkedAt)}` : ''}<span>Refreshes every 15 seconds</span></div>
|
||||
</div>
|
||||
{#if data.entries.length}
|
||||
<div class="mobile-view"><button class="text-button" aria-pressed={fullTable} onclick={() => fullTable = !fullTable}>{fullTable ? 'Show selected stat only' : 'Show all stats and player IDs'}</button></div>
|
||||
@@ -239,7 +240,7 @@
|
||||
<p>Records include active games and completed games. Filters apply to totals and rankings; player search only filters the rankings. Players are identified by their GUID within each game, so name changes keep their records together.</p>
|
||||
<p>Highest round includes mid-game joins. Total rounds counts round progression during observed participation, excluding earlier rounds. Joining at round 8 records a highest round of 8; reaching round 9 adds one to total rounds. The fleet total adds all players’ rounds.</p>
|
||||
<p>Kills, headshots, revives, and downs accumulate across recorded games. Highest reported score is the largest observed points balance, not the total points a player has earned. Equal values share a rank.</p>
|
||||
<p>Records cover telemetry saved since tracking began; older unrecorded games cannot be included.{#if data?.summary.firstTrackedAt}{' '}Tracking began <time datetime={data.summary.firstTrackedAt}>{new Date(data.summary.firstTrackedAt).toLocaleString()}</time> for this selection.{/if}{#if data?.summary.updatedAt}{' '}Latest report: <time datetime={data.summary.updatedAt}>{new Date(data.summary.updatedAt).toLocaleString()}</time>.{/if}</p>
|
||||
<p>Records cover telemetry saved since tracking began; older unrecorded games cannot be included.{#if data?.summary.firstTrackedAt}{' '}Tracking began <time datetime={data.summary.firstTrackedAt}>{$timeFormat.dateTime(data.summary.firstTrackedAt)}</time> for this selection.{/if}{#if data?.summary.updatedAt}{' '}Latest report: <time datetime={data.summary.updatedAt}>{$timeFormat.dateTime(data.summary.updatedAt)}</time>.{/if}</p>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { leaderboardMetrics, type PlayerProfile, type LeaderboardMetric } from '$lib/leaderboard.js';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
@@ -9,7 +10,7 @@
|
||||
const profile = $derived(data.profile);
|
||||
const stats = $derived(profile.stats);
|
||||
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
|
||||
const date = (value: string) => new Date(value).toLocaleString('en-US', { timeZone: 'UTC' }) + ' UTC';
|
||||
const date = (value: string) => $timeFormat.dateTime(value);
|
||||
const ratio = (value: number, total: number, percent = false) => total ? `${number.format(value / total * (percent ? 100 : 1))}${percent ? '%' : ''}` : '—';
|
||||
const historyColumns = ['highestRound', 'kills', 'roundsSurvived', 'headshots', 'revives', 'downs', 'bestScore'] as const satisfies readonly LeaderboardMetric[];
|
||||
let refreshing = $state(false);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
@@ -8,7 +9,7 @@
|
||||
const percent = (value: number, total: number) => total > 0 ? `${(100 * value / total).toFixed(1)}%` : '—';
|
||||
const hours = (value: number) => `${value.toFixed(1)} h`;
|
||||
const peak = $derived(Math.max(1, ...data.points.map(p => p.active + p.idle + p.unknown)));
|
||||
const label = (at: number) => new Date(at).toISOString().slice(0, data.days === 1 ? 16 : 10).replace('T', ' ');
|
||||
const label = (at: number) => $timeFormat.period(at);
|
||||
onMount(() => {
|
||||
const timer = setInterval(() => { if (!document.hidden) void invalidateAll(); }, 60_000);
|
||||
return () => clearInterval(timer);
|
||||
@@ -25,7 +26,7 @@
|
||||
<a href={`/metrics?days=${days}`} aria-current={data.days === days ? 'page' : undefined}>{days === 1 ? '24 hours' : `${days} days`}</a>
|
||||
{/each}
|
||||
</nav>
|
||||
<span>Updated {new Date(data.now).toISOString().slice(11, 19)} UTC · refreshes every minute</span>
|
||||
<span>Updated {$timeFormat.time(data.now)} · refreshes every minute</span>
|
||||
</div>
|
||||
|
||||
{#if data.servers.length === 0}
|
||||
@@ -40,19 +41,19 @@
|
||||
|
||||
<section>
|
||||
<h2>Utilization over time</h2>
|
||||
<p>Server-hours per {data.days === 1 ? 'hour' : 'day'} (UTC). Taller bars mean more time under observation.</p>
|
||||
<p>Server-hours per {data.days === 1 ? 'hour' : '24-hour period'}. Period starts are shown in {$timeZone.replaceAll('_', ' ')}. Taller bars mean more time under observation.</p>
|
||||
<div class="legend"><span><i class="active"></i>Utilized</span><span><i class="idle"></i>Idle</span><span><i class="unknown"></i>Unknown</span></div>
|
||||
<div class="chart" role="img" aria-label="Stacked server-hours chart. Exact values are in the time data table below.">
|
||||
{#each data.points as point}
|
||||
<div class="bar" title={`${label(point.at)} UTC: ${hours(point.active)} utilized, ${hours(point.idle)} idle, ${hours(point.unknown)} unknown`}>
|
||||
<div class="bar" title={`${label(point.at)}: ${hours(point.active)} utilized, ${hours(point.idle)} idle, ${hours(point.unknown)} unknown`}>
|
||||
<div class="unknown" style:height={`${point.unknown / peak * 100}%`}></div>
|
||||
<div class="idle" style:height={`${point.idle / peak * 100}%`}></div>
|
||||
<div class="active" style:height={`${point.active / peak * 100}%`}></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="axis"><span>{label(data.points[0].at)}</span><span>0–{hours(peak)} per bar</span><span>{label(data.points[data.points.length - 1].at)} UTC</span></div>
|
||||
<details><summary>View time data</summary><div class="table-wrap"><table><thead><tr><th>Period (UTC)</th><th>Utilized</th><th>Idle</th><th>Unknown</th><th>Player time</th></tr></thead><tbody>{#each data.points as point}<tr><td>{label(point.at)}</td><td>{hours(point.active)}</td><td>{hours(point.idle)}</td><td>{hours(point.unknown)}</td><td>{hours(point.playerHours)}</td></tr>{/each}</tbody></table></div></details>
|
||||
<div class="axis"><span>{label(data.points[0].at)}</span><span>0–{hours(peak)} per bar</span><span>{label(data.points[data.points.length - 1].at)}</span></div>
|
||||
<details><summary>View time data</summary><div class="table-wrap"><table><thead><tr><th>Period start ({$timeZone.replaceAll('_', ' ')})</th><th>Utilized</th><th>Idle</th><th>Unknown</th><th>Player time</th></tr></thead><tbody>{#each data.points as point}<tr><td>{label(point.at)}</td><td>{hours(point.active)}</td><td>{hours(point.idle)}</td><td>{hours(point.unknown)}</td><td>{hours(point.playerHours)}</td></tr>{/each}</tbody></table></div></details>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
@@ -53,7 +54,7 @@
|
||||
if (match && zombieMapBySlug(match[1], match[2])) { game = match[1] as ZombieGame; mapSlug = match[2]; lobby = Number(match[3]); }
|
||||
open = true;
|
||||
}
|
||||
function savedAt(value: string) { return new Date(value.includes('T') ? value : value.replace(' ', 'T') + 'Z').toLocaleString(); }
|
||||
function savedAt(value: string) { return $timeFormat.dateTime(value); }
|
||||
async function save(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!validId || !plutoKey.trim() || busy) return;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { timeFormat, timeZone, timeZonePersistenceError } from '$ui/stores/time-zone.js';
|
||||
import { availableTimeZones, DEFAULT_TIME_ZONE } from '$lib/time-zone.js';
|
||||
import { onMount } from 'svelte';
|
||||
import type { SystemStatus } from '$lib/system-status.js';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
@@ -7,6 +9,7 @@
|
||||
let data = $state<SystemStatus | null>(null);
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
const zones = $derived([...new Set([...availableTimeZones(), $timeZone])].sort());
|
||||
async function refresh(probe = false) {
|
||||
if (loading) return;
|
||||
loading = true; error = '';
|
||||
@@ -21,14 +24,27 @@
|
||||
const labels = { ready: 'Ready', attention: 'Needs attention', unconfigured: 'Not configured', unverified: 'Not verified' };
|
||||
</script>
|
||||
|
||||
<svelte:head><title>System status · Electric Cherry</title></svelte:head>
|
||||
<svelte:head><title>Settings · Electric Cherry</title></svelte:head>
|
||||
<section class="page">
|
||||
<SectionHeader title="System status" subtitle="Check your connections and the prerequisites for a playable server.">
|
||||
{#snippet actions()}<Button loading={loading} onclick={() => refresh(true)}>Check connections</Button>{/snippet}
|
||||
</SectionHeader>
|
||||
<SectionHeader title="Settings" subtitle="Choose how dates and times appear across Electric Cherry." />
|
||||
<section class="guide" aria-labelledby="display-heading">
|
||||
<h2 id="display-heading">Display preferences</h2>
|
||||
<label class="timezone">Timezone
|
||||
<select bind:value={$timeZone} aria-describedby="timezone-help">
|
||||
{#each zones as zone}<option value={zone}>{zone.replaceAll('_', ' ')}{zone === DEFAULT_TIME_ZONE ? ' (default)' : ''}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<p id="timezone-help">Dates and times throughout the app use this timezone, including daylight saving changes. Saved automatically in this browser.</p>
|
||||
{#if data}<p>Example: {$timeFormat.dateTime(data.checkedAt)}</p>{/if}
|
||||
{#if $timeZonePersistenceError}<p role="status">Browser storage is unavailable. Your timezone applies for this visit but cannot be saved.</p>{/if}
|
||||
</section>
|
||||
<div class="heading">
|
||||
<div><h2>System status</h2><p>Check your connections and the prerequisites for a playable server.</p></div>
|
||||
<Button loading={loading} onclick={() => refresh(true)}>Check connections</Button>
|
||||
</div>
|
||||
{#if error}<p class="ui-error" role="alert">{error}<Button size="sm" onclick={() => refresh()}>Retry</Button></p>{/if}
|
||||
{#if data}
|
||||
<p class="checked">Checked {new Date(data.checkedAt).toLocaleString()} · {data.readyServers} / {data.totalServers} servers ready</p>
|
||||
<p class="checked">Checked {$timeFormat.dateTime(data.checkedAt)} · {data.readyServers} / {data.totalServers} servers ready</p>
|
||||
<ol class="checks">{#each data.checks as check}<li><div class="heading"><h2>{check.title}</h2><Badge variant={check.state === 'ready' ? 'online' : check.state === 'unverified' ? 'neutral' : 'warning'}>{labels[check.state]}</Badge></div><p>{check.detail}</p>{#if check.href}<a href={check.href}>{check.action} →</a>{/if}</li>{/each}</ol>
|
||||
{:else if loading}<p role="status">Checking system setup…</p>{/if}
|
||||
<section id="setup-guide" class="guide"><h2>Connection setup</h2><p>Set these variables on the Electric Cherry container in Komodo, then restart the app to load the changes. Credentials are kept on the server and are never returned by this page.</p><dl><dt>Komodo</dt><dd><code>KOMODO_ADDRESS</code>, <code>KOMODO_API_KEY</code>, <code>KOMODO_API_SECRET</code>, and <code>KOMODO_TARGET_SERVER</code>. The target must match the host name in Komodo.</dd><dt>Game servers</dt><dd><code>RCON_PASSWORD</code> must match the game-server password. Set <code>RCON_HOST</code> if the default <code>host.docker.internal</code> does not reach the game host. Forward the allocated UDP game ports.</dd></dl></section>
|
||||
@@ -48,5 +64,7 @@
|
||||
dd { margin: 5px 0 0; }
|
||||
code { font-size: var(--text-xs); }
|
||||
.guide { scroll-margin-top: 90px; }
|
||||
.timezone { display: grid; gap: 8px; margin-top: 16px; font-size: var(--text-sm); max-width: 420px; }
|
||||
.timezone select { width: 100%; padding: 10px 12px; font: inherit; color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-control); border-radius: var(--radius-sm); }
|
||||
@media (max-width: 700px) { .checks { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user