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 migration024 } from './migrations/024_create_arr_rename_settings.ts';
|
||||||
import { migration as migration025 } from './migrations/025_add_rename_notification_mode.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 migration026 } from './migrations/026_create_upgrade_runs.ts';
|
||||||
|
import { migration as migration027 } from './migrations/027_create_rename_runs.ts';
|
||||||
|
|
||||||
export interface Migration {
|
export interface Migration {
|
||||||
version: number;
|
version: number;
|
||||||
@@ -268,7 +269,8 @@ export function loadMigrations(): Migration[] {
|
|||||||
migration023,
|
migration023,
|
||||||
migration024,
|
migration024,
|
||||||
migration025,
|
migration025,
|
||||||
migration026
|
migration026,
|
||||||
|
migration027
|
||||||
];
|
];
|
||||||
|
|
||||||
// Sort by version number
|
// 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_instance ON upgrade_runs(instance_id);
|
||||||
CREATE INDEX idx_upgrade_runs_started_at ON upgrade_runs(started_at DESC);
|
CREATE INDEX idx_upgrade_runs_started_at ON upgrade_runs(started_at DESC);
|
||||||
CREATE INDEX idx_upgrade_runs_status ON upgrade_runs(status);
|
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
|
* Structured logging for rename jobs
|
||||||
* Uses the shared logger with source 'RenameJob'
|
* Uses the shared logger with source 'RenameJob'
|
||||||
|
* Stores run history in the database
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { logger } from '$logger/logger.ts';
|
import { logger } from '$logger/logger.ts';
|
||||||
|
import { renameRunsQueries } from '$db/queries/renameRuns.ts';
|
||||||
import type { RenameJobLog } from './types.ts';
|
import type { RenameJobLog } from './types.ts';
|
||||||
|
|
||||||
const SOURCE = 'RenameJob';
|
const SOURCE = 'RenameJob';
|
||||||
@@ -41,6 +43,16 @@ export async function logRenameRun(log: RenameJobLog): Promise<void> {
|
|||||||
items
|
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 type { Actions, ServerLoad } from '@sveltejs/kit';
|
||||||
import { arrInstancesQueries } from '$db/queries/arrInstances.ts';
|
import { arrInstancesQueries } from '$db/queries/arrInstances.ts';
|
||||||
import { arrRenameSettingsQueries } from '$db/queries/arrRenameSettings.ts';
|
import { arrRenameSettingsQueries } from '$db/queries/arrRenameSettings.ts';
|
||||||
|
import { renameRunsQueries } from '$db/queries/renameRuns.ts';
|
||||||
import { logger } from '$logger/logger.ts';
|
import { logger } from '$logger/logger.ts';
|
||||||
import { processRenameConfig } from '$lib/server/rename/processor.ts';
|
import { processRenameConfig } from '$lib/server/rename/processor.ts';
|
||||||
|
|
||||||
@@ -19,10 +20,12 @@ export const load: ServerLoad = ({ params }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const settings = arrRenameSettingsQueries.getByInstanceId(id);
|
const settings = arrRenameSettingsQueries.getByInstanceId(id);
|
||||||
|
const renameRuns = renameRunsQueries.getByInstanceId(id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
instance,
|
instance,
|
||||||
settings: settings ?? null
|
settings: settings ?? null,
|
||||||
|
renameRuns
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { alertStore } from '$lib/client/alerts/store';
|
import { alertStore } from '$lib/client/alerts/store';
|
||||||
import { isDirty, initEdit, initCreate, update, clear } from '$lib/client/stores/dirty';
|
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 RenameSettings from './components/RenameSettings.svelte';
|
||||||
|
import RenameRunHistory from './components/RenameRunHistory.svelte';
|
||||||
import RenameInfoModal from './components/RenameInfoModal.svelte';
|
import RenameInfoModal from './components/RenameInfoModal.svelte';
|
||||||
import DirtyModal from '$lib/client/ui/modal/DirtyModal.svelte';
|
import DirtyModal from '$lib/client/ui/modal/DirtyModal.svelte';
|
||||||
import StickyCard from '$ui/card/StickyCard.svelte';
|
import StickyCard from '$ui/card/StickyCard.svelte';
|
||||||
@@ -136,6 +137,14 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</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 -->
|
<!-- Hidden forms -->
|
||||||
<form
|
<form
|
||||||
id="save-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