feat: implement arr instances management with CRUD operations and navigation

- Added new routes and components for managing Arr instances, including library, logs, and search priority.
- Implemented server-side logic for loading, creating, updating, and deleting instances.
- Enhanced the InstanceForm component to include an enabled toggle for instance activation.
- Updated navigation to consolidate Arr instances under a single route.
- Removed deprecated routes and streamlined the instance management process.
This commit is contained in:
Sam Chau
2025-12-26 06:00:21 +10:30
parent e9ce6a76bc
commit 85b594cdf1
23 changed files with 622 additions and 251 deletions

View File

@@ -10,9 +10,7 @@
<div class="flex-1 overflow-y-auto p-4">
<Group label="🏠 Home" href="/" hasItems={true}>
<GroupItem label="Databases" href="/databases" />
<GroupItem label="Radarr" href="/arr/radarr" />
<GroupItem label="Sonarr" href="/arr/sonarr" />
<GroupItem label="Lidarr" href="/arr/lidarr" />
<GroupItem label="Arrs" href="/arr" />
</Group>
<Group label="⚡ Quality Profiles" href="/quality-profiles" initialOpen={false} />

View File

@@ -108,7 +108,7 @@
$: sortedData = sortData(data);
</script>
<div class="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-800">
<div class="overflow-x-auto rounded-lg border-2 border-neutral-200 dark:border-neutral-800">
<table class="w-full">
<!-- Header -->
<thead

View File

@@ -0,0 +1,60 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, ServerLoad } from '@sveltejs/kit';
import { arrInstancesQueries } from '$db/queries/arrInstances.ts';
import { logger } from '$logger/logger.ts';
export const load: ServerLoad = () => {
const instances = arrInstancesQueries.getAll();
return {
instances
};
};
export const actions = {
delete: async ({ request }) => {
const formData = await request.formData();
const id = parseInt(formData.get('id')?.toString() || '0', 10);
if (!id) {
await logger.warn('Attempted to delete arr instance without ID', {
source: 'arr'
});
return fail(400, {
error: 'Instance ID is required'
});
}
try {
const deleted = arrInstancesQueries.delete(id);
if (!deleted) {
return fail(404, {
error: 'Instance not found'
});
}
await logger.info(`Deleted arr instance: ${id}`, {
source: 'arr',
meta: { id }
});
redirect(303, '/arr');
} catch (error) {
// Re-throw redirect errors (they're not actual errors)
if (error && typeof error === 'object' && 'status' in error && 'location' in error) {
throw error;
}
await logger.error('Failed to delete arr instance', {
source: 'arr',
meta: { error: error instanceof Error ? error.message : String(error) }
});
return fail(500, {
error: error instanceof Error ? error.message : 'Failed to delete instance'
});
}
}
} satisfies Actions;

188
src/routes/arr/+page.svelte Normal file
View File

