Files
chimera-Public/renderer/modules/operations.js
T

701 lines
49 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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);
});