1.0.38
This commit is contained in:
177 files changed
+21182
-1042
No files matched your search
@@ -13,6 +13,7 @@
|
||||
}
|
||||
|
||||
export interface FailedCheckItem {
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
imageName: string;
|
||||
error: string;
|
||||
@@ -117,7 +118,7 @@
|
||||
.map((r: any) => ({ containerId: r.containerId, containerName: r.containerName, imageName: r.imageName }));
|
||||
const failed: FailedCheckItem[] = data.results
|
||||
.filter((r: any) => r.error && !r.hasUpdate)
|
||||
.map((r: any) => ({ containerName: r.containerName, imageName: r.imageName, error: r.error }));
|
||||
.map((r: any) => ({ containerId: r.containerId, containerName: r.containerName, imageName: r.imageName, error: r.error }));
|
||||
|
||||
if (withUpdates.length === 0) {
|
||||
// Keep the "Latest" status until re-check / env-switch — don't auto-revert (#1019)
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Shared backup execution-history table. Renders a list of schedule executions
|
||||
* (run time, trigger, duration, status, detail/error) with failed rows expandable
|
||||
* inline. Used by the backup surfaces (container/stack modal Backups tab, main
|
||||
* Backups page). The data comes from /api/schedules/executions — the same source
|
||||
* 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 { formatBytes } from '$lib/utils/format';
|
||||
import { getRepoTypeIcon } from '$lib/utils/backup';
|
||||
import type { Execution } from '$lib/utils/execution-tally';
|
||||
|
||||
interface Props {
|
||||
executions: Execution[];
|
||||
loading?: boolean;
|
||||
/** Optional: open the full log dialog for one execution. */
|
||||
onViewLog?: (id: number) => void;
|
||||
}
|
||||
let { executions, loading = false, onViewLog }: Props = $props();
|
||||
|
||||
// Show the Repo column only when at least one run carries a stamped repo
|
||||
// (fetchBackupExecutions was given a config→destination map). Off on surfaces
|
||||
// that already scope to a single repo, so the table stays compact.
|
||||
const showRepo = $derived(executions.some((e) => e._repository || e._destinationName));
|
||||
|
||||
let expanded = $state<Set<number>>(new Set());
|
||||
function toggle(id: number) {
|
||||
const s = new Set(expanded);
|
||||
s.has(id) ? s.delete(id) : s.add(id);
|
||||
expanded = s;
|
||||
}
|
||||
|
||||
function cleanError(msg: string): string {
|
||||
try { const p = JSON.parse(msg); if (p.message) return p.message; } catch {}
|
||||
const m = msg.match(/\{"message":"([^"]+)"\}/);
|
||||
return m ? msg.replace(m[0], m[1]) : msg;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number | null): string {
|
||||
if (ms === null) return '—';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
|
||||
}
|
||||
|
||||
function statusBadge(status: string) {
|
||||
switch (status) {
|
||||
case 'success': return { cls: 'bg-emerald-500/15 text-emerald-500', icon: Check };
|
||||
case 'warning': return { cls: 'bg-amber-500/15 text-amber-500', icon: AlertTriangle };
|
||||
case 'failed': return { cls: 'bg-red-500/15 text-red-500', icon: X };
|
||||
case 'running': return { cls: 'bg-sky-500/15 text-sky-500', icon: Loader2 };
|
||||
case 'skipped': return { cls: 'bg-muted text-muted-foreground', icon: CheckCheck };
|
||||
case 'queued': return { cls: 'bg-amber-500/15 text-amber-500', icon: Clock };
|
||||
default: return { cls: 'bg-muted text-muted-foreground', icon: AlertCircle };
|
||||
}
|
||||
}
|
||||
|
||||
function triggerIcon(trigger: string) {
|
||||
switch (trigger) {
|
||||
case 'cron': return Timer;
|
||||
case 'webhook': return Webhook;
|
||||
default: return Hand;
|
||||
}
|
||||
}
|
||||
function triggerLabel(trigger: string) {
|
||||
return trigger === 'cron' ? 'Scheduled' : trigger === 'webhook' ? 'Webhook' : 'Manual';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#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">
|
||||
<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>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="border-b text-left text-muted-foreground">
|
||||
<th class="py-1.5 pl-2 font-medium">Run</th>
|
||||
<th class="py-1.5 pl-2 text-center font-medium">Trigger</th>
|
||||
<th class="py-1.5 pl-2 font-medium">Duration</th>
|
||||
<th class="py-1.5 pl-2 text-center font-medium">Status</th>
|
||||
<th class="py-1.5 pl-2 font-medium">Detail</th>
|
||||
{#if showRepo}<th class="py-1.5 pl-2 font-medium">Repo</th>{/if}
|
||||
<th class="py-1.5 pr-2 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each executions as exec}
|
||||
{@const badge = statusBadge(exec.status)}
|
||||
{@const BadgeIcon = badge.icon}
|
||||
{@const TrigIcon = triggerIcon(exec.triggeredBy)}
|
||||
{@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 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-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' : ''}" />
|
||||
</span>
|
||||
</td>
|
||||
<td class="max-w-[360px] truncate py-1.5 pl-2" title={exec.errorMessage || ''}>
|
||||
{#if exec.errorMessage}
|
||||
<span class="text-destructive">{cleanError(exec.errorMessage)}</span>
|
||||
{:else if exec.status === 'success' && exec.details?.dataAdded !== undefined}
|
||||
<span class="text-muted-foreground">{exec.details.filesNew ?? 0} new · {exec.details.filesChanged ?? 0} changed · {formatBytes(exec.details.dataAdded ?? 0)}</span>
|
||||
{:else}
|
||||
<span class="text-muted-foreground/60">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
{#if showRepo}
|
||||
{@const RepoIcon = getRepoTypeIcon(exec._repository || "")}
|
||||
<td class="max-w-[180px] truncate py-1.5 pl-2 text-muted-foreground" title={exec._repository || exec._destinationName || ""}>
|
||||
{#if exec._repository || exec._destinationName}
|
||||
<span class="inline-flex items-center gap-1.5"><RepoIcon class="h-3 w-3 shrink-0" /><span class="truncate">{exec._destinationName || exec._repository}</span></span>
|
||||
{:else}
|
||||
<span class="text-muted-foreground/60">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
{/if}
|
||||
<td class="py-1.5 pr-2 text-right">
|
||||
<div class="flex items-center justify-end gap-0.5">
|
||||
{#if onViewLog}
|
||||
<button type="button" class="rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" onclick={() => onViewLog?.(exec.id)} title="View log">
|
||||
<FileText class="h-3 w-3" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if isFail && exec.errorMessage}
|
||||
<button type="button" class="rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" onclick={() => toggle(exec.id)} title={isOpen ? 'Hide error' : 'Show error'}>
|
||||
<ChevronDown class="h-3.5 w-3.5 transition-transform {isOpen ? '' : '-rotate-90'}" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{#if isFail && isOpen && exec.errorMessage}
|
||||
<tr class="bg-destructive/5">
|
||||
<td colspan={showRepo ? 7 : 6} class="px-2 py-2">
|
||||
<div class="rounded-md border border-l-[3px] border-destructive/40 border-l-destructive bg-destructive/5 p-2.5">
|
||||
<div class="mb-1 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-destructive">
|
||||
<X class="h-3 w-3" /> Backup failed
|
||||
</div>
|
||||
<pre class="whitespace-pre-wrap break-all font-mono text-[11px] text-destructive/90">{exec.errorMessage}</pre>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -21,7 +21,7 @@
|
||||
type = 'trivy';
|
||||
} else if (content.startsWith('[grype]')) {
|
||||
type = 'grype';
|
||||
} else if (content.toLowerCase().includes('error')) {
|
||||
} else if (/\berrors?\b/i.test(content) && !/\bno\s+errors?\b/i.test(content)) {
|
||||
type = 'error';
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { Terminal, Sun, Moon, Folder, HardDrive } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
/** Log lines. A leading `[source]` token is rendered as a colored pill. */
|
||||
lines: string[];
|
||||
/** Map a source token (e.g. "restic") to a pill background class. Unknown
|
||||
* sources fall back to a neutral slate pill. */
|
||||
sourceColors?: Record<string, string>;
|
||||
class?: string;
|
||||
}
|
||||
let { lines, sourceColors = {}, class: className = '' }: Props = $props();
|
||||
|
||||
const DEFAULT_COLORS: Record<string, string> = {
|
||||
dockhand: 'bg-slate-500',
|
||||
restic: 'bg-sky-600',
|
||||
grype: 'bg-violet-500',
|
||||
trivy: 'bg-teal-500',
|
||||
scan: 'bg-violet-500',
|
||||
error: 'bg-red-500',
|
||||
};
|
||||
const colorFor = (src: string) => sourceColors[src] ?? DEFAULT_COLORS[src] ?? 'bg-slate-500';
|
||||
|
||||
// Split a line into its optional `[source]` pill token and the remaining text.
|
||||
function parse(line: string): { source: string | null; text: string } {
|
||||
const m = line.match(/^\[([a-z0-9_-]+)\]\s?/i);
|
||||
if (!m) return { source: null, text: line };
|
||||
return { source: m[1].toLowerCase(), text: line.slice(m[0].length) };
|
||||
}
|
||||
|
||||
// A backup volume-list line looks like ` • [BIND] /config` or ` • [VOL] name`.
|
||||
// Render the `[BIND]`/`[VOL]` token as a typed chip (same amber-bind / sky-vol
|
||||
// as the backup config UI) so the log matches the picker. Inert for any other
|
||||
// line (scan logs never emit this), so this stays a no-op for non-backup output.
|
||||
function volToken(text: string): { kind: 'bind' | 'vol'; rest: string } | null {
|
||||
const m = text.match(/^\s*•\s*\[(BIND|VOL)\]\s?(.*)$/);
|
||||
if (!m) return null;
|
||||
return { kind: m[1] === 'BIND' ? 'bind' : 'vol', rest: m[2] };
|
||||
}
|
||||
|
||||
let dark = $state(true);
|
||||
let container = $state<HTMLDivElement>();
|
||||
|
||||
// Auto-scroll to the newest line as logs stream in.
|
||||
$effect(() => {
|
||||
void lines.length;
|
||||
if (container) requestAnimationFrame(() => { if (container) container.scrollTop = container.scrollHeight; });
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col {className}">
|
||||
<div class="mb-2 flex shrink-0 items-center justify-between text-xs text-muted-foreground">
|
||||
<div class="flex items-center gap-2">
|
||||
<Terminal class="h-3.5 w-3.5" />
|
||||
<span>Output ({lines.length} {lines.length === 1 ? 'line' : 'lines'})</span>
|
||||
</div>
|
||||
<button type="button" onclick={() => (dark = !dark)} class="cursor-pointer rounded p-1 transition-colors hover:bg-muted" title="Toggle log theme">
|
||||
{#if dark}<Sun class="h-3.5 w-3.5" />{:else}<Moon class="h-3.5 w-3.5" />{/if}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
bind:this={container}
|
||||
class="{dark ? 'bg-zinc-950 text-zinc-300' : 'bg-zinc-100 text-zinc-700'} min-h-0 flex-1 overflow-auto rounded-lg p-3 font-mono text-xs"
|
||||
>
|
||||
{#each lines as line}
|
||||
{@const p = parse(line)}
|
||||
{@const isError = p.source === 'error' || /error|fatal|fail/i.test(p.text)}
|
||||
{@const vt = volToken(p.text)}
|
||||
<div class="flex items-start gap-1.5 whitespace-pre-wrap break-all leading-relaxed">
|
||||
{#if p.source}
|
||||
<span class="mt-[3px] inline-flex w-16 shrink-0 items-center justify-center rounded px-1 text-[8px] font-medium text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] {colorFor(p.source)}">{p.source}</span>
|
||||
{/if}
|
||||
{#if vt}
|
||||
{#if vt.kind === 'bind'}
|
||||
<span class="mt-[2px] inline-flex w-12 shrink-0 items-center justify-center gap-1 rounded-full bg-amber-500/15 px-1.5 text-[9px] font-semibold uppercase tracking-wide text-amber-500"><Folder class="h-2.5 w-2.5" />bind</span>
|
||||
{:else}
|
||||
<span class="mt-[2px] inline-flex w-12 shrink-0 items-center justify-center gap-1 rounded-full bg-sky-500/15 px-1.5 text-[9px] font-semibold uppercase tracking-wide text-sky-500"><HardDrive class="h-2.5 w-2.5" />vol</span>
|
||||
{/if}
|
||||
<span class={isError ? 'text-red-400' : ''}>{vt.rest}</span>
|
||||
{:else}
|
||||
<span class={isError ? 'text-red-400' : ''}>{p.text}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import type { Component, Snippet } from 'svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { currentEnvironment } from '$lib/stores/environment';
|
||||
|
||||
// Unified modal header: [icon] Title [truncated resource name] on <amber env>.
|
||||
// The resource name truncates with an ellipsis and shows the full value in a
|
||||
// shadcn Tooltip on hover. Content gets an explicit w-80 (the project's proven
|
||||
// long-text pattern) so it doesn't collapse to w-fit; ignoreNonKeyboardFocus
|
||||
// stops it opening on the modal's initial focus. The env suffix ("on <name>")
|
||||
// inherits the title's colour/weight, only the env name is amber. Anything
|
||||
// else — a rename pencil, a status badge — goes in the `extra` snippet,
|
||||
// rendered inline after the env suffix.
|
||||
let {
|
||||
icon,
|
||||
title,
|
||||
name = undefined,
|
||||
envName = undefined,
|
||||
extra = undefined
|
||||
}: {
|
||||
icon: Component;
|
||||
title: string;
|
||||
name?: string;
|
||||
envName?: string;
|
||||
extra?: Snippet;
|
||||
} = $props();
|
||||
|
||||
const Icon = $derived(icon);
|
||||
const env = $derived(envName ?? $currentEnvironment?.name);
|
||||
</script>
|
||||
|
||||
<Dialog.Title class="flex items-center gap-2 min-w-0">
|
||||
<Icon class="w-5 h-5 shrink-0" />
|
||||
<span class="shrink-0">{title}</span>
|
||||
{#if name}
|
||||
<Tooltip.Root delayDuration={200} ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger class="truncate min-w-0 max-w-[min(60%,28rem)] cursor-default">{name}</Tooltip.Trigger>
|
||||
<Tooltip.Content class="w-80"><p class="break-all">{name}</p></Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
{#if env}
|
||||
<span class="shrink-0">on <span class="text-amber-600 dark:text-amber-400">{env}</span></span>
|
||||
{/if}
|
||||
{#if extra}
|
||||
<span class="shrink-0 flex items-center gap-2">{@render extra()}</span>
|
||||
{/if}
|
||||
</Dialog.Title>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* A mount-type indicator: a colored icon + uppercase pill for `bind` vs `volume`.
|
||||
* Shared so every place that lists mounts (backup dialog VolumePicker, snapshot
|
||||
* Browse metadata — Volumes & binds and Mounts) renders them identically:
|
||||
* amber Folder + "bind" for binds, sky HardDrive + "vol" for named volumes.
|
||||
*/
|
||||
import { Folder, HardDrive } from 'lucide-svelte';
|
||||
|
||||
// `sm` matches the denser VolumePicker rows (smaller icon, fixed-width pill);
|
||||
// the default matches the roomier snapshot-metadata rows.
|
||||
let { type, size = 'default' }: { type?: string | null; size?: 'default' | 'sm' } = $props();
|
||||
const isBind = $derived(type === 'bind');
|
||||
const iconCls = $derived(size === 'sm' ? 'w-3.5 h-3.5' : 'w-4 h-4');
|
||||
const pillCls = $derived(size === 'sm' ? 'w-12' : '');
|
||||
</script>
|
||||
|
||||
{#if isBind}
|
||||
<Folder class="{iconCls} text-amber-500 shrink-0" />
|
||||
<span class="shrink-0 {pillCls} text-center rounded-full px-1.5 py-0.5 text-xs font-semibold uppercase tracking-wide bg-amber-500/15 text-amber-600 dark:text-amber-400">bind</span>
|
||||
{:else}
|
||||
<HardDrive class="{iconCls} text-sky-500 shrink-0" />
|
||||
<span class="shrink-0 {pillCls} text-center rounded-full px-1.5 py-0.5 text-xs font-semibold uppercase tracking-wide bg-sky-500/15 text-sky-600 dark:text-sky-400">vol</span>
|
||||
{/if}
|
||||
@@ -8,8 +8,8 @@
|
||||
import { CheckCircle2, XCircle, Loader2, AlertCircle, Terminal, Sun, Moon, Download } from 'lucide-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { appendEnvParam } from '$lib/stores/environment';
|
||||
import { watchJob } from '$lib/utils/sse-fetch';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
import { watchJob } from '$lib/utils/sse-fetch';
|
||||
|
||||
interface LayerProgress {
|
||||
id: string;
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
ClipboardList,
|
||||
Activity,
|
||||
Timer,
|
||||
Archive,
|
||||
LibraryBig,
|
||||
CircleArrowUp,
|
||||
Pencil,
|
||||
@@ -89,6 +90,11 @@
|
||||
* - ENTERPRISE edition: check if user has ANY permission for the resource
|
||||
*/
|
||||
function canSeeMenuItem(item: MenuItem): boolean {
|
||||
// BETA GATE: hide Backups unless FEAT_BACKUPS_ENABLED is on (see features.ts)
|
||||
if (item.href === '/backups' && !$page.data.backupsEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enterprise-only items are hidden without enterprise license
|
||||
if (item.enterpriseOnly && !$licenseStore.isEnterprise) {
|
||||
return false;
|
||||
@@ -126,6 +132,7 @@
|
||||
{ href: '/templates', Icon: LibraryBig, label: 'Templates', permission: 'templates' },
|
||||
{ href: '/registry', Icon: Download, label: 'Registry', permission: 'registries' },
|
||||
{ href: '/activity', Icon: Activity, label: 'Activity', permission: 'activity' },
|
||||
{ href: '/backups', Icon: Archive, label: 'Backups', permission: 'backups' },
|
||||
{ href: '/schedules', Icon: Timer, label: 'Schedules', permission: 'schedules' },
|
||||
{ href: '/audit', Icon: ClipboardList, label: 'Audit log', permission: 'audit_logs', enterpriseOnly: true },
|
||||
{ href: '/settings', Icon: Settings, label: 'Settings', permission: 'settings' }
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Shared backup-destination picker. Both BackupPanel and CreateBackupModal
|
||||
* rendered the same Select.Root with the same icon-resolution logic; one
|
||||
* also disabled local-path destinations when the target environment is
|
||||
* remote (because the helper container on the remote host can't see
|
||||
* Dockhand's local filesystem), the other didn't, which let users pick
|
||||
* a doomed combination that the backend would reject later.
|
||||
*
|
||||
* This component centralizes the rendering and (optionally) the
|
||||
* local-on-remote disabling.
|
||||
*/
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { getRepoTypeIcon, isLocalRepo } from '$lib/utils/backup';
|
||||
|
||||
interface Destination {
|
||||
id: number;
|
||||
name: string;
|
||||
repository: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
destinations: Destination[];
|
||||
/** Bound to the selected destination id. 0 = nothing selected. */
|
||||
value: number;
|
||||
/** Set to true on a remote environment to disable local-path destinations. */
|
||||
disableLocalForRemoteEnv?: boolean;
|
||||
/** CSS class for the trigger. Defaults to full width. */
|
||||
triggerClass?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
destinations,
|
||||
value = $bindable(),
|
||||
disableLocalForRemoteEnv = false,
|
||||
triggerClass = 'h-9 w-full',
|
||||
placeholder = 'Select repository...'
|
||||
}: Props = $props();
|
||||
|
||||
const selected = $derived(destinations.find((d) => d.id === value));
|
||||
</script>
|
||||
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={value ? String(value) : undefined}
|
||||
onValueChange={(v) => { value = Number(v); }}
|
||||
>
|
||||
<Select.Trigger class={triggerClass}>
|
||||
{#if selected}
|
||||
{@const SelIcon = getRepoTypeIcon(selected.repository)}
|
||||
<span class="flex items-center gap-2 truncate">
|
||||
<SelIcon class="w-4 h-4 text-primary/70 shrink-0" />
|
||||
{selected.name}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-muted-foreground text-xs">{placeholder}</span>
|
||||
{/if}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each destinations as dest}
|
||||
{@const DIcon = getRepoTypeIcon(dest.repository)}
|
||||
{@const localOnRemote = disableLocalForRemoteEnv && isLocalRepo(dest.repository)}
|
||||
{#if localOnRemote}
|
||||
<Select.Item value={String(dest.id)} disabled>
|
||||
<span class="flex items-center gap-2 opacity-50">
|
||||
<DIcon class="w-4 h-4 text-muted-foreground" />
|
||||
{dest.name}
|
||||
<span class="text-xs text-muted-foreground">— local</span>
|
||||
</span>
|
||||
</Select.Item>
|
||||
{:else}
|
||||
<Select.Item value={String(dest.id)}>
|
||||
<span class="flex items-center gap-2">
|
||||
<DIcon class="w-4 h-4 text-muted-foreground" />
|
||||
{dest.name}
|
||||
</span>
|
||||
</Select.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Shared "all volumes / pick a subset" UI used by BackupPanel and
|
||||
* CreateBackupModal. Both surfaces previously open-coded this — they
|
||||
* drifted slightly (different toggle labels, different icon for bind
|
||||
* mounts) and one of them silently omitted the bind-mount warning.
|
||||
*
|
||||
* mountType is optional per volume: when present, bind mounts get an
|
||||
* amber warning callout and a per-row Folder icon; when absent, all
|
||||
* rows render as named volumes with the neutral icon.
|
||||
*/
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { TogglePill } from '$lib/components/ui/toggle-pill';
|
||||
import { AlertTriangle } from 'lucide-svelte';
|
||||
import MountTypeBadge from '$lib/components/MountTypeBadge.svelte';
|
||||
|
||||
interface VolumeInfo {
|
||||
name: string;
|
||||
mountPoint: string;
|
||||
mountType?: 'volume' | 'bind';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
volumes: VolumeInfo[];
|
||||
allVolumes: boolean;
|
||||
selectedVolumes: string[];
|
||||
/** Empty-state label, e.g. "No volumes detected on this container". */
|
||||
emptyLabel?: string;
|
||||
/** When true, surface the bind-mount-portability warning when binds are present. */
|
||||
showBindWarning?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
volumes,
|
||||
allVolumes = $bindable(),
|
||||
selectedVolumes = $bindable(),
|
||||
emptyLabel = 'No volumes detected',
|
||||
showBindWarning = true
|
||||
}: Props = $props();
|
||||
|
||||
const bindMounts = $derived(volumes.filter((v) => v.mountType === 'bind'));
|
||||
|
||||
function toggleVolume(name: string) {
|
||||
if (selectedVolumes.includes(name)) {
|
||||
selectedVolumes = selectedVolumes.filter((v) => v !== name);
|
||||
} else {
|
||||
selectedVolumes = [...selectedVolumes, name];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if volumes.length > 0}
|
||||
<!-- One bordered section: the "all volumes" toggle heads the list it controls,
|
||||
so the switch clearly belongs to the volumes below it. -->
|
||||
<div class="border rounded-md overflow-hidden">
|
||||
<div class="flex items-center justify-between gap-4 px-3 py-2 bg-muted/30 border-b">
|
||||
<Label class="text-xs">Backup all volumes ({volumes.length})</Label>
|
||||
<TogglePill bind:checked={allVolumes} onLabel="Yes" offLabel="No" />
|
||||
</div>
|
||||
|
||||
{#if showBindWarning && bindMounts.length > 0}
|
||||
<div class="flex items-start gap-2 border-b border-amber-500/30 bg-amber-500/5 p-2 text-xs">
|
||||
<AlertTriangle class="w-3.5 h-3.5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div class="space-y-1">
|
||||
<p class="font-medium text-amber-600 dark:text-amber-400">{bindMounts.length} bind mount{bindMounts.length !== 1 ? 's' : ''} detected</p>
|
||||
<p class="text-muted-foreground">Contents are captured in the snapshot, but bind sources are host paths. Restoring to a different environment requires those exact paths on the target host — otherwise services start with empty mounts.</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- List always shown: informational when "all" is on, selectable when off. -->
|
||||
<div class="divide-y max-h-40 overflow-y-auto">
|
||||
{#each volumes as vol}
|
||||
<label class="flex items-center gap-2 px-3 py-1.5 text-xs" class:cursor-pointer={!allVolumes}>
|
||||
<Checkbox
|
||||
checked={allVolumes || selectedVolumes.includes(vol.name)}
|
||||
disabled={allVolumes}
|
||||
onCheckedChange={() => toggleVolume(vol.name)}
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
<MountTypeBadge type={vol.mountType} size="sm" />
|
||||
<span class="font-mono truncate">{vol.name}</span>
|
||||
{#if vol.mountPoint}
|
||||
<span class="text-muted-foreground ml-auto truncate">{vol.mountPoint}</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">{emptyLabel}</p>
|
||||
{/if}
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
let { class: className = 'w-4 h-4' }: { class?: string } = $props();
|
||||
</script>
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" class={className} fill="currentColor">
|
||||
<path opacity="0.85" d="M260 348l-137 33V131l137 32z"/>
|
||||
<path opacity="0.6" d="M256 349l133 32V131l-133 32v186"/>
|
||||
<g opacity="0.85"><path id="s3a" d="M256 64v97l58 14V93zm133 67v250l26-13V143zm-133 77v97l58-8v-82zm58 129l-58 14v97l58-29z"/></g>
|
||||
<use opacity="0.6" transform="rotate(180 256 256)" xlink:href="#s3a"/>
|
||||
<path opacity="0.4" d="M314 175l-58 11-58-11 58-15 58 15"/>
|
||||
<path opacity="0.95" d="M314 337l-58-11-58 11 58 16 58-16"/>
|
||||
</svg>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
let { class: className = 'w-4 h-4' }: { class?: string } = $props();
|
||||
</script>
|
||||
|
||||
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor" class={className}>
|
||||
<g>
|
||||
<path d="M7.486 9.384l-.012.13c-.01.11-.019.218-.032.325a.607.607 0 01-.05.158c-.033.077-.096.117-.18.127-.203.024-.274-.078-.315-.25-.054-.226-.049-.457-.037-.688l.001-.017a.826.826 0 01.04-.256.85.85 0 01.065-.142c.083-.138.32-.125.399-.02a.43.43 0 01.074.186c.016.107.026.215.035.322l.012.125zM8.575 6.053c-.06.178-.065.362-.062.56-.006.176.004.363.062.545.026.082.075.146.16.162.097.017.194.02.268-.066a.489.489 0 00.11-.27 2.8 2.8 0 000-.755.72.72 0 00-.064-.22c-.025-.048-.072-.103-.122-.119-.14-.044-.292-.013-.352.163z"/>
|
||||
<path fill-rule="evenodd" d="M9.827 4.895v1.173h1.157c0 .015.002.029.003.042.003.027.005.05.005.074v4.434c0 .083 0 .165-.04.242-.07.136-.172.224-.33.236l-.043.003c-.067.005-.134.01-.2.01H5.54c-.075 0-.15-.005-.223-.019a.397.397 0 01-.314-.347 1.194 1.194 0 01-.004-.12V5.386c0-.103.005-.2.064-.293A.432.432 0 015.38 4.9c.037-.006.076-.006.114-.005H9.827zM6.434 9.533l-.008-.114c.004-.202.021-.402.088-.593.073-.212.201-.377.432-.446.197-.06.391-.05.574.031a.5.5 0 01.278.294c.108.307.126.62.09.94-.015.135-.04.267-.09.394a.61.61 0 01-.538.406c-.11.012-.219.01-.325-.013a.542.542 0 01-.373-.294 1.044 1.044 0 01-.107-.36 3.931 3.931 0 01-.021-.245zm3.123-3.07l.013.118c-.01.206-.031.407-.096.602-.075.225-.214.391-.453.446-.233.053-.464.044-.666-.11a.585.585 0 01-.198-.325 2.319 2.319 0 01-.049-.89.986.986 0 01.162-.486.655.655 0 01.516-.272c.13-.008.256.011.375.06.074.03.138.077.19.142.112.14.166.3.18.476.007.08.017.16.026.239zM9.5 10.215a.307.307 0 01-.047.201l-.024.002c-.02.001-.042.003-.064.003h-.312c-.23-.001-.46-.002-.691.002-.1.001-.129-.036-.129-.124v-.037-.066c.005-.064.028-.086.095-.088.058-.002.116-.002.174-.001h.158c.022-.076.029-1.18.01-1.32a.727.727 0 01-.025.01.297.297 0 00-.051.02l-.092.055c-.04.024-.082.049-.124.072a.258.258 0 01-.08.024c-.037.005-.063-.01-.065-.049a1.552 1.552 0 01.001-.176c.002-.02.019-.045.037-.06.03-.025.064-.046.098-.068l.016-.01.093-.059c.058-.036.117-.071.172-.111a.399.399 0 01.246-.07c.042 0 .083.006.128.011l.068.008v1.714l.05.004.08.005h.169c.078.003.104.03.11.108zM7.44 5.58v1.727l.001.001H7.721c.092 0 .11.015.115.106a.805.805 0 01-.012.156c-.005.035-.03.053-.067.052h-1.04c-.105 0-.115-.01-.126-.114a.335.335 0 01-.003-.028c-.007-.155.01-.173.163-.173h.206c.06-.002.069-.01.07-.072v-.227V5.991L7 5.997a.256.256 0 00-.045.017 6.093 6.093 0 00-.103.057 4.65 4.65 0 01-.133.073c-.022.012-.047.019-.072.026l-.033.01a.346.346 0 01-.022-.242.13.13 0 01.051-.072c.133-.09.267-.179.405-.263a.25.25 0 01.088-.027.912.912 0 00.026-.005c.055-.011.107-.005.162 0 .037.005.075.01.116.008z" clip-rule="evenodd"/>
|
||||
<path fill-rule="evenodd" d="M14.849 8.259L15 8.004l-.057-.1-.155-.268-.107-.181a122.096 122.096 0 01-.376-.65 120.445 120.445 0 00-.677-1.165l-.151-.254-.225-.382c-.08-.136-.158-.273-.237-.41l-.156-.27a157.852 157.852 0 00-.68-1.164l-.113-.19-.085-.143-.17-.286-.018-.032a21.755 21.755 0 01-.258-.449c-.025-.044-.054-.063-.104-.058a.372.372 0 01-.066 0H6.198c-.54 0-1.08 0-1.62-.002-.069 0-.105.023-.138.082-.163.29-.33.577-.497.865l-.083.144-.39.67-.192.325a109.22 109.22 0 00-.795 1.363l-.199.344a56.291 56.291 0 01-.37.628l-.162.275-.161.275-.107.18-.17.288c-.1.17-.199.341-.297.512-.018.03-.026.058-.004.094.042.07.082.14.122.21l.087.15.13.218c.061.102.122.203.181.305.068.117.134.235.201.353.08.143.16.285.243.426l.303.51.215.363.157.271a41.4 41.4 0 00.3.515l.188.323.487.837.117.203c.06.106.121.212.183.317l.175.296c.109.185.218.37.325.556.041.072.085.102.173.102 1.49-.003 2.979-.002 4.468-.002h2.348c.058 0 .094-.012.126-.068.191-.34.388-.679.585-1.016.091-.158.184-.315.277-.472l.257-.436.34-.586.298-.516a108.318 108.318 0 01.71-1.2c.072-.122.141-.245.211-.368.076-.134.152-.268.23-.4.132-.226.265-.451.399-.677zM4.477 10.614V5.395c0-.133.01-.263.057-.39a.94.94 0 01.377-.468c.178-.116.376-.158.586-.158h.811c1.217 0 2.434 0 3.65-.002.079 0 .128.029.177.08.057.06.116.117.175.175l.095.093.095.096c.055.056.11.112.167.166l.287.277.287.277.112.113.123.123c.03.028.038.06.038.098v4.732c0 .036 0 .072-.002.107a.926.926 0 01-.492.784 1.023 1.023 0 01-.53.12H5.486a1 1 0 01-.403-.064.952.952 0 01-.605-.94z" clip-rule="evenodd"/>
|
||||
</g>
|
||||
</svg>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
let { class: className = 'w-4 h-4' }: { class?: string } = $props();
|
||||
</script>
|
||||
|
||||
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" class={className} fill="currentColor">
|
||||
<circle cx="512" cy="512" r="512" opacity="0.15"/>
|
||||
<path d="M450.77 205.55s85.35 74.82 73.92 141c-5.15 29.87-44.43 78.25-23.22 124.55 22.85 49.88 34.72 146.13-68.1 121.26-101.37-24.3-93.56-148.7-18.22-214.03s50.55-139.56 35.62-172.78zM528.65 430c-5.67 50.48 50.18 100.8 5.75 169.2C504 646.54 551 713.14 611.68 701c66.31-13.22 83-55.18 81.24-103.94-2.31-61.9-27-79.15-81.69-125.59-59.44-50.18-13.66-145.38-13.66-145.38S535.22 374.45 528.65 430zM350.49 651.31c11.87 33.3 30.24 86.69 102.89 113.79 61.15 22.92 146.8 27 196.15-36.29 11.35-14.19-3.58-8.66-18.52-3a119.47 119.47 0 0 1-111.47-21.41c-42-35-25.61-74.22-46.67-74.67-69.44-1.49-105.66-28.6-124.4-51.67-4.25-5.44-10.82 37.34 2.02 73.25z"/>
|
||||
</svg>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
let { class: className = 'w-4 h-4' }: { class?: string } = $props();
|
||||
</script>
|
||||
|
||||
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" fill="currentColor" class={className}>
|
||||
<path d="M16.255 3.172c-5.729-.13-10.813 3.661-12.313 9.193.068-.026-.073.016 0 0-5.167 3.401-5.229 10.812-.328 14.589l.005-.01-.01.042c1.568 1.182 3.474 1.813 5.438 1.807h6.896l.042.036h6.922c8.917.073 12.5-11.469 5.115-16.464-.635-2.349-1.943-4.458-3.76-6.068l-.057.057.005-.068c-2.203-1.943-5.021-3.047-7.953-3.115zM15.776 8.703c1.656-.057 3.359.49 4.646 1.531 1.609 1.344 2.526 3.344 2.484 5.438v.693c4.708-.094 4.708 7.016 0 6.922h-6.922l-.01.01v-.052h-6.927c-.49 0-.974-.104-1.422-.307-2.203-.995-2.729-3.891-1.021-5.604 1.714-1.708 4.604-1.182 5.604 1.021l4.016-4.016c-1.333-1.74-3.26-2.932-5.411-3.354.026-.01.052-.031.073-.026 1.26-1.385 3.026-2.198 4.896-2.255z"/>
|
||||
</svg>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
let { class: className = 'w-4 h-4' }: { class?: string } = $props();
|
||||
</script>
|
||||
|
||||
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" fill="currentColor" class={className}>
|
||||
<path opacity="0.15" d="M16,30A13.859,13.859,0,0,1,2.286,16,14.149,14.149,0,0,1,6.3,6.1,13.517,13.517,0,0,1,25.7,6.1,14.149,14.149,0,0,1,29.714,16,13.859,13.859,0,0,1,16,30Z"/>
|
||||
<path d="M11.869,23.015h-.4a4.263,4.263,0,0,1-1.048-.113,1.387,1.387,0,0,1-.767-.548,2.2,2.2,0,0,1-.409-1.026A13.28,13.28,0,0,1,9.159,19.6a12.78,12.78,0,0,0-.08-1.687,1.773,1.773,0,0,0-.405-.924,1.325,1.325,0,0,0-1.026-.391V15.4a1.325,1.325,0,0,0,1.026-.391,1.773,1.773,0,0,0,.405-.924,12.78,12.78,0,0,0,.08-1.687,13.28,13.28,0,0,1,.084-1.731,2.2,2.2,0,0,1,.409-1.026,1.387,1.387,0,0,1,.767-.548,4.263,4.263,0,0,1,1.048-.113h.4v1.14h-.226q-.76,0-.931.4a7.72,7.72,0,0,0-.172,2.17,4.876,4.876,0,0,1-.416,2.316A2.117,2.117,0,0,1,8.859,16a2.089,2.089,0,0,1,1.264.986A4.876,4.876,0,0,1,10.54,19.3a7.72,7.72,0,0,0,.172,2.17q.172.4.931.4h.226Zm1.6-3.141h1.035V16.42h1.459l1.97,3.454h1.184L17.04,16.308a2.243,2.243,0,0,0,1.833-2.319c0-1.7-1.2-2.294-2.855-2.294H13.474Zm1.035-4.3V12.53h1.359c1.272,0,1.97.386,1.97,1.459s-.7,1.583-1.97,1.583Zm5.557-6.591h.4a4.263,4.263,0,0,1,1.048.113,1.387,1.387,0,0,1,.767.548,2.2,2.2,0,0,1,.409,1.026,13.28,13.28,0,0,1,.084,1.731,12.78,12.78,0,0,0,.08,1.687,1.773,1.773,0,0,0,.405.924,1.325,1.325,0,0,0,1.026.391v1.191a1.325,1.325,0,0,0-1.026.391,1.773,1.773,0,0,0-.405.924,12.78,12.78,0,0,0-.08,1.687,13.28,13.28,0,0,1-.084,1.731,2.2,2.2,0,0,1-.409,1.026,1.387,1.387,0,0,1-.767.548,4.263,4.263,0,0,1-1.048.113h-.4v-1.14h.226q.76,0,.931-.4A7.72,7.72,0,0,0,21.4,19.3a4.876,4.876,0,0,1,.416-2.316A2.089,2.089,0,0,1,23.075,16a2.117,2.117,0,0,1-1.264-.994,4.876,4.876,0,0,1-.416-2.316,7.72,7.72,0,0,0-.172-2.17q-.172-.4-.931-.4h-.226Z"/>
|
||||
</svg>
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as AmazonS3Icon } from './AmazonS3.svelte';
|
||||
export { default as BackblazeIcon } from './Backblaze.svelte';
|
||||
export { default as AzureBlobIcon } from './AzureBlob.svelte';
|
||||
export { default as GoogleCloudIcon } from './GoogleCloud.svelte';
|
||||
export { default as RestServerIcon } from './RestServer.svelte';
|
||||
@@ -10,11 +10,13 @@
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onchange: (cron: string) => void;
|
||||
onchange?: (cron: string) => void;
|
||||
disabled?: boolean;
|
||||
compact?: boolean;
|
||||
invalid?: boolean;
|
||||
}
|
||||
|
||||
let { value, onchange, disabled = false }: Props = $props();
|
||||
let { value = $bindable(), onchange, disabled = false, compact = false, invalid = $bindable(false) }: Props = $props();
|
||||
|
||||
// Detect schedule type from cron expression
|
||||
function detectScheduleType(cron: string): 'daily' | 'weekly' | 'custom' {
|
||||
@@ -86,7 +88,8 @@
|
||||
return;
|
||||
}
|
||||
|
||||
onchange(newCron);
|
||||
value = newCron;
|
||||
onchange?.(newCron);
|
||||
}
|
||||
|
||||
// Handle schedule type change
|
||||
@@ -106,12 +109,14 @@
|
||||
if (type === 'daily') {
|
||||
minute = '0';
|
||||
hour = '3';
|
||||
onchange('0 3 * * *');
|
||||
value = '0 3 * * *';
|
||||
onchange?.(value);
|
||||
} else if (type === 'weekly') {
|
||||
minute = '0';
|
||||
hour = '3';
|
||||
dayOfWeek = '1'; // Monday
|
||||
onchange('0 3 * * 1');
|
||||
value = '0 3 * * 1';
|
||||
onchange?.(value);
|
||||
}
|
||||
previousScheduleType = type;
|
||||
}
|
||||
@@ -134,7 +139,8 @@
|
||||
|
||||
function handleCustomCronInput(e: Event) {
|
||||
const newValue = (e.currentTarget as HTMLInputElement).value;
|
||||
onchange(newValue);
|
||||
value = newValue;
|
||||
onchange?.(newValue);
|
||||
}
|
||||
|
||||
// Validate cron expression (supports 5-field and 6-field with seconds)
|
||||
@@ -148,6 +154,12 @@
|
||||
return parts.every((part) => cronFieldPattern.test(part));
|
||||
}
|
||||
|
||||
// Sync validity to bindable prop so parents can gate Save buttons
|
||||
$effect(() => {
|
||||
const next = !value || !value.trim() ? true : !isValidCron(value);
|
||||
if (invalid !== next) invalid = next;
|
||||
});
|
||||
|
||||
// Human-readable description using cronstrue
|
||||
let humanReadable = $derived(() => {
|
||||
if (!value) return '';
|
||||
@@ -202,10 +214,26 @@
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{#if compact}
|
||||
<!-- Compact mode: single-line cron input with preview -->
|
||||
<div>
|
||||
<Input
|
||||
{value}
|
||||
oninput={(e) => { value = e.currentTarget.value; onchange?.(e.currentTarget.value); }}
|
||||
placeholder="0 2 * * *"
|
||||
class="h-7 text-xs font-mono {humanReadable() === 'Invalid' ? 'border-destructive' : ''}"
|
||||
{disabled}
|
||||
/>
|
||||
{#if value}
|
||||
{@const readable = humanReadable()}
|
||||
<p class="text-[9px] mt-0.5 {readable === 'Invalid' ? 'text-destructive' : 'text-muted-foreground/60'}">{readable}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Schedule Type Selector -->
|
||||
<Select.Root type="single" value={scheduleType} onValueChange={handleScheduleTypeChange} {disabled}>
|
||||
<Select.Trigger class="w-[140px] h-9">
|
||||
<Select.Trigger class="w-[120px] h-9 flex-shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if scheduleType === 'daily'}
|
||||
<Calendar class="w-4 h-4" />
|
||||
@@ -245,7 +273,7 @@
|
||||
<!-- Time Selectors -->
|
||||
<span class="text-sm text-muted-foreground">at</span>
|
||||
<Select.Root type="single" value={hour} onValueChange={handleHourChange} {disabled}>
|
||||
<Select.Trigger class="w-[100px] h-9">
|
||||
<Select.Trigger class="w-[85px] h-9 flex-shrink-0">
|
||||
<span>{hours.find((h: { value: string; label: string }) => h.value === hour)?.label || hour}</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
@@ -255,7 +283,7 @@
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Select.Root type="single" value={minute} onValueChange={handleMinuteChange} {disabled}>
|
||||
<Select.Trigger class="w-[70px] h-9">
|
||||
<Select.Trigger class="w-[60px] h-9 flex-shrink-0">
|
||||
<span>{minutes.find(m => m.value === minute)?.label || `:${minute}`}</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
@@ -268,7 +296,7 @@
|
||||
{#if scheduleType === 'weekly'}
|
||||
<span class="text-sm text-muted-foreground">on</span>
|
||||
<Select.Root type="single" value={dayOfWeek} onValueChange={handleDayOfWeekChange} {disabled}>
|
||||
<Select.Trigger class="w-[110px] h-9">
|
||||
<Select.Trigger class="w-[100px] h-9 flex-shrink-0">
|
||||
<span>{daysOfWeek.find(d => d.value === dayOfWeek)?.label || dayOfWeek}</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
@@ -293,13 +321,12 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Description area with fixed height -->
|
||||
<div class="min-h-[20px] mt-1">
|
||||
{#if value}
|
||||
{@const readable = humanReadable()}
|
||||
{@const isInvalid = readable === 'Invalid'}
|
||||
<p class="text-xs {isInvalid ? 'text-destructive' : 'text-muted-foreground'}">
|
||||
{readable}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Description -->
|
||||
{#if value}
|
||||
{@const readable = humanReadable()}
|
||||
{@const isInvalid = readable === 'Invalid'}
|
||||
<p class="text-[10px] mt-0.5 {isInvalid ? 'text-destructive' : 'text-muted-foreground/60'}">
|
||||
{readable}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -764,6 +764,7 @@
|
||||
<th
|
||||
class="{colConfig.align === 'right' ? 'text-right' : colConfig.align === 'center' ? 'text-center' : 'text-left'} py-2 px-2 font-medium"
|
||||
style="width: {getDisplayWidth(colId)}px"
|
||||
title={colConfig.hint || ''}
|
||||
>
|
||||
{#if headerCell}
|
||||
{@render headerCell(colConfig, sortState)}
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { themeStore, type FontSize } from '$lib/stores/theme';
|
||||
import { getTimeFormat } from '$lib/stores/settings';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
import { getTimeFormat } from '$lib/stores/settings';
|
||||
|
||||
// Font size scaling for header
|
||||
let fontSize = $state<FontSize>('normal');
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
// lucide `rotate-cw-fading-clock` — not yet in the pinned lucide-svelte 0.562.0,
|
||||
// vendored from the official SVG so we don't need a major (0.x → 1.0) bump.
|
||||
// Drop-in with the same `class`/rest-props surface as a lucide-svelte icon.
|
||||
let { class: className = '', ...rest }: { class?: string; [key: string]: unknown } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class={className}
|
||||
{...rest}
|
||||
>
|
||||
<path d="M12 3a9.75 9.75 0 0 1 6.74 2.74" />
|
||||
<path d="M18.74 5.74 21 8" />
|
||||
<path d="M21 8V3" />
|
||||
<path d="M7.5 19.794c-6-3.464-6-12.124 0-15.588" />
|
||||
<path d="M7.5 4.206A9 9 0 0 1 12 3" />
|
||||
<path d="M12 7v5l4 2" />
|
||||
<path d="M14 20.775A9 9 0 0 1 12 21" />
|
||||
<path d="M19 17.656a9 9 0 0 1-1.5 1.456" />
|
||||
<path d="M21 12a9 9 0 0 1-.228 2" />
|
||||
<path d="M21 8h-5" />
|
||||
</svg>
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 {checked ? 'bg-green-500/15 text-green-600 hover:bg-green-500/25' : 'bg-muted text-muted-foreground hover:bg-muted/80'} {disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}"
|
||||
class="inline-flex items-center justify-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors min-w-[65px] focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 {checked ? 'bg-green-500/15 text-green-600 hover:bg-green-500/25' : 'bg-muted text-muted-foreground hover:bg-muted/80'} {disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}"
|
||||
onclick={toggle}
|
||||
{disabled}
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user