701 lines
49 KiB
JavaScript
701 lines
49 KiB
JavaScript
'use strict';
|
||
|
||
let _operationsLoaded = false;
|
||
let _operationsStatus = null;
|
||
let _operationsBusy = new Set();
|
||
let _operationsStorageReport = null;
|
||
let _operationsStorageTopology = null;
|
||
|
||
function operationsDate(value) {
|
||
if (!value) return '—';
|
||
try { return new Date(Number(value) || value).toLocaleString(window.i18n?.current || 'en'); } catch (_) { return '—'; }
|
||
}
|
||
|
||
function operationsDuration(value) {
|
||
const ms = Number(value || 0);
|
||
if (!ms) return '—';
|
||
if (ms < 1000) return `${ms} ms`;
|
||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
|
||
return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`;
|
||
}
|
||
|
||
function operationsBytes(value) {
|
||
const bytes = Number(value || 0);
|
||
if (!Number.isFinite(bytes) || bytes <= 0) return '—';
|
||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||
let amount = bytes;
|
||
let unit = 0;
|
||
while (amount >= 1024 && unit < units.length - 1) { amount /= 1024; unit += 1; }
|
||
return `${amount >= 10 || unit === 0 ? amount.toFixed(0) : amount.toFixed(1)} ${units[unit]}`;
|
||
}
|
||
|
||
function operationsRunSummary(run) {
|
||
const raw = String(run.error || run.summary || '').trim();
|
||
if (!raw) return run.status === 'completed' ? 'Completed' : '';
|
||
if (run.error) return raw.length > 240 ? `${raw.slice(0, 237)}...` : raw;
|
||
let result;
|
||
try { result = JSON.parse(raw); } catch (_) { return raw.length > 240 ? `${raw.slice(0, 237)}...` : raw; }
|
||
if (result == null) return 'Completed';
|
||
if (typeof result === 'string' || typeof result === 'number') return String(result);
|
||
if (run.task_id === 'library-scan') {
|
||
const total = Number(result.total ?? result.found?.length ?? result.found ?? 0);
|
||
const added = Number(result.added || 0);
|
||
return `${total} game${total === 1 ? '' : 's'} found · ${added} new`;
|
||
}
|
||
if (run.task_id === 'metadata-refresh') {
|
||
const queued = Number(result.queued ?? (typeof result === 'number' ? result : 0));
|
||
return `${queued} metadata item${queued === 1 ? '' : 's'} queued`;
|
||
}
|
||
if (run.task_id === 'payload-sync' && Array.isArray(result)) {
|
||
const failed = result.filter(item => item?.error).length;
|
||
return `${result.length} source${result.length === 1 ? '' : 's'} checked${failed ? ` · ${failed} error${failed === 1 ? '' : 's'}` : ''}`;
|
||
}
|
||
if (run.task_id === 'cheat-sync') {
|
||
const indexed = Number(result.ecosystem?.total || 0);
|
||
const cached = Number(result.legacy?.total || 0);
|
||
const failed = Number(result.legacy?.failed || 0) + Number(result.ecosystem?.errors?.length || 0);
|
||
return `${indexed} modern cheats or patches indexed · ${cached} legacy items cached${failed ? ` · ${failed} error${failed === 1 ? '' : 's'}` : ''}`;
|
||
}
|
||
if (run.task_id === 'storage-audit') {
|
||
const summary = result.summary || {};
|
||
const issues = Number(summary.missingBackups || 0) + Number(summary.partialFiles || 0) + Number(summary.orphanGameFolders || 0);
|
||
return `${Number(summary.games || 0)} games · ${Number(summary.backups || 0)} backups · ${issues} attention item${issues === 1 ? '' : 's'}`;
|
||
}
|
||
if (run.task_id === 'recovery-snapshot') {
|
||
const localFiles = Array.isArray(result.files) ? result.files.length : 0;
|
||
const remoteFiles = Array.isArray(result.remote) ? result.remote.filter(item => item.included).length : 0;
|
||
return `${localFiles} local and ${remoteFiles} PS5 files secured`;
|
||
}
|
||
const values = Object.entries(result).filter(([, value]) => ['string', 'number', 'boolean'].includes(typeof value)).slice(0, 3);
|
||
return values.length ? values.map(([key, value]) => `${key}: ${value}`).join(' · ') : 'Completed';
|
||
}
|
||
|
||
function operationsStatusBadge(status) {
|
||
const labels = { completed: 'Succeeded', failed: 'Failed', running: 'Running', compatible: 'Compatible', warning: 'Warning', unknown: 'Unknown' };
|
||
return `<span class="operations-status operations-status-${escHtml(status || 'unknown')}">${escHtml(labels[status] || status || 'Unknown')}</span>`;
|
||
}
|
||
|
||
async function operationsAction(key, button, task) {
|
||
if (_operationsBusy.has(key)) return null;
|
||
_operationsBusy.add(key);
|
||
const prior = button?.textContent;
|
||
if (button) { button.disabled = true; button.textContent = window.t('common.loading', 'Loading…'); }
|
||
try { return await task(); }
|
||
finally {
|
||
_operationsBusy.delete(key);
|
||
if (button) { button.disabled = false; button.textContent = prior; }
|
||
}
|
||
}
|
||
|
||
function renderOperationsAutomation(data) {
|
||
_operationsStatus = data;
|
||
const scheduler = $('operations-scheduler-state');
|
||
scheduler.textContent = data.running ? 'Active' : 'Stopped';
|
||
scheduler.className = `badge ${data.running ? 'badge-green' : 'badge-red'}`;
|
||
const tasks = data.tasks || [];
|
||
$('operations-task-list').innerHTML = tasks.map(task => `
|
||
<div class="operations-task" data-task-id="${escHtml(task.id)}">
|
||
<div class="operations-task-main">
|
||
<span class="operations-task-indicator ${task.running ? 'running' : task.enabled ? 'enabled' : ''}"></span>
|
||
<div><strong>${escHtml(task.label)}</strong><span>${task.running ? 'Running now' : task.enabled ? `Next: ${operationsDate(task.nextRunAt)}` : 'Disabled'}</span></div>
|
||
</div>
|
||
<label class="operations-switch"><input class="operations-task-enabled" type="checkbox" ${task.enabled ? 'checked' : ''}/><span>Active</span></label>
|
||
<label class="operations-interval"><input class="operations-task-interval" type="number" min="5" max="10080" value="${Number(task.intervalMinutes)}"/><span>min.</span></label>
|
||
<button class="btn btn-sm operations-task-save">Save</button>
|
||
<button class="btn btn-sm btn-accent operations-task-run" ${task.running ? 'disabled' : ''}>Run now</button>
|
||
</div>`).join('') || '<p class="hint">No tasks configured.</p>';
|
||
|
||
const runs = (data.recentRuns || []).slice(0, 25);
|
||
$('operations-run-list').innerHTML = runs.map(run => {
|
||
const summary = operationsRunSummary(run);
|
||
return `
|
||
<div class="operations-run">
|
||
<div><strong>${escHtml(tasks.find(task => task.id === run.task_id)?.label || run.task_id)}</strong><span>${escHtml(run.trigger || 'manual')} · ${operationsDate(run.started_at)}</span></div>
|
||
${operationsStatusBadge(run.status)}
|
||
<span>${operationsDuration(run.duration_ms)}</span>
|
||
<span class="operations-run-message" title="${escHtml(run.error || run.summary || '')}">${escHtml(summary)}</span>
|
||
</div>`;
|
||
}).join('') || '<p class="hint">No runs yet.</p>';
|
||
|
||
const failed = runs.filter(run => run.status === 'failed').length;
|
||
const health = $('operations-health');
|
||
health.textContent = failed
|
||
? window.tf('operations.status.failures', '{count} recent failure(s)', { count: failed })
|
||
: window.t('operations.status.healthy', 'Automation healthy');
|
||
health.className = `operations-health ${failed ? 'warning' : 'healthy'}`;
|
||
const badge = $('nav-operations-badge');
|
||
if (badge) { badge.hidden = failed === 0; badge.textContent = failed || ''; }
|
||
}
|
||
|
||
function renderOperationsStorage(report) {
|
||
_operationsStorageReport = report;
|
||
if (!report) {
|
||
$('operations-storage-summary').innerHTML = '';
|
||
$('operations-storage-details').innerHTML = '<p class="hint">No storage report available yet.</p>';
|
||
return;
|
||
}
|
||
const summary = report.summary || {};
|
||
const metrics = [
|
||
[window.t('operations.storage.metrics.mounts', 'Mounts'), summary.roots],
|
||
[window.t('operations.storage.metrics.games', 'Games'), summary.games],
|
||
[window.t('operations.storage.metrics.backups', 'Backups'), summary.backups],
|
||
[window.t('operations.storage.metrics.missing', 'Missing'), summary.missingBackups],
|
||
[window.t('operations.storage.metrics.duplicates', 'Duplicate games'), summary.duplicateGames],
|
||
[window.t('operations.storage.metrics.partial', 'Partial files'), summary.partialFiles],
|
||
[window.t('operations.storage.metrics.orphaned', 'Orphaned folders'), summary.orphanGameFolders],
|
||
];
|
||
$('operations-storage-summary').innerHTML = metrics.map(([label, value]) => `<div class="operations-metric"><strong>${Number(value || 0)}</strong><span>${escHtml(label)}</span></div>`).join('');
|
||
const sections = [];
|
||
if ((report.roots || []).length) sections.push(`<div class="operations-audit-section"><h4>${escHtml(window.t('operations.storage.volumes', 'Volumes'))}</h4>${report.roots.map(root => `<div class="operations-audit-row"><code>${escHtml(root.path)}</code><span>${root.error ? escHtml(root.error) : `${operationsBytes(root.availableBytes)} ${escHtml(window.t('operations.storage.freeOf', 'free of'))} ${operationsBytes(root.totalBytes)}`}</span></div>`).join('')}</div>`);
|
||
const issue = (title, items, render) => {
|
||
if (!items?.length) return;
|
||
sections.push(`<details class="operations-audit-section"><summary>${escHtml(title)} <span>${items.length}</span></summary>${items.slice(0, 100).map(render).join('')}${items.length > 100 ? `<p class="hint">${escHtml(window.t('operations.storage.first100', 'Only the first 100 items are shown.'))}</p>` : ''}</details>`);
|
||
};
|
||
issue(window.t('operations.storage.issues.missing', 'Missing backups'), report.missingBackups, item => `<div class="operations-audit-row"><strong>${escHtml(item.gameId)}</strong><code>${escHtml(item.path)}</code></div>`);
|
||
issue(window.t('operations.storage.issues.duplicates', 'Duplicate backup registrations'), report.duplicateBackups, item => `<div class="operations-audit-row"><strong>${escHtml(item.gameId)} (${item.count})</strong><span>${item.paths.map(escHtml).join('<br>')}</span></div>`);
|
||
issue(window.t('operations.storage.issues.partial', 'Partial files'), report.partialFiles, item => `<div class="operations-audit-row"><code>${escHtml(item)}</code></div>`);
|
||
issue(window.t('operations.storage.issues.orphaned', 'Orphaned game folders'), report.orphanGameFolders, item => `<div class="operations-audit-row"><strong>${escHtml(item.gameId)}</strong><code>${escHtml(item.path)}</code></div>`);
|
||
if (!report.missingBackups?.length && !report.partialFiles?.length && !report.orphanGameFolders?.length) sections.push(`<div class="operations-empty-ok">✓ ${escHtml(window.t('operations.storage.healthy', 'No missing backups, partial files or orphaned game folders found.'))}</div>`);
|
||
$('operations-storage-details').innerHTML = `<p class="hint">${escHtml(window.t('operations.storage.reportAt', 'Report from'))} ${operationsDate(report.generatedAt)}</p>${sections.join('')}`;
|
||
}
|
||
|
||
function renderStorageTopology(packet) {
|
||
const target = $('operations-storage-topology');
|
||
if (!target || !packet) return;
|
||
_operationsStorageTopology = packet;
|
||
target.innerHTML = `<p class="hint">${escHtml(window.t('operations.storage.consoleSource', 'Console source'))}: ${escHtml(packet.provenance?.console || 'unknown')} · ${escHtml(window.t('operations.storage.serverSource', 'server source'))}: ${escHtml(packet.provenance?.server || 'unknown')}</p><div class="ecosystem-update-list">${(packet.volumes || []).map(volume => `<div class="ecosystem-service-row"><div><strong>${escHtml(volume.scope === 'console' ? `PS5 ${volume.kind}` : volume.mountpoint)}</strong><span>${escHtml(volume.mountpoint || 'unknown')} · ${escHtml(volume.filesystem || 'unknown')} · ${volume.freeBytes == null ? escHtml(window.t('operations.storage.freeUnknown', 'free space unknown')) : `${operationsBytes(volume.freeBytes)} ${escHtml(window.t('operations.storage.free', 'free'))}`} · ${volume.totalBytes == null ? escHtml(window.t('operations.storage.totalUnknown', 'total unknown')) : operationsBytes(volume.totalBytes)}</span></div>${operationsStatusBadge(volume.status === 'unknown' || volume.source === 'unavailable' ? 'unknown' : volume.readOnly === true ? 'warning' : 'compatible')}</div>`).join('')}</div>`;
|
||
}
|
||
|
||
async function loadStorageTopology() {
|
||
if (!window.pork.consoleStorageTopology) return;
|
||
renderStorageTopology(await window.pork.consoleStorageTopology());
|
||
}
|
||
|
||
function renderOperationsRecovery(snapshots) {
|
||
$('operations-recovery-list').innerHTML = (snapshots || []).map(snapshot => {
|
||
const included = (snapshot.remote || []).filter(item => item.included).length;
|
||
return `<div class="operations-recovery-item" data-snapshot-id="${escHtml(snapshot.id)}">
|
||
<div class="operations-recovery-icon">↺</div>
|
||
<div class="operations-recovery-info"><strong>${escHtml(snapshot.label || 'Restore point')}</strong><span>${operationsDate(snapshot.createdAt)} · Chimera ${escHtml(snapshot.chimeraVersion || '?')} · ${included} remote files · ${snapshot.integrity === 'sha256' ? 'SHA-256' : 'legacy verification'}</span><code>${escHtml(snapshot.id)}</code></div>
|
||
<div class="operations-inline-actions"><button class="btn btn-sm operations-recovery-verify">Verify</button><button class="btn btn-sm operations-recovery-export">Download</button><button class="btn btn-sm operations-recovery-restore">Restore</button><button class="btn btn-sm btn-danger operations-recovery-delete">Delete</button></div>
|
||
</div>`;
|
||
}).join('') || '<div class="operations-empty"><strong>No restore points yet</strong><span>Create a restore point before making major changes.</span></div>';
|
||
}
|
||
|
||
function operationsAuditFilters() {
|
||
const since = $('operations-audit-since').value;
|
||
const until = $('operations-audit-until').value;
|
||
return {
|
||
level: $('operations-audit-level').value,
|
||
classification: $('operations-audit-classification').value,
|
||
result: $('operations-audit-result').value,
|
||
search: $('operations-audit-search').value.trim(),
|
||
since: since ? new Date(since).getTime() : null,
|
||
until: until ? new Date(until).getTime() : null,
|
||
limit: 200,
|
||
};
|
||
}
|
||
|
||
function renderOperationsAudit(result, health) {
|
||
const summary = result.summary || {};
|
||
$('operations-audit-summary').innerHTML = [
|
||
['Matched', result.totalMatched || 0], ['Succeeded', summary.success || 0], ['Failed', summary.failed || 0],
|
||
['Sensitive', summary.sensitive || 0], ['Destructive', summary.destructive || 0], ['Errors', summary.errors || 0],
|
||
].map(([label, value]) => `<div class="operations-metric"><strong>${Number(value)}</strong><span>${escHtml(label)}</span></div>`).join('');
|
||
const files = health?.files || [];
|
||
$('operations-audit-health').textContent = `${files.length} log file(s) · ${operationsBytes(health?.totalBytes)} · rotation ${operationsBytes(health?.retention?.maxBytes)} × ${Number(health?.retention?.maxFiles || 0)} · secrets redacted at rest`;
|
||
$('operations-audit-list').innerHTML = (result.entries || []).map(entry => `<div class="operations-run">
|
||
<div><strong>${escHtml(entry.command || entry.event || entry.level)}</strong><span>${operationsDate(entry.time)}${entry.requestIp ? ` · ${escHtml(entry.requestIp)}` : ''}</span></div>
|
||
${operationsStatusBadge(entry.ok === true ? 'completed' : entry.ok === false ? 'failed' : 'unknown')}
|
||
<span>${escHtml(entry.classification || entry.level)}</span>
|
||
<span class="operations-run-message" title="${escHtml(entry.error || '')}">${escHtml(entry.error || (entry.durationMs == null ? entry.event : `${entry.event} · ${operationsDuration(entry.durationMs)}`))}</span>
|
||
</div>`).join('') || '<div class="operations-empty"><strong>No audit records</strong><span>No records match these filters.</span></div>';
|
||
}
|
||
|
||
async function loadOperationsAudit() {
|
||
const [result, health] = await Promise.all([window.pork.auditQuery(operationsAuditFilters()), window.pork.auditHealth()]);
|
||
renderOperationsAudit(result, health);
|
||
return result;
|
||
}
|
||
|
||
function renderOperationsCompatibility(data) {
|
||
const profile = data?.profile || {};
|
||
$('operations-profile-firmware').value = profile.firmware || '';
|
||
$('operations-profile-hen').value = profile.hen || '';
|
||
$('operations-profile-hen-version').value = profile.henVersion || '';
|
||
$('operations-profile-kstuff').value = profile.kstuffVersion || '';
|
||
$('operations-profile-loader').value = profile.loader || '';
|
||
$('operations-profile-notes').value = profile.notes || '';
|
||
$('operations-compatibility-checks').innerHTML = (data?.checks || []).map(check => `<div class="operations-check"><div><strong>${escHtml(check.label)}</strong><span>${escHtml(check.detail)}</span></div>${operationsStatusBadge(check.status)}</div>`).join('') || '<p class="hint">No checks available.</p>';
|
||
}
|
||
|
||
function renderReplicationStatus(status) {
|
||
const targetState = status.available && status.writable ? 'Available and writable' : status.error || 'Unavailable';
|
||
const free = status.storage ? `${operationsBytes(status.storage.availableBytes)} free` : 'free space unknown';
|
||
const protection = status.protection || {};
|
||
const rpo = protection.effectiveRpoSeconds == null ? 'not established' : protection.effectiveRpoSeconds < 3600
|
||
? `${Math.ceil(protection.effectiveRpoSeconds / 60)} min`
|
||
: `${Math.round(protection.effectiveRpoSeconds / 360) / 10} h`;
|
||
const rto = protection.lastRestoreDrillDurationMs == null ? 'not measured' : `${Math.max(1, Math.round(protection.lastRestoreDrillDurationMs / 1000))} s drill`;
|
||
$('operations-replication-status').innerHTML = `
|
||
<div class="operations-audit-row"><strong>${escHtml(targetState)}</strong><code>${escHtml(status.target || '/replication')}</code><span>${escHtml(free)}${status.running ? ' · replication running' : ''}</span></div>
|
||
<div class="ecosystem-metrics">
|
||
<div class="operations-metric"><strong>${protection.threeTwoOneSatisfied ? 'Met' : 'Not met'}</strong><span>3-2-1 status</span></div>
|
||
<div class="operations-metric"><strong>${escHtml(rpo)}</strong><span>Effective RPO</span></div>
|
||
<div class="operations-metric"><strong>${escHtml(rto)}</strong><span>Observed restore drill</span></div>
|
||
<div class="operations-metric"><strong>${protection.immutableObserved ? 'Yes' : 'No'}</strong><span>Immutable target</span></div>
|
||
</div>
|
||
<div class="${protection.threeTwoOneSatisfied ? 'operations-empty-ok' : 'operations-warning'}"><strong>Target contract: ${escHtml(status.targetContract?.type || 'unknown')}</strong><p>Atomic: ${status.targetContract?.atomicPublish ? 'yes' : 'unknown'} · hashes: ${status.targetContract?.hashVerified ? 'yes' : 'unknown'} · immutable: ${status.targetContract?.immutable ? 'yes' : 'no'} · off-host: ${escHtml(status.targetContract?.offHost || 'unknown')}</p>${protection.blockers?.length ? `<ul>${protection.blockers.map(item => `<li>${escHtml(item)}</li>`).join('')}</ul>` : ''}</div>`;
|
||
$('operations-replication-retention-enabled').checked = status.retention?.enabled !== false;
|
||
$('operations-replication-retention-count').value = status.retention?.maxSnapshots || 14;
|
||
$('operations-replication-retention-days').value = status.retention?.maxAgeDays || 180;
|
||
$('operations-replication-run').disabled = !status.available || !status.writable || status.running;
|
||
$('operations-replication-list').innerHTML = (status.snapshots || []).map(snapshot => snapshot.invalid
|
||
? `<div class="operations-recovery-item"><div class="operations-recovery-info"><strong>Invalid snapshot</strong><code>${escHtml(snapshot.id)}</code><span>${escHtml(snapshot.error)}</span></div></div>`
|
||
: `<div class="operations-recovery-item" data-replication-id="${escHtml(snapshot.id)}"><div class="operations-recovery-info"><strong>${escHtml(snapshot.label || 'Replication snapshot')}</strong><span>${operationsDate(snapshot.completedAt || snapshot.createdAt)} · ${Number(snapshot.files || 0)} files · ${operationsBytes(snapshot.bytes)}${snapshot.verifiedAt ? ` · verified ${operationsDate(snapshot.verifiedAt)}` : ''}${snapshot.lastRestoreDrillAt ? ` · restore drill ${operationsDate(snapshot.lastRestoreDrillAt)}` : ''}</span><code>${escHtml(snapshot.id)}</code></div><div class="operations-inline-actions"><button class="btn btn-sm operations-replication-verify">Verify SHA-256</button><button class="btn btn-sm operations-replication-drill">Local restore drill</button></div></div>`).join('') || '<p class="hint">No replication snapshots yet.</p>';
|
||
}
|
||
|
||
function renderMonitoring(value) {
|
||
const metrics = [
|
||
['Uptime', `${Math.round(Number(value.uptimeSeconds || 0) / 60)} min.`],
|
||
['Memory', operationsBytes(value.memory?.rssBytes)],
|
||
['Games', value.library?.totalGames || 0],
|
||
['Backups', value.library?.totalBackups || 0],
|
||
['FTP', value.ftpConnected ? 'Connected' : 'Offline'],
|
||
['Task failures', value.automation?.recentFailures || 0],
|
||
['Replications', value.replication?.snapshots || 0],
|
||
['API p95', `${Number(value.api?.p95DurationMs || 0)} ms`],
|
||
['Last scan', value.scans?.lastDurationMs == null ? 'not measured' : `${Number(value.scans.lastDurationMs)} ms`],
|
||
['Transfer / 24h', operationsBytes(value.transfers?.bytes)],
|
||
['Cache hit', `${Math.round(Number(value.transport?.staticCacheHitRatio || 0) * 100)}%`],
|
||
['WebSocket disconnects', Number(value.transport?.wsDisconnects || 0)],
|
||
];
|
||
$('operations-monitoring-summary').innerHTML = metrics.map(([label, result]) => `<div class="operations-metric"><strong>${escHtml(result)}</strong><span>${escHtml(label)}</span></div>`).join('');
|
||
}
|
||
|
||
function renderSessions(result) {
|
||
$('operations-session-list').innerHTML = (result.sessions || []).map(session => `<div class="operations-recovery-item" data-session-id="${escHtml(session.id)}"><div class="operations-recovery-info"><strong>${session.current ? 'This browser' : escHtml(session.userAgent || 'Unknown browser')}</strong><span>${escHtml(session.ip || 'unknown')} · last active ${operationsDate(session.lastSeenAt)} · expires ${operationsDate(session.expiresAt)}</span><code>${escHtml(session.id)}</code></div><div class="operations-inline-actions"><button class="btn btn-sm btn-danger operations-session-revoke">${session.current ? 'Sign out' : 'Revoke'}</button></div></div>`).join('') || '<p class="hint">No active sessions.</p>';
|
||
}
|
||
|
||
async function loadOperationsReliability({ focus = '' } = {}) {
|
||
if (focus === 'security') {
|
||
if (!window.pork.authSessions) {
|
||
$('operations-session-list').innerHTML = '<p class="hint">Session management is available only in server mode.</p>';
|
||
return;
|
||
}
|
||
renderSessions(await window.pork.authSessions());
|
||
return;
|
||
}
|
||
if (!window.pork.backupReplicationStatus || !window.pork.authSessions) {
|
||
$('operations-replication-status').innerHTML = '<p class="hint">Session and replication management are available only in server mode.</p>';
|
||
return;
|
||
}
|
||
const [replication, monitoring, token, sessions] = await Promise.all([
|
||
window.pork.backupReplicationStatus(), window.pork.monitoringSnapshot(), window.pork.monitoringTokenStatus(), window.pork.authSessions(),
|
||
]);
|
||
renderReplicationStatus(replication);
|
||
renderMonitoring(monitoring);
|
||
$('operations-monitoring-token-state').textContent = token.configured
|
||
? window.t('operations.monitoring.tokenActive', 'Metrics token active · endpoint /metrics')
|
||
: window.t('operations.monitoring.tokenDisabled', 'Metrics disabled until a token is created');
|
||
renderSessions(sessions);
|
||
}
|
||
|
||
async function loadOperations({ background = false } = {}) {
|
||
const [status, storage, snapshots, compatibility, topology] = await Promise.all([
|
||
window.pork.operationsStatus(),
|
||
window.pork.storageStatus().catch(() => null),
|
||
window.pork.recoveryList().catch(() => []),
|
||
window.pork.compatibilityGet().catch(() => ({ profile: {}, checks: [] })),
|
||
window.pork.consoleStorageTopology?.().catch(() => null) || Promise.resolve(null),
|
||
]);
|
||
renderOperationsAutomation(status);
|
||
renderOperationsStorage(storage);
|
||
renderOperationsRecovery(snapshots);
|
||
renderOperationsCompatibility(compatibility);
|
||
renderStorageTopology(topology);
|
||
_operationsLoaded = true;
|
||
if (!background) $('operations-health').title = window.tf('operations.status.updated', 'Updated {time}', {
|
||
time: new Date().toLocaleTimeString(window.i18n?.current || 'en'),
|
||
});
|
||
}
|
||
|
||
async function loadOperationsShadowMount() {
|
||
const result = await window.pork.shadowMountStatus();
|
||
const config = result.files?.['shadowmount-config'] || {};
|
||
const manual = result.files?.['shadowmount-manual'] || {};
|
||
$('operations-shadow-config').value = config.text || '';
|
||
$('operations-shadow-manual').value = manual.text || '';
|
||
for (const [element, file] of [[$('operations-shadow-config-state'), config], [$('operations-shadow-manual-state'), manual]]) {
|
||
element.textContent = file.ok ? 'Loaded' : 'Unavailable';
|
||
element.className = `badge ${file.ok ? 'badge-green' : 'badge-red'}`;
|
||
element.title = file.error || '';
|
||
}
|
||
renderOperationsShadowMountModel(result.model || { summary: {}, entries: [], unknownStatusLines: [] });
|
||
renderOperationsShadowPreflight(result.preflight);
|
||
}
|
||
|
||
function renderOperationsShadowPreflight(preflight) {
|
||
const target = $('operations-shadow-preflight');
|
||
if (!target) return;
|
||
if (!preflight) {
|
||
target.innerHTML = '<strong>Configuration preflight unavailable</strong><p>Load a reachable config.ini or run the check again while saving.</p>';
|
||
return;
|
||
}
|
||
const counts = preflight.counts || {};
|
||
const headline = !preflight.ok ? 'Save blocked' : preflight.requiresRiskConfirmation ? 'Explicit risk confirmation required' : 'Preflight passed';
|
||
const findings = (preflight.findings || []).map(item => `<li><strong>${escHtml(item.severity)}</strong>${item.line ? ` · line ${Number(item.line)}` : ''}${item.key ? ` · ${escHtml(item.key)}` : ''}: ${escHtml(item.message)}</li>`).join('');
|
||
target.innerHTML = `<strong>${escHtml(headline)}</strong><p>${Number(counts.invalid || 0)} invalid · ${Number(counts.critical || 0)} critical · ${Number(counts.warning || 0)} warning(s) · ${Number(counts.unknown || 0)} unknown future key(s)</p>${findings ? `<ul>${findings}</ul>` : '<p>No safety issues in the recognized configuration.</p>'}`;
|
||
}
|
||
|
||
function renderOperationsShadowMountModel(model) {
|
||
const summary = model.summary || {};
|
||
const entries = model.entries || [];
|
||
const statusBadge = $('operations-shadow-status-state');
|
||
statusBadge.textContent = summary.invalid || summary.unknownStatusLines ? 'review needed' : `${summary.installed || 0} installed`;
|
||
statusBadge.className = `badge ${summary.invalid || summary.unknownStatusLines ? 'badge-yellow' : 'badge-green'}`;
|
||
$('operations-shadow-summary').innerHTML = [
|
||
['Active sources', summary.active || 0],
|
||
['Installed', summary.installed || 0],
|
||
['Pending', summary.pending || 0],
|
||
['Deleted', summary.deleted || 0],
|
||
['Experimental', summary.experimental || 0],
|
||
['Library matches', summary.libraryMatches || 0],
|
||
].map(([label, value]) => `<div class="operations-metric"><strong>${escHtml(value)}</strong><span>${escHtml(label)}</span></div>`).join('');
|
||
|
||
const stateLabel = { installed: 'Installed', pending: 'Pending', deleted: 'Deleted', invalid: 'Invalid' };
|
||
const supportLabel = { recommended: 'Recommended', compatibility: 'Compatibility', experimental: 'Experimental', normal: 'Folder', unknown: 'Unknown' };
|
||
const rows = entries.map(entry => `<article class="operations-detail-row shadowmount-entry ${entry.warnings?.length ? 'has-warning' : ''}">
|
||
<div><strong>${escHtml(entry.titleName || entry.titleId || entry.sourceName || 'Unknown source')}</strong><span>${escHtml(entry.titleId || 'no title ID')} · ${escHtml(entry.format?.label || 'Unknown')} · ${escHtml(supportLabel[entry.format?.support] || entry.format?.support || 'Unknown')}</span><code>${escHtml(entry.sourcePath)}</code>${entry.warnings?.length ? `<small>${entry.warnings.map(escHtml).join(' ')}</small>` : ''}</div>
|
||
<span class="badge ${entry.state === 'installed' ? 'badge-green' : entry.state === 'deleted' || entry.state === 'invalid' ? 'badge-red' : 'badge-yellow'}">${escHtml(stateLabel[entry.state] || entry.state)}</span>
|
||
</article>`);
|
||
if (model.unknownStatusLines?.length) rows.push(`<article class="operations-detail-row has-warning"><div><strong>${model.unknownStatusLines.length} unknown status line(s)</strong><span>Preserved because the upstream format was not recognized.</span><code>${escHtml(model.unknownStatusLines.map(item => `line ${item.line}: ${item.text}`).join('\n'))}</code></div><span class="badge badge-yellow">Raw</span></article>`);
|
||
$('operations-shadow-entries').innerHTML = rows.length ? rows.join('') : '<p class="hint">manual.lst and manual.status do not contain game sources yet.</p>';
|
||
}
|
||
|
||
async function loadOperationsPackages() {
|
||
const result = await window.pork.packagesStatus();
|
||
$('operations-package-services').innerHTML = (result.services || []).map(service => `<div class="operations-service"><span class="operations-service-dot ${service.reachable ? 'online' : ''}"></span><div><strong>${escHtml(service.label)}</strong><span>Port ${service.port}${service.error ? ` · ${escHtml(service.error)}` : ''}</span></div><b>${service.reachable ? 'Online' : 'Offline'}</b></div>`).join('');
|
||
return result;
|
||
}
|
||
|
||
function showOperationsJson(element, value) {
|
||
element.hidden = false;
|
||
element.textContent = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
||
}
|
||
|
||
function klogFilters() {
|
||
return { severity: $('operations-klog-severity').value, search: $('operations-klog-search').value.trim(), limit: 200 };
|
||
}
|
||
|
||
function renderKlogStatus(status) {
|
||
const badge = $('operations-klog-state');
|
||
badge.textContent = status.running ? status.connected ? 'Capture active' : 'Connecting…' : 'Stopped';
|
||
badge.className = `badge ${status.running ? status.connected ? 'badge-green' : 'badge-yellow' : ''}`;
|
||
$('operations-klog-start').disabled = status.running || !status.available;
|
||
$('operations-klog-stop').disabled = !status.running;
|
||
$('operations-klog-summary').innerHTML = [
|
||
['Port', status.port || 3232], ['Status', status.reason || 'unknown'], ['Lines', status.lines || 0],
|
||
['Received', operationsBytes(status.bytes)], ['Write contract', status.zeroWriteContract ? '0 bytes' : 'unknown'], ['Payload started', status.payloadLaunchAttempted ? 'yes' : 'no'],
|
||
].map(([label, value]) => `<div class="operations-metric"><strong>${escHtml(value)}</strong><span>${escHtml(label)}</span></div>`).join('');
|
||
}
|
||
|
||
function renderKlogEntries(result) {
|
||
$('operations-klog-list').innerHTML = (result.entries || []).map(entry => `<div class="operations-run">
|
||
<div><strong>${escHtml(entry.severity.toUpperCase())}</strong><span>${operationsDate(entry.time)}</span></div>
|
||
${operationsStatusBadge(entry.severity === 'error' ? 'failed' : entry.severity === 'warn' ? 'warning' : 'completed')}
|
||
<span>${escHtml(String(entry.sessionId || '').slice(0, 8))}</span>
|
||
<span class="operations-run-message" title="${escHtml(entry.line)}">${escHtml(entry.line)}</span>
|
||
</div>`).join('') || '<p class="hint">No kernel log records match these filters.</p>';
|
||
}
|
||
|
||
async function loadKlog() {
|
||
const [status, result] = await Promise.all([window.pork.klogStatus(), window.pork.klogQuery(klogFilters())]);
|
||
renderKlogStatus(status);
|
||
renderKlogEntries(result);
|
||
}
|
||
|
||
document.querySelectorAll('.operations-tab').forEach(button => button.addEventListener('click', async () => {
|
||
const tab = button.dataset.operationsTab;
|
||
document.querySelectorAll('.operations-tab').forEach(item => item.classList.toggle('active', item === button));
|
||
document.querySelectorAll('.operations-panel').forEach(panel => {
|
||
const active = panel.dataset.operationsPanel === tab;
|
||
panel.classList.toggle('active', active);
|
||
panel.hidden = !active;
|
||
});
|
||
try {
|
||
if (tab === 'shadowmount') await loadOperationsShadowMount();
|
||
if (tab === 'storage') await loadStorageTopology();
|
||
if (tab === 'packages') await loadOperationsPackages();
|
||
if (tab === 'reliability') await loadOperationsReliability();
|
||
if (tab === 'audit') await loadOperationsAudit();
|
||
if (tab === 'diagnostics') await loadKlog();
|
||
} catch (error) { showToast(error.message, 'error'); }
|
||
}));
|
||
|
||
$('operations-refresh')?.addEventListener('click', event => operationsAction('refresh', event.currentTarget, () => loadOperations()).catch(error => showToast(error.message, 'error')));
|
||
|
||
$('operations-task-list')?.addEventListener('click', async event => {
|
||
const row = event.target.closest('.operations-task');
|
||
if (!row) return;
|
||
const taskId = row.dataset.taskId;
|
||
try {
|
||
if (event.target.closest('.operations-task-run')) {
|
||
await operationsAction(`run-${taskId}`, event.target.closest('button'), () => window.pork.operationsRunTask(taskId));
|
||
showToast('Task completed.', 'ok');
|
||
await loadOperations();
|
||
}
|
||
if (event.target.closest('.operations-task-save')) {
|
||
await operationsAction(`save-${taskId}`, event.target.closest('button'), () => window.pork.operationsConfigureTask(taskId, {
|
||
enabled: row.querySelector('.operations-task-enabled').checked,
|
||
intervalMinutes: Number(row.querySelector('.operations-task-interval').value),
|
||
}));
|
||
showToast('Schedule saved.', 'ok');
|
||
await loadOperations();
|
||
}
|
||
} catch (error) { showToast(error.message, 'error'); }
|
||
});
|
||
|
||
$('operations-storage-run')?.addEventListener('click', async event => {
|
||
try {
|
||
const report = await operationsAction('storage', event.currentTarget, () => window.pork.storageAudit());
|
||
renderOperationsStorage(report);
|
||
showToast('Storage audit completed. Nothing was deleted.', 'ok');
|
||
} catch (error) { showToast(error.message, 'error'); }
|
||
});
|
||
|
||
$('operations-storage-topology-refresh')?.addEventListener('click', event => operationsAction('storage-topology', event.currentTarget, loadStorageTopology).catch(error => showToast(error.message, 'error')));
|
||
|
||
$('operations-recovery-create')?.addEventListener('click', async event => {
|
||
const label = await showPrompt('Name for this restore point:', 'Manual restore point');
|
||
if (label == null) return;
|
||
try {
|
||
await operationsAction('recovery-create', event.currentTarget, () => window.pork.recoveryCreate({ label, includeRemote: true }));
|
||
renderOperationsRecovery(await window.pork.recoveryList());
|
||
showToast('Restore point saved safely.', 'ok');
|
||
} catch (error) { showToast(error.message, 'error'); }
|
||
});
|
||
|
||
$('operations-recovery-import')?.addEventListener('click', async event => {
|
||
try {
|
||
const result = await operationsAction('recovery-import', event.currentTarget, () => window.pork.recoveryImport());
|
||
if (!result) return;
|
||
renderOperationsRecovery(await window.pork.recoveryList());
|
||
showToast(`Archive imported as ${result.id} and verified with SHA-256. Nothing was restored.`, 'ok', 9000);
|
||
} catch (error) { showToast(error.message, 'error', 10000); }
|
||
});
|
||
|
||
$('operations-recovery-list')?.addEventListener('click', async event => {
|
||
const row = event.target.closest('.operations-recovery-item');
|
||
if (!row) return;
|
||
const id = row.dataset.snapshotId;
|
||
try {
|
||
if (event.target.closest('.operations-recovery-verify')) {
|
||
const verified = await operationsAction(`verify-${id}`, event.target.closest('button'), () => window.pork.recoveryVerify(id));
|
||
showToast(`${verified.files.length} files verified (${verified.integrity}). Nothing was restored.`, 'ok');
|
||
}
|
||
if (event.target.closest('.operations-recovery-export')) await operationsAction(`export-${id}`, event.target.closest('button'), () => window.pork.recoveryExport(id));
|
||
if (event.target.closest('.operations-recovery-restore')) {
|
||
if (!await showExactConfirmation('This restores the database, settings and available PS5 configuration. A new safety recovery point is created first.', 'RESTORE')) return;
|
||
await operationsAction(`restore-${id}`, event.target.closest('button'), () => window.pork.recoveryRestore(id, true));
|
||
showToast('Restore completed. Refresh the page if settings are not immediately visible.', 'ok', 8000);
|
||
await loadOperations();
|
||
}
|
||
if (event.target.closest('.operations-recovery-delete')) {
|
||
if (!await showExactConfirmation('Permanently delete this recovery point?', 'DELETE')) return;
|
||
await operationsAction(`delete-${id}`, event.target.closest('button'), () => window.pork.recoveryDelete(id));
|
||
renderOperationsRecovery(await window.pork.recoveryList());
|
||
}
|
||
} catch (error) { showToast(error.message, 'error'); }
|
||
});
|
||
|
||
$('operations-replication-refresh')?.addEventListener('click', event => operationsAction('replication-refresh', event.currentTarget, loadOperationsReliability).catch(error => showToast(error.message, 'error')));
|
||
$('operations-replication-run')?.addEventListener('click', async event => {
|
||
const includeGameBackups = $('operations-replication-games').checked;
|
||
const warning = includeGameBackups ? '\n\nFull game backups are selected and may use a large amount of storage.' : '';
|
||
if (!await showConfirm(`Create a new hash-verified snapshot on the secondary backup target? Nothing is removed from the source.${warning}`)) return;
|
||
try {
|
||
const label = `Manual replication ${new Date().toLocaleString(window.i18n?.current || 'en')}`;
|
||
await operationsAction('replication-run', event.currentTarget, () => window.pork.backupReplicationRun({ includeGameBackups, label }));
|
||
await loadOperationsReliability();
|
||
showToast('Replication completed and every copied file was verified.', 'ok', 8000);
|
||
} catch (error) { showToast(error.message, 'error', 10000); }
|
||
});
|
||
$('operations-replication-list')?.addEventListener('click', async event => {
|
||
const row = event.target.closest('[data-replication-id]');
|
||
if (!row) return;
|
||
try {
|
||
const button = event.target.closest('button');
|
||
if (button?.classList.contains('operations-replication-verify')) {
|
||
await operationsAction(`replication-verify-${row.dataset.replicationId}`, button, () => window.pork.backupReplicationVerify(row.dataset.replicationId));
|
||
showToast('Snapshot fully verified with SHA-256.', 'ok');
|
||
} else if (button?.classList.contains('operations-replication-drill')) {
|
||
if (!await showConfirm('Copy every snapshot file to a temporary local destination, verify its hash, then remove the temporary copies? The PS5 is not modified.')) return;
|
||
const result = await operationsAction(`replication-drill-${row.dataset.replicationId}`, button, () => window.pork.backupReplicationRestoreDrill(row.dataset.replicationId));
|
||
showToast(`Restore drill succeeded: ${result.files} files, PS5 unchanged.`, 'ok', 8000);
|
||
} else return;
|
||
await loadOperationsReliability();
|
||
} catch (error) { showToast(error.message, 'error', 10000); }
|
||
});
|
||
$('operations-replication-retention')?.addEventListener('submit', async event => {
|
||
event.preventDefault();
|
||
try {
|
||
const preview = await operationsAction('replication-retention', event.submitter, () => window.pork.backupReplicationRetentionSave({ enabled: $('operations-replication-retention-enabled').checked, maxSnapshots: Number($('operations-replication-retention-count').value), maxAgeDays: Number($('operations-replication-retention-days').value) }));
|
||
showToast(`Retention saved. ${preview.removable?.length || 0} snapshot(s) will be eligible after the next successful replication.`, 'ok', 9000);
|
||
await loadOperationsReliability();
|
||
} catch (error) { showToast(error.message, 'error'); }
|
||
});
|
||
$('operations-monitoring-refresh')?.addEventListener('click', event => operationsAction('monitoring-refresh', event.currentTarget, async () => renderMonitoring(await window.pork.monitoringSnapshot())).catch(error => showToast(error.message, 'error')));
|
||
$('operations-monitoring-token')?.addEventListener('click', async event => {
|
||
if (!await showConfirm('Create a new metrics token? An existing token becomes invalid immediately.')) return;
|
||
try {
|
||
const result = await operationsAction('metrics-token', event.currentTarget, () => window.pork.monitoringTokenRotate());
|
||
$('operations-monitoring-token-state').textContent = 'New token active. Save it now; Chimera will not show it again.';
|
||
showOperationsJson($('operations-monitoring-token-output'), `Endpoint: ${location.origin}${result.endpoint}\nAuthorization: Bearer ${result.token}\n\nThis token is shown only once.`);
|
||
} catch (error) { showToast(error.message, 'error'); }
|
||
});
|
||
$('operations-sessions-refresh')?.addEventListener('click', event => operationsAction('sessions-refresh', event.currentTarget, async () => renderSessions(await window.pork.authSessions())).catch(error => showToast(error.message, 'error')));
|
||
$('operations-session-list')?.addEventListener('click', async event => {
|
||
const row = event.target.closest('[data-session-id]');
|
||
if (!row || !event.target.closest('.operations-session-revoke') || !await showExactConfirmation('Revoke this session immediately?', 'REVOKE')) return;
|
||
try {
|
||
const result = await window.pork.authSessionRevoke(row.dataset.sessionId);
|
||
if (result.current) { location.replace('/server-login.html'); return; }
|
||
renderSessions(await window.pork.authSessions());
|
||
showToast('Session revoked.', 'ok');
|
||
} catch (error) { showToast(error.message, 'error'); }
|
||
});
|
||
$('operations-password-form')?.addEventListener('submit', async event => {
|
||
event.preventDefault();
|
||
const currentPassword = $('operations-password-current').value;
|
||
const newPassword = $('operations-password-new').value;
|
||
if (newPassword !== $('operations-password-repeat').value) { showToast('The new passwords do not match.', 'error'); return; }
|
||
if (!await showConfirm('Change the password and revoke all other active sessions?')) return;
|
||
try {
|
||
const result = await operationsAction('password-change', event.submitter, () => window.pork.authPasswordChange(currentPassword, newPassword));
|
||
event.currentTarget.reset();
|
||
showToast(`Password changed; ${result.otherSessionsRevoked} other session(s) revoked.`, 'ok', 8000);
|
||
renderSessions(await window.pork.authSessions());
|
||
} catch (error) { showToast(error.message, 'error'); }
|
||
});
|
||
|
||
$('operations-profile-save')?.addEventListener('click', async event => {
|
||
try {
|
||
const result = await operationsAction('profile', event.currentTarget, () => window.pork.compatibilitySet({
|
||
firmware: $('operations-profile-firmware').value,
|
||
hen: $('operations-profile-hen').value,
|
||
henVersion: $('operations-profile-hen-version').value,
|
||
kstuffVersion: $('operations-profile-kstuff').value,
|
||
loader: $('operations-profile-loader').value,
|
||
notes: $('operations-profile-notes').value,
|
||
}));
|
||
renderOperationsCompatibility(result);
|
||
showToast('PS5 profile saved.', 'ok');
|
||
} catch (error) { showToast(error.message, 'error'); }
|
||
});
|
||
|
||
$('operations-shadow-reload')?.addEventListener('click', event => operationsAction('shadow-load', event.currentTarget, loadOperationsShadowMount).catch(error => showToast(error.message, 'error')));
|
||
$('operations-shadow-propose')?.addEventListener('click', async event => {
|
||
try {
|
||
const proposal = await operationsAction('shadow-proposal', event.currentTarget, () => window.pork.shadowMountProposal());
|
||
$('operations-shadow-manual').value = proposal.proposedManual;
|
||
$('operations-shadow-preflight').innerHTML = `<strong>Library proposal · ${proposal.additions.length} addition(s)</strong><p>${proposal.unchanged.length} unchanged · ${proposal.unresolved.length} unresolved. Nothing was written to the PS5.</p>${proposal.unresolved.length ? `<ul>${proposal.unresolved.slice(0, 20).map(item => `<li>${escHtml(item.gameId)} · ${escHtml(item.reason)}</li>`).join('')}</ul>` : ''}`;
|
||
showToast(proposal.additions.length ? 'ShadowMount proposal loaded for review; save is still separate.' : 'No new PS5-visible paths were found.', proposal.unresolved.length ? 'info' : 'ok', 9000);
|
||
} catch (error) { showToast(error.message, 'error', 10000); }
|
||
});
|
||
$('operations-shadow-save')?.addEventListener('click', async event => {
|
||
try {
|
||
const config = $('operations-shadow-config').value;
|
||
const manual = $('operations-shadow-manual').value;
|
||
const preflight = await operationsAction('shadow-preflight', event.currentTarget, () => window.pork.shadowMountPreflight(config));
|
||
renderOperationsShadowPreflight(preflight);
|
||
if (!preflight.ok) throw new Error('The configuration contains invalid values. Resolve the preflight findings before saving.');
|
||
let confirmation = null;
|
||
if (preflight.requiresRiskConfirmation) {
|
||
confirmation = await showExactConfirmation('This configuration activates critical ShadowMount options. Review the preflight carefully.', preflight.confirmationPhrase);
|
||
if (!confirmation) return;
|
||
} else if (!await showConfirm('Save the ShadowMount configuration? Chimera creates a recovery point first.')) return;
|
||
const result = await operationsAction('shadow-save', event.currentTarget, () => window.pork.shadowMountSave({ config, manual, confirmation }));
|
||
showToast(`ShadowMount saved. Safety restore point: ${result.snapshotId}`, 'ok', 8000);
|
||
await loadOperationsShadowMount();
|
||
} catch (error) { showToast(error.message, 'error'); }
|
||
});
|
||
$('operations-shadow-logs')?.addEventListener('click', async event => {
|
||
try {
|
||
const result = await operationsAction('shadow-logs', event.currentTarget, () => window.pork.shadowMountLogs());
|
||
if (!result.ok) throw new Error(result.error);
|
||
showOperationsJson($('operations-shadow-output'), result.text || 'debug.log is empty.');
|
||
} catch (error) { showToast(error.message, 'error'); }
|
||
});
|
||
|
||
$('operations-package-probe')?.addEventListener('click', event => operationsAction('package-probe', event.currentTarget, loadOperationsPackages).catch(error => showToast(error.message, 'error')));
|
||
$('operations-package-list')?.addEventListener('click', async event => {
|
||
try { showOperationsJson($('operations-package-output'), await operationsAction('package-list', event.currentTarget, () => window.pork.packagesList())); }
|
||
catch (error) { showToast(error.message, 'error'); }
|
||
});
|
||
$('operations-package-progress')?.addEventListener('click', async event => {
|
||
try { showOperationsJson($('operations-package-output'), await operationsAction('package-progress', event.currentTarget, () => window.pork.packagesProgress())); }
|
||
catch (error) { showToast(error.message, 'error'); }
|
||
});
|
||
$('operations-package-install')?.addEventListener('click', async event => {
|
||
const url = $('operations-package-url').value.trim();
|
||
if (!url || !await showConfirm(`Send this HTTPS URL to etaHEN DPI?\n\n${url}`)) return;
|
||
try {
|
||
const result = await operationsAction('package-install', event.currentTarget, () => window.pork.packagesInstallUrl(url));
|
||
showOperationsJson($('operations-package-output'), result);
|
||
showToast('Package URL sent to etaHEN DPI.', 'ok');
|
||
} catch (error) { showToast(error.message, 'error'); }
|
||
});
|
||
|
||
$('operations-diagnostics-run')?.addEventListener('click', async event => {
|
||
try {
|
||
const result = await operationsAction('diagnostics', event.currentTarget, () => window.pork.diagnosticsSummary());
|
||
const services = result.packages?.services || [];
|
||
const build = result.chimera?.build || {};
|
||
$('operations-diagnostics-summary').innerHTML = [
|
||
['Chimera', result.chimera?.version || '—'],
|
||
['Build', build.id || 'development'],
|
||
['Revision', String(build.revision || 'development').slice(0, 12)],
|
||
['Built', build.builtAt ? operationsDate(build.builtAt) : '—'],
|
||
['Uptime', `${Math.round(Number(result.chimera?.uptimeSeconds || 0) / 60)} min.`],
|
||
['FTP', result.ps5?.ftpConnected ? 'Connected' : result.ps5?.ftpReconnectAvailable ? 'Reconnect available' : 'Not connected'],
|
||
['PS5 services', `${services.filter(item => item.reachable).length}/${services.length} online`],
|
||
].map(([label, value]) => `<div class="operations-metric"><strong>${escHtml(value)}</strong><span>${escHtml(label)}</span></div>`).join('');
|
||
showOperationsJson($('operations-diagnostics-log'), result.recentLogs || []);
|
||
} catch (error) { showToast(error.message, 'error'); }
|
||
});
|
||
$('operations-diagnostics-export')?.addEventListener('click', event => operationsAction('diagnostics-export', event.currentTarget, () => window.pork.diagnosticsExport()).then(() => showToast('Geschoonde supportbundel aangemaakt.', 'ok')).catch(error => showToast(error.message, 'error')));
|
||
|
||
$('operations-audit-filter')?.addEventListener('submit', event => {
|
||
event.preventDefault();
|
||
operationsAction('audit-filter', event.submitter, loadOperationsAudit).catch(error => showToast(error.message, 'error'));
|
||
});
|
||
$('operations-audit-refresh')?.addEventListener('click', event => operationsAction('audit-refresh', event.currentTarget, loadOperationsAudit).catch(error => showToast(error.message, 'error')));
|
||
$('operations-audit-export')?.addEventListener('click', event => operationsAction('audit-export', event.currentTarget, () => window.pork.auditExport(operationsAuditFilters())).then(() => showToast('Gefilterde auditexport aangemaakt.', 'ok')).catch(error => showToast(error.message, 'error')));
|
||
|
||
$('operations-klog-start')?.addEventListener('click', async event => {
|
||
try {
|
||
const status = await operationsAction('klog-start', event.currentTarget, () => window.pork.klogStart({ durationSeconds: Number($('operations-klog-duration').value) }));
|
||
renderKlogStatus(status);
|
||
showToast('Passive capture started; Chimera writes nothing to the PS5.', 'ok');
|
||
} catch (error) { showToast(error.message, 'error'); }
|
||
});
|
||
$('operations-klog-stop')?.addEventListener('click', async event => {
|
||
try {
|
||
renderKlogStatus(await operationsAction('klog-stop', event.currentTarget, () => window.pork.klogStop()));
|
||
await loadKlog();
|
||
} catch (error) { showToast(error.message, 'error'); }
|
||
});
|
||
$('operations-klog-refresh')?.addEventListener('click', event => operationsAction('klog-refresh', event.currentTarget, loadKlog).catch(error => showToast(error.message, 'error')));
|
||
$('operations-klog-export')?.addEventListener('click', event => operationsAction('klog-export', event.currentTarget, () => window.pork.klogExport(klogFilters())).then(() => showToast('Geschoonde kernellogexport aangemaakt.', 'ok')).catch(error => showToast(error.message, 'error')));
|
||
|
||
window.pork?.on?.('operations:task-update', () => {
|
||
if (document.querySelector('#page-operations.active')) loadOperations().catch(() => {});
|
||
});
|
||
window.pork?.on?.('klog:status', status => renderKlogStatus(status));
|
||
window.addEventListener('chimera:language-changed', () => {
|
||
if (!document.querySelector('#page-operations.active [data-operations-panel="storage"]:not([hidden])')) return;
|
||
renderOperationsStorage(_operationsStorageReport);
|
||
if (_operationsStorageTopology) renderStorageTopology(_operationsStorageTopology);
|
||
});
|