feat: add drift detection page (#510)

This commit is contained in:
santiagosayshey
2026-05-03 16:36:43 +09:30
committed by GitHub
parent c2e53ee52d
commit 85a9adf2ff
7 changed files with 1136 additions and 4 deletions

View File

@@ -2,7 +2,11 @@
**Source:** `src/lib/server/jobs/handlers/arrDrift.ts`,
`src/lib/server/db/queries/arrDriftSettings.ts`,
`src/lib/server/db/queries/arrDriftStatus.ts`
`src/lib/server/db/queries/arrDriftStatus.ts`,
`src/lib/server/drift/display.ts`,
`src/routes/arr/[id]/drift/+page.svelte`,
`src/routes/arr/[id]/drift/+page.server.ts`,
`src/routes/arr/[id]/drift/components/DriftFieldDiffTable.svelte`
Drift detection checks whether an Arr instance still matches the configuration
Profilarr would sync now. It is observational: it does not write to Arr, repair
@@ -65,7 +69,63 @@ Comparison rules:
- compare normalized specifications and fields
- ignore unmanaged extra Arr custom formats
## Display Formatter
`src/lib/server/drift/display.ts` maps the raw `diff_json` stored in
`arr_drift_status` into a typed list of `DriftDisplayEntity` objects consumed
by the page. One entity is one drifted managed item (e.g. one custom format).
Each entity carries:
- `section` and `sectionLabel` (e.g. `custom_formats` / `Custom Format`)
- `state` and `stateLabel` (`missing` / `modified` / `extra`)
- `tone` for badge color signaling
- `summary` (one-line description, e.g. `3 changes detected`)
- `changes[]`: per-field `DriftDisplayChange` rows with `label`, optional
`detail`, and `expected` / `actual` `DriftDisplayValue`s. Values carry
`text`, optional `mono`, and optional `tone`.
For custom formats the formatter resolves Arr enum ids back to friendly names
(sources, resolutions, indexer flags, languages, release types, quality
modifiers), formats sizes in human-readable bytes, and turns specification
paths into labeled changes (negate / required / per-field changes / missing
condition / extra condition).
Display types live in `src/lib/shared/drift.ts`.
## Arr Drift Page
Route: `/arr/[id]/drift`. Source: `src/routes/arr/[id]/drift/+page.svelte`
and `+page.server.ts`.
Layout:
- Sticky header with `Run Now` (queues a manual `arr.drift` job) and `Save`
(persists the schedule and enabled state).
- Settings bar (borderless, full-width, with a bottom rule): `Detection`
toggle, `Schedule` `CronInput`, and timing pills aligned right
(`Paused` / `Ready` / `Next ...` / `Last ...`).
- Entities section: an `ExpandableTable` with `Name` / `Entity` / `State`
columns, mirroring the dev changes-page diff idiom. Each drifted entity is
one row; expanding shows a `DriftFieldDiffTable` with `Field` / `Expected`
/ `Actual` columns rendering the entity's `changes[]`.
State rendering inside the entities section:
| Latest status | Rendered as |
| -------------------------------------- | ----------------------------------------------------------------------- |
| Detection disabled | Empty `ExpandableTable` chrome with a disabled message in the empty row |
| `never_checked` | Neutral message box |
| `clean` | Success message box |
| `failed` | Error message box with `last_error` |
| `drift_detected`, displayable items | Populated `ExpandableTable` |
| `drift_detected`, no displayable items | Amber message box (formatter produced nothing for the stored diff) |
The page is read-only for drift results. It never writes to Arr, repairs
configuration, or triggers sync.
## TODO
Implement quality profile, delay profile, and media management comparison,
notifications, and the sync page drift UI.
- Quality profile, delay profile, and media management comparison plus their
display formatting.
- Drift notifications for `detected` and `failed`.
- Brief drift status on the sync page linking to the dedicated drift page.

View File

@@ -0,0 +1,504 @@
import {
INDEXER_FLAGS,
LANGUAGES,
QUALITY_MODIFIERS,
RELEASE_TYPES,
RESOLUTIONS,
SOURCES,
type SyncArrType
} from '$sync/mappings.ts';
import type {
DriftDiff,
DriftDisplayChange,
DriftDisplayEntity,
DriftDisplayTone,
DriftDisplayValue
} from '$shared/drift.ts';
interface CustomFormatDiff {
missing?: unknown[];
modified?: unknown[];
}
interface DriftFieldDiff {
path: string;
expected: unknown;
actual: unknown;
}
interface CustomFormatModifiedDiff {
name: string;
fields: DriftFieldDiff[];
}
interface SpecificationValue {
name: string;
implementation: string;
negate: boolean;
required: boolean;
fields: { name: string; value: unknown }[];
}
interface ParsedSpecificationPath {
implementation: string;
name: string;
member?: string;
field?: string;
}
const IMPLEMENTATION_LABELS: Record<string, string> = {
ReleaseTitleSpecification: 'Release Title',
ReleaseGroupSpecification: 'Release Group',
EditionSpecification: 'Edition',
SourceSpecification: 'Source',
ResolutionSpecification: 'Resolution',
IndexerFlagSpecification: 'Indexer Flag',
QualityModifierSpecification: 'Quality Modifier',
SizeSpecification: 'Size',
LanguageSpecification: 'Language',
ReleaseTypeSpecification: 'Release Type',
YearSpecification: 'Year'
};
export function buildDriftDisplayEntities(
diff: DriftDiff,
arrType: string | undefined
): DriftDisplayEntity[] {
const syncArrType = arrType === 'radarr' || arrType === 'sonarr' ? arrType : undefined;
return buildCustomFormatEntities(diff.custom_formats, syncArrType);
}
function buildCustomFormatEntities(
raw: unknown,
arrType: SyncArrType | undefined
): DriftDisplayEntity[] {
const diff = asCustomFormatDiff(raw);
if (!diff) return [];
const entities: DriftDisplayEntity[] = [];
for (const item of diff.missing ?? []) {
const name = recordString(item, 'name');
if (!name) continue;
entities.push({
id: `custom_formats:missing:${name}`,
section: 'custom_formats',
sectionLabel: 'Custom Format',
title: name,
state: 'missing',
stateLabel: 'Missing',
tone: 'danger',
summary: 'Profilarr expects this custom format, but Arr does not have it.',
changes: [
{
id: `custom_formats:missing:${name}:format`,
label: 'Custom format',
detail: 'Missing from Arr',
expected: value('Present'),
actual: value('Missing', { tone: 'danger' }),
tone: 'danger'
}
]
});
}
for (const item of diff.modified ?? []) {
const modified = asModifiedCustomFormat(item);
if (!modified) continue;
if (modified.fields.length === 0) continue;
const changes = modified.fields.map((field, index) =>
formatCustomFormatFieldDiff(field, index, arrType)
);
entities.push({
id: `custom_formats:modified:${modified.name}`,
section: 'custom_formats',
sectionLabel: 'Custom Format',
title: modified.name,
state: 'modified',
stateLabel: 'Modified',
tone: 'warning',
summary: `${changes.length} ${changes.length === 1 ? 'change' : 'changes'} detected`,
changes
});
}
return entities;
}
function formatCustomFormatFieldDiff(
field: DriftFieldDiff,
index: number,
arrType: SyncArrType | undefined
): DriftDisplayChange {
if (field.path === 'includeCustomFormatWhenRenaming') {
return {
id: `include-in-rename:${index}`,
label: 'Include in Rename',
detail: 'Rename behavior changed',
expected: formatBooleanValue(field.expected),
actual: formatBooleanValue(field.actual),
tone: 'warning'
};
}
const specPath = parseSpecificationPath(field.path);
if (!specPath) {
return {
id: `custom-format-field:${index}`,
label: 'Custom format setting',
detail: 'Value changed',
expected: formatGenericValue(field.expected),
actual: formatGenericValue(field.actual),
tone: field.actual === null || field.actual === undefined ? 'danger' : 'warning'
};
}
const label = `${implementationLabel(specPath.implementation)} condition: ${specPath.name}`;
if (!specPath.member && !specPath.field) {
return formatSpecificationChange(label, field, arrType, index);
}
if (specPath.member === 'negate') {
return {
id: `condition-negate:${index}`,
label,
detail: 'Match mode changed',
expected: formatNegateValue(field.expected),
actual: formatNegateValue(field.actual),
tone: 'warning'
};
}
if (specPath.member === 'required') {
return {
id: `condition-required:${index}`,
label,
detail: 'Requirement changed',
expected: formatRequiredValue(field.expected),
actual: formatRequiredValue(field.actual),
tone: 'warning'
};
}
if (specPath.field) {
return {
id: `condition-field:${index}`,
label,
detail: `${fieldLabel(specPath.implementation, specPath.field)} changed`,
expected: formatFieldValue(specPath.implementation, specPath.field, field.expected, arrType),
actual: formatFieldValue(specPath.implementation, specPath.field, field.actual, arrType),
tone: field.actual === null || field.actual === undefined ? 'danger' : 'warning'
};
}
return {
id: `condition-setting:${index}`,
label,
detail: 'Condition setting changed',
expected: formatGenericValue(field.expected),
actual: formatGenericValue(field.actual),
tone: 'warning'
};
}
function formatSpecificationChange(
label: string,
field: DriftFieldDiff,
arrType: SyncArrType | undefined,
index: number
): DriftDisplayChange {
if (field.actual === null || field.actual === undefined) {
return {
id: `condition-missing:${index}`,
label,
detail: 'Condition is missing from Arr',
expected: formatSpecificationValue(field.expected, arrType),
actual: value('Missing', { tone: 'danger' }),
tone: 'danger'
};
}
if (field.expected === null || field.expected === undefined) {
return {
id: `condition-extra:${index}`,
label,
detail: 'Extra condition exists in Arr',
expected: value('Not present'),
actual: formatSpecificationValue(field.actual, arrType),
tone: 'warning'
};
}
return {
id: `condition-changed:${index}`,
label,
detail: 'Condition changed',
expected: formatSpecificationValue(field.expected, arrType),
actual: formatSpecificationValue(field.actual, arrType),
tone: 'warning'
};
}
function parseSpecificationPath(path: string): ParsedSpecificationPath | null {
const match = /^specifications\[([^:\]]+):(.+?)\](?:\.(.+))?$/.exec(path);
if (!match) return null;
const tail = match[3];
const parsed: ParsedSpecificationPath = {
implementation: match[1],
name: match[2]
};
if (!tail) return parsed;
const fieldMatch = /^fields\[(.+)\]$/.exec(tail);
if (fieldMatch) {
parsed.field = fieldMatch[1];
return parsed;
}
parsed.member = tail;
return parsed;
}
function asCustomFormatDiff(raw: unknown): CustomFormatDiff | null {
if (!isRecord(raw)) return null;
return {
missing: Array.isArray(raw.missing) ? raw.missing : [],
modified: Array.isArray(raw.modified) ? raw.modified : []
};
}
function asModifiedCustomFormat(raw: unknown): CustomFormatModifiedDiff | null {
if (!isRecord(raw)) return null;
const name = recordString(raw, 'name');
if (!name || !Array.isArray(raw.fields)) return null;
const fields = raw.fields.filter(isFieldDiff);
return { name, fields };
}
function isFieldDiff(raw: unknown): raw is DriftFieldDiff {
return (
isRecord(raw) &&
typeof raw.path === 'string' &&
Object.hasOwn(raw, 'expected') &&
Object.hasOwn(raw, 'actual')
);
}
function asSpecificationValue(raw: unknown): SpecificationValue | null {
if (!isRecord(raw)) return null;
if (typeof raw.name !== 'string' || typeof raw.implementation !== 'string') return null;
return {
name: raw.name,
implementation: raw.implementation,
negate: Boolean(raw.negate),
required: Boolean(raw.required),
fields: Array.isArray(raw.fields)
? raw.fields.flatMap((field) => {
if (!isRecord(field) || typeof field.name !== 'string') return [];
return [{ name: field.name, value: field.value }];
})
: []
};
}
function formatSpecificationValue(
raw: unknown,
arrType: SyncArrType | undefined
): DriftDisplayValue {
const spec = asSpecificationValue(raw);
if (!spec) return formatGenericValue(raw);
const fields = spec.fields.map((field) => {
const formatted = formatFieldValue(spec.implementation, field.name, field.value, arrType);
return `${fieldLabel(spec.implementation, field.name)}: ${formatted.text}`;
});
const modes = [
spec.required ? 'Required' : 'Optional',
spec.negate ? 'Must not match' : 'Must match'
];
const suffix = [...fields, ...modes].join(', ');
return value(
`${implementationLabel(spec.implementation)}: ${spec.name}${suffix ? ` (${suffix})` : ''}`
);
}
function formatFieldValue(
implementation: string,
field: string,
raw: unknown,
arrType: SyncArrType | undefined
): DriftDisplayValue {
if (raw === null || raw === undefined) return value('Missing', { tone: 'danger' });
if (field === 'value') {
if (isPatternImplementation(implementation)) {
return value(String(raw), { mono: true });
}
if (implementation === 'SourceSpecification') {
return value(mappedName(arrType ? SOURCES[arrType] : undefined, raw) ?? String(raw));
}
if (implementation === 'ResolutionSpecification') {
return value(mappedName(RESOLUTIONS, raw) ?? `${String(raw)}p`);
}
if (implementation === 'IndexerFlagSpecification') {
return value(mappedName(arrType ? INDEXER_FLAGS[arrType] : undefined, raw) ?? String(raw));
}
if (implementation === 'QualityModifierSpecification') {
return value(mappedName(QUALITY_MODIFIERS, raw) ?? String(raw));
}
if (implementation === 'ReleaseTypeSpecification') {
return value(mappedName(RELEASE_TYPES, raw) ?? String(raw));
}
if (implementation === 'LanguageSpecification') {
return value(languageName(arrType, raw) ?? String(raw));
}
}
if (implementation === 'SizeSpecification' && (field === 'min' || field === 'max')) {
return value(formatBytes(raw));
}
if (field === 'exceptLanguage') {
return formatBooleanValue(raw);
}
return formatGenericValue(raw);
}
function formatGenericValue(raw: unknown): DriftDisplayValue {
if (raw === null || raw === undefined) return value('Missing', { tone: 'danger' });
if (typeof raw === 'boolean') return formatBooleanValue(raw);
if (typeof raw === 'string') return value(raw, { mono: looksTechnical(raw) });
if (typeof raw === 'number') return value(String(raw), { mono: true });
if (Array.isArray(raw)) return value(raw.map((item) => formatGenericValue(item).text).join(', '));
const spec = asSpecificationValue(raw);
if (spec) return formatSpecificationValue(spec, undefined);
return value('Structured value');
}
function formatBooleanValue(raw: unknown): DriftDisplayValue {
if (raw === null || raw === undefined) return value('Missing', { tone: 'danger' });
return value(raw ? 'Enabled' : 'Disabled', {
tone: raw ? 'success' : 'neutral'
});
}
function formatNegateValue(raw: unknown): DriftDisplayValue {
if (raw === null || raw === undefined) return value('Missing', { tone: 'danger' });
return value(raw ? 'Must not match' : 'Must match');
}
function formatRequiredValue(raw: unknown): DriftDisplayValue {
if (raw === null || raw === undefined) return value('Missing', { tone: 'danger' });
return value(raw ? 'Required' : 'Optional');
}
function fieldLabel(implementation: string, field: string): string {
if (field === 'value') {
if (isPatternImplementation(implementation)) return 'Pattern';
if (implementation === 'LanguageSpecification') return 'Language';
return implementationLabel(implementation);
}
if (field === 'min') return 'Minimum';
if (field === 'max') return 'Maximum';
if (field === 'exceptLanguage') return 'Except Language';
return titleize(field);
}
function implementationLabel(implementation: string): string {
return (
IMPLEMENTATION_LABELS[implementation] ?? titleize(implementation.replace(/Specification$/, ''))
);
}
function isPatternImplementation(implementation: string): boolean {
return (
implementation === 'ReleaseTitleSpecification' ||
implementation === 'ReleaseGroupSpecification' ||
implementation === 'EditionSpecification'
);
}
function mappedName(map: Record<string, number> | undefined, raw: unknown): string | null {
if (typeof raw !== 'number' || !map) return null;
for (const [key, value] of Object.entries(map)) {
if (value === raw) return titleizeMappingKey(key);
}
return null;
}
function languageName(arrType: SyncArrType | undefined, raw: unknown): string | null {
if (typeof raw !== 'number' || !arrType) return null;
for (const language of Object.values(LANGUAGES[arrType])) {
if (language.id === raw) return language.name;
}
return null;
}
function formatBytes(raw: unknown): string {
if (typeof raw !== 'number' || raw <= 0) return 'None';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = raw;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value = value / 1024;
unitIndex++;
}
const digits = value >= 10 || unitIndex === 0 ? 0 : 1;
return `${value.toFixed(digits)} ${units[unitIndex]}`;
}
function titleize(value: string): string {
return value
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/[_-]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function titleizeMappingKey(value: string): string {
const label = titleize(value);
return label
.replace(/\bTv\b/g, 'TV')
.replace(/\bDl\b/g, 'DL')
.replace(/\bHd\b/g, 'HD')
.replace(/\bRawhd\b/g, 'Raw HD')
.replace(/\bBrdisk\b/g, 'BR Disk');
}
function looksTechnical(value: string): boolean {
return /[\\^$.[\]()*+?{}|]/.test(value);
}
function value(
text: string,
options: { mono?: boolean; tone?: DriftDisplayTone } = {}
): DriftDisplayValue {
return {
text,
mono: options.mono,
tone: options.tone
};
}
function recordString(raw: unknown, key: string): string | null {
if (!isRecord(raw)) return null;
const value = raw[key];
return typeof value === 'string' ? value : null;
}
function isRecord(raw: unknown): raw is Record<string, unknown> {
return typeof raw === 'object' && raw !== null;
}

View File

@@ -9,3 +9,34 @@ export type DriftSection =
export type DriftCounts = Partial<Record<DriftSection, number>>;
export type DriftDiff = Record<string, unknown>;
export type DriftDisplayTone = 'neutral' | 'success' | 'warning' | 'danger' | 'info';
export type DriftDisplayState = 'missing' | 'modified' | 'extra';
export interface DriftDisplayValue {
text: string;
mono?: boolean;
tone?: DriftDisplayTone;
}
export interface DriftDisplayChange {
id: string;
label: string;
detail?: string;
expected?: DriftDisplayValue;
actual?: DriftDisplayValue;
tone: DriftDisplayTone;
}
export interface DriftDisplayEntity {
id: string;
section: DriftSection;
sectionLabel: string;
title: string;
state: DriftDisplayState;
stateLabel: string;
tone: DriftDisplayTone;
summary: string;
changes: DriftDisplayChange[];
}

View File

@@ -1,7 +1,15 @@
<script lang="ts">
import Tabs from '$ui/navigation/tabs/Tabs.svelte';
import { page } from '$app/stores';
import { Library, RefreshCw, ArrowUpCircle, FileEdit, ScrollText, Settings } from 'lucide-svelte';
import {
Library,
RefreshCw,
ArrowLeftRight,
ArrowUpCircle,
FileEdit,
ScrollText,
Settings
} from 'lucide-svelte';
import type { LayoutData } from './$types';
export let data: LayoutData;
@@ -25,7 +33,16 @@
onboarding: 'arr-tab-sync'
};
$: driftTab = {
label: 'Drift',
href: `/arr/${instanceId}/drift`,
active: currentPath.includes('/drift'),
icon: ArrowLeftRight,
onboarding: 'arr-tab-drift'
};
$: otherTabs = [
driftTab,
{
label: 'Upgrades',
href: `/arr/${instanceId}/upgrades`,

View File

@@ -0,0 +1,144 @@
import { error, fail } from '@sveltejs/kit';
import type { Actions, ServerLoad } from '@sveltejs/kit';
import { arrDriftSettingsQueries } from '$db/queries/arrDriftSettings.ts';
import { arrDriftStatusQueries } from '$db/queries/arrDriftStatus.ts';
import { arrInstancesQueries } from '$db/queries/arrInstances.ts';
import { logger } from '$logger/logger.ts';
import { scheduleDriftForInstance } from '$lib/server/jobs/init.ts';
import { enqueueJob } from '$lib/server/jobs/queueService.ts';
import { buildJobDisplayName } from '$lib/server/jobs/display.ts';
import { calculateNextRun, validateCronExpression } from '$lib/server/jobs/scheduleUtils.ts';
import { FEATURES } from '$shared/features.ts';
import { buildDriftDisplayEntities } from '$drift/display.ts';
export const load: ServerLoad = ({ params }) => {
const id = parseInt(params.id || '', 10);
if (isNaN(id)) {
error(404, `Invalid instance ID: ${params.id}`);
}
const instance = arrInstancesQueries.getById(id);
if (!instance) {
error(404, `Instance not found: ${id}`);
}
const driftSettings = arrDriftSettingsQueries.getByInstanceId(id);
const driftStatus = arrDriftStatusQueries.getByInstanceId(id);
const { api_key: _, ...safeInstance } = instance;
const diff = driftStatus?.diff ?? {};
return {
instance: safeInstance,
featureEnabled: FEATURES.drift,
settings: {
enabled: driftSettings?.enabled ?? false,
cron: driftSettings?.cron ?? '0 0 * * *',
nextRunAt: driftSettings?.nextRunAt ?? null
},
status: {
status: driftStatus?.status ?? 'never_checked',
lastCheckedAt: driftStatus?.lastCheckedAt ?? null,
lastError: driftStatus?.lastError ?? null
},
driftEntities: buildDriftDisplayEntities(diff, instance.type)
};
};
export const actions: Actions = {
save: async ({ params, request }) => {
const id = parseInt(params.id || '', 10);
if (isNaN(id)) {
return fail(400, { error: 'Invalid instance ID' });
}
if (!FEATURES.drift) {
return fail(404, { error: 'Drift detection is not available' });
}
const instance = arrInstancesQueries.getById(id);
if (!instance) {
return fail(404, { error: 'Instance not found' });
}
const formData = await request.formData();
const enabled = formData.get('enabled') === 'true';
const cron = ((formData.get('cron') as string | null) || '0 0 * * *').trim();
const cronError = enabled ? validateCronExpression(cron, 10) : null;
if (cronError) {
return fail(400, { error: cronError });
}
const nextRunAt = enabled ? calculateNextRun(cron) : null;
if (enabled && !nextRunAt) {
return fail(400, { error: 'Invalid drift schedule' });
}
try {
arrDriftSettingsQueries.upsert(id, { enabled, cron, nextRunAt });
scheduleDriftForInstance(id);
await logger.info(`Drift detection settings saved for "${instance.name}"`, {
source: 'drift',
meta: { instanceId: id, enabled }
});
return { success: true, nextRunAt };
} catch (err) {
await logger.error('Failed to save drift detection settings', {
source: 'drift',
meta: { instanceId: id, error: err }
});
return fail(500, { error: 'Failed to save drift detection settings' });
}
},
run: async ({ params }) => {
const id = parseInt(params.id || '', 10);
if (isNaN(id)) {
return fail(400, { error: 'Invalid instance ID' });
}
if (!FEATURES.drift) {
return fail(404, { error: 'Drift detection is not available' });
}
const instance = arrInstancesQueries.getById(id);
if (!instance) {
return fail(404, { error: 'Instance not found' });
}
const settings = arrDriftSettingsQueries.getByInstanceId(id);
if (!settings || !settings.enabled) {
return fail(400, { error: 'Drift detection is disabled' });
}
try {
const queued = enqueueJob({
jobType: 'arr.drift',
runAt: new Date().toISOString(),
payload: { instanceId: id },
source: 'manual'
});
await logger.info('Manual drift check queued', {
source: 'drift',
meta: {
jobId: queued.id,
instanceId: id,
instanceName: instance.name,
displayName: buildJobDisplayName('arr.drift', { instanceId: id })
}
});
return { success: true, queued: true };
} catch (err) {
await logger.error('Manual drift check failed', {
source: 'drift',
meta: { instanceId: id, error: err }
});
return fail(500, { error: 'Failed to queue drift check' });
}
}
};

View File

@@ -0,0 +1,315 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
import { onDestroy, onMount } from 'svelte';
import { alertStore } from '$lib/client/alerts/store';
import { initEdit, update as updateDirty, clear, isDirty } from '$lib/client/stores/dirty';
import { formatSmartDateTime } from '$shared/utils/dates';
import { serverTimezone } from '$lib/client/stores/timezone';
import Button from '$ui/button/Button.svelte';
import StickyCard from '$ui/card/StickyCard.svelte';
import CronInput from '$ui/cron/CronInput.svelte';
import DirtyModal from '$ui/modal/DirtyModal.svelte';
import Label from '$ui/label/Label.svelte';
import ExpandableTable from '$ui/table/ExpandableTable.svelte';
import Toggle from '$ui/toggle/Toggle.svelte';
import type { Column } from '$ui/table/types';
import { Loader2, Play, Save } from 'lucide-svelte';
import type { DriftDisplayEntity, DriftDisplayTone } from '$shared/drift.ts';
import DriftFieldDiffTable from './components/DriftFieldDiffTable.svelte';
export let data: PageData;
export let form: ActionData;
type LabelVariant = 'default' | 'secondary' | 'success' | 'warning' | 'danger' | 'info';
const toneToVariant: Record<DriftDisplayTone, LabelVariant> = {
neutral: 'secondary',
success: 'success',
warning: 'warning',
danger: 'danger',
info: 'info'
};
const driftColumns: Column<DriftDisplayEntity>[] = [
{ key: 'title', header: 'Name' },
{ key: 'section', header: 'Entity', width: 'w-44' },
{ key: 'state', header: 'State', width: 'w-32' }
];
const emptyDriftEntities: DriftDisplayEntity[] = [];
let saving = false;
let running = false;
let lastFormId: unknown = null;
let enabled = data.settings.enabled;
let cron = data.settings.cron;
let nextRunAt = data.settings.nextRunAt;
let now = Date.now();
let interval: ReturnType<typeof setInterval>;
onMount(() => {
initEdit({ enabled, cron });
interval = setInterval(() => {
now = Date.now();
}, 1000);
return () => {
clear();
if (interval) clearInterval(interval);
};
});
onDestroy(() => {
if (interval) clearInterval(interval);
});
$: updateDirty('enabled', enabled);
$: updateDirty('cron', cron);
$: nextRunTime = nextRunAt ? new Date(nextRunAt).getTime() : null;
$: timeUntilNext = nextRunTime ? nextRunTime - now : null;
$: if (form && form !== lastFormId) {
lastFormId = form;
if (form.success && form.queued) {
alertStore.add('success', 'Drift check queued');
}
if (form.success && !form.queued) {
if (form.nextRunAt || form.nextRunAt === null) nextRunAt = form.nextRunAt;
alertStore.add('success', 'Drift detection settings saved');
initEdit({ enabled, cron });
}
if (form.error) {
alertStore.add('error', form.error);
}
}
function formatTimeRemaining(ms: number): string {
if (ms <= 0) return 'now';
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
const remainingMinutes = minutes % 60;
return `${hours}h ${remainingMinutes}m`;
}
if (minutes > 0) {
return `${minutes}m`;
}
return `${seconds}s`;
}
</script>
<svelte:head>
<title>{data.instance.name} - Drift - Profilarr</title>
</svelte:head>
{#key data.instance.id}
<StickyCard position="top">
<div slot="left">
<h1 class="text-xl font-semibold text-neutral-900 dark:text-neutral-50">Drift</h1>
<p class="text-sm text-neutral-500 dark:text-neutral-400">
Compare synced configuration against the current Arr state.
</p>
</div>
<div slot="right" class="flex items-center gap-2">
<Button
text={running ? 'Running...' : 'Run Now'}
icon={Play}
iconColor="text-green-600 dark:text-green-400"
disabled={saving || running || $isDirty || !data.featureEnabled || !enabled}
on:click={() => {
const runForm = document.getElementById('drift-run-form');
if (runForm instanceof HTMLFormElement) {
runForm.requestSubmit();
}
}}
/>
<Button
text={saving ? 'Saving...' : 'Save'}
icon={saving ? Loader2 : Save}
iconColor={saving
? 'text-blue-600 dark:text-blue-400 animate-spin'
: 'text-blue-600 dark:text-blue-400'}
disabled={saving || !$isDirty || !data.featureEnabled}
on:click={() => {
const saveForm = document.getElementById('drift-save-form');
if (saveForm instanceof HTMLFormElement) {
saveForm.requestSubmit();
}
}}
/>
</div>
</StickyCard>
<div class="mt-4 space-y-6 pb-32">
<section class="border-b border-neutral-200 pb-5 dark:border-neutral-800">
{#if data.featureEnabled}
<div class="flex flex-wrap gap-4 md:items-end md:gap-x-5 md:gap-y-3 md:px-4">
<div>
<span
class="mb-1 block text-[10px] font-medium tracking-wider text-neutral-400 uppercase dark:text-neutral-500"
>
Detection
</span>
<Toggle
checked={enabled}
label={enabled ? 'Enabled' : 'Disabled'}
color={enabled ? 'green' : 'red'}
on:change={(event) => (enabled = event.detail)}
/>
</div>
<div data-onboarding="drift-schedule">
<span
class="mb-1 block text-[10px] font-medium tracking-wider text-neutral-400 uppercase dark:text-neutral-500"
>
Schedule
</span>
<CronInput
bind:value={cron}
disabled={saving || !enabled}
minIntervalMinutes={10}
onWarning={(msg) => alertStore.add('warning', msg)}
/>
</div>
{#if data.status.lastCheckedAt}
<div
class="flex w-full flex-wrap items-center gap-1.5 border-t border-neutral-200 pt-3 md:ml-auto md:w-auto md:border-0 md:pt-0 dark:border-neutral-800"
>
{#if !enabled}
<Label variant="warning" size="md" rounded="md">Paused</Label>
{:else if timeUntilNext !== null && timeUntilNext <= 0}
<Label variant="success" size="md" rounded="md">Ready</Label>
{:else if timeUntilNext !== null}
<Label variant="secondary" size="md" rounded="md" mono>
Next {formatTimeRemaining(timeUntilNext)}
</Label>
{/if}
<Label variant="secondary" size="md" rounded="md" mono>
Last {formatSmartDateTime(data.status.lastCheckedAt, $serverTimezone)}
</Label>
</div>
{/if}
</div>
{:else}
<div class="text-sm text-neutral-500 md:px-4 dark:text-neutral-400">
Drift detection is not available.
</div>
{/if}
</section>
{#if data.featureEnabled}
<section class="md:px-4">
{#if !enabled}
<ExpandableTable
columns={driftColumns}
data={emptyDriftEntities}
getRowId={(row) => row.id}
responsive
chevronPosition="right"
primaryColumnKey="title"
flushExpanded
emptyMessage="Drift detection is disabled. Toggle it on above to start checking."
/>
{:else if data.status.status === 'failed' && data.status.lastError}
<div
class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300"
>
{data.status.lastError}
</div>
{:else if data.status.status === 'never_checked'}
<div
class="rounded-lg border border-neutral-200 bg-neutral-50 p-4 text-sm text-neutral-600 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-400"
>
No drift check has run yet.
</div>
{:else if data.status.status === 'clean'}
<ExpandableTable
columns={driftColumns}
data={emptyDriftEntities}
getRowId={(row) => row.id}
responsive
chevronPosition="right"
primaryColumnKey="title"
flushExpanded
emptyMessage="✅ All synced up. Move along, nothing to see here."
/>
{:else if data.driftEntities.length > 0}
<ExpandableTable
columns={driftColumns}
data={data.driftEntities}
getRowId={(row) => row.id}
responsive
chevronPosition="right"
primaryColumnKey="title"
flushExpanded
>
<svelte:fragment slot="cell" let:row let:column>
{#if column.key === 'title'}
<div class="flex flex-col gap-1">
<span class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{row.title}
</span>
{#if row.summary}
<span class="text-xs text-neutral-500 dark:text-neutral-400">
{row.summary}
</span>
{/if}
</div>
{:else if column.key === 'section'}
<Label variant="secondary" size="md" rounded="md">{row.sectionLabel}</Label>
{:else if column.key === 'state'}
<Label variant={toneToVariant[row.tone]} size="md" rounded="md">
{row.stateLabel}
</Label>
{/if}
</svelte:fragment>
<svelte:fragment slot="expanded" let:row>
<div class="px-4 py-3 md:px-6 md:py-4">
<DriftFieldDiffTable changes={row.changes} />
</div>
</svelte:fragment>
</ExpandableTable>
{:else}
<div
class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-700 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-300"
>
Drift was detected, but no displayable items were stored.
</div>
{/if}
</section>
{/if}
</div>
<form
id="drift-save-form"
method="POST"
action="?/save"
class="hidden"
use:enhance={() => {
saving = true;
return async ({ update }) => {
await update({ reset: false });
saving = false;
};
}}
>
<input type="hidden" name="enabled" value={enabled} />
<input type="hidden" name="cron" value={cron} />
</form>
<form
id="drift-run-form"
method="POST"
action="?/run"
class="hidden"
use:enhance={() => {
running = true;
return async ({ update }) => {
await update({ reset: false });
running = false;
};
}}
></form>
<DirtyModal />
{/key}

View File

@@ -0,0 +1,61 @@
<script lang="ts">
import Table from '$ui/table/Table.svelte';
import Label from '$ui/label/Label.svelte';
import type { Column } from '$ui/table/types';
import type { DriftDisplayChange, DriftDisplayTone, DriftDisplayValue } from '$shared/drift.ts';
export let changes: DriftDisplayChange[];
type LabelVariant = 'default' | 'secondary' | 'success' | 'warning' | 'danger' | 'info';
const toneToVariant: Record<DriftDisplayTone, LabelVariant> = {
neutral: 'secondary',
success: 'success',
warning: 'warning',
danger: 'danger',
info: 'info'
};
function valueVariant(value: DriftDisplayValue): LabelVariant {
return toneToVariant[value.tone ?? 'neutral'];
}
const columns: Column<DriftDisplayChange>[] = [
{ key: 'label', header: 'Field' },
{ key: 'expected', header: 'Expected' },
{ key: 'actual', header: 'Actual' }
];
</script>
<Table {columns} data={changes} compact hoverable={false} responsive>
<svelte:fragment slot="cell" let:row let:column>
{#if column.key === 'label'}
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium text-neutral-700 dark:text-neutral-200">
{row.label}
</span>
{#if row.detail}
<span class="text-xs text-neutral-500 dark:text-neutral-400">
{row.detail}
</span>
{/if}
</div>
{:else if column.key === 'expected'}
{#if row.expected}
<Label variant={valueVariant(row.expected)} size="md" rounded="md" mono={row.expected.mono}>
{row.expected.text}
</Label>
{:else}
<span class="text-xs text-neutral-400 dark:text-neutral-500">None</span>
{/if}
{:else if column.key === 'actual'}
{#if row.actual}
<Label variant={valueVariant(row.actual)} size="md" rounded="md" mono={row.actual.mono}>
{row.actual.text}
</Label>
{:else}
<span class="text-xs text-neutral-400 dark:text-neutral-500">None</span>
{/if}
{/if}
</svelte:fragment>
</Table>