mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-29 22:10:52 +01:00
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:
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
60
src/routes/arr/+page.server.ts
Normal file
60
src/routes/arr/+page.server.ts
Normal 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
188
src/routes/arr/+page.svelte
Normal 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>
|
||||
39
src/routes/arr/[id]/+layout.svelte
Normal file
39
src/routes/arr/[id]/+layout.svelte
Normal 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>
|
||||
7
src/routes/arr/[id]/+page.server.ts
Normal file
7
src/routes/arr/[id]/+page.server.ts
Normal 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`);
|
||||
};
|
||||
4
src/routes/arr/[id]/+page.svelte
Normal file
4
src/routes/arr/[id]/+page.svelte
Normal file
@@ -0,0 +1,4 @@
|
||||
<script lang="ts">
|
||||
// This page redirects to /arr/[id]/library
|
||||
// See +page.server.ts
|
||||
</script>
|
||||
5
src/routes/arr/[id]/edit/+layout@.svelte
Normal file
5
src/routes/arr/[id]/edit/+layout@.svelte
Normal file
@@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
// Reset to root layout - edit page doesn't need the tabs
|
||||
</script>
|
||||
|
||||
<slot />
|
||||
149
src/routes/arr/[id]/edit/+page.server.ts
Normal file
149
src/routes/arr/[id]/edit/+page.server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
21
src/routes/arr/[id]/library/+page.server.ts
Normal file
21
src/routes/arr/[id]/library/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
21
src/routes/arr/[id]/library/+page.svelte
Normal file
21
src/routes/arr/[id]/library/+page.svelte
Normal 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>
|
||||
21
src/routes/arr/[id]/logs/+page.server.ts
Normal file
21
src/routes/arr/[id]/logs/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
21
src/routes/arr/[id]/logs/+page.svelte
Normal file
21
src/routes/arr/[id]/logs/+page.svelte
Normal 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>
|
||||
21
src/routes/arr/[id]/search-priority/+page.server.ts
Normal file
21
src/routes/arr/[id]/search-priority/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
21
src/routes/arr/[id]/search-priority/+page.svelte
Normal file
21
src/routes/arr/[id]/search-priority/+page.svelte
Normal 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>
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user