Template
Show Hysteria stop errors and add live container logs viewer.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+173
-2
@@ -953,6 +953,29 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Container Logs Modal (live) ===== -->
|
||||
<div class="modal-backdrop" id="containerLogsModal">
|
||||
<div class="modal" style="max-width:820px;">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title" id="containerLogsModalTitle">{{ _('container_logs') }}</h2>
|
||||
<button class="modal-close" onclick="closeContainerLogsModal()">×</button>
|
||||
</div>
|
||||
<input type="hidden" id="containerLogsProto" value="" />
|
||||
<div id="containerLogsMeta" class="form-hint" style="margin-bottom:var(--space-sm);"></div>
|
||||
<div class="log-output" id="containerLogsOutput"
|
||||
style="min-height:280px; max-height:55vh; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:0.78rem; white-space:pre-wrap; word-break:break-word;"></div>
|
||||
<div class="modal-footer" style="display:flex; gap:var(--space-sm); flex-wrap:wrap;">
|
||||
<label style="display:flex; align-items:center; gap:6px; margin-right:auto; cursor:pointer;">
|
||||
<input type="checkbox" id="containerLogsLive" checked onchange="toggleContainerLogsLive()">
|
||||
<span>{{ _('logs_live') }}</span>
|
||||
</label>
|
||||
<button class="btn btn-secondary btn-sm" onclick="refreshContainerLogs()">{{ _('refresh') }}</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard(document.getElementById('containerLogsOutput').textContent)">{{ _('copy') }}</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="closeContainerLogsModal()">{{ _('close') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
@@ -1552,17 +1575,21 @@
|
||||
// unbound's static config), so we don't expose a raw editor for them.
|
||||
if (ctrlEl) {
|
||||
ctrlEl.style.cssText = '';
|
||||
const logsBtn = `<button class="btn btn-secondary btn-sm" onclick="showContainerLogs('${proto}')" title="${_('container_logs')}">${_('logs_btn')}</button>`;
|
||||
if (protoBase(proto) === 'nginx') {
|
||||
ctrlEl.innerHTML = `
|
||||
${logsBtn}
|
||||
<button class="btn btn-secondary btn-sm" onclick="showServerConfig('${proto}')" title="${_('server_config')}">${_('config_btn')}</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="toggleContainer('${proto}', true)" title="${_('stop_container_confirm').replace('{}', '')}">${_('stop_btn')}</button>
|
||||
`;
|
||||
} else if (isService) {
|
||||
ctrlEl.innerHTML = `
|
||||
${logsBtn}
|
||||
<button class="btn btn-danger btn-sm" onclick="toggleContainer('${proto}', true)" title="${_('stop_container_confirm').replace('{}', '')}">${_('stop_btn')}</button>
|
||||
`;
|
||||
} else {
|
||||
ctrlEl.innerHTML = `
|
||||
${logsBtn}
|
||||
<button class="btn btn-secondary btn-sm" onclick="showServerConfig('${proto}')" title="${_('server_config')}">${_('config_btn')}</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="toggleContainer('${proto}', true)" title="${_('stop_container_confirm').replace('{}', '')}">${_('stop_btn')}</button>
|
||||
`;
|
||||
@@ -1570,33 +1597,59 @@
|
||||
}
|
||||
} else if (info.container_exists) {
|
||||
installedProtocols[proto] = true;
|
||||
statusEl.innerHTML = `<span class="badge badge-danger"><span class="badge-dot"></span> ${_('stop')}</span>`;
|
||||
const errText = (info.error || '').trim();
|
||||
statusEl.innerHTML = errText
|
||||
? `<span class="badge badge-danger"><span class="badge-dot"></span> ${_('stop')}: ${escapeHtml(errText.slice(0, 80))}</span>`
|
||||
: `<span class="badge badge-danger"><span class="badge-dot"></span> ${_('stop')}</span>`;
|
||||
if (infoEl && infoGrid) {
|
||||
infoEl.classList.remove('hidden');
|
||||
let grid = '';
|
||||
if (info.port) {
|
||||
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('port')}</span><span class="protocol-info-value">${info.port}</span></div>`;
|
||||
}
|
||||
if (info.container_status) {
|
||||
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('container_status')}</span><span class="protocol-info-value">${escapeHtml(String(info.container_status))}</span></div>`;
|
||||
}
|
||||
if (info.exit_code !== undefined && info.exit_code !== null) {
|
||||
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('exit_code')}</span><span class="protocol-info-value">${info.exit_code}</span></div>`;
|
||||
}
|
||||
if (errText) {
|
||||
grid += `<div class="protocol-info-item" style="grid-column:1/-1;"><span class="protocol-info-label">${_('error')}</span><span class="protocol-info-value" style="color:var(--danger); word-break:break-word;">${escapeHtml(errText)}</span></div>`;
|
||||
}
|
||||
infoGrid.innerHTML = grid;
|
||||
}
|
||||
if (isService) {
|
||||
actionsEl.innerHTML = `
|
||||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('${proto}')" style="flex:1">${_('reinstall')}</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="showContainerLogs('${proto}')">${_('logs_btn')}</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="showProtocolBackups('${proto}')">${_('backup')}</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
|
||||
`;
|
||||
} else {
|
||||
actionsEl.innerHTML = `
|
||||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('${proto}')" style="flex:1">${_('reinstall')}</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="showContainerLogs('${proto}')">${_('logs_btn')}</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="showProtocolBackups('${proto}')">${_('backup')}</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
|
||||
`;
|
||||
}
|
||||
if (ctrlEl) {
|
||||
ctrlEl.style.cssText = '';
|
||||
const logsBtn = `<button class="btn btn-secondary btn-sm" onclick="showContainerLogs('${proto}')" title="${_('container_logs')}">${_('logs_btn')}</button>`;
|
||||
if (protoBase(proto) === 'nginx') {
|
||||
ctrlEl.innerHTML = `
|
||||
${logsBtn}
|
||||
<button class="btn btn-secondary btn-sm" onclick="showServerConfig('${proto}')" title="${_('server_config')}">${_('config_btn')}</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="toggleContainer('${proto}', false)" title="${_('start_container_confirm').replace('{}', '')}">${_('start_btn')}</button>
|
||||
`;
|
||||
} else if (isService) {
|
||||
ctrlEl.innerHTML = `
|
||||
${logsBtn}
|
||||
<button class="btn btn-primary btn-sm" onclick="toggleContainer('${proto}', false)" title="${_('start_container_confirm').replace('{}', '')}">${_('start_btn')}</button>
|
||||
`;
|
||||
} else {
|
||||
ctrlEl.innerHTML = `
|
||||
${logsBtn}
|
||||
<button class="btn btn-secondary btn-sm" onclick="showServerConfig('${proto}')" title="${_('server_config')}">${_('config_btn')}</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="toggleContainer('${proto}', false)" title="${_('start_container_confirm').replace('{}', '')}">${_('start_btn')}</button>
|
||||
`;
|
||||
@@ -1624,13 +1677,131 @@
|
||||
showToast(`${action} ${name}...`, 'info');
|
||||
const res = await apiCall(`/api/servers/${SERVER_ID}/container/toggle`, 'POST', { protocol: proto });
|
||||
showToast(`${name} ${res.action === 'stopped' ? _('stopped') : _('started')}`, 'success');
|
||||
// Recheck after a short delay
|
||||
setTimeout(() => checkServer(), 1500);
|
||||
} catch (err) {
|
||||
showToast(_('error') + ': ' + err.message, 'error');
|
||||
// Show live logs so admin can see why start failed
|
||||
showContainerLogs(proto);
|
||||
setTimeout(() => checkServer(), 800);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Live container logs =====
|
||||
let containerLogsES = null;
|
||||
let containerLogsProto = '';
|
||||
|
||||
function renderContainerLogsText(text, stateStr) {
|
||||
const out = document.getElementById('containerLogsOutput');
|
||||
const meta = document.getElementById('containerLogsMeta');
|
||||
if (!out) return;
|
||||
const atBottom = out.scrollHeight - out.scrollTop - out.clientHeight < 40;
|
||||
out.textContent = text || _('no_logs');
|
||||
if (atBottom) out.scrollTop = out.scrollHeight;
|
||||
if (meta) {
|
||||
const parts = String(stateStr || '').split('|');
|
||||
const status = parts[0] || '';
|
||||
const running = String(parts[1] || '').toLowerCase() === 'true';
|
||||
const exitCode = parts[2] || '';
|
||||
const err = parts[3] || '';
|
||||
let line = running
|
||||
? `<span class="badge badge-success"><span class="badge-dot"></span> ${_('run')}</span>`
|
||||
: `<span class="badge badge-danger"><span class="badge-dot"></span> ${_('stop')}</span>`;
|
||||
if (status) line += ` · ${escapeHtml(status)}`;
|
||||
if (exitCode !== '' && exitCode !== '0') line += ` · ${_('exit_code')}: ${escapeHtml(exitCode)}`;
|
||||
if (err) line += ` · <span style="color:var(--danger)">${escapeHtml(err)}</span>`;
|
||||
meta.innerHTML = line;
|
||||
}
|
||||
}
|
||||
|
||||
function stopContainerLogsStream() {
|
||||
if (containerLogsES) {
|
||||
try { containerLogsES.close(); } catch (_) {}
|
||||
containerLogsES = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startContainerLogsStream(proto) {
|
||||
stopContainerLogsStream();
|
||||
const live = document.getElementById('containerLogsLive');
|
||||
if (live && !live.checked) return;
|
||||
const url = `/api/servers/${SERVER_ID}/container/logs/stream?protocol=${encodeURIComponent(proto)}&tail=200`;
|
||||
containerLogsES = new EventSource(url);
|
||||
containerLogsES.onmessage = (ev) => {
|
||||
try {
|
||||
const data = JSON.parse(ev.data);
|
||||
if (data.error) {
|
||||
renderContainerLogsText(data.error, '');
|
||||
return;
|
||||
}
|
||||
renderContainerLogsText(data.logs || '', data.state || '');
|
||||
} catch (_) {}
|
||||
};
|
||||
containerLogsES.onerror = () => {
|
||||
// Browser will retry; show a soft hint once
|
||||
const meta = document.getElementById('containerLogsMeta');
|
||||
if (meta && !meta.dataset.streamErr) {
|
||||
meta.dataset.streamErr = '1';
|
||||
meta.innerHTML += ` · <span class="form-hint">${_('logs_reconnect')}</span>`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshContainerLogs() {
|
||||
const proto = document.getElementById('containerLogsProto').value || containerLogsProto;
|
||||
if (!proto) return;
|
||||
try {
|
||||
const res = await apiCall(`/api/servers/${SERVER_ID}/container/logs`, 'POST', {
|
||||
protocol: proto, tail: 200,
|
||||
});
|
||||
const state = [
|
||||
res.container_status || '',
|
||||
res.running ? 'true' : 'false',
|
||||
res.exit_code != null ? String(res.exit_code) : '',
|
||||
res.error || '',
|
||||
].join('|');
|
||||
renderContainerLogsText(res.logs || '', state);
|
||||
} catch (err) {
|
||||
renderContainerLogsText(_('error') + ': ' + err.message, '');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleContainerLogsLive() {
|
||||
const live = document.getElementById('containerLogsLive');
|
||||
if (live && live.checked) {
|
||||
startContainerLogsStream(containerLogsProto);
|
||||
} else {
|
||||
stopContainerLogsStream();
|
||||
}
|
||||
}
|
||||
|
||||
async function showContainerLogs(proto) {
|
||||
containerLogsProto = proto;
|
||||
document.getElementById('containerLogsProto').value = proto;
|
||||
document.getElementById('containerLogsModalTitle').textContent =
|
||||
`${_('container_logs')} — ${getProtoTitle(proto)}`;
|
||||
document.getElementById('containerLogsOutput').textContent = _('loading');
|
||||
document.getElementById('containerLogsMeta').innerHTML = '';
|
||||
document.getElementById('containerLogsMeta').dataset.streamErr = '';
|
||||
const live = document.getElementById('containerLogsLive');
|
||||
if (live) live.checked = true;
|
||||
openModal('containerLogsModal');
|
||||
await refreshContainerLogs();
|
||||
startContainerLogsStream(proto);
|
||||
}
|
||||
|
||||
function closeContainerLogsModal() {
|
||||
stopContainerLogsStream();
|
||||
closeModal('containerLogsModal');
|
||||
}
|
||||
|
||||
// Stop live stream if modal is closed via backdrop / Escape
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target && e.target.id === 'containerLogsModal') stopContainerLogsStream();
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') stopContainerLogsStream();
|
||||
});
|
||||
|
||||
// View server-side WireGuard/Xray config
|
||||
async function showServerConfig(proto) {
|
||||
const name = getProtoTitle(proto);
|
||||
|
||||
Reference in New Issue
Block a user