@@ -0,0 +1,188 @@
<script lang="ts">
import { Server, Plus, Trash2, Pencil, Check, X } from 'lucide-svelte';
import { goto } from '$app/navigation';
import { enhance } from '$app/forms';
import EmptyState from '$ui/state/EmptyState.svelte';
import Table from '$ui/table/Table.svelte';
import Modal from '$ui/modal/Modal.svelte';
import { alertStore } from '$alerts/store';
import type { PageData } from './$types';
import type { Column } from '$ui/table/types';
import type { ArrInstance } from '$db/queries/arrInstances.ts';
export let data: PageData;
// Modal state
let showDeleteModal = false;
let selectedInstance: ArrInstance | null = null;
let deleteFormElement: HTMLFormElement;
// Format type for display with proper casing
function formatType(type: string): string {
return type.charAt(0).toUpperCase() + type.slice(1);
}
// Get type badge color classes
function getTypeBadgeClasses(type: string): string {
const colors: Record<string, string> = {
radarr: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400',
sonarr: 'bg-sky-100 text-sky-800 dark:bg-sky-900/30 dark:text-sky-400',
lidarr: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400',
chaptarr: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400'
};
return colors[type] || 'bg-neutral-100 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-200';
}
// Handle row click
function handleRowClick(instance: ArrInstance) {
goto(`/arr/${instance.id}`);
}
// Handle delete click
function handleDeleteClick(e: MouseEvent, instance: ArrInstance) {
e.stopPropagation();
selectedInstance = instance;
showDeleteModal = true;
}
// Define table columns
const columns: Column<ArrInstance>[] = [
{ key: 'name', header: 'Name', align: 'left' },
{ key: 'type', header: 'Type', align: 'left', width: 'w-32' },
{ key: 'url', header: 'URL', align: 'left' },
{ key: 'enabled', header: 'Enabled', align: 'center', width: 'w-24' }
];
</script>
<svelte:head>
<title>Arr Instances - Profilarr</title>
</svelte:head>
{#if data.instances.length === 0}
<EmptyState
icon={Server}
title="No Arr Instances"
description="Add a Radarr, Sonarr, Lidarr, or Chaptarr instance to get started."
buttonText="Add Instance"
buttonHref="/arr/new"
buttonIcon={Plus}
/>
{:else}
<div class="space-y-6 p-8">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-50">Arr Instances</h1>
<p class="mt-1 text-neutral-600 dark:text-neutral-400">
Manage your Radarr, Sonarr, Lidarr, and Chaptarr instances
</p>
</div>
<a
href="/arr/new"
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
>
<Plus size={16} />
Add Instance
</a>
</div>
<!-- Instance Table -->
<Table {columns} data={data.instances} hoverable={true} onRowClick={handleRowClick}>
<svelte:fragment slot="cell" let:row let:column>
{#if column.key === 'name'}
<div class="font-medium text-neutral-900 dark:text-neutral-50">
{row.name}
</div>
{:else if column.key === 'type'}
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {getTypeBadgeClasses(row.type)}">
{formatType(row.type)}
</span>
{:else if column.key === 'url'}
<code class="rounded bg-neutral-100 px-2 py-1 font-mono text-xs text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">
{row.url}
</code>
{:else if column.key === 'enabled'}
<div class="flex justify-center">
{#if row.enabled}
<span class="inline-flex h-6 w-6 items-center justify-center rounded-full bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400">
<Check size={14} />
</span>
{:else}
<span class="inline-flex h-6 w-6 items-center justify-center rounded-full bg-neutral-100 text-neutral-400 dark:bg-neutral-800 dark:text-neutral-500">
<X size={14} />
</span>
{/if}
</div>
{/if}
</svelte:fragment>
<svelte:fragment slot="actions" let:row>
<div class="flex items-center justify-end gap-2">
<!-- Edit Button -->
<a
href="/arr/{row.id}/edit"
on:click={(e) => e.stopPropagation()}
class="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-neutral-300 bg-white text-neutral-600 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700"
title="Edit instance"
>
<Pencil size={14} />
</a>
<!-- Delete Button -->
<button
type="button"
on:click={(e) => handleDeleteClick(e, row)}
class="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-neutral-300 bg-white text-red-600 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-red-400 dark:hover:bg-neutral-700"
title="Delete instance"
>
<Trash2 size={14} />
</button>
</div>
</svelte:fragment>
</Table>
</div>
{/if}
<!-- Delete Confirmation Modal -->
<Modal
open={showDeleteModal}
header="Delete Instance"
bodyMessage={`Are you sure you want to delete "${selectedInstance?.name}"? This action cannot be undone.`}
confirmText="Delete"
cancelText="Cancel"
confirmDanger={true}
on:confirm={() => {
showDeleteModal = false;
if (selectedInstance) {
deleteFormElement?.requestSubmit();
}
}}
on:cancel={() => {
showDeleteModal = false;
selectedInstance = null;
}}
/>
<!-- Hidden delete form -->
<form
bind:this={deleteFormElement}
method="POST"
action="?/delete"
class="hidden"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
alertStore.add(
'error',
(result.data as { error?: string }).error || 'Failed to delete instance'
);
} else if (result.type === 'redirect') {
alertStore.add('success', 'Instance deleted successfully');
}
await update();
selectedInstance = null;
};
}}
>
<input type="hidden" name="id" value={selectedInstance?.id || ''} />
</form>

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import Tabs from '$ui/navigation/tabs/Tabs.svelte';
import { page } from '$app/stores';
import { Library, ArrowUpDown, ScrollText } from 'lucide-svelte';
$: instanceId = $page.params.id;
$: currentPath = $page.url.pathname;
$: tabs = [
{
label: 'Library',
href: `/arr/${instanceId}/library`,
active: currentPath.includes('/library'),
icon: Library
},
{
label: 'Search Priority',
href: `/arr/${instanceId}/search-priority`,
active: currentPath.includes('/search-priority'),
icon: ArrowUpDown
},
{
label: 'Logs',
href: `/arr/${instanceId}/logs`,
active: currentPath.includes('/logs'),
icon: ScrollText
}
];
$: backButton = {
label: 'Back',
href: '/arr'
};
</script>
<div class="p-8">
<Tabs {tabs} {backButton} />
<slot />
</div>

