Settings flow rework, purge sessions, confirmation gate

Settings flow:
- Each section has its own Save button — no ambiguous shared button
- Appearance & Defaults: Save covers welcome message + output formats
- Privacy & Data: Save covers privacy toggle + retention hours
- Security: Change Password button is self-contained
- Bottom note clarifies all settings reset on server restart

Purge improvements:
- Now clears orphaned sessions (sessions with no remaining jobs)
- 'Sure?' confirmation gate on manual purge (3s timeout revert)
- Purge result shows sessions_deleted count
- Cleaner result display: 'X jobs removed' instead of 'Rows deleted: X'

SSE broker:
- publish_all() broadcasts to all sessions (used for purge)
This commit is contained in:
xpltd 2026-03-19 06:16:43 -05:00
parent dd60505f5a
commit fe45fdce50
3 changed files with 73 additions and 21 deletions

View file

@ -93,6 +93,20 @@ async def run_purge(
await db.commit() await db.commit()
# Clean up orphaned sessions (sessions with no remaining jobs)
if purge_all:
orphan_cursor = await db.execute(
"""
DELETE FROM sessions
WHERE id NOT IN (SELECT DISTINCT session_id FROM jobs)
"""
)
sessions_deleted = orphan_cursor.rowcount
await db.commit()
logger.info("Purge: removed %d orphaned sessions", sessions_deleted)
else:
sessions_deleted = 0
# Count skipped active jobs for observability # Count skipped active jobs for observability
active_cursor = await db.execute( active_cursor = await db.execute(
"SELECT COUNT(*) FROM jobs WHERE status IN ('queued', 'extracting', 'downloading')" "SELECT COUNT(*) FROM jobs WHERE status IN ('queued', 'extracting', 'downloading')"
@ -105,6 +119,7 @@ async def run_purge(
"files_deleted": files_deleted, "files_deleted": files_deleted,
"files_missing": files_missing, "files_missing": files_missing,
"active_skipped": active_skipped, "active_skipped": active_skipped,
"sessions_deleted": sessions_deleted,
"deleted_job_ids": deleted_job_ids, "deleted_job_ids": deleted_job_ids,
} }

View file

@ -23,6 +23,9 @@ const defaultAudioFormat = ref('auto')
const settingsSaved = ref(false) const settingsSaved = ref(false)
const privacyMode = ref(false) const privacyMode = ref(false)
const privacyRetentionHours = ref(24) const privacyRetentionHours = ref(24)
const privacySaved = ref(false)
const purgeConfirming = ref(false)
let purgeConfirmTimer: ReturnType<typeof setTimeout> | null = null
// Change password state // Change password state
const currentPassword = ref('') const currentPassword = ref('')
@ -70,14 +73,35 @@ async function saveSettings() {
welcome_message: welcomeMessage.value, welcome_message: welcomeMessage.value,
default_video_format: defaultVideoFormat.value, default_video_format: defaultVideoFormat.value,
default_audio_format: defaultAudioFormat.value, default_audio_format: defaultAudioFormat.value,
})
if (ok) {
await configStore.loadConfig()
settingsSaved.value = true
setTimeout(() => { settingsSaved.value = false }, 3000)
}
}
async function savePrivacy() {
privacySaved.value = false
const ok = await store.updateSettings({
privacy_mode: privacyMode.value, privacy_mode: privacyMode.value,
privacy_retention_hours: privacyRetentionHours.value, privacy_retention_hours: privacyRetentionHours.value,
}) })
if (ok) { if (ok) {
// Reload public config so main page picks up new defaults
await configStore.loadConfig() await configStore.loadConfig()
settingsSaved.value = true privacySaved.value = true
setTimeout(() => { settingsSaved.value = false }, 3000) setTimeout(() => { privacySaved.value = false }, 3000)
}
}
function handlePurgeClick() {
if (purgeConfirming.value) {
purgeConfirming.value = false
if (purgeConfirmTimer) clearTimeout(purgeConfirmTimer)
store.triggerPurge()
} else {
purgeConfirming.value = true
purgeConfirmTimer = setTimeout(() => { purgeConfirming.value = false }, 3000)
} }
} }
@ -290,18 +314,11 @@ function formatFilesize(bytes: number | null): string {
</div> </div>
<div class="settings-actions"> <div class="settings-actions">
<button <button @click="saveSettings" :disabled="store.isLoading" class="btn-save">
@click="saveSettings" {{ store.isLoading ? 'Saving…' : 'Save' }}
:disabled="store.isLoading"
class="btn-save"
>
{{ store.isLoading ? 'Saving…' : 'Save Settings' }}
</button> </button>
<span v-if="settingsSaved" class="save-confirm"> Saved</span> <span v-if="settingsSaved" class="save-confirm"> Saved</span>
</div> </div>
<p class="field-hint" style="margin-top: var(--space-sm);">
Changes are applied immediately but reset on server restart.
</p>
</div> </div>
<hr class="settings-divider" /> <hr class="settings-divider" />
@ -320,7 +337,7 @@ function formatFilesize(bytes: number | null): string {
</label> </label>
<p class="field-hint"> <p class="field-hint">
Automatically purge download history, files, and session data Automatically purge download history, files, and session data
after the retention period. Changes are saved with the button above. after the retention period.
</p> </p>
<div v-if="privacyMode" class="retention-setting"> <div v-if="privacyMode" class="retention-setting">
<label class="retention-label">Retention period</label> <label class="retention-label">Retention period</label>
@ -340,24 +357,33 @@ function formatFilesize(bytes: number | null): string {
</div> </div>
</div> </div>
<div class="settings-field" style="margin-top: var(--space-md);"> <div class="settings-actions">
<button @click="savePrivacy" :disabled="store.isLoading" class="btn-save">
{{ store.isLoading ? 'Saving…' : 'Save' }}
</button>
<span v-if="privacySaved" class="save-confirm"> Saved</span>
</div>
<div class="settings-field" style="margin-top: var(--space-lg);">
<label>Manual Purge</label> <label>Manual Purge</label>
<p class="field-hint"> <p class="field-hint">
Immediately clear all completed and failed downloads removes Immediately clear all completed and failed downloads removes
database records and files from disk. Active downloads are never affected. database records, files from disk, and orphaned sessions.
Active downloads are never affected.
</p> </p>
<button <button
@click="store.triggerPurge()" @click="handlePurgeClick"
:disabled="store.isLoading" :disabled="store.isLoading"
class="btn-purge" class="btn-purge"
:class="{ 'btn-confirm': purgeConfirming }"
> >
{{ store.isLoading ? 'Purging…' : 'Run Purge Now' }} {{ store.isLoading ? 'Purging…' : purgeConfirming ? 'Sure?' : 'Run Purge Now' }}
</button> </button>
<div v-if="store.purgeResult" class="purge-result"> <div v-if="store.purgeResult" class="purge-result">
<p>Rows deleted: {{ store.purgeResult.rows_deleted }}</p> <p>{{ store.purgeResult.rows_deleted }} jobs removed</p>
<p>Files deleted: {{ store.purgeResult.files_deleted }}</p> <p>{{ store.purgeResult.files_deleted }} files deleted</p>
<p>Files already gone: {{ store.purgeResult.files_missing }}</p> <p v-if="store.purgeResult.sessions_deleted">{{ store.purgeResult.sessions_deleted }} sessions cleared</p>
<p>Active jobs skipped: {{ store.purgeResult.active_skipped }}</p> <p v-if="store.purgeResult.active_skipped">{{ store.purgeResult.active_skipped }} active jobs skipped</p>
</div> </div>
</div> </div>
</div> </div>
@ -414,6 +440,10 @@ function formatFilesize(bytes: number | null): string {
</div> </div>
</div> </div>
</div> </div>
<p class="field-hint" style="margin-top: var(--space-lg);">
All settings are applied immediately but reset on server restart.
</p>
</div> </div>
</template> </template>
</div> </div>
@ -532,12 +562,18 @@ h3 {
color: var(--color-bg); color: var(--color-bg);
font-weight: 600; font-weight: 600;
margin-top: var(--space-md); margin-top: var(--space-md);
min-width: 130px;
transition: background-color 0.15s;
} }
.btn-purge:hover:not(:disabled) { .btn-purge:hover:not(:disabled) {
background: var(--color-error); background: var(--color-error);
} }
.btn-purge.btn-confirm {
background: var(--color-error);
}
.purge-result { .purge-result {
margin-top: var(--space-md); margin-top: var(--space-md);
padding: var(--space-md); padding: var(--space-md);

View file

@ -23,6 +23,7 @@ interface PurgeResult {
files_deleted: number files_deleted: number
files_missing: number files_missing: number
active_skipped: number active_skipped: number
sessions_deleted?: number
} }
export const useAdminStore = defineStore('admin', () => { export const useAdminStore = defineStore('admin', () => {