mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-06-18 18:28:46 -04:00
feat: add drift detection page (#510)
This commit is contained in:
@@ -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.
|
||||
|
||||
504
src/lib/server/drift/display.ts
Normal file
504
src/lib/server/drift/display.ts
Normal 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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
144
src/routes/arr/[id]/drift/+page.server.ts
Normal file
144
src/routes/arr/[id]/drift/+page.server.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
};
|
||||
315
src/routes/arr/[id]/drift/+page.svelte
Normal file
315
src/routes/arr/[id]/drift/+page.svelte
Normal 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}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user