diff --git a/app.py b/app.py
index 66511d6..f7b160b 100644
--- a/app.py
+++ b/app.py
@@ -1705,6 +1705,11 @@ class ProtocolRequest(BaseModel):
protocol: str = 'awg'
+class ContainerLogsRequest(BaseModel):
+ protocol: str = 'hysteria'
+ tail: Optional[int] = 200
+
+
class AddConnectionRequest(BaseModel):
protocol: str = 'awg'
name: str = 'Connection'
@@ -3141,15 +3146,169 @@ async def api_container_toggle(request: Request, server_id: int, req: ProtocolRe
ssh.run_sudo_command(f"docker stop {container}")
action = 'stopped'
else:
- ssh.run_sudo_command(f"docker start {container}")
+ # Hysteria: recreate with host network / current config instead of plain start
+ if protocol_base(req.protocol) == 'hysteria':
+ from managers.hysteria_manager import HysteriaManager
+ mgr = HysteriaManager(ssh, req.protocol)
+ meta = mgr._read_metadata()
+ port = int(meta.get('port') or mgr.DEFAULT_PORT)
+ mgr._write_config_from_clients(port)
+ mgr._start_container(port)
+ else:
+ ssh.run_sudo_command(f"docker start {container}")
action = 'started'
+ # After stop/start, return diagnostics for UI
+ error = ''
+ recent_logs = ''
+ if protocol_base(req.protocol) == 'hysteria':
+ from managers.hysteria_manager import HysteriaManager
+ mgr = HysteriaManager(ssh, req.protocol)
+ diag = mgr.get_container_diagnostics(req.protocol)
+ error = diag.get('error_summary') or ''
+ recent_logs = diag.get('recent_logs') or ''
+ if action == 'started' and not diag.get('running'):
+ ssh.disconnect()
+ return JSONResponse({
+ 'error': error or 'Hysteria failed to start',
+ 'action': action,
+ 'container': container,
+ 'recent_logs': recent_logs,
+ }, status_code=400)
ssh.disconnect()
- return {'status': 'success', 'action': action, 'container': container}
+ return {
+ 'status': 'success',
+ 'action': action,
+ 'container': container,
+ 'error': error,
+ 'recent_logs': recent_logs,
+ }
except Exception as e:
logger.exception("Error toggling container")
return JSONResponse({'error': str(e)}, status_code=500)
+@app.post('/api/servers/{server_id}/container/logs', tags=["Protocols"])
+async def api_container_logs(request: Request, server_id: int, req: ContainerLogsRequest):
+ """Fetch recent Docker logs for a protocol container."""
+ if not _check_admin(request):
+ return JSONResponse({'error': 'Forbidden'}, status_code=403)
+ try:
+ data = load_data()
+ if server_id >= len(data['servers']):
+ return JSONResponse({'error': 'Server not found'}, status_code=404)
+ if not is_valid_protocol(req.protocol):
+ return JSONResponse({'error': 'Unknown protocol'}, status_code=400)
+ server = data['servers'][server_id]
+ container = protocol_container_name(req.protocol)
+ ssh = get_ssh(server)
+ ssh.connect()
+ if protocol_base(req.protocol) == 'hysteria':
+ from managers.hysteria_manager import HysteriaManager
+ mgr = HysteriaManager(ssh, req.protocol)
+ logs = mgr.get_logs(req.protocol, tail=req.tail or 200)
+ diag = mgr.get_container_diagnostics(req.protocol)
+ ssh.disconnect()
+ return {
+ 'status': 'success',
+ 'protocol': req.protocol,
+ 'container': container,
+ 'logs': logs,
+ 'running': bool(diag.get('running')),
+ 'error': diag.get('error_summary') or '',
+ 'exit_code': diag.get('exit_code'),
+ 'container_status': diag.get('status') or '',
+ }
+ # Generic fallback for other protocols
+ tail = max(20, min(int(req.tail or 200), 2000))
+ out, err, _ = ssh.run_sudo_command(
+ f"docker logs --tail {tail} --timestamps {shlex.quote(container)} 2>&1",
+ timeout=30,
+ )
+ running_out, _, _ = ssh.run_sudo_command(
+ f"docker inspect -f '{{{{.State.Running}}}}' {shlex.quote(container)} 2>/dev/null"
+ )
+ ssh.disconnect()
+ return {
+ 'status': 'success',
+ 'protocol': req.protocol,
+ 'container': container,
+ 'logs': (out or err or '').strip(),
+ 'running': running_out.strip().lower() == 'true',
+ 'error': '',
+ }
+ except Exception as e:
+ logger.exception("Error reading container logs")
+ return JSONResponse({'error': str(e)}, status_code=500)
+
+
+@app.get('/api/servers/{server_id}/container/logs/stream', tags=["Protocols"])
+async def api_container_logs_stream(request: Request, server_id: int, protocol: str = 'hysteria', tail: int = 100):
+ """SSE stream of docker logs (polls every ~1.5s for new lines)."""
+ if not _check_admin(request):
+ return JSONResponse({'error': 'Forbidden'}, status_code=403)
+ data = load_data()
+ if server_id >= len(data['servers']):
+ return JSONResponse({'error': 'Server not found'}, status_code=404)
+ if not is_valid_protocol(protocol):
+ return JSONResponse({'error': 'Unknown protocol'}, status_code=400)
+ server = data['servers'][server_id]
+ container = protocol_container_name(protocol)
+ if not container:
+ return JSONResponse({'error': 'Unknown protocol'}, status_code=400)
+ tail = max(20, min(int(tail or 100), 500))
+
+ async def event_gen():
+ last_text = ''
+ try:
+ while True:
+ if await request.is_disconnected():
+ break
+
+ def _fetch():
+ ssh = get_ssh(server)
+ ssh.connect()
+ try:
+ out, err, _ = ssh.run_sudo_command(
+ f"docker logs --tail {tail} --timestamps {shlex.quote(container)} 2>&1",
+ timeout=25,
+ )
+ running_out, _, _ = ssh.run_sudo_command(
+ f"docker inspect -f '{{{{.State.Status}}}}|{{{{.State.Running}}}}|{{{{.State.ExitCode}}}}|{{{{.State.Error}}}}' {shlex.quote(container)} 2>/dev/null"
+ )
+ return (out or err or '').strip(), (running_out or '').strip()
+ finally:
+ ssh.disconnect()
+
+ logs, state = await asyncio.to_thread(_fetch)
+ if logs != last_text:
+ last_text = logs
+ payload = json.dumps({
+ 'logs': logs,
+ 'state': state,
+ 'container': container,
+ 'ts': time.time(),
+ }, ensure_ascii=False)
+ yield f"data: {payload}\n\n"
+ else:
+ yield f": ping\n\n"
+ await asyncio.sleep(1.5)
+ except asyncio.CancelledError:
+ return
+ except Exception as e:
+ payload = json.dumps({'error': str(e)}, ensure_ascii=False)
+ yield f"data: {payload}\n\n"
+
+ return StreamingResponse(
+ event_gen(),
+ media_type='text/event-stream',
+ headers={
+ 'Cache-Control': 'no-cache',
+ 'Connection': 'keep-alive',
+ 'X-Accel-Buffering': 'no',
+ },
+ )
+
+
@app.post('/api/servers/{server_id}/server_config', tags=["Protocols"])
async def api_server_config(request: Request, server_id: int, req: ProtocolRequest):
"""Get the raw server-side WireGuard/Xray configuration."""
diff --git a/managers/hysteria_manager.py b/managers/hysteria_manager.py
index 2554524..05a1b4e 100644
--- a/managers/hysteria_manager.py
+++ b/managers/hysteria_manager.py
@@ -91,12 +91,95 @@ class HysteriaManager:
)
return 'Up' in out
+ def get_logs(self, protocol_type=None, tail=200):
+ """Return recent docker logs for the Hysteria container."""
+ name = self._container_name(protocol_type or self.protocol)
+ tail = max(20, min(int(tail or 200), 2000))
+ out, err, code = self.ssh.run_sudo_command(
+ f"docker logs --tail {tail} --timestamps {name} 2>&1",
+ timeout=30,
+ )
+ text = (out or err or '').strip()
+ if code != 0 and not text:
+ text = f'(no logs: exit {code})'
+ return text
+
+ def get_container_diagnostics(self, protocol_type=None):
+ """Inspect why the container is stopped / unhealthy."""
+ name = self._container_name(protocol_type or self.protocol)
+ fmt = (
+ '{{.State.Status}}|{{.State.Running}}|{{.State.ExitCode}}|'
+ '{{.State.OOMKilled}}|{{.State.Error}}|{{.State.FinishedAt}}|'
+ '{{.State.StartedAt}}'
+ )
+ out, _, code = self.ssh.run_sudo_command(
+ f"docker inspect -f '{fmt}' {name} 2>/dev/null"
+ )
+ diag = {
+ 'status': 'unknown',
+ 'running': False,
+ 'exit_code': None,
+ 'oom_killed': False,
+ 'error': '',
+ 'finished_at': '',
+ 'started_at': '',
+ 'recent_logs': '',
+ 'error_summary': '',
+ }
+ if code != 0 or not (out or '').strip():
+ diag['status'] = 'missing'
+ diag['error_summary'] = 'Container not found'
+ return diag
+
+ parts = (out or '').strip().split('|')
+ while len(parts) < 7:
+ parts.append('')
+ status, running, exit_code, oom, err, finished, started = parts[:7]
+ diag['status'] = status or 'unknown'
+ diag['running'] = str(running).lower() == 'true'
+ try:
+ diag['exit_code'] = int(exit_code)
+ except Exception:
+ diag['exit_code'] = None
+ diag['oom_killed'] = str(oom).lower() == 'true'
+ diag['error'] = (err or '').strip()
+ diag['finished_at'] = (finished or '').strip()
+ diag['started_at'] = (started or '').strip()
+ diag['recent_logs'] = self.get_logs(protocol_type, tail=40)
+
+ # Build human-readable summary for the UI badge
+ if diag['running']:
+ diag['error_summary'] = ''
+ elif diag['oom_killed']:
+ diag['error_summary'] = 'OOM killed (out of memory)'
+ elif diag['error']:
+ diag['error_summary'] = diag['error']
+ elif diag['exit_code'] not in (None, 0):
+ # Pull last meaningful log line
+ last = ''
+ for line in reversed((diag['recent_logs'] or '').splitlines()):
+ line = line.strip()
+ if line:
+ last = line
+ break
+ if last:
+ # strip docker timestamp prefix if present
+ if ' ' in last and last[0].isdigit():
+ last = last.split(' ', 1)[-1]
+ diag['error_summary'] = f"exit {diag['exit_code']}: {last[:180]}"
+ else:
+ diag['error_summary'] = f"exited with code {diag['exit_code']}"
+ elif diag['status'] and diag['status'] != 'running':
+ diag['error_summary'] = f"status: {diag['status']}"
+ return diag
+
def get_server_status(self, protocol_type=None):
protocol_type = protocol_type or self.protocol
exists = self.check_protocol_installed(protocol_type)
running = self.check_container_running(protocol_type)
meta = self._read_metadata() if exists else {}
clients = self._read_clients() if exists else []
+ diag = {}
# Auto-migrate older installs (bridge NAT / userpass / missing BBR+obfs)
if exists:
@@ -120,6 +203,15 @@ class HysteriaManager:
meta = self._read_metadata()
except Exception as e:
logger.warning('Hysteria migrate failed: %s', e)
+ diag = {'error_summary': f'migrate failed: {e}'}
+
+ if exists:
+ try:
+ diag = self.get_container_diagnostics(protocol_type)
+ running = bool(diag.get('running'))
+ except Exception as e:
+ logger.warning('Hysteria diagnostics failed: %s', e)
+ diag = {'error_summary': str(e)}
return {
'container_exists': exists,
@@ -132,6 +224,10 @@ class HysteriaManager:
'base_protocol': self.PROTOCOL,
'instance': self._instance_index(protocol_type),
'container_name': self._container_name(protocol_type),
+ 'error': diag.get('error_summary') or '',
+ 'exit_code': diag.get('exit_code'),
+ 'container_status': diag.get('status') or '',
+ 'recent_logs': diag.get('recent_logs') or '',
}
# ===================== HELPERS =====================
diff --git a/templates/server.html b/templates/server.html
index 6503a11..493f937 100644
--- a/templates/server.html
+++ b/templates/server.html
@@ -953,6 +953,29 @@
+
+
+
{% 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 = ``;
if (protoBase(proto) === 'nginx') {
ctrlEl.innerHTML = `
+ ${logsBtn}
`;
} else if (isService) {
ctrlEl.innerHTML = `
+ ${logsBtn}
`;
} else {
ctrlEl.innerHTML = `
+ ${logsBtn}
`;
@@ -1570,33 +1597,59 @@
}
} else if (info.container_exists) {
installedProtocols[proto] = true;
- statusEl.innerHTML = ` ${_('stop')}`;
+ const errText = (info.error || '').trim();
+ statusEl.innerHTML = errText
+ ? ` ${_('stop')}: ${escapeHtml(errText.slice(0, 80))}`
+ : ` ${_('stop')}`;
+ if (infoEl && infoGrid) {
+ infoEl.classList.remove('hidden');
+ let grid = '';
+ if (info.port) {
+ grid += `${_('port')}${info.port}
`;
+ }
+ if (info.container_status) {
+ grid += `${_('container_status')}${escapeHtml(String(info.container_status))}
`;
+ }
+ if (info.exit_code !== undefined && info.exit_code !== null) {
+ grid += `${_('exit_code')}${info.exit_code}
`;
+ }
+ if (errText) {
+ grid += `${_('error')}${escapeHtml(errText)}
`;
+ }
+ infoGrid.innerHTML = grid;
+ }
if (isService) {
actionsEl.innerHTML = `
+
`;
} else {
actionsEl.innerHTML = `
+
`;
}
if (ctrlEl) {
ctrlEl.style.cssText = '';
+ const logsBtn = ``;
if (protoBase(proto) === 'nginx') {
ctrlEl.innerHTML = `
+ ${logsBtn}
`;
} else if (isService) {
ctrlEl.innerHTML = `
+ ${logsBtn}
`;
} else {
ctrlEl.innerHTML = `
+ ${logsBtn}
`;
@@ -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
+ ? ` ${_('run')}`
+ : ` ${_('stop')}`;
+ if (status) line += ` · ${escapeHtml(status)}`;
+ if (exitCode !== '' && exitCode !== '0') line += ` · ${_('exit_code')}: ${escapeHtml(exitCode)}`;
+ if (err) line += ` · ${escapeHtml(err)}`;
+ 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 += ` · ${_('logs_reconnect')}`;
+ }
+ };
+ }
+
+ 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);
diff --git a/translations/en.json b/translations/en.json
index 9b10e3f..cebbd95 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -362,6 +362,14 @@
"hysteria_port_hint": "UDP port for Hysteria (default 443 — better for DPI). Port 80/TCP must be free for certificate issuance.",
"hysteria_install_hint": "Point the domain A-record to this server before install. Port 80/TCP is used temporarily for Let's Encrypt HTTP-01 validation. BBR and salamander obfuscation are enabled automatically.",
"hysteria_dns_hint": "Create this DNS record before installation:",
+ "container_logs": "Container logs",
+ "logs_btn": "📋 Logs",
+ "logs_live": "Live",
+ "logs_reconnect": "reconnecting…",
+ "no_logs": "No logs yet",
+ "container_status": "Status",
+ "exit_code": "Exit code",
+ "close": "Close",
"tls_emulation": "TLS Emulation",
"tls_domain": "Masking Domain",
"max_connections_limit": "Max Connections",
diff --git a/translations/fa.json b/translations/fa.json
index 7d6af92..d957724 100644
--- a/translations/fa.json
+++ b/translations/fa.json
@@ -367,6 +367,13 @@
"hysteria_port_hint": "پورت UDP برای Hysteria (پیشفرض 443). پورت 80/TCP باید برای صدور گواهی آزاد باشد.",
"hysteria_install_hint": "قبل از نصب، رکورد A دامنه باید به این سرور اشاره کند. BBR و obfuscation salamander بهصورت خودکار فعال میشوند.",
"hysteria_dns_hint": "قبل از نصب این رکورد DNS را بسازید:",
+ "container_logs": "لاگهای کانتینر",
+ "logs_btn": "📋 Logs",
+ "logs_live": "زنده",
+ "logs_reconnect": "در حال اتصال مجدد…",
+ "no_logs": "هنوز لاگی نیست",
+ "container_status": "وضعیت",
+ "exit_code": "کد خروج",
"dns_desc": "سرور DNS شخصی برای مسدود کردن تبلیغات و حفظ حریم خصوصی.",
"dns_internal_hint": "The service runs on the internal network (172.29.172.254) without binding to host ports.",
"telemt_port_hint": "Port for the Telegram proxy (typically 443).",
diff --git a/translations/fr.json b/translations/fr.json
index 5dd0622..8c43470 100644
--- a/translations/fr.json
+++ b/translations/fr.json
@@ -367,6 +367,13 @@
"hysteria_port_hint": "Port UDP pour Hysteria (443 par défaut). Le port 80/TCP doit être libre pour le certificat.",
"hysteria_install_hint": "L'enregistrement A du domaine doit pointer vers ce serveur. BBR et obfuscation salamander sont activés automatiquement.",
"hysteria_dns_hint": "Créez cet enregistrement DNS avant l'installation :",
+ "container_logs": "Logs du conteneur",
+ "logs_btn": "📋 Logs",
+ "logs_live": "Temps réel",
+ "logs_reconnect": "reconnexion…",
+ "no_logs": "Pas encore de logs",
+ "container_status": "Statut",
+ "exit_code": "Code de sortie",
"dns_desc": "Serveur DNS personnel pour bloquer les publicités et protéger la vie privée.",
"dns_internal_hint": "The service runs on the internal network (172.29.172.254) without binding to host ports.",
"telemt_port_hint": "Port for the Telegram proxy (typically 443).",
diff --git a/translations/ru.json b/translations/ru.json
index e32164f..452d986 100644
--- a/translations/ru.json
+++ b/translations/ru.json
@@ -362,6 +362,14 @@
"hysteria_port_hint": "UDP-порт Hysteria (по умолчанию 443 — лучше проходит DPI). Порт 80/TCP должен быть свободен для выпуска сертификата.",
"hysteria_install_hint": "Перед установкой A-запись домена должна указывать на этот сервер. Порт 80/TCP временно используется для HTTP-01 проверки Let's Encrypt. BBR и obfuscation salamander включаются автоматически.",
"hysteria_dns_hint": "Перед установкой создайте DNS-запись:",
+ "container_logs": "Логи контейнера",
+ "logs_btn": "📋 Логи",
+ "logs_live": "В реальном времени",
+ "logs_reconnect": "переподключение…",
+ "no_logs": "Логов пока нет",
+ "container_status": "Статус",
+ "exit_code": "Код выхода",
+ "close": "Закрыть",
"tls_emulation": "TLS-эмуляция",
"tls_domain": "Домен маскировки",
"max_connections_limit": "Макс. соединений",
diff --git a/translations/zh.json b/translations/zh.json
index 8e46b6f..9d8dab6 100644
--- a/translations/zh.json
+++ b/translations/zh.json
@@ -367,6 +367,13 @@
"hysteria_port_hint": "Hysteria UDP 端口(默认 443)。签发证书时需要 80/TCP 空闲。",
"hysteria_install_hint": "安装前请将域名 A 记录指向本服务器。将自动启用 BBR 与 salamander 混淆。",
"hysteria_dns_hint": "安装前请创建此 DNS 记录:",
+ "container_logs": "容器日志",
+ "logs_btn": "📋 日志",
+ "logs_live": "实时",
+ "logs_reconnect": "重新连接…",
+ "no_logs": "暂无日志",
+ "container_status": "状态",
+ "exit_code": "退出码",
"dns_desc": "防止广告和保护隐私的个人 DNS 服务器。",
"dns_internal_hint": "The service runs on the internal network (172.29.172.254) without binding to host ports.",
"telemt_port_hint": "Port for the Telegram proxy (typically 443).",