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:
co-authored by
Claude Opus 4.8
parent
012614f5fb
commit
c5f591749f
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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,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
@@ -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;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user