From 3533b694f711bee13268cce0e19c91e672525d5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 15 Jun 2026 13:08:47 +0200 Subject: [PATCH] fix in job retention --- pytorrent/services/operation_logs.py | 76 +++++++++++++++++++++------- pytorrent/static/js/operationLogs.js | 2 +- 2 files changed, 60 insertions(+), 18 deletions(-) diff --git a/pytorrent/services/operation_logs.py b/pytorrent/services/operation_logs.py index a0d9058..15faa5f 100644 --- a/pytorrent/services/operation_logs.py +++ b/pytorrent/services/operation_logs.py @@ -155,19 +155,31 @@ def _next_retention_run(settings: dict, category: str) -> str | None: return (last + timedelta(hours=int(settings.get(f"{category}_retention_interval_hours") or 24))).isoformat(timespec="seconds") +def _profile_settings_owner_id() -> int: + """Use one canonical owner for profile-level retention settings.""" + return 0 + + def get_settings(profile_id: int = 0, user_id: int | None = None) -> dict: - user_id = _user_id(user_id) + """Return profile-level retention settings, with legacy per-user rows as fallback only.""" profile_id = int(profile_id or 0) + owner_id = _profile_settings_owner_id() with connect() as conn: row = conn.execute( - "SELECT * FROM operation_log_settings WHERE profile_id=? ORDER BY updated_at DESC, user_id ASC LIMIT 1", - (profile_id,), + """ + SELECT * + FROM operation_log_settings + WHERE profile_id=? + ORDER BY CASE WHEN user_id=? THEN 0 ELSE 1 END, updated_at DESC, user_id ASC + LIMIT 1 + """, + (profile_id, owner_id), ).fetchone() if not row: - data = {"owner_user_id": user_id, "profile_id": profile_id, **DEFAULT_SETTINGS} + data = {"owner_user_id": owner_id, "profile_id": profile_id, **DEFAULT_SETTINGS} else: data = {**DEFAULT_SETTINGS, **dict(row)} - data["owner_user_id"] = int(data.pop("user_id", user_id) or user_id) + data["owner_user_id"] = int(data.pop("user_id", owner_id) or owner_id) data["profile_id"] = profile_id data["retention_mode"] = _sanitize_mode(data.get("retention_mode"), DEFAULT_SETTINGS["retention_mode"]) data["retention_days"] = _sanitize_days(data.get("retention_days"), DEFAULT_SETTINGS["retention_days"]) @@ -186,9 +198,11 @@ def get_settings(profile_id: int = 0, user_id: int | None = None) -> dict: def save_settings(profile_id: int, data: dict, user_id: int | None = None) -> dict: user_id = _user_id(user_id) profile_id = int(profile_id or 0) + owner_id = _profile_settings_owner_id() now = utcnow() if not auth.can_write_profile(profile_id, user_id): raise PermissionError("No write access to profile") + # Note: retention is intentionally shared by every user that works on the same profile. current = get_settings(profile_id, user_id) legacy_mode = _sanitize_mode(data.get("retention_mode") or current.get("retention_mode"), DEFAULT_SETTINGS["retention_mode"]) legacy_days = _sanitize_days(data.get("retention_days") or current.get("retention_days"), DEFAULT_SETTINGS["retention_days"]) @@ -237,7 +251,7 @@ def save_settings(profile_id: int, data: dict, user_id: int | None = None) -> di updated_at=excluded.updated_at """, ( - user_id, profile_id, values["retention_mode"], values["retention_days"], values["retention_lines"], values["retention_interval_hours"], + owner_id, profile_id, values["retention_mode"], values["retention_days"], values["retention_lines"], values["retention_interval_hours"], values["job_retention_mode"], values["job_retention_days"], values["job_retention_lines"], values["job_retention_interval_hours"], values["job_last_retention_run_at"], values["job_last_retention_deleted"], values["operation_retention_mode"], values["operation_retention_days"], values["operation_retention_lines"], values["operation_retention_interval_hours"], values["operation_last_retention_run_at"], values["operation_last_retention_deleted"], now, now, @@ -478,29 +492,57 @@ def _apply_retention_category(conn, profile_id: int, settings: dict, category: s def _update_retention_metadata(conn, profile_id: int, category: str, deleted: int, settings: dict, user_id: int | None = None) -> None: + """Update last retention state on the shared profile settings row.""" now = utcnow() - owner_id = int(settings.get("owner_user_id") or _user_id(user_id)) + owner_id = _profile_settings_owner_id() profile_id = int(profile_id or 0) cur = conn.execute( f""" UPDATE operation_log_settings SET {category}_last_retention_run_at=?, {category}_last_retention_deleted=?, updated_at=? - WHERE profile_id=? + WHERE user_id=? AND profile_id=? """, - (now, int(deleted or 0), now, profile_id), + (now, int(deleted or 0), now, owner_id, profile_id), ) if int(cur.rowcount or 0) == 0: + # Note: preserve legacy settings when creating the shared profile row lazily. + values = { + "retention_mode": _sanitize_mode(settings.get("retention_mode"), DEFAULT_SETTINGS["retention_mode"]), + "retention_days": _sanitize_days(settings.get("retention_days"), DEFAULT_SETTINGS["retention_days"]), + "retention_lines": _sanitize_lines(settings.get("retention_lines"), DEFAULT_SETTINGS["retention_lines"]), + "retention_interval_hours": _sanitize_interval(settings.get("retention_interval_hours"), DEFAULT_SETTINGS["retention_interval_hours"]), + } + for cat, defaults in DEFAULT_CATEGORY_SETTINGS.items(): + values[f"{cat}_retention_mode"] = _sanitize_mode(settings.get(f"{cat}_retention_mode"), defaults["retention_mode"]) + values[f"{cat}_retention_days"] = _sanitize_days(settings.get(f"{cat}_retention_days"), defaults["retention_days"]) + values[f"{cat}_retention_lines"] = _sanitize_lines(settings.get(f"{cat}_retention_lines"), defaults["retention_lines"]) + values[f"{cat}_retention_interval_hours"] = _sanitize_interval(settings.get(f"{cat}_retention_interval_hours"), defaults["retention_interval_hours"]) + values[f"{cat}_last_retention_run_at"] = settings.get(f"{cat}_last_retention_run_at") + values[f"{cat}_last_retention_deleted"] = int(settings.get(f"{cat}_last_retention_deleted") or 0) + values[f"{category}_last_retention_run_at"] = now + values[f"{category}_last_retention_deleted"] = int(deleted or 0) conn.execute( """ - INSERT INTO operation_log_settings(user_id, profile_id, created_at, updated_at) - VALUES(?,?,?,?) - ON CONFLICT(user_id, profile_id) DO UPDATE SET updated_at=excluded.updated_at + INSERT INTO operation_log_settings( + user_id, profile_id, retention_mode, retention_days, retention_lines, + retention_interval_hours, + job_retention_mode, job_retention_days, job_retention_lines, job_retention_interval_hours, job_last_retention_run_at, job_last_retention_deleted, + operation_retention_mode, operation_retention_days, operation_retention_lines, operation_retention_interval_hours, operation_last_retention_run_at, operation_last_retention_deleted, + created_at, updated_at + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(user_id, profile_id) DO UPDATE SET + job_last_retention_run_at=excluded.job_last_retention_run_at, + job_last_retention_deleted=excluded.job_last_retention_deleted, + operation_last_retention_run_at=excluded.operation_last_retention_run_at, + operation_last_retention_deleted=excluded.operation_last_retention_deleted, + updated_at=excluded.updated_at """, - (owner_id, profile_id, now, now), - ) - conn.execute( - f"UPDATE operation_log_settings SET {category}_last_retention_run_at=?, {category}_last_retention_deleted=?, updated_at=? WHERE profile_id=?", - (now, int(deleted or 0), now, profile_id), + ( + owner_id, profile_id, values["retention_mode"], values["retention_days"], values["retention_lines"], values["retention_interval_hours"], + values["job_retention_mode"], values["job_retention_days"], values["job_retention_lines"], values["job_retention_interval_hours"], values["job_last_retention_run_at"], values["job_last_retention_deleted"], + values["operation_retention_mode"], values["operation_retention_days"], values["operation_retention_lines"], values["operation_retention_interval_hours"], values["operation_last_retention_run_at"], values["operation_last_retention_deleted"], + now, now, + ), ) diff --git a/pytorrent/static/js/operationLogs.js b/pytorrent/static/js/operationLogs.js index f27dbe1..123b49f 100644 --- a/pytorrent/static/js/operationLogs.js +++ b/pytorrent/static/js/operationLogs.js @@ -1 +1 @@ -export const operationLogsSource = " let operationLogsPage = 0;\n let operationLogsLastData = null;\n const operationLogsLimit = 200;\n const OPERATION_LOG_VIEW_STORAGE_KEY = 'pytorrent.operationLogView.v4';\n const OPERATION_LOG_TYPES = ['', 'torrent_added', 'torrent_removed', 'torrent_completed', 'job_queued', 'job_started', 'job_done', 'job_failed', 'job_retry', 'job_cancelled', 'job_timeout', 'job_resubmitted', 'job_forced'];\n\n function readOperationLogViewPrefs(){\n try{\n const current = JSON.parse(localStorage.getItem(OPERATION_LOG_VIEW_STORAGE_KEY) || '{}') || {};\n const previousV3 = JSON.parse(localStorage.getItem('pytorrent.operationLogView.v3') || '{}') || {};\n const previousV2 = JSON.parse(localStorage.getItem('pytorrent.operationLogView.v2') || '{}') || {};\n const previousV1 = JSON.parse(localStorage.getItem('pytorrent.operationLogView.v1') || '{}') || {};\n // Note: Older detail visibility is intentionally not migrated, so Show details starts disabled by default.\n const {showDetails: _oldShowDetailsV3, ...safePreviousV3} = previousV3;\n const {showDetails: _oldShowDetailsV2, ...safePreviousV2} = previousV2;\n const {showDetails: _oldShowDetailsV1, ...safePreviousV1} = previousV1;\n return {...safePreviousV1, ...safePreviousV2, ...safePreviousV3, ...current};\n }catch(e){ return {}; }\n }\n\n function saveOperationLogViewPrefs(prefs){ localStorage.setItem(OPERATION_LOG_VIEW_STORAGE_KEY, JSON.stringify(prefs)); }\n\n function operationLogViewPrefs(){\n const prefs = readOperationLogViewPrefs();\n return {\n defaultType: String(prefs.defaultType ?? ''),\n hideJobs: prefs.hideJobs !== false,\n showDetails: prefs.showDetails === true,\n };\n }\n\n function operationLogBadge(type, severity){\n const cls = severity === 'danger' ? 'danger' : severity === 'warning' ? 'warning' : type === 'torrent_completed' ? 'success' : type === 'torrent_removed' ? 'secondary' : 'info';\n return `${esc(type || 'log')}`;\n }\n\n function operationLogTypeLabel(type){\n const labels = {\n '': 'All types',\n torrent_added: 'Torrent added',\n torrent_removed: 'Torrent removed',\n torrent_completed: 'Torrent completed',\n job_queued: 'Job queued',\n job_started: 'Job started',\n job_done: 'Job done',\n job_failed: 'Job failed',\n job_retry: 'Job retry',\n job_cancelled: 'Job cancelled',\n job_timeout: 'Job timeout',\n job_resubmitted: 'Job resubmitted',\n job_forced: 'Job forced'\n };\n return labels[type] || type || 'All types';\n }\n\n function operationLogRetentionMeta(settings={}, category){\n const last = settings[`${category}_last_retention_run_at`];\n const next = settings[`${category}_next_retention_run_at`];\n const deleted = settings[`${category}_last_retention_deleted`] ?? 0;\n return `
Last run: ${last ? humanDateCell(last) : '-'}Next run: ${next ? humanDateCell(next) : 'after next due log'}Last deleted: ${esc(deleted)}
`;\n }\n\n function renderOperationLogStats(stats={}){\n const card = (label, value) => `
${esc(label)}${esc(value ?? 0)}
`;\n const types = (stats.by_type || []).map(x => card(x.event_type || 'unknown', x.n)).join('');\n const daily = (stats.by_day || []).map(x => `
${esc(x.bucket)}${esc(x.n)}
`).join('');\n const monthly = (stats.by_month || []).map(x => `
${esc(x.bucket)}${esc(x.n)}
`).join('');\n const actions = (stats.top_actions || []).map(x => `
${esc(x.action)}${esc(x.n)}
`).join('');\n const settings = stats.settings || {};\n const retention = `
Job retention
${operationLogRetentionMeta(settings, 'job')}
Operation retention
${operationLogRetentionMeta(settings, 'operation')}
`;\n return `
${card('Total logs', stats.total || 0)}${types}
${retention}
Daily count
${daily || 'No data.'}
Monthly count
${monthly || 'No data.'}
Top actions
${actions || 'No data.'}
`;\n }\n\n function fillOperationLogSettings(settings={}){\n [['job',7,2000], ['operation',30,5000]].forEach(([category, daysDefault, linesDefault]) => {\n const cap = category === 'job' ? 'Job' : 'Operation';\n const mode = $(`operationLog${cap}RetentionMode`);\n const days = $(`operationLog${cap}RetentionDays`);\n const lines = $(`operationLog${cap}RetentionLines`);\n const interval = $(`operationLog${cap}RetentionInterval`);\n const meta = $(`operationLog${cap}RetentionMeta`);\n if(mode) mode.value = settings[`${category}_retention_mode`] || 'days';\n if(days) days.value = settings[`${category}_retention_days`] || daysDefault;\n if(lines) lines.value = settings[`${category}_retention_lines`] || linesDefault;\n if(interval) interval.value = settings[`${category}_retention_interval_hours`] || 24;\n if(meta) meta.innerHTML = operationLogRetentionMeta(settings, category);\n });\n }\n\n function operationLogQuery(){\n const params = new URLSearchParams();\n params.set('limit', String(operationLogsLimit));\n params.set('offset', String(operationLogsPage * operationLogsLimit));\n const prefs = operationLogViewPrefs();\n const type = $('operationLogTypeFilter')?.value ?? prefs.defaultType;\n const q = $('operationLogSearch')?.value || '';\n const hideJobs = $('operationLogHideJobs') ? $('operationLogHideJobs').checked : prefs.hideJobs;\n if(type) params.set('type', type);\n if(q) params.set('q', q);\n if(hideJobs) params.set('hide_jobs', '1');\n return params.toString();\n }\n\n function operationLogTorrentCell(row){\n const value = row.torrent_name || row.torrent_hash || '-';\n // Note: A fixed character cap prevents long torrent names from stretching the Logs modal; the full value stays in the tooltip.\n return compactCell(value, 42);\n }\n\n function operationLogDetailLabel(key){\n return String(key || '')\n .replace(/_/g, ' ')\n .replace(/\\b\\w/g, x => x.toUpperCase());\n }\n\n function operationLogFormatBytes(value){\n const bytes = Number(value);\n if(!Number.isFinite(bytes) || bytes < 0) return String(value ?? '');\n const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];\n let size = bytes;\n let unit = 0;\n while(size >= 1024 && unit < units.length - 1){ size /= 1024; unit += 1; }\n const digits = unit === 0 ? 0 : size >= 100 ? 0 : size >= 10 ? 1 : 2;\n return `${size.toFixed(digits)} ${units[unit]}`;\n }\n\n function operationLogFormatDetailValue(key, value){\n if(value === null || value === undefined || value === '') return '';\n const name = String(key || '').toLowerCase();\n if((name === 'size' || name.endsWith('_size') || name.endsWith('_bytes') || name === 'bytes') && Number.isFinite(Number(value))){\n return operationLogFormatBytes(value);\n }\n if(typeof value === 'boolean') return value ? 'yes' : 'no';\n if(Array.isArray(value)){\n if(!value.length) return '';\n if(value.length <= 3 && value.every(item => item === null || ['string', 'number', 'boolean'].includes(typeof item))){\n return value.map(item => String(item)).join(', ');\n }\n return `${value.length} item(s)`;\n }\n if(typeof value === 'object'){\n const entries = Object.entries(value).filter(([, item]) => item !== null && item !== undefined && item !== '');\n if(!entries.length) return '';\n if(entries.length <= 3 && entries.every(([, item]) => ['string', 'number', 'boolean'].includes(typeof item))){\n return entries.map(([childKey, item]) => `${operationLogDetailLabel(childKey)}: ${operationLogFormatDetailValue(childKey, item)}`).join(', ');\n }\n return `${entries.length} field(s)`;\n }\n return String(value);\n }\n\n function operationLogDetailEntries(details){\n const data = details && typeof details === 'object' && !Array.isArray(details) ? details : {};\n return Object.entries(data)\n .map(([key, value]) => [key, operationLogFormatDetailValue(key, value)])\n .filter(([, value]) => value);\n }\n\n function operationLogDetailsCell(row){\n const entries = operationLogDetailEntries(row.details);\n if(!entries.length) return 'No details.';\n // Note: Details use a compact key-value table to keep the Logs modal narrow and easy to scan.\n return `
Details${entries.map(([key, value]) => ``).join('')}
${esc(operationLogDetailLabel(key))}${compactCell(value, 160)}
`;\n }\n\n function operationLogColumns(){\n return [\n ['Time', 'operation-log-col-time'],\n ['Type', 'operation-log-col-type'],\n ['Source', 'operation-log-col-source'],\n ['Action', 'operation-log-col-action'],\n ['Torrent', 'operation-log-col-torrent'],\n ['Message', 'operation-log-col-message'],\n ];\n }\n\n function operationLogRowCells(row){\n return [\n humanDateCell(row.created_at),\n operationLogBadge(row.event_type, row.severity),\n esc(row.source || '-'),\n esc(row.action || '-'),\n operationLogTorrentCell(row),\n compactCell(row.message || '', 260),\n ];\n }\n\n function operationLogTable(rows){\n const columns = operationLogColumns();\n const showDetails = operationLogViewPrefs().showDetails;\n const head = `${columns.map(([label]) => `${esc(label)}`).join('')}`;\n const colgroup = `${columns.map(([, cls]) => ``).join('')}`;\n const body = rows.map(row => {\n const main = `${operationLogRowCells(row).map(cell => `${cell}`).join('')}`;\n const details = showDetails ? `${operationLogDetailsCell(row)}` : '';\n return main + details;\n }).join('');\n return `
${colgroup}${head}${body}
`;\n }\n\n function renderOperationLogs(data={}){\n const box = $('operationLogsTable');\n if(!box) return;\n const rows = data.logs || [];\n const total = Number(data.total || 0);\n if($('operationLogTypeFilter') && !$('operationLogTypeFilter').dataset.ready){\n const prefs = operationLogViewPrefs();\n $('operationLogTypeFilter').innerHTML = OPERATION_LOG_TYPES.map(t => ``).join('');\n $('operationLogTypeFilter').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n $('operationLogTypeFilter').dataset.ready = '1';\n }\n box.innerHTML = operationLogTable(rows);\n if(!rows.length) box.innerHTML = '
No logs.No entries match current filters.
';\n const pages = Math.max(1, Math.ceil(total / operationLogsLimit));\n if($('operationLogsPager')) $('operationLogsPager').innerHTML = `Page ${operationLogsPage + 1} / ${pages} ${total} logs`;\n $('operationLogsPrev')?.addEventListener('click', () => { operationLogsPage = Math.max(0, operationLogsPage - 1); loadOperationLogs(); });\n $('operationLogsNext')?.addEventListener('click', () => { operationLogsPage += 1; loadOperationLogs(); });\n if($('operationLogStats') && data.stats) $('operationLogStats').innerHTML = renderOperationLogStats(data.stats || {});\n fillOperationLogSettings(data.settings || data.stats?.settings || {});\n }\n\n function syncOperationLogViewControls(){\n const prefs = operationLogViewPrefs();\n if($('operationLogDefaultType')) $('operationLogDefaultType').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n if($('operationLogHideJobsDefault')) $('operationLogHideJobsDefault').checked = prefs.hideJobs;\n if($('operationLogHideJobs')) $('operationLogHideJobs').checked = prefs.hideJobs;\n if($('operationLogShowDetails')) $('operationLogShowDetails').checked = prefs.showDetails;\n if($('operationLogShowDetailsDefault')) $('operationLogShowDetailsDefault').checked = prefs.showDetails;\n if($('operationLogTypeFilter') && !$('operationLogTypeFilter').value) $('operationLogTypeFilter').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n }\n\n function saveOperationLogViewSettings(){\n const current = operationLogViewPrefs();\n const prefs = {\n defaultType: $('operationLogDefaultType')?.value || current.defaultType || '',\n hideJobs: $('operationLogHideJobsDefault') ? $('operationLogHideJobsDefault').checked : current.hideJobs,\n showDetails: $('operationLogShowDetailsDefault') ? $('operationLogShowDetailsDefault').checked : current.showDetails,\n };\n saveOperationLogViewPrefs(prefs);\n syncOperationLogViewControls();\n toast('Log view settings saved.', 'success');\n loadOperationLogs(true);\n }\n\n function saveOperationLogDetailsPreference(){\n const prefs = {...operationLogViewPrefs(), showDetails: $('operationLogShowDetails')?.checked === true};\n saveOperationLogViewPrefs(prefs);\n if($('operationLogShowDetailsDefault')) $('operationLogShowDetailsDefault').checked = prefs.showDetails;\n renderOperationLogs(operationLogsLastData || {});\n }\n\n async function loadOperationLogs(reset=false){\n const box = $('operationLogsTable');\n if(!box) return;\n if(reset) operationLogsPage = 0;\n box.innerHTML = ' Loading logs...';\n try{\n const query = operationLogQuery() + (reset ? '&stats=1' : '');\n const data = await fetch(`/api/operation-logs?${query}`, {cache: 'no-store'}).then(r => r.json());\n if(!data.ok) throw new Error(data.error || 'Cannot load logs');\n operationLogsLastData = data;\n renderOperationLogs(data);\n }catch(e){\n box.innerHTML = `
${esc(e.message)}
`;\n }\n }\n\n async function saveOperationLogSettings(){\n try{\n const data = await post('/api/operation-logs/settings', {\n job_retention_mode: $('operationLogJobRetentionMode')?.value || 'days',\n job_retention_days: Number($('operationLogJobRetentionDays')?.value || 7),\n job_retention_lines: Number($('operationLogJobRetentionLines')?.value || 2000),\n job_retention_interval_hours: Number($('operationLogJobRetentionInterval')?.value || 24),\n operation_retention_mode: $('operationLogOperationRetentionMode')?.value || 'days',\n operation_retention_days: Number($('operationLogOperationRetentionDays')?.value || 30),\n operation_retention_lines: Number($('operationLogOperationRetentionLines')?.value || 5000),\n operation_retention_interval_hours: Number($('operationLogOperationRetentionInterval')?.value || 24),\n });\n fillOperationLogSettings(data.settings || {});\n toast('Log retention saved.', 'success');\n loadOperationLogs(true);\n }catch(e){ toast(e.message, 'danger'); }\n }\n\n async function applyOperationLogRetentionNow(category='all'){\n try{\n const j = await post('/api/operation-logs/apply-retention', {category});\n const job = j.categories?.job?.deleted ?? 0;\n const operation = j.categories?.operation?.deleted ?? 0;\n const msg = category === 'job' ? `Deleted ${job} job log entries.` : category === 'operation' ? `Deleted ${operation} operation log entries.` : `Deleted ${j.deleted || 0} log entries. Jobs: ${job}, operations: ${operation}.`;\n toast(msg, 'success');\n loadOperationLogs(true);\n }catch(e){ toast(e.message, 'danger'); }\n }\n\n function bindOperationLogEvents(){\n syncOperationLogViewControls();\n $('logsModal')?.addEventListener('show.bs.modal', () => { syncOperationLogViewControls(); loadOperationLogs(true); });\n $('refreshOperationLogsBtn')?.addEventListener('click', () => loadOperationLogs(true));\n $('operationLogTypeFilter')?.addEventListener('change', () => loadOperationLogs(true));\n $('operationLogHideJobs')?.addEventListener('change', () => loadOperationLogs(true));\n $('operationLogShowDetails')?.addEventListener('change', saveOperationLogDetailsPreference);\n $('operationLogSearch')?.addEventListener('input', debounce(() => loadOperationLogs(true), 300));\n $('saveOperationLogViewBtn')?.addEventListener('click', saveOperationLogViewSettings);\n $('operationLogDefaultType')?.addEventListener('change', saveOperationLogViewSettings);\n $('operationLogHideJobsDefault')?.addEventListener('change', saveOperationLogViewSettings);\n $('operationLogShowDetailsDefault')?.addEventListener('change', saveOperationLogViewSettings);\n $('saveOperationLogRetentionBtn')?.addEventListener('click', saveOperationLogSettings);\n $('applyOperationLogRetentionBtn')?.addEventListener('click', () => applyOperationLogRetentionNow('all'));\n $('applyJobLogRetentionBtn')?.addEventListener('click', () => applyOperationLogRetentionNow('job'));\n $('applyOnlyOperationLogRetentionBtn')?.addEventListener('click', () => applyOperationLogRetentionNow('operation'));\n $('clearOperationLogsBtn')?.addEventListener('click', async () => { if(!confirm('Clear operation logs for this profile?')) return; try{ const j = await post('/api/operation-logs/clear', {event_type: $('operationLogTypeFilter')?.value || ''}); toast(`Deleted ${j.deleted || 0} log entries.`, 'success'); loadOperationLogs(true); }catch(e){ toast(e.message, 'danger'); } });\n }\n"; +export const operationLogsSource = ' let operationLogsPage = 0;\n let operationLogsLastData = null;\n const operationLogsLimit = 200;\n const OPERATION_LOG_VIEW_STORAGE_KEY = \'pytorrent.operationLogView.v4\';\n const OPERATION_LOG_TYPES = [\'\', \'torrent_added\', \'torrent_removed\', \'torrent_completed\', \'job_queued\', \'job_started\', \'job_done\', \'job_failed\', \'job_retry\', \'job_cancelled\', \'job_timeout\', \'job_resubmitted\', \'job_forced\'];\n\n function readOperationLogViewPrefs(){\n try{\n const current = JSON.parse(localStorage.getItem(OPERATION_LOG_VIEW_STORAGE_KEY) || \'{}\') || {};\n const previousV3 = JSON.parse(localStorage.getItem(\'pytorrent.operationLogView.v3\') || \'{}\') || {};\n const previousV2 = JSON.parse(localStorage.getItem(\'pytorrent.operationLogView.v2\') || \'{}\') || {};\n const previousV1 = JSON.parse(localStorage.getItem(\'pytorrent.operationLogView.v1\') || \'{}\') || {};\n // Note: Older detail visibility is intentionally not migrated, so Show details starts disabled by default.\n const {showDetails: _oldShowDetailsV3, ...safePreviousV3} = previousV3;\n const {showDetails: _oldShowDetailsV2, ...safePreviousV2} = previousV2;\n const {showDetails: _oldShowDetailsV1, ...safePreviousV1} = previousV1;\n return {...safePreviousV1, ...safePreviousV2, ...safePreviousV3, ...current};\n }catch(e){ return {}; }\n }\n\n function saveOperationLogViewPrefs(prefs){ localStorage.setItem(OPERATION_LOG_VIEW_STORAGE_KEY, JSON.stringify(prefs)); }\n\n function operationLogViewPrefs(){\n const prefs = readOperationLogViewPrefs();\n return {\n defaultType: String(prefs.defaultType ?? \'\'),\n hideJobs: prefs.hideJobs !== false,\n showDetails: prefs.showDetails === true,\n };\n }\n\n function operationLogBadge(type, severity){\n const cls = severity === \'danger\' ? \'danger\' : severity === \'warning\' ? \'warning\' : type === \'torrent_completed\' ? \'success\' : type === \'torrent_removed\' ? \'secondary\' : \'info\';\n return `${esc(type || \'log\')}`;\n }\n\n function operationLogTypeLabel(type){\n const labels = {\n \'\': \'All types\',\n torrent_added: \'Torrent added\',\n torrent_removed: \'Torrent removed\',\n torrent_completed: \'Torrent completed\',\n job_queued: \'Job queued\',\n job_started: \'Job started\',\n job_done: \'Job done\',\n job_failed: \'Job failed\',\n job_retry: \'Job retry\',\n job_cancelled: \'Job cancelled\',\n job_timeout: \'Job timeout\',\n job_resubmitted: \'Job resubmitted\',\n job_forced: \'Job forced\'\n };\n return labels[type] || type || \'All types\';\n }\n\n function operationLogRetentionMeta(settings={}, category){\n const last = settings[`${category}_last_retention_run_at`];\n const next = settings[`${category}_next_retention_run_at`];\n const deleted = settings[`${category}_last_retention_deleted`] ?? 0;\n return `
Last run: ${last ? humanDateCell(last) : \'-\'}Next run: ${next ? humanDateCell(next) : \'after next due log\'}Last deleted: ${esc(deleted)}
`;\n }\n\n function renderOperationLogStats(stats={}){\n const card = (label, value) => `
${esc(label)}${esc(value ?? 0)}
`;\n const types = (stats.by_type || []).map(x => card(x.event_type || \'unknown\', x.n)).join(\'\');\n const daily = (stats.by_day || []).map(x => `
${esc(x.bucket)}${esc(x.n)}
`).join(\'\');\n const monthly = (stats.by_month || []).map(x => `
${esc(x.bucket)}${esc(x.n)}
`).join(\'\');\n const actions = (stats.top_actions || []).map(x => `
${esc(x.action)}${esc(x.n)}
`).join(\'\');\n const settings = stats.settings || {};\n const retention = `
Job retention
${operationLogRetentionMeta(settings, \'job\')}
Operation retention
${operationLogRetentionMeta(settings, \'operation\')}
`;\n return `
${card(\'Total logs\', stats.total || 0)}${types}
${retention}
Daily count
${daily || \'No data.\'}
Monthly count
${monthly || \'No data.\'}
Top actions
${actions || \'No data.\'}
`;\n }\n\n function fillOperationLogSettings(settings={}){\n [[\'job\',7,2000], [\'operation\',30,5000]].forEach(([category, daysDefault, linesDefault]) => {\n const cap = category === \'job\' ? \'Job\' : \'Operation\';\n const mode = $(`operationLog${cap}RetentionMode`);\n const days = $(`operationLog${cap}RetentionDays`);\n const lines = $(`operationLog${cap}RetentionLines`);\n const interval = $(`operationLog${cap}RetentionInterval`);\n const meta = $(`operationLog${cap}RetentionMeta`);\n if(mode) mode.value = settings[`${category}_retention_mode`] || \'days\';\n if(days) days.value = settings[`${category}_retention_days`] || daysDefault;\n if(lines) lines.value = settings[`${category}_retention_lines`] || linesDefault;\n if(interval) interval.value = settings[`${category}_retention_interval_hours`] || 24;\n if(meta) meta.innerHTML = operationLogRetentionMeta(settings, category);\n });\n }\n\n function updateOperationLogRetentionState(settings={}){\n // Note: Keep the Tools panel and Logs modal retention metadata in sync after saves and manual cleanup runs.\n fillOperationLogSettings(settings);\n if(operationLogsLastData){\n operationLogsLastData.settings = settings;\n operationLogsLastData.stats = {...(operationLogsLastData.stats || {}), settings};\n }\n if($(\'operationLogStats\') && operationLogsLastData?.stats){\n $(\'operationLogStats\').innerHTML = renderOperationLogStats(operationLogsLastData.stats);\n }\n }\n\n function currentOperationLogRetentionPayload(){\n // Note: Manual Apply buttons use the values visible in the form, so unsaved edits are not silently ignored.\n return {\n job_retention_mode: $(\'operationLogJobRetentionMode\')?.value || \'days\',\n job_retention_days: Number($(\'operationLogJobRetentionDays\')?.value || 7),\n job_retention_lines: Number($(\'operationLogJobRetentionLines\')?.value || 2000),\n job_retention_interval_hours: Number($(\'operationLogJobRetentionInterval\')?.value || 24),\n operation_retention_mode: $(\'operationLogOperationRetentionMode\')?.value || \'days\',\n operation_retention_days: Number($(\'operationLogOperationRetentionDays\')?.value || 30),\n operation_retention_lines: Number($(\'operationLogOperationRetentionLines\')?.value || 5000),\n operation_retention_interval_hours: Number($(\'operationLogOperationRetentionInterval\')?.value || 24),\n };\n }\n\n function operationLogQuery(){\n const params = new URLSearchParams();\n params.set(\'limit\', String(operationLogsLimit));\n params.set(\'offset\', String(operationLogsPage * operationLogsLimit));\n const prefs = operationLogViewPrefs();\n const type = $(\'operationLogTypeFilter\')?.value ?? prefs.defaultType;\n const q = $(\'operationLogSearch\')?.value || \'\';\n const hideJobs = $(\'operationLogHideJobs\') ? $(\'operationLogHideJobs\').checked : prefs.hideJobs;\n if(type) params.set(\'type\', type);\n if(q) params.set(\'q\', q);\n if(hideJobs) params.set(\'hide_jobs\', \'1\');\n return params.toString();\n }\n\n function operationLogTorrentCell(row){\n const value = row.torrent_name || row.torrent_hash || \'-\';\n // Note: A fixed character cap prevents long torrent names from stretching the Logs modal; the full value stays in the tooltip.\n return compactCell(value, 42);\n }\n\n function operationLogDetailLabel(key){\n return String(key || \'\')\n .replace(/_/g, \' \')\n .replace(/\\b\\w/g, x => x.toUpperCase());\n }\n\n function operationLogFormatBytes(value){\n const bytes = Number(value);\n if(!Number.isFinite(bytes) || bytes < 0) return String(value ?? \'\');\n const units = [\'B\', \'KB\', \'MB\', \'GB\', \'TB\', \'PB\'];\n let size = bytes;\n let unit = 0;\n while(size >= 1024 && unit < units.length - 1){ size /= 1024; unit += 1; }\n const digits = unit === 0 ? 0 : size >= 100 ? 0 : size >= 10 ? 1 : 2;\n return `${size.toFixed(digits)} ${units[unit]}`;\n }\n\n function operationLogFormatDetailValue(key, value){\n if(value === null || value === undefined || value === \'\') return \'\';\n const name = String(key || \'\').toLowerCase();\n if((name === \'size\' || name.endsWith(\'_size\') || name.endsWith(\'_bytes\') || name === \'bytes\') && Number.isFinite(Number(value))){\n return operationLogFormatBytes(value);\n }\n if(typeof value === \'boolean\') return value ? \'yes\' : \'no\';\n if(Array.isArray(value)){\n if(!value.length) return \'\';\n if(value.length <= 3 && value.every(item => item === null || [\'string\', \'number\', \'boolean\'].includes(typeof item))){\n return value.map(item => String(item)).join(\', \');\n }\n return `${value.length} item(s)`;\n }\n if(typeof value === \'object\'){\n const entries = Object.entries(value).filter(([, item]) => item !== null && item !== undefined && item !== \'\');\n if(!entries.length) return \'\';\n if(entries.length <= 3 && entries.every(([, item]) => [\'string\', \'number\', \'boolean\'].includes(typeof item))){\n return entries.map(([childKey, item]) => `${operationLogDetailLabel(childKey)}: ${operationLogFormatDetailValue(childKey, item)}`).join(\', \');\n }\n return `${entries.length} field(s)`;\n }\n return String(value);\n }\n\n function operationLogDetailEntries(details){\n const data = details && typeof details === \'object\' && !Array.isArray(details) ? details : {};\n return Object.entries(data)\n .map(([key, value]) => [key, operationLogFormatDetailValue(key, value)])\n .filter(([, value]) => value);\n }\n\n function operationLogDetailsCell(row){\n const entries = operationLogDetailEntries(row.details);\n if(!entries.length) return \'No details.\';\n // Note: Details use a compact key-value table to keep the Logs modal narrow and easy to scan.\n return `
Details${entries.map(([key, value]) => ``).join(\'\')}
${esc(operationLogDetailLabel(key))}${compactCell(value, 160)}
`;\n }\n\n function operationLogColumns(){\n return [\n [\'Time\', \'operation-log-col-time\'],\n [\'Type\', \'operation-log-col-type\'],\n [\'Source\', \'operation-log-col-source\'],\n [\'Action\', \'operation-log-col-action\'],\n [\'Torrent\', \'operation-log-col-torrent\'],\n [\'Message\', \'operation-log-col-message\'],\n ];\n }\n\n function operationLogRowCells(row){\n return [\n humanDateCell(row.created_at),\n operationLogBadge(row.event_type, row.severity),\n esc(row.source || \'-\'),\n esc(row.action || \'-\'),\n operationLogTorrentCell(row),\n compactCell(row.message || \'\', 260),\n ];\n }\n\n function operationLogTable(rows){\n const columns = operationLogColumns();\n const showDetails = operationLogViewPrefs().showDetails;\n const head = `${columns.map(([label]) => `${esc(label)}`).join(\'\')}`;\n const colgroup = `${columns.map(([, cls]) => ``).join(\'\')}`;\n const body = rows.map(row => {\n const main = `${operationLogRowCells(row).map(cell => `${cell}`).join(\'\')}`;\n const details = showDetails ? `${operationLogDetailsCell(row)}` : \'\';\n return main + details;\n }).join(\'\');\n return `
${colgroup}${head}${body}
`;\n }\n\n function renderOperationLogs(data={}){\n const box = $(\'operationLogsTable\');\n if(!box) return;\n const rows = data.logs || [];\n const total = Number(data.total || 0);\n if($(\'operationLogTypeFilter\') && !$(\'operationLogTypeFilter\').dataset.ready){\n const prefs = operationLogViewPrefs();\n $(\'operationLogTypeFilter\').innerHTML = OPERATION_LOG_TYPES.map(t => ``).join(\'\');\n $(\'operationLogTypeFilter\').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : \'\';\n $(\'operationLogTypeFilter\').dataset.ready = \'1\';\n }\n box.innerHTML = operationLogTable(rows);\n if(!rows.length) box.innerHTML = \'
No logs.No entries match current filters.
\';\n const pages = Math.max(1, Math.ceil(total / operationLogsLimit));\n if($(\'operationLogsPager\')) $(\'operationLogsPager\').innerHTML = `Page ${operationLogsPage + 1} / ${pages} ${total} logs`;\n $(\'operationLogsPrev\')?.addEventListener(\'click\', () => { operationLogsPage = Math.max(0, operationLogsPage - 1); loadOperationLogs(); });\n $(\'operationLogsNext\')?.addEventListener(\'click\', () => { operationLogsPage += 1; loadOperationLogs(); });\n if($(\'operationLogStats\') && data.stats) $(\'operationLogStats\').innerHTML = renderOperationLogStats(data.stats || {});\n fillOperationLogSettings(data.settings || data.stats?.settings || {});\n }\n\n function syncOperationLogViewControls(){\n const prefs = operationLogViewPrefs();\n if($(\'operationLogDefaultType\')) $(\'operationLogDefaultType\').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : \'\';\n if($(\'operationLogHideJobsDefault\')) $(\'operationLogHideJobsDefault\').checked = prefs.hideJobs;\n if($(\'operationLogHideJobs\')) $(\'operationLogHideJobs\').checked = prefs.hideJobs;\n if($(\'operationLogShowDetails\')) $(\'operationLogShowDetails\').checked = prefs.showDetails;\n if($(\'operationLogShowDetailsDefault\')) $(\'operationLogShowDetailsDefault\').checked = prefs.showDetails;\n if($(\'operationLogTypeFilter\') && !$(\'operationLogTypeFilter\').value) $(\'operationLogTypeFilter\').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : \'\';\n }\n\n function saveOperationLogViewSettings(){\n const current = operationLogViewPrefs();\n const prefs = {\n defaultType: $(\'operationLogDefaultType\')?.value || current.defaultType || \'\',\n hideJobs: $(\'operationLogHideJobsDefault\') ? $(\'operationLogHideJobsDefault\').checked : current.hideJobs,\n showDetails: $(\'operationLogShowDetailsDefault\') ? $(\'operationLogShowDetailsDefault\').checked : current.showDetails,\n };\n saveOperationLogViewPrefs(prefs);\n syncOperationLogViewControls();\n toast(\'Log view settings saved.\', \'success\');\n loadOperationLogs(true);\n }\n\n function saveOperationLogDetailsPreference(){\n const prefs = {...operationLogViewPrefs(), showDetails: $(\'operationLogShowDetails\')?.checked === true};\n saveOperationLogViewPrefs(prefs);\n if($(\'operationLogShowDetailsDefault\')) $(\'operationLogShowDetailsDefault\').checked = prefs.showDetails;\n renderOperationLogs(operationLogsLastData || {});\n }\n\n async function loadOperationLogs(reset=false){\n const box = $(\'operationLogsTable\');\n if(!box) return;\n if(reset) operationLogsPage = 0;\n box.innerHTML = \' Loading logs...\';\n try{\n const query = operationLogQuery() + (reset ? \'&stats=1\' : \'\');\n const data = await fetch(`/api/operation-logs?${query}`, {cache: \'no-store\'}).then(r => r.json());\n if(!data.ok) throw new Error(data.error || \'Cannot load logs\');\n operationLogsLastData = data;\n renderOperationLogs(data);\n }catch(e){\n box.innerHTML = `
${esc(e.message)}
`;\n }\n }\n\n async function saveOperationLogSettings(){\n try{\n const data = await post(\'/api/operation-logs/settings\', currentOperationLogRetentionPayload());\n updateOperationLogRetentionState(data.settings || {});\n toast(\'Log retention saved.\', \'success\');\n await loadOperationLogs(true);\n }catch(e){ toast(e.message, \'danger\'); }\n }\n\n async function applyOperationLogRetentionNow(category=\'all\'){\n try{\n const saved = await post(\'/api/operation-logs/settings\', currentOperationLogRetentionPayload());\n updateOperationLogRetentionState(saved.settings || {});\n const j = await post(\'/api/operation-logs/apply-retention\', {category});\n updateOperationLogRetentionState(j.settings || saved.settings || {});\n const job = j.categories?.job?.deleted ?? 0;\n const operation = j.categories?.operation?.deleted ?? 0;\n const msg = category === \'job\' ? `Deleted ${job} job log entries.` : category === \'operation\' ? `Deleted ${operation} operation log entries.` : `Deleted ${j.deleted || 0} log entries. Jobs: ${job}, operations: ${operation}.`;\n toast(msg, \'success\');\n await loadOperationLogs(true);\n }catch(e){ toast(e.message, \'danger\'); }\n }\n\n function bindOperationLogEvents(){\n syncOperationLogViewControls();\n $(\'logsModal\')?.addEventListener(\'show.bs.modal\', () => { syncOperationLogViewControls(); loadOperationLogs(true); });\n $(\'refreshOperationLogsBtn\')?.addEventListener(\'click\', () => loadOperationLogs(true));\n $(\'operationLogTypeFilter\')?.addEventListener(\'change\', () => loadOperationLogs(true));\n $(\'operationLogHideJobs\')?.addEventListener(\'change\', () => loadOperationLogs(true));\n $(\'operationLogShowDetails\')?.addEventListener(\'change\', saveOperationLogDetailsPreference);\n $(\'operationLogSearch\')?.addEventListener(\'input\', debounce(() => loadOperationLogs(true), 300));\n $(\'saveOperationLogViewBtn\')?.addEventListener(\'click\', saveOperationLogViewSettings);\n $(\'operationLogDefaultType\')?.addEventListener(\'change\', saveOperationLogViewSettings);\n $(\'operationLogHideJobsDefault\')?.addEventListener(\'change\', saveOperationLogViewSettings);\n $(\'operationLogShowDetailsDefault\')?.addEventListener(\'change\', saveOperationLogViewSettings);\n $(\'saveOperationLogRetentionBtn\')?.addEventListener(\'click\', saveOperationLogSettings);\n $(\'applyOperationLogRetentionBtn\')?.addEventListener(\'click\', () => applyOperationLogRetentionNow(\'all\'));\n $(\'applyJobLogRetentionBtn\')?.addEventListener(\'click\', () => applyOperationLogRetentionNow(\'job\'));\n $(\'applyOnlyOperationLogRetentionBtn\')?.addEventListener(\'click\', () => applyOperationLogRetentionNow(\'operation\'));\n $(\'clearOperationLogsBtn\')?.addEventListener(\'click\', async () => { if(!confirm(\'Clear operation logs for this profile?\')) return; try{ const j = await post(\'/api/operation-logs/clear\', {event_type: $(\'operationLogTypeFilter\')?.value || \'\'}); toast(`Deleted ${j.deleted || 0} log entries.`, \'success\'); loadOperationLogs(true); }catch(e){ toast(e.message, \'danger\'); } });\n }\n';