small ui polish: loading state, relative timestamps, log-marker formatting, wider tooltips, type tidy-ups
This commit is contained in:
9 files changed
+106
-26
No files matched your search
@@ -7,7 +7,7 @@
|
||||
* the Schedules view uses.
|
||||
*/
|
||||
import { Check, X, AlertTriangle, Clock, CheckCheck, AlertCircle, Timer, Hand, Webhook, ChevronDown, FileText, Loader2 } from 'lucide-svelte';
|
||||
import { formatDateTime } from '$lib/stores/settings';
|
||||
import { formatDateTime, formatRelativeTime } from '$lib/stores/settings';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
import { getRepoTypeIcon } from '$lib/utils/backup';
|
||||
import type { Execution } from '$lib/utils/execution-tally';
|
||||
@@ -72,7 +72,7 @@
|
||||
{#if loading && executions.length === 0}
|
||||
<div class="flex justify-center py-8"><Loader2 class="h-5 w-5 animate-spin text-muted-foreground" /></div>
|
||||
{:else if executions.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-10 text-center">
|
||||
<div class="flex min-h-[60vh] flex-col items-center justify-center py-10 text-center">
|
||||
<Clock class="mb-3 h-10 w-10 text-muted-foreground/40" />
|
||||
<p class="text-sm text-muted-foreground">No backup runs yet.</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">Run a backup — its history appears here.</p>
|
||||
@@ -99,13 +99,13 @@
|
||||
{@const isFail = exec.status === 'failed'}
|
||||
{@const isOpen = expanded.has(exec.id)}
|
||||
<tr class="border-b text-xs last:border-0 hover:bg-muted/30">
|
||||
<td class="py-1.5 pl-2 font-mono">{exec.triggeredAt ? formatDateTime(exec.triggeredAt, true) : '—'}</td>
|
||||
<td class="py-1.5 pl-2">{#if exec.triggeredAt}{formatDateTime(exec.triggeredAt, true)} <span class="text-muted-foreground opacity-60">({formatRelativeTime(exec.triggeredAt)})</span>{:else}—{/if}</td>
|
||||
<td class="py-1.5 pl-2 text-center">
|
||||
<span class="inline-flex h-5 w-5 items-center justify-center rounded bg-muted text-muted-foreground" title={triggerLabel(exec.triggeredBy)}>
|
||||
<TrigIcon class="h-3 w-3" />
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-1.5 pl-2 font-mono text-muted-foreground">{formatDuration(exec.duration)}</td>
|
||||
<td class="py-1.5 pl-2 text-muted-foreground">{formatDuration(exec.duration)}</td>
|
||||
<td class="py-1.5 pl-2 text-center">
|
||||
<span class="inline-flex h-5 w-5 items-center justify-center rounded {badge.cls}" title={exec.status}>
|
||||
<BadgeIcon class="h-3 w-3 {exec.status === 'running' ? 'animate-spin' : ''}" />
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import LoadingState from './loading-state.svelte';
|
||||
|
||||
export { LoadingState };
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { Loader2 } from 'lucide-svelte';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
interface Props {
|
||||
/** Optional label under the spinner, e.g. "Loading snapshots...". */
|
||||
label?: string;
|
||||
/** Extra classes on the wrapper (e.g. `h-full` to fill a flex parent). */
|
||||
class?: string;
|
||||
/** Spinner size class. Defaults to a large centered spinner. */
|
||||
spinnerClass?: string;
|
||||
}
|
||||
|
||||
let { label, class: className, spinnerClass = 'w-10 h-10' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={cn('flex min-h-[200px] flex-col items-center justify-center gap-3 text-muted-foreground', className)}>
|
||||
<Loader2 class={cn('animate-spin', spinnerClass)} />
|
||||
{#if label}
|
||||
<span class="text-sm">{label}</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -21,7 +21,7 @@
|
||||
{sideOffset}
|
||||
{side}
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground border shadow-lg animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-tooltip-content-transform-origin) z-[200] w-fit fixed text-balance rounded-md px-3 py-1.5 text-xs",
|
||||
"bg-popover text-popover-foreground border shadow-lg animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-tooltip-content-transform-origin) z-[200] w-max max-w-lg fixed whitespace-normal rounded-md px-3 py-1.5 text-xs",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -17,3 +17,40 @@ export function formatBytesCompact(bytes: number, decimals = 1): string {
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : decimals)}${units[i]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a long single-line error readable by putting each `[NNNN]` log marker on
|
||||
* its own line. Scanner/CLI output (grype, trivy, restic) crams a whole log
|
||||
* stream into one line prefixed by numeric markers like `[0026]`; splitting on
|
||||
* those turns the wall of text into one line per log entry.
|
||||
*
|
||||
* Only NUMERIC brackets are split — `[]` (an empty compose field path such as
|
||||
* `environment.[]`) is left attached so a field name is never torn in half.
|
||||
*/
|
||||
export function formatErrorLines(msg: string | null | undefined): string {
|
||||
if (!msg) return '';
|
||||
return msg.replace(/\s*(\[\d+\])/g, '\n$1').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact relative time like "just now", "5m ago", "2d ago", "3mo ago". Returns ''
|
||||
* for an unparseable/NaN date and "in the future" for a timestamp ahead of now.
|
||||
* Kept in this import-light module so it is unit-testable; stores/settings re-exports it.
|
||||
*/
|
||||
export function formatRelativeTime(date: Date | string | number): string {
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
const ms = d.getTime();
|
||||
if (isNaN(ms)) return '';
|
||||
const sec = Math.round((Date.now() - ms) / 1000);
|
||||
if (sec < 0) return 'in the future';
|
||||
if (sec < 45) return 'just now';
|
||||
const min = Math.round(sec / 60);
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const hr = Math.round(min / 60);
|
||||
if (hr < 24) return `${hr}h ago`;
|
||||
const day = Math.round(hr / 24);
|
||||
if (day < 30) return `${day}d ago`;
|
||||
const mo = Math.round(day / 30);
|
||||
if (mo < 12) return `${mo}mo ago`;
|
||||
return `${Math.round(mo / 12)}y ago`;
|
||||
}
|
||||
@@ -38,6 +38,7 @@
|
||||
TextCursorInput
|
||||
} from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { LoadingState } from '$lib/components/ui/loading-state';
|
||||
import { formatDateTime, appSettings } from '$lib/stores/settings';
|
||||
|
||||
interface FileEntry {
|
||||
@@ -762,13 +763,19 @@
|
||||
currentPath = data.path || path;
|
||||
// Map snapshot entries to match FileEntry interface
|
||||
if (isSnapshotMode) {
|
||||
// restic ls --json gives permissions as a 10-char ls string
|
||||
// ("-rw-------": leading type char + 9 rwx). permissionsToOctal wants
|
||||
// the 9 rwx chars, so drop the leading type char. Owner/group come as
|
||||
// user/group names when the source had /etc/passwd, else numeric uid/gid.
|
||||
entries = (data.entries || []).map((e: any) => ({
|
||||
name: e.name,
|
||||
type: e.type === 'dir' ? 'directory' : e.type,
|
||||
size: e.size || 0,
|
||||
permissions: '',
|
||||
owner: '',
|
||||
group: '',
|
||||
permissions: typeof e.permissions === 'string' && e.permissions.length >= 10
|
||||
? e.permissions.slice(1)
|
||||
: '',
|
||||
owner: e.user || (e.uid != null ? String(e.uid) : ''),
|
||||
group: e.group || (e.gid != null ? String(e.gid) : ''),
|
||||
modified: e.mtime || ''
|
||||
}));
|
||||
} else {
|
||||
@@ -1002,10 +1009,7 @@
|
||||
<!-- File list -->
|
||||
<div class="flex-1 overflow-auto relative">
|
||||
{#if loading}
|
||||
<div class="absolute inset-0 bg-background/80 flex items-center justify-center z-10">
|
||||
<Loader2 class="w-5 h-5 animate-spin mr-2 text-muted-foreground" />
|
||||
<span class="text-sm text-muted-foreground">Loading...</span>
|
||||
</div>
|
||||
<LoadingState class="absolute inset-0 z-10 bg-background/80" label="Loading files..." />
|
||||
{/if}
|
||||
{#if error}
|
||||
<div class="flex items-center justify-center p-4 h-full">
|
||||
@@ -1023,8 +1027,12 @@
|
||||
<span class="text-sm">{showHiddenFiles ? 'Directory is empty' : 'No visible files (hidden files are hidden)'}</span>
|
||||
</div>
|
||||
{:else if displayEntries().length > 0}
|
||||
<Table.Root class="text-xs">
|
||||
<Table.Header>
|
||||
<!-- Bare <table>, not Table.Root: Table.Root wraps the table in an
|
||||
overflow-x-auto div that becomes the sticky scroll context, so the
|
||||
sticky <thead> would anchor to that non-scrolling wrapper instead of
|
||||
the flex-1 overflow-auto above and never stick. Same pattern DataGrid uses. -->
|
||||
<table class="w-full caption-bottom text-sm text-xs">
|
||||
<Table.Header class="sticky top-0 z-10 bg-background">
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[35%] py-1.5 text-xs font-medium">
|
||||
<button type="button" class="flex items-center gap-1 hover:text-foreground" onclick={() => toggleSort('name')}>
|
||||
@@ -1038,16 +1046,19 @@
|
||||
<svelte:component this={getSortIcon('size')} class="w-3 h-3 opacity-50" />
|
||||
</button>
|
||||
</Table.Head>
|
||||
<Table.Head class="w-[18%] py-1.5 text-xs font-medium">
|
||||
<Table.Head class="w-[14%] py-1.5 text-xs font-medium">
|
||||
<span class="text-muted-foreground">Permissions</span>
|
||||
</Table.Head>
|
||||
<Table.Head class="w-[12%] py-1.5 text-xs font-medium">
|
||||
<span class="text-muted-foreground">Owner</span>
|
||||
</Table.Head>
|
||||
<Table.Head class="w-[14%] py-1.5 text-xs font-medium">
|
||||
<button type="button" class="flex items-center gap-1 hover:text-foreground" onclick={() => toggleSort('modified')}>
|
||||
Modified
|
||||
<svelte:component this={getSortIcon('modified')} class="w-3 h-3 opacity-50" />
|
||||
</button>
|
||||
</Table.Head>
|
||||
<Table.Head class="w-[25%] py-1.5 text-xs font-medium text-right">Actions</Table.Head>
|
||||
<Table.Head class="w-[21%] py-1.5 text-xs font-medium text-right">Actions</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
@@ -1093,10 +1104,17 @@
|
||||
<Table.Cell class="text-muted-foreground py-1">
|
||||
{entry.type === 'directory' ? '-' : formatSize(entry.size)}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground py-1 font-mono text-2xs">
|
||||
<Table.Cell class="text-muted-foreground py-1 font-mono text-xs">
|
||||
<span title={entry.permissions}>{permissionsToOctal(entry.permissions)}</span>
|
||||
<span class="ml-1 opacity-60">{entry.permissions}</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground py-1 font-mono text-xs">
|
||||
{#if entry.owner}
|
||||
<span title={entry.group ? `${entry.owner}:${entry.group}` : entry.owner}>{entry.owner}{#if entry.group && entry.group !== entry.owner}<span class="opacity-60">:{entry.group}</span>{/if}</span>
|
||||
{:else}
|
||||
<span class="opacity-40">-</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground py-1">
|
||||
{formatDate(entry.modified)}
|
||||
</Table.Cell>
|
||||
@@ -1184,7 +1202,7 @@
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1250,10 +1268,7 @@
|
||||
|
||||
<!-- File Viewer Overlay -->
|
||||
{#if loadingPreview && !viewingFile}
|
||||
<div class="absolute inset-0 bg-background flex flex-col items-center justify-center gap-2 z-10">
|
||||
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<span class="text-sm text-muted-foreground">Loading preview{previewingName ? ` — ${previewingName}` : ''}…</span>
|
||||
</div>
|
||||
<LoadingState class="absolute inset-0 z-10 bg-background" label={`Loading preview${previewingName ? ` - ${previewingName}` : ''}...`} />
|
||||
{:else if viewingFile}
|
||||
<div class="absolute inset-0 bg-background flex flex-col z-10">
|
||||
<div class="flex items-center justify-between p-2 border-b bg-muted/30">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { formatErrorLines } from '$lib/utils/format';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Progress } from '$lib/components/ui/progress';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
@@ -227,7 +228,7 @@
|
||||
<XCircle class="w-5 h-5 text-destructive shrink-0" />
|
||||
{/if}
|
||||
{#if phase === 'error'}
|
||||
<span class="text-destructive">{errorMessage}</span>
|
||||
<span class="whitespace-pre-wrap break-words text-destructive">{formatErrorLines(errorMessage)}</span>
|
||||
{:else}
|
||||
<span>
|
||||
Scanned <span class="font-medium">{scanned}</span> image{scanned === 1 ? '' : 's'}{failed > 0 ? `, ${failed} failed` : ''}
|
||||
|
||||
@@ -45,8 +45,9 @@
|
||||
|
||||
if (data.jobId) {
|
||||
await watchJob(data.jobId, (line) => {
|
||||
if (line.event === 'progress' && line.data?.message) {
|
||||
logs = [...logs, line.data.message];
|
||||
const msg = (line.data as { message?: string } | null)?.message;
|
||||
if (line.event === 'progress' && msg) {
|
||||
logs = [...logs, msg];
|
||||
}
|
||||
});
|
||||
status = 'success';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { formatErrorLines } from '$lib/utils/format';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
@@ -257,7 +258,7 @@
|
||||
<div class="mt-3 mx-2 p-3 rounded-md bg-destructive/10 border border-destructive/20">
|
||||
<div class="flex items-start gap-2 text-sm text-destructive">
|
||||
<AlertCircle class="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span class="break-all">{errorMessage}</span>
|
||||
<span class="whitespace-pre-wrap break-words">{formatErrorLines(errorMessage)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in new issue
Block a user