exclusion policy tools
This commit is contained in:
@@ -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
@@ -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
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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': [],
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user