media-rip/frontend/src/components/AdminPanel.vue
xpltd ff23c078c0 Settings layout rework, purge fix, SSE broadcast
Settings tab reorganized into 3 sections:
- Appearance & Defaults: welcome message + output formats + Save
- Privacy & Data: privacy mode toggle + manual purge
- Security: change password

Manual purge fix:
- purge_all=True clears ALL completed/failed jobs regardless of age
- Previously only cleared jobs older than max_age_hours (7 days),
  so recent downloads were never purged on manual trigger

SSE broadcast for purge:
- Added SSEBroker.publish_all() for cross-session broadcasts
- Purge endpoint sends job_removed events for each deleted job
- Frontend queue clears in real-time when admin purges
2026-03-19 06:04:59 -05:00

877 lines
24 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAdminStore } from '@/stores/admin'
import { useConfigStore } from '@/stores/config'
import { api } from '@/api/client'
import AdminLogin from './AdminLogin.vue'
const store = useAdminStore()
const configStore = useConfigStore()
const router = useRouter()
const activeTab = ref<'sessions' | 'storage' | 'settings'>('sessions')
// Session expansion state
const expandedSessions = ref<Set<string>>(new Set())
const sessionJobs = ref<Record<string, any[]>>({})
const loadingJobs = ref<Set<string>>(new Set())
// Settings state
const welcomeMessage = ref('')
const defaultVideoFormat = ref('auto')
const defaultAudioFormat = ref('auto')
const settingsSaved = ref(false)
const privacyMode = ref(false)
const privacyRetentionHours = ref(24)
// Change password state
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const changingPassword = ref(false)
const passwordChanged = ref(false)
const passwordError = ref<string | null>(null)
const canChangePassword = computed(() =>
currentPassword.value.length > 0 &&
newPassword.value.length >= 4 &&
newPassword.value === confirmPassword.value
)
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
}
async function switchTab(tab: typeof activeTab.value) {
activeTab.value = tab
settingsSaved.value = false
if (tab === 'sessions') await store.loadSessions()
if (tab === 'storage') await store.loadStorage()
if (tab === 'settings') {
try {
const config = await api.getPublicConfig()
welcomeMessage.value = config.welcome_message
defaultVideoFormat.value = config.default_video_format || 'auto'
defaultAudioFormat.value = config.default_audio_format || 'auto'
privacyMode.value = config.privacy_mode ?? false
privacyRetentionHours.value = config.privacy_retention_hours ?? 24
} catch {
// Keep current value
}
}
}
async function saveSettings() {
settingsSaved.value = false
const ok = await store.updateSettings({
welcome_message: welcomeMessage.value,
default_video_format: defaultVideoFormat.value,
default_audio_format: defaultAudioFormat.value,
privacy_mode: privacyMode.value,
privacy_retention_hours: privacyRetentionHours.value,
})
if (ok) {
// Reload public config so main page picks up new defaults
await configStore.loadConfig()
settingsSaved.value = true
setTimeout(() => { settingsSaved.value = false }, 3000)
}
}
async function changePassword() {
if (!canChangePassword.value) return
changingPassword.value = true
passwordChanged.value = false
passwordError.value = null
try {
const res = await fetch('/api/admin/password', {
method: 'PUT',
headers: {
'Authorization': 'Basic ' + btoa(store.username + ':' + store.password),
'Content-Type': 'application/json',
},
body: JSON.stringify({
current_password: currentPassword.value,
new_password: newPassword.value,
}),
})
if (res.ok) {
passwordChanged.value = true
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
// Auto-logout after 1.5s so user sees the success message
setTimeout(() => {
store.logout()
router.push('/')
}, 1500)
} else {
const data = await res.json()
passwordError.value = data.detail || 'Failed to change password'
}
} catch {
passwordError.value = 'Network error'
} finally {
changingPassword.value = false
}
}
async function toggleSession(sessionId: string) {
if (expandedSessions.value.has(sessionId)) {
expandedSessions.value.delete(sessionId)
return
}
expandedSessions.value.add(sessionId)
if (!sessionJobs.value[sessionId]) {
loadingJobs.value.add(sessionId)
try {
const res = await fetch(`/api/admin/sessions/${sessionId}/jobs`, {
headers: { Authorization: `Basic ${btoa(`${store.username}:${store.password}`)}` },
})
if (res.ok) {
const data = await res.json()
sessionJobs.value[sessionId] = data.jobs
}
} catch { /* ignore */ }
loadingJobs.value.delete(sessionId)
}
}
function jobFilename(job: any): string {
if (!job.filename) return '—'
const parts = job.filename.replace(/\\/g, '/').split('/')
return parts[parts.length - 1]
}
function formatFilesize(bytes: number | null): string {
if (!bytes) return '—'
return formatBytes(bytes)
}
</script>
<template>
<div class="admin-panel">
<AdminLogin v-if="!store.isAuthenticated" />
<template v-else>
<div class="admin-header">
<h2>Admin Panel</h2>
<button class="btn-logout" @click="store.logout(); router.push('/')">Logout</button>
</div>
<div class="admin-tabs">
<button
v-for="tab in (['sessions', 'storage', 'settings'] as const)"
:key="tab"
:class="{ active: activeTab === tab }"
@click="switchTab(tab)"
>
{{ tab }}
</button>
</div>
<!-- Sessions tab -->
<div v-if="activeTab === 'sessions'" class="tab-content">
<table class="admin-table" v-if="store.sessions.length">
<thead>
<tr>
<th></th>
<th>Session ID</th>
<th>Last Seen</th>
<th>Jobs</th>
</tr>
</thead>
<tbody>
<template v-for="s in store.sessions" :key="s.id">
<tr
class="session-row"
:class="{ expanded: expandedSessions.has(s.id), clickable: s.job_count > 0 }"
@click="s.job_count > 0 && toggleSession(s.id)"
>
<td class="col-expand">
<span v-if="s.job_count > 0" class="expand-icon">{{ expandedSessions.has(s.id) ? '' : '' }}</span>
</td>
<td class="mono">{{ s.id.slice(0, 8) }}</td>
<td>{{ new Date(s.last_seen).toLocaleString() }}</td>
<td>{{ s.job_count }}</td>
</tr>
<tr v-if="expandedSessions.has(s.id)" class="jobs-detail-row">
<td colspan="4">
<div v-if="loadingJobs.has(s.id)" class="jobs-loading">Loading</div>
<div v-else-if="sessionJobs[s.id]?.length" class="jobs-detail">
<div v-for="job in sessionJobs[s.id]" :key="job.id" class="job-item">
<span class="job-filename">{{ jobFilename(job) }}</span>
<span class="job-size">{{ formatFilesize(job.filesize) }}</span>
<span class="job-status badge-sm" :class="'badge-' + job.status">{{ job.status }}</span>
<span class="job-time">{{ new Date(job.created_at).toLocaleString() }}</span>
<a v-if="job.url" class="job-url" :href="job.url" target="_blank" rel="noopener" :title="job.url"></a>
</div>
</div>
<div v-else class="jobs-empty">No jobs found.</div>
</td>
</tr>
</template>
</tbody>
</table>
<p v-else class="empty">No sessions found.</p>
</div>
<!-- Storage tab -->
<div v-if="activeTab === 'storage'" class="tab-content">
<div v-if="store.storage" class="storage-info">
<div class="stat">
<span class="stat-label">Total</span>
<span class="stat-value">{{ formatBytes(store.storage.disk.total) }}</span>
</div>
<div class="stat">
<span class="stat-label">Used</span>
<span class="stat-value">{{ formatBytes(store.storage.disk.used) }}</span>
</div>
<div class="stat">
<span class="stat-label">Free</span>
<span class="stat-value">{{ formatBytes(store.storage.disk.free) }}</span>
</div>
<h3>Jobs by Status</h3>
<div v-for="(count, status) in store.storage.jobs_by_status" :key="status" class="stat">
<span class="stat-label">{{ status }}</span>
<span class="stat-value">{{ count }}</span>
</div>
</div>
<p v-else class="empty">Loading</p>
</div>
<!-- Settings tab -->
<div v-if="activeTab === 'settings'" class="tab-content">
<!-- Section: Appearance & Defaults -->
<div class="settings-section">
<h3 class="section-heading">Appearance &amp; Defaults</h3>
<div class="settings-field">
<label for="welcome-msg">Welcome Message</label>
<p class="field-hint">Displayed above the URL input on the main page. Leave empty to hide.</p>
<textarea
id="welcome-msg"
v-model="welcomeMessage"
rows="3"
class="settings-textarea"
placeholder="Enter a welcome message…"
></textarea>
</div>
<div class="settings-field" style="margin-top: var(--space-lg);">
<label>Default Output Formats</label>
<p class="field-hint">When "Auto" is selected, files are converted to these formats instead of the native container.</p>
<div class="format-defaults">
<div class="format-default-row">
<span class="format-default-label">Video</span>
<select v-model="defaultVideoFormat" class="settings-select">
<option value="auto">Auto (native container)</option>
<option value="mp4">MP4</option>
<option value="webm">WebM</option>
</select>
</div>
<div class="format-default-row">
<span class="format-default-label">Audio</span>
<select v-model="defaultAudioFormat" class="settings-select">
<option value="auto">Auto (native container)</option>
<option value="mp3">MP3</option>
<option value="m4a">M4A (AAC)</option>
<option value="flac">FLAC</option>
<option value="wav">WAV</option>
<option value="opus">Opus</option>
</select>
</div>
</div>
</div>
<div class="settings-actions">
<button
@click="saveSettings"
:disabled="store.isLoading"
class="btn-save"
>
{{ store.isLoading ? 'Saving…' : 'Save Settings' }}
</button>
<span v-if="settingsSaved" class="save-confirm"> Saved</span>
</div>
<p class="field-hint" style="margin-top: var(--space-sm);">
Changes are applied immediately but reset on server restart.
</p>
</div>
<hr class="settings-divider" />
<!-- Section: Privacy & Data -->
<div class="settings-section">
<h3 class="section-heading">Privacy &amp; Data</h3>
<div class="settings-field">
<label class="toggle-label">
<span>Privacy Mode</span>
<label class="toggle-switch">
<input type="checkbox" v-model="privacyMode" />
<span class="toggle-slider"></span>
</label>
</label>
<p class="field-hint">
Automatically purge download history, files, and session data
after the retention period. Changes are saved with the button above.
</p>
<div v-if="privacyMode" class="retention-setting">
<label class="retention-label">Retention period</label>
<div class="retention-input-row">
<input
type="number"
v-model.number="privacyRetentionHours"
min="1"
max="8760"
class="settings-input retention-input"
/>
<span class="retention-unit">hours</span>
</div>
<p class="field-hint">
Data older than this is automatically purged (default: 24 hours).
</p>
</div>
</div>
<div class="settings-field" style="margin-top: var(--space-md);">
<label>Manual Purge</label>
<p class="field-hint">
Immediately clear all completed and failed downloads removes
database records and files from disk. Active downloads are never affected.
</p>
<button
@click="store.triggerPurge()"
:disabled="store.isLoading"
class="btn-purge"
>
{{ store.isLoading ? 'Purging…' : 'Run Purge Now' }}
</button>
<div v-if="store.purgeResult" class="purge-result">
<p>Rows deleted: {{ store.purgeResult.rows_deleted }}</p>
<p>Files deleted: {{ store.purgeResult.files_deleted }}</p>
<p>Files already gone: {{ store.purgeResult.files_missing }}</p>
<p>Active jobs skipped: {{ store.purgeResult.active_skipped }}</p>
</div>
</div>
</div>
<hr class="settings-divider" />
<!-- Section: Security -->
<div class="settings-section">
<h3 class="section-heading">Security</h3>
<div class="settings-field">
<label>Change Password</label>
<p class="field-hint">Update the admin password. Takes effect immediately but resets on server restart.</p>
<div class="password-fields">
<input
v-model="currentPassword"
type="password"
placeholder="Current password"
autocomplete="current-password"
class="settings-input"
/>
<input
v-model="newPassword"
type="password"
placeholder="New password"
autocomplete="new-password"
class="settings-input"
/>
<input
v-model="confirmPassword"
type="password"
placeholder="Confirm new password"
autocomplete="new-password"
class="settings-input"
@keydown.enter="changePassword"
/>
<span
v-if="confirmPassword && newPassword && confirmPassword !== newPassword"
class="password-mismatch"
>
Passwords don't match
</span>
</div>
<div class="settings-actions" style="margin-top: var(--space-sm);">
<button
@click="changePassword"
:disabled="!canChangePassword || changingPassword"
class="btn-save"
>
{{ changingPassword ? 'Changing' : 'Change Password' }}
</button>
<span v-if="passwordChanged" class="save-confirm">✓ Password changed</span>
<span v-if="passwordError" class="password-error">{{ passwordError }}</span>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.admin-panel {
max-width: var(--content-max-width);
margin: 0 auto;
padding: var(--space-lg) var(--space-md);
}
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-lg);
}
.admin-header h2 {
color: var(--color-accent);
}
.btn-logout {
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
.btn-logout:hover {
color: var(--color-error);
border-color: var(--color-error);
}
.admin-tabs {
display: flex;
gap: var(--space-xs);
margin-bottom: var(--space-lg);
}
.admin-tabs button {
padding: var(--space-sm) var(--space-md);
background: var(--color-surface);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
text-transform: capitalize;
}
.admin-tabs button.active {
color: var(--color-accent);
border-color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
}
.tab-content {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-lg);
}
.admin-table {
width: 100%;
border-collapse: collapse;
}
.admin-table th,
.admin-table td {
padding: var(--space-sm) var(--space-md);
text-align: left;
border-bottom: 1px solid var(--color-border);
}
.admin-table th {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
text-transform: uppercase;
}
.mono {
font-family: var(--font-mono);
font-size: var(--font-size-sm);
}
.storage-info {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.stat {
display: flex;
justify-content: space-between;
padding: var(--space-xs) 0;
}
.stat-label {
color: var(--color-text-muted);
text-transform: capitalize;
}
.stat-value {
font-family: var(--font-mono);
}
h3 {
margin-top: var(--space-md);
margin-bottom: var(--space-sm);
color: var(--color-text-muted);
font-size: var(--font-size-sm);
text-transform: uppercase;
}
.btn-purge {
background: var(--color-warning);
color: var(--color-bg);
font-weight: 600;
margin-top: var(--space-md);
}
.btn-purge:hover:not(:disabled) {
background: var(--color-error);
}
.purge-result {
margin-top: var(--space-md);
padding: var(--space-md);
background: var(--color-bg);
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: var(--font-size-sm);
}
.empty {
color: var(--color-text-muted);
text-align: center;
}
.settings-section {
margin-bottom: var(--space-sm);
}
.section-heading {
font-size: var(--font-size-md);
font-weight: 600;
color: var(--color-text);
margin: 0 0 var(--space-md) 0;
letter-spacing: 0.02em;
}
.settings-field {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.settings-field label {
font-weight: 600;
color: var(--color-text);
}
.field-hint {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
margin: 0;
}
.settings-textarea {
width: 100%;
padding: var(--space-sm);
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: var(--font-size-base);
resize: vertical;
}
.settings-textarea:focus {
outline: none;
border-color: var(--color-accent);
}
.settings-actions {
display: flex;
align-items: center;
gap: var(--space-md);
margin-top: var(--space-md);
}
.btn-save {
background: var(--color-accent);
color: var(--color-bg);
font-weight: 600;
padding: var(--space-sm) var(--space-lg);
}
.btn-save:hover:not(:disabled) {
background: var(--color-accent-hover);
}
.save-confirm {
color: var(--color-success);
font-weight: 500;
font-size: var(--font-size-sm);
}
.format-defaults {
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin-top: var(--space-sm);
}
.format-default-row {
display: flex;
align-items: center;
gap: var(--space-md);
}
.format-default-label {
min-width: 50px;
font-weight: 500;
color: var(--color-text);
}
.settings-select {
padding: var(--space-xs) var(--space-sm);
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: var(--font-size-sm);
min-width: 200px;
}
.settings-select:focus {
outline: none;
border-color: var(--color-accent);
}
.settings-divider {
border: none;
border-top: 1px solid var(--color-border);
margin: var(--space-lg) 0;
}
/* Toggle switch */
.toggle-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
}
.toggle-switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
flex-shrink: 0;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
inset: 0;
background-color: var(--color-border);
border-radius: 24px;
transition: background-color 0.2s;
}
.toggle-slider::before {
content: '';
position: absolute;
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: var(--color-bg);
border-radius: 50%;
transition: transform 0.2s;
}
.toggle-switch input:checked + .toggle-slider {
background-color: var(--color-accent-primary, #00a8ff);
}
.toggle-switch input:checked + .toggle-slider::before {
transform: translateX(20px);
}
/* Retention input */
.retention-setting {
margin-top: var(--space-sm);
padding-left: var(--space-sm);
border-left: 2px solid var(--color-accent-primary, #00a8ff);
}
.retention-label {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
margin-bottom: var(--space-xs);
}
.retention-input-row {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.retention-input {
width: 80px !important;
text-align: center;
}
.retention-unit {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.password-fields {
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin-top: var(--space-sm);
max-width: 320px;
}
.settings-input {
padding: var(--space-sm);
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: var(--font-size-sm);
}
.settings-input:focus {
outline: none;
border-color: var(--color-accent);
}
.password-error {
color: var(--color-error);
font-size: var(--font-size-sm);
font-weight: 500;
}
.password-mismatch {
color: var(--color-warning);
font-size: var(--font-size-sm);
}
/* Expandable session rows */
.session-row.clickable {
cursor: pointer;
}
.session-row.clickable:hover {
background: var(--color-surface-hover);
}
.session-row.expanded {
background: color-mix(in srgb, var(--color-accent) 5%, transparent);
}
.col-expand {
width: 24px;
text-align: center;
}
.expand-icon {
font-size: 10px;
color: var(--color-text-muted);
}
.jobs-detail-row td {
padding: 0 var(--space-md) var(--space-md);
border-bottom: 1px solid var(--color-border);
}
.jobs-detail {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--space-sm) 0;
}
.job-item {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-xs) var(--space-sm);
font-size: var(--font-size-sm);
border-radius: var(--radius-sm);
background: var(--color-bg);
}
.job-filename {
flex: 1;
font-family: var(--font-mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 120px;
}
.job-size {
font-family: var(--font-mono);
color: var(--color-text-muted);
white-space: nowrap;
min-width: 60px;
}
.badge-sm {
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
text-transform: uppercase;
font-weight: 600;
white-space: nowrap;
}
.badge-completed { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
.badge-failed { background: color-mix(in srgb, var(--color-error) 15%, transparent); color: var(--color-error); }
.badge-downloading { background: color-mix(in srgb, var(--color-accent) 15%, transparent); color: var(--color-accent); }
.badge-queued { background: color-mix(in srgb, var(--color-text-muted) 15%, transparent); color: var(--color-text-muted); }
.job-time {
color: var(--color-text-muted);
white-space: nowrap;
font-size: var(--font-size-sm);
}
.job-url {
color: var(--color-accent);
text-decoration: none;
font-size: var(--font-size-sm);
}
.job-url:hover {
text-decoration: underline;
}
.jobs-loading, .jobs-empty {
padding: var(--space-sm);
color: var(--color-text-muted);
font-size: var(--font-size-sm);
font-style: italic;
}
</style>