Show Hysteria stop errors and add live container logs viewer.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1705,6 +1705,11 @@ class ProtocolRequest(BaseModel):
|
|||||||
protocol: str = 'awg'
|
protocol: str = 'awg'
|
||||||
|
|
||||||
|
|
||||||
|
class ContainerLogsRequest(BaseModel):
|
||||||
|
protocol: str = 'hysteria'
|
||||||
|
tail: Optional[int] = 200
|
||||||
|
|
||||||
|
|
||||||
class AddConnectionRequest(BaseModel):
|
class AddConnectionRequest(BaseModel):
|
||||||
protocol: str = 'awg'
|
protocol: str = 'awg'
|
||||||
name: str = 'Connection'
|
name: str = 'Connection'
|
||||||
@@ -3140,16 +3145,170 @@ async def api_container_toggle(request: Request, server_id: int, req: ProtocolRe
|
|||||||
if is_running:
|
if is_running:
|
||||||
ssh.run_sudo_command(f"docker stop {container}")
|
ssh.run_sudo_command(f"docker stop {container}")
|
||||||
action = 'stopped'
|
action = 'stopped'
|
||||||
|
else:
|
||||||
|
# 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:
|
else:
|
||||||
ssh.run_sudo_command(f"docker start {container}")
|
ssh.run_sudo_command(f"docker start {container}")
|
||||||
action = 'started'
|
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()
|
ssh.disconnect()
|
||||||
return {'status': 'success', 'action': action, 'container': container}
|
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,
|
||||||
|
'error': error,
|
||||||
|
'recent_logs': recent_logs,
|
||||||
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error toggling container")
|
logger.exception("Error toggling container")
|
||||||
return JSONResponse({'error': str(e)}, status_code=500)
|
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"])
|
@app.post('/api/servers/{server_id}/server_config', tags=["Protocols"])
|
||||||
async def api_server_config(request: Request, server_id: int, req: ProtocolRequest):
|
async def api_server_config(request: Request, server_id: int, req: ProtocolRequest):
|
||||||
"""Get the raw server-side WireGuard/Xray configuration."""
|
"""Get the raw server-side WireGuard/Xray configuration."""
|
||||||
|
|||||||
@@ -91,12 +91,95 @@ class HysteriaManager:
|
|||||||
)
|
)
|
||||||
return 'Up' in out
|
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):
|
def get_server_status(self, protocol_type=None):
|
||||||
protocol_type = protocol_type or self.protocol
|
protocol_type = protocol_type or self.protocol
|
||||||
exists = self.check_protocol_installed(protocol_type)
|
exists = self.check_protocol_installed(protocol_type)
|
||||||
running = self.check_container_running(protocol_type)
|
running = self.check_container_running(protocol_type)
|
||||||
meta = self._read_metadata() if exists else {}
|
meta = self._read_metadata() if exists else {}
|
||||||
clients = self._read_clients() if exists else []
|
clients = self._read_clients() if exists else []
|
||||||
|
diag = {}
|
||||||
|
|
||||||
# Auto-migrate older installs (bridge NAT / userpass / missing BBR+obfs)
|
# Auto-migrate older installs (bridge NAT / userpass / missing BBR+obfs)
|
||||||
if exists:
|
if exists:
|
||||||
@@ -120,6 +203,15 @@ class HysteriaManager:
|
|||||||
meta = self._read_metadata()
|
meta = self._read_metadata()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning('Hysteria migrate failed: %s', 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 {
|
return {
|
||||||
'container_exists': exists,
|
'container_exists': exists,
|
||||||
@@ -132,6 +224,10 @@ class HysteriaManager:
|
|||||||
'base_protocol': self.PROTOCOL,
|
'base_protocol': self.PROTOCOL,
|
||||||
'instance': self._instance_index(protocol_type),
|
'instance': self._instance_index(protocol_type),
|
||||||
'container_name': self._container_name(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 =====================
|
# ===================== HELPERS =====================
|
||||||
|
|||||||
+173
-2
@@ -953,6 +953,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
@@ -1552,17 +1575,21 @@
|
|||||||
// unbound's static config), so we don't expose a raw editor for them.
|
// unbound's static config), so we don't expose a raw editor for them.
|
||||||
if (ctrlEl) {
|
if (ctrlEl) {
|
||||||
ctrlEl.style.cssText = '';
|
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') {
|
if (protoBase(proto) === 'nginx') {
|
||||||
ctrlEl.innerHTML = `
|
ctrlEl.innerHTML = `
|
||||||
|
${logsBtn}
|
||||||
<button class="btn btn-secondary btn-sm" onclick="showServerConfig('${proto}')" title="${_('server_config')}">${_('config_btn')}</button>
|
<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>
|
<button class="btn btn-danger btn-sm" onclick="toggleContainer('${proto}', true)" title="${_('stop_container_confirm').replace('{}', '')}">${_('stop_btn')}</button>
|
||||||
`;
|
`;
|
||||||
} else if (isService) {
|
} else if (isService) {
|
||||||
ctrlEl.innerHTML = `
|
ctrlEl.innerHTML = `
|
||||||
|
${logsBtn}
|
||||||
<button class="btn btn-danger btn-sm" onclick="toggleContainer('${proto}', true)" title="${_('stop_container_confirm').replace('{}', '')}">${_('stop_btn')}</button>
|
<button class="btn btn-danger btn-sm" onclick="toggleContainer('${proto}', true)" title="${_('stop_container_confirm').replace('{}', '')}">${_('stop_btn')}</button>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
ctrlEl.innerHTML = `
|
ctrlEl.innerHTML = `
|
||||||
|
${logsBtn}
|
||||||
<button class="btn btn-secondary btn-sm" onclick="showServerConfig('${proto}')" title="${_('server_config')}">${_('config_btn')}</button>
|
<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>
|
<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) {
|
} else if (info.container_exists) {
|
||||||
installedProtocols[proto] = true;
|
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) {
|
if (isService) {
|
||||||
actionsEl.innerHTML = `
|
actionsEl.innerHTML = `
|
||||||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('${proto}')" style="flex:1">${_('reinstall')}</button>
|
<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-secondary btn-sm" onclick="showProtocolBackups('${proto}')">${_('backup')}</button>
|
||||||
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
|
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
actionsEl.innerHTML = `
|
actionsEl.innerHTML = `
|
||||||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('${proto}')" style="flex:1">${_('reinstall')}</button>
|
<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-secondary btn-sm" onclick="showProtocolBackups('${proto}')">${_('backup')}</button>
|
||||||
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
|
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
if (ctrlEl) {
|
if (ctrlEl) {
|
||||||
ctrlEl.style.cssText = '';
|
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') {
|
if (protoBase(proto) === 'nginx') {
|
||||||
ctrlEl.innerHTML = `
|
ctrlEl.innerHTML = `
|
||||||
|
${logsBtn}
|
||||||
<button class="btn btn-secondary btn-sm" onclick="showServerConfig('${proto}')" title="${_('server_config')}">${_('config_btn')}</button>
|
<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>
|
<button class="btn btn-primary btn-sm" onclick="toggleContainer('${proto}', false)" title="${_('start_container_confirm').replace('{}', '')}">${_('start_btn')}</button>
|
||||||
`;
|
`;
|
||||||
} else if (isService) {
|
} else if (isService) {
|
||||||
ctrlEl.innerHTML = `
|
ctrlEl.innerHTML = `
|
||||||
|
${logsBtn}
|
||||||
<button class="btn btn-primary btn-sm" onclick="toggleContainer('${proto}', false)" title="${_('start_container_confirm').replace('{}', '')}">${_('start_btn')}</button>
|
<button class="btn btn-primary btn-sm" onclick="toggleContainer('${proto}', false)" title="${_('start_container_confirm').replace('{}', '')}">${_('start_btn')}</button>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
ctrlEl.innerHTML = `
|
ctrlEl.innerHTML = `
|
||||||
|
${logsBtn}
|
||||||
<button class="btn btn-secondary btn-sm" onclick="showServerConfig('${proto}')" title="${_('server_config')}">${_('config_btn')}</button>
|
<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>
|
<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');
|
showToast(`${action} ${name}...`, 'info');
|
||||||
const res = await apiCall(`/api/servers/${SERVER_ID}/container/toggle`, 'POST', { protocol: proto });
|
const res = await apiCall(`/api/servers/${SERVER_ID}/container/toggle`, 'POST', { protocol: proto });
|
||||||
showToast(`${name} ${res.action === 'stopped' ? _('stopped') : _('started')}`, 'success');
|
showToast(`${name} ${res.action === 'stopped' ? _('stopped') : _('started')}`, 'success');
|
||||||
// Recheck after a short delay
|
|
||||||
setTimeout(() => checkServer(), 1500);
|
setTimeout(() => checkServer(), 1500);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(_('error') + ': ' + err.message, 'error');
|
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
|
// View server-side WireGuard/Xray config
|
||||||
async function showServerConfig(proto) {
|
async function showServerConfig(proto) {
|
||||||
const name = getProtoTitle(proto);
|
const name = getProtoTitle(proto);
|
||||||
|
|||||||
@@ -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_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_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:",
|
"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_emulation": "TLS Emulation",
|
||||||
"tls_domain": "Masking Domain",
|
"tls_domain": "Masking Domain",
|
||||||
"max_connections_limit": "Max Connections",
|
"max_connections_limit": "Max Connections",
|
||||||
|
|||||||
@@ -367,6 +367,13 @@
|
|||||||
"hysteria_port_hint": "پورت UDP برای Hysteria (پیشفرض 443). پورت 80/TCP باید برای صدور گواهی آزاد باشد.",
|
"hysteria_port_hint": "پورت UDP برای Hysteria (پیشفرض 443). پورت 80/TCP باید برای صدور گواهی آزاد باشد.",
|
||||||
"hysteria_install_hint": "قبل از نصب، رکورد A دامنه باید به این سرور اشاره کند. BBR و obfuscation salamander بهصورت خودکار فعال میشوند.",
|
"hysteria_install_hint": "قبل از نصب، رکورد A دامنه باید به این سرور اشاره کند. BBR و obfuscation salamander بهصورت خودکار فعال میشوند.",
|
||||||
"hysteria_dns_hint": "قبل از نصب این رکورد DNS را بسازید:",
|
"hysteria_dns_hint": "قبل از نصب این رکورد DNS را بسازید:",
|
||||||
|
"container_logs": "لاگهای کانتینر",
|
||||||
|
"logs_btn": "📋 Logs",
|
||||||
|
"logs_live": "زنده",
|
||||||
|
"logs_reconnect": "در حال اتصال مجدد…",
|
||||||
|
"no_logs": "هنوز لاگی نیست",
|
||||||
|
"container_status": "وضعیت",
|
||||||
|
"exit_code": "کد خروج",
|
||||||
"dns_desc": "سرور DNS شخصی برای مسدود کردن تبلیغات و حفظ حریم خصوصی.",
|
"dns_desc": "سرور DNS شخصی برای مسدود کردن تبلیغات و حفظ حریم خصوصی.",
|
||||||
"dns_internal_hint": "The service runs on the internal network (172.29.172.254) without binding to host ports.",
|
"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).",
|
"telemt_port_hint": "Port for the Telegram proxy (typically 443).",
|
||||||
|
|||||||
@@ -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_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_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 :",
|
"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_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.",
|
"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).",
|
"telemt_port_hint": "Port for the Telegram proxy (typically 443).",
|
||||||
|
|||||||
@@ -362,6 +362,14 @@
|
|||||||
"hysteria_port_hint": "UDP-порт Hysteria (по умолчанию 443 — лучше проходит DPI). Порт 80/TCP должен быть свободен для выпуска сертификата.",
|
"hysteria_port_hint": "UDP-порт Hysteria (по умолчанию 443 — лучше проходит DPI). Порт 80/TCP должен быть свободен для выпуска сертификата.",
|
||||||
"hysteria_install_hint": "Перед установкой A-запись домена должна указывать на этот сервер. Порт 80/TCP временно используется для HTTP-01 проверки Let's Encrypt. BBR и obfuscation salamander включаются автоматически.",
|
"hysteria_install_hint": "Перед установкой A-запись домена должна указывать на этот сервер. Порт 80/TCP временно используется для HTTP-01 проверки Let's Encrypt. BBR и obfuscation salamander включаются автоматически.",
|
||||||
"hysteria_dns_hint": "Перед установкой создайте DNS-запись:",
|
"hysteria_dns_hint": "Перед установкой создайте DNS-запись:",
|
||||||
|
"container_logs": "Логи контейнера",
|
||||||
|
"logs_btn": "📋 Логи",
|
||||||
|
"logs_live": "В реальном времени",
|
||||||
|
"logs_reconnect": "переподключение…",
|
||||||
|
"no_logs": "Логов пока нет",
|
||||||
|
"container_status": "Статус",
|
||||||
|
"exit_code": "Код выхода",
|
||||||
|
"close": "Закрыть",
|
||||||
"tls_emulation": "TLS-эмуляция",
|
"tls_emulation": "TLS-эмуляция",
|
||||||
"tls_domain": "Домен маскировки",
|
"tls_domain": "Домен маскировки",
|
||||||
"max_connections_limit": "Макс. соединений",
|
"max_connections_limit": "Макс. соединений",
|
||||||
|
|||||||
@@ -367,6 +367,13 @@
|
|||||||
"hysteria_port_hint": "Hysteria UDP 端口(默认 443)。签发证书时需要 80/TCP 空闲。",
|
"hysteria_port_hint": "Hysteria UDP 端口(默认 443)。签发证书时需要 80/TCP 空闲。",
|
||||||
"hysteria_install_hint": "安装前请将域名 A 记录指向本服务器。将自动启用 BBR 与 salamander 混淆。",
|
"hysteria_install_hint": "安装前请将域名 A 记录指向本服务器。将自动启用 BBR 与 salamander 混淆。",
|
||||||
"hysteria_dns_hint": "安装前请创建此 DNS 记录:",
|
"hysteria_dns_hint": "安装前请创建此 DNS 记录:",
|
||||||
|
"container_logs": "容器日志",
|
||||||
|
"logs_btn": "📋 日志",
|
||||||
|
"logs_live": "实时",
|
||||||
|
"logs_reconnect": "重新连接…",
|
||||||
|
"no_logs": "暂无日志",
|
||||||
|
"container_status": "状态",
|
||||||
|
"exit_code": "退出码",
|
||||||
"dns_desc": "防止广告和保护隐私的个人 DNS 服务器。",
|
"dns_desc": "防止广告和保护隐私的个人 DNS 服务器。",
|
||||||
"dns_internal_hint": "The service runs on the internal network (172.29.172.254) without binding to host ports.",
|
"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).",
|
"telemt_port_hint": "Port for the Telegram proxy (typically 443).",
|
||||||
|
|||||||
Reference in New Issue
Block a user