diff --git a/src/lib/components/ExecutionHistoryList.svelte b/src/lib/components/ExecutionHistoryList.svelte index 4b664bc..a6cfe8b 100644 --- a/src/lib/components/ExecutionHistoryList.svelte +++ b/src/lib/components/ExecutionHistoryList.svelte @@ -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}
{:else if executions.length === 0} -
+

No backup runs yet.

Run a backup — its history appears here.

@@ -99,13 +99,13 @@ {@const isFail = exec.status === 'failed'} {@const isOpen = expanded.has(exec.id)} - {exec.triggeredAt ? formatDateTime(exec.triggeredAt, true) : '—'} + {#if exec.triggeredAt}{formatDateTime(exec.triggeredAt, true)} ({formatRelativeTime(exec.triggeredAt)}){:else}—{/if} - {formatDuration(exec.duration)} + {formatDuration(exec.duration)} diff --git a/src/lib/components/ui/loading-state/index.ts b/src/lib/components/ui/loading-state/index.ts new file mode 100644 index 0000000..c2e687f --- /dev/null +++ b/src/lib/components/ui/loading-state/index.ts @@ -0,0 +1,3 @@ +import LoadingState from './loading-state.svelte'; + +export { LoadingState }; diff --git a/src/lib/components/ui/loading-state/loading-state.svelte b/src/lib/components/ui/loading-state/loading-state.svelte new file mode 100644 index 0000000..69a3828 --- /dev/null +++ b/src/lib/components/ui/loading-state/loading-state.svelte @@ -0,0 +1,22 @@ + + +
+ + {#if label} + {label} + {/if} +
diff --git a/src/lib/components/ui/tooltip/tooltip-content.svelte b/src/lib/components/ui/tooltip/tooltip-content.svelte index d1772f2..990235a 100644 --- a/src/lib/components/ui/tooltip/tooltip-content.svelte +++ b/src/lib/components/ui/tooltip/tooltip-content.svelte @@ -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} diff --git a/src/lib/utils/format.ts b/src/lib/utils/format.ts index bedf6e2..b789be7 100644 --- a/src/lib/utils/format.ts +++ b/src/lib/utils/format.ts @@ -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`; +} diff --git a/src/routes/containers/FileBrowserPanel.svelte b/src/routes/containers/FileBrowserPanel.svelte index 3e23c08..4a6ffe8 100644 --- a/src/routes/containers/FileBrowserPanel.svelte +++ b/src/routes/containers/FileBrowserPanel.svelte @@ -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 @@
{#if loading} -
- - Loading... -
+ {/if} {#if error}
@@ -1023,8 +1027,12 @@ {showHiddenFiles ? 'Directory is empty' : 'No visible files (hidden files are hidden)'}
{:else if displayEntries().length > 0} - - + + + - + Permissions + + Owner + - Actions + Actions @@ -1093,10 +1104,17 @@ {entry.type === 'directory' ? '-' : formatSize(entry.size)} - + {permissionsToOctal(entry.permissions)} {entry.permissions} + + {#if entry.owner} + {entry.owner}{#if entry.group && entry.group !== entry.owner}:{entry.group}{/if} + {:else} + - + {/if} + {formatDate(entry.modified)} @@ -1184,7 +1202,7 @@ {/each} - +
{/if}
@@ -1250,10 +1268,7 @@ {#if loadingPreview && !viewingFile} -
- - Loading preview{previewingName ? ` — ${previewingName}` : ''}… -
+ {:else if viewingFile}
diff --git a/src/routes/images/VulnerabilityScanModal.svelte b/src/routes/images/VulnerabilityScanModal.svelte index 9bda455..034345d 100644 --- a/src/routes/images/VulnerabilityScanModal.svelte +++ b/src/routes/images/VulnerabilityScanModal.svelte @@ -1,4 +1,5 @@