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'
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
Reference in New Issue
Block a user