View File

@@ -0,0 +1,7 @@
import { redirect } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
export const load: ServerLoad = ({ params }) => {
// Redirect to the library tab by default
redirect(302, `/arr/${params.id}/library`);
};

View File

@@ -0,0 +1,4 @@
<script lang="ts">
// This page redirects to /arr/[id]/library
// See +page.server.ts
</script>

View File

@@ -0,0 +1,5 @@
<script lang="ts">
// Reset to root layout - edit page doesn't need the tabs
</script>
<slot />

View File

@@ -0,0 +1,149 @@
import { error, redirect, fail } from '@sveltejs/kit';
import type { ServerLoad, Actions } from '@sveltejs/kit';
import { arrInstancesQueries } from '$db/queries/arrInstances.ts';
import { logger } from '$logger/logger.ts';
export const load: ServerLoad = ({ params }) => {
const id = parseInt(params.id || '', 10);
// Validate ID
if (isNaN(id)) {
error(404, `Invalid instance ID: ${params.id}`);
}
// Fetch the specific instance
const instance = arrInstancesQueries.getById(id);
if (!instance) {
error(404, `Instance not found: ${id}`);
}
return {
instance
};
};
export const actions: Actions = {
default: async ({ params, request }) => {
const id = parseInt(params.id || '', 10);
// Validate ID
if (isNaN(id)) {
return fail(400, { error: 'Invalid instance ID' });
}
// Fetch the instance to verify it exists
const instance = arrInstancesQueries.getById(id);
if (!instance) {
return fail(404, { error: 'Instance not found' });
}
const formData = await request.formData();
const name = formData.get('name')?.toString().trim();
const url = formData.get('url')?.toString().trim();
const apiKey = formData.get('api_key')?.toString().trim();
const tagsJson = formData.get('tags')?.toString() || '';
const enabled = formData.get('enabled')?.toString() === '1';
// Validate required fields
if (!name) {
return fail(400, { error: 'Name is required' });
}
if (!url) {
return fail(400, { error: 'URL is required' });
}
if (!apiKey) {
return fail(400, { error: 'API Key is required' });
}
// Check for duplicate name
if (arrInstancesQueries.nameExists(name, id)) {
return fail(400, { error: 'An instance with this name already exists' });
}
// Parse tags
let tags: string[] = [];
if (tagsJson) {
try {
tags = JSON.parse(tagsJson);
} catch {
// Ignore parse errors, use empty array
}
}
try {
arrInstancesQueries.update(id, {
name,
url,
apiKey,
tags,
enabled
});
await logger.info(`Updated arr instance: ${name}`, {
source: 'arr/[id]/edit',
meta: { id, name, type: instance.type, url }
});
redirect(303, `/arr/${id}`);
} catch (err) {
// Re-throw redirect errors
if (err && typeof err === 'object' && 'status' in err && 'location' in err) {
throw err;
}
await logger.error('Failed to update arr instance', {
source: 'arr/[id]/edit',
meta: { error: err instanceof Error ? err.message : String(err) }
});
return fail(500, { error: 'Failed to update instance' });
}
},
delete: async ({ params }) => {
const id = parseInt(params.id || '', 10);
// Validate ID
if (isNaN(id)) {
await logger.warn('Delete failed: Invalid instance ID', {
source: 'arr/[id]/edit',
meta: { id: params.id }
});
return fail(400, { error: 'Invalid instance ID' });
}
// Fetch the instance to verify it exists
const instance = arrInstancesQueries.getById(id);
if (!instance) {
await logger.warn('Delete failed: Instance not found', {
source: 'arr/[id]/edit',
meta: { id }
});
return fail(404, { error: 'Instance not found' });
}
// Delete the instance
const deleted = arrInstancesQueries.delete(id);
if (!deleted) {
await logger.error('Failed to delete instance', {
source: 'arr/[id]/edit',
meta: { id, name: instance.name, type: instance.type }
});
return fail(500, { error: 'Failed to delete instance' });
}
await logger.info(`Deleted ${instance.type} instance: ${instance.name}`, {
source: 'arr/[id]/edit',
meta: { id, name: instance.name, type: instance.type, url: instance.url }
});
// Redirect to the arr landing page
redirect(303, '/arr');
}
};

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import InstanceForm from '../../../components/InstanceForm.svelte';
import InstanceForm from '../../components/InstanceForm.svelte';
import type { ActionData, PageData } from './$types';
export let form: ActionData;

