feat(arr): add logs page for viewing Radarr/Sonarr logs

This commit is contained in:
Sam Chau
2026-01-19 02:39:59 +10:30
parent d11e060c91
commit 9b2f0d393c
4 changed files with 415 additions and 6 deletions

View File

@@ -9,7 +9,10 @@ import type {
ArrCustomFormat,
ArrQualityProfilePayload,
RadarrQualityProfile,
ArrCommand
ArrCommand,
ArrLogResponse,
ArrLogFile,
ArrLogParams
} from './types.ts';
import { logger } from '$logger/logger.ts';
@@ -306,4 +309,45 @@ export class BaseArrClient extends BaseHttpClient {
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
}
// =========================================================================
// Log Methods
// =========================================================================
/**
* Get paginated logs from the arr instance
* @param params - Query parameters for filtering and pagination
*/
getLogs(params: ArrLogParams = {}): Promise<ArrLogResponse> {
const queryParams = new URLSearchParams();
if (params.page !== undefined) queryParams.set('page', String(params.page));
if (params.pageSize !== undefined) queryParams.set('pageSize', String(params.pageSize));
if (params.sortKey) queryParams.set('sortKey', params.sortKey);
if (params.sortDirection) queryParams.set('sortDirection', params.sortDirection);
if (params.level) queryParams.set('level', params.level);
const queryString = queryParams.toString();
const url = `/api/${this.apiVersion}/log${queryString ? `?${queryString}` : ''}`;
return this.get<ArrLogResponse>(url);
}
/**
* Get list of available log files
*/
getLogFiles(): Promise<ArrLogFile[]> {
return this.get<ArrLogFile[]>(`/api/${this.apiVersion}/log/file`);
}
/**
* Get raw content of a specific log file
* @param filename - The log filename (e.g., "radarr.txt", "sonarr.debug.0.txt")
* @returns Raw log file content as text
*/
getLogFileContent(filename: string): Promise<string> {
return this.get<string>(`/api/${this.apiVersion}/log/file/${filename}`, {
responseType: 'text'
});
}
}

View File

@@ -648,3 +648,66 @@ export interface ArrSystemStatus {
packageUpdateMechanism: 'builtIn' | string;
packageUpdateMechanismMessage: string;
}
// =============================================================================
// Log Types
// =============================================================================
/**
* Log level for filtering
* API accepts: Trace, Debug, Info, Warn, Error, Fatal
*/
export type ArrLogLevel = 'Trace' | 'Debug' | 'Info' | 'Warn' | 'Error' | 'Fatal';
/**
* Sort direction for log queries
*/
export type ArrSortDirection = 'ascending' | 'descending' | 'default';
/**
* Log entry from /api/v3/log
*/
export interface ArrLogEntry {
id: number;
time: string; // ISO 8601 UTC
level: string; // lowercase: "info", "warn", "error", "debug", "trace", "fatal"
logger: string; // source/component: "RssSyncService", "DiskScanService"
message: string;
exception?: string | null;
exceptionType?: string | null;
method?: string | null;
}
/**
* Paginated log response from /api/v3/log
*/
export interface ArrLogResponse {
page: number;
pageSize: number;
sortKey: string;
sortDirection: string;
totalRecords: number;
records: ArrLogEntry[];
}
/**
* Log file metadata from /api/v3/log/file
*/
export interface ArrLogFile {
id: number;
filename: string;
lastWriteTime: string; // ISO 8601
contentsUrl: string;
downloadUrl: string;
}
/**
* Parameters for fetching logs
*/
export interface ArrLogParams {
page?: number;
pageSize?: number;
sortKey?: string;
sortDirection?: ArrSortDirection;
level?: ArrLogLevel;
}

View File

