Remove Activity and the notification system
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user