View File

@@ -0,0 +1,21 @@
import { error } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
import { arrInstancesQueries } from '$db/queries/arrInstances.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}`);
}
return {
instance
};
};

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
</script>
<svelte:head>
<title>{data.instance.name} - Library - Profilarr</title>
</svelte:head>
<div class="mt-6">
<div class="rounded-lg border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">Library</h2>
<p class="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
View and manage the library for this {data.instance.type} instance.
</p>
<div class="mt-4 text-sm text-neutral-500 dark:text-neutral-400">
Library content coming soon...
</div>
</div>
</div>

View File

@@ -0,0 +1,21 @@
import { error } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
import { arrInstancesQueries } from '$db/queries/arrInstances.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}`);
}
return {
instance
};
};

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
</script>
<svelte:head>
<title>{data.instance.name} - Logs - Profilarr</title>
</svelte:head>
<div class="mt-6">
<div class="rounded-lg border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">Logs</h2>
<p class="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
View sync and activity logs for this {data.instance.type} instance.
</p>
<div class="mt-4 text-sm text-neutral-500 dark:text-neutral-400">
Logs viewer coming soon...
</div>
</div>
</div>

View File

@@ -0,0 +1,21 @@
import { error } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
import { arrInstancesQueries } from '$db/queries/arrInstances.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}`);
}
return {
instance
};
};

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
</script>
<svelte:head>
<title>{data.instance.name} - Search Priority - Profilarr</title>
</svelte:head>
<div class="mt-6">
<div class="rounded-lg border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">Search Priority</h2>
<p class="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
Configure search priority settings for this {data.instance.type} instance.
</p>
<div class="mt-4 text-sm text-neutral-500 dark:text-neutral-400">
Search priority configuration coming soon...
</div>
</div>
</div>

View File

@@ -1,29 +0,0 @@
import { error, redirect } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
import { arrInstancesQueries } from '$db/queries/arrInstances.ts';
// Valid arr types
const VALID_TYPES = ['radarr', 'sonarr', 'lidarr', 'chaptarr'];
export const load: ServerLoad = ({ params }) => {
const type = params.type;
// Validate type
if (!type || !VALID_TYPES.includes(type)) {
error(404, `Invalid arr type: ${type}`);
}
// Fetch instances for this type
const instances = arrInstancesQueries.getByType(type);
// If instances exist, redirect to the first one
if (instances.length > 0) {
redirect(302, `/arr/${type}/${instances[0].id}`);
}
// If no instances, continue to show the page
return {
type,
instances
};
};

View File

@@ -1,19 +0,0 @@
<script lang="ts">
import type { PageData } from './$types';
import { Plus, Inbox } from 'lucide-svelte';
import EmptyState from '$ui/state/EmptyState.svelte';
export let data: PageData;
// Capitalize first letter for display
$: displayName = data.type ? data.type.charAt(0).toUpperCase() + data.type.slice(1) : '';
</script>
<EmptyState
icon={Inbox}
title="No {displayName} instances yet"
description="Get started by adding your first {displayName} instance to begin managing your media library."
buttonText="Add {displayName} Instance"
buttonHref="/arr/new?type={data.type}"
buttonIcon={Plus}
/>

View File

