Phase 21: container terminal (web exec), local + agent (0.27.0)
Interactive shell into a compose-managed container over WebSocket + xterm.js,
opened from the container card on the stack Overview tab. Admin-only (non-admin
handshake rejected with 4403); only containers with the compose project label
are reachable.
- backend services/exec_service.py: create/start/resize exec + a shared
bidirectional pump_exec (recv/sendall on sock._sock, executor thread,
resize control frames, exit-code frame).
- routers/ws.py: _authorize_admin + /ws/exec/{container_id} and the
/ws/agent-exec/{agent_id}/{container_id} proxy (forwards BOTH directions).
- agent_app.py: /agent/ws/exec/{container_id}.
- frontend: @xterm/xterm + @xterm/addon-fit; ContainerTerminal modal (shell
picker, fit/resize, exit/error handling) + a Terminal button on ContainerCard.
Live-verified (TestClient): local happy/exit/guard/4403/4401, agent happy/4401,
proxy bidirectional round-trip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b44a5b9f86
commit
be3568274f
@@ -153,6 +153,19 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
|
|||||||
container or connect any container on the host (`POST /api/networks/{id}/connect`
|
container or connect any container on the host (`POST /api/networks/{id}/connect`
|
||||||
/ `/disconnect`).
|
/ `/disconnect`).
|
||||||
|
|
||||||
|
### Phase 21 — Container terminal (web exec)
|
||||||
|
|
||||||
|
- An **interactive terminal** into any running, compose-managed container,
|
||||||
|
opened from the terminal button on its container card (stack Overview tab).
|
||||||
|
Streams an exec session over WebSocket into xterm.js — pick `/bin/sh`,
|
||||||
|
`/bin/bash`, or `/bin/ash`; full TTY with resize.
|
||||||
|
- **Admin-only** (exec is root-equivalent): a non-admin token is rejected at the
|
||||||
|
WebSocket handshake (`4403`). Only containers with the
|
||||||
|
`com.docker.compose.project` label can be reached.
|
||||||
|
- Works for **remote stacks** too: the same terminal proxies through
|
||||||
|
`/ws/agent-exec/{agent_id}/{container_id}` to the agent's new
|
||||||
|
`/agent/ws/exec/{container_id}` (bidirectional — keystrokes in, output out).
|
||||||
|
|
||||||
### Phase 20 — Container management
|
### Phase 20 — Container management
|
||||||
|
|
||||||
- The stack **Overview** tab now renders each service as an expandable
|
- The stack **Overview** tab now renders each service as an expandable
|
||||||
|
|||||||
+9
-1
@@ -12,7 +12,15 @@ is most design-ambiguous, left last).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 21 — Container terminal (web exec) ☐ NOT STARTED → target 0.27.0
|
## Phase 21 — Container terminal (web exec) ☑ DONE — shipped 0.27.0
|
||||||
|
|
||||||
|
**Result:** All backend paths live-verified via TestClient against real
|
||||||
|
containers — local exec (echo round-trip, exit code 0, unmanaged-guard,
|
||||||
|
non-admin→4403, no-token→4401), agent exec (echo, bad-token→4401), and the
|
||||||
|
central proxy (bidirectional: keystrokes browser→proxy→agent→container, output
|
||||||
|
back). docker-py 7.1.0 socket is `socket.SocketIO` with the real fd at
|
||||||
|
`._sock` (as predicted). Frontend `tsc -b && vite build` green with xterm.
|
||||||
|
Not click-tested in a real browser (the WS/exec plumbing is what's verified).
|
||||||
|
|
||||||
Interactive shell into a running, compose-managed container over WebSocket +
|
Interactive shell into a running, compose-managed container over WebSocket +
|
||||||
xterm.js, like Portainer's Console. Local + agent. **Admin-only** (exec is
|
xterm.js, like Portainer's Console. Local + agent. **Admin-only** (exec is
|
||||||
|
|||||||
+37
-1
@@ -42,6 +42,7 @@ from services import (
|
|||||||
compose_service,
|
compose_service,
|
||||||
container_service,
|
container_service,
|
||||||
device_service,
|
device_service,
|
||||||
|
exec_service,
|
||||||
file_service,
|
file_service,
|
||||||
image_service,
|
image_service,
|
||||||
network_service,
|
network_service,
|
||||||
@@ -63,7 +64,7 @@ def _map_docker(exc: DockerError):
|
|||||||
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
|
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
|
||||||
raise exc # falls through to the global 502 DockerError handler
|
raise exc # falls through to the global 502 DockerError handler
|
||||||
|
|
||||||
AGENT_VERSION = "0.26.0"
|
AGENT_VERSION = "0.27.0"
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -693,6 +694,41 @@ async def ws_deploy(websocket: WebSocket, stack_id: str, token: str | None = Que
|
|||||||
compose_service.clear_busy(stack_id)
|
compose_service.clear_busy(stack_id)
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/agent/ws/exec/{container_id}")
|
||||||
|
async def ws_exec(
|
||||||
|
websocket: WebSocket,
|
||||||
|
container_id: str,
|
||||||
|
token: str | None = Query(default=None),
|
||||||
|
cmd: str | None = Query(default=None),
|
||||||
|
):
|
||||||
|
"""Interactive shell into a compose-managed container (token via query)."""
|
||||||
|
await websocket.accept()
|
||||||
|
expected = settings.AGENT_TOKEN
|
||||||
|
if not expected or token != expected:
|
||||||
|
await websocket.close(code=4401)
|
||||||
|
return
|
||||||
|
shell = cmd or exec_service.DEFAULT_SHELL
|
||||||
|
try:
|
||||||
|
exec_id = exec_service.create_exec(container_id, [shell])
|
||||||
|
holder, raw = exec_service.start_exec(exec_id)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
try:
|
||||||
|
await websocket.send_text(json.dumps({"type": "error", "detail": str(exc)}))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
await websocket.close()
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await exec_service.pump_exec(websocket, exec_id, holder, raw)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
await websocket.close()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@app.get("/agent/health")
|
@app.get("/agent/health")
|
||||||
def health() -> dict:
|
def health() -> dict:
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|||||||
+1
-1
@@ -56,7 +56,7 @@ async def lifespan(app: FastAPI):
|
|||||||
schedule_task.cancel()
|
schedule_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="StackPilot", version="0.26.0", lifespan=lifespan)
|
app = FastAPI(title="StackPilot", version="0.27.0", lifespan=lifespan)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|||||||
+149
-1
@@ -17,7 +17,7 @@ from auth import decode_token
|
|||||||
from database import engine
|
from database import engine
|
||||||
from models.agent import Agent
|
from models.agent import Agent
|
||||||
from models.setting import EVENT_STACK_ERROR, EVENT_STACK_START
|
from models.setting import EVENT_STACK_ERROR, EVENT_STACK_START
|
||||||
from services import audit_service, compose_service, notify_service
|
from services import audit_service, compose_service, exec_service, notify_service
|
||||||
|
|
||||||
logger = logging.getLogger("stackpilot.ws")
|
logger = logging.getLogger("stackpilot.ws")
|
||||||
|
|
||||||
@@ -37,6 +37,24 @@ async def _authorize(websocket: WebSocket, token: str | None) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _authorize_admin(websocket: WebSocket, token: str | None) -> bool:
|
||||||
|
"""Like _authorize but also requires the admin role (exec is root-equivalent).
|
||||||
|
|
||||||
|
Closes 4401 on a missing/invalid token, 4403 on a valid non-admin token."""
|
||||||
|
if not token:
|
||||||
|
await websocket.close(code=4401)
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
payload = decode_token(token, "access")
|
||||||
|
except (JWTError, Exception): # noqa: BLE001
|
||||||
|
await websocket.close(code=4401)
|
||||||
|
return False
|
||||||
|
if payload.get("role") != "admin":
|
||||||
|
await websocket.close(code=4403)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def _stream_logs(websocket: WebSocket, stack_id: str, service: str | None):
|
async def _stream_logs(websocket: WebSocket, stack_id: str, service: str | None):
|
||||||
"""Stream `docker compose logs -f` output to the client."""
|
"""Stream `docker compose logs -f` output to the client."""
|
||||||
args = ["logs", "--no-color", "--tail", "200", "--timestamps", "-f"]
|
args = ["logs", "--no-color", "--tail", "200", "--timestamps", "-f"]
|
||||||
@@ -348,3 +366,133 @@ async def ws_events(
|
|||||||
finally:
|
finally:
|
||||||
stop.set()
|
stop.set()
|
||||||
task.cancel()
|
task.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/ws/exec/{container_id}")
|
||||||
|
async def ws_exec(
|
||||||
|
websocket: WebSocket,
|
||||||
|
container_id: str,
|
||||||
|
token: str | None = Query(default=None),
|
||||||
|
cmd: str | None = Query(default=None),
|
||||||
|
):
|
||||||
|
"""Interactive shell into a compose-managed container (admin only)."""
|
||||||
|
await websocket.accept()
|
||||||
|
if not await _authorize_admin(websocket, token):
|
||||||
|
return
|
||||||
|
username = decode_token(token, "access").get("sub", "unknown") if token else "unknown"
|
||||||
|
shell = cmd or exec_service.DEFAULT_SHELL
|
||||||
|
|
||||||
|
try:
|
||||||
|
exec_id = exec_service.create_exec(container_id, [shell])
|
||||||
|
holder, raw = exec_service.start_exec(exec_id)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await websocket.send_text(json.dumps({"type": "error", "detail": str(exc)}))
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await websocket.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
with Session(engine) as session:
|
||||||
|
audit_service.record(
|
||||||
|
session, user=username, action="container.exec",
|
||||||
|
target=container_id[:12], detail=shell, ip="ws",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await exec_service.pump_exec(websocket, exec_id, holder, raw)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await websocket.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/ws/agent-exec/{agent_id}/{container_id}")
|
||||||
|
async def ws_agent_exec(
|
||||||
|
websocket: WebSocket,
|
||||||
|
agent_id: int,
|
||||||
|
container_id: str,
|
||||||
|
token: str | None = Query(default=None),
|
||||||
|
cmd: str | None = Query(default=None),
|
||||||
|
):
|
||||||
|
"""Proxy an interactive exec session to a remote agent (admin only).
|
||||||
|
|
||||||
|
Unlike the log/deploy proxies this forwards in BOTH directions so keystrokes
|
||||||
|
reach the container and its output streams back."""
|
||||||
|
await websocket.accept()
|
||||||
|
if not await _authorize_admin(websocket, token):
|
||||||
|
return
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
agent = session.get(Agent, agent_id)
|
||||||
|
if not agent:
|
||||||
|
await websocket.send_text(json.dumps({"type": "error", "detail": "agent not found"}))
|
||||||
|
await websocket.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
base = agent.url.rstrip("/")
|
||||||
|
ws_url = ("wss://" + base[8:] if base.startswith("https://")
|
||||||
|
else "ws://" + base[7:] if base.startswith("http://")
|
||||||
|
else "ws://" + base)
|
||||||
|
ws_url += f"/agent/ws/exec/{container_id}?token={urllib.parse.quote(agent.token, safe='')}"
|
||||||
|
if cmd:
|
||||||
|
ws_url += f"&cmd={urllib.parse.quote(cmd, safe='')}"
|
||||||
|
|
||||||
|
async def _err(detail: str) -> None:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await websocket.send_text(json.dumps({"type": "error", "detail": detail}))
|
||||||
|
|
||||||
|
try:
|
||||||
|
upstream = await websockets.connect(ws_url, open_timeout=10, ping_interval=20)
|
||||||
|
except websockets.InvalidStatus as exc:
|
||||||
|
code = getattr(getattr(exc, "response", None), "status_code", None)
|
||||||
|
hint = " — the agent may be running an old version without terminal support; update it." if code == 404 else ""
|
||||||
|
logger.warning("Agent exec proxy: handshake to %s failed (%s)", agent.name, code)
|
||||||
|
await _err(f"Agent '{agent.name}' rejected the terminal (HTTP {code}){hint}")
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await websocket.close()
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("Agent exec proxy: cannot reach %s at %s: %s", agent.name, agent.url, exc)
|
||||||
|
await _err(f"Could not connect to agent '{agent.name}' at {agent.url}: {exc}")
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await websocket.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
with Session(engine) as session:
|
||||||
|
username = decode_token(token, "access").get("sub", "unknown") if token else "unknown"
|
||||||
|
audit_service.record(
|
||||||
|
session, user=username, action="agent.container.exec",
|
||||||
|
target=f"{agent.name}/{container_id[:12]}", ip="ws",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def browser_to_agent() -> None:
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
msg = await websocket.receive_text()
|
||||||
|
await upstream.send(msg)
|
||||||
|
except (WebSocketDisconnect, websockets.ConnectionClosed):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def agent_to_browser() -> None:
|
||||||
|
try:
|
||||||
|
async for message in upstream:
|
||||||
|
await websocket.send_text(
|
||||||
|
message if isinstance(message, str) else message.decode("utf-8", "replace")
|
||||||
|
)
|
||||||
|
except (WebSocketDisconnect, websockets.ConnectionClosed):
|
||||||
|
pass
|
||||||
|
|
||||||
|
b2a = asyncio.create_task(browser_to_agent())
|
||||||
|
a2b = asyncio.create_task(agent_to_browser())
|
||||||
|
done, pending = await asyncio.wait({b2a, a2b}, return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await asyncio.gather(*pending, return_exceptions=True)
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await upstream.close()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await websocket.close()
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""Interactive exec (web terminal) into compose-managed containers.
|
||||||
|
|
||||||
|
Reuses ``container_service._get_managed`` so a terminal can only be opened on a
|
||||||
|
container that belongs to a compose-managed stack — never an arbitrary host
|
||||||
|
container. The raw exec socket is bidirectional (stdin + a TTY-merged
|
||||||
|
stdout/stderr stream), suitable for piping straight to xterm.js.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import socket as _socket
|
||||||
|
|
||||||
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
|
from docker_client import DockerError, get_client, safe_call
|
||||||
|
from services.container_service import _get_managed
|
||||||
|
|
||||||
|
DEFAULT_SHELL = "/bin/sh"
|
||||||
|
|
||||||
|
|
||||||
|
def create_exec(container_id: str, cmd: list[str] | None = None, tty: bool = True) -> str:
|
||||||
|
"""Create an exec instance on a managed container and return its id."""
|
||||||
|
container = _get_managed(container_id)
|
||||||
|
client = get_client()
|
||||||
|
created = safe_call(
|
||||||
|
client.api.exec_create,
|
||||||
|
container.id,
|
||||||
|
cmd or [DEFAULT_SHELL],
|
||||||
|
stdin=True,
|
||||||
|
tty=tty,
|
||||||
|
stdout=True,
|
||||||
|
stderr=True,
|
||||||
|
)
|
||||||
|
return created["Id"]
|
||||||
|
|
||||||
|
|
||||||
|
def start_exec(exec_id: str, tty: bool = True):
|
||||||
|
"""Start the exec and return ``(holder, raw_socket)``.
|
||||||
|
|
||||||
|
docker-py 7.x returns a ``socket.SocketIO`` wrapper whose real, recv/sendall
|
||||||
|
-capable fd lives at ``._sock``; older versions hand back the socket
|
||||||
|
directly. We keep both: ``holder`` is what we close, ``raw`` is what we
|
||||||
|
recv/sendall on.
|
||||||
|
"""
|
||||||
|
client = get_client()
|
||||||
|
holder = safe_call(client.api.exec_start, exec_id, socket=True, tty=tty, demux=False)
|
||||||
|
raw = getattr(holder, "_sock", None) or holder
|
||||||
|
return holder, raw
|
||||||
|
|
||||||
|
|
||||||
|
def resize_exec(exec_id: str, height: int, width: int) -> None:
|
||||||
|
"""Resize the exec's TTY (rows x cols) so the shell wraps correctly."""
|
||||||
|
client = get_client()
|
||||||
|
safe_call(client.api.exec_resize, exec_id, height=height, width=width)
|
||||||
|
|
||||||
|
|
||||||
|
def exec_exit_code(exec_id: str):
|
||||||
|
"""Return the exec's ExitCode once it has finished (None while running)."""
|
||||||
|
client = get_client()
|
||||||
|
info = safe_call(client.api.exec_inspect, exec_id)
|
||||||
|
return info.get("ExitCode")
|
||||||
|
|
||||||
|
|
||||||
|
async def pump_exec(websocket, exec_id: str, holder, raw) -> None:
|
||||||
|
"""Bidirectionally pump an exec socket <-> a WebSocket.
|
||||||
|
|
||||||
|
Shared by the central app and the agent (both pass a Starlette WebSocket).
|
||||||
|
Browser -> container: JSON ``{"type":"data","data":...}`` keystrokes and
|
||||||
|
``{"type":"resize","rows","cols"}`` control frames (raw text is also
|
||||||
|
accepted as keystrokes). Container -> browser: ``{"type":"data","data":...}``
|
||||||
|
then a final ``{"type":"exit","code":...}``.
|
||||||
|
|
||||||
|
The blocking ``recv`` runs in the default executor; on teardown we shut the
|
||||||
|
socket down so that orphaned recv thread unblocks and exits.
|
||||||
|
"""
|
||||||
|
raw.setblocking(True)
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
closed = asyncio.Event()
|
||||||
|
|
||||||
|
async def to_browser() -> None:
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await loop.run_in_executor(None, raw.recv, 4096)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
await websocket.send_text(
|
||||||
|
json.dumps({"type": "data", "data": data.decode("utf-8", "replace")})
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
closed.set()
|
||||||
|
|
||||||
|
async def from_browser() -> None:
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
msg = await websocket.receive_text()
|
||||||
|
obj = None
|
||||||
|
try:
|
||||||
|
obj = json.loads(msg)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
obj = None
|
||||||
|
if isinstance(obj, dict) and obj.get("type") == "resize":
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
resize_exec(exec_id, int(obj.get("rows", 24)), int(obj.get("cols", 80)))
|
||||||
|
elif isinstance(obj, dict) and "data" in obj:
|
||||||
|
await loop.run_in_executor(None, raw.sendall, str(obj["data"]).encode())
|
||||||
|
else:
|
||||||
|
await loop.run_in_executor(None, raw.sendall, msg.encode())
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
closed.set()
|
||||||
|
|
||||||
|
out_task = asyncio.create_task(to_browser())
|
||||||
|
in_task = asyncio.create_task(from_browser())
|
||||||
|
await closed.wait()
|
||||||
|
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
raw.shutdown(_socket.SHUT_RDWR)
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
holder.close()
|
||||||
|
for task in (out_task, in_task):
|
||||||
|
task.cancel()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await asyncio.gather(out_task, in_task, return_exceptions=True)
|
||||||
|
|
||||||
|
code = None
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
code = exec_exit_code(exec_id)
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await websocket.send_text(json.dumps({"type": "exit", "code": code}))
|
||||||
Generated
+2477
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "stackpilot-frontend",
|
"name": "stackpilot-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.26.0",
|
"version": "0.27.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -11,6 +11,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@monaco-editor/react": "^4.6.0",
|
"@monaco-editor/react": "^4.6.0",
|
||||||
"@tanstack/react-query": "^5.62.7",
|
"@tanstack/react-query": "^5.62.7",
|
||||||
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
"@xterm/xterm": "^5.5.0",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Play, Square, RotateCw, ChevronDown, ChevronRight } from "lucide-react";
|
import { Play, Square, RotateCw, ChevronDown, ChevronRight, TerminalSquare } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
|
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
|
||||||
import { ContainerPorts } from "@/components/stacks/ContainerPorts";
|
import { ContainerPorts } from "@/components/stacks/ContainerPorts";
|
||||||
|
import { ContainerTerminal } from "@/components/stacks/ContainerTerminal";
|
||||||
import { containersApi, type ContainerAction } from "@/api/containers";
|
import { containersApi, type ContainerAction } from "@/api/containers";
|
||||||
import { apiErrorMessage } from "@/api/client";
|
import { apiErrorMessage } from "@/api/client";
|
||||||
import type { ContainerInfo } from "@/types";
|
import type { ContainerInfo } from "@/types";
|
||||||
@@ -23,6 +24,7 @@ export function ContainerCard({
|
|||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [termOpen, setTermOpen] = useState(false);
|
||||||
const running = container.state === "running";
|
const running = container.state === "running";
|
||||||
|
|
||||||
const detail = useQuery({
|
const detail = useQuery({
|
||||||
@@ -79,6 +81,15 @@ export function ContainerCard({
|
|||||||
<Button variant="outline" className="px-2 py-1" onClick={() => act("restart")} disabled={busy || !running}>
|
<Button variant="outline" className="px-2 py-1" onClick={() => act("restart")} disabled={busy || !running}>
|
||||||
<RotateCw className="h-3.5 w-3.5 text-sky-500" />
|
<RotateCw className="h-3.5 w-3.5 text-sky-500" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="px-2 py-1"
|
||||||
|
onClick={() => setTermOpen(true)}
|
||||||
|
disabled={!running}
|
||||||
|
title="Open terminal"
|
||||||
|
>
|
||||||
|
<TerminalSquare className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -137,6 +148,15 @@ export function ContainerCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{termOpen && (
|
||||||
|
<ContainerTerminal
|
||||||
|
containerId={container.id}
|
||||||
|
service={container.service}
|
||||||
|
agentId={agentId}
|
||||||
|
onClose={() => setTermOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Terminal } from "@xterm/xterm";
|
||||||
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
|
import { useAuthStore } from "@/store/auth";
|
||||||
|
|
||||||
|
type Status = "connecting" | "open" | "closed" | "error";
|
||||||
|
|
||||||
|
const SHELLS = ["/bin/sh", "/bin/bash", "/bin/ash"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interactive terminal modal: opens an exec session into a compose-managed
|
||||||
|
* container over `/ws/exec/{id}` (or `/ws/agent-exec/{aid}/{id}` for a remote
|
||||||
|
* agent) and wires it to an xterm.js terminal. Admin-only on the backend; a
|
||||||
|
* 4403 close surfaces as an "admin only" error.
|
||||||
|
*/
|
||||||
|
export function ContainerTerminal({
|
||||||
|
containerId,
|
||||||
|
service,
|
||||||
|
agentId,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
containerId: string;
|
||||||
|
service: string;
|
||||||
|
agentId?: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const token = useAuthStore((s) => s.accessToken);
|
||||||
|
const [shell, setShell] = useState(SHELLS[0]);
|
||||||
|
const [status, setStatus] = useState<Status>("connecting");
|
||||||
|
const [detail, setDetail] = useState<string | null>(null);
|
||||||
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || !boxRef.current) return;
|
||||||
|
setStatus("connecting");
|
||||||
|
setDetail(null);
|
||||||
|
|
||||||
|
const term = new Terminal({
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
||||||
|
cursorBlink: true,
|
||||||
|
theme: { background: "#020617", foreground: "#e2e8f0", cursor: "#38bdf8" },
|
||||||
|
});
|
||||||
|
const fit = new FitAddon();
|
||||||
|
term.loadAddon(fit);
|
||||||
|
term.open(boxRef.current);
|
||||||
|
fit.fit();
|
||||||
|
|
||||||
|
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
||||||
|
const path =
|
||||||
|
agentId != null
|
||||||
|
? `/ws/agent-exec/${agentId}/${containerId}`
|
||||||
|
: `/ws/exec/${containerId}`;
|
||||||
|
const url =
|
||||||
|
`${proto}://${window.location.host}${path}` +
|
||||||
|
`?token=${encodeURIComponent(token)}&cmd=${encodeURIComponent(shell)}`;
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
|
||||||
|
const sendResize = () => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: "resize", rows: term.rows, cols: term.cols }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
setStatus("open");
|
||||||
|
term.focus();
|
||||||
|
sendResize();
|
||||||
|
};
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(ev.data);
|
||||||
|
if (msg.type === "data") term.write(msg.data as string);
|
||||||
|
else if (msg.type === "exit") {
|
||||||
|
term.write(`\r\n\x1b[90m[process exited${msg.code != null ? ` with code ${msg.code}` : ""}]\x1b[0m\r\n`);
|
||||||
|
setStatus("closed");
|
||||||
|
} else if (msg.type === "error") {
|
||||||
|
term.write(`\r\n\x1b[31m${msg.detail || "error"}\x1b[0m\r\n`);
|
||||||
|
setStatus("error");
|
||||||
|
setDetail(msg.detail || "error");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* non-JSON: write raw */
|
||||||
|
term.write(typeof ev.data === "string" ? ev.data : "");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onclose = (ev) => {
|
||||||
|
if (ev.code === 4403) {
|
||||||
|
setStatus("error");
|
||||||
|
setDetail("Terminal access is admin-only.");
|
||||||
|
term.write("\r\n\x1b[31mTerminal access is admin-only.\x1b[0m\r\n");
|
||||||
|
} else if (ev.code === 4401) {
|
||||||
|
setStatus("error");
|
||||||
|
setDetail("Authentication failed.");
|
||||||
|
} else {
|
||||||
|
setStatus((s) => (s === "open" ? "closed" : s));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onData = term.onData((d) => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "data", data: d }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
try {
|
||||||
|
fit.fit();
|
||||||
|
sendResize();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ro.observe(boxRef.current);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
onData.dispose();
|
||||||
|
ws.close();
|
||||||
|
term.dispose();
|
||||||
|
};
|
||||||
|
}, [containerId, agentId, shell, token]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
|
<div className="flex h-[80vh] w-full max-w-4xl flex-col rounded-xl border border-slate-200 bg-card shadow-xl dark:border-slate-700 dark:bg-card-dark">
|
||||||
|
<div className="flex items-center justify-between gap-3 border-b border-slate-200 px-5 py-3 dark:border-slate-700">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="text-lg font-semibold">Terminal — {service}</h2>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
status === "open"
|
||||||
|
? "text-xs text-green-500"
|
||||||
|
: status === "error"
|
||||||
|
? "text-xs text-red-500"
|
||||||
|
: "text-xs text-slate-500"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{status === "connecting" && "connecting…"}
|
||||||
|
{status === "open" && "connected"}
|
||||||
|
{status === "closed" && "closed"}
|
||||||
|
{status === "error" && (detail || "error")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={shell}
|
||||||
|
onChange={(e) => setShell(e.target.value)}
|
||||||
|
className="rounded-md border border-slate-300 bg-transparent px-2 py-1 text-sm dark:border-slate-600"
|
||||||
|
title="Shell — switching reconnects"
|
||||||
|
>
|
||||||
|
{SHELLS.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button variant="outline" className="px-2 py-1" onClick={onClose}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div ref={boxRef} className="min-h-0 flex-1 overflow-hidden bg-[#020617] p-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user