484 lines
21 KiB
HTML
484 lines
21 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>CalibreSync</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet" />
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
|
|
<style>
|
|
[x-cloak] { display: none !important; }
|
|
</style>
|
|
</head>
|
|
<body data-theme="dim" x-data="app()" x-init="init()" class="min-h-screen bg-base-100">
|
|
|
|
<!-- Toast -->
|
|
<div class="toast toast-top toast-end z-50" x-cloak x-show="toast.show" x-transition>
|
|
<div :class="toast.type === 'success' ? 'alert alert-success' : 'alert alert-error'" class="text-sm">
|
|
<span x-text="toast.msg"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Navbar -->
|
|
<div class="navbar bg-base-200 shadow-md sticky top-0 z-40">
|
|
<div class="navbar-start">
|
|
<span class="text-xl font-bold px-4 tracking-tight">CalibreSync</span>
|
|
</div>
|
|
<div class="navbar-center">
|
|
<div role="tablist" class="tabs tabs-boxed">
|
|
<a role="tab" class="tab" :class="{ 'tab-active': tab === 'dashboard' }" @click="tab = 'dashboard'">Dashboard</a>
|
|
<a role="tab" class="tab" :class="{ 'tab-active': tab === 'settings' }" @click="tab = 'settings'">Settings</a>
|
|
</div>
|
|
</div>
|
|
<div class="navbar-end pr-4">
|
|
<a href="/docs" target="_blank" class="link link-hover text-xs opacity-50">API docs</a>
|
|
</div>
|
|
</div>
|
|
|
|
<main class="container mx-auto px-4 py-6 max-w-5xl">
|
|
|
|
<!-- DASHBOARD TAB -->
|
|
<div x-show="tab === 'dashboard'" x-cloak>
|
|
<template x-if="dashboard">
|
|
<div>
|
|
|
|
<!-- Header row -->
|
|
<div class="flex flex-wrap items-center gap-3 mb-5">
|
|
<h1 class="text-2xl font-bold flex-1">Dashboard</h1>
|
|
<template x-if="dashboard.interval > 0">
|
|
<span class="badge badge-outline badge-sm" x-text="'Auto every ' + dashboard.interval + ' min'"></span>
|
|
</template>
|
|
<template x-if="dashboard.next_run">
|
|
<span class="badge badge-ghost badge-sm" x-text="'Next: ' + dashboard.next_run"></span>
|
|
</template>
|
|
<button class="btn btn-sm btn-outline" :disabled="dashboard.sync_running"
|
|
@click="triggerSync('/api/sync/rescan')">Rescan remote</button>
|
|
<button class="btn btn-sm btn-outline" :disabled="dashboard.sync_running"
|
|
@click="triggerSync('/api/sync/test')">Test (1 zip)</button>
|
|
<button class="btn btn-sm btn-primary" :disabled="dashboard.sync_running"
|
|
@click="triggerSync('/api/sync')">
|
|
<span x-show="dashboard.sync_running" class="loading loading-spinner loading-xs"></span>
|
|
<span x-text="dashboard.sync_running ? 'Running…' : 'Run Sync Now'"></span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Cache status -->
|
|
<div class="text-sm opacity-50 mb-5" x-show="dashboard.cache_info">
|
|
<template x-if="dashboard.cache_info && dashboard.cache_info.count > 0">
|
|
<span>Remote cache: <strong x-text="dashboard.cache_info.count"></strong> zip(s) — last scanned <span x-text="formatDate(dashboard.cache_info.last_scan)"></span> UTC</span>
|
|
</template>
|
|
<template x-if="dashboard.cache_info && !dashboard.cache_info.count">
|
|
<span class="text-warning">Remote cache empty — first sync will run a full scan.</span>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Stats cards -->
|
|
<div class="stats stats-horizontal shadow mb-6 w-full flex-wrap">
|
|
<div class="stat">
|
|
<div class="stat-title">Archives processed</div>
|
|
<div class="stat-value text-base-content" x-text="dashboard.stats.total_zips ?? 0"></div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-title">Books imported</div>
|
|
<div class="stat-value text-success" x-text="dashboard.stats.total_books ?? 0"></div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-title">Skipped (dupes)</div>
|
|
<div class="stat-value text-info" x-text="dashboard.stats.total_skipped ?? 0"></div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-title">Errors</div>
|
|
<div class="stat-value text-error" x-text="dashboard.stats.total_errored ?? 0"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recent sync runs -->
|
|
<h2 class="text-lg font-semibold mb-2">Recent sync runs</h2>
|
|
<div class="overflow-x-auto mb-6 rounded-lg border border-base-300">
|
|
<table class="table table-zebra table-sm w-full">
|
|
<thead>
|
|
<tr>
|
|
<th>Started</th><th>Finished</th><th>Status</th>
|
|
<th class="text-right">New zips</th><th class="text-right">Imported</th>
|
|
<th class="text-right">Skipped</th><th class="text-right">Errors</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="r in dashboard.runs" :key="r.id">
|
|
<tr>
|
|
<td class="text-xs font-mono" x-text="formatDate(r.started_at)"></td>
|
|
<td class="text-xs font-mono" x-text="formatDate(r.finished_at)"></td>
|
|
<td>
|
|
<span :class="{
|
|
'badge badge-success badge-sm': r.status === 'success',
|
|
'badge badge-error badge-sm': r.status === 'error',
|
|
'badge badge-warning badge-sm': r.status === 'running'
|
|
}" x-text="r.status"></span>
|
|
</td>
|
|
<td class="text-right" x-text="r.zips_new ?? 0"></td>
|
|
<td class="text-right" x-text="r.books_imported ?? 0"></td>
|
|
<td class="text-right" x-text="r.books_skipped ?? 0"></td>
|
|
<td class="text-right" x-text="r.books_errored ?? 0"></td>
|
|
</tr>
|
|
</template>
|
|
<template x-if="!dashboard.runs || !dashboard.runs.length">
|
|
<tr><td colspan="7" class="text-center opacity-40 py-4">No sync runs yet</td></tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Recent books -->
|
|
<h2 class="text-lg font-semibold mb-2">Recent books</h2>
|
|
<div class="overflow-x-auto rounded-lg border border-base-300">
|
|
<table class="table table-zebra table-sm w-full">
|
|
<thead>
|
|
<tr><th>Filename</th><th>Status</th><th>Placed at</th><th>Source zip</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="b in dashboard.books" :key="b.id">
|
|
<tr>
|
|
<td class="font-mono text-xs max-w-xs truncate" x-text="b.filename"></td>
|
|
<td>
|
|
<span :class="{
|
|
'badge badge-success badge-sm': b.status === 'success',
|
|
'badge badge-info badge-sm': b.status === 'skipped',
|
|
'badge badge-error badge-sm': b.status === 'error'
|
|
}" x-text="b.status"></span>
|
|
</td>
|
|
<td class="text-xs font-mono" x-text="formatDate(b.placed_at)"></td>
|
|
<td class="font-mono text-xs opacity-50 max-w-xs truncate" x-text="b.zip_remote_path ?? '—'"></td>
|
|
</tr>
|
|
</template>
|
|
<template x-if="!dashboard.books || !dashboard.books.length">
|
|
<tr><td colspan="4" class="text-center opacity-40 py-4">No books processed yet</td></tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
</div>
|
|
</template>
|
|
<template x-if="!dashboard">
|
|
<div class="flex justify-center items-center py-24">
|
|
<span class="loading loading-spinner loading-lg"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- SETTINGS TAB -->
|
|
<div x-show="tab === 'settings'" x-cloak>
|
|
<template x-if="settings">
|
|
<form @submit.prevent="saveSettings($el)">
|
|
<h1 class="text-2xl font-bold mb-6">Settings</h1>
|
|
|
|
<!-- SFTP -->
|
|
<div class="card bg-base-200 shadow mb-4">
|
|
<div class="card-body gap-3">
|
|
<h2 class="card-title text-base">Remote host (SFTP)</h2>
|
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
|
<div class="sm:col-span-2 form-control">
|
|
<label class="label py-1"><span class="label-text text-xs">Host</span></label>
|
|
<input name="sftp_host" type="text" class="input input-bordered input-sm"
|
|
:value="settings.settings.sftp_host ?? ''" placeholder="sftp.example.com" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs">Port</span></label>
|
|
<input name="sftp_port" type="number" class="input input-bordered input-sm"
|
|
:value="settings.settings.sftp_port ?? '22'" />
|
|
</div>
|
|
</div>
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs">Username</span></label>
|
|
<input name="sftp_user" type="text" class="input input-bordered input-sm"
|
|
:value="settings.settings.sftp_user ?? ''" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs">Remote path</span></label>
|
|
<input name="sftp_remote_path" type="text" class="input input-bordered input-sm"
|
|
:value="settings.settings.sftp_remote_path ?? ''" placeholder="/books" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Auth method -->
|
|
<div x-data="{ authMethod: settings.settings.sftp_auth_method || 'key' }">
|
|
<input type="hidden" name="sftp_auth_method" :value="authMethod" />
|
|
<div class="flex gap-4 mb-2 text-sm">
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input type="radio" class="radio radio-sm" value="key"
|
|
x-model="authMethod" /> SSH key
|
|
</label>
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input type="radio" class="radio radio-sm" value="password"
|
|
x-model="authMethod" /> Password
|
|
</label>
|
|
</div>
|
|
<div x-show="authMethod === 'key'">
|
|
<label class="label py-1">
|
|
<span class="label-text text-xs">Private key (PEM)</span>
|
|
<span class="label-text-alt text-xs opacity-50" x-show="settings.has_key"
|
|
x-text="'Current: ' + (settings.key_fingerprint || 'loaded')"></span>
|
|
</label>
|
|
<textarea name="sftp_key" rows="5"
|
|
class="textarea textarea-bordered w-full font-mono text-xs"
|
|
placeholder="Paste new key to replace. Leave empty to keep current."></textarea>
|
|
</div>
|
|
<div x-show="authMethod === 'password'">
|
|
<label class="label py-1"><span class="label-text text-xs">SSH password</span></label>
|
|
<input name="sftp_password" type="password" class="input input-bordered input-sm w-full"
|
|
placeholder="Leave empty to keep current" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-3 mt-1">
|
|
<button type="button" class="btn btn-sm btn-outline"
|
|
@click="testConn('ssh')" :disabled="sshTestLoading">
|
|
<span x-show="sshTestLoading" class="loading loading-spinner loading-xs"></span>
|
|
Test SSH connection
|
|
</button>
|
|
<template x-if="sshTestResult">
|
|
<span :class="sshTestResult.ok ? 'text-success text-sm' : 'text-error text-sm'"
|
|
x-text="sshTestResult.message"></span>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Grimmory -->
|
|
<div class="card bg-base-200 shadow mb-4">
|
|
<div class="card-body gap-3">
|
|
<h2 class="card-title text-base">Grimmory</h2>
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs">URL</span></label>
|
|
<input name="grimmory_url" type="url" class="input input-bordered input-sm"
|
|
:value="settings.settings.grimmory_url ?? ''" placeholder="http://grimmory:6060" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs">Username</span></label>
|
|
<input name="grimmory_user" type="text" class="input input-bordered input-sm"
|
|
:value="settings.settings.grimmory_user ?? ''" />
|
|
</div>
|
|
</div>
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs">Password</span></label>
|
|
<input name="grimmory_password" type="password" class="input input-bordered input-sm"
|
|
placeholder="Leave empty to keep current" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1">
|
|
<span class="label-text text-xs">Bookdrop path</span>
|
|
<span class="label-text-alt text-xs opacity-50">local mount of Grimmory's /bookdrop volume</span>
|
|
</label>
|
|
<input name="grimmory_bookdrop_path" type="text" class="input input-bordered input-sm"
|
|
:value="settings.settings.grimmory_bookdrop_path ?? ''" placeholder="/bookdrop" />
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-3 mt-1">
|
|
<button type="button" class="btn btn-sm btn-outline"
|
|
@click="testConn('grimmory')" :disabled="grimmoryTestLoading">
|
|
<span x-show="grimmoryTestLoading" class="loading loading-spinner loading-xs"></span>
|
|
Test Grimmory connection
|
|
</button>
|
|
<template x-if="grimmoryTestResult">
|
|
<span :class="grimmoryTestResult.ok ? 'text-success text-sm' : 'text-error text-sm'"
|
|
x-text="grimmoryTestResult.message"></span>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Local / Schedule -->
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
|
|
<div class="card bg-base-200 shadow">
|
|
<div class="card-body gap-3">
|
|
<h2 class="card-title text-base">Local</h2>
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs">Work directory</span></label>
|
|
<input name="work_dir" type="text" class="input input-bordered input-sm"
|
|
:value="settings.settings.work_dir ?? '/tmp/calibresync'" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="card bg-base-200 shadow">
|
|
<div class="card-body gap-3">
|
|
<h2 class="card-title text-base">Schedule</h2>
|
|
<div class="form-control">
|
|
<label class="label py-1">
|
|
<span class="label-text text-xs">Interval (minutes)</span>
|
|
<span class="label-text-alt text-xs opacity-50">0 = disabled</span>
|
|
</label>
|
|
<input name="scheduler_interval_minutes" type="number" min="0"
|
|
class="input input-bordered input-sm"
|
|
:value="settings.settings.scheduler_interval_minutes ?? '0'" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1">
|
|
<span class="label-text text-xs">Batch size</span>
|
|
<span class="label-text-alt text-xs opacity-50">0 = all</span>
|
|
</label>
|
|
<input name="sync_batch_size" type="number" min="0"
|
|
class="input input-bordered input-sm"
|
|
:value="settings.settings.sync_batch_size ?? '0'" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex gap-3 mb-8">
|
|
<button type="submit" class="btn btn-primary">Save settings</button>
|
|
</div>
|
|
|
|
<!-- Danger zone -->
|
|
<div class="card border border-error">
|
|
<div class="card-body gap-2">
|
|
<h2 class="card-title text-error text-base">Danger zone</h2>
|
|
<p class="text-sm opacity-60">Deletes all records of processed zips, books, and sync run history. Settings are kept.</p>
|
|
<button type="button" class="btn btn-error btn-sm w-fit mt-1"
|
|
@click="resetSyncData()">Delete all sync data</button>
|
|
</div>
|
|
</div>
|
|
|
|
</form>
|
|
</template>
|
|
<template x-if="!settings">
|
|
<div class="flex justify-center items-center py-24">
|
|
<span class="loading loading-spinner loading-lg"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
</main>
|
|
|
|
<script>
|
|
function app() {
|
|
return {
|
|
tab: 'dashboard',
|
|
dashboard: null,
|
|
settings: null,
|
|
toast: { show: false, msg: '', type: 'success' },
|
|
sshTestResult: null,
|
|
sshTestLoading: false,
|
|
grimmoryTestResult: null,
|
|
grimmoryTestLoading: false,
|
|
_pollInterval: null,
|
|
_prevRunning: false,
|
|
|
|
async init() {
|
|
await Promise.all([this.loadDashboard(), this.loadSettings()])
|
|
this._prevRunning = this.dashboard?.sync_running ?? false
|
|
this._pollInterval = setInterval(() => this.poll(), 3000)
|
|
},
|
|
|
|
async poll() {
|
|
try {
|
|
const s = await fetch('/api/status').then(r => r.json())
|
|
if (this.dashboard) {
|
|
this.dashboard.sync_running = s.sync_running
|
|
this.dashboard.stats = s.stats
|
|
this.dashboard.next_run = s.next_run
|
|
if (this._prevRunning && !s.sync_running) {
|
|
await this.loadDashboard()
|
|
}
|
|
this._prevRunning = s.sync_running
|
|
}
|
|
} catch (_) {}
|
|
},
|
|
|
|
async loadDashboard() {
|
|
try {
|
|
this.dashboard = await fetch('/api/dashboard').then(r => r.json())
|
|
} catch (e) {
|
|
this.showToast('Failed to load dashboard', 'error')
|
|
}
|
|
},
|
|
|
|
async loadSettings() {
|
|
try {
|
|
this.settings = await fetch('/api/settings').then(r => r.json())
|
|
} catch (e) {
|
|
this.showToast('Failed to load settings', 'error')
|
|
}
|
|
},
|
|
|
|
async triggerSync(endpoint) {
|
|
try {
|
|
const r = await fetch(endpoint, { method: 'POST' }).then(r => r.json())
|
|
if (r.ok) {
|
|
if (this.dashboard) this.dashboard.sync_running = true
|
|
this._prevRunning = true
|
|
this.showToast('Started', 'success')
|
|
} else {
|
|
this.showToast(r.reason === 'already_running' ? 'Sync already running' : 'Failed to start', 'error')
|
|
}
|
|
} catch (_) {
|
|
this.showToast('Request failed', 'error')
|
|
}
|
|
},
|
|
|
|
async saveSettings(form) {
|
|
const data = {}
|
|
const fd = new FormData(form)
|
|
for (const [k, v] of fd.entries()) data[k] = v
|
|
// Ensure auth-method-hidden fields aren't duplicated by radio inputs
|
|
try {
|
|
const r = await fetch('/api/settings', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data),
|
|
}).then(r => r.json())
|
|
if (r.ok) {
|
|
this.showToast('Settings saved', 'success')
|
|
await Promise.all([this.loadSettings(), this.loadDashboard()])
|
|
} else {
|
|
this.showToast('Save failed', 'error')
|
|
}
|
|
} catch (_) {
|
|
this.showToast('Request failed', 'error')
|
|
}
|
|
},
|
|
|
|
async testConn(type) {
|
|
this[type + 'TestLoading'] = true
|
|
this[type + 'TestResult'] = null
|
|
try {
|
|
const r = await fetch('/api/test/' + type).then(r => r.json())
|
|
this[type + 'TestResult'] = r
|
|
} catch (e) {
|
|
this[type + 'TestResult'] = { ok: false, message: 'Request failed' }
|
|
} finally {
|
|
this[type + 'TestLoading'] = false
|
|
}
|
|
},
|
|
|
|
async resetSyncData() {
|
|
if (!confirm('Delete all sync history? This cannot be undone.')) return
|
|
try {
|
|
const r = await fetch('/api/settings/reset-sync-data', { method: 'POST' }).then(r => r.json())
|
|
if (r.ok) {
|
|
this.showToast('Sync data cleared', 'success')
|
|
await this.loadDashboard()
|
|
}
|
|
} catch (_) {
|
|
this.showToast('Request failed', 'error')
|
|
}
|
|
},
|
|
|
|
showToast(msg, type) {
|
|
this.toast = { show: true, msg, type }
|
|
setTimeout(() => { this.toast.show = false }, 3500)
|
|
},
|
|
|
|
formatDate(iso) {
|
|
if (!iso) return '—'
|
|
return iso.substring(0, 19).replace('T', ' ')
|
|
},
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|