From 67f01e750eba5ceba6660fcc6db322566e95a7dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 3 Jun 2026 22:54:06 +0200 Subject: [PATCH 1/4] fix logs sections --- pytorrent/static/js/operationLogs.js | 2 +- pytorrent/static/styles.css | 90 ++++++++++++++++++---------- pytorrent/templates/index.html | 4 +- 3 files changed, 60 insertions(+), 36 deletions(-) diff --git a/pytorrent/static/js/operationLogs.js b/pytorrent/static/js/operationLogs.js index f9abb4c..cee53b4 100644 --- a/pytorrent/static/js/operationLogs.js +++ b/pytorrent/static/js/operationLogs.js @@ -1 +1 @@ -export const operationLogsSource = " let operationLogsPage = 0;\n const operationLogsLimit = 200;\n const OPERATION_LOG_VIEW_STORAGE_KEY = 'pytorrent.operationLogView.v1';\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 function readOperationLogViewPrefs(){\n try{ return JSON.parse(localStorage.getItem(OPERATION_LOG_VIEW_STORAGE_KEY) || '{}') || {}; }\n catch(e){ return {}; }\n }\n function saveOperationLogViewPrefs(prefs){ localStorage.setItem(OPERATION_LOG_VIEW_STORAGE_KEY, JSON.stringify(prefs)); }\n function operationLogViewPrefs(){\n const prefs = readOperationLogViewPrefs();\n return {defaultType: String(prefs.defaultType ?? ''), hideJobs: prefs.hideJobs !== false};\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 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 return `
${card('Total logs', stats.total || 0)}${types}
Daily count
${daily || 'No data.'}
Monthly count
${monthly || 'No data.'}
Top actions
${actions || 'No data.'}
`;\n }\n\n function fillOperationLogSettings(settings={}){\n if($('operationLogRetentionMode')) $('operationLogRetentionMode').value = settings.retention_mode || 'days';\n if($('operationLogRetentionDays')) $('operationLogRetentionDays').value = settings.retention_days || 30;\n if($('operationLogRetentionLines')) $('operationLogRetentionLines').value = settings.retention_lines || 5000;\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 operationLogDetailsCell(row){\n const summary = row.details_h || '';\n const full = JSON.stringify(row.details || {}, null, 2);\n if(!summary && (!full || full === '{}')) return '';\n // Note: Details are expandable so long JSON results stay complete without widening the table.\n return `
${summary ? esc(summary) : 'Full details'}
${esc(full)}
`;\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 = responsiveTable(['Time','Type','Source','Action','Torrent','Message','Details'], rows.map(r => [\n humanDateCell(r.created_at),\n operationLogBadge(r.event_type, r.severity),\n esc(r.source || '-'),\n esc(r.action || '-'),\n operationLogTorrentCell(r),\n compactCell(r.message || '', 260),\n operationLogDetailsCell(r),\n ]), 'operation-log-table');\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')) $('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($('operationLogTypeFilter') && !$('operationLogTypeFilter').value) $('operationLogTypeFilter').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n }\n\n function saveOperationLogViewSettings(){\n const prefs = {defaultType: $('operationLogDefaultType')?.value || '', hideJobs: $('operationLogHideJobsDefault')?.checked !== false};\n saveOperationLogViewPrefs(prefs);\n syncOperationLogViewControls();\n toast('Log view settings saved.', 'success');\n loadOperationLogs(true);\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 data = await fetch(`/api/operation-logs?${operationLogQuery()}`, {cache: 'no-store'}).then(r => r.json());\n if(!data.ok) throw new Error(data.error || 'Cannot load logs');\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 retention_mode: $('operationLogRetentionMode')?.value || 'days',\n retention_days: Number($('operationLogRetentionDays')?.value || 30),\n retention_lines: Number($('operationLogRetentionLines')?.value || 5000),\n });\n fillOperationLogSettings(data.settings || {});\n toast(`Log retention saved. Deleted ${data.retention?.deleted || 0} old entries.`, '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 $('operationLogSearch')?.addEventListener('input', debounce(() => loadOperationLogs(true), 300));\n $('saveOperationLogViewBtn')?.addEventListener('click', saveOperationLogViewSettings);\n $('operationLogDefaultType')?.addEventListener('change', saveOperationLogViewSettings);\n $('operationLogHideJobsDefault')?.addEventListener('change', saveOperationLogViewSettings);\n $('saveOperationLogRetentionBtn')?.addEventListener('click', saveOperationLogSettings);\n $('applyOperationLogRetentionBtn')?.addEventListener('click', async () => { try{ const j = await post('/api/operation-logs/apply-retention', {}); toast(`Deleted ${j.deleted || 0} old log entries.`, 'success'); loadOperationLogs(true); }catch(e){ toast(e.message, 'danger'); } });\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.v2';\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 previous = JSON.parse(localStorage.getItem('pytorrent.operationLogView.v1') || '{}') || {};\n return {...previous, ...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 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 return `
${card('Total logs', stats.total || 0)}${types}
Daily count
${daily || 'No data.'}
Monthly count
${monthly || 'No data.'}
Top actions
${actions || 'No data.'}
`;\n }\n\n function fillOperationLogSettings(settings={}){\n if($('operationLogRetentionMode')) $('operationLogRetentionMode').value = settings.retention_mode || 'days';\n if($('operationLogRetentionDays')) $('operationLogRetentionDays').value = settings.retention_days || 30;\n if($('operationLogRetentionLines')) $('operationLogRetentionLines').value = settings.retention_lines || 5000;\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 '-';\n // Note: Details are rendered as readable fields instead of raw JSON, so desktop and mobile users see the same content.\n return `
${entries.map(([key, value]) => `
${esc(operationLogDetailLabel(key))}
${compactCell(value, 120)}
`).join('')}
`;\n }\n\n function operationLogHeaders(){\n const headers = ['Time','Type','Source','Action','Torrent','Message'];\n if(operationLogViewPrefs().showDetails) headers.push('Details');\n return headers;\n }\n\n function operationLogRowCells(row){\n const cells = [\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 if(operationLogViewPrefs().showDetails) cells.push(operationLogDetailsCell(row));\n return cells;\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 = responsiveTable(operationLogHeaders(), rows.map(operationLogRowCells), 'operation-log-table');\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')) $('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 data = await fetch(`/api/operation-logs?${operationLogQuery()}`, {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 retention_mode: $('operationLogRetentionMode')?.value || 'days',\n retention_days: Number($('operationLogRetentionDays')?.value || 30),\n retention_lines: Number($('operationLogRetentionLines')?.value || 5000),\n });\n fillOperationLogSettings(data.settings || {});\n toast(`Log retention saved. Deleted ${data.retention?.deleted || 0} old entries.`, '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', async () => { try{ const j = await post('/api/operation-logs/apply-retention', {}); toast(`Deleted ${j.deleted || 0} old log entries.`, 'success'); loadOperationLogs(true); }catch(e){ toast(e.message, 'danger'); } });\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"; diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index 8587950..d2a4d29 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -4563,10 +4563,10 @@ body, .operation-log-toolbar-main, .operation-log-settings-grid, .operation-log-view-settings { + align-items: flex-end; display: flex; flex-wrap: wrap; - gap: .5rem; - align-items: end; + gap: 0.5rem; } .operation-log-toolbar { @@ -4578,10 +4578,21 @@ body, } .operation-log-toolbar-toggle { + align-items: center; + display: flex; flex: 0 0 auto; + flex-wrap: wrap; + gap: 0.75rem; margin-left: auto; } +.operation-log-hide-jobs, +.operation-log-show-details { + align-items: center; + margin-bottom: 0; + min-height: 31px; +} + .operation-log-view-settings { align-items: center; } @@ -4603,29 +4614,24 @@ body, max-width: 260px; } -.operation-log-hide-jobs { - align-items: center; - min-height: 31px; -} - .operation-log-settings-actions { display: flex; flex-wrap: wrap; - gap: .5rem; + gap: 0.5rem; } .operation-log-stats-grid { display: grid; + gap: 0.75rem; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); - gap: .75rem; } .operation-log-stat, .operation-log-panels section { - border: 1px solid var(--bs-border-color); - border-radius: .75rem; - padding: .75rem; background: var(--bs-body-bg); + border: 1px solid var(--bs-border-color); + border-radius: 0.75rem; + padding: 0.75rem; } .operation-log-stat span { @@ -4636,47 +4642,63 @@ body, .operation-log-panels { display: grid; + gap: 0.75rem; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: .75rem; - margin-top: .75rem; + margin-top: 0.75rem; } .operation-log-row { - display: flex; - justify-content: space-between; - gap: 1rem; - padding: .25rem 0; border-bottom: 1px solid var(--bs-border-color-translucent); + display: flex; + gap: 1rem; + justify-content: space-between; + padding: 0.25rem 0; } .operation-log-row:last-child { border-bottom: 0; } +.operation-log-table { + min-width: 1080px; + white-space: normal; +} + .operation-log-table td { vertical-align: top; } -.operation-log-details { +.operation-log-table td:nth-child(5), +.operation-log-table td:nth-child(6), +.operation-log-table td:nth-child(7) { max-width: 24rem; -} - -.operation-log-details summary { - cursor: pointer; overflow-wrap: anywhere; } -.operation-log-details pre { +.operation-log-details { + display: grid; + gap: 0.35rem; + margin: 0; + min-width: 18rem; +} + +.operation-log-details div { background: var(--bs-tertiary-bg); - border: 1px solid var(--bs-border-color); - border-radius: .5rem; - color: var(--bs-body-color); - font-size: .75rem; - margin: .5rem 0 0; - max-height: 18rem; - overflow: auto; - padding: .65rem; - white-space: pre-wrap; + border: 1px solid var(--bs-border-color-translucent); + border-radius: 0.5rem; + padding: 0.35rem 0.45rem; +} + +.operation-log-details dt { + color: var(--bs-secondary-color); + font-size: 0.72rem; + font-weight: 700; + line-height: 1.2; +} + +.operation-log-details dd { + margin: 0.1rem 0 0; + overflow-wrap: anywhere; } @media (max-width: 760px) { @@ -4688,12 +4710,14 @@ body, .operation-log-toolbar, .operation-log-toolbar-main, + .operation-log-toolbar-toggle, .operation-log-view-settings { align-items: stretch; flex-direction: column; } .operation-log-toolbar-toggle { + gap: 0.35rem; margin-left: 0; } diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index 25eee33..cf43089 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -298,7 +298,7 @@
Appearance
Theme, typography and interface scale. Torrent view preferences also remember the selected filter, sorting and the height of the General / Files / Trackers panel.
View state is saved automatically in the database: current torrent filter, last sort column and direction, visible columns, and details panel height.
Browser title
Controls what is shown in the browser tab.
Tracker icons
Visual helper for tracker filters in the sidebar.
Notifications
Toast notifications from automatic systems.
Disk monitor
Choose what the footer disk bar should represent and add extra storage paths.
Progress source
Monitored paths
The footer tooltip always shows details for available paths; this setting only decides which value drives the visible progress bar.
Port checker
Incoming connection test, separate from visual preferences.
disabled
Uses YouGetSignal first. Manual check bypasses the 6h cache.
Peers
Optional peer table helpers.
Easter egg
Optional visual easter egg for loading states and occasional button clicks. Disabled by default.
Changes apply immediately where possible; initial startup loader uses them after reload.
Job scheduling
These settings are stored per active rTorrent profile. Light jobs are control actions such as start, stop, pause, resume, labels, ratio assignment, reannounce and speed limits. Heavy jobs are long or destructive actions such as move, remove and adding torrents.
-
Operation log retention
Manage operation log retention without changing torrent data.
Default log view
Controls the default category and job log visibility used by the Logs modal. Queued, retry, timeout and recovery events are included.
Log statistics
Profile-scoped log counts and cleanup overview.
Loading statistics...
+
Operation log retention
Manage operation log retention without changing torrent data.
Default log view
Controls the default category and job log visibility used by the Logs modal. Queued, retry, timeout and recovery events are included.
Log statistics
Profile-scoped log counts and cleanup overview.
Loading statistics...
Users
Manage optional pyTorrent users. Empty profile means all profiles. R/O blocks rTorrent-changing actions; Full allows them.
Labels
Create reusable labels and remove labels that are no longer needed.
@@ -341,7 +341,7 @@ - +