Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions dream-server/extensions/services/dashboard-api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
}

Expand Down Expand Up @@ -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)}
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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] })
}
Expand Down Expand Up @@ -304,19 +323,19 @@ export default function Extensions() {
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setConfirm(null)}>
<div className="bg-theme-card border border-theme-border rounded-xl p-6 max-w-md mx-4" onClick={e => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Confirm action">
<h3 className="text-lg font-semibold text-theme-text mb-2">
{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
</h3>
<p className="text-sm text-theme-text-muted mb-4">{confirm.message}</p>
<div className="flex justify-end gap-3">
<button onClick={() => setConfirm(null)} autoFocus className="px-4 py-2 text-sm text-theme-text-muted hover:text-theme-text transition-colors">Cancel</button>
<button
onClick={() => handleMutation(confirm.ext.id, confirm.action)}
className={`px-4 py-2 text-sm rounded-lg transition-colors ${
confirm.action === 'uninstall' ? 'bg-red-500/20 text-red-300 hover:bg-red-500/30' :
confirm.action === 'uninstall' || confirm.action === 'purge' ? 'bg-red-500/20 text-red-300 hover:bg-red-500/30' :
'bg-theme-accent/20 text-theme-accent-light hover:bg-theme-accent/30'
}`}
>
{confirm.action === 'uninstall' ? 'Remove' : confirm.action.charAt(0).toUpperCase() + confirm.action.slice(1)}
{confirm.action === 'uninstall' ? 'Remove' : confirm.action === 'purge' ? 'Purge' : confirm.action.charAt(0).toUpperCase() + confirm.action.slice(1)}
</button>
</div>
</div>
Expand Down Expand Up @@ -455,6 +474,16 @@ function ExtensionCard({ ext, gpuBackend, agentAvailable, onDetails, onConsole,
{isMutating ? <Loader2 size={12} className="animate-spin" /> : <><Trash2 size={12} /> Remove</>}
</button>
)}
{showRemove && (
<button
disabled={actionDisabled}
title={disabledTitle || 'Permanently delete service data'}
onClick={() => onAction(ext, 'purge')}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-theme-card text-amber-400 hover:bg-amber-500/20 hover:text-amber-300 transition-colors disabled:opacity-50"
>
{isMutating ? <Loader2 size={12} className="animate-spin" /> : <><Database size={12} /> Purge Data</>}
</button>
)}
{isUserExt && status === 'enabled' && (
<span className="text-[10px] text-theme-text-muted">Disable to remove</span>
)}
Expand Down
Loading