From 85a9adf2ffe29fd0ea348edfb25f0618bb47f021 Mon Sep 17 00:00:00 2001 From: santiagosayshey Date: Sun, 3 May 2026 16:36:43 +0930 Subject: [PATCH] feat: add drift detection page (#510) --- docs/backend/drift.md | 66 ++- src/lib/server/drift/display.ts | 504 ++++++++++++++++++ src/lib/shared/drift.ts | 31 ++ src/routes/arr/[id]/+layout.svelte | 19 +- src/routes/arr/[id]/drift/+page.server.ts | 144 +++++ src/routes/arr/[id]/drift/+page.svelte | 315 +++++++++++ .../components/DriftFieldDiffTable.svelte | 61 +++ 7 files changed, 1136 insertions(+), 4 deletions(-) create mode 100644 src/lib/server/drift/display.ts create mode 100644 src/routes/arr/[id]/drift/+page.server.ts create mode 100644 src/routes/arr/[id]/drift/+page.svelte create mode 100644 src/routes/arr/[id]/drift/components/DriftFieldDiffTable.svelte diff --git a/docs/backend/drift.md b/docs/backend/drift.md index 5f5b3056..72849095 100644 --- a/docs/backend/drift.md +++ b/docs/backend/drift.md @@ -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. diff --git a/src/lib/server/drift/display.ts b/src/lib/server/drift/display.ts new file mode 100644 index 00000000..a24a9ab7 --- /dev/null +++ b/src/lib/server/drift/display.ts @@ -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 = { + 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 | 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 { + return typeof raw === 'object' && raw !== null; +} diff --git a/src/lib/shared/drift.ts b/src/lib/shared/drift.ts index a675387b..3dca8e55 100644 --- a/src/lib/shared/drift.ts +++ b/src/lib/shared/drift.ts @@ -9,3 +9,34 @@ export type DriftSection = export type DriftCounts = Partial>; export type DriftDiff = Record; + +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[]; +} diff --git a/src/routes/arr/[id]/+layout.svelte b/src/routes/arr/[id]/+layout.svelte index 39669366..68c86cca 100644 --- a/src/routes/arr/[id]/+layout.svelte +++ b/src/routes/arr/[id]/+layout.svelte @@ -1,7 +1,15 @@ + + + {data.instance.name} - Drift - Profilarr + + +{#key data.instance.id} + +
+

Drift

+

+ Compare synced configuration against the current Arr state. +

+
+
+
+
+ +
+
+ {#if data.featureEnabled} +
+
+ + Detection + + (enabled = event.detail)} + /> +
+
+ + Schedule + + alertStore.add('warning', msg)} + /> +
+ {#if data.status.lastCheckedAt} +
+ {#if !enabled} + + {:else if timeUntilNext !== null && timeUntilNext <= 0} + + {:else if timeUntilNext !== null} + + {/if} + +
+ {/if} +
+ {:else} +
+ Drift detection is not available. +
+ {/if} +
+ + {#if data.featureEnabled} +
+ {#if !enabled} + 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} +
+ {data.status.lastError} +
+ {:else if data.status.status === 'never_checked'} +
+ No drift check has run yet. +
+ {:else if data.status.status === 'clean'} + row.id} + responsive + chevronPosition="right" + primaryColumnKey="title" + flushExpanded + emptyMessage="✅ All synced up. Move along, nothing to see here." + /> + {:else if data.driftEntities.length > 0} + row.id} + responsive + chevronPosition="right" + primaryColumnKey="title" + flushExpanded + > + + {#if column.key === 'title'} +
+ + {row.title} + + {#if row.summary} + + {row.summary} + + {/if} +
+ {:else if column.key === 'section'} + + {:else if column.key === 'state'} + + {/if} +
+ + +
+ +
+
+
+ {:else} +
+ Drift was detected, but no displayable items were stored. +
+ {/if} +
+ {/if} +
+ + + + + +{/key} diff --git a/src/routes/arr/[id]/drift/components/DriftFieldDiffTable.svelte b/src/routes/arr/[id]/drift/components/DriftFieldDiffTable.svelte new file mode 100644 index 00000000..86998ae2 --- /dev/null +++ b/src/routes/arr/[id]/drift/components/DriftFieldDiffTable.svelte @@ -0,0 +1,61 @@ + + + + + {#if column.key === 'label'} +
+ + {row.label} + + {#if row.detail} + + {row.detail} + + {/if} +
+ {:else if column.key === 'expected'} + {#if row.expected} + + {:else} + None + {/if} + {:else if column.key === 'actual'} + {#if row.actual} + + {:else} + None + {/if} + {/if} +
+