Files
pyTorrent/pytorrent/static/js/operationLogs.js
T
Mateusz Gruszczyński 3533b694f7 fix in job retention
2026-06-15 13:08:47 +02:00

2 lines
20 KiB
JavaScript

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 `<span class="badge text-bg-${cls}">${esc(type || \'log\')}</span>`;\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 `<div class="operation-log-retention-meta"><span>Last run: <b>${last ? humanDateCell(last) : \'-\'}</b></span><span>Next run: <b>${next ? humanDateCell(next) : \'after next due log\'}</b></span><span>Last deleted: <b>${esc(deleted)}</b></span></div>`;\n }\n\n function renderOperationLogStats(stats={}){\n const card = (label, value) => `<div class="operation-log-stat"><b>${esc(label)}</b><span>${esc(value ?? 0)}</span></div>`;\n const types = (stats.by_type || []).map(x => card(x.event_type || \'unknown\', x.n)).join(\'\');\n const daily = (stats.by_day || []).map(x => `<div class="operation-log-row"><span>${esc(x.bucket)}</span><b>${esc(x.n)}</b></div>`).join(\'\');\n const monthly = (stats.by_month || []).map(x => `<div class="operation-log-row"><span>${esc(x.bucket)}</span><b>${esc(x.n)}</b></div>`).join(\'\');\n const actions = (stats.top_actions || []).map(x => `<div class="operation-log-row"><span>${esc(x.action)}</span><b>${esc(x.n)}</b></div>`).join(\'\');\n const settings = stats.settings || {};\n const retention = `<div class="operation-log-panels"><section><h6>Job retention</h6>${operationLogRetentionMeta(settings, \'job\')}</section><section><h6>Operation retention</h6>${operationLogRetentionMeta(settings, \'operation\')}</section></div>`;\n return `<div class="operation-log-stats-grid">${card(\'Total logs\', stats.total || 0)}${types}</div>${retention}<div class="operation-log-panels"><section><h6>Daily count</h6>${daily || \'<span class="empty-mini">No data.</span>\'}</section><section><h6>Monthly count</h6>${monthly || \'<span class="empty-mini">No data.</span>\'}</section><section><h6>Top actions</h6>${actions || \'<span class="empty-mini">No data.</span>\'}</section></div>`;\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 \'<span class="text-muted">No details.</span>\';\n // Note: Details use a compact key-value table to keep the Logs modal narrow and easy to scan.\n return `<div class="operation-log-details-inline"><span class="operation-log-details-title">Details</span><table class="operation-log-details-table"><tbody>${entries.map(([key, value]) => `<tr><th scope="row">${esc(operationLogDetailLabel(key))}</th><td>${compactCell(value, 160)}</td></tr>`).join(\'\')}</tbody></table></div>`;\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 = `<thead><tr>${columns.map(([label]) => `<th>${esc(label)}</th>`).join(\'\')}</tr></thead>`;\n const colgroup = `<colgroup>${columns.map(([, cls]) => `<col class="${cls}">`).join(\'\')}</colgroup>`;\n const body = rows.map(row => {\n const main = `<tr class="operation-log-main-row">${operationLogRowCells(row).map(cell => `<td>${cell}</td>`).join(\'\')}</tr>`;\n const details = showDetails ? `<tr class="operation-log-details-row"><td colspan="${columns.length}">${operationLogDetailsCell(row)}</td></tr>` : \'\';\n return main + details;\n }).join(\'\');\n return `<div class="responsive-table-wrap"><table class="table table-sm detail-table operation-log-table">${colgroup}${head}<tbody>${body}</tbody></table></div>`;\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 => `<option value="${esc(t)}">${esc(operationLogTypeLabel(t))}</option>`).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 = \'<div class="empty-state"><i class="fa-solid fa-book"></i><b>No logs.</b><span>No entries match current filters.</span></div>\';\n const pages = Math.max(1, Math.ceil(total / operationLogsLimit));\n if($(\'operationLogsPager\')) $(\'operationLogsPager\').innerHTML = `<button id="operationLogsPrev" class="btn btn-sm btn-outline-secondary" ${operationLogsPage <= 0 ? \'disabled\' : \'\'}>Prev</button><span class="small text-muted">Page ${operationLogsPage + 1} / ${pages} <i class="fa-solid fa-circle fa-2xs modal-meta-separator" aria-hidden="true"></i> ${total} logs</span><button id="operationLogsNext" class="btn btn-sm btn-outline-secondary" ${operationLogsPage >= pages - 1 ? \'disabled\' : \'\'}>Next</button>`;\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 = \'<span class="spinner-border spinner-border-sm"></span> 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 = `<div class="alert alert-danger mb-0">${esc(e.message)}</div>`;\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';