Add NAS dashboard MVP: Docker mgmt, Gitea, Files, Terminal
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.tar.gz
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.tar.gz
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
GITEA_TOKEN=your_gitea_api_token_here
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Stage 1: Build frontend
|
||||||
|
FROM node:20-alpine AS frontend
|
||||||
|
WORKDIR /build
|
||||||
|
COPY frontend/package.json frontend/package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Python runtime
|
||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY backend/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY backend/ ./
|
||||||
|
COPY --from=frontend /build/dist /app/static
|
||||||
|
EXPOSE 4000
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "4000"]
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
GITEA_URL = os.environ.get("GITEA_URL", "http://gitea:3000")
|
||||||
|
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
|
||||||
|
DOCKER_HOST = os.environ.get("DOCKER_HOST", "tcp://docker-socket-proxy:2375")
|
||||||
|
VOLUME_ROOT = os.environ.get("VOLUME_ROOT", "/volume1")
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from routers import docker_router, gitea, files, terminal
|
||||||
|
|
||||||
|
app = FastAPI(title="NAS Dashboard")
|
||||||
|
app.include_router(docker_router.router, prefix="/api/docker")
|
||||||
|
app.include_router(gitea.router, prefix="/api/gitea")
|
||||||
|
app.include_router(files.router, prefix="/api/files")
|
||||||
|
app.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint)
|
||||||
|
app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn==0.30.6
|
||||||
|
docker==7.1.0
|
||||||
|
httpx==0.27.2
|
||||||
|
python-multipart==0.0.9
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
services:
|
||||||
|
docker-socket-proxy:
|
||||||
|
image: tecnativa/docker-socket-proxy
|
||||||
|
container_name: docker-socket-proxy
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
CONTAINERS: 1
|
||||||
|
IMAGES: 1
|
||||||
|
INFO: 1
|
||||||
|
POST: 1
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
dashboard:
|
||||||
|
build: .
|
||||||
|
image: nas-dashboard:latest
|
||||||
|
container_name: nas-dashboard
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "4000:4000"
|
||||||
|
environment:
|
||||||
|
- DOCKER_HOST=tcp://docker-socket-proxy:2375
|
||||||
|
- GITEA_URL=http://gitea:3000
|
||||||
|
- GITEA_TOKEN=${GITEA_TOKEN}
|
||||||
|
- VOLUME_ROOT=/volume1
|
||||||
|
volumes:
|
||||||
|
- /volume1:/volume1
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
- gitea_gitea
|
||||||
|
depends_on:
|
||||||
|
- docker-socket-proxy
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
internal:
|
||||||
|
gitea_gitea:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>NAS Dashboard</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1998
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "nas-dashboard",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"vite": "^6.3.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"@tailwindcss/vite": "^4.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
"@xterm/addon-fit": "^0.10.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script>
|
||||||
|
import Sidebar from "./components/Sidebar.svelte";
|
||||||
|
import Dashboard from "./routes/Dashboard.svelte";
|
||||||
|
import Docker from "./routes/Docker.svelte";
|
||||||
|
import Gitea from "./routes/Gitea.svelte";
|
||||||
|
import Files from "./routes/Files.svelte";
|
||||||
|
import Terminal from "./routes/Terminal.svelte";
|
||||||
|
|
||||||
|
let page = $state("dashboard");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-screen bg-white text-gray-900">
|
||||||
|
<Sidebar bind:page />
|
||||||
|
<main class="flex-1 overflow-auto p-6">
|
||||||
|
{#if page === "dashboard"}
|
||||||
|
<Dashboard />
|
||||||
|
{:else if page === "docker"}
|
||||||
|
<Docker />
|
||||||
|
{:else if page === "gitea"}
|
||||||
|
<Gitea />
|
||||||
|
{:else if page === "files"}
|
||||||
|
<Files />
|
||||||
|
{:else if page === "terminal"}
|
||||||
|
<Terminal />
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script>
|
||||||
|
let { page = $bindable("dashboard") } = $props();
|
||||||
|
const links = [
|
||||||
|
{ id: "dashboard", label: "Dashboard", icon: "■" },
|
||||||
|
{ id: "docker", label: "Docker", icon: "▶" },
|
||||||
|
{ id: "gitea", label: "Gitea", icon: "📁" },
|
||||||
|
{ id: "files", label: "Files", icon: "📄" },
|
||||||
|
{ id: "terminal", label: "Terminal", icon: ">" },
|
||||||
|
];
|
||||||
|
const external = [
|
||||||
|
{ label: "Navidrome", href: "//:4533", port: 4533 },
|
||||||
|
{ label: "Immich", href: "//:2283", port: 2283 },
|
||||||
|
{ label: "Emby", href: "//:8096", port: 8096 },
|
||||||
|
{ label: "Gitea Web", href: "//:3300", port: 3300 },
|
||||||
|
];
|
||||||
|
function extHref(port) {
|
||||||
|
return `${location.protocol}//${location.hostname}:${port}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside class="w-56 bg-gray-50 border-r border-gray-200 h-screen flex flex-col p-4 shrink-0">
|
||||||
|
<h1 class="text-lg font-bold mb-6">NAS Dashboard</h1>
|
||||||
|
<nav class="flex flex-col gap-1">
|
||||||
|
{#each links as link}
|
||||||
|
<button
|
||||||
|
class="text-left px-3 py-2 rounded text-sm {page === link.id ? 'bg-blue-100 text-blue-700 font-medium' : 'hover:bg-gray-100 text-gray-700'}"
|
||||||
|
onclick={() => page = link.id}
|
||||||
|
>
|
||||||
|
<span class="mr-2">{@html link.icon}</span>{link.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
<hr class="my-4 border-gray-200" />
|
||||||
|
<p class="text-xs text-gray-400 mb-2 px-3">External Services</p>
|
||||||
|
<nav class="flex flex-col gap-1">
|
||||||
|
{#each external as svc}
|
||||||
|
<a href={extHref(svc.port)} target="_blank" class="text-left px-3 py-2 rounded text-sm hover:bg-gray-100 text-gray-700">
|
||||||
|
{svc.label} ↗
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
const BASE = "/api";
|
||||||
|
export async function get(path) {
|
||||||
|
const r = await fetch(BASE + path);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
export async function post(path) {
|
||||||
|
const r = await fetch(BASE + path, { method: "POST" });
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
export async function del(path) {
|
||||||
|
const r = await fetch(BASE + path, { method: "DELETE" });
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import "./app.css";
|
||||||
|
import App from "./App.svelte";
|
||||||
|
import { mount } from "svelte";
|
||||||
|
|
||||||
|
const app = mount(App, { target: document.getElementById("app") });
|
||||||
|
export default app;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script>
|
||||||
|
import { get } from "../lib/api.js";
|
||||||
|
let containers = $state([]);
|
||||||
|
let repos = $state([]);
|
||||||
|
async function load() {
|
||||||
|
containers = await get("/docker/containers").catch(() => []);
|
||||||
|
const data = await get("/gitea/repos").catch(() => ({ data: [] }));
|
||||||
|
repos = data?.data || [];
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h2 class="text-2xl font-bold mb-6">Dashboard</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div class="border rounded-lg p-4">
|
||||||
|
<h3 class="font-semibold mb-3">Docker Containers</h3>
|
||||||
|
<p class="text-3xl font-bold">{containers.length}</p>
|
||||||
|
<p class="text-sm text-gray-500">{containers.filter(c => c.status === "running").length} running</p>
|
||||||
|
</div>
|
||||||
|
<div class="border rounded-lg p-4">
|
||||||
|
<h3 class="font-semibold mb-3">Gitea Repos</h3>
|
||||||
|
<p class="text-3xl font-bold">{repos.length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script>
|
||||||
|
import { get, post } from "../lib/api.js";
|
||||||
|
let containers = $state([]);
|
||||||
|
let logs = $state("");
|
||||||
|
let logTarget = $state("");
|
||||||
|
async function load() { containers = await get("/docker/containers"); }
|
||||||
|
async function action(id, act) { await post(`/docker/containers/${id}/${act}`); await load(); }
|
||||||
|
async function showLogs(id, name) { logTarget = name; const r = await get(`/docker/containers/${id}/logs?tail=100`); logs = r.logs; }
|
||||||
|
load();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h2 class="text-2xl font-bold mb-6">Docker Containers</h2>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each containers as c}
|
||||||
|
<div class="border rounded-lg p-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span class="font-medium">{c.name}</span>
|
||||||
|
<span class="ml-2 text-xs px-2 py-0.5 rounded {c.status === 'running' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}">{c.status}</span>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">{c.image}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{#if c.status === "running"}
|
||||||
|
<button class="text-xs px-3 py-1 bg-red-50 text-red-600 rounded hover:bg-red-100" onclick={() => action(c.id, "stop")}>Stop</button>
|
||||||
|
<button class="text-xs px-3 py-1 bg-yellow-50 text-yellow-600 rounded hover:bg-yellow-100" onclick={() => action(c.id, "restart")}>Restart</button>
|
||||||
|
{:else}
|
||||||
|
<button class="text-xs px-3 py-1 bg-green-50 text-green-600 rounded hover:bg-green-100" onclick={() => action(c.id, "start")}>Start</button>
|
||||||
|
{/if}
|
||||||
|
<button class="text-xs px-3 py-1 bg-gray-50 text-gray-600 rounded hover:bg-gray-100" onclick={() => showLogs(c.id, c.name)}>Logs</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if logTarget}
|
||||||
|
<div class="mt-6">
|
||||||
|
<div class="flex justify-between items-center mb-2">
|
||||||
|
<h3 class="font-semibold">Logs: {logTarget}</h3>
|
||||||
|
<button class="text-xs text-gray-400 hover:text-gray-600" onclick={() => { logTarget = ""; logs = ""; }}>Close</button>
|
||||||
|
</div>
|
||||||
|
<pre class="bg-gray-900 text-green-400 text-xs p-4 rounded-lg overflow-auto max-h-96 whitespace-pre-wrap">{logs}</pre>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script>
|
||||||
|
import { get, del } from "../lib/api.js";
|
||||||
|
let entries = $state([]);
|
||||||
|
let currentPath = $state("");
|
||||||
|
async function browse(path) {
|
||||||
|
currentPath = path;
|
||||||
|
const data = await get(`/files/browse?path=${encodeURIComponent(path)}`);
|
||||||
|
entries = data.entries || [];
|
||||||
|
}
|
||||||
|
function goUp() {
|
||||||
|
const parts = currentPath.split("/").filter(Boolean);
|
||||||
|
parts.pop();
|
||||||
|
browse(parts.join("/"));
|
||||||
|
}
|
||||||
|
async function remove(name) {
|
||||||
|
if (!confirm(`Delete ${name}?`)) return;
|
||||||
|
const path = currentPath ? `${currentPath}/${name}` : name;
|
||||||
|
await del(`/files/delete?path=${encodeURIComponent(path)}`);
|
||||||
|
browse(currentPath);
|
||||||
|
}
|
||||||
|
function downloadUrl(name) {
|
||||||
|
const path = currentPath ? `${currentPath}/${name}` : name;
|
||||||
|
return `/api/files/download?path=${encodeURIComponent(path)}`;
|
||||||
|
}
|
||||||
|
function formatSize(bytes) {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||||
|
return `${(bytes / 1073741824).toFixed(1)} GB`;
|
||||||
|
}
|
||||||
|
browse("");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h2 class="text-2xl font-bold mb-4">Files</h2>
|
||||||
|
<div class="flex items-center gap-2 mb-4 text-sm text-gray-500">
|
||||||
|
<button class="hover:text-blue-600" onclick={() => browse("")}>/volume1</button>
|
||||||
|
{#each currentPath.split("/").filter(Boolean) as part, i}
|
||||||
|
<span>/</span>
|
||||||
|
<button class="hover:text-blue-600" onclick={() => browse(currentPath.split("/").slice(0, i + 1).join("/"))}>{part}</button>
|
||||||
|
{/each}
|
||||||
|
{#if currentPath}
|
||||||
|
<button class="ml-4 text-xs px-2 py-1 bg-gray-100 rounded hover:bg-gray-200" onclick={goUp}>Up</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border rounded-lg divide-y">
|
||||||
|
{#each entries as e}
|
||||||
|
<div class="flex items-center justify-between px-4 py-2 hover:bg-gray-50">
|
||||||
|
{#if e.is_dir}
|
||||||
|
<button class="text-blue-600 hover:underline text-sm" onclick={() => browse(currentPath ? `${currentPath}/${e.name}` : e.name)}>{e.name}/</button>
|
||||||
|
{:else}
|
||||||
|
<span class="text-sm">{e.name}</span>
|
||||||
|
{/if}
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-xs text-gray-400">{e.is_dir ? "" : formatSize(e.size)}</span>
|
||||||
|
{#if !e.is_dir}
|
||||||
|
<a href={downloadUrl(e.name)} class="text-xs text-blue-500 hover:underline">Download</a>
|
||||||
|
{/if}
|
||||||
|
<button class="text-xs text-red-400 hover:text-red-600" onclick={() => remove(e.name)}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script>
|
||||||
|
import { get } from "../lib/api.js";
|
||||||
|
let repos = $state([]);
|
||||||
|
let commits = $state([]);
|
||||||
|
let selectedRepo = $state("");
|
||||||
|
async function load() { const data = await get("/gitea/repos"); repos = data?.data || []; }
|
||||||
|
async function showCommits(owner, repo) {
|
||||||
|
selectedRepo = `${owner}/${repo}`;
|
||||||
|
const data = await get(`/gitea/repos/${owner}/${repo}/commits`);
|
||||||
|
commits = Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h2 class="text-2xl font-bold mb-6">Gitea Repos</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{#each repos as r}
|
||||||
|
<button class="border rounded-lg p-4 text-left hover:bg-gray-50" onclick={() => showCommits(r.owner?.login, r.name)}>
|
||||||
|
<span class="font-medium">{r.full_name}</span>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">{r.description || "No description"}</p>
|
||||||
|
<p class="text-xs text-gray-400">Updated {new Date(r.updated_at).toLocaleDateString()}</p>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if selectedRepo}
|
||||||
|
<div class="mt-6">
|
||||||
|
<h3 class="font-semibold mb-3">Commits: {selectedRepo}</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each commits.slice(0, 20) as c}
|
||||||
|
<div class="border-b pb-2">
|
||||||
|
<p class="text-sm">{c.commit?.message?.split("\n")[0]}</p>
|
||||||
|
<p class="text-xs text-gray-400">{c.commit?.author?.name} - {new Date(c.commit?.author?.date).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
let termEl;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const { Terminal } = await import("@xterm/xterm");
|
||||||
|
const { FitAddon } = await import("@xterm/addon-fit");
|
||||||
|
await import("@xterm/xterm/css/xterm.css");
|
||||||
|
|
||||||
|
const term = new Terminal({ cursorBlink: true, fontSize: 14, theme: { background: "#1a1a2e" } });
|
||||||
|
const fit = new FitAddon();
|
||||||
|
term.loadAddon(fit);
|
||||||
|
term.open(termEl);
|
||||||
|
fit.fit();
|
||||||
|
|
||||||
|
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const ws = new WebSocket(`${proto}//${location.host}/ws/terminal`);
|
||||||
|
ws.binaryType = "arraybuffer";
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
const buf = new Uint8Array(5);
|
||||||
|
buf[0] = 0x01;
|
||||||
|
new DataView(buf.buffer).setUint16(1, term.cols);
|
||||||
|
new DataView(buf.buffer).setUint16(3, term.rows);
|
||||||
|
ws.send(buf);
|
||||||
|
};
|
||||||
|
ws.onmessage = (e) => term.write(new Uint8Array(e.data));
|
||||||
|
ws.onclose = () => term.write("\r\n[Connection closed]");
|
||||||
|
term.onData((data) => ws.readyState === 1 && ws.send(new TextEncoder().encode(data)));
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
fit.fit();
|
||||||
|
if (ws.readyState === 1) {
|
||||||
|
const buf = new Uint8Array(5);
|
||||||
|
buf[0] = 0x01;
|
||||||
|
new DataView(buf.buffer).setUint16(1, term.cols);
|
||||||
|
new DataView(buf.buffer).setUint16(3, term.rows);
|
||||||
|
ws.send(buf);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ro.observe(termEl);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h2 class="text-2xl font-bold mb-4">Terminal</h2>
|
||||||
|
<div bind:this={termEl} class="h-[calc(100vh-8rem)] rounded-lg overflow-hidden"></div>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||||
|
export default { preprocess: vitePreprocess() };
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./src/**/*.{svelte,js}', './index.html'],
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: { extend: {} },
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tailwindcss(), svelte()],
|
||||||
|
server: { proxy: { "/api": "http://localhost:8000", "/ws": { target: "ws://localhost:8000", ws: true } } },
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user