Voor remote client agent
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.log
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.DS_Store
|
||||||
|
.sqlite3
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Finder Commander
|
||||||
|
|
||||||
|
Lokale webapp die aanvoelt als een Midnight Commander-achtige file manager voor macOS/Linux home directories.
|
||||||
|
|
||||||
|
## Wat zit erin
|
||||||
|
|
||||||
|
- twee panelen naast elkaar
|
||||||
|
- actieve panel met duidelijke focusrand
|
||||||
|
- keyboard shortcuts:
|
||||||
|
- `Tab` wissel panel
|
||||||
|
- `↑ / ↓ / PgUp / PgDn` navigatie
|
||||||
|
- `Enter` open map of view bestand
|
||||||
|
- `Backspace` omhoog
|
||||||
|
- `Space` markeer bestand/map
|
||||||
|
- `F3` view
|
||||||
|
- `F4` edit
|
||||||
|
- `Shift+F4` nieuw bestand
|
||||||
|
- `F5` copy naar ander panel
|
||||||
|
- `F6` move naar ander panel
|
||||||
|
- `Shift+F6` rename
|
||||||
|
- `F7` nieuwe map
|
||||||
|
- `F8` delete naar `~/.Trash`
|
||||||
|
- `Ctrl+H` toggle verborgen bestanden
|
||||||
|
- `Ctrl+R` refresh
|
||||||
|
- `Alt+X` focus command line
|
||||||
|
- command line onderin met beperkte veilige commando's:
|
||||||
|
- `cd <path>`
|
||||||
|
- `mkdir <name>`
|
||||||
|
- `touch <name>`
|
||||||
|
- `select <glob>`
|
||||||
|
- `help`
|
||||||
|
- upload per panel
|
||||||
|
- viewer voor tekst en afbeeldingen
|
||||||
|
- editor voor tekstbestanden
|
||||||
|
- padbeveiliging: alles blijft binnen `~`
|
||||||
|
|
||||||
|
## Starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./run-local.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Het script kiest automatisch `python3.14` als dat aanwezig is. Bestaat er al een `.venv` met een andere Python minor-versie, dan wordt die automatisch opnieuw aangemaakt.
|
||||||
|
|
||||||
|
Open daarna:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:8765/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Python 3.14-notes
|
||||||
|
|
||||||
|
Deze build is opgeschoond voor Python 3.14.x:
|
||||||
|
|
||||||
|
- minimale Uvicorn-installatie (`uvicorn` i.p.v. `uvicorn[standard]`)
|
||||||
|
- geen optionele C-extensies nodig om te starten
|
||||||
|
- dependencies ondersteunen Python 3.14
|
||||||
|
|
||||||
|
## Opmerking
|
||||||
|
|
||||||
|
Dit is een **MC-like v1**, geen volledige Midnight Commander clone. Bewust niet ingebouwd:
|
||||||
|
|
||||||
|
- shell/subshell uitvoering
|
||||||
|
- chmod/chown dialogs
|
||||||
|
- archive browsing als directory
|
||||||
|
- remote FTP/SFTP panels
|
||||||
|
- diff/compare directories
|
||||||
@@ -0,0 +1,522 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fnmatch
|
||||||
|
import html
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from fastapi import Body, FastAPI, File, Form, HTTPException, Request, UploadFile
|
||||||
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
APP_NAME = "Finder Commander"
|
||||||
|
HOME_ROOT = Path.home().resolve()
|
||||||
|
TRASH_DIR = HOME_ROOT / ".Trash"
|
||||||
|
MAX_TEXT_PREVIEW_BYTES = 2 * 1024 * 1024
|
||||||
|
CSRF_TOKEN = secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
app = FastAPI(title=APP_NAME)
|
||||||
|
app.mount("/static", StaticFiles(directory=str(Path(__file__).parent / "static")), name="static")
|
||||||
|
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
|
||||||
|
|
||||||
|
|
||||||
|
class PathsPayload(BaseModel):
|
||||||
|
paths: list[str] = Field(default_factory=list)
|
||||||
|
destination_dir: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RenamePayload(BaseModel):
|
||||||
|
path: str
|
||||||
|
new_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class DeletePayload(BaseModel):
|
||||||
|
paths: list[str] = Field(default_factory=list)
|
||||||
|
mode: Literal["trash", "permanent"] = "trash"
|
||||||
|
|
||||||
|
|
||||||
|
class CommandPayload(BaseModel):
|
||||||
|
command: str
|
||||||
|
cwd: str = ""
|
||||||
|
|
||||||
|
PathsPayload.model_rebuild()
|
||||||
|
RenamePayload.model_rebuild()
|
||||||
|
DeletePayload.model_rebuild()
|
||||||
|
CommandPayload.model_rebuild()
|
||||||
|
|
||||||
|
TEXT_SUFFIXES = {
|
||||||
|
".md",
|
||||||
|
".txt",
|
||||||
|
".py",
|
||||||
|
".js",
|
||||||
|
".ts",
|
||||||
|
".tsx",
|
||||||
|
".jsx",
|
||||||
|
".css",
|
||||||
|
".html",
|
||||||
|
".json",
|
||||||
|
".yaml",
|
||||||
|
".yml",
|
||||||
|
".toml",
|
||||||
|
".ini",
|
||||||
|
".env",
|
||||||
|
".log",
|
||||||
|
".xml",
|
||||||
|
".sh",
|
||||||
|
".zsh",
|
||||||
|
".bash",
|
||||||
|
".c",
|
||||||
|
".cpp",
|
||||||
|
".h",
|
||||||
|
".java",
|
||||||
|
".go",
|
||||||
|
".rs",
|
||||||
|
".sql",
|
||||||
|
".conf",
|
||||||
|
".service",
|
||||||
|
".container",
|
||||||
|
".network",
|
||||||
|
".pod",
|
||||||
|
".kube",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
return datetime.utcnow().isoformat(timespec="seconds") + "Z"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def rel_from_home(path: Path) -> str:
|
||||||
|
return "" if path == HOME_ROOT else str(path.relative_to(HOME_ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_within_home(candidate: Path) -> Path:
|
||||||
|
try:
|
||||||
|
candidate.relative_to(HOME_ROOT)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=403, detail="Path escapes home directory") from exc
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_name(name: str) -> str:
|
||||||
|
name = (name or "").strip()
|
||||||
|
if not name or name in {".", ".."} or "/" in name:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid name")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_user_path(raw_path: Optional[str], *, must_exist: bool = True) -> Path:
|
||||||
|
raw_path = (raw_path or "").strip()
|
||||||
|
candidate = (HOME_ROOT / raw_path).resolve(strict=False)
|
||||||
|
candidate = ensure_within_home(candidate)
|
||||||
|
if must_exist and not candidate.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Path not found")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def check_origin(request: Request) -> None:
|
||||||
|
origin = request.headers.get("origin")
|
||||||
|
if not origin:
|
||||||
|
return
|
||||||
|
expected = str(request.base_url).rstrip("/")
|
||||||
|
if origin.rstrip("/") != expected:
|
||||||
|
raise HTTPException(status_code=403, detail="Origin not allowed")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def check_csrf(request: Request) -> None:
|
||||||
|
token = request.headers.get("x-csrf-token")
|
||||||
|
if token != CSRF_TOKEN:
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid CSRF token")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def perms_string(mode: int) -> str:
|
||||||
|
return stat.filemode(mode)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def can_preview_text(path: Path) -> bool:
|
||||||
|
if path.is_dir():
|
||||||
|
return False
|
||||||
|
if path.stat().st_size > MAX_TEXT_PREVIEW_BYTES:
|
||||||
|
return False
|
||||||
|
mime, _ = mimetypes.guess_type(path.name)
|
||||||
|
if mime and (
|
||||||
|
mime.startswith("text/")
|
||||||
|
or mime in {"application/json", "application/xml", "application/javascript"}
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return path.suffix.lower() in TEXT_SUFFIXES
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def entry_payload(path: Path) -> dict:
|
||||||
|
st = path.lstat()
|
||||||
|
kind = "directory" if path.is_dir() else "file"
|
||||||
|
mime, _ = mimetypes.guess_type(path.name)
|
||||||
|
return {
|
||||||
|
"name": path.name,
|
||||||
|
"rel_path": rel_from_home(path),
|
||||||
|
"parent_rel_path": rel_from_home(path.parent),
|
||||||
|
"kind": kind,
|
||||||
|
"is_symlink": path.is_symlink(),
|
||||||
|
"size": st.st_size,
|
||||||
|
"modified": datetime.fromtimestamp(st.st_mtime).isoformat(timespec="seconds"),
|
||||||
|
"mime": mime or "application/octet-stream",
|
||||||
|
"perms": perms_string(st.st_mode),
|
||||||
|
"can_preview_text": can_preview_text(path) if path.is_file() else False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def sorted_entries(path: Path, show_hidden: bool = False) -> list[dict]:
|
||||||
|
try:
|
||||||
|
children = list(path.iterdir())
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise HTTPException(status_code=403, detail="Permission denied by operating system") from exc
|
||||||
|
filtered = []
|
||||||
|
for child in children:
|
||||||
|
if not show_hidden and child.name.startswith('.'):
|
||||||
|
continue
|
||||||
|
filtered.append(child)
|
||||||
|
filtered.sort(key=lambda p: (not p.is_dir(), p.name.lower()))
|
||||||
|
return [entry_payload(child) for child in filtered]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def move_to_trash(path: Path) -> Path:
|
||||||
|
TRASH_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
target = TRASH_DIR / path.name
|
||||||
|
if target.exists():
|
||||||
|
target = TRASH_DIR / f"{target.stem}-{int(time.time())}{target.suffix}"
|
||||||
|
shutil.move(str(path), str(target))
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def copy_entry(source: Path, destination_dir: Path) -> Path:
|
||||||
|
destination = destination_dir / source.name
|
||||||
|
if destination.exists():
|
||||||
|
raise HTTPException(status_code=409, detail=f"Destination already exists: {destination.name}")
|
||||||
|
if source.is_dir():
|
||||||
|
shutil.copytree(source, destination, symlinks=True)
|
||||||
|
else:
|
||||||
|
shutil.copy2(source, destination, follow_symlinks=False)
|
||||||
|
return destination
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def move_entry(source: Path, destination_dir: Path) -> Path:
|
||||||
|
destination = destination_dir / source.name
|
||||||
|
if destination.exists():
|
||||||
|
raise HTTPException(status_code=409, detail=f"Destination already exists: {destination.name}")
|
||||||
|
shutil.move(str(source), str(destination))
|
||||||
|
return destination
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def select_paths_or_current(paths: list[str], cwd: str) -> list[Path]:
|
||||||
|
result = [resolve_user_path(p) for p in paths]
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=400, detail="No paths selected")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_from_cwd(cwd_path: Path, raw: str, *, must_exist: bool = True) -> Path:
|
||||||
|
raw = (raw or "").strip()
|
||||||
|
candidate = (cwd_path / raw).resolve(strict=False)
|
||||||
|
candidate = ensure_within_home(candidate)
|
||||||
|
if must_exist and not candidate.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Path not found")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def run_command(command: str, cwd: str) -> dict:
|
||||||
|
command = (command or "").strip()
|
||||||
|
if not command:
|
||||||
|
raise HTTPException(status_code=400, detail="Empty command")
|
||||||
|
cwd_path = resolve_user_path(cwd)
|
||||||
|
if not cwd_path.is_dir():
|
||||||
|
raise HTTPException(status_code=400, detail="CWD is not a directory")
|
||||||
|
|
||||||
|
parts = command.split()
|
||||||
|
verb = parts[0].lower()
|
||||||
|
args = parts[1:]
|
||||||
|
|
||||||
|
if verb == "cd":
|
||||||
|
raw_target = " ".join(args) if args else ""
|
||||||
|
target = resolve_user_path(raw_target) if raw_target.startswith("/") else resolve_from_cwd(cwd_path, raw_target or ".")
|
||||||
|
if not target.is_dir():
|
||||||
|
raise HTTPException(status_code=400, detail="Target is not a directory")
|
||||||
|
return {"ok": True, "action": "cd", "cwd": rel_from_home(target), "message": str(target)}
|
||||||
|
|
||||||
|
if verb == "mkdir":
|
||||||
|
name = sanitize_name(" ".join(args))
|
||||||
|
target = resolve_from_cwd(cwd_path, name, must_exist=False)
|
||||||
|
target.mkdir(exist_ok=False)
|
||||||
|
return {"ok": True, "action": "mkdir", "cwd": rel_from_home(cwd_path), "message": f"Created {name}"}
|
||||||
|
|
||||||
|
if verb == "touch":
|
||||||
|
name = sanitize_name(" ".join(args))
|
||||||
|
target = resolve_from_cwd(cwd_path, name, must_exist=False)
|
||||||
|
target.touch(exist_ok=False)
|
||||||
|
return {"ok": True, "action": "touch", "cwd": rel_from_home(cwd_path), "message": f"Created {name}"}
|
||||||
|
|
||||||
|
if verb == "select":
|
||||||
|
pattern = " ".join(args).strip() or "*"
|
||||||
|
entries = sorted_entries(cwd_path, show_hidden=True)
|
||||||
|
matches = [e["rel_path"] for e in entries if fnmatch.fnmatch(e["name"], pattern)]
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"action": "select",
|
||||||
|
"cwd": rel_from_home(cwd_path),
|
||||||
|
"message": f"Matched {len(matches)} item(s)",
|
||||||
|
"matches": matches,
|
||||||
|
}
|
||||||
|
|
||||||
|
if verb == "help":
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"action": "help",
|
||||||
|
"cwd": rel_from_home(cwd_path),
|
||||||
|
"message": "Commands: cd <path>, mkdir <name>, touch <name>, select <glob>, help",
|
||||||
|
}
|
||||||
|
|
||||||
|
raise HTTPException(status_code=400, detail="Unsupported command")
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def harden_headers(request: Request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers["X-Frame-Options"] = "DENY"
|
||||||
|
response.headers["Content-Security-Policy"] = (
|
||||||
|
"default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; "
|
||||||
|
"connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
||||||
|
)
|
||||||
|
response.headers["Referrer-Policy"] = "no-referrer"
|
||||||
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> dict:
|
||||||
|
return {"ok": True, "app": APP_NAME, "time": _now_iso(), "home": str(HOME_ROOT)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
def index(request: Request):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"index.html",
|
||||||
|
{
|
||||||
|
"app_name": APP_NAME,
|
||||||
|
"home_root": str(HOME_ROOT),
|
||||||
|
"csrf_token": CSRF_TOKEN,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/list")
|
||||||
|
def api_list(path: str = "", show_hidden: bool = False) -> dict:
|
||||||
|
target = resolve_user_path(path)
|
||||||
|
if not target.is_dir():
|
||||||
|
raise HTTPException(status_code=400, detail="Path is not a directory")
|
||||||
|
return {
|
||||||
|
"cwd": rel_from_home(target),
|
||||||
|
"absolute": str(target),
|
||||||
|
"parent": "" if target == HOME_ROOT else rel_from_home(target.parent),
|
||||||
|
"entries": sorted_entries(target, show_hidden=show_hidden),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/read")
|
||||||
|
def api_read(path: str) -> dict:
|
||||||
|
target = resolve_user_path(path)
|
||||||
|
if target.is_dir():
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot read a directory as text")
|
||||||
|
if not can_preview_text(target):
|
||||||
|
raise HTTPException(status_code=415, detail="File is not previewable as text")
|
||||||
|
try:
|
||||||
|
content = target.read_text(encoding="utf-8")
|
||||||
|
encoding = "utf-8"
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
content = target.read_text(encoding="utf-8", errors="replace")
|
||||||
|
encoding = "utf-8 (lossy)"
|
||||||
|
return {
|
||||||
|
"path": rel_from_home(target),
|
||||||
|
"name": target.name,
|
||||||
|
"encoding": encoding,
|
||||||
|
"content": content,
|
||||||
|
"size": target.stat().st_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/meta")
|
||||||
|
def api_meta(path: str) -> dict:
|
||||||
|
target = resolve_user_path(path)
|
||||||
|
payload = entry_payload(target)
|
||||||
|
payload["absolute"] = str(target)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/download")
|
||||||
|
def api_download(path: str):
|
||||||
|
target = resolve_user_path(path)
|
||||||
|
if target.is_dir():
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot download a directory")
|
||||||
|
return FileResponse(path=target, filename=target.name)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/preview")
|
||||||
|
def api_preview(path: str):
|
||||||
|
target = resolve_user_path(path)
|
||||||
|
if target.is_dir():
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot preview a directory")
|
||||||
|
mime, _ = mimetypes.guess_type(target.name)
|
||||||
|
if not mime or not mime.startswith("image/"):
|
||||||
|
raise HTTPException(status_code=415, detail="Preview only supports images")
|
||||||
|
return FileResponse(path=target, media_type=mime)
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/write")
|
||||||
|
async def api_write(request: Request, path: str = Form(...), content: str = Form(...)) -> dict:
|
||||||
|
check_origin(request)
|
||||||
|
check_csrf(request)
|
||||||
|
target = resolve_user_path(path, must_exist=False)
|
||||||
|
ensure_within_home(target.parent.resolve(strict=False))
|
||||||
|
if target.exists() and target.is_dir():
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot overwrite a directory")
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = target.with_name(target.name + ".tmp-write")
|
||||||
|
tmp.write_text(content, encoding="utf-8")
|
||||||
|
os.replace(tmp, target)
|
||||||
|
return {"ok": True, "path": rel_from_home(target)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/mkdir")
|
||||||
|
async def api_mkdir(request: Request, path: str = Form(...), name: str = Form(...)) -> dict:
|
||||||
|
check_origin(request)
|
||||||
|
check_csrf(request)
|
||||||
|
base = resolve_user_path(path)
|
||||||
|
if not base.is_dir():
|
||||||
|
raise HTTPException(status_code=400, detail="Base path is not a directory")
|
||||||
|
child = resolve_user_path(str(Path(rel_from_home(base)) / sanitize_name(name)), must_exist=False)
|
||||||
|
child.mkdir(parents=False, exist_ok=False)
|
||||||
|
return {"ok": True, "path": rel_from_home(child)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/upload")
|
||||||
|
async def api_upload(request: Request, path: str = Form(...), files: list[UploadFile] = File(...)) -> dict:
|
||||||
|
check_origin(request)
|
||||||
|
check_csrf(request)
|
||||||
|
base = resolve_user_path(path)
|
||||||
|
if not base.is_dir():
|
||||||
|
raise HTTPException(status_code=400, detail="Upload target is not a directory")
|
||||||
|
saved: list[str] = []
|
||||||
|
for upload in files:
|
||||||
|
filename = Path(upload.filename or "").name
|
||||||
|
if not filename:
|
||||||
|
continue
|
||||||
|
destination = resolve_user_path(str(Path(rel_from_home(base)) / filename), must_exist=False)
|
||||||
|
with destination.open("wb") as f:
|
||||||
|
while chunk := await upload.read(1024 * 1024):
|
||||||
|
f.write(chunk)
|
||||||
|
saved.append(rel_from_home(destination))
|
||||||
|
return {"ok": True, "saved": saved}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/rename")
|
||||||
|
async def api_rename(request: Request, payload: RenamePayload) -> dict:
|
||||||
|
check_origin(request)
|
||||||
|
check_csrf(request)
|
||||||
|
source = resolve_user_path(payload.path)
|
||||||
|
destination = resolve_user_path(str(Path(rel_from_home(source.parent)) / sanitize_name(payload.new_name)), must_exist=False)
|
||||||
|
if destination.exists():
|
||||||
|
raise HTTPException(status_code=409, detail="Destination already exists")
|
||||||
|
os.replace(source, destination)
|
||||||
|
return {"ok": True, "old_path": rel_from_home(source), "new_path": rel_from_home(destination)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/copy")
|
||||||
|
async def api_copy(request: Request, payload: PathsPayload) -> dict:
|
||||||
|
check_origin(request)
|
||||||
|
check_csrf(request)
|
||||||
|
if payload.destination_dir is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing destination_dir")
|
||||||
|
destination_dir = resolve_user_path(payload.destination_dir)
|
||||||
|
if not destination_dir.is_dir():
|
||||||
|
raise HTTPException(status_code=400, detail="Destination is not a directory")
|
||||||
|
results = []
|
||||||
|
for source in select_paths_or_current(payload.paths, payload.destination_dir):
|
||||||
|
copied = copy_entry(source, destination_dir)
|
||||||
|
results.append(rel_from_home(copied))
|
||||||
|
return {"ok": True, "copied": results}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/move")
|
||||||
|
async def api_move(request: Request, payload: PathsPayload) -> dict:
|
||||||
|
check_origin(request)
|
||||||
|
check_csrf(request)
|
||||||
|
if payload.destination_dir is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing destination_dir")
|
||||||
|
destination_dir = resolve_user_path(payload.destination_dir)
|
||||||
|
if not destination_dir.is_dir():
|
||||||
|
raise HTTPException(status_code=400, detail="Destination is not a directory")
|
||||||
|
results = []
|
||||||
|
for source in select_paths_or_current(payload.paths, payload.destination_dir):
|
||||||
|
moved = move_entry(source, destination_dir)
|
||||||
|
results.append(rel_from_home(moved))
|
||||||
|
return {"ok": True, "moved": results}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/delete")
|
||||||
|
async def api_delete(request: Request, payload: DeletePayload) -> dict:
|
||||||
|
check_origin(request)
|
||||||
|
check_csrf(request)
|
||||||
|
paths = select_paths_or_current(payload.paths, "")
|
||||||
|
deleted = []
|
||||||
|
for target in paths:
|
||||||
|
if target == HOME_ROOT:
|
||||||
|
raise HTTPException(status_code=400, detail="Refusing to delete home root")
|
||||||
|
if payload.mode == "trash":
|
||||||
|
moved = move_to_trash(target)
|
||||||
|
deleted.append(str(moved))
|
||||||
|
else:
|
||||||
|
if target.is_dir():
|
||||||
|
shutil.rmtree(target)
|
||||||
|
else:
|
||||||
|
target.unlink()
|
||||||
|
deleted.append(rel_from_home(target))
|
||||||
|
return {"ok": True, "mode": payload.mode, "deleted": deleted}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/command")
|
||||||
|
async def api_command(request: Request, payload: CommandPayload) -> dict:
|
||||||
|
check_origin(request)
|
||||||
|
check_csrf(request)
|
||||||
|
return run_command(payload.command, payload.cwd)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(HTTPException)
|
||||||
|
async def http_exception_handler(_: Request, exc: HTTPException):
|
||||||
|
return JSONResponse(status_code=exc.status_code, content={"ok": False, "detail": exc.detail})
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def unhandled_exception_handler(_: Request, exc: Exception):
|
||||||
|
return JSONResponse(status_code=500, content={"ok": False, "detail": html.escape(str(exc))})
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi>=0.128.8,<1.0
|
||||||
|
uvicorn>=0.39,<1.0
|
||||||
|
jinja2>=3.1.6,<4.0
|
||||||
|
python-multipart>=0.0.22,<1.0
|
||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
if command -v python3.14 >/dev/null 2>&1; then
|
||||||
|
PYTHON_BIN=python3.14
|
||||||
|
else
|
||||||
|
PYTHON_BIN=python3
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Using Python: $($PYTHON_BIN --version 2>&1)"
|
||||||
|
|
||||||
|
TARGET_MM=$("$PYTHON_BIN" -c 'import sys; print(f"{sys.version_info[0]}.{sys.version_info[1]}")')
|
||||||
|
CURRENT_MM=""
|
||||||
|
if [ -x .venv/bin/python ]; then
|
||||||
|
CURRENT_MM=$(.venv/bin/python -c 'import sys; print(f"{sys.version_info[0]}.{sys.version_info[1]}")')
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d .venv ] || [ "$CURRENT_MM" != "$TARGET_MM" ]; then
|
||||||
|
rm -rf .venv
|
||||||
|
"$PYTHON_BIN" -m venv .venv
|
||||||
|
fi
|
||||||
|
|
||||||
|
source .venv/bin/activate
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install -r requirements.txt
|
||||||
|
exec python -m uvicorn app.main:app --host 127.0.0.1 --port 8765
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
# Remote Client Shares Implementation Phases V1.1
|
||||||
|
|
||||||
|
## Doel
|
||||||
|
|
||||||
|
Dit document splitst `REMOTE_CLIENT_SHARES_V1_DESIGN.md` op in pragmatische implementatiefases.
|
||||||
|
|
||||||
|
Uitgangspunten:
|
||||||
|
|
||||||
|
- geen overengineering
|
||||||
|
- elke fase moet zelfstandig waarde leveren
|
||||||
|
- WebManager mag nooit blokkeren op remote agents
|
||||||
|
- bestaande storage-functionaliteit moet intact blijven
|
||||||
|
- `/Clients` blijft een aparte source, geen uitbreiding van lokale filesystem roots
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overzicht
|
||||||
|
|
||||||
|
### Phase 1
|
||||||
|
|
||||||
|
Client registry, identiteit en statusmodel.
|
||||||
|
|
||||||
|
### Phase 2
|
||||||
|
|
||||||
|
Browse van remote client shares via virtuele `Clients` root.
|
||||||
|
|
||||||
|
### Phase 3
|
||||||
|
|
||||||
|
Info, tekstpreview, eenvoudige image preview en download voor remote shares.
|
||||||
|
|
||||||
|
### Later
|
||||||
|
|
||||||
|
Alle write-acties, bookmarks/startup paths en cross-source flows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Client Registry
|
||||||
|
|
||||||
|
## Doel
|
||||||
|
|
||||||
|
WebManager moet remote agents kennen, identificeren en hun status betrouwbaar kunnen bijhouden.
|
||||||
|
|
||||||
|
## Resultaat
|
||||||
|
|
||||||
|
De backend en UI kunnen een lijst van bekende clients tonen, inclusief stabiele identiteit en basisstatus.
|
||||||
|
|
||||||
|
## In scope
|
||||||
|
|
||||||
|
- remote client registratie
|
||||||
|
- heartbeat endpoint
|
||||||
|
- opslag van client metadata
|
||||||
|
- statusmodel met gescheiden velden
|
||||||
|
- lijstendpoint voor bekende clients
|
||||||
|
- registratie-auth
|
||||||
|
- agent-access-auth contract vastleggen
|
||||||
|
|
||||||
|
## Niet in scope
|
||||||
|
|
||||||
|
- browsen in shares
|
||||||
|
- file operations
|
||||||
|
- download
|
||||||
|
- rename/delete/mkdir
|
||||||
|
|
||||||
|
## Beslissingen
|
||||||
|
|
||||||
|
- `client_id` is leidend
|
||||||
|
- `display_name` is niet leidend
|
||||||
|
- browse-routing mag niet afhankelijk zijn van alleen displaynaam
|
||||||
|
- `last_seen`, `status`, `last_error` en `reachable_at` blijven logisch gescheiden
|
||||||
|
|
||||||
|
## Backendwerk
|
||||||
|
|
||||||
|
Nieuwe onderdelen:
|
||||||
|
|
||||||
|
- repository voor remote clients
|
||||||
|
- service voor registratie en heartbeat
|
||||||
|
- statusafleiding
|
||||||
|
- opslag van auth- en endpointmetadata
|
||||||
|
- routes:
|
||||||
|
- `POST /api/clients/register`
|
||||||
|
- `POST /api/clients/heartbeat`
|
||||||
|
- `GET /api/clients`
|
||||||
|
|
||||||
|
Waarschijnlijk te wijzigen:
|
||||||
|
|
||||||
|
- [main.py](/workspace/webmanager-mvp/webui/backend/app/main.py)
|
||||||
|
- [dependencies.py](/workspace/webmanager-mvp/webui/backend/app/dependencies.py)
|
||||||
|
|
||||||
|
Waarschijnlijk nieuw:
|
||||||
|
|
||||||
|
- `webui/backend/app/api/routes_clients.py`
|
||||||
|
- `webui/backend/app/services/remote_client_service.py`
|
||||||
|
- `webui/backend/app/db/remote_client_repository.py`
|
||||||
|
|
||||||
|
## Agentwerk
|
||||||
|
|
||||||
|
- vaste config inlezen
|
||||||
|
- `client_id` beheren
|
||||||
|
- registratie naar WebManager
|
||||||
|
- periodieke heartbeat
|
||||||
|
- agent-access-token config toevoegen
|
||||||
|
|
||||||
|
## UI-werk
|
||||||
|
|
||||||
|
Minimaal:
|
||||||
|
|
||||||
|
- geen browse-integratie nodig
|
||||||
|
- een eenvoudige clientlijst of debug-status is voldoende
|
||||||
|
|
||||||
|
## Acceptatiecriteria
|
||||||
|
|
||||||
|
- agent kan zich registreren
|
||||||
|
- client verschijnt in `GET /api/clients`
|
||||||
|
- `last_seen` wordt bijgewerkt
|
||||||
|
- `status` wordt afgeleid zonder te flappen
|
||||||
|
- `last_error` en `reachable_at` bestaan als apart concept
|
||||||
|
- server blijft normaal werken als er geen agents bestaan
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Browse via `/Clients`
|
||||||
|
|
||||||
|
## Doel
|
||||||
|
|
||||||
|
Remote clients en hun shares moeten zichtbaar worden in dezelfde browse-ervaring als server storage, zonder lokale services te vervormen.
|
||||||
|
|
||||||
|
## Resultaat
|
||||||
|
|
||||||
|
De gebruiker kan navigeren naar:
|
||||||
|
|
||||||
|
- `/Clients`
|
||||||
|
- `/Clients/<client>`
|
||||||
|
- `/Clients/<client>/<share>`
|
||||||
|
|
||||||
|
## In scope
|
||||||
|
|
||||||
|
- virtuele root `/Clients`
|
||||||
|
- clientlijst als directories
|
||||||
|
- sharelijst per client als directories
|
||||||
|
- browse binnen share
|
||||||
|
- offline foutafhandeling
|
||||||
|
- agent-auth op browsecalls
|
||||||
|
|
||||||
|
## Niet in scope
|
||||||
|
|
||||||
|
- view/download
|
||||||
|
- edit
|
||||||
|
- rename/delete/mkdir
|
||||||
|
- bookmarks/startup paths
|
||||||
|
|
||||||
|
## Beslissingen
|
||||||
|
|
||||||
|
- `/Clients` wordt vroeg in de backend-route afgehandeld
|
||||||
|
- remote paden mogen niet in gewone lokale `PathGuard` resolution terechtkomen
|
||||||
|
- lokale browse-services blijven verantwoordelijk voor alleen lokale server sources
|
||||||
|
|
||||||
|
## Backendwerk
|
||||||
|
|
||||||
|
Waarschijnlijk te wijzigen:
|
||||||
|
|
||||||
|
- [routes_browse.py](/workspace/webmanager-mvp/webui/backend/app/api/routes_browse.py)
|
||||||
|
|
||||||
|
Liever niet verbreden:
|
||||||
|
|
||||||
|
- [path_guard.py](/workspace/webmanager-mvp/webui/backend/app/security/path_guard.py)
|
||||||
|
|
||||||
|
Nieuwe onderdelen:
|
||||||
|
|
||||||
|
- browse-facade voor remote client paden
|
||||||
|
- agent HTTP client met korte timeouts en auth
|
||||||
|
|
||||||
|
## UI-werk
|
||||||
|
|
||||||
|
Waarschijnlijk te wijzigen:
|
||||||
|
|
||||||
|
- [app.js](/workspace/webmanager-mvp/webui/html/app.js)
|
||||||
|
|
||||||
|
Benodigd:
|
||||||
|
|
||||||
|
- rootnavigatie voor `/Clients`
|
||||||
|
- breadcrumbs voor client/share-paden
|
||||||
|
- render van client/status/share directories
|
||||||
|
- nette foutmelding bij offline client
|
||||||
|
|
||||||
|
## Agentwerk
|
||||||
|
|
||||||
|
Nieuwe browse endpoint(s):
|
||||||
|
|
||||||
|
- `GET /health`
|
||||||
|
- `GET /api/list?share=...&path=...`
|
||||||
|
|
||||||
|
## Acceptatiecriteria
|
||||||
|
|
||||||
|
- `/Clients` toont bekende clients
|
||||||
|
- `/Clients/<client>` toont alleen toegestane shares
|
||||||
|
- `/Clients/<client>/<share>` toont directory-inhoud
|
||||||
|
- offline client geeft een snelle fout, geen hang
|
||||||
|
- `/Volumes` gedrag blijft intact
|
||||||
|
- lokale browse-code blijft logisch gescheiden van remote browse-code
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Info, Preview, Download
|
||||||
|
|
||||||
|
## Doel
|
||||||
|
|
||||||
|
Remote shares moeten read-only bruikbaar worden voor dagelijkse taken.
|
||||||
|
|
||||||
|
## Resultaat
|
||||||
|
|
||||||
|
Gebruiker kan bestanden in remote shares inspecteren, bekijken en downloaden.
|
||||||
|
|
||||||
|
## In scope
|
||||||
|
|
||||||
|
- file info
|
||||||
|
- tekstpreview
|
||||||
|
- eenvoudige image preview
|
||||||
|
- download van remote bestanden
|
||||||
|
- expliciete resource-limieten
|
||||||
|
|
||||||
|
## Niet in scope
|
||||||
|
|
||||||
|
- edit
|
||||||
|
- rename/delete/mkdir
|
||||||
|
- upload
|
||||||
|
- cross-source copy/move
|
||||||
|
|
||||||
|
## Beslissingen
|
||||||
|
|
||||||
|
- tekstpreview krijgt een harde limiet
|
||||||
|
- text/binary-detectie moet expliciet zijn
|
||||||
|
- downloads worden gestreamd
|
||||||
|
- geen grote in-memory buffering voor download
|
||||||
|
|
||||||
|
## Backendwerk
|
||||||
|
|
||||||
|
Nieuwe facade of routes voor remote file actions:
|
||||||
|
|
||||||
|
- info
|
||||||
|
- read/view
|
||||||
|
- download
|
||||||
|
|
||||||
|
Belangrijk:
|
||||||
|
|
||||||
|
- backend vertaalt WebManager-pad naar agent-call
|
||||||
|
- timeouts en foutmapping blijven streng
|
||||||
|
- source-aware afhandeling blijft gescheiden van lokale file ops
|
||||||
|
|
||||||
|
Waarschijnlijk geraakt:
|
||||||
|
|
||||||
|
- `routes_files.py` of parallelle remote-fileroute
|
||||||
|
- aparte service-laag voor remote file proxying
|
||||||
|
|
||||||
|
## UI-werk
|
||||||
|
|
||||||
|
Waarschijnlijk te wijzigen:
|
||||||
|
|
||||||
|
- [app.js](/workspace/webmanager-mvp/webui/html/app.js)
|
||||||
|
|
||||||
|
Benodigd:
|
||||||
|
|
||||||
|
- source-aware afhandeling voor `View`
|
||||||
|
- downloadknop moet remote paths ondersteunen
|
||||||
|
- properties/info moet ook werken voor remote paden
|
||||||
|
|
||||||
|
## Agentwerk
|
||||||
|
|
||||||
|
Nieuwe endpoints:
|
||||||
|
|
||||||
|
- `GET /api/info`
|
||||||
|
- `GET /api/read`
|
||||||
|
- `GET /api/download`
|
||||||
|
|
||||||
|
## Acceptatiecriteria
|
||||||
|
|
||||||
|
- file info werkt voor remote paden
|
||||||
|
- tekstbestand kan bekeken worden binnen limieten
|
||||||
|
- afbeelding kan bekeken worden als ondersteund
|
||||||
|
- download van remote bestand werkt via streaming
|
||||||
|
- foutafhandeling blijft lokaal tot betreffende pane/actie
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Later
|
||||||
|
|
||||||
|
Deze onderdelen horen niet in V1.1.
|
||||||
|
|
||||||
|
### Write-acties
|
||||||
|
|
||||||
|
- mkdir
|
||||||
|
- rename
|
||||||
|
- delete
|
||||||
|
- upload
|
||||||
|
|
||||||
|
### UI-integraties
|
||||||
|
|
||||||
|
- bookmarks voor `/Clients/...`
|
||||||
|
- startup paths voor `/Clients/...`
|
||||||
|
|
||||||
|
### Cross-source flows
|
||||||
|
|
||||||
|
- `/Volumes/...` naar `/Clients/...`
|
||||||
|
- `/Clients/...` naar `/Volumes/...`
|
||||||
|
|
||||||
|
Dit vereist expliciete transfersemantiek en hoort niet in de eerste read-mostly release.
|
||||||
|
|
||||||
|
### Zwaardere netwerkmodellen
|
||||||
|
|
||||||
|
- reverse-connect
|
||||||
|
- tunnelmodel
|
||||||
|
- relay-infrastructuur
|
||||||
|
|
||||||
|
### Sterkere pairing
|
||||||
|
|
||||||
|
- pair codes
|
||||||
|
- per-agent secret rotation
|
||||||
|
- signed registration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Aanbevolen volgorde
|
||||||
|
|
||||||
|
1. Phase 1 volledig afronden.
|
||||||
|
2. Daarna Phase 2 volledig afronden.
|
||||||
|
3. Daarna Phase 3 read-only afronden.
|
||||||
|
4. Alles daarna alleen oppakken als een concrete productbehoefte dat rechtvaardigt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Beslisadvies
|
||||||
|
|
||||||
|
Als er snel waarde geleverd moet worden, is de beste minimale keten:
|
||||||
|
|
||||||
|
1. registry
|
||||||
|
2. browse
|
||||||
|
3. info/preview/download
|
||||||
|
|
||||||
|
Daarmee ontstaat een bruikbare remote client bron zonder write-complexiteit, contractbreuk in lokale services of half-afgewerkte transferlogica.
|
||||||
@@ -0,0 +1,697 @@
|
|||||||
|
# Remote Client Shares V1.1 Design
|
||||||
|
|
||||||
|
## Doel
|
||||||
|
|
||||||
|
Een gebruiker van WebManager moet naast de bestaande server-side storage-roots ook een beperkte set lokale mappen van zijn eigen client-Mac kunnen benaderen, zonder de hele homefolder bloot te geven.
|
||||||
|
|
||||||
|
Voorbeelden van toegestane client-shares:
|
||||||
|
|
||||||
|
- `Downloads`
|
||||||
|
- `Movies`
|
||||||
|
- `Pictures`
|
||||||
|
|
||||||
|
De oplossing moet:
|
||||||
|
|
||||||
|
- simpel blijven
|
||||||
|
- veilig blijven
|
||||||
|
- de bestaande storage-workflow niet breken
|
||||||
|
- WebManager niet laten vastlopen als een remote helper-agent offline is
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kernbeslissingen voor V1.1
|
||||||
|
|
||||||
|
Deze beslissingen liggen in V1.1 vast.
|
||||||
|
|
||||||
|
- Remote client shares worden niet opgenomen in `root_aliases`.
|
||||||
|
- `/Clients` wordt een aparte virtuele bron naast `/Volumes`.
|
||||||
|
- Remote paden lopen niet door de bestaande lokale filesystem-resolutie.
|
||||||
|
- `client_id` is intern de enige leidende identiteit.
|
||||||
|
- `display_name` is alleen voor UI-weergave.
|
||||||
|
- De agent werkt alleen met `share key + relatief pad`.
|
||||||
|
- Alle agent-calls vereisen authenticatie, niet alleen registratie.
|
||||||
|
- Offline agents mogen alleen hun eigen subtree beïnvloeden, nooit de rest van de app.
|
||||||
|
- V1 blijft read-mostly: registry, browse, info, preview, download.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Waarom niet als gewone root alias
|
||||||
|
|
||||||
|
De huidige backend gaat uit van server-side whitelisted filesystem roots.
|
||||||
|
|
||||||
|
Dat model werkt voor:
|
||||||
|
|
||||||
|
- `/Volumes/...`
|
||||||
|
- gemounte server storage
|
||||||
|
- container-side toegankelijke paden
|
||||||
|
|
||||||
|
Dat model werkt niet goed voor:
|
||||||
|
|
||||||
|
- de lokale schijf van de browsergebruiker
|
||||||
|
- een remote Mac die buiten de server draait
|
||||||
|
- clients die offline kunnen zijn
|
||||||
|
- clients die dynamische IP-adressen hebben
|
||||||
|
|
||||||
|
Daarom mogen remote client shares niet in hetzelfde model worden gestopt als `root_aliases`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope V1.1
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
|
||||||
|
- beperkte client-shares: `Downloads`, `Movies`, `Pictures`
|
||||||
|
- lokale helper-agent op macOS
|
||||||
|
- agent registratie in WebManager
|
||||||
|
- heartbeat/status tracking
|
||||||
|
- virtuele `Clients` bron in de WebUI
|
||||||
|
- browse van remote shares
|
||||||
|
- bestand-info
|
||||||
|
- tekstpreview
|
||||||
|
- image preview waar triviaal
|
||||||
|
- download van bestanden
|
||||||
|
- nette offline-afhandeling
|
||||||
|
|
||||||
|
### Expliciet niet in V1.1
|
||||||
|
|
||||||
|
- hele homefolder
|
||||||
|
- willekeurige custom paths buiten de toegestane sharelijst
|
||||||
|
- shell/subprocess execution
|
||||||
|
- rename
|
||||||
|
- mkdir
|
||||||
|
- delete
|
||||||
|
- upload naar remote share
|
||||||
|
- bookmarks voor `/Clients/...`
|
||||||
|
- startup paths voor `/Clients/...`
|
||||||
|
- cross-source copy of move
|
||||||
|
- complete taakrunner-integratie zoals server copy/move tasks
|
||||||
|
- automatische LAN discovery
|
||||||
|
- multi-user auth met OS user mapping
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gewenste gebruikerservaring
|
||||||
|
|
||||||
|
In de WebUI komt naast server-storage een extra virtuele bron:
|
||||||
|
|
||||||
|
- `/Volumes`
|
||||||
|
- `/Clients`
|
||||||
|
|
||||||
|
Onder `/Clients` ziet de gebruiker geregistreerde clients, bijvoorbeeld:
|
||||||
|
|
||||||
|
- `MacBook Pro van Jan`
|
||||||
|
- `iMac Woonkamer`
|
||||||
|
|
||||||
|
Onder een client ziet de gebruiker alleen de toegestane shares:
|
||||||
|
|
||||||
|
- `Downloads`
|
||||||
|
- `Movies`
|
||||||
|
- `Pictures`
|
||||||
|
|
||||||
|
Voor de gebruiker kan dat eruitzien als:
|
||||||
|
|
||||||
|
- `/Clients/MacBook-Pro-van-Jan/Downloads`
|
||||||
|
- `/Clients/MacBook-Pro-van-Jan/Movies`
|
||||||
|
- `/Clients/MacBook-Pro-van-Jan/Pictures`
|
||||||
|
|
||||||
|
Maar intern mag routing niet op `display_name` leunen.
|
||||||
|
|
||||||
|
Intern moet WebManager werken met een stabiele client-identiteit en een mappinglaag:
|
||||||
|
|
||||||
|
- `client_id` voor routing en opslag
|
||||||
|
- `display_name` voor weergave
|
||||||
|
- optioneel een afgeleide slug voor browse-url-presentatie
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architectuuroverzicht
|
||||||
|
|
||||||
|
Er zijn drie componenten.
|
||||||
|
|
||||||
|
### 1. WebManager backend
|
||||||
|
|
||||||
|
Verantwoordelijk voor:
|
||||||
|
|
||||||
|
- registry van bekende remote clients
|
||||||
|
- status- en heartbeat-tracking
|
||||||
|
- virtuele browse-root `Clients`
|
||||||
|
- proxying van requests naar agents
|
||||||
|
- timeouts en foutafhandeling
|
||||||
|
- scheiding tussen local-source en remote-source afhandeling
|
||||||
|
|
||||||
|
### 2. WebUI frontend
|
||||||
|
|
||||||
|
Verantwoordelijk voor:
|
||||||
|
|
||||||
|
- tonen van `Clients` als extra bron
|
||||||
|
- navigeren binnen client/share paden
|
||||||
|
- offline status tonen
|
||||||
|
- requests afvuren naar gewone WebManager backend-routes
|
||||||
|
|
||||||
|
### 3. Remote helper-agent op macOS
|
||||||
|
|
||||||
|
Verantwoordelijk voor:
|
||||||
|
|
||||||
|
- toegang tot vaste lokale shares
|
||||||
|
- strikte padvalidatie binnen die shares
|
||||||
|
- simpele browse/info/read/download endpoints
|
||||||
|
- zichzelf registreren bij WebManager
|
||||||
|
- heartbeat sturen
|
||||||
|
- auth afdwingen op alle agent-endpoints
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bereikbaarheidsmodel
|
||||||
|
|
||||||
|
Dit is de eerste harde productbeslissing.
|
||||||
|
|
||||||
|
### V1.1-keuze
|
||||||
|
|
||||||
|
V1.1 gaat uit van een omgeving waarin WebManager de agent rechtstreeks kan bereiken.
|
||||||
|
|
||||||
|
Dat betekent praktisch:
|
||||||
|
|
||||||
|
- dezelfde LAN
|
||||||
|
- of een expliciet configureerbaar agent-endpoint
|
||||||
|
- of een deployment waar server en client netwerkmatig direct verbonden zijn
|
||||||
|
|
||||||
|
### Waarom deze keuze
|
||||||
|
|
||||||
|
Dit is het simpelste model dat functioneel klopt zonder reverse tunnels, websockets als transportlaag, of extra relay-infrastructuur.
|
||||||
|
|
||||||
|
### Wat V1.1 niet probeert op te lossen
|
||||||
|
|
||||||
|
Deze versie garandeert niet dat een agent achter willekeurige NAT/firewall altijd bereikbaar is.
|
||||||
|
|
||||||
|
Dus:
|
||||||
|
|
||||||
|
- self-registration blijft het discoverymodel
|
||||||
|
- direct bereikbare agent-endpoint blijft het V1-transportmodel
|
||||||
|
- reverse-connect of tunnelmodellen zijn uitgesteld
|
||||||
|
|
||||||
|
### Fallback
|
||||||
|
|
||||||
|
Een handmatige endpoint override blijft toegestaan als operationele fallback, bijvoorbeeld:
|
||||||
|
|
||||||
|
- `http://192.168.1.25:8765`
|
||||||
|
|
||||||
|
Maar dat is geen hoofdmodel en geen productbelofte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hoe de remote agent bekend wordt in WebManager
|
||||||
|
|
||||||
|
### Gekozen model: agent registreert zichzelf
|
||||||
|
|
||||||
|
De agent meldt zichzelf actief aan bij WebManager. Niet andersom.
|
||||||
|
|
||||||
|
Dat betekent:
|
||||||
|
|
||||||
|
- geen handmatig client-IP nodig als hoofdmodel
|
||||||
|
- geen server-naar-client discovery nodig
|
||||||
|
- geen afhankelijkheid van LAN-broadcasting
|
||||||
|
- geen probleem als het client-IP wisselt, zolang het geregistreerde endpoint actueel is
|
||||||
|
|
||||||
|
### Registratiestroom
|
||||||
|
|
||||||
|
Bij starten van de agent:
|
||||||
|
|
||||||
|
1. de agent leest lokale config
|
||||||
|
2. de agent bepaalt:
|
||||||
|
- `client_id`
|
||||||
|
- `display_name`
|
||||||
|
- `shares`
|
||||||
|
- `endpoint`
|
||||||
|
3. de agent registreert zich bij WebManager
|
||||||
|
4. WebManager slaat client-record op of werkt het bij
|
||||||
|
5. de agent stuurt periodieke heartbeats
|
||||||
|
|
||||||
|
### Benodigde velden bij registratie
|
||||||
|
|
||||||
|
Voorstel:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"client_id": "f4b2c8f8-2b1b-4d89-9ed2-8d6d7b1f3abc",
|
||||||
|
"display_name": "MacBook Pro van Jan",
|
||||||
|
"platform": "macos",
|
||||||
|
"agent_version": "1.1.0",
|
||||||
|
"endpoint": "http://192.168.1.25:8765",
|
||||||
|
"shares": [
|
||||||
|
{ "key": "downloads", "label": "Downloads" },
|
||||||
|
{ "key": "movies", "label": "Movies" },
|
||||||
|
{ "key": "pictures", "label": "Pictures" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend bewaart per client
|
||||||
|
|
||||||
|
- `client_id`
|
||||||
|
- `display_name`
|
||||||
|
- `platform`
|
||||||
|
- `agent_version`
|
||||||
|
- `endpoint`
|
||||||
|
- `shares`
|
||||||
|
- `last_seen`
|
||||||
|
- `status`
|
||||||
|
- `last_error`
|
||||||
|
- `reachable_at`
|
||||||
|
- eventueel `registration_token_id`
|
||||||
|
|
||||||
|
### Heartbeat
|
||||||
|
|
||||||
|
De agent stuurt elke 15-30 seconden een heartbeat.
|
||||||
|
|
||||||
|
Bijvoorbeeld:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"client_id": "f4b2c8f8-2b1b-4d89-9ed2-8d6d7b1f3abc",
|
||||||
|
"agent_version": "1.1.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Statusmodel
|
||||||
|
|
||||||
|
Deze velden moeten logisch gescheiden blijven:
|
||||||
|
|
||||||
|
- `last_seen`
|
||||||
|
Laatste succesvolle heartbeat van de agent.
|
||||||
|
- `status`
|
||||||
|
Afgeleide UI-status, bijvoorbeeld `online` of `offline`.
|
||||||
|
- `last_error`
|
||||||
|
Laatste connect- of browsefout richting agent.
|
||||||
|
- `reachable_at`
|
||||||
|
Laatste moment waarop een directe agent-call echt succesvol was.
|
||||||
|
|
||||||
|
Belangrijk:
|
||||||
|
|
||||||
|
- een heartbeat bepaalt niet automatisch dat elke browse-call werkt
|
||||||
|
- een enkele browse-timeout mag niet blind `last_seen` overschrijven
|
||||||
|
- status mag niet gaan flappen op basis van één los incident
|
||||||
|
|
||||||
|
### Aanbevolen statusregels
|
||||||
|
|
||||||
|
- `online` als `last_seen` recent is
|
||||||
|
- `offline` als heartbeat-timeout overschreden is
|
||||||
|
- extra foutdetails via `last_error`
|
||||||
|
- optioneel UI-label zoals `online with recent errors` later, maar niet nodig in V1.1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authenticatie en beveiliging
|
||||||
|
|
||||||
|
### Backend registratie-auth
|
||||||
|
|
||||||
|
Registratie vereist een bearer token.
|
||||||
|
|
||||||
|
Bijvoorbeeld:
|
||||||
|
|
||||||
|
- `Authorization: Bearer <registration-token>`
|
||||||
|
|
||||||
|
### Agent endpoint-auth
|
||||||
|
|
||||||
|
Alle agent-calls vereisen authenticatie. Niet alleen registratie.
|
||||||
|
|
||||||
|
Dus ook:
|
||||||
|
|
||||||
|
- `/health`
|
||||||
|
- `/api/list`
|
||||||
|
- `/api/info`
|
||||||
|
- `/api/read`
|
||||||
|
- `/api/download`
|
||||||
|
|
||||||
|
moeten beschermd zijn.
|
||||||
|
|
||||||
|
### V1.1 minimum
|
||||||
|
|
||||||
|
Voor V1.1 volstaat een eenvoudige gedeelde agent-token, bijvoorbeeld:
|
||||||
|
|
||||||
|
- WebManager bewaart een secret per client of per installatie
|
||||||
|
- backend stuurt dat token mee op elke agent-call
|
||||||
|
- agent weigert requests zonder geldig token
|
||||||
|
|
||||||
|
Voorbeeld:
|
||||||
|
|
||||||
|
- `Authorization: Bearer <agent-access-token>`
|
||||||
|
|
||||||
|
### Niet doen in V1.1
|
||||||
|
|
||||||
|
- open agent-HTTP API zonder auth
|
||||||
|
- browse/download endpoints publiek bereikbaar maken op het LAN
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Virtueel padmodel
|
||||||
|
|
||||||
|
Remote client shares krijgen een aparte namespace.
|
||||||
|
|
||||||
|
Voorstel voor de gebruikersweergave:
|
||||||
|
|
||||||
|
- `/Clients`
|
||||||
|
- `/Clients/<client-display>`
|
||||||
|
- `/Clients/<client-display>/<share-label>`
|
||||||
|
- `/Clients/<client-display>/<share-label>/subdir/file.ext`
|
||||||
|
|
||||||
|
Intern moet de backend dit mappen naar:
|
||||||
|
|
||||||
|
- `client_id`
|
||||||
|
- `share_key`
|
||||||
|
- relatief share-pad
|
||||||
|
|
||||||
|
Belangrijk:
|
||||||
|
|
||||||
|
- dit zijn logische WebManager-paden
|
||||||
|
- het zijn geen echte lokale backend filesystem-paden
|
||||||
|
- ze mogen niet door de bestaande lokale `PathGuard` resolved worden
|
||||||
|
|
||||||
|
### Consequentie voor de codebasis
|
||||||
|
|
||||||
|
`/Clients/...` moet vroeg in routing worden onderschept door een aparte browse- of source-facade.
|
||||||
|
|
||||||
|
Dus:
|
||||||
|
|
||||||
|
- niet de lokale `PathGuard` uitbreiden tot remote sources
|
||||||
|
- niet overal `if remote` in bestaande lokale services strooien
|
||||||
|
- wel een duidelijke scheiding tussen local source en remote source
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Share-validatie in de agent
|
||||||
|
|
||||||
|
De agent werkt niet met vrije absolute paden.
|
||||||
|
|
||||||
|
De agent heeft een vaste share-map, bijvoorbeeld:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"downloads": "/Users/jan/Downloads",
|
||||||
|
"movies": "/Users/jan/Movies",
|
||||||
|
"pictures": "/Users/jan/Pictures"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Een request bevat dan:
|
||||||
|
|
||||||
|
- `share = downloads`
|
||||||
|
- `path = Some/Subdir/file.txt`
|
||||||
|
|
||||||
|
Niet:
|
||||||
|
|
||||||
|
- `/Users/jan/...`
|
||||||
|
|
||||||
|
### Validatieregels
|
||||||
|
|
||||||
|
- onbekende `share` weigeren
|
||||||
|
- `..` weigeren
|
||||||
|
- pad resolven binnen de gekozen share-root
|
||||||
|
- symlink escape blokkeren
|
||||||
|
- alleen toegestane bestandshandelingen toestaan
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Read, preview en download limieten
|
||||||
|
|
||||||
|
V1.1 moet resource-grenzen expliciet vastleggen.
|
||||||
|
|
||||||
|
### Tekstpreview
|
||||||
|
|
||||||
|
- maximum grootte voor tekstpreview vastleggen
|
||||||
|
- voorstel: zelfde orde als huidige server-side preview/edit-limieten, of kleiner
|
||||||
|
- grote tekstbestanden niet volledig in memory laden voor preview
|
||||||
|
|
||||||
|
### Binary versus text
|
||||||
|
|
||||||
|
- agent moet tekstpreview alleen teruggeven voor ondersteunde teksttypes
|
||||||
|
- binaire content mag niet per ongeluk als tekst in JSON-responses worden gepusht
|
||||||
|
|
||||||
|
### Download
|
||||||
|
|
||||||
|
- downloads moeten gestreamd worden
|
||||||
|
- geen volledige bestand-buffering in memory
|
||||||
|
|
||||||
|
### Image preview
|
||||||
|
|
||||||
|
- alleen triviale image preview in V1.1
|
||||||
|
- geen zware thumbnail-pipeline in deze fase
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Offline gedrag
|
||||||
|
|
||||||
|
Dit is een harde eis.
|
||||||
|
|
||||||
|
WebManager mag niet vastlopen als de agent niet draait.
|
||||||
|
|
||||||
|
### Backendregels
|
||||||
|
|
||||||
|
- alle agent-calls krijgen korte timeouts, bijvoorbeeld 1-3 seconden
|
||||||
|
- connect- of timeoutfouten worden vertaald naar nette app-fouten
|
||||||
|
- offline agent blokkeert nooit globale pagina-initialisatie
|
||||||
|
- browse- en file-fouten blijven lokaal tot betreffende request
|
||||||
|
|
||||||
|
### Frontendregels
|
||||||
|
|
||||||
|
- `/Clients` mag laden, ook als sommige clients offline zijn
|
||||||
|
- offline clients mogen zichtbaar blijven in de lijst
|
||||||
|
- browsen in offline subtree toont foutmelding
|
||||||
|
- andere panes blijven bruikbaar
|
||||||
|
- geen endless spinner
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API-ontwerp
|
||||||
|
|
||||||
|
## 1. Backend registry endpoints
|
||||||
|
|
||||||
|
### `POST /api/clients/register`
|
||||||
|
|
||||||
|
Registreert of update een remote agent.
|
||||||
|
|
||||||
|
### `POST /api/clients/heartbeat`
|
||||||
|
|
||||||
|
Werkt `last_seen` bij.
|
||||||
|
|
||||||
|
### `GET /api/clients`
|
||||||
|
|
||||||
|
Geeft bekende clients terug met:
|
||||||
|
|
||||||
|
- `client_id`
|
||||||
|
- `display_name`
|
||||||
|
- `status`
|
||||||
|
- `last_seen`
|
||||||
|
- `last_error`
|
||||||
|
- `shares`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Backend browse facade voor UI
|
||||||
|
|
||||||
|
De frontend blijft praten met gewone WebManager-routes.
|
||||||
|
|
||||||
|
### `GET /api/browse?path=/Clients`
|
||||||
|
|
||||||
|
Geeft alle bekende clients terug als directories.
|
||||||
|
|
||||||
|
### `GET /api/browse?path=/Clients/<client>/`
|
||||||
|
|
||||||
|
Geeft shares van die client terug als directories.
|
||||||
|
|
||||||
|
### `GET /api/browse?path=/Clients/<client>/<share>/...`
|
||||||
|
|
||||||
|
Backend vertaalt dit naar een agent-call.
|
||||||
|
|
||||||
|
Belangrijk:
|
||||||
|
|
||||||
|
- browse facade bepaalt eerst of pad onder `/Clients` valt
|
||||||
|
- alleen niet-remote paden mogen daarna naar bestaande lokale browse-paths
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Agent endpoints
|
||||||
|
|
||||||
|
Eenvoudig houden. Geen shell.
|
||||||
|
|
||||||
|
### `GET /health`
|
||||||
|
|
||||||
|
Gezondheidscheck met auth.
|
||||||
|
|
||||||
|
### `GET /api/list?share=downloads&path=subdir`
|
||||||
|
|
||||||
|
Directory-inhoud binnen een share.
|
||||||
|
|
||||||
|
### `GET /api/info?share=downloads&path=file.txt`
|
||||||
|
|
||||||
|
Metadata.
|
||||||
|
|
||||||
|
### `GET /api/read?share=downloads&path=file.txt`
|
||||||
|
|
||||||
|
Tekstpreview.
|
||||||
|
|
||||||
|
### `GET /api/download?share=downloads&path=file.txt`
|
||||||
|
|
||||||
|
Gestreamde download.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Haalbaarheid
|
||||||
|
|
||||||
|
## Goed haalbaar in V1.1
|
||||||
|
|
||||||
|
- client registry
|
||||||
|
- heartbeat online/offline
|
||||||
|
- virtuele `Clients` root
|
||||||
|
- browse
|
||||||
|
- file info
|
||||||
|
- tekstpreview
|
||||||
|
- eenvoudige image preview
|
||||||
|
- gestreamde download
|
||||||
|
|
||||||
|
## Bewust uitgesteld
|
||||||
|
|
||||||
|
- rename
|
||||||
|
- mkdir
|
||||||
|
- delete
|
||||||
|
- upload
|
||||||
|
- bookmarks/startup paths
|
||||||
|
- cross-source copy
|
||||||
|
- cross-source move
|
||||||
|
- unified history
|
||||||
|
- task-runner integratie
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Veranderingen per gebied
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
Nieuwe onderdelen:
|
||||||
|
|
||||||
|
- client registry repository
|
||||||
|
- client registry service
|
||||||
|
- routes voor register/heartbeat/list
|
||||||
|
- browse/source facade voor `Clients/...`
|
||||||
|
- agent HTTP client met harde timeouts en auth
|
||||||
|
|
||||||
|
Bestaande onderdelen die waarschijnlijk geraakt worden:
|
||||||
|
|
||||||
|
- [routes_browse.py](/workspace/webmanager-mvp/webui/backend/app/api/routes_browse.py)
|
||||||
|
Om `/Clients` vroeg te routeren.
|
||||||
|
- [dependencies.py](/workspace/webmanager-mvp/webui/backend/app/dependencies.py)
|
||||||
|
Voor nieuwe registry- en agent-services.
|
||||||
|
- [app/main.py](/workspace/webmanager-mvp/webui/backend/app/main.py)
|
||||||
|
Voor nieuwe routers.
|
||||||
|
|
||||||
|
Liever niet verbreden:
|
||||||
|
|
||||||
|
- [path_guard.py](/workspace/webmanager-mvp/webui/backend/app/security/path_guard.py)
|
||||||
|
Deze hoort lokaal filesystemgericht te blijven.
|
||||||
|
- [file_ops_service.py](/workspace/webmanager-mvp/webui/backend/app/services/file_ops_service.py)
|
||||||
|
Deze service is nu server-filesystemgericht en moet niet vervuild raken met remote transportlogica.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
Waarschijnlijk aanpassen:
|
||||||
|
|
||||||
|
- [app.js](/workspace/webmanager-mvp/webui/html/app.js)
|
||||||
|
Voor:
|
||||||
|
- extra virtuele root
|
||||||
|
- render van clients en shares
|
||||||
|
- offline status
|
||||||
|
- source-aware browse/view/download/info flows
|
||||||
|
- [index.html](/workspace/webmanager-mvp/webui/html/index.html)
|
||||||
|
Alleen als extra statuslabels of clientindicatoren nodig zijn
|
||||||
|
|
||||||
|
## Remote agent
|
||||||
|
|
||||||
|
Te baseren op:
|
||||||
|
|
||||||
|
- [finder_commander/app/main.py](/workspace/webmanager-mvp/finder_commander/app/main.py)
|
||||||
|
- [finder_commander/run-local.sh](/workspace/webmanager-mvp/finder_commander/run-local.sh)
|
||||||
|
- [finder_commander/requirements.txt](/workspace/webmanager-mvp/finder_commander/requirements.txt)
|
||||||
|
|
||||||
|
Maar vereenvoudigd:
|
||||||
|
|
||||||
|
- geen shell command endpoint
|
||||||
|
- geen hele home-root
|
||||||
|
- alleen `share key + relatief pad`
|
||||||
|
- registratie en heartbeat toevoegen
|
||||||
|
- auth afdwingen op alle endpoints
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Minimale agent-config
|
||||||
|
|
||||||
|
Voorstel lokaal configbestand:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"webmanager_base_url": "https://webmanager.example.com",
|
||||||
|
"registration_token": "registration-secret",
|
||||||
|
"agent_access_token": "agent-secret",
|
||||||
|
"client_id": "f4b2c8f8-2b1b-4d89-9ed2-8d6d7b1f3abc",
|
||||||
|
"display_name": "MacBook Pro van Jan",
|
||||||
|
"shares": {
|
||||||
|
"downloads": "/Users/jan/Downloads",
|
||||||
|
"movies": "/Users/jan/Movies",
|
||||||
|
"pictures": "/Users/jan/Pictures"
|
||||||
|
},
|
||||||
|
"listen_host": "0.0.0.0",
|
||||||
|
"listen_port": 8765,
|
||||||
|
"public_endpoint": "http://192.168.1.25:8765"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Opmerking:
|
||||||
|
|
||||||
|
- `public_endpoint` is het endpoint dat WebManager gebruikt
|
||||||
|
- `listen_host` en `public_endpoint` hoeven niet identiek te zijn
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open keuzes die bewust zijn uitgesteld
|
||||||
|
|
||||||
|
Deze keuzes zijn echt later werk, niet meer V1.1:
|
||||||
|
|
||||||
|
- reverse-connect of tunnelmodel
|
||||||
|
- cross-source copy
|
||||||
|
- cross-source move
|
||||||
|
- bookmarks/startup paths voor `/Clients/...`
|
||||||
|
- write-acties op remote shares
|
||||||
|
- sterkere pairing of key rotation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Beslisadvies
|
||||||
|
|
||||||
|
Aanbevolen implementatievolgorde voor V1.1:
|
||||||
|
|
||||||
|
1. agent registry + heartbeat
|
||||||
|
2. virtuele `Clients` root in browse
|
||||||
|
3. online/offline status met gescheiden statusvelden
|
||||||
|
4. browse/info/preview/download voor remote shares
|
||||||
|
|
||||||
|
Niet in V1.1:
|
||||||
|
|
||||||
|
5. write-acties
|
||||||
|
6. bookmarks/startup paths
|
||||||
|
7. cross-source flows
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Samenvatting
|
||||||
|
|
||||||
|
De juiste V1.1-richting is:
|
||||||
|
|
||||||
|
- geen hele homefolder
|
||||||
|
- wel beperkte shares zoals `Downloads`, `Movies`, `Pictures`
|
||||||
|
- remote helper-agent op macOS
|
||||||
|
- agent registreert zichzelf bij WebManager
|
||||||
|
- WebManager bewaart `client_id`-geleide registry en status
|
||||||
|
- `/Clients` wordt een aparte virtuele bron
|
||||||
|
- remote paden blijven buiten lokale filesystem services
|
||||||
|
- alle agent-calls vereisen auth
|
||||||
|
- offline agents mogen nooit de rest van WebManager verstoren
|
||||||
|
|
||||||
|
Dit model is haalbaar, beperkt in scope, en houdt de bestaande lokale storage-architectuur schoon.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -259,13 +259,23 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
self._extract_js_function(app_js, "formatTaskLine"),
|
self._extract_js_function(app_js, "formatTaskLine"),
|
||||||
self._extract_js_function(app_js, "isActiveTask"),
|
self._extract_js_function(app_js, "isActiveTask"),
|
||||||
self._extract_js_function(app_js, "activeTasksFromItems"),
|
self._extract_js_function(app_js, "activeTasksFromItems"),
|
||||||
|
self._extract_js_function(app_js, "isTerminalOperationTask"),
|
||||||
|
self._extract_js_function(app_js, "statusBadgeLabel"),
|
||||||
|
self._extract_js_function(app_js, "terminalOperationChipLabel"),
|
||||||
|
self._extract_js_function(app_js, "visibleOperationSortKey"),
|
||||||
|
self._extract_js_function(app_js, "sortVisibleOperations"),
|
||||||
self._extract_js_function(app_js, "taskIsCancellable"),
|
self._extract_js_function(app_js, "taskIsCancellable"),
|
||||||
self._extract_js_function(app_js, "cancelTaskRequest"),
|
self._extract_js_function(app_js, "cancelTaskRequest"),
|
||||||
self._extract_js_function(app_js, "formatTaskOperationLabel"),
|
self._extract_js_function(app_js, "formatTaskOperationLabel"),
|
||||||
|
self._extract_js_function(app_js, "statusBadgeLabel"),
|
||||||
self._extract_js_function(app_js, "hasMeaningfulItemProgress"),
|
self._extract_js_function(app_js, "hasMeaningfulItemProgress"),
|
||||||
self._extract_js_function(app_js, "canShowChipItemProgress"),
|
self._extract_js_function(app_js, "canShowChipItemProgress"),
|
||||||
self._extract_js_function(app_js, "compactTaskCurrentItem"),
|
self._extract_js_function(app_js, "compactTaskCurrentItem"),
|
||||||
self._extract_js_function(app_js, "activeTaskChipLabel"),
|
self._extract_js_function(app_js, "activeTaskChipLabel"),
|
||||||
|
self._extract_js_function(app_js, "isTerminalOperationTask"),
|
||||||
|
self._extract_js_function(app_js, "terminalOperationChipLabel"),
|
||||||
|
self._extract_js_function(app_js, "visibleOperationSortKey"),
|
||||||
|
self._extract_js_function(app_js, "sortVisibleOperations"),
|
||||||
self._extract_js_function(app_js, "taskProgressText"),
|
self._extract_js_function(app_js, "taskProgressText"),
|
||||||
self._extract_js_function(app_js, "taskProgressSubtext"),
|
self._extract_js_function(app_js, "taskProgressSubtext"),
|
||||||
self._extract_js_function(app_js, "headerTaskRenderKey"),
|
self._extract_js_function(app_js, "headerTaskRenderKey"),
|
||||||
@@ -360,16 +370,23 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
}}
|
}}
|
||||||
|
|
||||||
async function refreshTasksSnapshot() {{}}
|
async function refreshTasksSnapshot() {{}}
|
||||||
|
async function loadBrowsePane() {{}}
|
||||||
function setError() {{}}
|
function setError() {{}}
|
||||||
|
|
||||||
let headerTaskState = {{
|
let headerTaskState = {{
|
||||||
activeItems: [],
|
activeItems: [],
|
||||||
|
visibleItems: [],
|
||||||
|
recentItems: [],
|
||||||
popoverOpen: false,
|
popoverOpen: false,
|
||||||
pollTimer: null,
|
pollTimer: null,
|
||||||
lastRenderKey: "",
|
lastRenderKey: "",
|
||||||
|
knownStatuses: {{}},
|
||||||
|
recentExpiryMs: 4000,
|
||||||
|
paneRefreshPromise: null,
|
||||||
}};
|
}};
|
||||||
const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]);
|
const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]);
|
||||||
const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]);
|
const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]);
|
||||||
|
const TERMINAL_OPERATION_STATUSES = new Set(["completed", "cancelled", "failed"]);
|
||||||
|
|
||||||
{functions}
|
{functions}
|
||||||
|
|
||||||
@@ -412,8 +429,10 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
const deleteRow = elements["header-task-popover-list"].children[3];
|
const deleteRow = elements["header-task-popover-list"].children[3];
|
||||||
const deleteProgress = deleteRow.children[3];
|
const deleteProgress = deleteRow.children[3];
|
||||||
const deleteCurrent = deleteRow.children[4];
|
const deleteCurrent = deleteRow.children[4];
|
||||||
|
const deleteBadge = deleteRow.children[0].children[1];
|
||||||
assert(deleteProgress.textContent === "2/5", "Delete operations should show done/total progress when available");
|
assert(deleteProgress.textContent === "2/5", "Delete operations should show done/total progress when available");
|
||||||
assert(deleteCurrent.textContent === "folder/delete-me.txt", "Delete operations should show compact current item");
|
assert(deleteCurrent.textContent === "folder/delete-me.txt", "Delete operations should show compact current item");
|
||||||
|
assert(deleteBadge.textContent === "Running", "Delete operations should expose a readable status badge");
|
||||||
const cancellingRow = elements["header-task-popover-list"].children[4];
|
const cancellingRow = elements["header-task-popover-list"].children[4];
|
||||||
const cancellingProgress = cancellingRow.children[3];
|
const cancellingProgress = cancellingRow.children[3];
|
||||||
const cancellingCurrent = cancellingRow.children[4];
|
const cancellingCurrent = cancellingRow.children[4];
|
||||||
@@ -485,10 +504,15 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
self._extract_js_function(app_js, "taskIsCancellable"),
|
self._extract_js_function(app_js, "taskIsCancellable"),
|
||||||
self._extract_js_function(app_js, "cancelTaskRequest"),
|
self._extract_js_function(app_js, "cancelTaskRequest"),
|
||||||
self._extract_js_function(app_js, "formatTaskOperationLabel"),
|
self._extract_js_function(app_js, "formatTaskOperationLabel"),
|
||||||
|
self._extract_js_function(app_js, "statusBadgeLabel"),
|
||||||
self._extract_js_function(app_js, "hasMeaningfulItemProgress"),
|
self._extract_js_function(app_js, "hasMeaningfulItemProgress"),
|
||||||
self._extract_js_function(app_js, "canShowChipItemProgress"),
|
self._extract_js_function(app_js, "canShowChipItemProgress"),
|
||||||
self._extract_js_function(app_js, "compactTaskCurrentItem"),
|
self._extract_js_function(app_js, "compactTaskCurrentItem"),
|
||||||
self._extract_js_function(app_js, "activeTaskChipLabel"),
|
self._extract_js_function(app_js, "activeTaskChipLabel"),
|
||||||
|
self._extract_js_function(app_js, "isTerminalOperationTask"),
|
||||||
|
self._extract_js_function(app_js, "terminalOperationChipLabel"),
|
||||||
|
self._extract_js_function(app_js, "visibleOperationSortKey"),
|
||||||
|
self._extract_js_function(app_js, "sortVisibleOperations"),
|
||||||
self._extract_js_function(app_js, "taskProgressText"),
|
self._extract_js_function(app_js, "taskProgressText"),
|
||||||
self._extract_js_function(app_js, "taskProgressSubtext"),
|
self._extract_js_function(app_js, "taskProgressSubtext"),
|
||||||
self._extract_js_function(app_js, "headerTaskRenderKey"),
|
self._extract_js_function(app_js, "headerTaskRenderKey"),
|
||||||
@@ -499,6 +523,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
self._extract_js_function(app_js, "renderHeaderTaskPopover"),
|
self._extract_js_function(app_js, "renderHeaderTaskPopover"),
|
||||||
self._extract_js_function(app_js, "renderHeaderTaskChip"),
|
self._extract_js_function(app_js, "renderHeaderTaskChip"),
|
||||||
self._extract_js_function(app_js, "updateHeaderTaskState"),
|
self._extract_js_function(app_js, "updateHeaderTaskState"),
|
||||||
|
self._extract_js_function(app_js, "refreshOperationPanes"),
|
||||||
self._extract_js_function(app_js, "applyTaskSnapshot"),
|
self._extract_js_function(app_js, "applyTaskSnapshot"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -583,17 +608,25 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
}}
|
}}
|
||||||
|
|
||||||
async function refreshTasksSnapshot() {{}}
|
async function refreshTasksSnapshot() {{}}
|
||||||
|
const paneRefreshCalls = [];
|
||||||
|
async function loadBrowsePane(pane) {{ paneRefreshCalls.push(pane); }}
|
||||||
function setError() {{}}
|
function setError() {{}}
|
||||||
|
|
||||||
let state = {{ lastTaskCount: 0 }};
|
let state = {{ lastTaskCount: 0 }};
|
||||||
let headerTaskState = {{
|
let headerTaskState = {{
|
||||||
activeItems: [],
|
activeItems: [],
|
||||||
|
visibleItems: [],
|
||||||
|
recentItems: [],
|
||||||
popoverOpen: false,
|
popoverOpen: false,
|
||||||
pollTimer: null,
|
pollTimer: null,
|
||||||
lastRenderKey: "",
|
lastRenderKey: "",
|
||||||
|
knownStatuses: {{}},
|
||||||
|
recentExpiryMs: 4000,
|
||||||
|
paneRefreshPromise: null,
|
||||||
}};
|
}};
|
||||||
const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]);
|
const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]);
|
||||||
const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]);
|
const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]);
|
||||||
|
const TERMINAL_OPERATION_STATUSES = new Set(["completed", "cancelled", "failed"]);
|
||||||
|
|
||||||
{functions}
|
{functions}
|
||||||
|
|
||||||
@@ -603,12 +636,16 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
assert(!elements["header-task-chip-container"].classList.contains("hidden"), "Running task should make chip visible");
|
assert(!elements["header-task-chip-container"].classList.contains("hidden"), "Running task should make chip visible");
|
||||||
assert(elements["header-task-chip-label"].textContent === "Copy running", "Single active task should show compact task status");
|
assert(elements["header-task-chip-label"].textContent === "Copy running", "Single active task should show compact task status");
|
||||||
assert(headerTaskState.activeItems.length === 1, "Snapshot should store active task state");
|
assert(headerTaskState.activeItems.length === 1, "Snapshot should store active task state");
|
||||||
|
assert(paneRefreshCalls.length === 0, "Running progress should not refresh panes");
|
||||||
|
|
||||||
applyTaskSnapshot([
|
applyTaskSnapshot([
|
||||||
{{ id: "copy-1", operation: "copy", status: "completed", source: "/src", destination: "/dst" }},
|
{{ id: "copy-1", operation: "copy", status: "completed", source: "/src", destination: "/dst" }},
|
||||||
]);
|
]);
|
||||||
assert(elements["header-task-chip-container"].classList.contains("hidden"), "Chip should hide when latest task snapshot has no active tasks");
|
assert(!elements["header-task-chip-container"].classList.contains("hidden"), "Terminal operations should remain briefly visible");
|
||||||
assert(headerTaskState.activeItems.length === 0, "Active task state should be reset when tasks are completed");
|
assert(headerTaskState.activeItems.length === 0, "Active task state should be reset when tasks are completed");
|
||||||
|
assert(headerTaskState.recentItems.length === 1, "Completed operations should stay briefly visible as recent");
|
||||||
|
assert(elements["header-task-chip-label"].textContent === "1 recent operation", "Chip should surface a brief recent-operation state");
|
||||||
|
assert(paneRefreshCalls.length === 2, "Terminal operation should refresh both visible panes once");
|
||||||
assert(state.lastTaskCount === 1, "Total task snapshot should still reflect fetched tasks list length");
|
assert(state.lastTaskCount === 1, "Total task snapshot should still reflect fetched tasks list length");
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@@ -961,6 +998,8 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
self.assertIn(".header-task-chip {", base_css)
|
self.assertIn(".header-task-chip {", base_css)
|
||||||
self.assertIn(".header-task-popover {", base_css)
|
self.assertIn(".header-task-popover {", base_css)
|
||||||
self.assertIn(".header-task-popover-list {", base_css)
|
self.assertIn(".header-task-popover-list {", base_css)
|
||||||
|
self.assertIn(".header-task-item-heading {", base_css)
|
||||||
|
self.assertIn(".header-task-status-badge {", base_css)
|
||||||
self.assertIn("width: min(1180px, calc(100vw - 32px));", base_css)
|
self.assertIn("width: min(1180px, calc(100vw - 32px));", base_css)
|
||||||
self.assertIn(".settings-activity-grid {", base_css)
|
self.assertIn(".settings-activity-grid {", base_css)
|
||||||
self.assertIn("grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);", base_css)
|
self.assertIn("grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);", base_css)
|
||||||
@@ -1006,6 +1045,10 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
self.assertIn('function headerTaskElements()', app_js)
|
self.assertIn('function headerTaskElements()', app_js)
|
||||||
self.assertIn('function isActiveTask(task)', app_js)
|
self.assertIn('function isActiveTask(task)', app_js)
|
||||||
self.assertIn('function activeTasksFromItems(items)', app_js)
|
self.assertIn('function activeTasksFromItems(items)', app_js)
|
||||||
|
self.assertIn('function isTerminalOperationTask(task)', app_js)
|
||||||
|
self.assertIn('function statusBadgeLabel(task)', app_js)
|
||||||
|
self.assertIn('function terminalOperationChipLabel(items)', app_js)
|
||||||
|
self.assertIn('function sortVisibleOperations(items)', app_js)
|
||||||
self.assertIn('function taskIsCancellable(task)', app_js)
|
self.assertIn('function taskIsCancellable(task)', app_js)
|
||||||
self.assertIn('async function cancelTaskRequest(taskId)', app_js)
|
self.assertIn('async function cancelTaskRequest(taskId)', app_js)
|
||||||
self.assertIn('function formatTaskOperationLabel(task)', app_js)
|
self.assertIn('function formatTaskOperationLabel(task)', app_js)
|
||||||
@@ -1021,15 +1064,18 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
self.assertIn('function renderHeaderTaskPopover(items)', app_js)
|
self.assertIn('function renderHeaderTaskPopover(items)', app_js)
|
||||||
self.assertIn('function renderHeaderTaskChip(items)', app_js)
|
self.assertIn('function renderHeaderTaskChip(items)', app_js)
|
||||||
self.assertIn('function updateHeaderTaskState(taskItems)', app_js)
|
self.assertIn('function updateHeaderTaskState(taskItems)', app_js)
|
||||||
|
self.assertIn('function refreshOperationPanes()', app_js)
|
||||||
self.assertIn('function applyTaskSnapshot(taskItems)', app_js)
|
self.assertIn('function applyTaskSnapshot(taskItems)', app_js)
|
||||||
self.assertIn('return `${count} active operation${count === 1 ? "" : "s"}`;', app_js)
|
self.assertIn('return `${count} active operation${count === 1 ? "" : "s"}`;', app_js)
|
||||||
|
self.assertIn('return `${count} recent operation${count === 1 ? "" : "s"}`;', app_js)
|
||||||
self.assertIn('return task.operation === "copy" || task.operation === "duplicate" || task.operation === "delete";', app_js)
|
self.assertIn('return task.operation === "copy" || task.operation === "duplicate" || task.operation === "delete";', app_js)
|
||||||
self.assertIn('return `${action} ${task.done_items}/${task.total_items}`;', app_js)
|
self.assertIn('return `${action} ${task.done_items}/${task.total_items}`;', app_js)
|
||||||
self.assertIn('return `${action} running`;', app_js)
|
self.assertIn('return `${action} running`;', app_js)
|
||||||
self.assertIn('return "Stopping after current item...";', app_js)
|
self.assertIn('return "Stopping after current item...";', app_js)
|
||||||
self.assertIn('ACTIVE_OPERATION_OPERATIONS.has(task.operation)', app_js)
|
self.assertIn('ACTIVE_OPERATION_OPERATIONS.has(task.operation)', app_js)
|
||||||
self.assertIn('headerTaskState.activeItems = activeTasksFromItems(taskItems);', app_js)
|
self.assertIn('headerTaskState.activeItems = activeTasksFromItems(items);', app_js)
|
||||||
self.assertIn('const open = Boolean(nextOpen) && headerTaskState.activeItems.length > 0;', app_js)
|
self.assertIn('headerTaskState.visibleItems = sortVisibleOperations([...headerTaskState.activeItems, ...headerTaskState.recentItems]);', app_js)
|
||||||
|
self.assertIn('const open = Boolean(nextOpen) && headerTaskState.visibleItems.length > 0;', app_js)
|
||||||
self.assertIn('const headerTasks = headerTaskElements();', app_js)
|
self.assertIn('const headerTasks = headerTaskElements();', app_js)
|
||||||
self.assertIn('headerTasks.chipButton.onclick = (event) => {', app_js)
|
self.assertIn('headerTasks.chipButton.onclick = (event) => {', app_js)
|
||||||
self.assertIn('headerTasks.logsButton.onclick = () => {', app_js)
|
self.assertIn('headerTasks.logsButton.onclick = () => {', app_js)
|
||||||
|
|||||||
+136
-11
@@ -116,13 +116,19 @@ let settingsState = {
|
|||||||
};
|
};
|
||||||
let headerTaskState = {
|
let headerTaskState = {
|
||||||
activeItems: [],
|
activeItems: [],
|
||||||
|
visibleItems: [],
|
||||||
|
recentItems: [],
|
||||||
popoverOpen: false,
|
popoverOpen: false,
|
||||||
pollTimer: null,
|
pollTimer: null,
|
||||||
lastRenderKey: "",
|
lastRenderKey: "",
|
||||||
|
knownStatuses: {},
|
||||||
|
recentExpiryMs: 4000,
|
||||||
|
paneRefreshPromise: null,
|
||||||
};
|
};
|
||||||
// The header chip/popover reflects user-visible file operations, not every task-backed file action.
|
// The header chip/popover reflects user-visible file operations, not every task-backed file action.
|
||||||
const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]);
|
const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]);
|
||||||
const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]);
|
const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]);
|
||||||
|
const TERMINAL_OPERATION_STATUSES = new Set(["completed", "cancelled", "failed"]);
|
||||||
const VALID_THEME_FAMILIES = [
|
const VALID_THEME_FAMILIES = [
|
||||||
"default",
|
"default",
|
||||||
"macos-soft",
|
"macos-soft",
|
||||||
@@ -3879,6 +3885,75 @@ function activeTasksFromItems(items) {
|
|||||||
return Array.isArray(items) ? items.filter((task) => isActiveTask(task)) : [];
|
return Array.isArray(items) ? items.filter((task) => isActiveTask(task)) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTerminalOperationTask(task) {
|
||||||
|
return Boolean(task) && ACTIVE_OPERATION_OPERATIONS.has(task.operation) && TERMINAL_OPERATION_STATUSES.has(task.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadgeLabel(task) {
|
||||||
|
switch (task?.status) {
|
||||||
|
case "queued":
|
||||||
|
return "Queued";
|
||||||
|
case "running":
|
||||||
|
return "Running";
|
||||||
|
case "cancelling":
|
||||||
|
return "Cancelling";
|
||||||
|
case "completed":
|
||||||
|
return "Completed";
|
||||||
|
case "cancelled":
|
||||||
|
return "Cancelled";
|
||||||
|
case "failed":
|
||||||
|
return "Failed";
|
||||||
|
default:
|
||||||
|
return formatTaskStatusLabel(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminalOperationChipLabel(items) {
|
||||||
|
const count = Array.isArray(items) ? items.length : 0;
|
||||||
|
if (count <= 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return `${count} recent operation${count === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleOperationSortKey(task) {
|
||||||
|
const statusOrder = {
|
||||||
|
running: 0,
|
||||||
|
cancelling: 1,
|
||||||
|
queued: 2,
|
||||||
|
completed: 3,
|
||||||
|
cancelled: 4,
|
||||||
|
failed: 5,
|
||||||
|
};
|
||||||
|
return statusOrder[task?.status] ?? 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortVisibleOperations(items) {
|
||||||
|
return [...items].sort((left, right) => {
|
||||||
|
const statusDelta = visibleOperationSortKey(left) - visibleOperationSortKey(right);
|
||||||
|
if (statusDelta !== 0) {
|
||||||
|
return statusDelta;
|
||||||
|
}
|
||||||
|
const leftTime = left.finished_at || left.created_at || "";
|
||||||
|
const rightTime = right.finished_at || right.created_at || "";
|
||||||
|
return rightTime.localeCompare(leftTime);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshOperationPanes() {
|
||||||
|
if (headerTaskState.paneRefreshPromise) {
|
||||||
|
return headerTaskState.paneRefreshPromise;
|
||||||
|
}
|
||||||
|
headerTaskState.paneRefreshPromise = Promise.all([loadBrowsePane("left"), loadBrowsePane("right")])
|
||||||
|
.catch((err) => {
|
||||||
|
setError("actions-error", `Refresh panes: ${err.message}`);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
headerTaskState.paneRefreshPromise = null;
|
||||||
|
});
|
||||||
|
return headerTaskState.paneRefreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
function taskIsCancellable(task) {
|
function taskIsCancellable(task) {
|
||||||
return Boolean(task) && ACTIVE_OPERATION_OPERATIONS.has(task.operation) && ["queued", "running"].includes(task.status);
|
return Boolean(task) && ACTIVE_OPERATION_OPERATIONS.has(task.operation) && ["queued", "running"].includes(task.status);
|
||||||
}
|
}
|
||||||
@@ -3979,7 +4054,7 @@ function headerTaskRenderKey(items) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shouldPollHeaderTasks() {
|
function shouldPollHeaderTasks() {
|
||||||
return headerTaskState.popoverOpen || headerTaskState.activeItems.length > 0;
|
return headerTaskState.popoverOpen || headerTaskState.activeItems.length > 0 || headerTaskState.recentItems.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopHeaderTaskPolling() {
|
function stopHeaderTaskPolling() {
|
||||||
@@ -4002,7 +4077,7 @@ function scheduleHeaderTaskPolling() {
|
|||||||
|
|
||||||
function setHeaderTaskPopoverOpen(nextOpen) {
|
function setHeaderTaskPopoverOpen(nextOpen) {
|
||||||
const elements = headerTaskElements();
|
const elements = headerTaskElements();
|
||||||
const open = Boolean(nextOpen) && headerTaskState.activeItems.length > 0;
|
const open = Boolean(nextOpen) && headerTaskState.visibleItems.length > 0;
|
||||||
headerTaskState.popoverOpen = open;
|
headerTaskState.popoverOpen = open;
|
||||||
if (elements.chipButton) {
|
if (elements.chipButton) {
|
||||||
elements.chipButton.setAttribute("aria-expanded", open ? "true" : "false");
|
elements.chipButton.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
@@ -4035,17 +4110,23 @@ function renderHeaderTaskPopover(items) {
|
|||||||
for (const task of items) {
|
for (const task of items) {
|
||||||
const line = formatTaskLine(task);
|
const line = formatTaskLine(task);
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "header-task-item";
|
row.className = `header-task-item status-${task.status}`;
|
||||||
|
const heading = document.createElement("div");
|
||||||
|
heading.className = "header-task-item-heading";
|
||||||
const title = document.createElement("div");
|
const title = document.createElement("div");
|
||||||
title.className = "header-task-item-title";
|
title.className = "header-task-item-title";
|
||||||
title.textContent = line.title;
|
title.textContent = formatTaskOperationLabel(task);
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
badge.className = `header-task-status-badge status-${task.status}`;
|
||||||
|
badge.textContent = statusBadgeLabel(task);
|
||||||
|
heading.append(title, badge);
|
||||||
const path = document.createElement("div");
|
const path = document.createElement("div");
|
||||||
path.className = "header-task-item-path";
|
path.className = "header-task-item-path";
|
||||||
path.textContent = line.path;
|
path.textContent = line.path;
|
||||||
const meta = document.createElement("div");
|
const meta = document.createElement("div");
|
||||||
meta.className = "header-task-item-meta";
|
meta.className = "header-task-item-meta";
|
||||||
meta.textContent = line.meta;
|
meta.textContent = line.meta;
|
||||||
row.append(title, path, meta);
|
row.append(heading, path, meta);
|
||||||
const progressText = taskProgressText(task);
|
const progressText = taskProgressText(task);
|
||||||
if (progressText) {
|
if (progressText) {
|
||||||
const progress = document.createElement("div");
|
const progress = document.createElement("div");
|
||||||
@@ -4102,27 +4183,71 @@ function renderHeaderTaskChip(items) {
|
|||||||
if (!elements.container || !elements.chipLabel) {
|
if (!elements.container || !elements.chipLabel) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hasActiveTasks = Array.isArray(items) && items.length > 0;
|
const activeItems = Array.isArray(items) ? items : [];
|
||||||
elements.container.classList.toggle("hidden", !hasActiveTasks);
|
const recentItems = Array.isArray(headerTaskState.recentItems) ? headerTaskState.recentItems : [];
|
||||||
elements.chipLabel.textContent = activeTaskChipLabel(items);
|
const visibleItems = activeItems.length > 0 ? activeItems : recentItems;
|
||||||
if (!hasActiveTasks) {
|
const hasVisibleItems = visibleItems.length > 0;
|
||||||
|
elements.container.classList.toggle("hidden", !hasVisibleItems);
|
||||||
|
elements.chipLabel.textContent = activeItems.length > 0 ? activeTaskChipLabel(activeItems) : terminalOperationChipLabel(recentItems);
|
||||||
|
if (!hasVisibleItems) {
|
||||||
headerTaskState.lastRenderKey = "";
|
headerTaskState.lastRenderKey = "";
|
||||||
setHeaderTaskPopoverOpen(false);
|
setHeaderTaskPopoverOpen(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderHeaderTaskPopover(items);
|
renderHeaderTaskPopover(visibleItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateHeaderTaskState(taskItems) {
|
function updateHeaderTaskState(taskItems) {
|
||||||
headerTaskState.activeItems = activeTasksFromItems(taskItems);
|
const items = Array.isArray(taskItems) ? taskItems : [];
|
||||||
|
headerTaskState.activeItems = activeTasksFromItems(items);
|
||||||
|
headerTaskState.visibleItems = sortVisibleOperations([...headerTaskState.activeItems, ...headerTaskState.recentItems]);
|
||||||
renderHeaderTaskChip(headerTaskState.activeItems);
|
renderHeaderTaskChip(headerTaskState.activeItems);
|
||||||
scheduleHeaderTaskPolling();
|
scheduleHeaderTaskPolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTaskSnapshot(taskItems) {
|
function applyTaskSnapshot(taskItems) {
|
||||||
const items = Array.isArray(taskItems) ? taskItems : [];
|
const items = Array.isArray(taskItems) ? taskItems : [];
|
||||||
|
const now = Date.now();
|
||||||
|
const activeById = new Set();
|
||||||
|
const nextKnownStatuses = {};
|
||||||
|
const nextRecentItems = [];
|
||||||
|
let shouldRefreshPanes = false;
|
||||||
|
|
||||||
|
for (const task of items) {
|
||||||
|
if (!task?.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
nextKnownStatuses[task.id] = task.status || "";
|
||||||
|
const previousStatus = headerTaskState.knownStatuses[task.id] || "";
|
||||||
|
if (isActiveTask(task)) {
|
||||||
|
activeById.add(task.id);
|
||||||
|
}
|
||||||
|
if (isTerminalOperationTask(task)) {
|
||||||
|
if (previousStatus && !TERMINAL_OPERATION_STATUSES.has(previousStatus)) {
|
||||||
|
shouldRefreshPanes = true;
|
||||||
|
nextRecentItems.push({ ...task, _recent_until: now + headerTaskState.recentExpiryMs });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const recentTask of headerTaskState.recentItems) {
|
||||||
|
if (!recentTask?.id || activeById.has(recentTask.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((recentTask._recent_until || 0) > now) {
|
||||||
|
nextRecentItems.push(recentTask);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
headerTaskState.recentItems = sortVisibleOperations(
|
||||||
|
nextRecentItems.filter((task, index, collection) => collection.findIndex((entry) => entry.id === task.id) === index)
|
||||||
|
);
|
||||||
|
headerTaskState.knownStatuses = nextKnownStatuses;
|
||||||
state.lastTaskCount = items.length;
|
state.lastTaskCount = items.length;
|
||||||
updateHeaderTaskState(items);
|
updateHeaderTaskState(items);
|
||||||
|
if (shouldRefreshPanes) {
|
||||||
|
void refreshOperationPanes();
|
||||||
|
}
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -143,11 +143,56 @@ body {
|
|||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-task-item-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.header-task-item-title {
|
.header-task-item-title {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-task-status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface-elevated);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-task-status-badge.status-queued {
|
||||||
|
background: color-mix(in srgb, var(--color-surface-elevated) 72%, var(--color-accent) 28%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-task-status-badge.status-running {
|
||||||
|
background: color-mix(in srgb, var(--color-surface-elevated) 60%, var(--color-accent) 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-task-status-badge.status-cancelling {
|
||||||
|
background: color-mix(in srgb, var(--color-surface-elevated) 70%, var(--color-warning, #c08a00) 30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-task-status-badge.status-completed {
|
||||||
|
background: color-mix(in srgb, var(--color-surface-elevated) 72%, var(--color-success, #2f855a) 28%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-task-status-badge.status-cancelled {
|
||||||
|
background: color-mix(in srgb, var(--color-surface-elevated) 78%, var(--color-text-muted) 22%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-task-status-badge.status-failed {
|
||||||
|
background: color-mix(in srgb, var(--color-surface-elevated) 68%, var(--color-danger) 32%);
|
||||||
|
color: var(--color-danger-text, var(--color-text-primary));
|
||||||
|
}
|
||||||
|
|
||||||
.header-task-item-path,
|
.header-task-item-path,
|
||||||
.header-task-item-meta,
|
.header-task-item-meta,
|
||||||
.header-task-item-empty {
|
.header-task-item-empty {
|
||||||
|
|||||||
Reference in New Issue
Block a user