diff --git a/README.md b/README.md index 343db8f..6b5b091 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. > destinations) + Phase 7 (Scheduled backups) + Phase 8 (Remote-stack backups) > + Phase 9 (Networks) + Phase 10 (iGPU passthrough) + Phase 11 (Remote UX & > network attach) + Phase 12 (File browser) + Phase 13 (Multi-host networks & -> images) complete. +> images) + Phase 14 (Multi-host file browser) complete. ## What works today (Phase 1) @@ -146,6 +146,16 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. container or connect any container on the host (`POST /api/networks/{id}/connect` / `/disconnect`). +### Phase 14 — Multi-host file browser + +- **The Files page now has a host switcher.** When agents are registered, a + *Host* dropdown at the top switches the whole browser between the local host + and any online agent; switching resets the path and clipboard. +- All file operations (browse, view/edit, create, rename, copy/move, delete, + upload files & folders, download) work against the selected agent, sandboxed + by *that agent's* `ALLOWED_BROWSE_ROOTS`/`HOST_ROOT_PREFIX`. +- New agent endpoints `/agent/files/*`, proxied at `/api/agents/{id}/files/*`. + ### Phase 13 — Multi-host networks & images - **Networks and Images are now per-host.** Both pages render a section for the diff --git a/backend/agent_app.py b/backend/agent_app.py index 2b14c5b..5608349 100644 --- a/backend/agent_app.py +++ b/backend/agent_app.py @@ -39,6 +39,8 @@ from docker_client import DockerError, get_client, safe_call from services import ( backup_service, compose_service, + device_service, + file_service, image_service, network_service, update_service, @@ -57,7 +59,7 @@ def _map_docker(exc: DockerError): raise HTTPException(status_code=code, detail=exc.detail or exc.error) raise exc # falls through to the global 502 DockerError handler -AGENT_VERSION = "0.14.0" +AGENT_VERSION = "0.15.0" # --------------------------------------------------------------------------- # @@ -99,6 +101,34 @@ class ContainerRefBody(BaseModel): force: bool = False +class FileWriteBody(BaseModel): + path: str + content: str + + +class FileNameBody(BaseModel): + path: str + name: str + + +class FileRenameBody(BaseModel): + path: str + new_name: str + + +class FileTransferBody(BaseModel): + src: str + dest_dir: str + overwrite: bool = False + + +def _file_guard(fn, *args, **kwargs): + try: + return fn(*args, **kwargs) + except file_service.BrowseError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + # --------------------------------------------------------------------------- # # Helpers # --------------------------------------------------------------------------- # @@ -406,6 +436,85 @@ async def image_check() -> dict: return await update_service.check_all() +# --------------------------------------------------------------------------- # +# File browser (sandboxed by this agent's ALLOWED_BROWSE_ROOTS/HOST_ROOT_PREFIX) +# --------------------------------------------------------------------------- # + + +@app.get("/agent/files/list", dependencies=[Depends(verify_token)]) +def files_list(path: str = Query("/"), show_hidden: bool = Query(False)) -> dict: + return _file_guard(device_service.browse, path, show_hidden) + + +@app.get("/agent/files/read", dependencies=[Depends(verify_token)]) +def files_read(path: str = Query(...)) -> dict: + return _file_guard(file_service.read_file, path) + + +@app.get("/agent/files/download", dependencies=[Depends(verify_token)]) +def files_download(path: str = Query(...)): + real, filename = _file_guard(file_service.resolve_download, path) + return FileResponse(real, filename=filename, media_type="application/octet-stream") + + +@app.put("/agent/files/write", dependencies=[Depends(verify_token)]) +def files_write(body: FileWriteBody) -> dict: + return _file_guard(file_service.write_file, body.path, body.content) + + +@app.post("/agent/files/mkdir", dependencies=[Depends(verify_token)]) +def files_mkdir(body: FileNameBody) -> dict: + return _file_guard(file_service.create_dir, body.path, body.name) + + +@app.post("/agent/files/touch", dependencies=[Depends(verify_token)]) +def files_touch(body: FileNameBody) -> dict: + return _file_guard(file_service.create_file, body.path, body.name) + + +@app.post("/agent/files/rename", dependencies=[Depends(verify_token)]) +def files_rename(body: FileRenameBody) -> dict: + return _file_guard(file_service.rename, body.path, body.new_name) + + +@app.post("/agent/files/copy", dependencies=[Depends(verify_token)]) +def files_copy(body: FileTransferBody) -> dict: + return _file_guard(file_service.copy, body.src, body.dest_dir, body.overwrite) + + +@app.post("/agent/files/move", dependencies=[Depends(verify_token)]) +def files_move(body: FileTransferBody) -> dict: + return _file_guard(file_service.move, body.src, body.dest_dir, body.overwrite) + + +@app.delete("/agent/files", dependencies=[Depends(verify_token)]) +def files_delete(path: str = Query(...), recursive: bool = Query(False)) -> dict: + return _file_guard(file_service.delete, path, recursive) + + +@app.post("/agent/files/upload", dependencies=[Depends(verify_token)]) +async def files_upload( + path: str = Form(...), + overwrite: bool = Form(False), + rel_path: str = Form(""), + file: UploadFile = File(...), +) -> dict: + real = _file_guard( + file_service.upload_target, path, file.filename or "", overwrite, rel_path or None + ) + tmp = tempfile.NamedTemporaryFile(delete=False, dir=os.path.dirname(real)) + try: + while chunk := await file.read(1024 * 1024): + tmp.write(chunk) + tmp.close() + os.replace(tmp.name, real) + except OSError as exc: + if os.path.exists(tmp.name): + os.unlink(tmp.name) + raise HTTPException(status_code=400, detail=f"Upload failed: {exc}") from exc + return {"ok": True, "name": rel_path or file.filename} + + @app.websocket("/agent/ws/logs/{stack_id}") async def ws_logs(websocket: WebSocket, stack_id: str, token: str | None = Query(default=None)): """Stream `docker compose logs -f` to the central app (token via query param).""" diff --git a/backend/main.py b/backend/main.py index 4c484e8..da627d3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -55,7 +55,7 @@ async def lifespan(app: FastAPI): schedule_task.cancel() -app = FastAPI(title="StackPilot", version="0.14.0", lifespan=lifespan) +app = FastAPI(title="StackPilot", version="0.15.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, diff --git a/backend/routers/agents.py b/backend/routers/agents.py index 2079756..1c7a2c2 100644 --- a/backend/routers/agents.py +++ b/backend/routers/agents.py @@ -17,6 +17,7 @@ from models.agent import Agent, AgentCreate, AgentRead, AgentUpdate from models.backup_destination import BackupDestination from models.stack import StackCreate, StackUpdate from models.user import User +from routers.files import NameBody, RenameBody, TransferBody, WriteBody from routers.networks import ContainerRef, NetworkCreate from services import ( agent_service, @@ -642,3 +643,213 @@ async def agent_image_check( ip=_ip(request), ) return result + + +# --------------------------------------------------------------------------- # +# File browser (proxied) +# --------------------------------------------------------------------------- # + + +@router.get("/{agent_id}/files/list") +async def agent_files_list( + agent_id: int, + path: str = Query("/"), + show_hidden: bool = Query(False), + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +) -> dict: + agent = _get_or_404(session, agent_id) + return await _proxy( + session, agent, "GET", "/agent/files/list", + params={"path": path, "show_hidden": show_hidden}, + ) + + +@router.get("/{agent_id}/files/read") +async def agent_files_read( + agent_id: int, + path: str = Query(...), + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +) -> dict: + agent = _get_or_404(session, agent_id) + return await _proxy(session, agent, "GET", "/agent/files/read", params={"path": path}) + + +@router.get("/{agent_id}/files/download") +async def agent_files_download( + agent_id: int, + path: str = Query(...), + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +): + agent = _get_or_404(session, agent_id) + tmp = tempfile.NamedTemporaryFile(delete=False) + tmp.close() + try: + await agent_service.download_to_file( + session, agent, "/agent/files/download", tmp.name, params={"path": path} + ) + except AgentError as exc: + if os.path.exists(tmp.name): + os.unlink(tmp.name) + _raise(exc) + return FileResponse( + tmp.name, media_type="application/octet-stream", filename=os.path.basename(path), + background=BackgroundTask(os.unlink, tmp.name), + ) + + +@router.put("/{agent_id}/files/write") +async def agent_files_write( + agent_id: int, + body: WriteBody, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + agent = _get_or_404(session, agent_id) + result = await _proxy(session, agent, "PUT", "/agent/files/write", json=body.model_dump()) + audit_service.record( + session, user=user.username, action="agent.file.write", + target=f"{agent.name}:{body.path}", ip=_ip(request), + ) + return result + + +@router.post("/{agent_id}/files/mkdir") +async def agent_files_mkdir( + agent_id: int, + body: NameBody, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + agent = _get_or_404(session, agent_id) + result = await _proxy(session, agent, "POST", "/agent/files/mkdir", json=body.model_dump()) + audit_service.record( + session, user=user.username, action="agent.file.mkdir", + target=f"{agent.name}:{result.get('path')}", ip=_ip(request), + ) + return result + + +@router.post("/{agent_id}/files/touch") +async def agent_files_touch( + agent_id: int, + body: NameBody, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + agent = _get_or_404(session, agent_id) + result = await _proxy(session, agent, "POST", "/agent/files/touch", json=body.model_dump()) + audit_service.record( + session, user=user.username, action="agent.file.create", + target=f"{agent.name}:{result.get('path')}", ip=_ip(request), + ) + return result + + +@router.post("/{agent_id}/files/rename") +async def agent_files_rename( + agent_id: int, + body: RenameBody, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + agent = _get_or_404(session, agent_id) + result = await _proxy(session, agent, "POST", "/agent/files/rename", json=body.model_dump()) + audit_service.record( + session, user=user.username, action="agent.file.rename", + target=f"{agent.name}:{body.path}", detail=f"-> {result.get('path')}", ip=_ip(request), + ) + return result + + +@router.post("/{agent_id}/files/copy") +async def agent_files_copy( + agent_id: int, + body: TransferBody, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + agent = _get_or_404(session, agent_id) + result = await _proxy(session, agent, "POST", "/agent/files/copy", json=body.model_dump()) + audit_service.record( + session, user=user.username, action="agent.file.copy", + target=f"{agent.name}:{body.src}", detail=f"-> {result.get('path')}", ip=_ip(request), + ) + return result + + +@router.post("/{agent_id}/files/move") +async def agent_files_move( + agent_id: int, + body: TransferBody, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + agent = _get_or_404(session, agent_id) + result = await _proxy(session, agent, "POST", "/agent/files/move", json=body.model_dump()) + audit_service.record( + session, user=user.username, action="agent.file.move", + target=f"{agent.name}:{body.src}", detail=f"-> {result.get('path')}", ip=_ip(request), + ) + return result + + +@router.delete("/{agent_id}/files") +async def agent_files_delete( + agent_id: int, + request: Request, + path: str = Query(...), + recursive: bool = Query(False), + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + agent = _get_or_404(session, agent_id) + result = await _proxy( + session, agent, "DELETE", "/agent/files", params={"path": path, "recursive": recursive} + ) + audit_service.record( + session, user=user.username, action="agent.file.delete", + target=f"{agent.name}:{path}", detail="recursive" if recursive else None, ip=_ip(request), + ) + return result + + +@router.post("/{agent_id}/files/upload") +async def agent_files_upload( + agent_id: int, + request: Request, + path: str = Form(...), + overwrite: bool = Form(False), + rel_path: str = Form(""), + file: UploadFile = File(...), + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + agent = _get_or_404(session, agent_id) + tmp = tempfile.NamedTemporaryFile(delete=False) + try: + while chunk := await file.read(1024 * 1024): + tmp.write(chunk) + tmp.close() + result = await agent_service.upload_file( + session, agent, "/agent/files/upload", tmp.name, file.filename or "upload", + {"path": path, "overwrite": str(overwrite).lower(), "rel_path": rel_path}, + ) + except AgentError as exc: + _raise(exc) + finally: + if os.path.exists(tmp.name): + os.unlink(tmp.name) + audit_service.record( + session, user=user.username, action="agent.file.upload", + target=f"{agent.name}:{path}", detail=rel_path or file.filename, ip=_ip(request), + ) + return result diff --git a/frontend/package.json b/frontend/package.json index 707e6ad..d52d89e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.14.0", + "version": "0.15.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/files.ts b/frontend/src/api/files.ts index 48e1957..ee17faa 100644 --- a/frontend/src/api/files.ts +++ b/frontend/src/api/files.ts @@ -20,52 +20,56 @@ function triggerDownload(blob: Blob, filename: string) { URL.revokeObjectURL(url); } +// Base path for the local host or, when agentId is given, a remote agent. +const base = (agentId?: number) => + agentId != null ? `/api/agents/${agentId}/files` : "/api/files"; + export const filesApi = { - list: (path: string, showHidden = false) => + list: (path: string, showHidden = false, agentId?: number) => api - .get("/api/files/list", { params: { path, show_hidden: showHidden } }) + .get(`${base(agentId)}/list`, { params: { path, show_hidden: showHidden } }) .then((r) => r.data), - read: (path: string) => - api.get("/api/files/read", { params: { path } }).then((r) => r.data), + read: (path: string, agentId?: number) => + api.get(`${base(agentId)}/read`, { params: { path } }).then((r) => r.data), - write: (path: string, content: string) => - api.put<{ path: string; size: number }>("/api/files/write", { path, content }).then((r) => r.data), + write: (path: string, content: string, agentId?: number) => + api.put<{ path: string; size: number }>(`${base(agentId)}/write`, { path, content }).then((r) => r.data), - mkdir: (path: string, name: string) => - api.post<{ path: string }>("/api/files/mkdir", { path, name }).then((r) => r.data), + mkdir: (path: string, name: string, agentId?: number) => + api.post<{ path: string }>(`${base(agentId)}/mkdir`, { path, name }).then((r) => r.data), - touch: (path: string, name: string) => - api.post<{ path: string }>("/api/files/touch", { path, name }).then((r) => r.data), + touch: (path: string, name: string, agentId?: number) => + api.post<{ path: string }>(`${base(agentId)}/touch`, { path, name }).then((r) => r.data), - rename: (path: string, newName: string) => - api.post<{ path: string }>("/api/files/rename", { path, new_name: newName }).then((r) => r.data), + rename: (path: string, newName: string, agentId?: number) => + api.post<{ path: string }>(`${base(agentId)}/rename`, { path, new_name: newName }).then((r) => r.data), - remove: (path: string, recursive = false) => - api.delete("/api/files", { params: { path, recursive } }).then((r) => r.data), + remove: (path: string, recursive = false, agentId?: number) => + api.delete(base(agentId), { params: { path, recursive } }).then((r) => r.data), - copy: (src: string, destDir: string, overwrite = false) => + copy: (src: string, destDir: string, overwrite = false, agentId?: number) => api - .post<{ path: string }>("/api/files/copy", { src, dest_dir: destDir, overwrite }) + .post<{ path: string }>(`${base(agentId)}/copy`, { src, dest_dir: destDir, overwrite }) .then((r) => r.data), - move: (src: string, destDir: string, overwrite = false) => + move: (src: string, destDir: string, overwrite = false, agentId?: number) => api - .post<{ path: string }>("/api/files/move", { src, dest_dir: destDir, overwrite }) + .post<{ path: string }>(`${base(agentId)}/move`, { src, dest_dir: destDir, overwrite }) .then((r) => r.data), - download: async (path: string, filename: string) => { - const res = await api.get("/api/files/download", { params: { path }, responseType: "blob" }); + download: async (path: string, filename: string, agentId?: number) => { + const res = await api.get(`${base(agentId)}/download`, { params: { path }, responseType: "blob" }); triggerDownload(res.data as Blob, filename); }, - upload: async (path: string, file: File, overwrite = false, relPath = "") => { + upload: async (path: string, file: File, overwrite = false, relPath = "", agentId?: number) => { const form = new FormData(); form.append("path", path); form.append("overwrite", String(overwrite)); if (relPath) form.append("rel_path", relPath); form.append("file", file); - const res = await api.post<{ ok: boolean; name: string }>("/api/files/upload", form); + const res = await api.post<{ ok: boolean; name: string }>(`${base(agentId)}/upload`, form); return res.data; }, }; diff --git a/frontend/src/pages/Files.tsx b/frontend/src/pages/Files.tsx index 5e68663..2c56c20 100644 --- a/frontend/src/pages/Files.tsx +++ b/frontend/src/pages/Files.tsx @@ -21,12 +21,14 @@ import { Copy, Scissors, ClipboardPaste, + Server, } from "lucide-react"; import { toast } from "sonner"; import Editor from "@monaco-editor/react"; import { Button, Card, Input, Spinner } from "@/components/ui"; import { ConfirmDialog } from "@/components/ui/ConfirmDialog"; import { filesApi } from "@/api/files"; +import { agentsApi } from "@/api/agents"; import { apiErrorMessage } from "@/api/client"; import { useAuthStore } from "@/store/auth"; import { useThemeStore } from "@/store/theme"; @@ -72,6 +74,7 @@ function crumbs(path: string): { label: string; path: string }[] { export function Files() { const isAdmin = useAuthStore((s) => s.user?.role === "admin"); const qc = useQueryClient(); + const [host, setHost] = useState(undefined); // undefined = local const [path, setPath] = useState("/"); const [showHidden, setShowHidden] = useState(false); const [editing, setEditing] = useState(null); @@ -83,15 +86,30 @@ export function Files() { const fileInput = useRef(null); const folderInput = useRef(null); + const agents = useQuery({ + queryKey: ["agents"], + queryFn: () => agentsApi.list(), + refetchInterval: 15000, + }); + const onlineAgents = (agents.data ?? []).filter((a) => a.status === "online"); + + // Switch host: reset workspace state so we never mix paths/clipboards across hosts. + const switchHost = (h: number | undefined) => { + setHost(h); + setPath("/"); + setEditing(null); + setClip(null); + }; + const { data, isLoading, isFetching, error } = useQuery({ - queryKey: ["files", path, showHidden], - queryFn: () => filesApi.list(path, showHidden), + queryKey: ["files", host ?? "local", path, showHidden], + queryFn: () => filesApi.list(path, showHidden, host), }); const refresh = () => qc.invalidateQueries({ queryKey: ["files"] }); const upload = useMutation({ - mutationFn: (file: File) => filesApi.upload(path, file), + mutationFn: (file: File) => filesApi.upload(path, file, false, "", host), onSuccess: (r) => { toast.success(`Uploaded ${r.name}`); refresh(); @@ -118,7 +136,7 @@ export function Files() { for (const f of files) { const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath || f.name; try { - await filesApi.upload(path, f, true, rel); + await filesApi.upload(path, f, true, rel, host); ok += 1; } catch { failed += 1; @@ -140,7 +158,7 @@ export function Files() { const paste = useMutation({ mutationFn: (overwrite: boolean) => { const op = clip!.mode === "copy" ? filesApi.copy : filesApi.move; - return op(clip!.src, path, overwrite); + return op(clip!.src, path, overwrite, host); }, onSuccess: () => { toast.success(clip!.mode === "copy" ? "Copied" : "Moved"); @@ -158,7 +176,7 @@ export function Files() { }); const remove = useMutation({ - mutationFn: (e: HostPathEntry) => filesApi.remove(join(path, e.name), e.type === "dir"), + mutationFn: (e: HostPathEntry) => filesApi.remove(join(path, e.name), e.type === "dir", host), onSuccess: () => { toast.success("Deleted"); setDeleting(null); @@ -168,10 +186,35 @@ export function Files() { }); const download = (e: HostPathEntry) => - filesApi.download(join(path, e.name), e.name).catch((err) => toast.error(apiErrorMessage(err))); + filesApi + .download(join(path, e.name), e.name, host) + .catch((err) => toast.error(apiErrorMessage(err))); return (
+ {/* Host switcher (only when remote hosts are registered) */} + {(agents.data?.length ?? 0) > 0 && ( +
+ + Host + + {host != null && !onlineAgents.some((a) => a.id === host) && ( + selected host is offline + )} +
+ )} + {/* Roots + actions */}
{data?.roots.map((r) => ( @@ -397,6 +440,7 @@ export function Files() { path={join(path, editing.name)} name={editing.name} isAdmin={isAdmin} + agentId={host} onClose={() => setEditing(null)} onSaved={refresh} /> @@ -405,6 +449,7 @@ export function Files() { setNewKind(null)} onDone={() => { setNewKind(null); @@ -416,6 +461,7 @@ export function Files() { setRenaming(null)} onDone={() => { setRenaming(null); @@ -459,12 +505,14 @@ function FileEditor({ path, name, isAdmin, + agentId, onClose, onSaved, }: { path: string; name: string; isAdmin: boolean; + agentId?: number; onClose: () => void; onSaved: () => void; }) { @@ -472,8 +520,8 @@ function FileEditor({ const [content, setContent] = useState(""); const [dirty, setDirty] = useState(false); const { data, isLoading, error } = useQuery({ - queryKey: ["file-content", path], - queryFn: () => filesApi.read(path), + queryKey: ["file-content", agentId ?? "local", path], + queryFn: () => filesApi.read(path, agentId), }); useEffect(() => { @@ -481,7 +529,7 @@ function FileEditor({ }, [data]); const save = useMutation({ - mutationFn: () => filesApi.write(path, content), + mutationFn: () => filesApi.write(path, content, agentId), onSuccess: () => { toast.success("Saved"); setDirty(false); @@ -531,7 +579,7 @@ function FileEditor({ ? "This looks like a binary file and can't be edited here." : `File is too large to edit (${formatBytes(data?.size ?? 0)}).`}

-
@@ -563,17 +611,22 @@ function FileEditor({ function NewEntryDialog({ kind, dir, + agentId, onCancel, onDone, }: { kind: "dir" | "file"; dir: string; + agentId?: number; onCancel: () => void; onDone: () => void; }) { const [name, setName] = useState(""); const create = useMutation({ - mutationFn: () => (kind === "dir" ? filesApi.mkdir(dir, name.trim()) : filesApi.touch(dir, name.trim())), + mutationFn: () => + kind === "dir" + ? filesApi.mkdir(dir, name.trim(), agentId) + : filesApi.touch(dir, name.trim(), agentId), onSuccess: () => { toast.success(kind === "dir" ? "Folder created" : "File created"); onDone(); @@ -603,17 +656,19 @@ function NewEntryDialog({ function RenameDialog({ entry, dir, + agentId, onCancel, onDone, }: { entry: HostPathEntry; dir: string; + agentId?: number; onCancel: () => void; onDone: () => void; }) { const [name, setName] = useState(entry.name); const rename = useMutation({ - mutationFn: () => filesApi.rename(join(dir, entry.name), name.trim()), + mutationFn: () => filesApi.rename(join(dir, entry.name), name.trim(), agentId), onSuccess: () => { toast.success("Renamed"); onDone();