mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
feat: run history for rename jobs
This commit is contained in:
@@ -28,6 +28,7 @@ import { migration as migration023 } from './migrations/023_create_pattern_match
|
||||
import { migration as migration024 } from './migrations/024_create_arr_rename_settings.ts';
|
||||
import { migration as migration025 } from './migrations/025_add_rename_notification_mode.ts';
|
||||
import { migration as migration026 } from './migrations/026_create_upgrade_runs.ts';
|
||||
import { migration as migration027 } from './migrations/027_create_rename_runs.ts';
|
||||
|
||||
export interface Migration {
|
||||
version: number;
|
||||
@@ -268,7 +269,8 @@ export function loadMigrations(): Migration[] {
|
||||
migration023,
|
||||
migration024,
|
||||
migration025,
|
||||
migration026
|
||||
migration026,
|
||||
migration027
|
||||
];
|
||||
|
||||
// Sort by version number
|
||||
|
||||
91
src/lib/server/db/migrations/027_create_rename_runs.ts
Normal file
91
src/lib/server/db/migrations/027_create_rename_runs.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { Migration } from '../migrations.ts';
|
||||
|
||||
/**
|
||||
* Migration 027: Create rename_runs table
|
||||
*
|
||||
* Creates the table for storing rename run history.
|
||||
* Similar to upgrade_runs but with rename-specific fields.
|
||||
*
|
||||
* Fields:
|
||||
* - id: UUID primary key
|
||||
* - instance_id: Foreign key to arr_instances
|
||||
* - started_at, completed_at: Timestamps for the run
|
||||
* - status: success, partial, failed, skipped
|
||||
* - dry_run: Whether this was a dry run
|
||||
* - manual: Whether this was manually triggered
|
||||
*
|
||||
* Config snapshot:
|
||||
* - rename_folders, ignore_tag
|
||||
*
|
||||
* Stats (flat for easy querying):
|
||||
* - library_total, after_ignore_tag, skipped_by_tag
|
||||
* - files_needing_rename, files_renamed, folders_renamed
|
||||
* - commands_triggered, commands_completed, commands_failed
|
||||
*
|
||||
* Complex data as JSON:
|
||||
* - items: Array of renamed items with file paths
|
||||
* - errors: Array of error strings
|
||||
*/
|
||||
|
||||
export const migration: Migration = {
|
||||
version: 27,
|
||||
name: 'Create rename_runs table',
|
||||
|
||||
up: `
|
||||
CREATE TABLE rename_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
-- Relationship
|
||||
instance_id INTEGER NOT NULL,
|
||||
|
||||
-- Timing
|
||||
started_at TEXT NOT NULL,
|
||||
completed_at TEXT NOT NULL,
|
||||
|
||||
-- Status
|
||||
status TEXT NOT NULL CHECK (status IN ('success', 'partial', 'failed', 'skipped')),
|
||||
dry_run INTEGER NOT NULL DEFAULT 1,
|
||||
manual INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
-- Config snapshot
|
||||
rename_folders INTEGER NOT NULL DEFAULT 0,
|
||||
ignore_tag TEXT,
|
||||
|
||||
-- Library stats
|
||||
library_total INTEGER NOT NULL,
|
||||
library_fetch_ms INTEGER NOT NULL,
|
||||
|
||||
-- Filtering stats
|
||||
after_ignore_tag INTEGER NOT NULL,
|
||||
skipped_by_tag INTEGER NOT NULL,
|
||||
|
||||
-- Results stats
|
||||
files_needing_rename INTEGER NOT NULL,
|
||||
files_renamed INTEGER NOT NULL,
|
||||
folders_renamed INTEGER NOT NULL,
|
||||
commands_triggered INTEGER NOT NULL,
|
||||
commands_completed INTEGER NOT NULL,
|
||||
commands_failed INTEGER NOT NULL,
|
||||
|
||||
-- Complex data as JSON
|
||||
items TEXT NOT NULL DEFAULT '[]',
|
||||
errors TEXT NOT NULL DEFAULT '[]',
|
||||
|
||||
-- Metadata
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
FOREIGN KEY (instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_rename_runs_instance ON rename_runs(instance_id);
|
||||
CREATE INDEX idx_rename_runs_started_at ON rename_runs(started_at DESC);
|
||||
CREATE INDEX idx_rename_runs_status ON rename_runs(status);
|
||||
`,
|
||||
|
||||
down: `
|
||||
DROP INDEX IF EXISTS idx_rename_runs_status;
|
||||
DROP INDEX IF EXISTS idx_rename_runs_started_at;
|
||||
DROP INDEX IF EXISTS idx_rename_runs_instance;
|
||||
DROP TABLE IF EXISTS rename_runs;
|
||||
`
|
||||
};
|
||||
251
src/lib/server/db/queries/renameRuns.ts
Normal file
251
src/lib/server/db/queries/renameRuns.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { db } from '../db.ts';
|
||||
import type { RenameJobLog } from '$lib/server/rename/types.ts';
|
||||
|
||||
/**
|
||||
* Database row type for rename_runs table
|
||||
*/
|
||||
interface RenameRunRow {
|
||||
id: string;
|
||||
instance_id: number;
|
||||
started_at: string;
|
||||
completed_at: string;
|
||||
status: string;
|
||||
dry_run: number;
|
||||
manual: number;
|
||||
rename_folders: number;
|
||||
ignore_tag: string | null;
|
||||
library_total: number;
|
||||
library_fetch_ms: number;
|
||||
after_ignore_tag: number;
|
||||
skipped_by_tag: number;
|
||||
files_needing_rename: number;
|
||||
files_renamed: number;
|
||||
folders_renamed: number;
|
||||
commands_triggered: number;
|
||||
commands_completed: number;
|
||||
commands_failed: number;
|
||||
items: string;
|
||||
errors: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renamed item stored in the database
|
||||
*/
|
||||
interface RenamedItemRow {
|
||||
id: number;
|
||||
title: string;
|
||||
files: { existingPath: string; newPath: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert database row to RenameJobLog format
|
||||
*/
|
||||
function rowToLog(row: RenameRunRow): RenameJobLog {
|
||||
return {
|
||||
id: row.id,
|
||||
instanceId: row.instance_id,
|
||||
instanceName: '', // Not stored, can be joined if needed
|
||||
instanceType: 'radarr', // Not stored, default
|
||||
startedAt: row.started_at,
|
||||
completedAt: row.completed_at,
|
||||
status: row.status as 'success' | 'partial' | 'failed' | 'skipped',
|
||||
|
||||
config: {
|
||||
dryRun: row.dry_run === 1,
|
||||
renameFolders: row.rename_folders === 1,
|
||||
ignoreTag: row.ignore_tag,
|
||||
manual: row.manual === 1
|
||||
},
|
||||
|
||||
library: {
|
||||
totalItems: row.library_total,
|
||||
fetchDurationMs: row.library_fetch_ms
|
||||
},
|
||||
|
||||
filtering: {
|
||||
afterIgnoreTag: row.after_ignore_tag,
|
||||
skippedByTag: row.skipped_by_tag
|
||||
},
|
||||
|
||||
results: {
|
||||
filesNeedingRename: row.files_needing_rename,
|
||||
filesRenamed: row.files_renamed,
|
||||
foldersRenamed: row.folders_renamed,
|
||||
commandsTriggered: row.commands_triggered,
|
||||
commandsCompleted: row.commands_completed,
|
||||
commandsFailed: row.commands_failed,
|
||||
errors: JSON.parse(row.errors) as string[]
|
||||
},
|
||||
|
||||
renamedItems: JSON.parse(row.items) as RenamedItemRow[]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* All queries for rename_runs table
|
||||
*/
|
||||
export const renameRunsQueries = {
|
||||
/**
|
||||
* Insert a new rename run
|
||||
*/
|
||||
insert(log: RenameJobLog): void {
|
||||
db.execute(
|
||||
`INSERT INTO rename_runs (
|
||||
id, instance_id, started_at, completed_at, status, dry_run, manual,
|
||||
rename_folders, ignore_tag,
|
||||
library_total, library_fetch_ms,
|
||||
after_ignore_tag, skipped_by_tag,
|
||||
files_needing_rename, files_renamed, folders_renamed,
|
||||
commands_triggered, commands_completed, commands_failed,
|
||||
items, errors
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
log.id,
|
||||
log.instanceId,
|
||||
log.startedAt,
|
||||
log.completedAt,
|
||||
log.status,
|
||||
log.config.dryRun ? 1 : 0,
|
||||
log.config.manual ? 1 : 0,
|
||||
log.config.renameFolders ? 1 : 0,
|
||||
log.config.ignoreTag,
|
||||
log.library.totalItems,
|
||||
log.library.fetchDurationMs,
|
||||
log.filtering.afterIgnoreTag,
|
||||
log.filtering.skippedByTag,
|
||||
log.results.filesNeedingRename,
|
||||
log.results.filesRenamed,
|
||||
log.results.foldersRenamed,
|
||||
log.results.commandsTriggered,
|
||||
log.results.commandsCompleted,
|
||||
log.results.commandsFailed,
|
||||
JSON.stringify(log.renamedItems),
|
||||
JSON.stringify(log.results.errors)
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all rename runs for an instance, newest first
|
||||
*/
|
||||
getByInstanceId(instanceId: number, limit = 100): RenameJobLog[] {
|
||||
const rows = db.query<RenameRunRow>(
|
||||
`SELECT * FROM rename_runs
|
||||
WHERE instance_id = ?
|
||||
ORDER BY started_at DESC
|
||||
LIMIT ?`,
|
||||
instanceId,
|
||||
limit
|
||||
);
|
||||
return rows.map(rowToLog);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a single rename run by ID
|
||||
*/
|
||||
getById(id: string): RenameJobLog | undefined {
|
||||
const row = db.queryFirst<RenameRunRow>(
|
||||
'SELECT * FROM rename_runs WHERE id = ?',
|
||||
id
|
||||
);
|
||||
return row ? rowToLog(row) : undefined;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get recent runs across all instances
|
||||
*/
|
||||
getRecent(limit = 50): RenameJobLog[] {
|
||||
const rows = db.query<RenameRunRow>(
|
||||
`SELECT * FROM rename_runs
|
||||
ORDER BY started_at DESC
|
||||
LIMIT ?`,
|
||||
limit
|
||||
);
|
||||
return rows.map(rowToLog);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get runs by status for an instance
|
||||
*/
|
||||
getByStatus(instanceId: number, status: string, limit = 50): RenameJobLog[] {
|
||||
const rows = db.query<RenameRunRow>(
|
||||
`SELECT * FROM rename_runs
|
||||
WHERE instance_id = ? AND status = ?
|
||||
ORDER BY started_at DESC
|
||||
LIMIT ?`,
|
||||
instanceId,
|
||||
status,
|
||||
limit
|
||||
);
|
||||
return rows.map(rowToLog);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete old runs (for retention policy)
|
||||
* Returns number of rows deleted
|
||||
*/
|
||||
deleteOlderThan(days: number): number {
|
||||
return db.execute(
|
||||
`DELETE FROM rename_runs
|
||||
WHERE datetime(started_at) < datetime('now', '-' || ? || ' days')`,
|
||||
days
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete all runs for an instance
|
||||
*/
|
||||
deleteByInstanceId(instanceId: number): number {
|
||||
return db.execute(
|
||||
'DELETE FROM rename_runs WHERE instance_id = ?',
|
||||
instanceId
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get count of runs for an instance
|
||||
*/
|
||||
getCount(instanceId: number): number {
|
||||
const result = db.queryFirst<{ count: number }>(
|
||||
'SELECT COUNT(*) as count FROM rename_runs WHERE instance_id = ?',
|
||||
instanceId
|
||||
);
|
||||
return result?.count ?? 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get stats summary for an instance
|
||||
*/
|
||||
getStats(instanceId: number): {
|
||||
totalRuns: number;
|
||||
successfulRuns: number;
|
||||
failedRuns: number;
|
||||
totalFilesRenamed: number;
|
||||
totalFoldersRenamed: number;
|
||||
} {
|
||||
const result = db.queryFirst<{
|
||||
total_runs: number;
|
||||
successful_runs: number;
|
||||
failed_runs: number;
|
||||
total_files_renamed: number;
|
||||
total_folders_renamed: number;
|
||||
}>(
|
||||
`SELECT
|
||||
COUNT(*) as total_runs,
|
||||
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as successful_runs,
|
||||
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed_runs,
|
||||
SUM(files_renamed) as total_files_renamed,
|
||||
SUM(folders_renamed) as total_folders_renamed
|
||||
FROM rename_runs
|
||||
WHERE instance_id = ?`,
|
||||
instanceId
|
||||
);
|
||||
|
||||
return {
|
||||
totalRuns: result?.total_runs ?? 0,
|
||||
successfulRuns: result?.successful_runs ?? 0,
|
||||
failedRuns: result?.failed_runs ?? 0,
|
||||
totalFilesRenamed: result?.total_files_renamed ?? 0,
|
||||
totalFoldersRenamed: result?.total_folders_renamed ?? 0
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -555,3 +555,59 @@ CREATE TABLE upgrade_runs (
|
||||
CREATE INDEX idx_upgrade_runs_instance ON upgrade_runs(instance_id);
|
||||
CREATE INDEX idx_upgrade_runs_started_at ON upgrade_runs(started_at DESC);
|
||||
CREATE INDEX idx_upgrade_runs_status ON upgrade_runs(status);
|
||||
|
||||
-- ==============================================================================
|
||||
-- TABLE: rename_runs
|
||||
-- Purpose: Store rename run history for each arr instance
|
||||
-- Migration: 027_create_rename_runs.ts
|
||||
-- ==============================================================================
|
||||
|
||||
CREATE TABLE rename_runs (
|
||||
id TEXT PRIMARY KEY, -- UUID
|
||||
|
||||
-- Relationship
|
||||
instance_id INTEGER NOT NULL, -- Foreign key to arr_instances
|
||||
|
||||
-- Timing
|
||||
started_at TEXT NOT NULL, -- ISO timestamp when run started
|
||||
completed_at TEXT NOT NULL, -- ISO timestamp when run completed
|
||||
|
||||
-- Status
|
||||
status TEXT NOT NULL CHECK (status IN ('success', 'partial', 'failed', 'skipped')),
|
||||
dry_run INTEGER NOT NULL DEFAULT 1, -- 1=dry run, 0=live
|
||||
manual INTEGER NOT NULL DEFAULT 0, -- 1=manually triggered, 0=scheduled
|
||||
|
||||
-- Config snapshot
|
||||
rename_folders INTEGER NOT NULL DEFAULT 0, -- 1=rename folders too
|
||||
ignore_tag TEXT, -- Tag name to skip
|
||||
|
||||
-- Library stats
|
||||
library_total INTEGER NOT NULL, -- Total items in library
|
||||
library_fetch_ms INTEGER NOT NULL, -- Time to fetch library in ms
|
||||
|
||||
-- Filtering stats
|
||||
after_ignore_tag INTEGER NOT NULL, -- Items after ignore tag filter
|
||||
skipped_by_tag INTEGER NOT NULL, -- Items skipped due to tag
|
||||
|
||||
-- Results stats
|
||||
files_needing_rename INTEGER NOT NULL, -- Files that need renaming
|
||||
files_renamed INTEGER NOT NULL, -- Files actually renamed
|
||||
folders_renamed INTEGER NOT NULL, -- Folders renamed
|
||||
commands_triggered INTEGER NOT NULL, -- Rename commands triggered
|
||||
commands_completed INTEGER NOT NULL, -- Commands completed successfully
|
||||
commands_failed INTEGER NOT NULL, -- Commands that failed
|
||||
|
||||
-- Complex data as JSON
|
||||
items TEXT NOT NULL DEFAULT '[]', -- JSON array of renamed items
|
||||
errors TEXT NOT NULL DEFAULT '[]', -- JSON array of error strings
|
||||
|
||||
-- Metadata
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
FOREIGN KEY (instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Rename runs indexes (Migration: 027_create_rename_runs.ts)
|
||||
CREATE INDEX idx_rename_runs_instance ON rename_runs(instance_id);
|
||||
CREATE INDEX idx_rename_runs_started_at ON rename_runs(started_at DESC);
|
||||
CREATE INDEX idx_rename_runs_status ON rename_runs(status);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* Structured logging for rename jobs
|
||||
* Uses the shared logger with source 'RenameJob'
|
||||
* Stores run history in the database
|
||||
*/
|
||||
|
||||
import { logger } from '$logger/logger.ts';
|
||||
import { renameRunsQueries } from '$db/queries/renameRuns.ts';
|
||||
import type { RenameJobLog } from './types.ts';
|
||||
|
||||
const SOURCE = 'RenameJob';
|
||||
@@ -41,6 +43,16 @@ export async function logRenameRun(log: RenameJobLog): Promise<void> {
|
||||
items
|
||||
}
|
||||
});
|
||||
|
||||
// Save full structured data to database
|
||||
try {
|
||||
renameRunsQueries.insert(log);
|
||||
} catch (err) {
|
||||
await logger.error(`Failed to save rename run to database: ${err}`, {
|
||||
source: SOURCE,
|
||||
meta: { runId: log.id, error: err }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@ import { error, fail } from '@sveltejs/kit';
|
||||
import type { Actions, ServerLoad } from '@sveltejs/kit';
|
||||
import { arrInstancesQueries } from '$db/queries/arrInstances.ts';
|
||||
import { arrRenameSettingsQueries } from '$db/queries/arrRenameSettings.ts';
|
||||
import { renameRunsQueries } from '$db/queries/renameRuns.ts';
|
||||
import { logger } from '$logger/logger.ts';
|
||||
import { processRenameConfig } from '$lib/server/rename/processor.ts';
|
||||
|
||||
@@ -19,10 +20,12 @@ export const load: ServerLoad = ({ params }) => {
|
||||
}
|
||||
|
||||
const settings = arrRenameSettingsQueries.getByInstanceId(id);
|
||||
const renameRuns = renameRunsQueries.getByInstanceId(id);
|
||||
|
||||
return {
|
||||
instance,
|
||||
settings: settings ?? null
|
||||
settings: settings ?? null,
|
||||
renameRuns
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { alertStore } from '$lib/client/alerts/store';
|
||||
import { isDirty, initEdit, initCreate, update, clear } from '$lib/client/stores/dirty';
|
||||
import { Info, Save, Play, Settings } from 'lucide-svelte';
|
||||
import { Info, Save, Play, Settings, History } from 'lucide-svelte';
|
||||
import RenameSettings from './components/RenameSettings.svelte';
|
||||
import RenameRunHistory from './components/RenameRunHistory.svelte';
|
||||
import RenameInfoModal from './components/RenameInfoModal.svelte';
|
||||
import DirtyModal from '$lib/client/ui/modal/DirtyModal.svelte';
|
||||
import StickyCard from '$ui/card/StickyCard.svelte';
|
||||
@@ -136,6 +137,14 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="mt-6">
|
||||
<h2 class="mb-3 flex items-center gap-2 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
<History size={18} class="text-neutral-500 dark:text-neutral-400" />
|
||||
Run History
|
||||
</h2>
|
||||
<RenameRunHistory runs={data.renameRuns} />
|
||||
</section>
|
||||
|
||||
<!-- Hidden forms -->
|
||||
<form
|
||||
id="save-form"
|
||||
|
||||
374
src/routes/arr/[id]/rename/components/RenameRunHistory.svelte
Normal file
374
src/routes/arr/[id]/rename/components/RenameRunHistory.svelte
Normal file
@@ -0,0 +1,374 @@
|
||||
<script lang="ts">
|
||||
import { AlertTriangle, X, FileText, Calendar, CircleDot, Check } from 'lucide-svelte';
|
||||
import type { RenameJobLog } from '$lib/server/rename/types.ts';
|
||||
import { createSearchStore } from '$lib/client/stores/search';
|
||||
import ActionsBar from '$ui/actions/ActionsBar.svelte';
|
||||
import SearchAction from '$ui/actions/SearchAction.svelte';
|
||||
import ActionButton from '$ui/actions/ActionButton.svelte';
|
||||
import Dropdown from '$ui/dropdown/Dropdown.svelte';
|
||||
import DropdownItem from '$ui/dropdown/DropdownItem.svelte';
|
||||
import ExpandableTable from '$ui/table/ExpandableTable.svelte';
|
||||
import Badge from '$ui/badge/Badge.svelte';
|
||||
import type { Column } from '$ui/table/types';
|
||||
|
||||
const searchStore = createSearchStore();
|
||||
const debouncedQuery = searchStore.debouncedQuery;
|
||||
|
||||
export let runs: RenameJobLog[] = [];
|
||||
|
||||
// Filter state
|
||||
let dateFilter: 'all' | 'today' | 'yesterday' | 'week' | 'month' = 'all';
|
||||
let statusFilter: 'all' | 'success' | 'partial' | 'failed' | 'skipped' = 'all';
|
||||
|
||||
// Filter runs based on all criteria
|
||||
$: filteredRuns = filterRuns(runs, $debouncedQuery, dateFilter, statusFilter);
|
||||
|
||||
function filterRuns(
|
||||
items: RenameJobLog[],
|
||||
query: string,
|
||||
date: typeof dateFilter,
|
||||
status: typeof statusFilter
|
||||
): RenameJobLog[] {
|
||||
let result = items;
|
||||
|
||||
// Text search (search in renamed item titles)
|
||||
if (query) {
|
||||
const queryLower = query.toLowerCase();
|
||||
result = result.filter(
|
||||
(item) =>
|
||||
item.renamedItems.some((r) => r.title.toLowerCase().includes(queryLower)) ||
|
||||
item.status.toLowerCase().includes(queryLower)
|
||||
);
|
||||
}
|
||||
|
||||
// Date filter
|
||||
if (date !== 'all') {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const weekAgo = new Date(today);
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
const monthAgo = new Date(today);
|
||||
monthAgo.setDate(monthAgo.getDate() - 30);
|
||||
|
||||
result = result.filter((item) => {
|
||||
const itemDate = new Date(item.startedAt);
|
||||
switch (date) {
|
||||
case 'today':
|
||||
return itemDate >= today;
|
||||
case 'yesterday':
|
||||
return itemDate >= yesterday && itemDate < today;
|
||||
case 'week':
|
||||
return itemDate >= weekAgo;
|
||||
case 'month':
|
||||
return itemDate >= monthAgo;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (status !== 'all') {
|
||||
result = result.filter((item) => item.status === status);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check if any filters are active
|
||||
$: hasActiveFilters = dateFilter !== 'all' || statusFilter !== 'all';
|
||||
|
||||
let expandedIds: Set<string> = new Set();
|
||||
|
||||
const columns: Column<RenameJobLog>[] = [
|
||||
{ key: 'runNumber', header: '#', sortable: false },
|
||||
{ key: 'date', header: 'Date', sortable: true },
|
||||
{ key: 'duration', header: 'Duration', sortable: false },
|
||||
{ key: 'status', header: 'Status', sortable: true },
|
||||
{ key: 'summary', header: 'Summary', sortable: false }
|
||||
];
|
||||
|
||||
// Status badge config
|
||||
const statusConfig = {
|
||||
success: { variant: 'success' as const, icon: Check },
|
||||
partial: { variant: 'warning' as const, icon: AlertTriangle },
|
||||
failed: { variant: 'danger' as const, icon: X },
|
||||
skipped: { variant: 'neutral' as const, icon: X }
|
||||
};
|
||||
|
||||
function getRunNumber(row: RenameJobLog): number {
|
||||
const originalIndex = runs.findIndex((r) => r.id === row.id);
|
||||
return runs.length - originalIndex;
|
||||
}
|
||||
|
||||
function formatDate(isoString: string): string {
|
||||
const date = new Date(isoString);
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
let dateStr: string;
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
dateStr = 'Today';
|
||||
} else if (date.toDateString() === yesterday.toDateString()) {
|
||||
dateStr = 'Yesterday';
|
||||
} else {
|
||||
dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
const timeStr = date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
|
||||
return `${dateStr}, ${timeStr}`;
|
||||
}
|
||||
|
||||
function formatDuration(startedAt: string, completedAt: string): string {
|
||||
const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime();
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
|
||||
}
|
||||
|
||||
function getFileName(path: string): string {
|
||||
return path.split('/').pop() || path;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="-mx-8 bg-neutral-50 px-8 pb-6 pt-2 dark:bg-neutral-900">
|
||||
<div class="mb-4">
|
||||
<ActionsBar>
|
||||
<SearchAction {searchStore} placeholder="Search runs..." />
|
||||
|
||||
<!-- Date Filter -->
|
||||
<ActionButton icon={Calendar} hasDropdown square title="Filter by date">
|
||||
<Dropdown slot="dropdown" position="right">
|
||||
{#each [
|
||||
{ value: 'all', label: 'All time' },
|
||||
{ value: 'today', label: 'Today' },
|
||||
{ value: 'yesterday', label: 'Yesterday' },
|
||||
{ value: 'week', label: 'Last 7 days' },
|
||||
{ value: 'month', label: 'Last 30 days' }
|
||||
] as option}
|
||||
<DropdownItem
|
||||
label={option.label}
|
||||
selected={dateFilter === option.value}
|
||||
on:click={() => (dateFilter = option.value)}
|
||||
/>
|
||||
{/each}
|
||||
</Dropdown>
|
||||
</ActionButton>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<ActionButton icon={CircleDot} hasDropdown square title="Filter by status">
|
||||
<Dropdown slot="dropdown" position="right">
|
||||
{#each [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'success', label: 'Success' },
|
||||
{ value: 'partial', label: 'Partial' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
{ value: 'skipped', label: 'Skipped' }
|
||||
] as option}
|
||||
<DropdownItem
|
||||
label={option.label}
|
||||
selected={statusFilter === option.value}
|
||||
on:click={() => (statusFilter = option.value)}
|
||||
/>
|
||||
{/each}
|
||||
</Dropdown>
|
||||
</ActionButton>
|
||||
</ActionsBar>
|
||||
</div>
|
||||
|
||||
<ExpandableTable
|
||||
{columns}
|
||||
data={filteredRuns}
|
||||
getRowId={(row) => row.id}
|
||||
bind:expandedRows={expandedIds}
|
||||
chevronPosition="right"
|
||||
flushExpanded={true}
|
||||
emptyMessage="No rename runs yet. Configure and enable rename to start."
|
||||
>
|
||||
<svelte:fragment slot="cell" let:row let:column>
|
||||
{#if column.key === 'runNumber'}
|
||||
<span class="font-mono text-neutral-500 dark:text-neutral-500">
|
||||
#{getRunNumber(row)}
|
||||
</span>
|
||||
{:else if column.key === 'date'}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-neutral-600 dark:text-neutral-400">
|
||||
{formatDate(row.startedAt)}
|
||||
</span>
|
||||
{#if row.config.dryRun}
|
||||
<span
|
||||
class="rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/50 dark:text-amber-400"
|
||||
>
|
||||
DRY
|
||||
</span>
|
||||
{/if}
|
||||
{#if row.config.manual}
|
||||
<span
|
||||
class="rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/50 dark:text-blue-400"
|
||||
>
|
||||
MANUAL
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if column.key === 'duration'}
|
||||
<span class="font-mono text-xs text-neutral-600 dark:text-neutral-400">
|
||||
{formatDuration(row.startedAt, row.completedAt)}
|
||||
</span>
|
||||
{:else if column.key === 'status'}
|
||||
{@const config = statusConfig[row.status] || statusConfig.failed}
|
||||
<Badge variant={config.variant} icon={config.icon} size="md">
|
||||
{row.status.charAt(0).toUpperCase() + row.status.slice(1)}
|
||||
</Badge>
|
||||
{:else if column.key === 'summary'}
|
||||
<span class="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
<span class="font-mono">{row.library.totalItems.toLocaleString()}</span> scanned
|
||||
<span class="mx-1 text-neutral-300 dark:text-neutral-600">→</span>
|
||||
<span class="font-mono">{row.results.filesNeedingRename}</span> need rename
|
||||
<span class="mx-1 text-neutral-300 dark:text-neutral-600">→</span>
|
||||
<span class="font-mono">{row.results.filesRenamed}</span>
|
||||
{row.config.dryRun ? 'would rename' : 'renamed'}
|
||||
</span>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="expanded" let:row>
|
||||
<div class="space-y-3 p-6">
|
||||
<!-- Config -->
|
||||
<div class="flex">
|
||||
<span class="w-24 shrink-0 text-sm font-medium text-neutral-500 dark:text-neutral-400"
|
||||
>Config</span
|
||||
>
|
||||
<span class="text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{#if row.config.renameFolders}Folders enabled{:else}Files only{/if}
|
||||
{#if row.config.ignoreTag}
|
||||
| Ignore tag: "{row.config.ignoreTag}"
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Library -->
|
||||
<div class="flex">
|
||||
<span class="w-24 shrink-0 text-sm font-medium text-neutral-500 dark:text-neutral-400"
|
||||
>Library</span
|
||||
>
|
||||
<span class="text-sm text-neutral-900 dark:text-neutral-100">
|
||||
<span class="font-mono">{row.library.totalItems.toLocaleString()}</span> items
|
||||
<span class="ml-1 font-mono text-xs text-neutral-500 dark:text-neutral-400">
|
||||
({row.library.fetchDurationMs}ms)
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Filtering -->
|
||||
{#if row.filtering.skippedByTag > 0}
|
||||
<div class="flex">
|
||||
<span class="w-24 shrink-0 text-sm font-medium text-neutral-500 dark:text-neutral-400"
|
||||
>Filtered</span
|
||||
>
|
||||
<span class="text-sm text-neutral-900 dark:text-neutral-100">
|
||||
<span class="font-mono">{row.filtering.skippedByTag}</span> skipped by tag
|
||||
<span class="mx-1 text-neutral-400">→</span>
|
||||
<span class="font-mono font-medium">{row.filtering.afterIgnoreTag}</span> remaining
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Results -->
|
||||
<div class="flex">
|
||||
<span class="w-24 shrink-0 text-sm font-medium text-neutral-500 dark:text-neutral-400"
|
||||
>Results</span
|
||||
>
|
||||
<span class="text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{#if row.config.dryRun}
|
||||
<span class="font-mono">{row.results.filesNeedingRename}</span> files would be renamed
|
||||
{:else}
|
||||
<span class="font-mono">{row.results.filesRenamed}</span>/<span class="font-mono"
|
||||
>{row.results.filesNeedingRename}</span
|
||||
> files renamed
|
||||
{#if row.results.foldersRenamed > 0}
|
||||
, <span class="font-mono">{row.results.foldersRenamed}</span> folders
|
||||
{/if}
|
||||
{/if}
|
||||
{#if row.results.commandsFailed > 0}
|
||||
<span class="font-mono text-red-600 dark:text-red-400"
|
||||
>, {row.results.commandsFailed} failed</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Renamed Items -->
|
||||
{#if row.renamedItems.length > 0}
|
||||
{@const itemsColumns = [
|
||||
{ key: 'title', header: 'Title', sortable: false },
|
||||
{ key: 'files', header: 'Files', sortable: false, align: 'center' as const }
|
||||
]}
|
||||
<div class="mt-4 border-t border-neutral-200 pt-4 dark:border-neutral-700">
|
||||
<div
|
||||
class="mb-3 flex items-center gap-1.5 text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
<FileText size={14} />
|
||||
Items {row.config.dryRun ? 'Needing Rename' : 'Renamed'}
|
||||
</div>
|
||||
<ExpandableTable
|
||||
columns={itemsColumns}
|
||||
data={row.renamedItems}
|
||||
getRowId={(item) => item.id}
|
||||
compact={true}
|
||||
emptyMessage="No items"
|
||||
>
|
||||
<svelte:fragment slot="cell" let:row={item} let:column>
|
||||
{#if column.key === 'title'}
|
||||
<span class="text-neutral-900 dark:text-neutral-100">{item.title}</span>
|
||||
{:else if column.key === 'files'}
|
||||
<Badge variant="neutral" mono>{item.files.length}</Badge>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="expanded" let:row={item}>
|
||||
<div class="space-y-2 py-2 pl-2">
|
||||
{#each item.files as file}
|
||||
<div class="space-y-1">
|
||||
<div class="flex gap-2">
|
||||
<span
|
||||
class="w-12 shrink-0 text-xs font-medium text-neutral-500 dark:text-neutral-400"
|
||||
>From:</span
|
||||
>
|
||||
<span
|
||||
class="truncate font-mono text-xs text-neutral-700 dark:text-neutral-300"
|
||||
title={file.existingPath}
|
||||
>
|
||||
{getFileName(file.existingPath)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<span
|
||||
class="w-12 shrink-0 text-xs font-medium text-emerald-600 dark:text-emerald-400"
|
||||
>To:</span
|
||||
>
|
||||
<span
|
||||
class="truncate font-mono text-xs text-neutral-700 dark:text-neutral-300"
|
||||
title={file.newPath}
|
||||
>
|
||||
{getFileName(file.newPath)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</ExpandableTable>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</ExpandableTable>
|
||||
</div>
|
||||
Reference in New Issue
Block a user