Remove Activity and the notification system
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
<script lang="ts">
|
||||
import Button from './primitives/Button.svelte';
|
||||
import { toast } from '../toast.js';
|
||||
let { value, label = 'ID' }: { value: string; label?: string } = $props();
|
||||
let copyStatus = $state('');
|
||||
$effect(() => { value; copyStatus = ''; });
|
||||
async function copy() {
|
||||
try { await navigator.clipboard.writeText(value); toast(`${label} copied.`, { variant: 'success' }); }
|
||||
catch { toast('Copy unavailable. Select and copy the text directly.', { variant: 'warning' }); }
|
||||
copyStatus = '';
|
||||
try { await navigator.clipboard.writeText(value); copyStatus = `${label} copied.`; }
|
||||
catch { copyStatus = 'Copy unavailable. Select and copy the text directly.'; }
|
||||
}
|
||||
</script>
|
||||
<div class="copy"><code>{value}</code><Button variant="ghost" size="sm" ariaLabel={`Copy ${label}`} onclick={copy}>Copy</Button></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>
|
||||
<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-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 { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { toast } from '$ui/toast.js';
|
||||
import { createLogRefresh, type LogResponse } from '$ui/log-refresh.js';
|
||||
import Tabs from './primitives/Tabs.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
@@ -33,11 +32,12 @@
|
||||
let loading = $state(false);
|
||||
let autoRefresh = $state(true);
|
||||
let refreshError = $state('');
|
||||
let copyStatus = $state('');
|
||||
let bodyEl = $state<HTMLPreElement | null>(null);
|
||||
let stickToBottom = $state(true);
|
||||
|
||||
const refresh = createLogRefresh({
|
||||
onReset: () => { data = {}; refreshError = ''; },
|
||||
onReset: () => { data = {}; refreshError = ''; copyStatus = ''; },
|
||||
onLoading: value => { loading = value; },
|
||||
onError: message => { refreshError = message; },
|
||||
onData: (key, body) => {
|
||||
@@ -67,8 +67,9 @@
|
||||
await tick(); if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
|
||||
}
|
||||
async function copy(value: string, message: string) {
|
||||
try { await navigator.clipboard.writeText(value); toast(message); }
|
||||
catch { toast('Could not copy. Select the log text to copy manually.', { variant: 'danger' }); }
|
||||
copyStatus = '';
|
||||
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() {
|
||||
const url = new URL(window.location.href); url.searchParams.set('tab', 'logs'); url.searchParams.set('log', active);
|
||||
@@ -134,6 +135,7 @@
|
||||
</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 id={uid + '-panel'} role="tabpanel" aria-labelledby={uid + '-' + active}>
|
||||
@@ -167,6 +169,7 @@
|
||||
.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 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); }
|
||||
mark { background: var(--state-warning); color: var(--surface-sunken); }
|
||||
.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 Button from './primitives/Button.svelte';
|
||||
import Badge from './primitives/Badge.svelte';
|
||||
import { toast } from '$ui/toast.js';
|
||||
|
||||
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>;
|
||||
@@ -71,12 +70,12 @@
|
||||
});
|
||||
const body = await response.json();
|
||||
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;
|
||||
await onimport?.(body);
|
||||
if (startBeta && window.location.pathname === startPath) await goto(body.betaUrl);
|
||||
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; }
|
||||
}
|
||||
</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 { page } from '$app/stores';
|
||||
import BrandMark from '$ui/components/BrandMark.svelte';
|
||||
import Toaster from '$ui/components/Toaster.svelte';
|
||||
import Confirmation from '$ui/components/Confirmation.svelte';
|
||||
import NotificationHistory from '$ui/components/NotificationHistory.svelte';
|
||||
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||||
import { notificationHistory, startNotificationHistory } from '$ui/toast.js';
|
||||
import { resolveConfirmation } from '$ui/confirm.js';
|
||||
import '$ui/styles/components.css';
|
||||
import { enableViewTransitions } from '$ui/motion/viewTransition.js';
|
||||
@@ -28,9 +25,7 @@
|
||||
let accountOpen = $state(false);
|
||||
let signingOut = $state(false);
|
||||
let signOutError = $state('');
|
||||
let notificationsOpen = $state(false);
|
||||
let helpOpen = $state(false);
|
||||
const unread = $derived($notificationHistory.filter(item => !item.read).length);
|
||||
let menuButton = $state<HTMLButtonElement>();
|
||||
let accountButton = $state<HTMLButtonElement>();
|
||||
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;
|
||||
}
|
||||
|
||||
function openUtility(panel: 'notifications' | 'help') {
|
||||
function openHelp() {
|
||||
if (menuOpen) { menuButton?.focus(); menuOpen = false; }
|
||||
if (panel === 'notifications') notificationsOpen = true;
|
||||
else helpOpen = true;
|
||||
helpOpen = true;
|
||||
}
|
||||
|
||||
beforeNavigate(() => performance.mark('ec:navigation-start'));
|
||||
@@ -64,18 +58,15 @@
|
||||
try {
|
||||
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.'); }
|
||||
try { sessionStorage.removeItem('electric-cherry:notifications'); } catch { /* Optional local history. */ }
|
||||
notificationHistory.set([]);
|
||||
window.location.assign('/login');
|
||||
} catch (cause) { signOutError = (cause as Error).message; signingOut = false; }
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!sessionReady) return;
|
||||
const stopNotifications = startNotificationHistory();
|
||||
const stopPipeline = startPipelineStream();
|
||||
const stopServers = startServerStream();
|
||||
return () => { stopNotifications(); stopPipeline?.(); stopServers?.(); };
|
||||
return () => { stopPipeline?.(); stopServers?.(); };
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -103,7 +94,7 @@
|
||||
const target = e.target as HTMLElement;
|
||||
if (target && (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable || target.closest('dialog'))) 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') {
|
||||
armed = true;
|
||||
@@ -215,7 +206,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
<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>
|
||||
{/if}
|
||||
</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}
|
||||
{@render children()}
|
||||
</main>
|
||||
<Toaster />
|
||||
<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.">
|
||||
<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>
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
try {
|
||||
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 { sessionStorage.removeItem('electric-cherry:notifications'); } catch { /* Optional history. */ }
|
||||
window.location.assign('/login');
|
||||
} catch (cause) { error = (cause as Error).message; signingOut = false; }
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import { toast } from '$ui/toast.js';
|
||||
import { createProvisioningRefresh, type ProvisioningRefreshState } from '$ui/provisioning-refresh.js';
|
||||
import { elapsedTime, isProvisioningDetail, provisioningOutcome, provisioningReturn } from '$ui/provisioning-presentation.js';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
@@ -30,6 +29,7 @@
|
||||
});
|
||||
let now = $state(Date.parse(initial.generatedAt));
|
||||
let launchOpen = $state(false);
|
||||
let copyStatus = $state('');
|
||||
const controller = createProvisioningRefresh<ProvisioningDetail>({ validate: isProvisioningDetail, onChange: value => { view = value; } });
|
||||
const detail = $derived(view.detail);
|
||||
const deployment = $derived(detail?.deployment);
|
||||
@@ -48,6 +48,7 @@
|
||||
const snapshot = data.detail;
|
||||
untrack(() => {
|
||||
launchOpen = false;
|
||||
copyStatus = '';
|
||||
now = Date.parse(snapshot.generatedAt);
|
||||
controller.setOperation(snapshot.deployment.id, snapshot, snapshot.generatedAt);
|
||||
});
|
||||
@@ -56,12 +57,13 @@
|
||||
onDestroy(() => controller.dispose());
|
||||
function fmt(value: string | null | undefined) { return value ? $timeFormat.dateTime(value) : 'Not recorded'; }
|
||||
async function copyLink() {
|
||||
copyStatus = '';
|
||||
try {
|
||||
const url = new URL($page.url);
|
||||
url.search = ''; url.hash = '';
|
||||
await navigator.clipboard.writeText(url.toString());
|
||||
toast('Operation link copied.', { variant: 'success' });
|
||||
} catch { toast('Copy unavailable. Copy this page’s address from your browser.', { variant: 'warning' }); }
|
||||
copyStatus = 'Operation link copied.';
|
||||
} catch { copyStatus = 'Copy unavailable. Copy this page’s address from your browser.'; }
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -74,7 +76,7 @@
|
||||
</SectionHeader>
|
||||
<div class="identity-row">
|
||||
{#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>
|
||||
|
||||
{#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