feat: add sync status indicator and warning banner
- Add SyncStatusIndicator component showing connection status in TopNav - Add SyncWarningBanner that appears after 30s of backend disconnection - Green dot when synced, amber pulsing when syncing, red when error/offline - Warning banner is dismissible but reappears on next failure Closes #11
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* SyncStatusIndicator.svelte - Compact sync status indicator for TopNav
|
||||
* Shows connection status with backend: synced, syncing, error, or offline
|
||||
*/
|
||||
import { syncState } from '$lib/backend';
|
||||
|
||||
/** Computed status for display */
|
||||
let displayStatus = $derived.by(() => {
|
||||
if (syncState.status === 'offline' || !syncState.isOnline) {
|
||||
return 'offline';
|
||||
}
|
||||
if (syncState.status === 'error') {
|
||||
return 'error';
|
||||
}
|
||||
if (syncState.status === 'syncing') {
|
||||
return 'syncing';
|
||||
}
|
||||
return 'synced';
|
||||
});
|
||||
|
||||
/** Tooltip text based on status */
|
||||
let tooltipText = $derived.by(() => {
|
||||
switch (displayStatus) {
|
||||
case 'offline':
|
||||
return 'Backend offline - data stored locally only';
|
||||
case 'error':
|
||||
return syncState.lastError
|
||||
? `Sync error: ${syncState.lastError}`
|
||||
: 'Sync error - check backend connection';
|
||||
case 'syncing':
|
||||
return 'Syncing...';
|
||||
case 'synced':
|
||||
if (syncState.lastSyncTime) {
|
||||
const ago = getTimeAgo(syncState.lastSyncTime);
|
||||
return `Synced ${ago}`;
|
||||
}
|
||||
return 'Synced';
|
||||
}
|
||||
});
|
||||
|
||||
/** Format relative time */
|
||||
function getTimeAgo(date: Date): string {
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
if (seconds < 60) return 'just now';
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
||||
return `${Math.floor(seconds / 86400)}d ago`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative flex items-center" title={tooltipText}>
|
||||
<!-- Status dot -->
|
||||
<span
|
||||
class="inline-block h-2 w-2 rounded-full {displayStatus === 'synced'
|
||||
? 'bg-emerald-500'
|
||||
: displayStatus === 'syncing'
|
||||
? 'animate-pulse bg-amber-500'
|
||||
: 'bg-red-500'}"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
|
||||
<!-- Pending count badge (only when error/offline with pending items) -->
|
||||
{#if (displayStatus === 'error' || displayStatus === 'offline') && syncState.pendingCount > 0}
|
||||
<span
|
||||
class="ml-1 rounded-full bg-red-500/20 px-1.5 py-0.5 text-[10px] font-medium text-red-500"
|
||||
>
|
||||
{syncState.pendingCount}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -9,6 +9,7 @@
|
||||
import ExportDialog from '$lib/components/shared/ExportDialog.svelte';
|
||||
import ConfirmDialog from '$lib/components/shared/ConfirmDialog.svelte';
|
||||
import ContextUsageBar from '$lib/components/chat/ContextUsageBar.svelte';
|
||||
import SyncStatusIndicator from './SyncStatusIndicator.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Slot for the model select dropdown */
|
||||
@@ -167,8 +168,13 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Right section: Theme toggle + Chat actions -->
|
||||
<!-- Right section: Sync status + Theme toggle + Chat actions -->
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Sync status indicator (always visible) -->
|
||||
<div class="mr-1 px-2">
|
||||
<SyncStatusIndicator />
|
||||
</div>
|
||||
|
||||
<!-- Theme toggle (always visible) -->
|
||||
<button
|
||||
type="button"
|
||||
|
||||
154
frontend/src/lib/components/shared/SyncWarningBanner.svelte
Normal file
154
frontend/src/lib/components/shared/SyncWarningBanner.svelte
Normal file
@@ -0,0 +1,154 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* SyncWarningBanner.svelte - Warning banner for sync failures
|
||||
* Shows when backend is disconnected for >30 seconds continuously
|
||||
*/
|
||||
import { syncState } from '$lib/backend';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
/** Threshold before showing banner (30 seconds) */
|
||||
const FAILURE_THRESHOLD_MS = 30_000;
|
||||
|
||||
/** Track when failure started */
|
||||
let failureStartTime = $state<number | null>(null);
|
||||
|
||||
/** Whether banner has been dismissed for this failure period */
|
||||
let isDismissed = $state(false);
|
||||
|
||||
/** Whether enough time has passed to show banner */
|
||||
let thresholdReached = $state(false);
|
||||
|
||||
/** Interval for checking threshold */
|
||||
let checkInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** Check if we're in a failure state */
|
||||
let isInFailureState = $derived(
|
||||
syncState.status === 'error' || syncState.status === 'offline' || !syncState.isOnline
|
||||
);
|
||||
|
||||
/** Should show the banner */
|
||||
let shouldShow = $derived(isInFailureState && thresholdReached && !isDismissed);
|
||||
|
||||
/** Watch for failure state changes */
|
||||
$effect(() => {
|
||||
if (isInFailureState) {
|
||||
// Start tracking failure time if not already
|
||||
if (failureStartTime === null) {
|
||||
failureStartTime = Date.now();
|
||||
isDismissed = false;
|
||||
thresholdReached = false;
|
||||
|
||||
// Start interval to check threshold
|
||||
if (checkInterval) clearInterval(checkInterval);
|
||||
checkInterval = setInterval(() => {
|
||||
if (failureStartTime && Date.now() - failureStartTime >= FAILURE_THRESHOLD_MS) {
|
||||
thresholdReached = true;
|
||||
if (checkInterval) {
|
||||
clearInterval(checkInterval);
|
||||
checkInterval = null;
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
} else {
|
||||
// Reset on recovery
|
||||
failureStartTime = null;
|
||||
isDismissed = false;
|
||||
thresholdReached = false;
|
||||
if (checkInterval) {
|
||||
clearInterval(checkInterval);
|
||||
checkInterval = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
return () => {
|
||||
if (checkInterval) {
|
||||
clearInterval(checkInterval);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/** Dismiss the banner */
|
||||
function handleDismiss() {
|
||||
isDismissed = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if shouldShow}
|
||||
<div
|
||||
class="fixed left-0 right-0 top-12 z-50 flex items-center justify-center px-4 animate-in"
|
||||
role="alert"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-2 text-red-400 shadow-lg backdrop-blur-sm"
|
||||
>
|
||||
<!-- Warning icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 flex-shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Message -->
|
||||
<span class="text-sm font-medium">
|
||||
Backend not connected. Your data is only stored in this browser.
|
||||
</span>
|
||||
|
||||
<!-- Pending count if any -->
|
||||
{#if syncState.pendingCount > 0}
|
||||
<span
|
||||
class="rounded-full bg-red-500/20 px-2 py-0.5 text-xs font-medium"
|
||||
>
|
||||
{syncState.pendingCount} pending
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Dismiss button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleDismiss}
|
||||
class="ml-1 flex-shrink-0 rounded p-0.5 opacity-70 transition-opacity hover:opacity-100"
|
||||
aria-label="Dismiss sync warning"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes slide-in-from-top {
|
||||
from {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
animation: slide-in-from-top 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
@@ -17,6 +17,7 @@
|
||||
import ModelSelect from '$lib/components/layout/ModelSelect.svelte';
|
||||
import { ToastContainer, ShortcutsModal } from '$lib/components/shared';
|
||||
import UpdateBanner from '$lib/components/shared/UpdateBanner.svelte';
|
||||
import SyncWarningBanner from '$lib/components/shared/SyncWarningBanner.svelte';
|
||||
|
||||
import type { LayoutData } from './$types';
|
||||
import type { Snippet } from 'svelte';
|
||||
@@ -184,5 +185,8 @@
|
||||
<!-- Update notification banner -->
|
||||
<UpdateBanner />
|
||||
|
||||
<!-- Sync warning banner (shows when backend disconnected) -->
|
||||
<SyncWarningBanner />
|
||||
|
||||
<!-- Keyboard shortcuts help -->
|
||||
<ShortcutsModal isOpen={showShortcutsModal} onClose={() => (showShortcutsModal = false)} />
|
||||
|
||||
Reference in New Issue
Block a user