exclusion policy tools
Continuous Integration / backend-tests (push) Successful in 50s
Continuous Integration / frontend-check (push) Successful in 31s
Continuous Integration / e2e-tests (push) Successful in 6m6s

This commit is contained in:
2026-05-04 19:27:43 -04:00
parent adb036a2f4
commit 699bc415fb
9 changed files with 434 additions and 24 deletions
+127 -4
View File
@@ -1,13 +1,33 @@
from fastapi import APIRouter, Depends
import csv
import io
from typing import Dict, List
import pathspec
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.db.database import get_db
from app.api.common import SettingSchema
from typing import Dict
from app.api.common import FileItemSchema, SettingSchema
from app.db import models
from app.db.database import get_db
router = APIRouter(tags=["System"])
class TestExclusionsRequest(BaseModel):
patterns: str
limit: int = 10
class TestExclusionsResponse(BaseModel):
total_files: int
total_size: int
matched_count: int
matched_size: int
sample: List[FileItemSchema]
@router.get("/settings", response_model=Dict[str, str], operation_id="get_settings")
def get_settings(db_session: Session = Depends(get_db)):
"""Retrieves all global system configuration key-value pairs."""
@@ -38,3 +58,106 @@ def update_settings(setting_data: SettingSchema, db_session: Session = Depends(g
scheduler_manager.reload()
return {"message": "Setting committed."}
@router.post(
"/settings/test-exclusions",
response_model=TestExclusionsResponse,
operation_id="test_exclusions",
)
def test_exclusions(
request_data: TestExclusionsRequest, db_session: Session = Depends(get_db)
):
"""Tests exclusion patterns against the current filesystem index."""
patterns = [p.strip() for p in request_data.patterns.splitlines() if p.strip()]
if not patterns:
return TestExclusionsResponse(
total_files=0, total_size=0, matched_count=0, matched_size=0, sample=[]
)
spec = pathspec.PathSpec.from_lines("gitwildmatch", patterns)
all_files = (
db_session.query(models.FilesystemState)
.filter(models.FilesystemState.is_deleted.is_(False))
.all()
)
total_size = 0
matched = []
matched_size = 0
for file_record in all_files:
total_size += file_record.size or 0
if spec.match_file(file_record.file_path):
matched_size += file_record.size or 0
matched.append(
FileItemSchema(
name=file_record.file_path.split("/")[-1],
path=file_record.file_path,
type="file",
size=file_record.size,
mtime=file_record.mtime,
ignored=file_record.is_ignored,
sha256_hash=file_record.sha256_hash,
)
)
total_files = len(all_files)
matched_count = len(matched)
sample = matched[: request_data.limit]
return TestExclusionsResponse(
total_files=total_files,
total_size=total_size,
matched_count=matched_count,
matched_size=matched_size,
sample=sample,
)
@router.post(
"/settings/test-exclusions/download",
operation_id="download_exclusion_report",
)
def download_exclusion_report(
request_data: TestExclusionsRequest, db_session: Session = Depends(get_db)
):
"""Generates a CSV report of files matched by exclusion patterns."""
patterns = [p.strip() for p in request_data.patterns.splitlines() if p.strip()]
if not patterns:
raise HTTPException(status_code=400, detail="No patterns provided")
spec = pathspec.PathSpec.from_lines("gitwildmatch", patterns)
all_files = (
db_session.query(models.FilesystemState)
.filter(models.FilesystemState.is_deleted.is_(False))
.all()
)
matched = []
for file_record in all_files:
if spec.match_file(file_record.file_path):
matched.append(file_record)
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["path", "size", "mtime", "sha256_hash"])
for file_record in matched:
writer.writerow(
[
file_record.file_path,
file_record.size,
file_record.mtime,
file_record.sha256_hash or "",
]
)
csv_bytes = output.getvalue().encode("utf-8")
output.close()
return StreamingResponse(
io.BytesIO(csv_bytes),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=exclusion_report.csv"},
)
File diff suppressed because one or more lines are too long
+29 -1
View File
@@ -2,7 +2,7 @@
import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen';
import type { AddDirectoryToRestoreQueueData, AddDirectoryToRestoreQueueErrors, AddDirectoryToRestoreQueueResponses, AddFileToRestoreQueueData, AddFileToRestoreQueueErrors, AddFileToRestoreQueueResponses, ArchiveBrowseData, ArchiveBrowseErrors, ArchiveBrowseResponses, ArchiveMetadataData, ArchiveMetadataErrors, ArchiveMetadataResponses, ArchiveSearchData, ArchiveSearchErrors, ArchiveSearchResponses, ArchiveTreeData, ArchiveTreeErrors, ArchiveTreeResponses, BatchAddToRestoreQueueData, BatchAddToRestoreQueueErrors, BatchAddToRestoreQueueResponses, BatchConfirmDiscrepanciesData, BatchConfirmDiscrepanciesErrors, BatchConfirmDiscrepanciesResponses, BatchDeleteDiscrepanciesData, BatchDeleteDiscrepanciesErrors, BatchDeleteDiscrepanciesResponses, BatchDismissDiscrepanciesData, BatchDismissDiscrepanciesErrors, BatchDismissDiscrepanciesResponses, BatchTrackData, BatchTrackErrors, BatchTrackResponses, BrowseDiscrepanciesData, BrowseDiscrepanciesErrors, BrowseDiscrepanciesResponses, BrowseRestoreQueueData, BrowseRestoreQueueErrors, BrowseRestoreQueueResponses, CancelJobData, CancelJobErrors, CancelJobResponses, CheckHealthData, CheckHealthResponses, ClearRestoreQueueData, ClearRestoreQueueResponses, ConfirmDiscrepancyData, ConfirmDiscrepancyErrors, ConfirmDiscrepancyResponses, CreateMediaData, CreateMediaErrors, CreateMediaResponses, DeleteDiscrepancyData, DeleteDiscrepancyErrors, DeleteDiscrepancyResponses, DeleteMediaData, DeleteMediaErrors, DeleteMediaResponses, DetectMediaData, DetectMediaResponses, DiscoverHardwareData, DiscoverHardwareResponses, DismissDiscrepancyData, DismissDiscrepancyErrors, DismissDiscrepancyResponses, ExportDatabaseData, ExportDatabaseResponses, FilesystemBrowseData, FilesystemBrowseErrors, FilesystemBrowseResponses, FilesystemSearchData, FilesystemSearchErrors, FilesystemSearchResponses, FilesystemTreeData, FilesystemTreeErrors, FilesystemTreeResponses, GetAnalyticsData, GetAnalyticsResponses, GetDashboardStatsData, GetDashboardStatsResponses, GetDiscrepancyTreeData, GetDiscrepancyTreeErrors, GetDiscrepancyTreeResponses, GetJobCountData, GetJobCountResponses, GetJobData, GetJobErrors, GetJobLogsData, GetJobLogsErrors, GetJobLogsResponses, GetJobResponses, GetJobStatsData, GetJobStatsResponses, GetRestoreManifestData, GetRestoreManifestResponses, GetRestoreQueueData, GetRestoreQueueResponses, GetRestoreQueueTreeData, GetRestoreQueueTreeErrors, GetRestoreQueueTreeResponses, GetScanStatusData, GetScanStatusResponses, GetSettingsData, GetSettingsResponses, GetTreemapData, GetTreemapResponses, IgnoreHardwareData, IgnoreHardwareErrors, IgnoreHardwareResponses, ImportDatabaseData, ImportDatabaseErrors, ImportDatabaseResponses, InitializeMediaData, InitializeMediaErrors, InitializeMediaResponses, ListBackupsData, ListBackupsResponses, ListDirectoriesData, ListDirectoriesErrors, ListDirectoriesResponses, ListDiscrepanciesData, ListDiscrepanciesResponses, ListJobsData, ListJobsErrors, ListJobsResponses, ListMediaData, ListMediaErrors, ListMediaResponses, ListProvidersData, ListProvidersResponses, RemoveFromRestoreQueueData, RemoveFromRestoreQueueErrors, RemoveFromRestoreQueueResponses, ReorderMediaData, ReorderMediaErrors, ReorderMediaResponses, ResetTestEnvironmentData, ResetTestEnvironmentResponses, RetryJobData, RetryJobErrors, RetryJobResponses, StreamJobsData, StreamJobsResponses, TestNotificationData, TestNotificationErrors, TestNotificationResponses, TriggerAutoBackupData, TriggerAutoBackupResponses, TriggerBackupData, TriggerBackupErrors, TriggerBackupResponses, TriggerIndexingData, TriggerIndexingResponses, TriggerRestoreData, TriggerRestoreErrors, TriggerRestoreResponses, TriggerScanData, TriggerScanResponses, UndoDismissDiscrepancyData, UndoDismissDiscrepancyErrors, UndoDismissDiscrepancyResponses, UpdateMediaData, UpdateMediaErrors, UpdateMediaResponses, UpdateSettingsData, UpdateSettingsErrors, UpdateSettingsResponses } from './types.gen';
import type { AddDirectoryToRestoreQueueData, AddDirectoryToRestoreQueueErrors, AddDirectoryToRestoreQueueResponses, AddFileToRestoreQueueData, AddFileToRestoreQueueErrors, AddFileToRestoreQueueResponses, ArchiveBrowseData, ArchiveBrowseErrors, ArchiveBrowseResponses, ArchiveMetadataData, ArchiveMetadataErrors, ArchiveMetadataResponses, ArchiveSearchData, ArchiveSearchErrors, ArchiveSearchResponses, ArchiveTreeData, ArchiveTreeErrors, ArchiveTreeResponses, BatchAddToRestoreQueueData, BatchAddToRestoreQueueErrors, BatchAddToRestoreQueueResponses, BatchConfirmDiscrepanciesData, BatchConfirmDiscrepanciesErrors, BatchConfirmDiscrepanciesResponses, BatchDeleteDiscrepanciesData, BatchDeleteDiscrepanciesErrors, BatchDeleteDiscrepanciesResponses, BatchDismissDiscrepanciesData, BatchDismissDiscrepanciesErrors, BatchDismissDiscrepanciesResponses, BatchTrackData, BatchTrackErrors, BatchTrackResponses, BrowseDiscrepanciesData, BrowseDiscrepanciesErrors, BrowseDiscrepanciesResponses, BrowseRestoreQueueData, BrowseRestoreQueueErrors, BrowseRestoreQueueResponses, CancelJobData, CancelJobErrors, CancelJobResponses, CheckHealthData, CheckHealthResponses, ClearRestoreQueueData, ClearRestoreQueueResponses, ConfirmDiscrepancyData, ConfirmDiscrepancyErrors, ConfirmDiscrepancyResponses, CreateMediaData, CreateMediaErrors, CreateMediaResponses, DeleteDiscrepancyData, DeleteDiscrepancyErrors, DeleteDiscrepancyResponses, DeleteMediaData, DeleteMediaErrors, DeleteMediaResponses, DetectMediaData, DetectMediaResponses, DiscoverHardwareData, DiscoverHardwareResponses, DismissDiscrepancyData, DismissDiscrepancyErrors, DismissDiscrepancyResponses, DownloadExclusionReportData, DownloadExclusionReportErrors, DownloadExclusionReportResponses, ExportDatabaseData, ExportDatabaseResponses, FilesystemBrowseData, FilesystemBrowseErrors, FilesystemBrowseResponses, FilesystemSearchData, FilesystemSearchErrors, FilesystemSearchResponses, FilesystemTreeData, FilesystemTreeErrors, FilesystemTreeResponses, GetAnalyticsData, GetAnalyticsResponses, GetDashboardStatsData, GetDashboardStatsResponses, GetDiscrepancyTreeData, GetDiscrepancyTreeErrors, GetDiscrepancyTreeResponses, GetJobCountData, GetJobCountResponses, GetJobData, GetJobErrors, GetJobLogsData, GetJobLogsErrors, GetJobLogsResponses, GetJobResponses, GetJobStatsData, GetJobStatsResponses, GetRestoreManifestData, GetRestoreManifestResponses, GetRestoreQueueData, GetRestoreQueueResponses, GetRestoreQueueTreeData, GetRestoreQueueTreeErrors, GetRestoreQueueTreeResponses, GetScanStatusData, GetScanStatusResponses, GetSettingsData, GetSettingsResponses, GetTreemapData, GetTreemapResponses, IgnoreHardwareData, IgnoreHardwareErrors, IgnoreHardwareResponses, ImportDatabaseData, ImportDatabaseErrors, ImportDatabaseResponses, InitializeMediaData, InitializeMediaErrors, InitializeMediaResponses, ListBackupsData, ListBackupsResponses, ListDirectoriesData, ListDirectoriesErrors, ListDirectoriesResponses, ListDiscrepanciesData, ListDiscrepanciesResponses, ListJobsData, ListJobsErrors, ListJobsResponses, ListMediaData, ListMediaErrors, ListMediaResponses, ListProvidersData, ListProvidersResponses, RemoveFromRestoreQueueData, RemoveFromRestoreQueueErrors, RemoveFromRestoreQueueResponses, ReorderMediaData, ReorderMediaErrors, ReorderMediaResponses, ResetTestEnvironmentData, ResetTestEnvironmentResponses, RetryJobData, RetryJobErrors, RetryJobResponses, StreamJobsData, StreamJobsResponses, TestExclusionsData, TestExclusionsErrors, TestExclusionsResponses, TestNotificationData, TestNotificationErrors, TestNotificationResponses, TriggerAutoBackupData, TriggerAutoBackupResponses, TriggerBackupData, TriggerBackupErrors, TriggerBackupResponses, TriggerIndexingData, TriggerIndexingResponses, TriggerRestoreData, TriggerRestoreErrors, TriggerRestoreResponses, TriggerScanData, TriggerScanResponses, UndoDismissDiscrepancyData, UndoDismissDiscrepancyErrors, UndoDismissDiscrepancyResponses, UpdateMediaData, UpdateMediaErrors, UpdateMediaResponses, UpdateSettingsData, UpdateSettingsErrors, UpdateSettingsResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
/**
@@ -158,6 +158,34 @@ export const updateSettings = <ThrowOnError extends boolean = false>(options: Op
}
});
/**
* Test Exclusions
*
* Tests exclusion patterns against the current filesystem index.
*/
export const testExclusions = <ThrowOnError extends boolean = false>(options: Options<TestExclusionsData, ThrowOnError>) => (options.client ?? client).post<TestExclusionsResponses, TestExclusionsErrors, ThrowOnError>({
url: '/system/settings/test-exclusions',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Download Exclusion Report
*
* Generates a CSV report of files matched by exclusion patterns.
*/
export const downloadExclusionReport = <ThrowOnError extends boolean = false>(options: Options<DownloadExclusionReportData, ThrowOnError>) => (options.client ?? client).post<DownloadExclusionReportResponses, DownloadExclusionReportErrors, ThrowOnError>({
url: '/system/settings/test-exclusions/download',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Test Notification
*
+64 -4
View File
@@ -662,6 +662,20 @@ export type StorageProviderSchema = {
};
};
/**
* TestExclusionsRequest
*/
export type TestExclusionsRequest = {
/**
* Patterns
*/
patterns: string;
/**
* Limit
*/
limit?: number;
};
/**
* TestNotificationRequest
*/
@@ -751,7 +765,7 @@ export type AppApiBackupsJobSchema = {
/**
* JobSchema
*/
export type AppApiSystemJobSchema = {
export type AppApiCommonJobSchema = {
/**
* Id
*/
@@ -855,7 +869,7 @@ export type ListJobsResponses = {
*
* Successful Response
*/
200: Array<AppApiSystemJobSchema>;
200: Array<AppApiCommonJobSchema>;
};
export type ListJobsResponse = ListJobsResponses[keyof ListJobsResponses];
@@ -913,7 +927,7 @@ export type GetJobResponses = {
/**
* Successful Response
*/
200: AppApiSystemJobSchema;
200: AppApiCommonJobSchema;
};
export type GetJobResponse = GetJobResponses[keyof GetJobResponses];
@@ -1171,7 +1185,7 @@ export type GetSettingsResponses = {
* Successful Response
*/
200: {
[key: string]: string;
[key: string]: unknown;
};
};
@@ -1200,6 +1214,52 @@ export type UpdateSettingsResponses = {
200: unknown;
};
export type TestExclusionsData = {
body: TestExclusionsRequest;
path?: never;
query?: never;
url: '/system/settings/test-exclusions';
};
export type TestExclusionsErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type TestExclusionsError = TestExclusionsErrors[keyof TestExclusionsErrors];
export type TestExclusionsResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type DownloadExclusionReportData = {
body: TestExclusionsRequest;
path?: never;
query?: never;
url: '/system/settings/test-exclusions/download';
};
export type DownloadExclusionReportErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type DownloadExclusionReportError = DownloadExclusionReportErrors[keyof DownloadExclusionReportErrors];
export type DownloadExclusionReportResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type TestNotificationData = {
body: TestNotificationRequest;
path?: never;
@@ -4,7 +4,7 @@
import { Button } from './ui/button';
import { Card } from './ui/card';
import Dialog from './ui/Dialog.svelte';
import { getJob, getJobLogs, type AppApiSystemJobSchema } from '$lib/api';
import { getJob, getJobLogs, type AppApiCommonJobSchema } from '$lib/api';
import { cn, formatLocalTime, formatLocalDateTime, parseUTCDate } from '$lib/utils';
import { POLL_FAST } from '$lib/config';
@@ -13,7 +13,7 @@
onClear: () => void;
}>();
let job = $state<AppApiSystemJobSchema | null>(null);
let job = $state<AppApiCommonJobSchema | null>(null);
let logs = $state<{ id: number; message: string; timestamp: string }[]>([]);
let loading = $state(true);
let pollInterval: any;
+49 -2
View File
@@ -23,14 +23,17 @@
import SectionHeader from '$lib/components/ui/SectionHeader.svelte';
import StatCard from '$lib/components/ui/StatCard.svelte';
import ProgressBar from '$lib/components/ui/ProgressBar.svelte';
import { getDashboardStats, triggerScan, triggerIndexing, type DashboardStatsSchema } from '$lib/api';
import { getDashboardStats, getScanStatus, triggerScan, triggerIndexing, type DashboardStatsSchema, type ScanStatusSchema } from '$lib/api';
import { cn, formatLocalDate, formatLocalTime, formatSize } from '$lib/utils';
import { toast } from 'svelte-sonner';
import { POLL_FAST } from '$lib/config';
let stats = $state<DashboardStatsSchema | null>(null);
let loading = $state(true);
let scanning = $state(false);
let indexing = $state(false);
let scanStatus = $state<ScanStatusSchema | null>(null);
let pollInterval: any;
async function loadStats() {
loading = true;
@@ -46,6 +49,23 @@
}
}
async function checkScanStatus() {
try {
const response = await getScanStatus();
if (response.data) {
const wasRunning = scanStatus?.is_running;
scanStatus = response.data;
// Auto-refresh stats when scan completes
if (wasRunning && !scanStatus.is_running) {
await loadStats();
}
}
} catch (error) {
console.error("Failed to get scan status:", error);
}
}
async function startIndexing() {
indexing = true;
try {
@@ -70,7 +90,14 @@
}
}
onMount(loadStats);
onMount(() => {
loadStats();
checkScanStatus();
pollInterval = setInterval(checkScanStatus, POLL_FAST);
return () => {
if (pollInterval) clearInterval(pollInterval);
};
});
</script>
@@ -105,6 +132,26 @@
{/snippet}
</PageHeader>
{#if scanStatus?.is_running}
<div class="bg-blue-500/5 border border-blue-500/20 rounded-xl px-5 py-3 flex items-center gap-4">
<RotateCw size={16} class="text-blue-500 animate-spin" />
<div class="flex-1">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-text-primary">Scan in progress</span>
<span class="text-xs text-text-secondary mono">
{scanStatus.files_processed.toLocaleString()} / {scanStatus.total_files_found.toLocaleString()} files
</span>
</div>
<div class="w-full bg-bg-primary h-1.5 rounded-full overflow-hidden mt-2">
<div
class="bg-blue-500 h-full transition-all duration-1000"
style="width: {scanStatus.total_files_found ? Math.round((scanStatus.files_processed / scanStatus.total_files_found) * 100) : 0}%"
></div>
</div>
</div>
</div>
{/if}
<div class="space-y-6">
{#if loading && !stats}
<div class="h-80 bg-bg-secondary animate-pulse rounded-xl border border-border-color/50"></div>
+3 -3
View File
@@ -29,12 +29,12 @@
getJobStats,
cancelJob as cancelJobApi,
retryJob as retryJobApi,
type AppApiSystemJobSchema
type AppApiCommonJobSchema
} from '$lib/api';
import { cn, formatLocalTime, parseUTCDate } from '$lib/utils';
import { toast } from 'svelte-sonner';
let jobs = $state<AppApiSystemJobSchema[]>([]);
let jobs = $state<AppApiCommonJobSchema[]>([]);
let totalJobs = $state(0);
let loading = $state(true);
let loadingMore = $state(false);
@@ -236,7 +236,7 @@
}
const groupedHistorical = $derived(() => {
const groups: Record<string, AppApiSystemJobSchema[]> = {
const groups: Record<string, AppApiCommonJobSchema[]> = {
'Today': [],
'Yesterday': [],
'This week': [],
+3 -2
View File
@@ -50,8 +50,9 @@
loading = true;
try {
const settingsRes = await getSettings();
if (settingsRes.data?.restore_destinations) {
restoreDests = JSON.parse(settingsRes.data.restore_destinations);
const settingsData = settingsRes.data as Record<string, string>;
if (settingsData?.restore_destinations) {
restoreDests = JSON.parse(settingsData.restore_destinations);
if (restoreDests.length > 0 && !selectedDest) selectedDest = restoreDests[0];
}
+155 -4
View File
@@ -14,6 +14,7 @@
RotateCw,
ArrowRight,
ShieldAlert,
ShieldCheck,
FolderSearch,
Download,
Upload,
@@ -30,10 +31,12 @@
updateSettings,
testNotification,
exportDatabase,
importDatabase
importDatabase,
testExclusions,
downloadExclusionReport
} from "$lib/api";
import { toast } from "svelte-sonner";
import { cn } from "$lib/utils";
import { cn, formatSize } from "$lib/utils";
import { beforeNavigate } from '$app/navigation';
import type { Navigation } from '@sveltejs/kit';
@@ -80,6 +83,22 @@
let saving = $state(false);
let exporting = $state(false);
let importing = $state(false);
let testingExclusions = $state(false);
let exclusionResults = $state<{
total_files: number;
total_size: number;
matched_count: number;
matched_size: number;
sample: Array<{
name: string;
path: string;
type: string;
size: number;
mtime: number;
ignored: boolean;
sha256_hash: string | null;
}>;
} | null>(null);
// Path Picker state
let pickerType = $state<"root" | "dest" | null>(null);
@@ -117,7 +136,7 @@
try {
const response = await getSettings();
if (response.data) {
const data = response.data;
const data = response.data as Record<string, string>;
if (data.source_roots) sourceRoots = JSON.parse(data.source_roots);
if (data.restore_destinations) restoreDestinations = JSON.parse(data.restore_destinations);
if (data.tape_drives) tapeDrives = JSON.parse(data.tape_drives);
@@ -207,6 +226,44 @@
}
}
async function handleTestExclusions() {
testingExclusions = true;
exclusionResults = null;
try {
const response = await testExclusions({
body: { patterns: globalExclusions, limit: 10 }
});
if (response.data) {
exclusionResults = response.data as any;
}
} catch (error: any) {
toast.error(error.body?.detail || "Failed to test exclusions");
} finally {
testingExclusions = false;
}
}
async function handleDownloadExclusionReport() {
try {
const response = await downloadExclusionReport({
body: { patterns: globalExclusions, limit: 10 }
});
if (response.data) {
const blob = await (response.data as any).blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `exclusion_report_${new Date().toISOString().split('T')[0]}.csv`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
toast.success("Exclusion report downloaded");
}
} catch (error) {
toast.error("Download failed");
}
}
function addSource() { sourceRoots = [...sourceRoots, ""]; }
function removeSource(i: number) { sourceRoots = sourceRoots.filter((_, idx) => idx !== i); }
function addDest() { restoreDestinations = [...restoreDestinations, ""]; }
@@ -315,7 +372,7 @@
</div>
{:else if activeTab === 'exclusions'}
<div class="animate-in slide-in-from-bottom-4 duration-500">
<div class="animate-in slide-in-from-bottom-4 duration-500 space-y-6">
<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">
@@ -340,6 +397,100 @@
</div>
</div>
<div class="flex gap-3">
<Button
variant="outline"
class="h-10 px-4 text-sm font-medium border-orange-500/30 text-orange-500 hover:bg-orange-500/5"
onclick={handleTestExclusions}
disabled={testingExclusions || !globalExclusions.trim()}
>
{#if testingExclusions}
<RotateCw size={14} class="mr-2 animate-spin" /> Testing...
{:else}
<FolderSearch size={14} class="mr-2" /> Test exception list
{/if}
</Button>
{#if exclusionResults && exclusionResults.matched_count > 0}
<Button
variant="outline"
class="h-10 px-4 text-sm font-medium border-border-color hover:border-blue-500/40 hover:bg-blue-500/5"
onclick={handleDownloadExclusionReport}
>
<Download size={14} class="mr-2" /> Download CSV report
</Button>
{/if}
</div>
{#if exclusionResults}
<div class="space-y-4">
<div class="grid grid-cols-3 gap-4">
<div class="bg-bg-primary/50 rounded-xl p-4 border border-border-color/60">
<span class="text-[10px] font-medium text-text-secondary uppercase tracking-wide">Indexed files</span>
<p class="text-2xl font-bold text-text-primary mono mt-1">{exclusionResults.total_files.toLocaleString()}</p>
<p class="text-xs text-text-secondary mono mt-1">{formatSize(exclusionResults.total_size)}</p>
</div>
<div class="bg-orange-500/5 rounded-xl p-4 border border-orange-500/20">
<span class="text-[10px] font-medium text-orange-500 uppercase tracking-wide">Would be excluded</span>
<p class="text-2xl font-bold text-orange-500 mono mt-1">{exclusionResults.matched_count.toLocaleString()}</p>
<p class="text-xs text-orange-500 mono mt-1">{formatSize(exclusionResults.matched_size)}</p>
</div>
<div class="bg-bg-primary/50 rounded-xl p-4 border border-border-color/60">
<span class="text-[10px] font-medium text-text-secondary uppercase tracking-wide">Match rate</span>
<p class="text-2xl font-bold text-text-primary mono mt-1">
{exclusionResults.total_files > 0
? Math.round((exclusionResults.matched_count / exclusionResults.total_files) * 100)
: 0}%
</p>
<p class="text-xs text-text-secondary mono mt-1">
{exclusionResults.total_size > 0
? Math.round((exclusionResults.matched_size / exclusionResults.total_size) * 100)
: 0}% by size
</p>
</div>
</div>
{#if exclusionResults.sample.length > 0}
<div class="bg-bg-primary/30 rounded-xl border border-border-color/60 overflow-hidden">
<div class="px-4 py-3 border-b border-border-color/60 bg-bg-primary/50">
<span class="text-xs font-semibold text-text-primary">Sample of matched files ({exclusionResults.sample.length} shown)</span>
</div>
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead>
<tr class="border-b border-border-color/40">
<th class="px-4 py-2 text-left text-text-secondary font-medium">Path</th>
<th class="px-4 py-2 text-right text-text-secondary font-medium w-24">Size</th>
<th class="px-4 py-2 text-right text-text-secondary font-medium w-20">Type</th>
</tr>
</thead>
<tbody>
{#each exclusionResults.sample as file}
<tr class="border-b border-border-color/20 last:border-0">
<td class="px-4 py-2 text-text-primary font-mono truncate max-w-xs">{file.path}</td>
<td class="px-4 py-2 text-right text-text-secondary mono">{file.size?.toLocaleString() || '—'}</td>
<td class="px-4 py-2 text-right">
<span class="inline-flex px-2 py-0.5 rounded text-[10px] font-medium {file.type === 'directory' ? 'bg-blue-500/10 text-blue-500' : 'bg-text-secondary/10 text-text-secondary'}">
{file.type}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{:else if exclusionResults.matched_count === 0}
<div class="p-4 bg-green-500/5 border border-dashed border-green-500/30 rounded-xl flex gap-4 items-start">
<ShieldCheck size={20} class="text-green-500 shrink-0 mt-0.5" />
<div>
<span class="text-xs font-bold text-green-500 uppercase tracking-wider">No matches</span>
<p class="text-xs text-text-secondary leading-relaxed font-medium">None of the current indexed files match these exclusion patterns.</p>
</div>
</div>
{/if}
</div>
{/if}
<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">