@@ -1,42 +0,0 @@
import { error } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
import { arrInstancesQueries } from '$db/queries/arrInstances.ts';
// Valid arr types
const VALID_TYPES = ['radarr', 'sonarr', 'lidarr', 'chaptarr'];
export const load: ServerLoad = ({ params }) => {
const type = params.type;
const id = parseInt(params.id || '', 10);
// Validate type
if (!type || !VALID_TYPES.includes(type)) {
error(404, `Invalid arr type: ${type}`);
}
// Validate ID
if (isNaN(id)) {
error(404, `Invalid instance ID: ${params.id}`);
}
// Fetch the specific instance
const instance = arrInstancesQueries.getById(id);
if (!instance) {
error(404, `Instance not found: ${id}`);
}
// Verify instance type matches route type
if (instance.type !== type) {
error(404, `Instance ${id} is not a ${type} instance`);
}
// Fetch all instances of this type for the tabs
const allInstances = arrInstancesQueries.getByType(type);
return {
type,
instance,
allInstances
};
};

View File

@@ -1,47 +0,0 @@
<script lang="ts">
import type { PageData } from './$types';
import { Plus, Pencil } from 'lucide-svelte';
import Tabs from '$ui/navigation/tabs/Tabs.svelte';
export let data: PageData;
// Build tabs from instances
const tabs = data.allInstances.map((instance) => ({
label: instance.name,
href: `/arr/${data.type}/${instance.id}`,
active: instance.id === data.instance.id
}));
</script>
<div class="p-8">
<!-- Tabs Section -->
<div class="mb-8">
<Tabs {tabs}>
<svelte:fragment slot="actions">
<a
href="/arr/new?type={data.type}"
class="flex items-center gap-2 border-b-2 border-transparent px-4 py-3 text-sm font-medium text-neutral-600 transition-colors hover:border-neutral-300 hover:text-neutral-900 dark:text-neutral-400 dark:hover:border-neutral-700 dark:hover:text-neutral-50"
>
<Plus size={16} />
Add Instance
</a>
</svelte:fragment>
</Tabs>
</div>
<!-- Content Area -->
<div class="text-neutral-600 dark:text-neutral-400">
<p>Instance content for: {data.instance.name} (ID: {data.instance.id})</p>
<p class="mt-2">URL: {data.instance.url}</p>
<p class="mt-2">Type: {data.instance.type}</p>
</div>
</div>
<!-- Floating Edit Button -->
<a
href="/arr/{data.type}/{data.instance.id}/edit"
class="group fixed right-8 bottom-8 z-50 flex h-12 w-12 items-center justify-center rounded-full border border-neutral-200 bg-white text-neutral-700 shadow-md transition-all hover:scale-110 hover:border-neutral-300 hover:shadow-lg dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:border-neutral-600"
aria-label="Edit instance"
>
<Pencil size={18} class="transition-transform duration-300 group-hover:rotate-12" />
</a>

View File

