Phase 14: multi-host file browser (0.15.0)

The Files page gained a host switcher: when agents are registered, a Host
dropdown switches the whole browser between the local host and any online agent
(switching resets path + clipboard). Every file operation is sandboxed by the
selected agent's own ALLOWED_BROWSE_ROOTS/HOST_ROOT_PREFIX.

- agent_app.py: /agent/files/* (list/read/download/write/mkdir/touch/rename/
  copy/move/delete/upload) reusing file_service + device_service; BrowseError
  -> HTTP 400.
- routers/agents.py: proxy routes at /api/agents/{id}/files/* (audit-logged
  mutations); download streams via download_to_file, upload via upload_file.
  Reuses the WriteBody/NameBody/RenameBody/TransferBody models from routers.files.
- Frontend: filesApi methods take an optional trailing agentId; Files.tsx tracks
  a host and threads it through every call, query key, and the editor/dialogs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-08 12:04:56 +00:00
co-authored by Claude Opus 4.8
parent 012614f5fb
commit c5f591749f
7 changed files with 428 additions and 39 deletions
+11 -1
View File
@@ -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) > destinations) + Phase 7 (Scheduled backups) + Phase 8 (Remote-stack backups)
> + Phase 9 (Networks) + Phase 10 (iGPU passthrough) + Phase 11 (Remote UX & > + Phase 9 (Networks) + Phase 10 (iGPU passthrough) + Phase 11 (Remote UX &
> network attach) + Phase 12 (File browser) + Phase 13 (Multi-host networks & > 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) ## 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` container or connect any container on the host (`POST /api/networks/{id}/connect`
/ `/disconnect`). / `/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 ### Phase 13 — Multi-host networks & images
- **Networks and Images are now per-host.** Both pages render a section for the - **Networks and Images are now per-host.** Both pages render a section for the
+110 -1
View File
@@ -39,6 +39,8 @@ from docker_client import DockerError, get_client, safe_call
from services import ( from services import (
backup_service, backup_service,
compose_service, compose_service,
device_service,
file_service,
image_service, image_service,
network_service, network_service,
update_service, update_service,
@@ -57,7 +59,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.14.0" AGENT_VERSION = "0.15.0"
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -99,6 +101,34 @@ class ContainerRefBody(BaseModel):
force: bool = False 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 # Helpers
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -406,6 +436,85 @@ async def image_check() -> dict:
return await update_service.check_all() 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}") @app.websocket("/agent/ws/logs/{stack_id}")
async def ws_logs(websocket: WebSocket, stack_id: str, token: str | None = Query(default=None)): 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).""" """Stream `docker compose logs -f` to the central app (token via query param)."""
+1 -1
View File
@@ -55,7 +55,7 @@ async def lifespan(app: FastAPI):
schedule_task.cancel() 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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
+211
View File
@@ -17,6 +17,7 @@ from models.agent import Agent, AgentCreate, AgentRead, AgentUpdate
from models.backup_destination import BackupDestination from models.backup_destination import BackupDestination
from models.stack import StackCreate, StackUpdate from models.stack import StackCreate, StackUpdate
from models.user import User from models.user import User
from routers.files import NameBody, RenameBody, TransferBody, WriteBody
from routers.networks import ContainerRef, NetworkCreate from routers.networks import ContainerRef, NetworkCreate
from services import ( from services import (
agent_service, agent_service,
@@ -642,3 +643,213 @@ async def agent_image_check(
ip=_ip(request), ip=_ip(request),
) )
return result 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
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "stackpilot-frontend", "name": "stackpilot-frontend",
"private": true, "private": true,
"version": "0.14.0", "version": "0.15.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+26 -22
View File
@@ -20,52 +20,56 @@ function triggerDownload(blob: Blob, filename: string) {
URL.revokeObjectURL(url); 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 = { export const filesApi = {
list: (path: string, showHidden = false) => list: (path: string, showHidden = false, agentId?: number) =>
api api
.get<HostPathResult>("/api/files/list", { params: { path, show_hidden: showHidden } }) .get<HostPathResult>(`${base(agentId)}/list`, { params: { path, show_hidden: showHidden } })
.then((r) => r.data), .then((r) => r.data),
read: (path: string) => read: (path: string, agentId?: number) =>
api.get<FileContent>("/api/files/read", { params: { path } }).then((r) => r.data), api.get<FileContent>(`${base(agentId)}/read`, { params: { path } }).then((r) => r.data),
write: (path: string, content: string) => write: (path: string, content: string, agentId?: number) =>
api.put<{ path: string; size: number }>("/api/files/write", { path, content }).then((r) => r.data), api.put<{ path: string; size: number }>(`${base(agentId)}/write`, { path, content }).then((r) => r.data),
mkdir: (path: string, name: string) => mkdir: (path: string, name: string, agentId?: number) =>
api.post<{ path: string }>("/api/files/mkdir", { path, name }).then((r) => r.data), api.post<{ path: string }>(`${base(agentId)}/mkdir`, { path, name }).then((r) => r.data),
touch: (path: string, name: string) => touch: (path: string, name: string, agentId?: number) =>
api.post<{ path: string }>("/api/files/touch", { path, name }).then((r) => r.data), api.post<{ path: string }>(`${base(agentId)}/touch`, { path, name }).then((r) => r.data),
rename: (path: string, newName: string) => rename: (path: string, newName: string, agentId?: number) =>
api.post<{ path: string }>("/api/files/rename", { path, new_name: newName }).then((r) => r.data), api.post<{ path: string }>(`${base(agentId)}/rename`, { path, new_name: newName }).then((r) => r.data),
remove: (path: string, recursive = false) => remove: (path: string, recursive = false, agentId?: number) =>
api.delete("/api/files", { params: { path, recursive } }).then((r) => r.data), 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 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), .then((r) => r.data),
move: (src: string, destDir: string, overwrite = false) => move: (src: string, destDir: string, overwrite = false, agentId?: number) =>
api 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), .then((r) => r.data),
download: async (path: string, filename: string) => { download: async (path: string, filename: string, agentId?: number) => {
const res = await api.get("/api/files/download", { params: { path }, responseType: "blob" }); const res = await api.get(`${base(agentId)}/download`, { params: { path }, responseType: "blob" });
triggerDownload(res.data as Blob, filename); 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(); const form = new FormData();
form.append("path", path); form.append("path", path);
form.append("overwrite", String(overwrite)); form.append("overwrite", String(overwrite));
if (relPath) form.append("rel_path", relPath); if (relPath) form.append("rel_path", relPath);
form.append("file", file); 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; return res.data;
}, },
}; };
+68 -13
View File
@@ -21,12 +21,14 @@ import {
Copy, Copy,
Scissors, Scissors,
ClipboardPaste, ClipboardPaste,
Server,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import Editor from "@monaco-editor/react"; import Editor from "@monaco-editor/react";
import { Button, Card, Input, Spinner } from "@/components/ui"; import { Button, Card, Input, Spinner } from "@/components/ui";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog"; import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { filesApi } from "@/api/files"; import { filesApi } from "@/api/files";
import { agentsApi } from "@/api/agents";
import { apiErrorMessage } from "@/api/client"; import { apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/store/auth"; import { useAuthStore } from "@/store/auth";
import { useThemeStore } from "@/store/theme"; import { useThemeStore } from "@/store/theme";
@@ -72,6 +74,7 @@ function crumbs(path: string): { label: string; path: string }[] {
export function Files() { export function Files() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin"); const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const qc = useQueryClient(); const qc = useQueryClient();
const [host, setHost] = useState<number | undefined>(undefined); // undefined = local
const [path, setPath] = useState("/"); const [path, setPath] = useState("/");
const [showHidden, setShowHidden] = useState(false); const [showHidden, setShowHidden] = useState(false);
const [editing, setEditing] = useState<HostPathEntry | null>(null); const [editing, setEditing] = useState<HostPathEntry | null>(null);
@@ -83,15 +86,30 @@ export function Files() {
const fileInput = useRef<HTMLInputElement>(null); const fileInput = useRef<HTMLInputElement>(null);
const folderInput = useRef<HTMLInputElement>(null); const folderInput = useRef<HTMLInputElement>(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({ const { data, isLoading, isFetching, error } = useQuery({
queryKey: ["files", path, showHidden], queryKey: ["files", host ?? "local", path, showHidden],
queryFn: () => filesApi.list(path, showHidden), queryFn: () => filesApi.list(path, showHidden, host),
}); });
const refresh = () => qc.invalidateQueries({ queryKey: ["files"] }); const refresh = () => qc.invalidateQueries({ queryKey: ["files"] });
const upload = useMutation({ const upload = useMutation({
mutationFn: (file: File) => filesApi.upload(path, file), mutationFn: (file: File) => filesApi.upload(path, file, false, "", host),
onSuccess: (r) => { onSuccess: (r) => {
toast.success(`Uploaded ${r.name}`); toast.success(`Uploaded ${r.name}`);
refresh(); refresh();
@@ -118,7 +136,7 @@ export function Files() {
for (const f of files) { for (const f of files) {
const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath || f.name; const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath || f.name;
try { try {
await filesApi.upload(path, f, true, rel); await filesApi.upload(path, f, true, rel, host);
ok += 1; ok += 1;
} catch { } catch {
failed += 1; failed += 1;
@@ -140,7 +158,7 @@ export function Files() {
const paste = useMutation({ const paste = useMutation({
mutationFn: (overwrite: boolean) => { mutationFn: (overwrite: boolean) => {
const op = clip!.mode === "copy" ? filesApi.copy : filesApi.move; const op = clip!.mode === "copy" ? filesApi.copy : filesApi.move;
return op(clip!.src, path, overwrite); return op(clip!.src, path, overwrite, host);
}, },
onSuccess: () => { onSuccess: () => {
toast.success(clip!.mode === "copy" ? "Copied" : "Moved"); toast.success(clip!.mode === "copy" ? "Copied" : "Moved");
@@ -158,7 +176,7 @@ export function Files() {
}); });
const remove = useMutation({ 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: () => { onSuccess: () => {
toast.success("Deleted"); toast.success("Deleted");
setDeleting(null); setDeleting(null);
@@ -168,10 +186,35 @@ export function Files() {
}); });
const download = (e: HostPathEntry) => 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Host switcher (only when remote hosts are registered) */}
{(agents.data?.length ?? 0) > 0 && (
<div className="flex items-center gap-2 text-sm">
<Server className="h-4 w-4 text-slate-400" />
<span className="text-slate-500">Host</span>
<select
value={host ?? "local"}
onChange={(e) => switchHost(e.target.value === "local" ? undefined : Number(e.target.value))}
className="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm dark:border-slate-600 dark:bg-slate-800"
>
<option value="local">This host</option>
{onlineAgents.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
{host != null && !onlineAgents.some((a) => a.id === host) && (
<span className="text-xs text-amber-500">selected host is offline</span>
)}
</div>
)}
{/* Roots + actions */} {/* Roots + actions */}
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{data?.roots.map((r) => ( {data?.roots.map((r) => (
@@ -397,6 +440,7 @@ export function Files() {
path={join(path, editing.name)} path={join(path, editing.name)}
name={editing.name} name={editing.name}
isAdmin={isAdmin} isAdmin={isAdmin}
agentId={host}
onClose={() => setEditing(null)} onClose={() => setEditing(null)}
onSaved={refresh} onSaved={refresh}
/> />
@@ -405,6 +449,7 @@ export function Files() {
<NewEntryDialog <NewEntryDialog
kind={newKind} kind={newKind}
dir={path} dir={path}
agentId={host}
onCancel={() => setNewKind(null)} onCancel={() => setNewKind(null)}
onDone={() => { onDone={() => {
setNewKind(null); setNewKind(null);
@@ -416,6 +461,7 @@ export function Files() {
<RenameDialog <RenameDialog
entry={renaming} entry={renaming}
dir={path} dir={path}
agentId={host}
onCancel={() => setRenaming(null)} onCancel={() => setRenaming(null)}
onDone={() => { onDone={() => {
setRenaming(null); setRenaming(null);
@@ -459,12 +505,14 @@ function FileEditor({
path, path,
name, name,
isAdmin, isAdmin,
agentId,
onClose, onClose,
onSaved, onSaved,
}: { }: {
path: string; path: string;
name: string; name: string;
isAdmin: boolean; isAdmin: boolean;
agentId?: number;
onClose: () => void; onClose: () => void;
onSaved: () => void; onSaved: () => void;
}) { }) {
@@ -472,8 +520,8 @@ function FileEditor({
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ["file-content", path], queryKey: ["file-content", agentId ?? "local", path],
queryFn: () => filesApi.read(path), queryFn: () => filesApi.read(path, agentId),
}); });
useEffect(() => { useEffect(() => {
@@ -481,7 +529,7 @@ function FileEditor({
}, [data]); }, [data]);
const save = useMutation({ const save = useMutation({
mutationFn: () => filesApi.write(path, content), mutationFn: () => filesApi.write(path, content, agentId),
onSuccess: () => { onSuccess: () => {
toast.success("Saved"); toast.success("Saved");
setDirty(false); setDirty(false);
@@ -531,7 +579,7 @@ function FileEditor({
? "This looks like a binary file and can't be edited here." ? "This looks like a binary file and can't be edited here."
: `File is too large to edit (${formatBytes(data?.size ?? 0)}).`} : `File is too large to edit (${formatBytes(data?.size ?? 0)}).`}
</p> </p>
<Button variant="outline" onClick={() => filesApi.download(path, name)}> <Button variant="outline" onClick={() => filesApi.download(path, name, agentId)}>
<Download className="h-4 w-4" /> Download instead <Download className="h-4 w-4" /> Download instead
</Button> </Button>
</div> </div>
@@ -563,17 +611,22 @@ function FileEditor({
function NewEntryDialog({ function NewEntryDialog({
kind, kind,
dir, dir,
agentId,
onCancel, onCancel,
onDone, onDone,
}: { }: {
kind: "dir" | "file"; kind: "dir" | "file";
dir: string; dir: string;
agentId?: number;
onCancel: () => void; onCancel: () => void;
onDone: () => void; onDone: () => void;
}) { }) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const create = useMutation({ 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: () => { onSuccess: () => {
toast.success(kind === "dir" ? "Folder created" : "File created"); toast.success(kind === "dir" ? "Folder created" : "File created");
onDone(); onDone();
@@ -603,17 +656,19 @@ function NewEntryDialog({
function RenameDialog({ function RenameDialog({
entry, entry,
dir, dir,
agentId,
onCancel, onCancel,
onDone, onDone,
}: { }: {
entry: HostPathEntry; entry: HostPathEntry;
dir: string; dir: string;
agentId?: number;
onCancel: () => void; onCancel: () => void;
onDone: () => void; onDone: () => void;
}) { }) {
const [name, setName] = useState(entry.name); const [name, setName] = useState(entry.name);
const rename = useMutation({ const rename = useMutation({
mutationFn: () => filesApi.rename(join(dir, entry.name), name.trim()), mutationFn: () => filesApi.rename(join(dir, entry.name), name.trim(), agentId),
onSuccess: () => { onSuccess: () => {
toast.success("Renamed"); toast.success("Renamed");
onDone(); onDone();