@@ -1,8 +1,10 @@
import { error } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
import { arrInstancesQueries } from '$db/queries/arrInstances.ts';
import { createArrClient } from '$arr/factory.ts';
import type { ArrType } from '$arr/types.ts';
export const load: ServerLoad = async ({ params }) => {
export const load: ServerLoad = async ({ params, url }) => {
const id = parseInt(params.id || '', 10);
if (isNaN(id)) {
@@ -15,7 +17,34 @@ export const load: ServerLoad = async ({ params }) => {
error(404, `Instance not found: ${id}`);
}
return {
instance
};
// Parse query params for pagination/filtering
const page = parseInt(url.searchParams.get('page') || '1', 10);
const pageSize = parseInt(url.searchParams.get('pageSize') || '50', 10);
const level = url.searchParams.get('level') || undefined;
const client = createArrClient(instance.type as ArrType, instance.url, instance.api_key);
try {
const logs = await client.getLogs({
page,
pageSize,
sortKey: 'time',
sortDirection: 'descending',
level: level as 'Trace' | 'Debug' | 'Info' | 'Warn' | 'Error' | 'Fatal' | undefined
});
return {
instance,
logs,
filters: {
page,
pageSize,
level
}
};
} catch (err) {
error(500, `Failed to fetch logs: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
client.close();
}
};

View File

@@ -1,7 +1,148 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { Copy, RefreshCw, Filter, ChevronLeft, ChevronRight, Rows3 } from 'lucide-svelte';
import { alertStore } from '$alerts/store';
import Table from '$ui/table/Table.svelte';
import TableActionButton from '$ui/table/TableActionButton.svelte';
import ActionsBar from '$ui/actions/ActionsBar.svelte';
import SearchAction from '$ui/actions/SearchAction.svelte';
import ActionButton from '$ui/actions/ActionButton.svelte';
import Dropdown from '$ui/dropdown/Dropdown.svelte';
import NumberInput from '$ui/form/NumberInput.svelte';
import type { Column } from '$ui/table/types';
import { createSearchStore } from '$lib/client/stores/search';
import type { PageData } from './$types';
export let data: PageData;
interface LogEntry {
id: number;
time: string;
level: string;
logger: string;
message: string;
exception?: string | null;
}
// Initialize search store
const searchStore = createSearchStore({ debounceMs: 300 });
// Filter state
let selectedLevel: string = data.filters.level || 'ALL';
let pageSize: number = data.filters.pageSize;
let isRefreshing = false;
const logLevels = ['ALL', 'Trace', 'Debug', 'Info', 'Warn', 'Error', 'Fatal'] as const;
// Level colors matching arr log levels
const levelColors: Record<string, string> = {
Trace: 'text-neutral-500 dark:text-neutral-500',
Debug: 'text-cyan-600 dark:text-cyan-400',
Info: 'text-green-600 dark:text-green-400',
Warn: 'text-yellow-600 dark:text-yellow-400',
Error: 'text-red-600 dark:text-red-400',
Fatal: 'text-red-800 dark:text-red-300'
};
// Table columns
const columns: Column<LogEntry>[] = [
{
key: 'time',
header: 'Time',
width: '180px',
cell: (row) => ({
html: `<span class="font-mono text-xs text-neutral-600 dark:text-neutral-400">${new Date(row.time).toLocaleString()}</span>`
})
},
{
key: 'level',
header: 'Level',
width: '80px',
cell: (row) => ({
html: `<span class="font-semibold ${levelColors[row.level] || 'text-neutral-600 dark:text-neutral-400'}">${row.level}</span>`
})
},
{
key: 'logger',
header: 'Logger',
width: '200px',
cell: (row) => ({
html: `<span class="font-mono text-xs text-neutral-500 dark:text-neutral-500">${row.logger}</span>`
})
},
{
key: 'message',
header: 'Message',
cell: (row) => row.message
}
];
// Copy log entry to clipboard
async function copyLog(log: LogEntry) {
const logText = `[${log.time}] ${log.level} [${log.logger}] ${log.message}${log.exception ? `\nException: ${log.exception}` : ''}`;
try {
await navigator.clipboard.writeText(logText);
alertStore.add('success', 'Log entry copied to clipboard');
} catch {
alertStore.add('error', 'Failed to copy to clipboard');
}
}
// Navigate with updated params
function updateParams(params: Record<string, string | number | undefined>) {
const url = new URL($page.url);
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === 'ALL') {
url.searchParams.delete(key);
} else {
url.searchParams.set(key, String(value));
}
}
goto(url.toString(), { invalidateAll: true });
}
// Refresh logs
async function refreshLogs() {
isRefreshing = true;
await goto($page.url.toString(), { invalidateAll: true });
isRefreshing = false;
}
// Change level filter
function changeLevel(level: string) {
selectedLevel = level;
updateParams({ level: level === 'ALL' ? undefined : level, page: 1 });
}
// Change page size
function changePageSize(newSize: number) {
pageSize = newSize;
updateParams({ pageSize: newSize, page: 1 });
}
// Pagination
function goToPage(pageNum: number) {
updateParams({ page: pageNum });
}
// Client-side search filter
$: filteredLogs = data.logs.records.filter((log) => {
const query = $searchStore.query;
if (!query) return true;
const searchLower = query.toLowerCase();
return (
log.message.toLowerCase().includes(searchLower) ||
log.logger.toLowerCase().includes(searchLower)
);
});
// Pagination info
$: totalPages = Math.ceil(data.logs.totalRecords / data.logs.pageSize);
$: currentPage = data.logs.page;
</script>
<svelte:head>
@@ -9,5 +150,137 @@
</svelte:head>
<div class="mt-6">
<!-- TODO: New logs page content -->
<!-- Actions Bar -->
<ActionsBar className="justify-end">
<SearchAction {searchStore} placeholder="Search logs..." />
<!-- Level Filter -->
<ActionButton icon={Filter} hasDropdown={true} dropdownPosition="right">
<svelte:fragment slot="dropdown" let:dropdownPosition>
<Dropdown position={dropdownPosition} minWidth="8rem">
{#each logLevels as level}
<button
type="button"
on:click={() => changeLevel(level)}
class="flex w-full items-center justify-between gap-3 border-b border-neutral-200 px-4 py-2 text-left transition-colors first:rounded-t-lg last:rounded-b-lg last:border-b-0 dark:border-neutral-700
{selectedLevel === level
? 'bg-neutral-100 dark:bg-neutral-700'
: 'hover:bg-neutral-100 dark:hover:bg-neutral-700'}"
>
<span class="font-medium {level === 'ALL' ? 'text-neutral-600 dark:text-neutral-400' : levelColors[level]}">{level}</span>
</button>
{/each}
</Dropdown>
</svelte:fragment>
</ActionButton>
<!-- Page Size -->
<ActionButton icon={Rows3} hasDropdown={true} dropdownPosition="right">
<svelte:fragment slot="dropdown" let:dropdownPosition>
<Dropdown position={dropdownPosition} minWidth="10rem">
<div class="p-3">
<label for="pageSize" class="mb-2 block text-xs font-medium text-neutral-600 dark:text-neutral-400">
Rows per page
</label>
<NumberInput
name="pageSize"
bind:value={pageSize}
min={10}
max={1000}
step={10}
onchange={changePageSize}
/>
</div>
</Dropdown>
</svelte:fragment>
</ActionButton>
<!-- Refresh -->
<ActionButton hasDropdown={true} dropdownPosition="right" on:click={refreshLogs}>
<RefreshCw
size={20}
class="text-neutral-700 dark:text-neutral-300 {isRefreshing ? 'animate-spin' : ''}"
/>
<svelte:fragment slot="dropdown" let:dropdownPosition>
<Dropdown position={dropdownPosition} minWidth="6rem">
<div class="px-3 py-2 text-sm text-neutral-600 dark:text-neutral-400">
Refresh logs
</div>
</Dropdown>
</svelte:fragment>
</ActionButton>
</ActionsBar>
<!-- Stats -->
<div class="mt-6 mb-4 flex items-center justify-between text-sm text-neutral-600 dark:text-neutral-400">
<span>
Showing {filteredLogs.length} of {data.logs.totalRecords} logs
{#if selectedLevel !== 'ALL'}
(filtered by {selectedLevel})
{/if}
</span>
<!-- Pagination -->
{#if totalPages > 1}
<div class="flex items-center gap-2">
<button
type="button"
disabled={currentPage <= 1}
on:click={() => goToPage(currentPage - 1)}
class="rounded p-1 transition-colors hover:bg-neutral-200 disabled:opacity-50 disabled:cursor-not-allowed dark:hover:bg-neutral-700"
>
<ChevronLeft size={20} />
</button>
<span class="text-sm">
Page {currentPage} of {totalPages}
</span>
<button
type="button"
disabled={currentPage >= totalPages}
on:click={() => goToPage(currentPage + 1)}
class="rounded p-1 transition-colors hover:bg-neutral-200 disabled:opacity-50 disabled:cursor-not-allowed dark:hover:bg-neutral-700"
>
<ChevronRight size={20} />
</button>
</div>
{/if}
</div>
<!-- Log Table -->
<Table data={filteredLogs} {columns} emptyMessage="No logs found" hoverable={true} compact={true}>
<svelte:fragment slot="actions" let:row>
<div class="flex items-center justify-end gap-1">
<TableActionButton
icon={Copy}
title="Copy log entry"
on:click={() => copyLog(row)}
/>
</div>
</svelte:fragment>
</Table>
<!-- Bottom Pagination -->
{#if totalPages > 1}
<div class="mt-4 flex items-center justify-center gap-2">
<button
type="button"
disabled={currentPage <= 1}
on:click={() => goToPage(currentPage - 1)}
class="rounded px-3 py-1.5 text-sm transition-colors hover:bg-neutral-200 disabled:opacity-50 disabled:cursor-not-allowed dark:hover:bg-neutral-700"
>
Previous
</button>
<span class="text-sm text-neutral-600 dark:text-neutral-400">
Page {currentPage} of {totalPages}
</span>
<button
type="button"
disabled={currentPage >= totalPages}
on:click={() => goToPage(currentPage + 1)}
class="rounded px-3 py-1.5 text-sm transition-colors hover:bg-neutral-200 disabled:opacity-50 disabled:cursor-not-allowed dark:hover:bg-neutral-700"
>
Next
</button>
</div>
{/if}
</div>