more design tweaks
Continuous Integration / frontend-check (push) Successful in 10m53s
Continuous Integration / backend-tests (push) Successful in 12m22s

This commit is contained in:
2026-04-28 21:14:53 -04:00
parent e1fd853890
commit fc47062dd5
15 changed files with 283 additions and 195 deletions
@@ -2,6 +2,7 @@
import { X, Activity, Search, Play, RotateCw, Clock, CheckCircle2, AlertCircle, FileText, Database, HardDrive, MapPin, ExternalLink, ArrowRight } from 'lucide-svelte';
import { Button } from './ui/button';
import { Card } from './ui/card';
import Dialog from './ui/Dialog.svelte';
import { getJobDetailSystemJobsJobIdGet, type JobSchema } from '$lib/api';
import { cn, formatLocalTime, formatLocalDateTime, parseUTCDate } from '$lib/utils';
import { onMount } from 'svelte';
@@ -44,8 +45,8 @@
onMount(loadJob);
</script>
<div class="fixed inset-0 z-[1200] bg-black/90 backdrop-blur-md flex items-center justify-center p-6 animate-in fade-in duration-300" onmousedown={onClear}>
<Card class="w-[800px] max-h-[90vh] bg-bg-secondary border-border-color shadow-2xl overflow-hidden flex flex-col animate-in zoom-in-95 duration-300" onmousedown={(e) => e.stopPropagation()}>
<Dialog show={true} onClose={onClear} ariaLabelledBy="modal-title">
<Card class="w-[800px] max-h-[90vh] overflow-hidden flex flex-col shadow-2xl">
{#if loading}
<div class="flex-1 flex flex-col items-center justify-center gap-4 py-24">
<RotateCw size={48} class="animate-spin text-blue-500" />
@@ -63,7 +64,7 @@
</div>
<div>
<div class="flex items-center gap-3 mb-1">
<h2 class="text-xl font-bold text-text-primary">{job.job_type.charAt(0) + job.job_type.slice(1).toLowerCase()} job #{job.id}</h2>
<h2 id="modal-title" class="text-xl font-bold text-text-primary">{job.job_type.charAt(0) + job.job_type.slice(1).toLowerCase()} job #{job.id}</h2>
<span class={cn(
"px-2.5 py-0.5 rounded-full border text-[10px] font-medium uppercase tracking-wider",
job.status === 'COMPLETED' ? 'text-success-color border-success-color/20 bg-success-color/5' :
@@ -149,4 +150,4 @@
</footer>
{/if}
</Card>
</div>
</Dialog>
+5 -3
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import ContextMenu from './ui/ContextMenu.svelte';
import EmptyState from './ui/EmptyState.svelte';
import { FolderSearch, ExternalLink, ChevronLeft } from 'lucide-svelte';
import type { TreemapItem } from '$lib/types';
@@ -135,9 +136,10 @@
{/each}
{#if currentItems.length === 0}
<div class="absolute inset-0 flex items-center justify-center opacity-20">
<span class="text-sm font-medium">No nested data</span>
</div>
<EmptyState
title="No nested data"
class="absolute inset-0 p-0"
/>
{/if}
</div>
</div>
@@ -326,10 +326,10 @@
}
</script>
<svelte:window onkeydown={handleKeyDown} />
<div
class="file-browser flex h-full flex-col overflow-hidden rounded-lg border border-border-color bg-bg-secondary shadow-2xl min-w-0"
onkeydown={handleKeyDown}
tabindex="0"
role="application"
aria-label="File Browser"
>
@@ -378,8 +378,9 @@
<div
class="flex-1 flex items-center bg-bg-primary border border-border-color/40 rounded-md px-3 h-9 shadow-inner overflow-hidden max-w-3xl group transition-all focus-within:border-action-color/50 min-w-0"
onclick={handleAddressClick}
onkeydown={(e) => e.key === 'Enter' && handleAddressClick()}
role="button"
tabindex="-1"
tabindex="0"
>
<Folder size={16} class="text-yellow-500/80 mr-2 shrink-0"></Folder>
@@ -389,7 +390,6 @@
class="flex-1 bg-transparent border-none outline-none text-[13px] text-text-primary mono"
bind:value={pathInputValue}
onblur={() => setTimeout(() => isEditingPath = false, 100)}
autoFocus
/>
{:else}
<div class="flex-1 flex items-center overflow-x-auto scrollbar-hide">
@@ -14,7 +14,7 @@
children: Snippet;
}>();
let menuElement: HTMLElement;
let menuElement = $state<HTMLElement | null>(null);
// Adjust position to stay within viewport
let adjustedX = $state(0);
@@ -0,0 +1,41 @@
<script lang="ts">
import { type Snippet } from 'svelte';
import { fade } from 'svelte/transition';
import { cn } from '$lib/utils';
interface Props {
show: boolean;
onClose: () => void;
children: Snippet;
class?: string;
ariaLabelledBy?: string;
}
let { show, onClose, children, class: className, ariaLabelledBy }: Props = $props();
function handleMousedown(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
</script>
{#if show}
<div
class="fixed inset-0 z-[1200] bg-black/90 backdrop-blur-md flex items-center justify-center p-6"
onmousedown={handleMousedown}
transition:fade={{ duration: 200 }}
role="presentation"
>
<div
class={cn("relative animate-in zoom-in-95 duration-300", className)}
onmousedown={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby={ariaLabelledBy}
tabindex="-1"
>
{@render children()}
</div>
</div>
{/if}
@@ -0,0 +1,35 @@
<script lang="ts">
import { cn } from '$lib/utils';
import type { Snippet } from 'svelte';
interface Props {
icon?: any;
title: string;
description?: string;
action?: Snippet;
class?: string;
}
let { icon: Icon, title, description, action, class: className }: Props = $props();
</script>
<div class={cn("flex w-full flex-col items-center justify-center p-12 text-center animate-in fade-in zoom-in duration-500", className)}>
<div class="flex w-full flex-col items-center">
{#if Icon}
<div class="w-20 h-20 bg-bg-tertiary rounded-full flex items-center justify-center mb-6 border-2 border-dashed border-border-color opacity-50">
<Icon size={40} class="text-text-secondary" strokeWidth={1} />
</div>
{/if}
<h2 class="text-lg font-bold text-text-primary">{title}</h2>
{#if description}
<p class="text-sm mt-3 text-text-secondary leading-relaxed opacity-60">
{description}
</p>
{/if}
{#if action}
<div class="mt-8">
{@render action()}
</div>
{/if}
</div>
</div>
@@ -0,0 +1,49 @@
<script lang="ts">
import { Button } from './button';
import { cn } from '$lib/utils';
interface Props {
icon: any;
iconSize?: number;
onclick?: (e: MouseEvent) => void;
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
size?: 'sm' | 'md' | 'lg';
class?: string;
title?: string;
disabled?: boolean;
href?: string;
}
let {
icon: Icon,
iconSize,
onclick,
variant = 'ghost',
size = 'md',
class: className,
title,
disabled = false,
href
}: Props = $props();
const sizes = {
sm: { btn: 'h-7 w-7', icon: 12 },
md: { btn: 'h-9 w-9', icon: 16 },
lg: { btn: 'h-11 w-11', icon: 20 }
};
const currentSize = $derived(sizes[size]);
const currentIconSize = $derived(iconSize || currentSize.icon);
</script>
<Button
size="icon"
{variant}
{onclick}
{href}
class={cn(currentSize.btn, className)}
{title}
{disabled}
>
<Icon size={currentIconSize} />
</Button>
@@ -3,7 +3,7 @@ import type { Button as ButtonPrimitive } from "bits-ui";
import Root from "./button.svelte";
const buttonVariants = tv({
base: "inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-40 active:translate-y-[1px] border select-none relative overflow-hidden",
base: "inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-40 active:translate-y-[1px] border select-none relative overflow-hidden [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-blue-600 text-white border-blue-700 shadow-lg shadow-blue-500/10 hover:bg-blue-700",
+3 -3
View File
@@ -159,10 +159,10 @@
<!-- Dynamic Scan Status Overlay (Global) -->
<ScanStatusOverlay />
<div class="flex-1 overflow-y-auto p-8 lg:p-10 relative">
<div class="flex-1 overflow-y-auto overflow-x-hidden p-8 lg:p-10 relative">
<!-- Animated Background Glow -->
<div class="absolute -top-24 -right-24 w-96 h-96 bg-blue-600/5 rounded-full blur-[120px] pointer-events-none"></div>
<div class="absolute -bottom-24 -left-24 w-96 h-96 bg-blue-600/5 rounded-full blur-[120px] pointer-events-none"></div>
<div class="absolute -top-24 -right-24 w-96 h-96 bg-blue-600/5 rounded-full blur-[120px] pointer-events-none overflow-hidden"></div>
<div class="absolute -bottom-24 -left-24 w-96 h-96 bg-blue-600/5 rounded-full blur-[120px] pointer-events-none overflow-hidden"></div>
{@render children()}
</div>
+75 -76
View File
@@ -33,6 +33,7 @@
import SectionHeader from '$lib/components/ui/SectionHeader.svelte';
import StatusBadge from '$lib/components/ui/StatusBadge.svelte';
import ProgressBar from '$lib/components/ui/ProgressBar.svelte';
import Dialog from '$lib/components/ui/Dialog.svelte';
import { Card } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { cn } from '$lib/utils';
@@ -459,8 +460,8 @@
<Button variant="default" size="sm" class="h-7 text-[10px] bg-blue-600 hover:bg-blue-500" onclick={() => handleStartBackup(media.id, media.identifier)}>Archive</Button>
{/if}
{/if}
<Button variant="ghost" size="icon" class="h-7 w-7 text-text-secondary hover:text-text-primary hover:bg-white/5" onclick={() => openEdit(media)}><Edit3 size={12} /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7 text-text-secondary hover:text-error-color hover:bg-error-color/10" onclick={() => handleDelete(media.id)}><Trash2 size={12} /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7 text-text-secondary hover:text-text-primary hover:bg-white/5" onclick={() => openEdit(media)}><Edit3 size={14} /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7 text-text-secondary hover:text-error-color hover:bg-error-color/10" onclick={() => handleDelete(media.id)}><Trash2 size={16} /></Button>
</div>
</td>
{/snippet}
@@ -828,89 +829,87 @@
</div>
<!-- Registration Dialog -->
{#if showRegisterDialog}
<div class="fixed inset-0 bg-black/80 backdrop-blur-sm z-[100] flex items-center justify-center p-6" onmousedown={() => showRegisterDialog = false}>
<Card class="w-[700px] max-h-[90vh] overflow-y-auto bg-bg-secondary border-border-color shadow-2xl p-8 flex flex-col gap-6 animate-in zoom-in-95 duration-300" onmousedown={(e) => e.stopPropagation()}>
<header class="flex justify-between items-start">
<div>
<h2 class="text-xl font-bold text-text-primary">Add new media</h2>
<p class="text-sm text-text-secondary mt-1 opacity-60">Add physical storage locations for your backups.</p>
</div>
<Button variant="ghost" size="icon" class="hover:bg-white/5" onclick={() => showRegisterDialog = false}><X size={20} /></Button>
</header>
<div class="grid grid-cols-3 gap-4">
{#each providersList as provider}
<button class={cn("flex flex-col items-center gap-3 p-4 rounded-xl border-2 transition-all", newMedia.media_type === provider.provider_id ? "bg-blue-500/10 border-blue-500 text-blue-400 shadow-lg shadow-blue-500/10" : "bg-bg-primary/50 border-border-color text-text-secondary hover:border-text-secondary/30")}
onclick={() => {
newMedia.media_type = provider.provider_id;
if (provider.provider_id === 'lto_tape') newMedia.location = 'Storage Shelf';
else if (provider.provider_id === 'local_hdd') newMedia.location = 'Offsite Safe';
else newMedia.location = 'Cloud';
}}
>
{@render ConfigIcon(provider.provider_id)}
<span class="text-xs font-semibold">{provider.name}</span>
</button>
{/each}
<Dialog show={showRegisterDialog} onClose={() => showRegisterDialog = false} ariaLabelledBy="register-title">
<Card class="w-[700px] max-h-[90vh] overflow-y-auto p-8 flex flex-col gap-6 shadow-2xl">
<header class="flex justify-between items-start">
<div>
<h2 id="register-title" class="text-xl font-bold text-text-primary">Add new media</h2>
<p class="text-sm text-text-secondary mt-1 opacity-60">Add physical storage locations for your backups.</p>
</div>
<Button variant="ghost" size="icon" class="hover:bg-white/5" onclick={() => showRegisterDialog = false}><X size={20} /></Button>
</header>
<div class="space-y-6">
<div class="grid grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="identifier">Identifier (Barcode/SN)</label>
<Input id="identifier" bind:value={newMedia.identifier} placeholder="BUP-00001" class="h-10 bg-bg-primary/50 border-border-color font-mono text-sm" />
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="capacity">Capacity (GB)</label>
<Input id="capacity" type="number" bind:value={newMedia.capacity_gb} class="h-10 bg-bg-primary/50 border-border-color font-mono" />
<p class="text-[10px] text-text-secondary leading-tight opacity-60">Auto-detected when possible. You can manually reduce this to reserve space.</p>
</div>
</div>
<div class="grid grid-cols-3 gap-4">
{#each providersList as provider}
<button class={cn("flex flex-col items-center gap-3 p-4 rounded-xl border-2 transition-all", newMedia.media_type === provider.provider_id ? "bg-blue-500/10 border-blue-500 text-blue-400 shadow-lg shadow-blue-500/10" : "bg-bg-primary/50 border-border-color text-text-secondary hover:border-text-secondary/30")}
onclick={() => {
newMedia.media_type = provider.provider_id;
if (provider.provider_id === 'lto_tape') newMedia.location = 'Storage Shelf';
else if (provider.provider_id === 'local_hdd') newMedia.location = 'Offsite Safe';
else newMedia.location = 'Cloud';
}}
>
{@render ConfigIcon(provider.provider_id)}
<span class="text-xs font-semibold">{provider.name}</span>
</button>
{/each}
</div>
<div class="space-y-6">
<div class="grid grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="location">Physical location</label>
<div class="relative">
<MapPin size={16} class="absolute left-4 top-3 text-text-secondary opacity-50" />
<Input id="location" bind:value={newMedia.location} placeholder="Cabinet A, Shelf 2" class="h-10 bg-bg-primary/50 pl-12 border-border-color font-mono text-sm" />
</div>
<label class="text-xs font-medium text-text-secondary ml-1" for="identifier">Identifier (Barcode/SN)</label>
<Input id="identifier" bind:value={newMedia.identifier} placeholder="BUP-00001" class="h-10 bg-bg-primary/50 border-border-color font-mono text-sm" />
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="capacity">Capacity (GB)</label>
<Input id="capacity" type="number" bind:value={newMedia.capacity_gb} class="h-10 bg-bg-primary/50 border-border-color font-mono" />
<p class="text-[10px] text-text-secondary leading-tight opacity-60">Auto-detected when possible. You can manually reduce this to reserve space.</p>
</div>
<!-- Dynamic Provider Config Fields -->
{#if activeProvider}
<div class="grid grid-cols-2 gap-4 animate-in slide-in-from-top-2 duration-300">
{#each Object.entries(activeProvider.config_schema) as [key, schema]}
{@const field = schema as any}
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="config-{key}">{field.title || key}</label>
<Input
id="config-{key}"
bind:value={dynamicConfig[key]}
placeholder={field.description || ""}
type={key.includes("key") || key.includes("passphrase") ? "password" : "text"}
class="h-10 bg-bg-primary/50 border-border-color font-mono text-sm"
/>
</div>
{/each}
</div>
{/if}
</div>
<footer class="flex gap-3 pt-4 border-t border-border-color">
<Button variant="outline" class="flex-1 h-10" onclick={() => showRegisterDialog = false}>Cancel</Button>
<Button variant="default" class="flex-[2] h-10" onclick={handleRegister}>Register media</Button>
</footer>
</Card>
</div>
{/if}
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="location">Physical location</label>
<div class="relative">
<MapPin size={16} class="absolute left-4 top-3 text-text-secondary opacity-50" />
<Input id="location" bind:value={newMedia.location} placeholder="Cabinet A, Shelf 2" class="h-10 bg-bg-primary/50 pl-12 border-border-color font-mono text-sm" />
</div>
</div>
<!-- Dynamic Provider Config Fields -->
{#if activeProvider}
<div class="grid grid-cols-2 gap-4 animate-in slide-in-from-top-2 duration-300">
{#each Object.entries(activeProvider.config_schema) as [key, schema]}
{@const field = schema as any}
<div class="space-y-2">
<label class="text-xs font-medium text-text-secondary ml-1" for="config-{key}">{field.title || key}</label>
<Input
id="config-{key}"
bind:value={dynamicConfig[key]}
placeholder={field.description || ""}
type={key.includes("key") || key.includes("passphrase") ? "password" : "text"}
class="h-10 bg-bg-primary/50 border-border-color font-mono text-sm"
/>
</div>
{/each}
</div>
{/if}
</div>
<footer class="flex gap-3 pt-4 border-t border-border-color">
<Button variant="outline" class="flex-1 h-10" onclick={() => showRegisterDialog = false}>Cancel</Button>
<Button variant="default" class="flex-[2] h-10" onclick={handleRegister}>Register media</Button>
</footer>
</Card>
</Dialog>
<!-- Edit Dialog -->
{#if editingMedia}
<div class="fixed inset-0 bg-black/80 backdrop-blur-sm z-[100] flex items-center justify-center p-6" onmousedown={() => editingMedia = null}>
<Card class="w-[600px] max-h-[90vh] overflow-y-auto bg-bg-secondary border-border-color shadow-2xl p-8 flex flex-col gap-6 animate-in zoom-in-95 duration-300" onmousedown={(e) => e.stopPropagation()}>
<Dialog show={!!editingMedia} onClose={() => editingMedia = null} ariaLabelledBy="edit-title">
{#if editingMedia}
<Card class="w-[600px] max-h-[90vh] overflow-y-auto p-8 flex flex-col gap-6 shadow-2xl">
<header class="flex justify-between items-start">
<div>
<h2 class="text-xl font-bold text-text-primary flex items-center gap-3">
<h2 id="edit-title" class="text-xl font-bold text-text-primary flex items-center gap-3">
<Edit3 size={20} class="text-blue-500" />
Edit media config
</h2>
@@ -975,6 +974,6 @@
</Button>
</footer>
</Card>
</div>
{/if}
{/if}
</Dialog>
</div>
+6 -4
View File
@@ -18,6 +18,7 @@
import SectionHeader from '$lib/components/ui/SectionHeader.svelte';
import StatusBadge from '$lib/components/ui/StatusBadge.svelte';
import ProgressBar from '$lib/components/ui/ProgressBar.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import JobDetailModal from '$lib/components/JobDetailModal.svelte';
import {
listJobsSystemJobsGet,
@@ -214,10 +215,11 @@
</div>
</Card>
{:else}
<div class="py-12 border-2 border-dashed border-border-color rounded-2xl flex flex-col items-center justify-center opacity-20">
<Activity size={40} class="mb-2 text-blue-500" />
<p class="text-xs font-medium">No active operations</p>
</div>
<EmptyState
icon={Activity}
title="No active operations"
description="There are currently no tasks running on this station."
/>
{/each}
</div>
</section>
+10 -12
View File
@@ -18,6 +18,7 @@
import { Button } from '$lib/components/ui/button';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import SectionHeader from '$lib/components/ui/SectionHeader.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import { Card } from '$lib/components/ui/card';
import { ScrollArea } from '$lib/components/ui/scroll-area';
import FileBrowser from '$lib/components/file-browser/FileBrowser.svelte';
@@ -189,20 +190,17 @@
</PageHeader>
{#if (manifest?.total_files || 0) === 0 && !loading}
<div class="flex-1 flex flex-col items-center justify-center p-12 text-center animate-in fade-in zoom-in duration-500">
<div class="max-w-2xl flex flex-col items-center">
<div class="w-24 h-24 bg-bg-tertiary rounded-full flex items-center justify-center mb-8 border-2 border-dashed border-border-color opacity-50">
<History size={48} class="text-text-secondary" strokeWidth={1} />
</div>
<h2 class="text-xl font-bold text-text-primary">Recovery queue is empty</h2>
<p class="text-sm mt-4 text-text-secondary leading-relaxed opacity-60">
You haven't selected any files for restoration yet. Use the Index Browser to find and queue the items you need to recover from your archives.
</p>
<Button variant="default" class="mt-8 px-8 shadow-lg shadow-blue-500/20" href="/index-browser">
<EmptyState
icon={History}
title="Recovery queue is empty"
description="You haven't selected any files for restoration yet. Use the Index Browser to find and queue the items you need to recover from your archives."
>
{#snippet action()}
<Button variant="default" class="px-8 shadow-lg shadow-blue-500/20" href="/index-browser">
Browse virtual index <ArrowRight size={14} class="ml-2" />
</Button>
</div>
</div>
{/snippet}
</EmptyState>
{:else}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 flex-1 min-h-0">
<!-- RECOVERY STRUCTURE TREE -->
+40 -87
View File
@@ -22,6 +22,7 @@
} from "lucide-svelte";
import { Button } from "$lib/components/ui/button";
import PageHeader from "$lib/components/ui/PageHeader.svelte";
import SectionHeader from "$lib/components/ui/SectionHeader.svelte";
import { Card } from "$lib/components/ui/card";
import { Input } from "$lib/components/ui/input";
import {
@@ -245,7 +246,7 @@
{#each tabs as tab}
<button
class={cn(
"flex items-center gap-3 px-4 py-3 rounded-xl text-3xs font-black uppercase tracking-widest transition-all whitespace-nowrap",
"flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all whitespace-nowrap cursor-pointer",
activeTab === tab.id
? "bg-blue-500/10 text-blue-500 border border-blue-500/20 shadow-[0_0_15px_rgba(59,130,246,0.1)]"
: "text-text-secondary hover:bg-white/5 border border-transparent"
@@ -265,14 +266,8 @@
{:else}
{#if activeTab === 'hardware'}
<div class="animate-in slide-in-from-bottom-4 duration-500">
<Card class="p-6 bg-bg-secondary border-border-color shadow-xl">
<div class="flex items-center gap-3 mb-6">
<div class="p-2.5 bg-blue-500/10 rounded-xl text-blue-500 border border-blue-500/20"><Monitor size={20} /></div>
<div>
<h3 class="text-lg font-black text-text-primary uppercase tracking-tight">LTO Hardware</h3>
<p class="text-4xs text-text-secondary font-medium uppercase tracking-wider opacity-60">Define tape drive device nodes</p>
</div>
</div>
<Card class="p-5 shadow-xl">
<SectionHeader title="LTO hardware" icon={Monitor} class="mb-6 px-0" />
<div class="space-y-3">
{#each tapeDrives as drive, i}
<div class="flex gap-2 animate-in slide-in-from-left-4 duration-300" style="animation-delay: {i * 50}ms">
@@ -280,11 +275,11 @@
<Terminal size={14} class="absolute left-4 top-3 text-text-secondary opacity-50" />
<Input bind:value={tapeDrives[i]} placeholder="/dev/nst0" class="h-10 bg-bg-primary/50 pl-10 border-border-color font-mono text-xs" />
</div>
<Button variant="ghost" class="h-10 w-10 rounded-xl bg-error-color/5 text-error-color/60 hover:bg-error-color/10 hover:text-error-color" onclick={() => removeDrive(i)}><Trash2 size={16} /></Button>
<Button variant="ghost" class="h-10 w-10 shrink-0 rounded-xl bg-error-color/5 text-error-color/60 hover:bg-error-color/10 hover:text-error-color" onclick={() => removeDrive(i)}><Trash2 size={18} /></Button>
</div>
{/each}
<Button variant="outline" class="w-full h-12 border-dashed border-2 border-border-color hover:border-blue-500/50 hover:bg-blue-500/5 font-black uppercase tracking-widest text-3xs mt-2" onclick={addDrive}>
<Plus size={16} class="mr-2" /> Add Tape Drive
<Button variant="outline" class="w-full h-11 border-dashed border-2 font-medium text-sm mt-2" onclick={addDrive}>
<Plus size={20} class="mr-2" /> Add tape drive
</Button>
</div>
</Card>
@@ -292,55 +287,37 @@
{:else if activeTab === 'paths'}
<div class="animate-in slide-in-from-bottom-4 duration-500 space-y-6">
<Card class="p-6 bg-bg-secondary border-border-color shadow-xl">
<div class="flex items-center gap-3 mb-6">
<div class="p-2.5 bg-blue-500/10 rounded-xl text-blue-500 border border-blue-500/20"><HardDrive size={20} /></div>
<div>
<h3 class="text-lg font-black text-text-primary uppercase tracking-tight">Source Roots</h3>
<p class="text-4xs text-text-secondary font-medium uppercase tracking-wider opacity-60">Directories available for archival</p>
</div>
</div>
<Card class="p-5 shadow-xl">
<SectionHeader title="Source roots" icon={HardDrive} class="mb-6 px-0" />
<div class="space-y-3">
{#each sourceRoots as root, i}
<div class="flex gap-2">
<Input bind:value={sourceRoots[i]} placeholder="/mnt/data" class="h-10 bg-bg-primary/50 border-border-color font-mono text-xs" />
<Button variant="ghost" class="h-10 w-10 rounded-xl bg-error-color/5 text-error-color/60 hover:bg-error-color/10 hover:text-error-color" onclick={() => removeSource(i)}><Trash2 size={16} /></Button>
<Button variant="ghost" class="h-10 w-10 shrink-0 rounded-xl bg-error-color/5 text-error-color/60 hover:bg-error-color/10 hover:text-error-color" onclick={() => removeSource(i)}><Trash2 size={18} /></Button>
</div>
{/each}
<Button variant="outline" class="w-full h-12 border-dashed border-2 border-border-color font-black uppercase tracking-widest text-3xs" onclick={addSource}><Plus size={16} class="mr-2" /> Add Source Root</Button>
<Button variant="outline" class="w-full h-11 border-dashed border-2 font-medium text-sm" onclick={addSource}><Plus size={20} class="mr-2" /> Add source root</Button>
</div>
</Card>
<Card class="p-6 bg-bg-secondary border-border-color shadow-xl">
<div class="flex items-center gap-3 mb-6">
<div class="p-2.5 bg-green-500/10 rounded-xl text-green-500 border border-green-500/20"><ArrowRight size={20} /></div>
<div>
<h3 class="text-lg font-black text-text-primary uppercase tracking-tight">Restore Targets</h3>
<p class="text-4xs text-text-secondary font-medium uppercase tracking-wider opacity-60">Permitted recovery destinations</p>
</div>
</div>
<Card class="p-5 shadow-xl">
<SectionHeader title="Restore targets" icon={ArrowRight} iconColor="text-success-color" class="mb-6 px-0" />
<div class="space-y-3">
{#each restoreDestinations as dest, i}
<div class="flex gap-2">
<Input bind:value={restoreDestinations[i]} placeholder="/restores" class="h-10 bg-bg-primary/50 border-border-color font-mono text-xs" />
<Button variant="ghost" class="h-10 w-10 rounded-xl bg-error-color/5 text-error-color/60 hover:bg-error-color/10 hover:text-error-color" onclick={() => removeDest(i)}><Trash2 size={16} /></Button>
<Button variant="ghost" class="h-10 w-10 shrink-0 rounded-xl bg-error-color/5 text-error-color/60 hover:bg-error-color/10 hover:text-error-color" onclick={() => removeDest(i)}><Trash2 size={18} /></Button>
</div>
{/each}
<Button variant="outline" class="w-full h-12 border-dashed border-2 border-border-color font-black uppercase tracking-widest text-3xs" onclick={addDest}><Plus size={16} class="mr-2" /> Add Restore Path</Button>
<Button variant="outline" class="w-full h-11 border-dashed border-2 font-medium text-sm" onclick={addDest}><Plus size={20} class="mr-2" /> Add restore path</Button>
</div>
</Card>
</div>
{:else if activeTab === 'exclusions'}
<div class="animate-in slide-in-from-bottom-4 duration-500">
<Card class="p-6 shadow-xl border-border-color/60 bg-bg-secondary">
<div class="flex items-center gap-3 mb-6">
<div class="p-2.5 bg-orange-500/10 rounded-xl text-orange-500 border border-orange-500/20"><ListX size={20} /></div>
<div>
<h3 class="text-lg font-black text-text-primary uppercase tracking-tight">Exclusion Policy</h3>
<p class="text-4xs text-text-secondary font-medium uppercase tracking-wider opacity-60">Git-style ignore patterns for all scans.</p>
</div>
</div>
<Card class="p-5 shadow-xl">
<SectionHeader title="Exclusion policy" icon={ListX} iconColor="text-orange-500" class="mb-6 px-0" />
<div class="space-y-5">
<textarea
bind:value={globalExclusions}
@@ -349,14 +326,14 @@
></textarea>
<div class="space-y-3">
<h4 class="text-5xs font-black uppercase tracking-widest text-text-secondary opacity-40">Common Patterns</h4>
<h4 class="text-[10px] font-semibold uppercase tracking-wider text-text-secondary opacity-40">Common patterns</h4>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
{#each commonExclusions as item}
<button
class="flex items-center justify-between px-3 py-1.5 bg-bg-primary/40 border border-border-color/60 rounded-lg hover:border-orange-500/40 hover:bg-orange-500/5 transition-all group"
onclick={() => addCommonExclusion(item.pattern)}
>
<span class="text-5xs font-bold text-text-secondary group-hover:text-text-primary">{item.label}</span>
<span class="text-[10px] font-medium text-text-secondary group-hover:text-text-primary">{item.label}</span>
<Plus size={10} class="text-text-secondary opacity-20 group-hover:opacity-100" />
</button>
{/each}
@@ -366,8 +343,8 @@
<div class="p-4 bg-orange-500/5 border border-dashed border-orange-500/30 rounded-xl flex gap-4 items-start">
<ShieldAlert size={20} class="text-orange-500 shrink-0 mt-0.5" />
<div class="space-y-1">
<span class="text-4xs font-black uppercase text-orange-500 tracking-widest">Policy Warning</span>
<p class="text-5xs text-text-secondary leading-relaxed font-medium">Broad exclusion patterns can result in critical data being skipped during the archival process. Ensure patterns match only transient data.</p>
<span class="text-xs font-bold text-orange-500 uppercase tracking-wider">Policy warning</span>
<p class="text-xs text-text-secondary leading-relaxed font-medium">Broad exclusion patterns can result in critical data being skipped during the archival process. Ensure patterns match only transient data.</p>
</div>
</div>
</div>
@@ -376,42 +353,30 @@
{:else if activeTab === 'scheduling'}
<div class="animate-in slide-in-from-bottom-4 duration-500 space-y-6">
<Card class="p-6 bg-bg-secondary border-border-color shadow-xl">
<div class="flex items-center gap-3 mb-6">
<div class="p-2.5 bg-blue-500/10 rounded-xl text-blue-500 border border-blue-500/20"><CalendarClock size={20} /></div>
<div>
<h3 class="text-lg font-black text-text-primary uppercase tracking-tight">Scan Frequency</h3>
<p class="text-4xs text-text-secondary font-medium uppercase tracking-wider opacity-60">Scheduled system discovery policy</p>
</div>
</div>
<Card class="p-5 shadow-xl">
<SectionHeader title="Scan frequency" icon={CalendarClock} class="mb-6 px-0" />
<div class="flex gap-3">
<div class="relative flex-1">
<Terminal size={14} class="absolute left-4 top-3 text-text-secondary opacity-50" />
<Input bind:value={scanSchedule} placeholder="0 2 * * *" class="h-10 bg-bg-primary/50 pl-10 border-border-color font-mono text-xs" />
</div>
<div class="flex gap-2">
<Button variant="outline" class="h-10 px-3 text-5xs uppercase font-black tracking-widest" onclick={() => scanSchedule = "0 * * * *"}>Hourly</Button>
<Button variant="outline" class="h-10 px-3 text-5xs uppercase font-black tracking-widest" onclick={() => scanSchedule = "0 2 * * *"}>Daily</Button>
<Button variant="outline" class="h-10 px-3 text-[10px] font-semibold" onclick={() => scanSchedule = "0 * * * *"}>Hourly</Button>
<Button variant="outline" class="h-10 px-3 text-[10px] font-semibold" onclick={() => scanSchedule = "0 2 * * *"}>Daily</Button>
</div>
</div>
</Card>
<Card class="p-6 bg-bg-secondary border-border-color shadow-xl">
<div class="flex items-center gap-3 mb-6">
<div class="p-2.5 bg-action-color/10 rounded-xl text-action-color border border-action-color/20"><CalendarClock size={20} /></div>
<div>
<h3 class="text-lg font-black text-text-primary uppercase tracking-tight">Archival Frequency</h3>
<p class="text-4xs text-text-secondary font-medium uppercase tracking-wider opacity-60">Scheduled media ingestion policy</p>
</div>
</div>
<Card class="p-5 shadow-xl">
<SectionHeader title="Archival frequency" icon={CalendarClock} iconColor="text-action-color" class="mb-6 px-0" />
<div class="flex gap-3">
<div class="relative flex-1">
<Terminal size={14} class="absolute left-4 top-3 text-text-secondary opacity-50" />
<Input bind:value={archivalSchedule} placeholder="0 4 * * 0" class="h-10 bg-bg-primary/50 pl-10 border-border-color font-mono text-xs" />
</div>
<div class="flex gap-2">
<Button variant="outline" class="h-10 px-3 text-5xs uppercase font-black tracking-widest" onclick={() => archivalSchedule = "0 4 * * 0"}>Weekly</Button>
<Button variant="outline" class="h-10 px-3 text-5xs uppercase font-black tracking-widest" onclick={() => archivalSchedule = "0 4 1 * *"}>Monthly</Button>
<Button variant="outline" class="h-10 px-3 text-[10px] font-semibold" onclick={() => archivalSchedule = "0 4 * * 0"}>Weekly</Button>
<Button variant="outline" class="h-10 px-3 text-[10px] font-semibold" onclick={() => archivalSchedule = "0 4 1 * *"}>Monthly</Button>
</div>
</div>
</Card>
@@ -419,14 +384,8 @@
{:else if activeTab === 'notifications'}
<div class="animate-in slide-in-from-bottom-4 duration-500">
<Card class="p-6 bg-bg-secondary border-border-color shadow-xl">
<div class="flex items-center gap-3 mb-6">
<div class="p-2.5 bg-blue-500/10 rounded-xl text-blue-500 border border-blue-500/20"><Bell size={20} /></div>
<div>
<h3 class="text-lg font-black text-text-primary uppercase tracking-tight">Alerting Endpoints</h3>
<p class="text-4xs text-text-secondary font-medium uppercase tracking-wider opacity-60">Apprise-compatible notification URLs</p>
</div>
</div>
<Card class="p-5 shadow-xl">
<SectionHeader title="Alerting endpoints" icon={Bell} class="mb-6 px-0" />
<div class="space-y-3">
{#each notificationUrls as url, i}
<div class="flex gap-2">
@@ -434,35 +393,29 @@
<Globe size={14} class="absolute left-4 top-3 text-text-secondary opacity-50" />
<Input bind:value={notificationUrls[i]} placeholder="prowl://apikey" class="h-10 bg-bg-primary/50 pl-10 border-border-color font-mono text-xs" />
</div>
<Button variant="outline" class="h-10 px-3 text-5xs uppercase font-black tracking-widest border-border-color" onclick={() => testNotify(notificationUrls[i])}>Test</Button>
<Button variant="ghost" class="h-10 w-10 rounded-xl bg-error-color/5 text-error-color/60 hover:bg-error-color/10 hover:text-error-color" onclick={() => removeNotify(i)}><Trash2 size={16} /></Button>
<Button variant="outline" class="h-10 px-3 text-[10px] font-semibold border-border-color" onclick={() => testNotify(notificationUrls[i])}>Test</Button>
<Button variant="ghost" class="h-10 w-10 shrink-0 rounded-xl bg-error-color/5 text-error-color/60 hover:bg-error-color/10 hover:text-error-color" onclick={() => removeNotify(i)}><Trash2 size={18} /></Button>
</div>
{/each}
<Button variant="outline" class="w-full h-12 border-dashed border-2 border-border-color font-black uppercase tracking-widest text-3xs" onclick={addNotify}><Plus size={16} class="mr-2" /> Add Notification Endpoint</Button>
<Button variant="outline" class="w-full h-11 border-dashed border-2 font-medium text-sm" onclick={addNotify}><Plus size={20} class="mr-2" /> Add notification endpoint</Button>
</div>
</Card>
</div>
{:else if activeTab === 'system'}
<div class="animate-in slide-in-from-bottom-4 duration-500 space-y-6">
<Card class="p-6 bg-bg-secondary border-border-color shadow-xl">
<div class="flex items-center gap-3 mb-6">
<div class="p-2.5 bg-blue-500/10 rounded-xl text-blue-500 border border-blue-500/20"><Database size={20} /></div>
<div>
<h3 class="text-lg font-black text-text-primary uppercase tracking-tight">Index Management</h3>
<p class="text-4xs text-text-secondary font-medium uppercase tracking-wider opacity-60">Backup and restore the system state</p>
</div>
</div>
<Card class="p-5 shadow-xl">
<SectionHeader title="Index management" icon={Database} class="mb-6 px-0" />
<div class="grid grid-cols-2 gap-4">
<Button variant="outline" class="h-14 font-black uppercase tracking-widest text-3xs border-border-color hover:bg-blue-500/5 group" onclick={handleExport} disabled={exporting}>
<Button variant="outline" class="h-14 font-medium text-sm group" onclick={handleExport} disabled={exporting}>
{#if exporting}
<RotateCw size={18} class="mr-2 animate-spin" /> Compiling...
{:else}
<Download size={18} class="mr-2 text-blue-400 group-hover:scale-110 transition-transform" /> Export Database Index
<Download size={18} class="mr-2 text-blue-400 group-hover:scale-110 transition-transform" /> Export database index
{/if}
</Button>
<Button variant="outline" class="h-14 font-black uppercase tracking-widest text-3xs border-border-color hover:bg-orange-500/5 group opacity-50 cursor-not-allowed">
<Upload size={18} class="mr-2 text-orange-400" /> Import Index (Restricted)
<Button variant="outline" class="h-14 font-medium text-sm group opacity-50 cursor-not-allowed">
<Upload size={18} class="mr-2 text-orange-400" /> Import index (Restricted)
</Button>
</div>
</Card>