@@ -1,103 +0,0 @@
import { error, redirect, fail } from '@sveltejs/kit';
import type { ServerLoad, Actions } from '@sveltejs/kit';
import { arrInstancesQueries } from '$db/queries/arrInstances.ts';
import { logger } from '$logger/logger.ts';
// Valid arr types
const VALID_TYPES = ['radarr', 'sonarr', 'lidarr', 'chaptarr'];
export const load: ServerLoad = ({ params }) => {
const type = params.type;
const id = parseInt(params.id || '', 10);
// Validate type
if (!type || !VALID_TYPES.includes(type)) {
error(404, `Invalid arr type: ${type}`);
}
// Validate ID
if (isNaN(id)) {
error(404, `Invalid instance ID: ${params.id}`);
}
// Fetch the specific instance
const instance = arrInstancesQueries.getById(id);
if (!instance) {
error(404, `Instance not found: ${id}`);
}
// Verify instance type matches route type
if (instance.type !== type) {
error(404, `Instance ${id} is not a ${type} instance`);
}
return {
type,
instance
};
};
export const actions: Actions = {
delete: async ({ params }) => {
const type = params.type;
const id = parseInt(params.id || '', 10);
// Validate type
if (!type || !VALID_TYPES.includes(type)) {
await logger.warn('Delete failed: Invalid arr type', {
source: 'arr/[type]/[id]/edit',
meta: { type }
});
return fail(400, { error: 'Invalid arr type' });
}
// Validate ID
if (isNaN(id)) {
await logger.warn('Delete failed: Invalid instance ID', {
source: 'arr/[type]/[id]/edit',
meta: { id: params.id }
});
return fail(400, { error: 'Invalid instance ID' });
}
// Fetch the instance to verify it exists
const instance = arrInstancesQueries.getById(id);
if (!instance) {
await logger.warn('Delete failed: Instance not found', {
source: 'arr/[type]/[id]/edit',
meta: { id, type }
});
return fail(404, { error: 'Instance not found' });
}
// Verify instance type matches route type
if (instance.type !== type) {
await logger.warn('Delete failed: Instance type mismatch', {
source: 'arr/[type]/[id]/edit',
meta: { id, expectedType: type, actualType: instance.type }
});
return fail(400, { error: 'Instance type mismatch' });
}
// Delete the instance
const deleted = arrInstancesQueries.delete(id);
if (!deleted) {
await logger.error('Failed to delete instance', {
source: 'arr/[type]/[id]/edit',
meta: { id, name: instance.name, type: instance.type }
});
return fail(500, { error: 'Failed to delete instance' });
}
await logger.info(`Deleted ${type} instance: ${instance.name}`, {
source: 'arr/[type]/[id]/edit',
meta: { id, name: instance.name, type: instance.type, url: instance.url }
});
// Redirect to the arr type page
redirect(303, `/arr/${type}`);
}
};

View File

@@ -32,6 +32,7 @@
let url = (form as any)?.values?.url ?? (mode === 'edit' ? instance?.url : '') ?? '';
let apiKey = ''; // Never pre-populate API key for security
let tags: string[] = mode === 'edit' && instance ? parseTags(instance.tags) : [];
let enabled: boolean = mode === 'edit' ? Boolean(instance?.enabled) : true;
// Connection test state
type ConnectionStatus = 'idle' | 'testing' | 'success' | 'error';
@@ -185,6 +186,38 @@
</select>
{/if}
</div>
<!-- Enabled Toggle -->
<div class="flex items-center justify-between">
<div>
<label
for="enabled"
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Enabled
</label>
<p class="text-xs text-neutral-500 dark:text-neutral-400">
Disable to exclude this instance from sync operations
</p>
</div>
<button
type="button"
role="switch"
aria-checked={enabled}
aria-label="Toggle enabled status"
on:click={() => (enabled = !enabled)}
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 {enabled
? 'bg-blue-600 dark:bg-blue-500'
: 'bg-neutral-200 dark:bg-neutral-700'}"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {enabled
? 'translate-x-5'
: 'translate-x-0'}"
></span>
</button>
<input type="hidden" name="enabled" value={enabled ? '1' : '0'} />
</div>
</div>
</div>
@@ -312,8 +345,7 @@
<div class="flex flex-wrap items-center justify-end gap-3">
{#if mode === 'edit'}
<a
href="/arr/{type}/{instance?.id}"
data-sveltekit-reload
href="/arr/{instance?.id}"
class="flex items-center gap-2 rounded-lg border border-neutral-300 bg-white px-4 py-2 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
Cancel

View File

@@ -14,6 +14,7 @@ export const actions = {
const url = formData.get('url')?.toString().trim();
const apiKey = formData.get('api_key')?.toString().trim();
const tagsJson = formData.get('tags')?.toString().trim();
const enabled = formData.get('enabled')?.toString() === '1';
// Validation
if (!name || !type || !url || !apiKey) {
@@ -69,14 +70,16 @@ export const actions = {
}
}
let id: number;
try {
// Create the instance
const id = arrInstancesQueries.create({
id = arrInstancesQueries.create({
name,
type,
url,
apiKey,
tags
tags,
enabled
});
await logger.info(`Created new ${type} instance: ${name}`, {
@@ -95,7 +98,7 @@ export const actions = {
});
}
// Redirect to the type page (outside try-catch since redirect throws)
redirect(303, `/arr/${type}`);
// Redirect to the new instance page (outside try-catch since redirect throws)
redirect(303, `/arr/${id}`);
}
} satisfies Actions;