From fc4ec39646da552eb605d8477980679ed0889f84 Mon Sep 17 00:00:00 2001 From: kodi Date: Wed, 25 Mar 2026 18:21:54 +0100 Subject: [PATCH] Voor remote client agent --- .gitignore | 7 + finder_commander/README.md | 67 ++ finder_commander/app/main.py | 522 +++++++++++++ finder_commander/requirements.txt | 4 + finder_commander/run-local.sh | 27 + ...OTE_CLIENT_SHARES_IMPLEMENTATION_PHASES.md | 338 +++++++++ .../REMOTE_CLIENT_SHARES_V1_DESIGN.md | 697 ++++++++++++++++++ .../b00fba24-e752-405f-98a7-6f2c21fa3a1c.zip | Bin 25946 -> 0 bytes .../ece0fef1-0632-4242-b038-ebe6b2c28c3d.zip | Bin 0 -> 701 bytes webui/backend/data/tasks.db | Bin 372736 -> 405504 bytes .../test_ui_smoke_golden.cpython-313.pyc | Bin 95765 -> 99319 bytes .../tests/golden/test_ui_smoke_golden.py | 52 +- webui/html/app.js | 147 +++- webui/html/base.css | 45 ++ 14 files changed, 1892 insertions(+), 14 deletions(-) create mode 100644 .gitignore create mode 100644 finder_commander/README.md create mode 100644 finder_commander/app/main.py create mode 100644 finder_commander/requirements.txt create mode 100755 finder_commander/run-local.sh create mode 100644 project_docs/REMOTE_CLIENT_SHARES_IMPLEMENTATION_PHASES.md create mode 100644 project_docs/REMOTE_CLIENT_SHARES_V1_DESIGN.md delete mode 100644 webui/backend/data/archive_tmp/b00fba24-e752-405f-98a7-6f2c21fa3a1c.zip create mode 100644 webui/backend/data/archive_tmp/ece0fef1-0632-4242-b038-ebe6b2c28c3d.zip diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..06b3256 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +*.log +.venv/ +venv/ +.DS_Store +.sqlite3 diff --git a/finder_commander/README.md b/finder_commander/README.md new file mode 100644 index 0000000..fd79c46 --- /dev/null +++ b/finder_commander/README.md @@ -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 ` + - `mkdir ` + - `touch ` + - `select ` + - `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 diff --git a/finder_commander/app/main.py b/finder_commander/app/main.py new file mode 100644 index 0000000..e7ed3a8 --- /dev/null +++ b/finder_commander/app/main.py @@ -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 , mkdir , touch , select , 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))}) diff --git a/finder_commander/requirements.txt b/finder_commander/requirements.txt new file mode 100644 index 0000000..c1e2eef --- /dev/null +++ b/finder_commander/requirements.txt @@ -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 diff --git a/finder_commander/run-local.sh b/finder_commander/run-local.sh new file mode 100755 index 0000000..7bac349 --- /dev/null +++ b/finder_commander/run-local.sh @@ -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 diff --git a/project_docs/REMOTE_CLIENT_SHARES_IMPLEMENTATION_PHASES.md b/project_docs/REMOTE_CLIENT_SHARES_IMPLEMENTATION_PHASES.md new file mode 100644 index 0000000..e5d0cf8 --- /dev/null +++ b/project_docs/REMOTE_CLIENT_SHARES_IMPLEMENTATION_PHASES.md @@ -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/` +- `/Clients//` + +## 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/` toont alleen toegestane shares +- `/Clients//` 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. diff --git a/project_docs/REMOTE_CLIENT_SHARES_V1_DESIGN.md b/project_docs/REMOTE_CLIENT_SHARES_V1_DESIGN.md new file mode 100644 index 0000000..fc51acf --- /dev/null +++ b/project_docs/REMOTE_CLIENT_SHARES_V1_DESIGN.md @@ -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 ` + +### 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 ` + +### 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/` +- `/Clients//` +- `/Clients///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//` + +Geeft shares van die client terug als directories. + +### `GET /api/browse?path=/Clients///...` + +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. diff --git a/webui/backend/data/archive_tmp/b00fba24-e752-405f-98a7-6f2c21fa3a1c.zip b/webui/backend/data/archive_tmp/b00fba24-e752-405f-98a7-6f2c21fa3a1c.zip deleted file mode 100644 index 4a3ca91f51572a6e7ef5bce31729fd493fd6b794..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25946 zcmY(qL$EMPux7ihf7`Zg+qP}nwr$(CZQHhOE*iG^P@_+EI3%sN|1i6m4DA&JHrl50yub){jDzlrkI|oADo8gHa{u0m^M(@Mb)D z2l!H*==c$njkzWUPf#Pqe6qc6gXWg0a4K^=ZLn1aI*UB*;EG|}L>{g)I(t<4R9edl z-EArqz;`NQsO!p3Y_w~m)wrN6rBZGJn_v>EQf@3AdFSU9$hzQ;KSi(hW!g@}RykK5 z6&-_naxt-~`cfd5JzeNE55_{h=P}DBeAooOd_30H!>C?}-R?7Y^evl6T+A>N(Hi(AJk8m5E7q*qcKaG}wkpfWEpH!?rwYf84ozy@Ic=o!qNfc!&xoph zU9hPHJ%PyvR*p4>+6N@At$$|dAEoMc={pD2;A9OcRdJm+sgZW{wY=;{9o~V#jx|k$ z>!CTcQd(o0c0J<4`Po!3ynQ`|0IcX3*(>kuL-~vOxR0S-))tw!54DVdaOt7DImP)U99{19b&8>WagJwKifJ>t`BoV=Ud0Eco2=16c3uW9th1L-o1M& zc21>FW6V`#&fOfsF&p^mU&;5;vYmjIDlU0|B}pwh{aRy!MU z1FWJB0kadsk1xY3TnJ2hvb)FoBe5o6k1RQ+T8oumKgpb&QIz?5!bQAIfYD02-F`{? zF(fHS|JCB_6rDVc_2(3yuOIr#);!Z?SlIb)5j7~OLBw72KJc;uloNQNf8%C*0*X@;X%Pbvkgj$CL1y1is%2@59RBU;q*o|^K-EsS8j--K;mWqbW5H#P zc8=*lJ#jA9bn?KHMQ>TNmQ?|KM3uUq%%8;RWyBhZV609K>h)bZ4m5a5eh~J(Cjg6u8_ihkOOK-;E_lYL?p5qi(@D)yfBpxpRA*_G%iMuv|Fpl)6rD_J+jUm!rdlBcaj2B!zB zrT}wxkfM$68hQ?yxZ@N+B%DdW&(?x-;(b_SA$p2wR2Om;EJ{EFvJ^q)L$IR+Ym825 zX|==(RVOMLIJ;pkE7~EK$m011okTm!nu7Jnf%)`F)}EmuA-Y#G%+xpW^Ys2=O|Gv~ z#8YBy1c(zg04FOeilJ|k@|6K+gnCT2+dUoq39B`$kc*$!I|wB4?5%++CT$*BQC^b` zymmw1ahSya=_lLFh=AE-U--y~huk%JVRUrh$Bq%~Xv`}}PH#;^y*U($mV z87^SUbK=PV*6rNo9+k^EpGN4l#)P>LVq~cUEB7ahi&=H&cd&AmJ5Zh$#zFSYSm(k} zc10ao{e?U82>7TG1QzUmq7b_&BCWUCctF66N@?2rwq+TLA&jbTNgw&ui?ThX0dImM zO7#Mdc!kGFqOrW#T5vY^GLfBE*48X|^s;akZ7v~0gCk4Yo`KFi{BS~f({<+Iv5<`p zl__K{6;b0S!L(#V+$D*(AC`?np9RDnn&mWfqY+xluhU*Y_&g+CIB|qYh0Ir?ZrHd; zjPNF>L~EVfBfv6PKV+zA$4HL&5)x`*O}paW|8GDr^UUT%57qaCLwVP(+q;4|<+L%b z$nc$Jx%m$pMq!JH>v$gJ%e7Uw1jNbP-3^7vBMI{#NOcpk*^pnSs>UZ5j&_U@Bm!E_ zI%=&o>bNJ ze)$>g2b*3d+qdfiZkZj+23aJXGK|@XDK?k4JK7h{)@XLloIk6pdwjx1EM&4gl{kJ# zlEyZN+iYILC>$UYnz$BfSTrP*#U&_=3^w=d#i>UoltdY`Pxd4^OV*i8nj9$K8xjE} zMc`L1X00Gv5jlpej!7=&->jHIL+wGGE;kl;z#cRH)TXj}eT2}?^;ZTn^iy@wJDmLg z0oiR=Yi(T!JOu^_0Dxsg006ZAKvrH@R8LhxL`_8T|H0P~^{xNGSMN2o{7Ebn(=bY3dH%30AR(;`~UvDtF$XegmFra22& zQj;65qm&mYIk=)5YrExL&LXfI7dWcT3bZZUM_4j)YnjmpVI-4-5#A5*>k%HC9EY@ z0ncavrO-)o$~8F`oQ;`JZa|n1vRo@l!FJ-GXER^{ut7VWPm4N0mYu$Kk8WtR0Ui$) z9T>H*v=*}dje0$AEJfXPDN?;_Q2xPC?h$5H$w9>8O+GzYt#*|{XFZsNV(=D8T`x5} zAHZ|ow4)A@WURaA+-&UEqh}`tOOdVI%M7m`iyCLGuUUQ_rPkW+m8s@UyS99K*+@Ku z>ZZ?{avu4Nvv2J`wb}Iz$)U{yI)UHD%o-gpA-VIP46$E#x6&@;FCg?cAWAKTxC&bh z0Ncins|{(c^CX%tzvU4#^l7MVS`u!6-1x4cEp3N zB)s0=A;$+KvR=J_&dghd9;9KaVuD15Oj&j-dDmmlUh9=f$(DT{LT+}bei+m~0MD_S zfp7+19_y6lWvy$~G6~YtC zvc{^X#)-a-#jML7&*^O;HGtQTl%CsSFv*?}jon6+c&3M5EA>IlB^C<5`ZsqEd{hIV zp=V%fK^;_~Qs2I=AFc&I`$-}3ASt?Fnyq#03ra&m?wqJFP$Zee{v!7%F zZcimq96Yy>umoHIq+zH;AuuL0`pz6`YdxW`h}5T3#Rke2gCmQdQI+bh&F=-uRO%48 z4y>u(QFx^j#M>%$TPi`9H92`h+ls@x ziW5axxx_4t5crb3@5KbN?9fu^TeHYS=S=U#6NL_t^ME7Iln@vN#*vUyi*f~c7^N6Z z^7W(XL$>@_)$AE$ao{zs5iIvgI~5ldug}&3PW3eQNC$;DDy85)!(0k6NDm$O@Tg1Z zF`J|yFD<}E;Yk|gpsxqp8(xP~3P_s544r4plqYgD64ev2{> zVh>BN%W7YHk?6RHhLFrHm*oK!u1X;cP!3qb9-R1YX|LwL&A2N~iIu2Iw45f~yrcBV zas*hK#TET{xM-O%cr0b{3n6KUo*x4!`dLZk^_1raM`k80DIicMuXO5g8qRoST=~ig z#ScNUuTl?G@lw>>$~8Mubu*?mJ35G({JMbgucvD%;#&!n(u}&!&}ji&A%+dTBAx8< zw_Y_5fvqv(V?#)2m7~-%T0%7m4$taLI=Fmsy>)!|XZpQF!hs3Km9`&otv4G={ds^b z|H%StVchvBso6FRt2P`!>2&(?OO6uez?{{|q)vxUm@I!yT;;g9Cml_JMo=;NPy0}{s_)<#FlUf#2|JIHABB4d zZvRzHl_XNHU2;C-s%nwtU}Y;F{vLZz*hWd+CY;)fMf)9p`b zfqkl!uT~{Pnvw5sj71ROo8>^$vwXU+PNLe2#E&Ph?Cp0C#su|SucHK+CZ`YGd`U;^ z+@Lp2%_@;17K~z(C%_>TbZO2|yEEOFy(x=MgL~tmogOe2SWe3h{8u#KO#q;5hG<-MIcQsQxJrX%V45~lV{bghx#yGa ze<`EZ!de?`Du)gi8UO&~zpM=Be<(vyL{vmkQAAizNm)QqSw&t?UO-t~Nl%sWzo@K6 zV=H!R9Kq*Ijrlqa1l@vhR~vjCK=*p4pU~m3<*9uMQA#-rIm?`t#4A4RTl7B%HTomg zOH@+X={9FJ0w0g!AeV7c|Nss3)9scfRjNP=_O+o<_g5mj0sg|3ZND|JDh2_PRw3tlO&sbI+3m*WW` zYVjGK0u~)XnjnpBwQ#@KWc#(xceT-N=`NvN=X~5GTN{_QK=Jl%ZcP;vi@I=sY<aoJKY&eb(?0K;%hC|RxL+^9AgJE%Dq{B4`G-n;g zTfstK-0ph2=_K!UMJT;9_y-qeqrIib#df{u-Zh#x-D791mBC}3kln`VzOI$M=C(Zk zjkWHFW*4;r?ACbmFxBST)@E(Ox*E!9m4!HDveiwW) z2v>_tgkwY1Rmdu*539;%X;4bfi(+SosW(Kij5_l6KCzmL5la_+lz-up&X=D1xx0W% z8izg<`!p1>Ni`iCWC(B#x+FSPm3}l+S%!`y&D`I9Xy)jBm>TxPp&9uRjAOGit6(!6 zY&8*&X7yxx#YMU6z*_v%pYlCDA6Dz2bsa53)u69Lcv0wMFg>~L!U5?g$@>|BbX^8k z+U?~iY`=mlq{^OfWIkGfGrKd0s3YQI#DSvI#r>4k_p@)RrT!5MZipy+GOHrQZz`dT zox|d`x0}9_H*xDgo;&Ol502(PMx0bHZZIP{^>`|Is*3NqiJ+=UG78r zUixjCxz|xk3)#QuHx@G3>G@)T+5?S3ZE+Sh2D(Y;85{(E zH}lu1Ylo;tXD)%{!5Q`UeSg0aACL=dconZRl=nWO36cQ|r<#I6=qe!+5ks#!pah!@ zC52x4VX+m9^D{Kfv!T+Wy)4@SS+U-%h#LnfGI?D|1V&Y8+yHmu;Y_Z$z{!D>^@Lpp z_*}>MuSVEdNZEn@_*(U8Tc_`7#y($nXtm8^vkVNX(qa){=_}fA7?NUQeBy$unDR^H zAw&VlK$+nYtTOP}+rDS(vr5^{U3_%J+5QDM_8UL1Wsq21NY^jvN!4Ivmb*-2Hume} zOwZTW2|64BN1liHTpS}J1e1cw=PbOYz>2}yk+z@wyDv27sFTFsJLO>Q_8!L6voxMa zqH-Zjan(iJiQNX^>3ZDkqH5ve$y}~{(&8yPW)>bh+op(L7`hj`H4*$a9+s~41TiKI zTuZ8E16pNdjK@9n?JZn8WMHp$rYrhp#A<>+TV*JWcbx{w-^@ zHkI3ITAamCSdoH*+WVb+#r`1xb#QzdIIq=nJQq!|J$!%>zyE7<}3rmMt?F>;G-4?b3o?D$lIaH;J?~aok*5` z5XH{!M9epLSRWDXQ(!c+IbT!$!Gy#4H|bFz;NJX98V|QeeHISEE51jN-#QQ}>ki3_ z#9fhKzy1$+PF7hix>D-1ckr)&6P}v!V~oTIa8|({7K*?W|17DKS`iqN-L*}C2C*rc z=7u4(wHRlK9K)0{CcElhY!2nMmoWZpnz6S2A#ruQk#Hpky}DUF*)Upg+C0drM@g*W zK`OR>59c64h@Vmtm&WkLZ1WE$=S=LBcvDSKthk)61YhK_`3ye&nfxAd0io1Niu%o< zOpu+n5dB&(i(qatb{HB)bQU30otoDHqqm$fWP`g-DU}Y~Y5*`w2=L>H{f2c)K8+<)Mg1IJ2;Bb;s%O01*gGNCi9ug7;Kh!0)igpR@JNuzeaE? z)hnH6YgwO#v9n>QiPAC$f+SU5D>5MRjG~ZnX^JYnjzW<>R+5`VLVd(mDm+HN?={)h z@H4}ENT#5tI=;tHk$^eUmZV}z_M@qI$8VC5)VN2CFic32X5htzOe9EZ-J@@C?H(Xay^SBl!7Ef%ebY3 zO%(#!;~JPF3{Gbeqk)APlKk8KzMux3@r(l%xim0p$+2Q#9B9l!s`y@+@(#*2yin<7 zHPM_Z85NLZUxSlI`1Db3HOS)(fal3^5j3Z$F;492dJ?d5n;qzxotNfgB|Sl?b@Mzj zge&A{d*D~AJst$oKx(iF`Q!?TFd9So4^XjP2cesj%)JDKc5=0Xmn^w?g9?Pp6X!iT z6Q(*XO0Cp)hz!DLUR|sBW@>mo*P(Du3; z3Nx~S5Z7JEqk0uC2f#~GatL|`5~D-WyV-CVXnkV|Td%gvuO5X1Z;U97{*x|-bYJQC z4NSK8VOn-5s9aP*`^_jM$5_E@} z&dWAfDAxj*&%ix>-TLN}%t+}1*y$TWe69FK2xT#Ob#hofnNxM3;Zp-xuCrPu6a%Ui zPc3={y~3wol9HJ)P}{T!o_?dS&ON>ObxQ7rDm7N-f{|s~VokkQ?CxICO2U)Jm!vM& zLXMK(G_ID+XE!g0ki^;2!^#X|j9~L7%gI*_t$1neFUGuz(nJ3_v-u6d4@N9Brxq*v zI-IZJu>hU`e&5>rbt>j$!{wIaa3Z^;1N^(>J>>n-QEiLO0cG)_OUQMRbCKff@G1-F zOKSNT)xf~KqZqBIzu?{Z{wer&amY@rUgtBq4c5hOz3bLlR-8{5QlNQB{tA_p5ma~K zIoz_qP^6u+ObKn@>_G6GJ~^R-3$L6}*xRE{;#lSd(nrB$nJ@3+JH;%>Kl;5=^iwx2 ztc|4sAj`p4vhrX$GGBYR0|Cu$LTVXxH;|vMpxpF(4BD={OLg3N^b!3o&Uq2Hr4rNML%-9u!|EaT05W$*Gb!SN9%X5Ze%PFY*1DJ9Fj;yZE0h# zyF?!_wFHeb#)n`pSO;;eeIc9VVf`i!E^ZGDEp!2%y_c((>Yd7Xm}O=H3Hd!rAD#Pa zw<+Sn6;e^fT;yIOm9{vRwR|)W^6hB(6U)F|1+AAGfiQ3YKJOGW5^I+Mt6@8N4r*I-!s2tJl|Y}wDy~LuIO6i4_rGF6Vr;GbEk7t1^q*M7;{gB= z|0foTa%y@?BGUhL;U(l`_0%Mk#pP6#^@PMlgro%J)c=b#|MQC!jjh_PQI!Au0y?8@_{21m{ob7d-tA}i=CpLQ;-enW2aBhym+kHDl6puhXFjY}>Q(rD6oHH+6|V z-8Xs6_j`S_S9Lv~0C|@8YIi!$?J`b@t`T#ovI@iAq{KnE>?L2;R5|9Uh_hpWx6^mI zMj^fmE2goB6Rxy*bgD7(i>`AF{N*;L)6pyCnf{=979D$)Kj$H!?^V++#!9D6sjedT zYBqiWdI1wkO?92KswUIkm*v8hN)wv~I2*LoA!g?sP@tQ=6(f6RVtr8i8VK&ZKzJ zLb2LBRVL=c-QKf2S&TOJgxOJzk30&kVS*u@W4v%l1VMK zMu7f|wt|}8*?CAz&;SGpTi+9Ma;DXe`l`^7ncDDAjvPB zKfW5S(%L^OQU3d=RM;7IASoI!-WtZv zM!^G7SC24tx`ZK>W{{W&SN%=!4LoS3abAucR+gjvf>J(}K&F`$XBsI;CoC^b%=jDfrozc6_@j}F&LihkX!2( zGISnifcL81H8J9`U7T2#k8<38si{60%mJ;GJ;%vvzk@#IJa&ft$V37L${HBfVx z&6m{?_lyCE_*U{BAJV5bUen!H2FYERkLK4W6$iUn%Db5Dz4I|=7%UcSc$@teP*Guf zzZ!Tjeub{w(suhLzJuItEFAl&c0WR@Dof7u2yh-W4%faNqu4l!$hjKM^tm9ADH5_* zGcx~14TU-`H!M42Ishjk7&|Ro9`tBag@!t^uGF;|fmSenyRrJ(89*(s491-57&VnK z(VAa>AFy)IT`3N!h=YL$7Fd=D7?J@$#23FICZIT>)ao{k>z9g0SJ#zAfmb6P+Ffflr2bIpxbj|N zgTYVWj`2K}sQ}~EAXZ>I!)oiHy)z<`<@cRIF}jZw1mrPaMMPuy8k*KsM>j&>#$P|1 zVK-Cvk$m=6MFx`zKBBjg_0%d9wu^qN5H{zXK6x-RvC~ruUUMkpqoUY4I-#7)_}=UYZoGa+Ixe=3 zziFXiB1NC5FCk32*C)WvSX{EtD(iKuqATkldx23ngh%y{$q8zC-BL$9;qI|}bJPbO2 zYJQhX5+82TsblzNCc*e{SA8rotI!*S{ia^XcAlU2p>a|(c)Qq;b&%@xHzLo{PKO-w zxKx*PspxM>KHFvY%ub;?o31bc9SAWd%j1aAQjq>YXYhPjs>A5VL~AEBO<=2iZ%%!f z{OhnLO+~K!zGruFfqQ+|%l}dWgDFmW)<8Rv((;|9p4aO0Q3uhp(<3X`L`f~lBDRVsh+(KF~ z<7ZD`Gr*Nc5h3Z)fEdw3#43)B9m6e7uG)cKLk|Esl(>kY^i8zMH2IOp1gi1}Zl=bC zA3m4;#kyH=!iE6)hkmtb?IW8#;;jIV*_QkhhPDMwX3LTF`~Cg`55h2F(*R`h0?67< z^WzY4{%ng&T=|p-$p=*HSeeDT;R$H50tX!GK=xsQpIJGdTZnxXWI|7Sh%w{0Y7aYh zd;>?sZmKpW<~JeANo-gI&*d*NNEEh}^VcX2_}MX_9H4QseE>x^*-Od{C`1oNYSC8~ zuG$jcsjw>Rcpnl|!=XGNEb|%K1L+#Ax2H@<;nP}&jdk8~iW4COgl3;{G`yPqvxSB# zjY-aO%_u{RMsGZt-xlc?KN}uW|DxV78d994)z&E%Q$uzn#g|E0Od?9d1zazs9=&Lj zp(N_M#@(MZvgrfmH3(P6!Ekn+UALae&vS-#Qr|2<8cKx~ht+1&C%i!ex#+!PAt?HV z$?_mW8)`DqavQ;DR42Q1?kN@vYp4yke_hLZbI+6-l4$_WB?L|m{=1A*;MmjoQqinj zEs`RQIaae!Olm^qSgz9Tvfwl?*+HY3FycBP97ntqD2m#Fm!{$=OP?KVSx;b>U!i3JN-MnoyYo8sf+60~t8!tLR_ z`6ghso;qHrz24wAcRCp`nC7|cz);ekFM7xrnAEw+})PNyn)7tvK!!w z{izjlN{uA20)Fg9aJl@qy7UPa3 zz9VPVn~WPIxyA#%8eyNFyk8Um_&WN-`)|v6+c-fedV_0+Jgw<9HK*d5FH(d^Pu~*2 z$-pDrVA0PfNJ4%5Y-tO*(66@+g<08pjs25XvA~<&5{bbP4P$7uj(ng2;2Recj#?Ky z9Ek{6qU%6t2=OzcEg}!E){;QtNrz&h>%-;Emaq&K0KEN^mmiT!;PGF_aUO8n6Ga5q z_a8wXE8J#r_Zby#tA2(*Oi#0__wwPi*mxHQet`cgG5=JpwN3wsL-ip60ESQj0FeHZ7$p$_#s8*>|L^hP z81=2#<5mQpH8ps3eDVF8+??SKm_v|WTh#EN5H}oPj|TDz%^4JuN#YeJk4d}Zw?`5V zm10_+rK6XnXL;f(b(hO!0=;s#Vjmynva3S(2SJ^?6}4Dvh3TQ#&GJx*9m^Xm#f1b% zpQ55cK2Piv49_KIx)K&^f{i zqy!a$K>^IyDFaV2g&0l{Ol6Z$KtKD)NP2`-^P~Hkr=5Y$tt`VbTx^&04MmZpxa)1> zLQX>}ouLXO3f;i;o8&V3jdG$=yotfrOD^q41AJ`veqq+D9h-a{EvTYe2LB_x#nB?R zp3#S8nvYv!moO579KZvI`;{tgG#|?Q*#E2Rk3gPQDiRiSJTHl=lNrW-Av%^MV#N(;)JlwaE6a1;a^;eV`vbcLQ*>k zfomS_Z7MkT2&cTO0>XV*vLoPE*m+RcoUp!YTtg2>r6m0_uUw)sAw}FAl%yT>n4@K? zsBEv1_V8>C#+YMVcDtIYcj2P4ppGNS{%LwNz`#HwU|6{D5*8MTP2V=Pzt96HayA_W z?<-9T7{y(D+I#;{nM-&GA9)iVkGTVE7w*)!K0?Xej&#Z^3P&e(eG~Jx1$ch@gTL$= zGq#`D%;WNv&XeT=?mz*`pbDJO4SDnlD6?>t_6SBJT!J{rgv%p+f3p=%OHP0cF)z_z zwH#caA|}uJNGO)Ssod#epj^zL6`J2X&j7*tZ2Voxf?W@qYaBA7q@*P-z!M$n$hBpC zqu1VJr<`*d(q!^P1b(E~qxk8il0wihMr;SoVTdrEVnTJnSJw!1rWKJx`&h)_{w_16IpHk1p`xBE4hC+>iKq2?@?VA^CI5+qS#h<2=WsqTxP~2(wbu8tZ$`7X zed?~+YfUJhfwB{#p$gN58s_jDgxzP5)XJ4szt-4wI2j59kaV!6Hi9|YcnHegCFs4(Kiw;KO{@ctci zMQz=H_#ot^xZyxzd!Wh2Wi0|eLD&k=T(&Y97M!#9ZP)_vPYBB+w8BV$5ggdqA58Vr z@{#y;1WKTheFy99$pjhw)>d|>KI|k&T+T(mjU@hksb&D9fb(HVXJQ3x<`8sSXN8Eu zXP}&}tIml6-zV-gc=vEw=Id6hraCk+%i6|R0GN44Oa6)~;jTm90}_W1<%M^MDv&>$+In>u@vAMf5SyFLJ>$oi+C=ZR8TqEGAt!`6Z%5uiKC zLIpV7@)&pH^kekWu3afhPo_`o7Nui{gt8sd`=5Xb7Yciv#uaa9 z7%6~Rs*(Y2MYcA*#}&-9tPK`&xceJTNr_!CO_tMxn>W6w9B^&zCIDb?ILRM{Uqft+ zrVBn9CTDG|8!>Oo$S04t*=SYKQ!sK!s8v&J+dXjj-B5s`JwAYw>uMD)XJ*5z%yRYJ zok=G;9$(h(R#I?hR0!Al8h++pzkce9xgO~3SK#=Iu2v|q&uH+cIA}%dvlp7ZgPxCr z-PiNlPpa`-Nr#E&8zp%foFT2SgjefhJ9M-}&jN9+Jk9Y93bt`z^W6pPKb!mGXSCKD zvGrpjd}O-Oeq5UX=qSFE7P2zq>G)caApBBA2t$l5dqKBxGw3%A{yThTavLz%%p|oS z|6UYW{*3rq$zaGJU2o9VlTvy*6(6ugzQc|@l;P97WU6khX_CB1 z<7vGlgk*%r!IIUI*wb?y;*7$AeM?D~{}di^GDCES2oPcW5Ku1@xxSOmYnADq<u8ePHUZocEF^5yTnIXn0@IG&hCWpW42A}@TIv)a zF7uv1OJI82Y$+gVH$@W*yB{2dw5g89eFUy)=op9~>*UN|525KeUt&yY=3QHtYBpgb zcMY`CqLt5j)kMBaKv)h>E@w~uM|S#9qVGsUmo*M56kU4by208y{^Lz%O=3ykumJ5e z@siihl^ykd_^;8^9PrLo=H>7AX^+NF9Q+c2q~iu4TAM;Q@~B#ZMrqq$qRkuBSd7B4 z=eV(BozjfQtyc>4*&w96wuILVbnF6k1G2M%zzPY2dTDX>YNw~M_G;a19z4&jl%v+6 zIC{uGcN{dbec>5Exye=hKzt1^)l>rS)On_oZLiv~7jV8KisXa0k8QjH4$ThwI%MC8 zLm)SGo}%OHNw;TAOk2O9m_;73qhOzxn1|<4t)set#^wVh;%(2vzUKTy zDmHFBwnm-XNOk*#xPBkaBE5fi|9v+AmEsOCPUB7Cr-*^BrQ~$T_fIrr*TuO-n?0@Q z(NyT943n}mX2@9M)#=ktNXt=6vMf*OkG{*5+m-z2EEelR)i^F)-7p9Y5K&?sD-sv z{D}#$@Aen(e}U}4!Acu!Y2D8e764%XzepMXKOj?)&=dIo6J;fFIYnh573Kd%RsOHf zT{W6pw%hE8|3hcvL$mrdw#K*j`gq*2JVw)=h{F=5dyC3ZHKigEfx<3hzZE~>KMg!8 z-Qt#9Oi7Nl$S%?S0?w)sdySLlZXIq?GU$^hq?c+~ZR~(+MueXbf+tZU> zRq1pi8r|?zin~b)LM6)3sw?FbZL6%-_vY5FjTl?_kkrCBCXyrx6bn0r7Wd64Dv}C; z3{9viMvsi^Dj@cYlURbF1P1^ zUNoq)&J~lYsve}YRtt54j$!D{8u=2{8e6J!r4|%A$vlvoSE#$2A_|B$a8JwXm# zlUONN8@C5zPZaj}E+xTVp0Y?7qZGRM?jzn6OsoYy8`&M|7{Z!~s&rw3RKdNsw~hdg zmIQS7PA0BoS!qzP0RruMFy2!>L6nG*S|9VNB6f}@h`$_eJ#|zn!-zFf&p5b#)TkT| zO6DhS^al6}>jZLT_X}KstQrF69$MDD=P%m}!m1RxeA}CMfcdrs1%2JCG&kPIw)CWS zo|dT42My@Ep0xSrhZj?Vdhg$FaA>Z!&G0pwIJ`TlGZzJG6p0&08Xtp~M*7%G!z|*@2OOJ!} zlaex`L$2!B4)K`8wf%^N2*wH;f4IaEVvQ<%sEYs5=LpsCj%0EVf_*vAIEgSTgnVEoq%1x*D6y}}th z^>-flhddkvLw;K>m$QIR=H@t<5{3vE5#w&Ky(eccBt$k}P=NG2KM(0|evT}}+#0Na z?mZQhkkzOabuVmItkhxz9GHDX91qyR;UG|Ax#e;XN;Q4p4%Fd_oxl4}Qdy4GY4ahb&@kZKGtade~1fLX;qi`8Ybt4=!z}QoToI z@!XGPgkKEU{Eo|-%r(peWrFP!IDtW&Vrb=6@1xIsiy%JHwJ3r9U4JB{U}X20bHom^ z2nmJ74j7Q~rHP#SAifUB)e`*2NUK`Wn6`rP4tqWB-)=qG_veI^w_94ax3uKq*(oJw zx40ySFG!59#dv82kRZ1bGfzAn_atp$EiOTnh$OeapDaaCkcF4il*rUwQJ_34CO$Q< zVUrc`pQ>8S64n|bnS$~EkeR^G@1wtX=!=g^NNWcAf#z`G5v=zdQp6;uuqR%nKtfqH z?q6|XFE9M2A9|-wtZUV%VyU;R@#Ac)y~fN*Dng~BYTk=O6Dq~Oo08N@oa?GgLWeIW zokG|g+*4oDhVHzvAt^qxm~>C_4-okri5pal;t4V&pr#|c-UrmTh58fjOcvR1vR%6* z)2!YQH0#0a?;^K)uV+K{5!Dd7NVV^MSXa}4hjXy^*-NLh$2}L9Qt?7lKgUbt-zUf+COh@uM{u&T8>6FnN0g79%matKCJ)2d&M3ira2TUW zlkm&5>ow3MR>^K2QN^ACYR0;MRBF+ri2sms_;g|wr9tudEx;aWmvu4oWBVp~z*yCS z$7Vd8SgcLZQl6CTd5n*>toW;&t&4Kz>OYW2%l4DA9z6tnjkIVjG;jOcnxKGQus>|p zl+2y$mStl{eI8Cmqi_iMA$H$G7oo!hjlnG+^&zi*(%S*M0V=N5IZ3^YE%S-XnzOz? zw^H2y{V3cH4zpHmQ3OPTYP?bvvJx5SqR%>Gm{UpQFg_|Hg)VcKAO{eeaw*boE9^c> zDq;h6QRaPtFPBuewN+->2%yfwr3=yuo-mIb)O)`tr^=>G;L#oXyk7|ZF15p#;acU{ zPA@%_hcufCoYd9j3(+8;W z4*-(&hX1xLnbNMBojDlCB?YxJj0#jaOs&I>AE1}-coT$qYR8aHeu;csW zJsVUc_LC`?74RKAx#Gp2nh1qkDLG^;PTqq7x}`ZGmnu`aT0N|~HY~=PM6Xk*IO+v$ zHZ}osUI?mUa`tb>*S6mZQ^u~FJ6ZA)9&v%#6>DQy%=t%#Gv?)9GC}zwmUK7LhVX1`aQ6R}+}S_OkOAlDw{}64~JmKc2}AK0FYU{AF{JF$i4b z6i1fe@W%{M`}?&a-u1OkjhQmB`Ls3hmZP~CUYM>xV z#cuimMr9(utsz3H=74g*17s3=Khpj85_9kJ-&zw+!p%a8+5>3a|5tB!L|zYcQXRMK zaquc+o~hAA#i7#>18^B?A2omO#fdw0Np&9leF zZG@K21{IMmg?np#!D*0N4N`FI>~yIDRM{2iJZiT$|t;N5LC#-Q!U7W zinmHS7)#!937M!C^6R;^#3|t4VXM-F6+PRCRgs)4N@sa5^NBg@Mjt;p z4MbDwT9LY@{zkS$hrQGdg%mku77QQuuECp7maj~8xhtfX?Aq=at}0?tz#GVOd~pD6Q&UAW_HpZ=J%EGek8xH>E#$kOM*0Ce2JvZ500BwqvrEB zhK=C;S-QS1U?)O>y;g-{;^|%tRtu&W1VRdH9aWkU4e;P?`4dR(TA;>a1yoeqX8R}+ zcj9gB>f|rbrh*_B)`1|A=-49CrS`C7%dSQygHgV$#x^HyQ0PA7OmyGeRtlq&{UU`L zglB?G-kyWIkt(jvuTl6eYjjS>g8K|;Es|pnR*y%DWnYVNuq?AB!V%4){$3$NbvtcL zHZk6kLQ#HGq7H`AZoQ9ey3|6y8Fo+G*y6q|RS4Zd@|)lh!Q_L(^~5|xsnNa@CMdUO zwKE9{OB=^AltOURsT36@fBnOGH!ZC0Xu?DZ!uB&?j{}H-K=56N7L_R@=v4kNz%}0S z9z#~F*|i-6PT?YOA0tYCUm7~#N(?_kB*s+M2}d~{^M0}Y2rtstQb*$?6m|k_6J7Ea z;&gatkmAg4&zMw5D6>s3<282WFk5;n#$|YeH zzXG@GZ(3_^7tGK>?sP5T<8W{v1!=L?jSM#Qm~tr*O8&B+~gx`&iWoA%41=TZ^ra(22zj=i@Tt)&t%lLOj&9^H)$j zLt#R!bh&{v()}F?p6c1xHt1Ee!R_h^Fh17T8EBdYMaryL;t6&KQR_otD+D$(Xe)3; zL*ke4&g2gWGzna!$GZ=Nzxvh3Xd4ilTMLjJ0RRa5Uj~c+r-u5s z_$lV|A0y{5n$L)qd1KB%a)>8Cb*+IcdU?9Cw&xWYMuKbSQOsmEg+=o#_1 zw&LUDj->Skjuo6S)0WTl34%en(&dtZ(;`=?*p{k78+WzF$Qgt8y{w+Y(njj(`5r;V zT>DK0N?ch{|A{JICA{j;w=;!O#_M;@S0l$J&$7{SlMOzNVRM%HPplD+HuZ8domb7$qzk5Ney*Icv9(Ms@bjgqv|3Oh`rvASv-dU-fz zRRhl|BbxGKN?_a-c!z&xJ~ikl5@PoYr~MMDK*?a7_eF^=^;wET1P+NVe&FewbM8^#P422{jYd5a&np~I?=PJPQBw}&uRY_k8ZU&MUH-XAA|<3#PS+=xV?V%f87<+$Y; zF4P@?Fn~oVBeE_~Yb5M4?l`C9a>QZBdRJjDw?F|DxgIqDdqb%*^!9H@jZbq8)MVr1 z^0d35r=qMf7d5PC7%rooG8AF=R>%FLtau!p3Gvn>$tUWijGBJ*so^&^fF>SGTc5Cr zzy5OTl(f@+X>JdL>4lrnT8x+!NK|T-dmzl}zjESw*C58KotkOW*0)xh+i@bRgMe=6gZG6&O zw93$TET_bb1d$xEZq#$2&Je=LEJLE%GN{(eaq%b!B~(J0W5_<0u>l1w7p;+o62P2x zW%s!t5WpovuAg2=2fdvOkv>b5zzvOT#Dj+&<%ZlE-)d0GUQ!W$g2HtJ$6S2>1C+s> zk0SLbqzrVNFx*!VjFI{+v=mF@*p#7O*jr=AZ5BoqpJWZ$#zZ#ETE4eLJ4FQ*F)GDk zv8FpPlL0{k0np;$frdY2sp*Tic3(v`9n1yw)!zqbvNAji{#@$zL8KEo)ty-~qzV8- zeDBOju{eeJQ!!O#3X7$j>CfJnB$qZCJlHA2aAgyFT1W(|a;Tz?Lig;I5!l6v>+L57 z1~$%6*H|h8eR=ynwGyExQ2Huu&+U@h#~PU&iEFDHxPT?O$LT>$@TV@XvrA|dZ}kg2 zcs5pzXqMmY+{m;CCn$T$kKLq()w9ko<3fp zO~=vxYPUCLg^)CS@Eqblm9pajlr~nYwM+E!uM9c1cRI|-bKuXRc`8Lv=;z(I%!c=< z*JOD|hf3Wr+;YiMbpQk?0#zB%LOiX&1|Np?;{u!)@ zI#XfiH6zxJ8Dz(k>%lWGY-H{ZF{iw}f(TaDEiV2)msEdZ%aYK--l8F%kVi4jf;44F+#&jA;H&`#GHN?tk9>BwM?KH+$*PY*wh}7}M zgpYE^ogJvPBfi4M6_CSZ>KcsxcrQJG$6q@v;Mc@pTtgkqvZbJR(100!HfoBV_WbK1 zb>a@6MHXK$C@M_z;kZ~?d<43k60t6b$$G6|Y#A_sH$$A;$b zsOvqVPUU_Z)o~VW_reJ9vU4ds=-BUh0;FVT;_qHu*qo_5+JyZs;NS+WB-mQ=z6LQ* zOX*~${{3Xm9O?n*Rxv@zH+HItKY#iUBX0+!M0r#fw(2{GPCGO0IG`Wp8H2WAP(%nnr(oMvaULhs-*^+`yw$NfHF}lUf%Q z62Y1sJ`JVZ+SSe_3SojZ4M4m?VY)T?}??(mFN8 z22|(aE8-6g02reH02Kczt^Z2-`>VEA7FCr||NI|+L5!Ay!-gbk*Xvc+MUsc*oONo-lcCSdhdKgH&XkpjNhbcKZ;|{FkH1 z^|0E`-)+q!(m&=qP~V>A{3<^#-`0p!dk?(%qTRoHPN=n>m$AmmF4o@(#k(!SoWXD0 z`ca7z6=LSJGg>ocZVI0l)K;Xb6bWS87DDmA0lOmZk$4xbkvNxA?pnMQz#YAqSkAU7!)sfYf zqXSy#8Vh&l(5Cq!mr+>k9FQhw^VDO@m61{AME4*U3VDu`JuvA|@=Z6pW;f2{MxeZm zhxbR=h~m z7SiTmyZQtN&r_xOU`v#S{Tlqh_@La)vT5gtz$=n)Ij=w@Z!f7luXzt!zAE;kK zj-nF|hC8cqtdc`svRPRx4z%1-{J`$^gWM|m1k2^%v*53%C=Zt3hDWKL+}pmMC#!n> zs3Y1L0_VxEBlM$QdS#O}HKG~ACCjP@`}in-15T;3apkGJYm$culJB7Cp)GBU;w~+< z1M-yQ;V$CS6~Gt#A++B@*a>0BA9)Hj1l##(>Kky%2eYl{3p!X^!G^9`Gj${SZVP+e zfcoG(@vilpsu6(ASJA-A0|g9&r@53`4nJy4me9h7B*x5Lxi#`84OF=piyhzL(t2Wa+vJ3_a`mD$@DYeYvc#6voP} z(Ru66D&o@Qd1EK4r<-Lnh*6Q_l@sn0LxK+ebvLK*zwl?`WM1JilFvUCw?+|TV2r?G*6IzJ=53!^|ck2bIBhwemC zN(XL6-6}k(Hi(o}EFs7klAv;>jhJ8afdaObW?@`Gd8DNN2FF4)0IT`g_QRm~T>&4N zMs3u^lc$^q{%{rl<01vvB&SU1^qBH)7ubp+8F}z1!er%dgXRG)Ame1W<yJq;k?exmzDRMXC;P@m%fXtWl^~*7DlL@9YZl$Y|^7~li znnOnNvgO=wL;UqG(!6(}4+IPbu)OFYO{p5k)*vupJ&eoZj%no(#*&gH6}OYmH7c?U1l!((9fyS9tQYblg}vA8*YrWN|wf?;{coH$Lf2M3|j9?zop~| zvvAXGaa0=mTDT$U62v7TA3WQ?uAd8VJdA@AI>u<}f-g2Q7Qk@4Ru9-|r@alWJk$GK zbHqumx0wV%6~)%{GNRNlcc!nm`SdMcT;Z*5?GAn6@1;R;=RKKL5_kq^JHo~uAlOCd zWjgjJ+Mst%ecM9UAc~F9o}|Fl8qW2qYp=KiaDg!OLt+N4IStk&H@-{kz_rZzmM3M$ zup8FDSMh;M3wD5O?&JWbk+u2jgwckTmf^vYJJZef1}Zo`!c8Fr^!l1D;8DwuZoRn;hl9SNVvN9@(=K01iKZgwn&T>gAss*+!t2fKcv<2?9jgdULK7J*x`_6TcZq%BR5`e*&6 zFqmr}5W?VnbP5TtY45&#I48kTye>LOJ!Xd187gxI4iL3xRPWocaEx87$?fvVFcU_V zE|56aOsEK)kZ%O*)Ze2VLw8dEFD*xpVTxHYvS4wRkTc{}Hoj{%dm=mY2E@YA>RTqR z3`2ga*w*nyyc-gEHLosk!z83!#h+{n+)*)lStNyU-2A?bAr5ZLJj7Ax!RQDziY9dz zz@bafTh_}sxQ25?i0fg}Ie#aDqW^$1THp~dS8nn1V1nDBwTEiUt`MR+Za1 zDJ20|S!ADE119cZUh)iu5%#^C8*~{>h~L}81e%6qRB2|XDXg`~D-*`T@Xf2%4v$@8 zb@ZOq=zQJg(;{-~_^OvEiF@JCG>y-A!F%>gWHCC^VI}gCMNHD3@(Z0+RDI>m?d+&u zd$&qjJ8K%Nv~z5t8_P#{7Ty^`hb#ldZ~}((5cmK;^mUO=RvKy)e2kCYB|<3TzG#_1 zwsN}mgJez}9QTsO=xj3#cWSgB!Z^R?PS%^)PrEYot4h)u`X?MvAj0n88!SF#lHh_n zLTcG(h2i$(O`08CW=Z&9iZ!Qcc({J`ZXD{=SB`eWu%9_+x*+R&W)S3T%K)^)FZfQ& zMT1*CIe%rRN}p+OXaw-E-`9ya???m>HIGuqy&}sZ6t28B6K@H@g{nVn4i3Pzo;XXI z7K-H4J4%WZtra)>1m%+xp>~{NOC1Xa(Li6nHIq+7I3>}AoV{*8hrC6TuTA?LF z@78w{qMFI~XS*TbW1;Ik{Td-IEvxwv%Ds0?g*Wm9Aq}XRhZ)`(g$<7~QblF6B(mw$ zmPWZDkJJ)}bFhVd#304i_TJ`)w6rHPnkh%)qIdEgshX;YaaCc_Cso=z=f@0ITHHv$ zZ=;p~5+;g}vzO@}}nbP0nUIOK%6^)-)HykaV`(@Vzd6ELo4Z!>kqc z_cMui1yRU!TG|jghu<|(CMIP!DF9Dh)1RFN=@K&!2k}^wrx$v-g0P*J1rma6(H;#9 z)b@*`(Ap|yuFjF`oH&%&fFjI?!xg!q)Zxli50@mjduSmpyW-~wzd*$D&7Gj|?<|*2 z0imJ&+xQ<)%t|W)tnBFRMmz!h8T<%Hl;AtD-oNCQx_=@sU>gh6)B~&8?YxtP-n9+z zJKq`)^Pcw*WXJFITE1>P&u~_IY~2lLUK;N0&5KKWe6GK&64-n=Z02u|v>%w@G~jw2 zy(ZDYZSybVn1k?{rulK|U+feKWS>6rx0zR#gT??|24f~jEpZ3AH1qfxO6mKsQUwgH z&^(O`PMZoeuP4D+wr%bKyX|3g=a0Y zN#Dl+cikKwCjtm0VdTZ~12Om+>Ffx-IX1I;k=+c2RL+A%C2Z3*rwOSn zuovcGKK8JwCpkZ)6g>y@uv?tS(`}1kNVzPI#xxc<@c;uIixKrrV(xtc36rrd6SBxuZS__D zasEbt*8FfMfx5ry@+fPi_WcrW-&Ayyl_O@W^_I9O(@c0^eX~42QsTrRG8pyRln`~D zB`Gjn6Iyg(ESc~_H`m?PMoWW@7GXy*pEK~SE}9RTFB|lfkHPsT#T}LR%v#Opgor6G z(U-N-f41^skB3!51dTN?g;_3_tmCFf=zB=@4HP2In6e}1iI|dBB^Q!j$<~RoM?Kbj zr1myNUyDooy0nJOPfWA7o<>5-)<4 zKzqTO6t90$_Q1d~A^zuUDgM;^|9*-B{`L7^FRA!D?w`Ve{{sU6iUP*|+BZ<&gc}2r{8;55LtL!%zj=_`ikwZJzg~3zxLmY_1~6@bzvuCB-ORD zx6Nypylj5g`RtJ?*H&>|(HBZTc*V`8&FB;+7xuhSSqap6^yk8(Zc*2bIhUCV^$gDKi)XIQ{)sv@h z+u6B3aOe8us*nj2C+=RIJu`pF{FU)bR?ghMv(YItC^ODeQyl0JMkWzv+_4RG4G;)0 z{B;D;NO6y>5nJ4Yl!L&RMr|O8q!E%5(6yrn0a!0sdonOFVQ2>hNPssh8%P;55bg%j JZ-5FJ7yxu<@l5~# literal 0 HcmV?d00001 diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index e74a73fc29fef13b424c3cf2b7e4549e3dcbe964..3869ff4a8d03b9f7f153964ecd1bb6af62fd00ce 100644 GIT binary patch delta 26102 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 delta 3962 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 delta 1867 zcmZ{k3rtg27{~8-Z+m;Gw6r>CX@T;Vi*blXz&TxHg7byLTAdTHMLXWmwPc7-C@3s( zIz80GTsGrl4xKvLO?HjCFc%+-n-g3Uof;)($&xu2GDzH_!p^w`$CB+f{r$i5?{`n~ z{d@0u+{OofQ|kwHIxR!4^OwG?kYDN6=VxkT$dY3i7}QJ6RU*e`n2;@NiYD5@>KVHQ z6=qE}TJ13oE`zhj+T+N_W{*d^voaw8UC5dfmN<)xXUZ5c32CyMq~Yuxz@D2R&3k*6 zTI!kmhYIz-T7lZd|#kgFKGM=>8TS^{>`Sk5*nafElgX z-i=~+%tzrnjOdE;-86e3!>rQ}a+XMKd)QDGGtq)=du;=35g2s?8(2nfYF|9StO2ub zppk80b%sH%k)5b*=ZEUm=s=rO8#^}ZG&1XAGOJmc)uPO5Rc4K`$11bZImx8<1ZCDl zW!9w8S(B09lali$+046!qKKlHqJ&~Cg_FWXQA)9nVm*Z$-S@;q-8Q;Bp6bRGE?)ZbJBytKTP1O6^Bj4$m!5^qP& zHg!&BG!5OVgr4(1p+_j0R{_nu29(T;q>tMYE#Q$Z9GsjkHqqIeDf|>I6s;7yDc+~p zMX^URexD2MLsoKJyQ*bNbESk^k{ZR_dkNwKeUJn;N@?Z2T`|C+zB>sL|0EFtHy{x$ zdb$}k4NpQpJzcG6-ZP`36{Gszvq5xvBns^tHYj?2R38i%k-jLjVkG2m97#grlVtSK zNGGZpOb*P1wX{EQ92Q~|KqvkYU^~47*Ro)sh1|h{1!xn#$PtSczjEMLn*1tA#Tz(C zmAh19UANT4OXf-I(j${_j|L*}2@T}Pi+Gp{DA1pb8#FLU+2>~sbYWf#IrxwOb$DhN z^if})tA!<6)Nv zG~jW9ne4`+%#kEGj4wn(3U|aI&?WbYBTUhw4y~e}IwmOEcFXpf-@Qo0ciIFJ`jj zIUyH4cG4vB=oWZ4lBIVO#b)`D6?Ot{je`U^7z@Af@~RYAz~ZkbgIivg24;Y{sL*f4 zSS05JeNh(HyK8*n?2QX?W;#qCdp!iXAsuF9@muMx+wf^8M2d*k`zSgn0u-2{lcI{E zj-mno?u0nlFY8>e3-}Y{c=big2TS1z$YK_Tz?jYYjB>w|*(`P$iAS{@WC#@&$Kl^+ylj5EZpte`OcxzTCsN(~B`c%TfT z 0;', app_js) + self.assertIn('headerTaskState.activeItems = activeTasksFromItems(items);', 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('headerTasks.chipButton.onclick = (event) => {', app_js) self.assertIn('headerTasks.logsButton.onclick = () => {', app_js) diff --git a/webui/html/app.js b/webui/html/app.js index 76f803e..35e8e29 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -116,13 +116,19 @@ let settingsState = { }; let headerTaskState = { activeItems: [], + visibleItems: [], + recentItems: [], popoverOpen: false, pollTimer: null, lastRenderKey: "", + knownStatuses: {}, + recentExpiryMs: 4000, + paneRefreshPromise: null, }; // 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_TASK_STATUSES = new Set(["queued", "running", "cancelling"]); +const TERMINAL_OPERATION_STATUSES = new Set(["completed", "cancelled", "failed"]); const VALID_THEME_FAMILIES = [ "default", "macos-soft", @@ -3879,6 +3885,75 @@ function activeTasksFromItems(items) { 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) { return Boolean(task) && ACTIVE_OPERATION_OPERATIONS.has(task.operation) && ["queued", "running"].includes(task.status); } @@ -3979,7 +4054,7 @@ function headerTaskRenderKey(items) { } function shouldPollHeaderTasks() { - return headerTaskState.popoverOpen || headerTaskState.activeItems.length > 0; + return headerTaskState.popoverOpen || headerTaskState.activeItems.length > 0 || headerTaskState.recentItems.length > 0; } function stopHeaderTaskPolling() { @@ -4002,7 +4077,7 @@ function scheduleHeaderTaskPolling() { function setHeaderTaskPopoverOpen(nextOpen) { const elements = headerTaskElements(); - const open = Boolean(nextOpen) && headerTaskState.activeItems.length > 0; + const open = Boolean(nextOpen) && headerTaskState.visibleItems.length > 0; headerTaskState.popoverOpen = open; if (elements.chipButton) { elements.chipButton.setAttribute("aria-expanded", open ? "true" : "false"); @@ -4035,17 +4110,23 @@ function renderHeaderTaskPopover(items) { for (const task of items) { const line = formatTaskLine(task); 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"); 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"); path.className = "header-task-item-path"; path.textContent = line.path; const meta = document.createElement("div"); meta.className = "header-task-item-meta"; meta.textContent = line.meta; - row.append(title, path, meta); + row.append(heading, path, meta); const progressText = taskProgressText(task); if (progressText) { const progress = document.createElement("div"); @@ -4102,27 +4183,71 @@ function renderHeaderTaskChip(items) { if (!elements.container || !elements.chipLabel) { return; } - const hasActiveTasks = Array.isArray(items) && items.length > 0; - elements.container.classList.toggle("hidden", !hasActiveTasks); - elements.chipLabel.textContent = activeTaskChipLabel(items); - if (!hasActiveTasks) { + const activeItems = Array.isArray(items) ? items : []; + const recentItems = Array.isArray(headerTaskState.recentItems) ? headerTaskState.recentItems : []; + const visibleItems = activeItems.length > 0 ? activeItems : recentItems; + 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 = ""; setHeaderTaskPopoverOpen(false); return; } - renderHeaderTaskPopover(items); + renderHeaderTaskPopover(visibleItems); } 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); scheduleHeaderTaskPolling(); } function applyTaskSnapshot(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; updateHeaderTaskState(items); + if (shouldRefreshPanes) { + void refreshOperationPanes(); + } return items; } diff --git a/webui/html/base.css b/webui/html/base.css index cd541ec..ba84c69 100644 --- a/webui/html/base.css +++ b/webui/html/base.css @@ -143,11 +143,56 @@ body { padding: 10px 12px; } +.header-task-item-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + .header-task-item-title { font-size: 12px; 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-meta, .header-task-item-empty {