Add NAS dashboard MVP: Docker mgmt, Gitea, Files, Terminal
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import httpx
|
||||
from fastapi import APIRouter
|
||||
from config import DOCKER_PROXY_URL
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/containers")
|
||||
async def list_containers():
|
||||
async with httpx.AsyncClient() as c:
|
||||
r = await c.get(f"{DOCKER_PROXY_URL}/containers/json", params={"all": "true"})
|
||||
return [{"id": x["Id"][:12], "name": x["Names"][0].lstrip("/"), "state": x["State"],
|
||||
"status": x["Status"], "image": x["Image"]} for x in r.json()]
|
||||
|
||||
@router.post("/containers/{container_id}/{action}")
|
||||
async def container_action(container_id: str, action: str):
|
||||
if action not in ("start", "stop", "restart"):
|
||||
return {"error": "invalid action"}
|
||||
async with httpx.AsyncClient() as c:
|
||||
r = await c.post(f"{DOCKER_PROXY_URL}/containers/{container_id}/{action}")
|
||||
return {"ok": r.status_code < 400}
|
||||
|
||||
@router.get("/containers/{container_id}/logs")
|
||||
async def container_logs(container_id: str, tail: int = 200):
|
||||
async with httpx.AsyncClient() as c:
|
||||
r = await c.get(f"{DOCKER_PROXY_URL}/containers/{container_id}/logs",
|
||||
params={"stdout": "true", "stderr": "true", "tail": str(tail), "timestamps": "true"})
|
||||
# Strip docker stream header bytes (8 bytes per line)
|
||||
lines = []
|
||||
raw = r.content
|
||||
i = 0
|
||||
while i < len(raw):
|
||||
if i + 8 <= len(raw):
|
||||
size = int.from_bytes(raw[i+4:i+8], "big")
|
||||
lines.append(raw[i+8:i+8+size].decode("utf-8", errors="replace"))
|
||||
i += 8 + size
|
||||
else:
|
||||
break
|
||||
return {"logs": "".join(lines)}
|
||||
@@ -0,0 +1,35 @@
|
||||
import docker
|
||||
from fastapi import APIRouter
|
||||
from config import DOCKER_HOST
|
||||
|
||||
router = APIRouter()
|
||||
client = docker.DockerClient(base_url=DOCKER_HOST)
|
||||
|
||||
|
||||
@router.get("/containers")
|
||||
def list_containers():
|
||||
return [
|
||||
{
|
||||
"id": c.short_id,
|
||||
"name": c.name,
|
||||
"status": c.status,
|
||||
"image": c.image.tags[0] if c.image.tags else str(c.image.id)[:20],
|
||||
"ports": c.ports,
|
||||
}
|
||||
for c in client.containers.list(all=True)
|
||||
]
|
||||
|
||||
|
||||
@router.post("/containers/{container_id}/{action}")
|
||||
def container_action(container_id: str, action: str):
|
||||
if action not in ("start", "stop", "restart"):
|
||||
return {"error": "invalid action"}
|
||||
c = client.containers.get(container_id)
|
||||
getattr(c, action)()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/containers/{container_id}/logs")
|
||||
def container_logs(container_id: str, tail: int = 200):
|
||||
c = client.containers.get(container_id)
|
||||
return {"logs": c.logs(tail=tail, timestamps=True).decode(errors="replace")}
|
||||
@@ -0,0 +1,52 @@
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, UploadFile, File
|
||||
from fastapi.responses import FileResponse
|
||||
from config import VOLUME_ROOT
|
||||
|
||||
router = APIRouter()
|
||||
BASE = Path(VOLUME_ROOT)
|
||||
|
||||
|
||||
def _safe_path(path: str) -> Path:
|
||||
target = (BASE / path).resolve()
|
||||
if not str(target).startswith(str(BASE.resolve())):
|
||||
raise ValueError("forbidden")
|
||||
return target
|
||||
|
||||
|
||||
@router.get("/browse")
|
||||
def browse(path: str = ""):
|
||||
target = _safe_path(path)
|
||||
entries = []
|
||||
for item in sorted(target.iterdir()):
|
||||
try:
|
||||
st = item.stat()
|
||||
entries.append({"name": item.name, "is_dir": item.is_dir(), "size": st.st_size if item.is_file() else 0})
|
||||
except PermissionError:
|
||||
continue
|
||||
return {"path": str(target.relative_to(BASE)), "entries": entries}
|
||||
|
||||
|
||||
@router.get("/download")
|
||||
def download(path: str):
|
||||
return FileResponse(_safe_path(path))
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload(path: str, file: UploadFile = File(...)):
|
||||
target = _safe_path(path) / file.filename
|
||||
with open(target, "wb") as f:
|
||||
shutil.copyfileobj(file.file, f)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/delete")
|
||||
def delete(path: str):
|
||||
target = _safe_path(path)
|
||||
if target.is_dir():
|
||||
shutil.rmtree(target)
|
||||
else:
|
||||
target.unlink()
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,20 @@
|
||||
import httpx
|
||||
from fastapi import APIRouter
|
||||
from config import GITEA_URL, GITEA_TOKEN
|
||||
|
||||
router = APIRouter()
|
||||
HEADERS = {"Authorization": f"token {GITEA_TOKEN}"} if GITEA_TOKEN else {}
|
||||
|
||||
|
||||
@router.get("/repos")
|
||||
async def list_repos():
|
||||
async with httpx.AsyncClient() as c:
|
||||
r = await c.get(f"{GITEA_URL}/api/v1/repos/search", headers=HEADERS, params={"limit": 50})
|
||||
return r.json()
|
||||
|
||||
|
||||
@router.get("/repos/{owner}/{repo}/commits")
|
||||
async def list_commits(owner: str, repo: str, page: int = 1):
|
||||
async with httpx.AsyncClient() as c:
|
||||
r = await c.get(f"{GITEA_URL}/api/v1/repos/{owner}/{repo}/commits", headers=HEADERS, params={"page": page})
|
||||
return r.json()
|
||||
@@ -0,0 +1,73 @@
|
||||
import asyncio
|
||||
import os
|
||||
import pty
|
||||
import signal
|
||||
import struct
|
||||
import fcntl
|
||||
import termios
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
|
||||
|
||||
async def ws_endpoint(ws: WebSocket):
|
||||
# Block non-Tailscale IPs
|
||||
client_ip = ws.client.host if ws.client else ""
|
||||
if not client_ip.startswith("100."):
|
||||
await ws.close(code=1008, reason="Tailscale only")
|
||||
return
|
||||
|
||||
await ws.accept()
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
pid = os.fork()
|
||||
|
||||
if pid == 0:
|
||||
os.close(master_fd)
|
||||
os.setsid()
|
||||
os.dup2(slave_fd, 0)
|
||||
os.dup2(slave_fd, 1)
|
||||
os.dup2(slave_fd, 2)
|
||||
os.close(slave_fd)
|
||||
os.execvp("/bin/bash", ["/bin/bash"])
|
||||
|
||||
os.close(slave_fd)
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
async def read_pty():
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(0.01)
|
||||
if select_readable(master_fd):
|
||||
data = os.read(master_fd, 4096)
|
||||
if not data:
|
||||
break
|
||||
await ws.send_bytes(data)
|
||||
except (OSError, WebSocketDisconnect):
|
||||
pass
|
||||
|
||||
def select_readable(fd):
|
||||
import select
|
||||
r, _, _ = select.select([fd], [], [], 0)
|
||||
return bool(r)
|
||||
|
||||
read_task = asyncio.create_task(read_pty())
|
||||
|
||||
try:
|
||||
while True:
|
||||
msg = await ws.receive()
|
||||
if "bytes" in msg and msg["bytes"]:
|
||||
data = msg["bytes"]
|
||||
# Handle resize: first byte 0x01 means resize message
|
||||
if data[0:1] == b"\x01" and len(data) == 5:
|
||||
cols = struct.unpack("!H", data[1:3])[0]
|
||||
rows = struct.unpack("!H", data[3:5])[0]
|
||||
fcntl.ioctl(master_fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
|
||||
else:
|
||||
os.write(master_fd, data)
|
||||
elif "text" in msg and msg["text"]:
|
||||
os.write(master_fd, msg["text"].encode())
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
read_task.cancel()
|
||||
os.close(master_fd)
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
os.waitpid(pid, 0)
|
||||
Reference in New Issue
Block a user