diff --git a/dream-server/extensions/services/dashboard-api/main.py b/dream-server/extensions/services/dashboard-api/main.py index 55e6173e..0facd7a9 100644 --- a/dream-server/extensions/services/dashboard-api/main.py +++ b/dream-server/extensions/services/dashboard-api/main.py @@ -37,10 +37,9 @@ from gpu import get_gpu_info from helpers import ( get_all_services, get_cached_services, set_services_cache, - get_disk_usage, get_model_info, get_bootstrap_status, + get_disk_usage, dir_size_gb, get_model_info, get_bootstrap_status, get_uptime, get_cpu_metrics, get_ram_metrics, get_llama_metrics, get_loaded_model, get_llama_context_size, - dir_size_gb, ) from agent_monitor import collect_metrics diff --git a/dream-server/extensions/services/dashboard-api/routers/extensions.py b/dream-server/extensions/services/dashboard-api/routers/extensions.py index 2c74948a..2720ae57 100644 --- a/dream-server/extensions/services/dashboard-api/routers/extensions.py +++ b/dream-server/extensions/services/dashboard-api/routers/extensions.py @@ -18,6 +18,7 @@ import yaml from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel from config import ( AGENT_URL, CORE_SERVICE_IDS, DATA_DIR, @@ -278,6 +279,23 @@ def _copytree_safe(src: Path, dst: Path) -> None: shutil.copytree(src, dst, ignore=_ignore_special) +def _get_service_data_info(service_id: str) -> dict | None: + """Return data directory info for a service, or None if no data dir exists.""" + from helpers import dir_size_gb # noqa: PLC0415 — deferred to avoid circular import at module level + data_path = (Path(DATA_DIR) / service_id).resolve() + if not data_path.is_relative_to(Path(DATA_DIR).resolve()): + return None + if not data_path.is_dir(): + return None + size_gb = dir_size_gb(data_path) + return { + "path": f"data/{service_id}", + "size_gb": size_gb, + "preserved": True, + "purge_command": f"dream purge {service_id}", + } + + # --- Host Agent Helpers --- _AGENT_TIMEOUT = 300 # seconds — image pulls can take several minutes on first install @@ -770,6 +788,7 @@ def disable_extension(service_id: str, api_key: str = Depends(verify_api_key)): "action": "disabled", "restart_required": not agent_ok, "dependents_warning": dependents_warning, + "data_info": _get_service_data_info(service_id), "message": message, } @@ -815,6 +834,78 @@ def uninstall_extension(service_id: str, api_key: str = Depends(verify_api_key)) return { "id": service_id, "action": "uninstalled", + "data_info": _get_service_data_info(service_id), "message": "Extension uninstalled. Docker volumes may remain — run 'docker volume ls' to check.", "cleanup_hint": f"To remove orphaned volumes: docker volume ls --filter 'name={service_id}' -q | xargs docker volume rm", } + + +class PurgeRequest(BaseModel): + confirm: bool = False + + +@router.delete("/api/extensions/{service_id}/data") +def purge_extension_data( + service_id: str, + body: PurgeRequest, + api_key: str = Depends(verify_api_key), +): + """Permanently delete service data directory.""" + if not _SERVICE_ID_RE.match(service_id): + raise HTTPException(status_code=404, detail=f"Invalid service_id: {service_id}") + + if service_id in CORE_SERVICE_IDS: + raise HTTPException(status_code=403, detail="Cannot purge core service data") + + with _extensions_lock(): + # Check if service is still enabled (built-in or user extension) + for check_dir in [Path(EXTENSIONS_DIR) / service_id, USER_EXTENSIONS_DIR / service_id]: + if (check_dir / "compose.yaml").exists(): + raise HTTPException(status_code=400, detail=f"{service_id} is still enabled. Disable it first.") + + data_path = (Path(DATA_DIR) / service_id).resolve() + if not data_path.is_relative_to(Path(DATA_DIR).resolve()): + raise HTTPException(status_code=400, detail="Invalid data path") + + if not data_path.is_dir(): + raise HTTPException(status_code=404, detail=f"No data directory found for {service_id}") + + if not body.confirm: + raise HTTPException(status_code=400, detail="Confirmation required: set confirm=true") + + from helpers import dir_size_gb # noqa: PLC0415 + size_gb = dir_size_gb(data_path) + + shutil.rmtree(data_path, ignore_errors=True) + + if data_path.exists(): + raise HTTPException(status_code=500, detail=f"Could not fully remove data/{service_id}. Some files may be owned by root.") + + return {"id": service_id, "action": "purged", "size_gb_freed": size_gb} + + +@router.get("/api/storage/orphaned") +def orphaned_storage(api_key: str = Depends(verify_api_key)): + """Find data directories not belonging to any known service.""" + from helpers import dir_size_gb # noqa: PLC0415 + + data_path = Path(DATA_DIR) + if not data_path.is_dir(): + return {"orphaned": [], "total_gb": 0} + + # Known system directories that are not service data + system_dirs = {"models", "config", "user-extensions", "extensions-library"} + known_ids = set(SERVICES.keys()) | system_dirs + + orphaned = [] + total = 0.0 + for child in sorted(data_path.iterdir()): + if not child.is_dir(): + continue + if child.name in known_ids: + continue + size = dir_size_gb(child) + orphaned.append({"name": child.name, "size_gb": size, "path": f"data/{child.name}"}) + total += size + + return {"orphaned": orphaned, "total_gb": round(total, 2)} diff --git a/dream-server/extensions/services/dashboard-api/tests/test_extensions.py b/dream-server/extensions/services/dashboard-api/tests/test_extensions.py index 550549e0..94ffae69 100644 --- a/dream-server/extensions/services/dashboard-api/tests/test_extensions.py +++ b/dream-server/extensions/services/dashboard-api/tests/test_extensions.py @@ -738,6 +738,7 @@ def test_path_traversal_all_mutations(self, test_client, monkeypatch, tmp_path): ("POST", "/api/extensions/{}/enable"), ("POST", "/api/extensions/{}/disable"), ("DELETE", "/api/extensions/{}"), + ("DELETE", "/api/extensions/{}/data"), ] for bad_id in bad_ids: @@ -747,6 +748,12 @@ def test_path_traversal_all_mutations(self, test_client, monkeypatch, tmp_path): resp = test_client.post( url, headers=test_client.auth_headers, ) + elif "/data" in pattern: + # Purge endpoint requires a JSON body (PurgeRequest) + resp = test_client.request( + "DELETE", url, headers=test_client.auth_headers, + json={"confirm": False}, + ) else: resp = test_client.delete( url, headers=test_client.auth_headers, diff --git a/dream-server/extensions/services/dashboard/src/pages/Extensions.jsx b/dream-server/extensions/services/dashboard/src/pages/Extensions.jsx index a47cd04d..78fb7daa 100644 --- a/dream-server/extensions/services/dashboard/src/pages/Extensions.jsx +++ b/dream-server/extensions/services/dashboard/src/pages/Extensions.jsx @@ -37,6 +37,10 @@ const friendlyError = (detail) => { return 'This extension is already disabled.' if (detail.includes('Disable extension before')) return 'Please disable this extension before removing it.' + if (detail.includes('still enabled')) + return 'Please disable this extension before purging its data.' + if (detail.includes('No data directory')) + return 'No data directory found for this extension.' if (detail.includes('Missing dependencies')) return detail return detail @@ -104,17 +108,31 @@ export default function Extensions() { try { const url = action === 'uninstall' ? `/api/extensions/${serviceId}` + : action === 'purge' + ? `/api/extensions/${serviceId}/data` : `/api/extensions/${serviceId}/${action}` - const res = await fetch(url, { - method: action === 'uninstall' ? 'DELETE' : 'POST', + const opts = { + method: action === 'uninstall' || action === 'purge' ? 'DELETE' : 'POST', signal: AbortSignal.timeout(120000), - }) + } + if (action === 'purge') { + opts.headers = { 'Content-Type': 'application/json' } + opts.body = JSON.stringify({ confirm: true }) + } + const res = await fetch(url, opts) if (!res.ok) { const err = await res.json().catch(() => ({})) throw new Error(err.detail || `Failed to ${action}`) } const data = await res.json() - const successText = data.message || (action === 'uninstall' ? 'Extension removed' : `Extension ${action}d`) + let successText = data.message || ( + action === 'uninstall' ? 'Extension removed' : + action === 'purge' ? `Data purged — ${data.size_gb_freed ?? 0} GB freed` : + `Extension ${action}d` + ) + if (data.data_info) { + successText += ` Data preserved (${data.data_info.size_gb} GB) — purge to remove.` + } if (data.restart_required) { setToast({ type: 'info', text: `${successText} — restart needed to apply.` }) } else { @@ -134,6 +152,7 @@ export default function Extensions() { enable: `Enable ${ext.name}? The service will be started.`, disable: `Disable ${ext.name}? The service will be stopped.`, uninstall: `Remove ${ext.name}? You can reinstall it from the library.`, + purge: `Permanently delete all data for ${ext.name}? This cannot be undone.`, } setConfirm({ action, ext, message: messages[action] }) } @@ -304,7 +323,7 @@ export default function Extensions() {
setConfirm(null)}>
e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Confirm action">

- {confirm.action === 'uninstall' ? 'Remove' : confirm.action.charAt(0).toUpperCase() + confirm.action.slice(1)} Extension + {confirm.action === 'uninstall' ? 'Remove' : confirm.action === 'purge' ? 'Purge Data —' : confirm.action.charAt(0).toUpperCase() + confirm.action.slice(1)} Extension

{confirm.message}

@@ -312,11 +331,11 @@ export default function Extensions() {
@@ -455,6 +474,16 @@ function ExtensionCard({ ext, gpuBackend, agentAvailable, onDetails, onConsole, {isMutating ? : <> Remove} )} + {showRemove && ( + + )} {isUserExt && status === 'enabled' && ( Disable to remove )}