feat(admin): runtime-editable crawler & analysis config in the dashboard
Move crawler and analysis configuration out of boot-only env vars and into
a DB-backed, admin-editable surface applied live without a restart.
- New `app_settings` table (migration 0026) holds one JSONB row per group;
env vars seed it on first boot, then the DB is the source of truth.
- `settings.rs` adds serializable Crawler/Analysis DTOs with env-base +
DB-overlay conversion and field-level validation; env-only/secret fields
(browser, proxy, TOR, vision API key, PHPSESSID) are never persisted.
- `GET/PUT /api/v1/admin/settings/{crawler,analysis}` (RequireAdmin,
cookie-only, CSRF-guarded): GET returns the editable DTO + a read-only
env-managed view + prompt defaults; PUT validates (422 + per-field
details), persists + audits in one tx, then gracefully respawns the
affected daemon via a Supervisor.
- AppState swaps the crawler control/resync handles and analysis enable
gate behind shared runtime cells so reloads take effect with no restart;
a boot-time spawn failure is logged rather than aborting startup.
- Make the three vision prompts and sampling temperature configurable
(defaults preserved); cookie_domain is admin-editable too.
- Frontend: tabbed /admin/settings page with grouped fieldsets, prompt
reset-to-default, env-managed read-only panel, save-&-apply confirm, and
per-field validation; typed client + ApiError.details.
- Bump version 0.79.1 -> 0.80.0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -556,3 +556,110 @@ export type AnalysisEvent =
|
||||
export function analysisStatusStreamUrl(): string {
|
||||
return apiUrl('/v1/admin/analysis/status/stream');
|
||||
}
|
||||
|
||||
// ---- runtime settings (crawler + analysis) ---------------------------------
|
||||
|
||||
/** Operationally-safe, admin-editable crawler settings. Host/infra and
|
||||
* session/secret fields are managed via environment and surfaced read-only
|
||||
* in {@link CrawlerEnvOnly}. */
|
||||
export type CrawlerSettings = {
|
||||
daemon_enabled: boolean;
|
||||
daily_at: string; // "HH:MM"
|
||||
tz: string; // IANA
|
||||
idle_timeout_secs: number;
|
||||
chapter_workers: number;
|
||||
retention_days: number;
|
||||
start_url: string | null;
|
||||
rate_ms: number;
|
||||
cdn_host: string | null;
|
||||
cdn_rate_ms: number;
|
||||
cookie_domain: string | null;
|
||||
user_agent: string | null;
|
||||
download_allowlist: string[];
|
||||
allow_any_host: boolean;
|
||||
max_image_bytes: number;
|
||||
manga_limit: number;
|
||||
job_timeout_secs: number;
|
||||
metadata_max_consecutive_failures: number;
|
||||
browser_restart_threshold: number;
|
||||
};
|
||||
|
||||
/** Read-only view of crawler fields managed via environment. */
|
||||
export type CrawlerEnvOnly = {
|
||||
browser_mode: string;
|
||||
browser_args: string[];
|
||||
proxy: string | null;
|
||||
tor_control_url: string | null;
|
||||
tor_credentials_configured: boolean;
|
||||
session_configured: boolean;
|
||||
};
|
||||
|
||||
export type CrawlerSettingsResponse = {
|
||||
editable: CrawlerSettings;
|
||||
env_only: CrawlerEnvOnly;
|
||||
};
|
||||
|
||||
/** Admin-editable analysis settings. Prompts are `null` to mean "use the
|
||||
* compiled default" (see {@link PromptDefaults}). The vision API key is
|
||||
* env-only and never returned. */
|
||||
export type AnalysisSettings = {
|
||||
enabled: boolean;
|
||||
workers: number;
|
||||
endpoint: string;
|
||||
model: string;
|
||||
request_timeout_secs: number;
|
||||
job_timeout_secs: number;
|
||||
max_tokens: number;
|
||||
max_pixels: number;
|
||||
min_slice_height: number;
|
||||
slice_overlap: number;
|
||||
tall_aspect_threshold: number;
|
||||
max_slices: number;
|
||||
max_image_bytes: number;
|
||||
response_format: 'json_schema' | 'json_object' | 'none';
|
||||
frequency_penalty: number;
|
||||
temperature: number;
|
||||
system_prompt: string | null;
|
||||
ocr_prompt: string | null;
|
||||
grounding_prompt: string | null;
|
||||
};
|
||||
|
||||
export type PromptDefaults = {
|
||||
system_prompt: string;
|
||||
ocr_prompt: string;
|
||||
grounding_prompt: string;
|
||||
};
|
||||
|
||||
export type AnalysisSettingsResponse = {
|
||||
editable: AnalysisSettings;
|
||||
env_only: { api_key_configured: boolean };
|
||||
prompt_defaults: PromptDefaults;
|
||||
};
|
||||
|
||||
export async function getCrawlerSettings(): Promise<CrawlerSettingsResponse> {
|
||||
return request<CrawlerSettingsResponse>('/v1/admin/settings/crawler');
|
||||
}
|
||||
|
||||
export async function updateCrawlerSettings(
|
||||
settings: CrawlerSettings
|
||||
): Promise<CrawlerSettingsResponse> {
|
||||
return request<CrawlerSettingsResponse>('/v1/admin/settings/crawler', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAnalysisSettings(): Promise<AnalysisSettingsResponse> {
|
||||
return request<AnalysisSettingsResponse>('/v1/admin/settings/analysis');
|
||||
}
|
||||
|
||||
export async function updateAnalysisSettings(
|
||||
settings: AnalysisSettings
|
||||
): Promise<AnalysisSettingsResponse> {
|
||||
return request<AnalysisSettingsResponse>('/v1/admin/settings/analysis', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,14 +25,19 @@ export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly code: string,
|
||||
message: string
|
||||
message: string,
|
||||
/** The error envelope's `details` payload, when present (e.g. the
|
||||
* per-field `{ fields: [...] }` of a `validation_failed` response). */
|
||||
public readonly details?: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
type ErrorEnvelope = { error?: { code?: unknown; message?: unknown } };
|
||||
type ErrorEnvelope = {
|
||||
error?: { code?: unknown; message?: unknown; details?: unknown };
|
||||
};
|
||||
|
||||
/**
|
||||
* Optional hook fired the first moment `request()` observes a 401 on
|
||||
@@ -59,6 +64,7 @@ export async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
if (!res.ok) {
|
||||
let code = 'http_error';
|
||||
let message = `${res.status} ${res.statusText}`;
|
||||
let details: unknown;
|
||||
const ct = res.headers.get('content-type') ?? '';
|
||||
try {
|
||||
if (ct.includes('application/json')) {
|
||||
@@ -70,6 +76,9 @@ export async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
if (typeof body.error.message === 'string' && body.error.message) {
|
||||
message = body.error.message;
|
||||
}
|
||||
if (body.error.details !== undefined) {
|
||||
details = body.error.details;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const text = await res.text();
|
||||
@@ -88,7 +97,7 @@ export async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
console.error('on401 hook threw:', e);
|
||||
}
|
||||
}
|
||||
throw new ApiError(res.status, code, message);
|
||||
throw new ApiError(res.status, code, message, details);
|
||||
}
|
||||
// Any empty body (not just 204) returns undefined — the manga-add
|
||||
// endpoint, for instance, signals create-vs-already-present via
|
||||
|
||||
167
frontend/src/lib/api/settings.test.ts
Normal file
167
frontend/src/lib/api/settings.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type MockInstance
|
||||
} from 'vitest';
|
||||
import {
|
||||
getCrawlerSettings,
|
||||
updateCrawlerSettings,
|
||||
getAnalysisSettings,
|
||||
updateAnalysisSettings,
|
||||
type CrawlerSettings,
|
||||
type AnalysisSettings
|
||||
} from './admin';
|
||||
import { ApiError } from './client';
|
||||
|
||||
function ok(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const crawlerFixture: CrawlerSettings = {
|
||||
daemon_enabled: true,
|
||||
daily_at: '00:00',
|
||||
tz: 'UTC',
|
||||
idle_timeout_secs: 600,
|
||||
chapter_workers: 1,
|
||||
retention_days: 7,
|
||||
start_url: null,
|
||||
rate_ms: 1000,
|
||||
cdn_host: null,
|
||||
cdn_rate_ms: 1000,
|
||||
cookie_domain: null,
|
||||
user_agent: null,
|
||||
download_allowlist: [],
|
||||
allow_any_host: false,
|
||||
max_image_bytes: 33554432,
|
||||
manga_limit: 0,
|
||||
job_timeout_secs: 600,
|
||||
metadata_max_consecutive_failures: 10,
|
||||
browser_restart_threshold: 3
|
||||
};
|
||||
|
||||
const analysisFixture: AnalysisSettings = {
|
||||
enabled: false,
|
||||
workers: 1,
|
||||
endpoint: 'http://localhost:8000/v1/chat/completions',
|
||||
model: '',
|
||||
request_timeout_secs: 120,
|
||||
job_timeout_secs: 600,
|
||||
max_tokens: 4096,
|
||||
max_pixels: 1000000,
|
||||
min_slice_height: 640,
|
||||
slice_overlap: 0.12,
|
||||
tall_aspect_threshold: 1.6,
|
||||
max_slices: 16,
|
||||
max_image_bytes: 8388608,
|
||||
response_format: 'json_schema',
|
||||
frequency_penalty: 0.3,
|
||||
temperature: 0,
|
||||
system_prompt: null,
|
||||
ocr_prompt: null,
|
||||
grounding_prompt: null
|
||||
};
|
||||
|
||||
describe('settings api client', () => {
|
||||
let fetchSpy: MockInstance<typeof globalThis.fetch>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('getCrawlerSettings GETs the crawler settings endpoint', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({
|
||||
editable: crawlerFixture,
|
||||
env_only: {
|
||||
browser_mode: 'headless',
|
||||
browser_args: [],
|
||||
proxy: null,
|
||||
tor_control_url: null,
|
||||
tor_credentials_configured: false,
|
||||
session_configured: false
|
||||
}
|
||||
})
|
||||
);
|
||||
const r = await getCrawlerSettings();
|
||||
expect(r.editable.rate_ms).toBe(1000);
|
||||
expect(r.env_only.browser_mode).toBe('headless');
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/admin\/settings\/crawler$/);
|
||||
});
|
||||
|
||||
it('updateCrawlerSettings PUTs the body as JSON', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({
|
||||
editable: { ...crawlerFixture, rate_ms: 2000 },
|
||||
env_only: {
|
||||
browser_mode: 'headless',
|
||||
browser_args: [],
|
||||
proxy: null,
|
||||
tor_control_url: null,
|
||||
tor_credentials_configured: false,
|
||||
session_configured: false
|
||||
}
|
||||
})
|
||||
);
|
||||
const r = await updateCrawlerSettings({ ...crawlerFixture, rate_ms: 2000 });
|
||||
expect(r.editable.rate_ms).toBe(2000);
|
||||
const init = fetchSpy.mock.calls[0][1]!;
|
||||
expect(init.method).toBe('PUT');
|
||||
expect(JSON.parse(init.body as string).rate_ms).toBe(2000);
|
||||
});
|
||||
|
||||
it('getAnalysisSettings returns prompt defaults and the no-secret indicator', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({
|
||||
editable: analysisFixture,
|
||||
env_only: { api_key_configured: true },
|
||||
prompt_defaults: {
|
||||
system_prompt: 'SYS',
|
||||
ocr_prompt: 'OCR',
|
||||
grounding_prompt: 'GND'
|
||||
}
|
||||
})
|
||||
);
|
||||
const r = await getAnalysisSettings();
|
||||
expect(r.editable.system_prompt).toBeNull();
|
||||
expect(r.env_only.api_key_configured).toBe(true);
|
||||
expect(r.prompt_defaults.system_prompt).toBe('SYS');
|
||||
// The secret is never present on the editable DTO.
|
||||
expect('api_key' in (r.editable as object)).toBe(false);
|
||||
});
|
||||
|
||||
it('updateAnalysisSettings surfaces 422 field errors via ApiError.details', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: 'validation_failed',
|
||||
message: 'invalid settings',
|
||||
details: { fields: [{ field: 'workers', message: 'must be at least 1' }] }
|
||||
}
|
||||
}),
|
||||
{ status: 422, headers: { 'content-type': 'application/json' } }
|
||||
)
|
||||
);
|
||||
try {
|
||||
await updateAnalysisSettings({ ...analysisFixture, workers: 0 });
|
||||
expect.unreachable('should have thrown');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(ApiError);
|
||||
expect((e as ApiError).status).toBe(422);
|
||||
expect((e as ApiError).code).toBe('validation_failed');
|
||||
const details = (e as ApiError).details as { fields: { field: string }[] };
|
||||
expect(details.fields[0].field).toBe('workers');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@
|
||||
{ href: '/admin/mangas', label: 'Mangas' },
|
||||
{ href: '/admin/crawler', label: 'Crawler' },
|
||||
{ href: '/admin/analysis', label: 'Analysis' },
|
||||
{ href: '/admin/settings', label: 'Settings' },
|
||||
{ href: '/admin/system', label: 'System' }
|
||||
];
|
||||
</script>
|
||||
|
||||
664
frontend/src/routes/admin/settings/+page.svelte
Normal file
664
frontend/src/routes/admin/settings/+page.svelte
Normal file
@@ -0,0 +1,664 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import { ApiError } from '$lib/api/client';
|
||||
import {
|
||||
getCrawlerSettings,
|
||||
updateCrawlerSettings,
|
||||
getAnalysisSettings,
|
||||
updateAnalysisSettings,
|
||||
type CrawlerSettings,
|
||||
type CrawlerEnvOnly,
|
||||
type AnalysisSettings,
|
||||
type PromptDefaults
|
||||
} from '$lib/api/admin';
|
||||
|
||||
type Tab = 'crawler' | 'analysis';
|
||||
let tab = $state<Tab>('crawler');
|
||||
|
||||
// Crawler state
|
||||
let crawler = $state<CrawlerSettings | null>(null);
|
||||
let crawlerEnv = $state<CrawlerEnvOnly | null>(null);
|
||||
let allowlistText = $state(''); // textarea mirror of download_allowlist
|
||||
|
||||
// Analysis state
|
||||
let analysis = $state<AnalysisSettings | null>(null);
|
||||
let promptDefaults = $state<PromptDefaults | null>(null);
|
||||
let apiKeyConfigured = $state(false);
|
||||
|
||||
let loading = $state(true);
|
||||
let loadError = $state<string | null>(null);
|
||||
let saving = $state(false);
|
||||
let saveError = $state<string | null>(null);
|
||||
let success = $state<string | null>(null);
|
||||
// field -> message, parsed from a 422 validation_failed response
|
||||
let fieldErrors = $state<Record<string, string>>({});
|
||||
let confirmOpen = $state(false);
|
||||
|
||||
onMount(load);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
loadError = null;
|
||||
try {
|
||||
const [c, a] = await Promise.all([getCrawlerSettings(), getAnalysisSettings()]);
|
||||
crawler = c.editable;
|
||||
crawlerEnv = c.env_only;
|
||||
allowlistText = c.editable.download_allowlist.join('\n');
|
||||
analysis = a.editable;
|
||||
promptDefaults = a.prompt_defaults;
|
||||
apiKeyConfigured = a.env_only.api_key_configured;
|
||||
} catch (e) {
|
||||
loadError = e instanceof Error ? e.message : 'Failed to load settings.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function parseFieldErrors(e: unknown): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (
|
||||
e instanceof ApiError &&
|
||||
e.details &&
|
||||
typeof e.details === 'object' &&
|
||||
'fields' in e.details
|
||||
) {
|
||||
const fields = (e.details as { fields?: { field: string; message: string }[] }).fields;
|
||||
for (const f of fields ?? []) out[f.field] = f.message;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function askSave() {
|
||||
saveError = null;
|
||||
success = null;
|
||||
fieldErrors = {};
|
||||
confirmOpen = true;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
confirmOpen = false;
|
||||
saving = true;
|
||||
saveError = null;
|
||||
success = null;
|
||||
fieldErrors = {};
|
||||
try {
|
||||
if (tab === 'crawler' && crawler) {
|
||||
// Sync the allowlist textarea back into the model.
|
||||
crawler.download_allowlist = allowlistText
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const r = await updateCrawlerSettings(crawler);
|
||||
crawler = r.editable;
|
||||
crawlerEnv = r.env_only;
|
||||
allowlistText = r.editable.download_allowlist.join('\n');
|
||||
} else if (tab === 'analysis' && analysis) {
|
||||
const r = await updateAnalysisSettings(analysis);
|
||||
analysis = r.editable;
|
||||
promptDefaults = r.prompt_defaults;
|
||||
apiKeyConfigured = r.env_only.api_key_configured;
|
||||
}
|
||||
success = 'Saved and applied. The subsystem was restarted with the new settings.';
|
||||
} catch (e) {
|
||||
fieldErrors = parseFieldErrors(e);
|
||||
saveError =
|
||||
Object.keys(fieldErrors).length > 0
|
||||
? 'Some fields need fixing.'
|
||||
: e instanceof Error
|
||||
? e.message
|
||||
: 'Save failed.';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetPrompt(which: 'system_prompt' | 'ocr_prompt' | 'grounding_prompt') {
|
||||
if (analysis) analysis[which] = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>Settings</h1>
|
||||
<p class="lead muted">
|
||||
Edit crawler and analysis configuration. Saving applies immediately — the affected daemon is
|
||||
gracefully restarted, so in-flight jobs are retried rather than lost. Host-level and secret
|
||||
values stay environment-managed and are shown read-only.
|
||||
</p>
|
||||
|
||||
<div class="tabs" role="tablist">
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={tab === 'crawler'}
|
||||
class:active={tab === 'crawler'}
|
||||
onclick={() => (tab = 'crawler')}
|
||||
data-testid="settings-tab-crawler">Crawler</button
|
||||
>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={tab === 'analysis'}
|
||||
class:active={tab === 'analysis'}
|
||||
onclick={() => (tab = 'analysis')}
|
||||
data-testid="settings-tab-analysis">Analysis</button
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="muted">Loading…</p>
|
||||
{:else if loadError}
|
||||
<p class="error" role="alert">{loadError}</p>
|
||||
{:else}
|
||||
<div class="sticky-bar">
|
||||
{#if tab === 'crawler' && crawler}
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={crawler.daemon_enabled}
|
||||
data-testid="settings-crawler-enabled"
|
||||
/>
|
||||
<span>Crawler daemon {crawler.daemon_enabled ? 'enabled' : 'disabled'}</span>
|
||||
</label>
|
||||
{:else if tab === 'analysis' && analysis}
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={analysis.enabled}
|
||||
data-testid="settings-analysis-enabled"
|
||||
/>
|
||||
<span>Analysis worker {analysis.enabled ? 'enabled' : 'disabled'}</span>
|
||||
</label>
|
||||
{/if}
|
||||
<div class="spacer"></div>
|
||||
<button class="primary" disabled={saving} onclick={askSave} data-testid="settings-save">
|
||||
{saving ? 'Saving…' : 'Save & apply'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if success}<p class="notice" role="status">{success}</p>{/if}
|
||||
{#if saveError}<p class="error" role="alert">{saveError}</p>{/if}
|
||||
|
||||
{#if tab === 'crawler' && crawler}
|
||||
<div class="form">
|
||||
<fieldset>
|
||||
<legend>Schedule</legend>
|
||||
<label class="field">
|
||||
<span>Daily metadata pass (HH:MM)</span>
|
||||
<input type="time" bind:value={crawler.daily_at} />
|
||||
{#if fieldErrors.daily_at}<small class="fe">{fieldErrors.daily_at}</small>{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Timezone (IANA)</span>
|
||||
<input type="text" bind:value={crawler.tz} placeholder="UTC" />
|
||||
{#if fieldErrors.tz}<small class="fe">{fieldErrors.tz}</small>{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Idle timeout (seconds)</span>
|
||||
<input type="number" min="0" bind:value={crawler.idle_timeout_secs} />
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Source</legend>
|
||||
<label class="field">
|
||||
<span>Start URL</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={crawler.start_url}
|
||||
placeholder="https://source.example/"
|
||||
/>
|
||||
{#if fieldErrors.start_url}<small class="fe">{fieldErrors.start_url}</small>{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>CDN host</span>
|
||||
<input type="text" bind:value={crawler.cdn_host} placeholder="cdn.example" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Cookie domain</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={crawler.cookie_domain}
|
||||
placeholder="source.example"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>User-Agent</span>
|
||||
<input type="text" bind:value={crawler.user_agent} />
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Rate limiting</legend>
|
||||
<label class="field">
|
||||
<span>Request interval (ms)</span>
|
||||
<input type="number" min="0" bind:value={crawler.rate_ms} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>CDN request interval (ms)</span>
|
||||
<input type="number" min="0" bind:value={crawler.cdn_rate_ms} />
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Workers & retention</legend>
|
||||
<label class="field">
|
||||
<span>Chapter workers</span>
|
||||
<input type="number" min="1" bind:value={crawler.chapter_workers} />
|
||||
{#if fieldErrors.chapter_workers}<small class="fe"
|
||||
>{fieldErrors.chapter_workers}</small
|
||||
>{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Job retention (days)</span>
|
||||
<input type="number" min="0" bind:value={crawler.retention_days} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Manga limit per pass (0 = unlimited)</span>
|
||||
<input type="number" min="0" bind:value={crawler.manga_limit} />
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Download safety</legend>
|
||||
<label class="field-inline">
|
||||
<input type="checkbox" bind:checked={crawler.allow_any_host} />
|
||||
<span>Allow any host (bypass allowlist; still blocks private IPs)</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Download allowlist (one host per line)</span>
|
||||
<textarea
|
||||
rows="4"
|
||||
bind:value={allowlistText}
|
||||
disabled={crawler.allow_any_host}
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Max image bytes</span>
|
||||
<input type="number" min="0" bind:value={crawler.max_image_bytes} />
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Reliability</legend>
|
||||
<label class="field">
|
||||
<span>Chapter job timeout (seconds)</span>
|
||||
<input type="number" min="1" bind:value={crawler.job_timeout_secs} />
|
||||
{#if fieldErrors.job_timeout_secs}<small class="fe"
|
||||
>{fieldErrors.job_timeout_secs}</small
|
||||
>{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Metadata max consecutive failures</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
bind:value={crawler.metadata_max_consecutive_failures}
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Browser restart threshold</span>
|
||||
<input type="number" min="0" bind:value={crawler.browser_restart_threshold} />
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
{#if crawlerEnv}
|
||||
<fieldset class="env">
|
||||
<legend>Managed via environment (read-only)</legend>
|
||||
<dl>
|
||||
<dt>Browser mode</dt>
|
||||
<dd>{crawlerEnv.browser_mode}</dd>
|
||||
<dt>Browser args</dt>
|
||||
<dd>{crawlerEnv.browser_args.join(' ') || '—'}</dd>
|
||||
<dt>Proxy</dt>
|
||||
<dd>{crawlerEnv.proxy ?? '—'}</dd>
|
||||
<dt>TOR control URL</dt>
|
||||
<dd>{crawlerEnv.tor_control_url ?? '—'}</dd>
|
||||
<dt>TOR credentials</dt>
|
||||
<dd>{crawlerEnv.tor_credentials_configured ? 'configured' : '—'}</dd>
|
||||
<dt>Initial session</dt>
|
||||
<dd>{crawlerEnv.session_configured ? 'configured' : '—'}</dd>
|
||||
</dl>
|
||||
</fieldset>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if tab === 'analysis' && analysis}
|
||||
<div class="form">
|
||||
<fieldset>
|
||||
<legend>Endpoint & model</legend>
|
||||
<label class="field">
|
||||
<span>Vision endpoint URL</span>
|
||||
<input type="text" bind:value={analysis.endpoint} />
|
||||
{#if fieldErrors.endpoint}<small class="fe">{fieldErrors.endpoint}</small>{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Model</span>
|
||||
<input type="text" bind:value={analysis.model} />
|
||||
{#if fieldErrors.model}<small class="fe">{fieldErrors.model}</small>{/if}
|
||||
</label>
|
||||
<p class="env-note muted">
|
||||
API key: {apiKeyConfigured
|
||||
? 'configured via environment'
|
||||
: 'not set'} (managed via environment).
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Workers & timeouts</legend>
|
||||
<label class="field">
|
||||
<span>Workers</span>
|
||||
<input type="number" min="1" bind:value={analysis.workers} />
|
||||
{#if fieldErrors.workers}<small class="fe">{fieldErrors.workers}</small>{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Request timeout (seconds)</span>
|
||||
<input type="number" min="1" bind:value={analysis.request_timeout_secs} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Job timeout (seconds)</span>
|
||||
<input type="number" min="1" bind:value={analysis.job_timeout_secs} />
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Sampling & output</legend>
|
||||
<label class="field">
|
||||
<span>Temperature</span>
|
||||
<input type="number" min="0" step="0.05" bind:value={analysis.temperature} />
|
||||
{#if fieldErrors.temperature}<small class="fe"
|
||||
>{fieldErrors.temperature}</small
|
||||
>{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Frequency penalty</span>
|
||||
<input type="number" step="0.05" bind:value={analysis.frequency_penalty} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Max output tokens</span>
|
||||
<input type="number" min="1" bind:value={analysis.max_tokens} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Response format</span>
|
||||
<select bind:value={analysis.response_format}>
|
||||
<option value="json_schema">json_schema</option>
|
||||
<option value="json_object">json_object</option>
|
||||
<option value="none">none</option>
|
||||
</select>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Image slicing</legend>
|
||||
<label class="field">
|
||||
<span>Max pixels per slice</span>
|
||||
<input type="number" min="1" bind:value={analysis.max_pixels} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Min slice height (px)</span>
|
||||
<input type="number" min="1" bind:value={analysis.min_slice_height} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Slice overlap (0–0.9)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="0.9"
|
||||
step="0.01"
|
||||
bind:value={analysis.slice_overlap}
|
||||
/>
|
||||
{#if fieldErrors.slice_overlap}<small class="fe"
|
||||
>{fieldErrors.slice_overlap}</small
|
||||
>{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Tall aspect threshold (≥1.0)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="0.1"
|
||||
bind:value={analysis.tall_aspect_threshold}
|
||||
/>
|
||||
{#if fieldErrors.tall_aspect_threshold}<small class="fe"
|
||||
>{fieldErrors.tall_aspect_threshold}</small
|
||||
>{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Max slices</span>
|
||||
<input type="number" min="1" bind:value={analysis.max_slices} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Max image bytes</span>
|
||||
<input type="number" min="0" bind:value={analysis.max_image_bytes} />
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Prompts</legend>
|
||||
{#each [['system_prompt', 'System prompt'], ['ocr_prompt', 'OCR prompt (slices)'], ['grounding_prompt', 'Grounding prompt']] as [key, label] (key)}
|
||||
{@const k = key as 'system_prompt' | 'ocr_prompt' | 'grounding_prompt'}
|
||||
<div class="prompt">
|
||||
<div class="prompt-head">
|
||||
<span>{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="link"
|
||||
disabled={analysis[k] == null}
|
||||
onclick={() => resetPrompt(k)}>Reset to default</button
|
||||
>
|
||||
</div>
|
||||
<textarea
|
||||
rows="6"
|
||||
class="mono"
|
||||
value={analysis[k] ?? ''}
|
||||
placeholder={promptDefaults?.[k] ?? ''}
|
||||
oninput={(e) => {
|
||||
const v = (e.currentTarget as HTMLTextAreaElement).value;
|
||||
if (analysis) analysis[k] = v.trim() === '' ? null : v;
|
||||
}}
|
||||
></textarea>
|
||||
<small class="muted">
|
||||
{analysis[k] == null
|
||||
? 'Using compiled default (shown as placeholder).'
|
||||
: `${analysis[k]?.length ?? 0} chars (override).`}
|
||||
</small>
|
||||
</div>
|
||||
{/each}
|
||||
</fieldset>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<Modal
|
||||
open={confirmOpen}
|
||||
title="Apply settings?"
|
||||
onClose={() => (confirmOpen = false)}
|
||||
size="sm"
|
||||
closeOnBackdrop
|
||||
testid="settings-confirm"
|
||||
>
|
||||
<p>
|
||||
Saving restarts the {tab} subsystem with the new settings. In-flight jobs are leased and
|
||||
will be retried, but any work in progress is interrupted briefly.
|
||||
</p>
|
||||
{#snippet footer()}
|
||||
<button type="button" onclick={() => (confirmOpen = false)}>Cancel</button>
|
||||
<button type="button" class="primary" onclick={save} data-testid="settings-confirm-apply">
|
||||
Save & apply
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
margin: 0 0 var(--space-2) 0;
|
||||
}
|
||||
.lead {
|
||||
margin: 0 0 var(--space-4) 0;
|
||||
font-size: var(--font-sm);
|
||||
max-width: 60ch;
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.tabs button {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.tabs button.active {
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--primary);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
.sticky-bar {
|
||||
position: sticky;
|
||||
top: calc(var(--app-header-h) + var(--space-2));
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
background: var(--surface-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.sticky-bar .spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
fieldset {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
legend {
|
||||
padding: 0 var(--space-2);
|
||||
font-weight: var(--weight-semibold);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.field {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.field > span {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.field input,
|
||||
.field select,
|
||||
.field textarea {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.field-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.fe {
|
||||
color: var(--danger, #dc2626);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: var(--font-xs);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
.prompt {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.prompt-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
button.link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-xs);
|
||||
padding: 0;
|
||||
}
|
||||
button.link:disabled {
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.env dl {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: var(--space-1) var(--space-3);
|
||||
margin: 0;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.env dt {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.env dd {
|
||||
margin: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
.env-note {
|
||||
font-size: var(--font-xs);
|
||||
margin: 0;
|
||||
}
|
||||
.error {
|
||||
color: var(--danger, #dc2626);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--danger, #dc2626);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.notice {
|
||||
color: var(--success, #16a34a);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--success, #16a34a);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
button.primary {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--primary);
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user