Remove Activity and the notification system

This commit is contained in:
2026-10-05 17:25:39 -04:00
parent 193383ddd5
commit b62fb5db30
11 changed files with 32 additions and 216 deletions
+4 -2
View File
@@ -33,17 +33,19 @@ The changes prioritize clear operational outcomes, reviewed action scope, recove
| 23. Blueprint coverage | Existing/queued inventory is distinguished from healthy playable servers, with linked coverage details. Collections group assignments and show a sticky key count and jump-to-missing-key action. Creation remains limited to the target shortfall. | | 23. Blueprint coverage | Existing/queued inventory is distinguished from healthy playable servers, with linked coverage details. Collections group assignments and show a sticky key count and jump-to-missing-key action. Creation remains limited to the target shortfall. |
| 24. Mobile overhead | Fleet avoids redundant headings/zero campaign summaries, empty libraries emphasize their next action, and Leaderboard totals collapse on phones with a compact selected-stat table. Long forms retain reachable actions and focus spacing. | | 24. Mobile overhead | Fleet avoids redundant headings/zero campaign summaries, empty libraries emphasize their next action, and Leaderboard totals collapse on phones with a compact selected-stat table. Long forms retain reachable actions and focus spacing. |
| 25. Consistent hierarchy | Editable controls use stronger dedicated boundaries. Routes have descriptive titles, operational language is clearer, keyboard shortcuts have a help panel, and selected overflow tabs scroll into view without moving the page. | | 25. Consistent hierarchy | Editable controls use stronger dedicated boundaries. Routes have descriptive titles, operational language is clearer, keyboard shortcuts have a help panel, and selected overflow tabs scroll into view without moving the page. |
| 26. Investigation/handoffs | Logs support literal search/highlighting, copy, tail download, deep links, explicit follow/refresh state and jump-to-latest. A bounded recent-notifications panel retains results in the current browser tab. Beta rows include labels, age and blocker context. | | 26. Investigation/handoffs | Logs support literal search/highlighting, copy, tail download, deep links, explicit follow/refresh state and jump-to-latest. Beta rows include labels, age and blocker context. |
The review explicitly made overlay-priority reordering and Leaderboard player history/recent-period views conditional on further design or operator demand. Those product extensions remain future discovery work; the composition explanations and mobile Leaderboard changes are implemented. The review explicitly made overlay-priority reordering and Leaderboard player history/recent-period views conditional on further design or operator demand. Those product extensions remain future discovery work; the composition explanations and mobile Leaderboard changes are implemented.
Activity and notifications have been removed. Copy results appear next to their controls; GitHub imports retain their inline completion and error states.
## API and operational boundaries ## API and operational boundaries
- Promotion uses `GET /api/betas/:id/promote` to review impact and `POST` with the returned token as `previewToken` to apply it. A changed plan returns HTTP 409 and needs renewed review. - Promotion uses `GET /api/betas/:id/promote` to review impact and `POST` with the returned token as `previewToken` to apply it. A changed plan returns HTTP 409 and needs renewed review.
- Reconciliation uses `POST /api/drift` with `dryRun: true` and optional `servers`, then executes with the returned `expectedFingerprint`. Omitted servers explicitly mean the entire fleet. A stale review returns HTTP 409; changes detected during application are reported as skipped files. - Reconciliation uses `POST /api/drift` with `dryRun: true` and optional `servers`, then executes with the returned `expectedFingerprint`. Omitted servers explicitly mean the entire fleet. A stale review returns HTTP 409; changes detected during application are reported as skipped files.
- `POST /api/deployments/:id/cancel` only withdraws a waiting removal. It does not restore a server after deletion has started. - `POST /api/deployments/:id/cancel` only withdraws a waiting removal. It does not restore a server after deletion has started.
- System status exposes no credential values. Its optional connection check verifies Komodo network reachability, not credential validity. Remote game-file volumes remain explicitly unverified. - System status exposes no credential values. Its optional connection check verifies Komodo network reachability, not credential validity. Remote game-file volumes remain explicitly unverified.
- Drafts contain nonsecret beta setup. Launch keys are not stored in browser drafts. Recent notifications use tab-scoped session storage, capped at 50 entries, and do not imply background push delivery. - Drafts contain nonsecret beta setup. Launch keys are not stored in browser drafts.
- Log search/export covers the loaded tail, not an entire remote file or a full diagnostic archive. - Log search/export covers the loaded tail, not an entire remote file or a full diagnostic archive.
## Validation ## Validation
+1 -1
View File
@@ -320,7 +320,7 @@ Add log search/highlighting, copy/download, and a deep link preserving the selec
Acceptance: operators can return to a completed notification or share a precise diagnostic view without reconstructing the investigation. Acceptance: operators can return to a completed notification or share a precise diagnostic view without reconstructing the investigation.
Sources: [LogPanel](../src/lib/ui/components/LogPanel.svelte); [notification store](../src/lib/ui/toast.ts); [watch handling](../src/lib/ui/stores/fleet.ts); [beta list](../src/routes/betas/+page.svelte); [Leaderboard](../src/routes/leaderboard/+page.svelte). Sources: [LogPanel](../src/lib/ui/components/LogPanel.svelte); [watch handling](../src/lib/ui/stores/fleet.ts); [beta list](../src/routes/betas/+page.svelte); [Leaderboard](../src/routes/leaderboard/+page.svelte).
## Suggested layout for operational detail pages ## Suggested layout for operational detail pages
+7 -5
View File
@@ -1,11 +1,13 @@
<script lang="ts"> <script lang="ts">
import Button from './primitives/Button.svelte'; import Button from './primitives/Button.svelte';
import { toast } from '../toast.js';
let { value, label = 'ID' }: { value: string; label?: string } = $props(); let { value, label = 'ID' }: { value: string; label?: string } = $props();
let copyStatus = $state('');
$effect(() => { value; copyStatus = ''; });
async function copy() { async function copy() {
try { await navigator.clipboard.writeText(value); toast(`${label} copied.`, { variant: 'success' }); } copyStatus = '';
catch { toast('Copy unavailable. Select and copy the text directly.', { variant: 'warning' }); } try { await navigator.clipboard.writeText(value); copyStatus = `${label} copied.`; }
catch { copyStatus = 'Copy unavailable. Select and copy the text directly.'; }
} }
</script> </script>
<div class="copy"><code>{value}</code><Button variant="ghost" size="sm" ariaLabel={`Copy ${label}`} onclick={copy}>Copy</Button></div> <div class="copy"><code>{value}</code><Button variant="ghost" size="sm" ariaLabel={`Copy ${label}`} onclick={copy}>Copy</Button>{#if copyStatus}<span class="copy-status" role="status">{copyStatus}</span>{/if}</div>
<style>.copy { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); color: var(--text-secondary); } code { font: var(--text-xs) var(--font-mono); user-select: all; overflow-wrap: anywhere; }</style> <style>.copy-status { font-size: var(--text-xs); } .copy { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); color: var(--text-secondary); } code { font: var(--text-xs) var(--font-mono); user-select: all; overflow-wrap: anywhere; }</style>
+7 -4
View File
@@ -2,7 +2,6 @@
import { tick, untrack, onMount, onDestroy } from 'svelte'; import { tick, untrack, onMount, onDestroy } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { toast } from '$ui/toast.js';
import { createLogRefresh, type LogResponse } from '$ui/log-refresh.js'; import { createLogRefresh, type LogResponse } from '$ui/log-refresh.js';
import Tabs from './primitives/Tabs.svelte'; import Tabs from './primitives/Tabs.svelte';
import Button from './primitives/Button.svelte'; import Button from './primitives/Button.svelte';
@@ -33,11 +32,12 @@
let loading = $state(false); let loading = $state(false);
let autoRefresh = $state(true); let autoRefresh = $state(true);
let refreshError = $state(''); let refreshError = $state('');
let copyStatus = $state('');
let bodyEl = $state<HTMLPreElement | null>(null); let bodyEl = $state<HTMLPreElement | null>(null);
let stickToBottom = $state(true); let stickToBottom = $state(true);
const refresh = createLogRefresh({ const refresh = createLogRefresh({
onReset: () => { data = {}; refreshError = ''; }, onReset: () => { data = {}; refreshError = ''; copyStatus = ''; },
onLoading: value => { loading = value; }, onLoading: value => { loading = value; },
onError: message => { refreshError = message; }, onError: message => { refreshError = message; },
onData: (key, body) => { onData: (key, body) => {
@@ -67,8 +67,9 @@
await tick(); if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight; await tick(); if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
} }
async function copy(value: string, message: string) { async function copy(value: string, message: string) {
try { await navigator.clipboard.writeText(value); toast(message); } copyStatus = '';
catch { toast('Could not copy. Select the log text to copy manually.', { variant: 'danger' }); } try { await navigator.clipboard.writeText(value); copyStatus = message; }
catch { copyStatus = 'Could not copy. Select the log text or browser address to copy manually.'; }
} }
function copyLink() { function copyLink() {
const url = new URL(window.location.href); url.searchParams.set('tab', 'logs'); url.searchParams.set('log', active); const url = new URL(window.location.href); url.searchParams.set('tab', 'logs'); url.searchParams.set('log', active);
@@ -134,6 +135,7 @@
</div> </div>
</div> </div>
<div class="log-tools"><label class="log-search">Search loaded lines<input type="search" bind:value={query} placeholder="Find text in this log" /></label><div class="log-actions"><Button size="sm" onclick={copyLink}>Copy link</Button><Button size="sm" disabled={!current?.lines?.length} onclick={() => copy(visibleLines.join('\n'), 'Log text copied.')}>Copy {query ? 'matches' : 'log'}</Button><Button size="sm" disabled={!current?.lines?.length} onclick={download}>Download tail</Button></div></div> <div class="log-tools"><label class="log-search">Search loaded lines<input type="search" bind:value={query} placeholder="Find text in this log" /></label><div class="log-actions"><Button size="sm" onclick={copyLink}>Copy link</Button><Button size="sm" disabled={!current?.lines?.length} onclick={() => copy(visibleLines.join('\n'), 'Log text copied.')}>Copy {query ? 'matches' : 'log'}</Button><Button size="sm" disabled={!current?.lines?.length} onclick={download}>Download tail</Button></div></div>
{#if copyStatus}<p class="copy-status" role="status">{copyStatus}</p>{/if}
<div class="follow-state"><span>{!autoRefresh ? 'Refresh paused' : 'Auto-refresh on'} · {query ? `${visibleLines.length} matching lines` : stickToBottom ? 'Following latest lines' : 'Reading earlier lines'}</span>{#if !stickToBottom || query}<Button size="sm" variant="ghost" onclick={jumpToLatest}>Jump to latest</Button>{/if}</div> <div class="follow-state"><span>{!autoRefresh ? 'Refresh paused' : 'Auto-refresh on'} · {query ? `${visibleLines.length} matching lines` : stickToBottom ? 'Following latest lines' : 'Reading earlier lines'}</span>{#if !stickToBottom || query}<Button size="sm" variant="ghost" onclick={jumpToLatest}>Jump to latest</Button>{/if}</div>
<div id={uid + '-panel'} role="tabpanel" aria-labelledby={uid + '-' + active}> <div id={uid + '-panel'} role="tabpanel" aria-labelledby={uid + '-' + active}>
@@ -167,6 +169,7 @@
.log-tools { align-items: end; } .log-tools { align-items: end; }
.log-search { display: grid; gap: 6px; flex: 1; min-width: 180px; font-size: var(--text-xs); color: var(--text-secondary); } .log-search { display: grid; gap: 6px; flex: 1; min-width: 180px; font-size: var(--text-xs); color: var(--text-secondary); }
.log-search input { background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); border-radius: var(--radius-sm); padding: 9px 12px; font: inherit; } .log-search input { background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); border-radius: var(--radius-sm); padding: 9px 12px; font: inherit; }
.copy-status { margin: 0; font-size: var(--text-xs); color: var(--text-secondary); }
.follow-state { justify-content: space-between; font-size: var(--text-xs); color: var(--text-secondary); } .follow-state { justify-content: space-between; font-size: var(--text-xs); color: var(--text-secondary); }
mark { background: var(--state-warning); color: var(--surface-sunken); } mark { background: var(--state-warning); color: var(--surface-sunken); }
.log-panel { .log-panel {
@@ -1,25 +0,0 @@
<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import Dialog from './primitives/Dialog.svelte';
import Button from './primitives/Button.svelte';
import { notificationHistory, markNotificationsRead, clearNotifications } from '$ui/toast.js';
let { open = $bindable(false) }: { open?: boolean } = $props();
$effect(() => { if (open) markNotificationsRead(); });
</script>
<Dialog bind:open title="Recent notifications" description="Recent action results and completed lobby watches in this browser tab. Watches run while the app is open.">
{#if $notificationHistory.length}
<ol>{#each $notificationHistory as item (item.id)}<li class:attention={item.variant === 'danger' || item.variant === 'warning'}><p>{item.message}</p><time datetime={item.createdAt}>{$timeFormat.dateTime(item.createdAt)}</time>{#if item.href}<a href={item.href} onclick={() => open = false}>View details →</a>{/if}</li>{/each}</ol>
{:else}<p class="empty">No recent notifications. Completed lobby watches and action results will appear here.</p>{/if}
{#snippet actions()}<Button disabled={!$notificationHistory.length} onclick={clearNotifications}>Clear history</Button><Button onclick={() => open = false}>Close</Button>{/snippet}
</Dialog>
<style>
ol { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; }
li { border: 1px solid var(--border-strong); padding: 12px; border-radius: var(--radius-md); }
li.attention { border-color: var(--state-warning); }
p { margin: 0 0 8px; font-size: var(--text-sm); }
time { display: block; color: var(--text-secondary); font-size: var(--text-xs); }
a { display: inline-block; margin-top: 8px; font-size: var(--text-sm); }
.empty { color: var(--text-secondary); }
</style>
@@ -6,7 +6,6 @@
import Card from './primitives/Card.svelte'; import Card from './primitives/Card.svelte';
import Button from './primitives/Button.svelte'; import Button from './primitives/Button.svelte';
import Badge from './primitives/Badge.svelte'; import Badge from './primitives/Badge.svelte';
import { toast } from '$ui/toast.js';
let { pluginId, targetPath, disabled = false, busy = $bindable(false), onimport }: { let { pluginId, targetPath, disabled = false, busy = $bindable(false), onimport }: {
pluginId?: number; targetPath?: string; disabled?: boolean; busy?: boolean; onimport?: (result: { pluginId: number; betaUrl: string; alreadyImported: boolean }) => void | Promise<void>; pluginId?: number; targetPath?: string; disabled?: boolean; busy?: boolean; onimport?: (result: { pluginId: number; betaUrl: string; alreadyImported: boolean }) => void | Promise<void>;
@@ -71,12 +70,12 @@
}); });
const body = await response.json(); const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Import failed.'); if (!response.ok) throw new Error(body.error ?? 'Import failed.');
if (!active) { toast('Plugin imported. Review its version when ready.', { variant: 'success', href: `/plugins/${body.pluginId}` }); return; } if (!active) return;
success = body; success = body;
await onimport?.(body); await onimport?.(body);
if (startBeta && window.location.pathname === startPath) await goto(body.betaUrl); if (startBeta && window.location.pathname === startPath) await goto(body.betaUrl);
else if (pluginId) await checkUpdates(); else if (pluginId) await checkUpdates();
} catch (e) { error = (e as Error).message; if (!active) toast(`Plugin import failed: ${error}`, { variant: 'danger', href: '/plugins' }); } } catch (e) { error = (e as Error).message; }
finally { busy = false; } finally { busy = false; }
} }
</script> </script>
-88
View File
@@ -1,88 +0,0 @@
<script lang="ts">
import { fly, fade } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import { toasts, dismiss } from '$ui/toast.js';
</script>
<div class="toaster" role="region" aria-label="Notifications" aria-live="polite">
{#each $toasts as t (t.id)}
<div
class="toast v-{t.variant}"
in:fly={{ y: 16, duration: 240, easing: cubicOut }}
out:fade={{ duration: 160 }}
>
<span class="dot" aria-hidden="true"></span>
<span class="msg">{t.message}</span>
{#if t.href}<a href={t.href} onclick={() => dismiss(t.id)}>View</a>{/if}
<button
type="button"
class="close"
onclick={() => dismiss(t.id)}
aria-label="Dismiss notification"
>×</button>
</div>
{/each}
</div>
<style>
.toaster {
position: fixed;
bottom: var(--space-6);
right: var(--space-6);
z-index: 200;
display: flex;
flex-direction: column;
gap: var(--space-2);
pointer-events: none;
max-width: min(420px, calc(100vw - 2 * var(--space-6)));
}
.toast {
pointer-events: auto;
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: color-mix(in oklab, var(--surface-raised) 92%, transparent);
color: var(--text-primary);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
box-shadow: var(--shadow-raised);
font-size: var(--text-sm);
}
@supports (backdrop-filter: blur(4px)) {
.toast {
backdrop-filter: blur(12px) saturate(1.4);
-webkit-backdrop-filter: blur(12px) saturate(1.4);
background: color-mix(in oklab, var(--surface-raised) 78%, transparent);
}
}
.dot {
width: 8px; height: 8px;
border-radius: 50%;
flex-shrink: 0;
background: var(--text-tertiary);
}
.v-success .dot { background: var(--state-online); box-shadow: 0 0 4px rgba(124, 212, 255, 0.5); }
.v-warning .dot { background: var(--state-warning); }
.v-danger .dot { background: var(--state-danger); }
.v-info .dot { background: var(--accent-primary); }
.msg { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.close {
appearance: none;
background: transparent;
border: none;
color: var(--text-tertiary);
font-size: var(--text-lg);
line-height: 1;
cursor: pointer;
padding: 0 var(--space-1);
border-radius: var(--radius-sm);
transition: color var(--dur-fast) var(--ease-standard);
}
.close:hover { color: var(--text-primary); }
.close:focus-visible { outline: none; box-shadow: var(--focus-ring); }
@media (max-width: 720px) {
.toaster { left: max(16px, env(safe-area-inset-left)); right: max(16px, env(safe-area-inset-right)); bottom: calc(16px + env(safe-area-inset-bottom)); max-width: none; }
.close { min-width: 44px; flex-shrink: 0; }
}
</style>
-67
View File
@@ -1,67 +0,0 @@
import { writable } from 'svelte/store';
// Small global toast queue.
//
// Any part of the UI can drop a message and it appears in the corner for a
// couple of seconds, then quietly dismisses. Replaces the ad-hoc "flash"
// strings scattered across pages when the message doesn't need to live inline
// with a form.
export type ToastVariant = 'info' | 'success' | 'warning' | 'danger';
export interface Toast {
id: number;
message: string;
variant: ToastVariant;
ttl: number;
href?: string;
}
export const toasts = writable<Toast[]>([]);
export interface Notice extends Toast { createdAt: string; read: boolean; }
export const notificationHistory = writable<Notice[]>([]);
const historyKey = 'electric-cherry:notifications';
export function startNotificationHistory(): () => void {
try {
const saved: unknown = JSON.parse(sessionStorage.getItem(historyKey) ?? '[]');
if (Array.isArray(saved)) {
const valid = saved.filter((item): item is Notice => item && typeof item.id === 'number' && Number.isSafeInteger(item.id)
&& typeof item.message === 'string' && ['info', 'success', 'warning', 'danger'].includes(item.variant)
&& typeof item.createdAt === 'string' && Number.isFinite(Date.parse(item.createdAt))
&& typeof item.read === 'boolean' && (!item.href || (typeof item.href === 'string' && item.href.startsWith('/') && !item.href.startsWith('//')))).slice(0, 50);
notificationHistory.set(valid);
nextId = Math.max(nextId, ...valid.map(item => item.id + 1));
}
} catch { /* Notifications still work when browser storage is unavailable. */ }
return notificationHistory.subscribe(items => { try { sessionStorage.setItem(historyKey, JSON.stringify(items)); } catch { /* Optional persistence. */ } });
}
export function markNotificationsRead() { notificationHistory.update(items => items.map(item => ({ ...item, read: true }))); }
export function clearNotifications() { notificationHistory.set([]); }
let nextId = 1;
export function toast(
message: string,
opts: { variant?: ToastVariant; ttl?: number; href?: string } = {}
): void {
const t: Toast = {
id: nextId++,
message,
variant: opts.variant ?? 'info',
ttl: opts.ttl ?? 3500,
href: opts.href
};
toasts.update((arr) => [...arr, t]);
if (t.href || t.ttl === 0 || t.variant !== 'info') {
notificationHistory.update(items => [{ ...t, createdAt: new Date().toISOString(), read: false }, ...items].slice(0, 50));
}
if (t.ttl > 0) {
setTimeout(() => dismiss(t.id), t.ttl);
}
}
export function dismiss(id: number): void {
toasts.update((arr) => arr.filter((t) => t.id !== id));
}
+5 -16
View File
@@ -7,11 +7,8 @@
import { startFleetMonitor, hydrateFleet } from '$ui/stores/fleet.js'; import { startFleetMonitor, hydrateFleet } from '$ui/stores/fleet.js';
import { page } from '$app/stores'; import { page } from '$app/stores';
import BrandMark from '$ui/components/BrandMark.svelte'; import BrandMark from '$ui/components/BrandMark.svelte';
import Toaster from '$ui/components/Toaster.svelte';
import Confirmation from '$ui/components/Confirmation.svelte'; import Confirmation from '$ui/components/Confirmation.svelte';
import NotificationHistory from '$ui/components/NotificationHistory.svelte';
import Dialog from '$ui/components/primitives/Dialog.svelte'; import Dialog from '$ui/components/primitives/Dialog.svelte';
import { notificationHistory, startNotificationHistory } from '$ui/toast.js';
import { resolveConfirmation } from '$ui/confirm.js'; import { resolveConfirmation } from '$ui/confirm.js';
import '$ui/styles/components.css'; import '$ui/styles/components.css';
import { enableViewTransitions } from '$ui/motion/viewTransition.js'; import { enableViewTransitions } from '$ui/motion/viewTransition.js';
@@ -28,9 +25,7 @@
let accountOpen = $state(false); let accountOpen = $state(false);
let signingOut = $state(false); let signingOut = $state(false);
let signOutError = $state(''); let signOutError = $state('');
let notificationsOpen = $state(false);
let helpOpen = $state(false); let helpOpen = $state(false);
const unread = $derived($notificationHistory.filter(item => !item.read).length);
let menuButton = $state<HTMLButtonElement>(); let menuButton = $state<HTMLButtonElement>();
let accountButton = $state<HTMLButtonElement>(); let accountButton = $state<HTMLButtonElement>();
const accountName = $derived(data.user?.displayName || data.user?.username || 'Account'); const accountName = $derived(data.user?.displayName || data.user?.username || 'Account');
@@ -40,10 +35,9 @@
if (event.target instanceof Element && !event.target.closest('.account-menu')) accountOpen = false; if (event.target instanceof Element && !event.target.closest('.account-menu')) accountOpen = false;
} }
function openUtility(panel: 'notifications' | 'help') { function openHelp() {
if (menuOpen) { menuButton?.focus(); menuOpen = false; } if (menuOpen) { menuButton?.focus(); menuOpen = false; }
if (panel === 'notifications') notificationsOpen = true; helpOpen = true;
else helpOpen = true;
} }
beforeNavigate(() => performance.mark('ec:navigation-start')); beforeNavigate(() => performance.mark('ec:navigation-start'));
@@ -64,18 +58,15 @@
try { try {
const response = await fetch('/api/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); const response = await fetch('/api/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
if (!response.ok) { const body = await response.json(); throw new Error(body.error ?? 'Could not sign out. Try again.'); } if (!response.ok) { const body = await response.json(); throw new Error(body.error ?? 'Could not sign out. Try again.'); }
try { sessionStorage.removeItem('electric-cherry:notifications'); } catch { /* Optional local history. */ }
notificationHistory.set([]);
window.location.assign('/login'); window.location.assign('/login');
} catch (cause) { signOutError = (cause as Error).message; signingOut = false; } } catch (cause) { signOutError = (cause as Error).message; signingOut = false; }
} }
$effect(() => { $effect(() => {
if (!sessionReady) return; if (!sessionReady) return;
const stopNotifications = startNotificationHistory();
const stopPipeline = startPipelineStream(); const stopPipeline = startPipelineStream();
const stopServers = startServerStream(); const stopServers = startServerStream();
return () => { stopNotifications(); stopPipeline?.(); stopServers?.(); }; return () => { stopPipeline?.(); stopServers?.(); };
}); });
$effect(() => { $effect(() => {
@@ -103,7 +94,7 @@
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (target && (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable || target.closest('dialog'))) return; if (target && (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable || target.closest('dialog'))) return;
if (e.metaKey || e.ctrlKey || e.altKey) return; if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key === '?') { e.preventDefault(); openUtility('help'); return; } if (e.key === '?') { e.preventDefault(); openHelp(); return; }
if (!armed && e.key === 'g') { if (!armed && e.key === 'g') {
armed = true; armed = true;
@@ -215,7 +206,7 @@
{/each} {/each}
</div> </div>
<div class="nav-tools" class:expanded={menuOpen}> <div class="nav-tools" class:expanded={menuOpen}>
<button class="utility" aria-label={`Recent notifications${unread ? `, ${unread} unread` : ''}`} onclick={() => openUtility('notifications')}>Activity{unread ? ` · ${unread}` : ''}</button><button class="utility" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)" onclick={() => openUtility('help')}>?</button> <button class="utility" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)" onclick={() => openHelp()}>?</button>
</div> </div>
{/if} {/if}
</nav> </nav>
@@ -225,9 +216,7 @@
{#if sessionReady && data.user?.role === 'viewer'}<aside class="viewer-note"><strong>Viewer access</strong><span>You can read fleet status and summaries. Operating servers or viewing raw configs, file changes, logs, and telemetry requires an operator account.</span></aside>{/if} {#if sessionReady && data.user?.role === 'viewer'}<aside class="viewer-note"><strong>Viewer access</strong><span>You can read fleet status and summaries. Operating servers or viewing raw configs, file changes, logs, and telemetry requires an operator account.</span></aside>{/if}
{@render children()} {@render children()}
</main> </main>
<Toaster />
<Confirmation /> <Confirmation />
<NotificationHistory bind:open={notificationsOpen} />
<Dialog bind:open={helpOpen} title="Keyboard shortcuts" description="Press g, then the destination key. Shortcuts are inactive while typing or using a dialog."> <Dialog bind:open={helpOpen} title="Keyboard shortcuts" description="Press g, then the destination key. Shortcuts are inactive while typing or using a dialog.">
<dl class="shortcuts">{#each [['g f','Fleet'],['g o','Hosts'],['g s','Scripts'],['g c','Configs'],['g p','Plugins'],['g d','Provisioning'],['g a','Campaigns'],['g w','Maintenance'],['g b','Betas'],...(data.user?.role === 'admin' ? [['g h','Settings'],['g k','Keys']] : []),['g l','Leaderboard'],['g m','Metrics'],['?','This help']] as [keys,label]}<div><dt><kbd>{keys}</kbd></dt><dd>{label}</dd></div>{/each}</dl> <dl class="shortcuts">{#each [['g f','Fleet'],['g o','Hosts'],['g s','Scripts'],['g c','Configs'],['g p','Plugins'],['g d','Provisioning'],['g a','Campaigns'],['g w','Maintenance'],['g b','Betas'],...(data.user?.role === 'admin' ? [['g h','Settings'],['g k','Keys']] : []),['g l','Leaderboard'],['g m','Metrics'],['?','This help']] as [keys,label]}<div><dt><kbd>{keys}</kbd></dt><dd>{label}</dd></div>{/each}</dl>
</Dialog> </Dialog>
-1
View File
@@ -24,7 +24,6 @@
try { try {
const response = await fetch('/api/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); const response = await fetch('/api/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
if (!response.ok) { const body = await response.json(); throw new Error(body.error ?? 'Could not sign out.'); } if (!response.ok) { const body = await response.json(); throw new Error(body.error ?? 'Could not sign out.'); }
try { sessionStorage.removeItem('electric-cherry:notifications'); } catch { /* Optional history. */ }
window.location.assign('/login'); window.location.assign('/login');
} catch (cause) { error = (cause as Error).message; signingOut = false; } } catch (cause) { error = (cause as Error).message; signingOut = false; }
} }
+6 -4
View File
@@ -7,7 +7,6 @@
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import { serverTitle } from '$ui/server-labels.js'; import { serverTitle } from '$ui/server-labels.js';
import { toast } from '$ui/toast.js';
import { createProvisioningRefresh, type ProvisioningRefreshState } from '$ui/provisioning-refresh.js'; import { createProvisioningRefresh, type ProvisioningRefreshState } from '$ui/provisioning-refresh.js';
import { elapsedTime, isProvisioningDetail, provisioningOutcome, provisioningReturn } from '$ui/provisioning-presentation.js'; import { elapsedTime, isProvisioningDetail, provisioningOutcome, provisioningReturn } from '$ui/provisioning-presentation.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
@@ -30,6 +29,7 @@
}); });
let now = $state(Date.parse(initial.generatedAt)); let now = $state(Date.parse(initial.generatedAt));
let launchOpen = $state(false); let launchOpen = $state(false);
let copyStatus = $state('');
const controller = createProvisioningRefresh<ProvisioningDetail>({ validate: isProvisioningDetail, onChange: value => { view = value; } }); const controller = createProvisioningRefresh<ProvisioningDetail>({ validate: isProvisioningDetail, onChange: value => { view = value; } });
const detail = $derived(view.detail); const detail = $derived(view.detail);
const deployment = $derived(detail?.deployment); const deployment = $derived(detail?.deployment);
@@ -48,6 +48,7 @@
const snapshot = data.detail; const snapshot = data.detail;
untrack(() => { untrack(() => {
launchOpen = false; launchOpen = false;
copyStatus = '';
now = Date.parse(snapshot.generatedAt); now = Date.parse(snapshot.generatedAt);
controller.setOperation(snapshot.deployment.id, snapshot, snapshot.generatedAt); controller.setOperation(snapshot.deployment.id, snapshot, snapshot.generatedAt);
}); });
@@ -56,12 +57,13 @@
onDestroy(() => controller.dispose()); onDestroy(() => controller.dispose());
function fmt(value: string | null | undefined) { return value ? $timeFormat.dateTime(value) : 'Not recorded'; } function fmt(value: string | null | undefined) { return value ? $timeFormat.dateTime(value) : 'Not recorded'; }
async function copyLink() { async function copyLink() {
copyStatus = '';
try { try {
const url = new URL($page.url); const url = new URL($page.url);
url.search = ''; url.hash = ''; url.search = ''; url.hash = '';
await navigator.clipboard.writeText(url.toString()); await navigator.clipboard.writeText(url.toString());
toast('Operation link copied.', { variant: 'success' }); copyStatus = 'Operation link copied.';
} catch { toast('Copy unavailable. Copy this page’s address from your browser.', { variant: 'warning' }); } } catch { copyStatus = 'Copy unavailable. Copy this page’s address from your browser.'; }
} }
</script> </script>
@@ -74,7 +76,7 @@
</SectionHeader> </SectionHeader>
<div class="identity-row"> <div class="identity-row">
{#if deployment}<div class="operation-id"><span>Operation</span><CopyValue value={deployment.id} label="operation ID" /></div>{/if} {#if deployment}<div class="operation-id"><span>Operation</span><CopyValue value={deployment.id} label="operation ID" /></div>{/if}
<div class="identity-links"><a href={returnTo.href}>← {returnTo.label}</a><Button size="sm" variant="ghost" onclick={copyLink}>Copy link</Button></div> <div class="identity-links"><a href={returnTo.href}>← {returnTo.label}</a><Button size="sm" variant="ghost" onclick={copyLink}>Copy link</Button>{#if copyStatus}<span role="status">{copyStatus}</span>{/if}</div>
</div> </div>
{#if view.loadError}<div class="refresh-error" role="alert"><div><strong>Refresh unavailable</strong><p>{view.loadError} {detail ? 'Showing the last successfully loaded record.' : ''}</p></div><Button size="sm" disabled={view.refreshing || !!view.busy} onclick={() => controller.forceRefresh()}>Retry refresh</Button></div>{/if} {#if view.loadError}<div class="refresh-error" role="alert"><div><strong>Refresh unavailable</strong><p>{view.loadError} {detail ? 'Showing the last successfully loaded record.' : ''}</p></div><Button size="sm" disabled={view.refreshing || !!view.busy} onclick={() => controller.forceRefresh()}>Retry refresh</Button></div>{/if}