diff --git a/dream-server/docker-compose.base.yml b/dream-server/docker-compose.base.yml index 7377c769..2b4596ad 100644 --- a/dream-server/docker-compose.base.yml +++ b/dream-server/docker-compose.base.yml @@ -177,7 +177,9 @@ services: - ./scripts:/dream-server/scripts:ro - ./config:/dream-server/config:ro - ./extensions:/dream-server/extensions:ro - - ./.env:/dream-server/.env:ro + - ./.env:/dream-server/.env + - ./.env.example:/dream-server/.env.example:ro + - ./.env.schema.json:/dream-server/.env.schema.json:ro - ./data:/data deploy: resources: diff --git a/dream-server/extensions/services/dashboard-api/main.py b/dream-server/extensions/services/dashboard-api/main.py index c764aa3f..cbe7b790 100644 --- a/dream-server/extensions/services/dashboard-api/main.py +++ b/dream-server/extensions/services/dashboard-api/main.py @@ -24,9 +24,9 @@ import time from datetime import datetime, timezone from pathlib import Path -from typing import Optional +from typing import Any, Optional -from fastapi import FastAPI, Depends, HTTPException +from fastapi import FastAPI, Depends, HTTPException, Body from fastapi.middleware.cors import CORSMiddleware # --- Local modules --- @@ -78,8 +78,16 @@ def set(self, key: str, value: object, ttl: float): _STATUS_CACHE_TTL = 2.0 _STORAGE_CACHE_TTL = 30.0 _SETTINGS_SUMMARY_CACHE_TTL = 5.0 +_SETTINGS_CONFIG_CACHE_TTL = 15.0 +_SETTINGS_ENV_CACHE_TTL = 5.0 _SERVICE_POLL_INTERVAL = 10.0 # background health check interval +_ENV_ASSIGNMENT_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)=(.*)$") +_ENV_COMMENTED_ASSIGNMENT_RE = re.compile(r"^\s*#\s*([A-Za-z_][A-Za-z0-9_]*)=(.*)$") +_SENSITIVE_ENV_KEY_RE = re.compile( + r"(SECRET|TOKEN|PASSWORD|(?:^|_)PASS(?:$|_)|API_KEY|PRIVATE_KEY|ENCRYPTION_KEY|(?:^|_)SALT(?:$|_))" +) + # --- Router imports --- from routers import workflows, features, setup, updates, agents, privacy, extensions, gpu as gpu_router, resources @@ -252,6 +260,459 @@ def _fallback_services() -> list[dict]: }) return links + +def _resolve_runtime_env_path() -> Path: + install_root = _resolve_install_root() + env_path = install_root / ".env" + if env_path.exists(): + return env_path + return Path(INSTALL_DIR) / ".env" + + +def _resolve_bundled_path(name: str) -> Path: + return Path(__file__).resolve().parent / name + + +def _resolve_template_path(name: str) -> Path: + install_root = _resolve_install_root() + for candidate in ( + install_root / name, + _resolve_bundled_path(name), + Path(INSTALL_DIR) / name, + ): + if candidate.exists(): + return candidate + return _resolve_bundled_path(name) + + +def _strip_env_quotes(value: str) -> str: + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + return value[1:-1] + return value + + +def _read_env_map_from_path(path: Path) -> tuple[dict[str, str], list[dict[str, Any]]]: + try: + return _parse_env_text(path.read_text(encoding="utf-8")) + except OSError: + return {}, [] + + +def _parse_env_text(raw_text: str) -> tuple[dict[str, str], list[dict[str, Any]]]: + values: dict[str, str] = {} + issues: list[dict[str, Any]] = [] + + for index, line in enumerate(raw_text.splitlines(), start=1): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + + match = _ENV_ASSIGNMENT_RE.match(line) + if not match: + issues.append({ + "key": None, + "line": index, + "message": "Line is not a valid KEY=value entry.", + }) + continue + + key, value = match.groups() + values[key] = _strip_env_quotes(value) + + return values, issues + + +def _normalize_bool(value: Any) -> Optional[str]: + if isinstance(value, bool): + return "true" if value else "false" + text = str(value).strip().lower() + if text in {"true", "1", "yes", "on"}: + return "true" + if text in {"false", "0", "no", "off"}: + return "false" + return None + + +def _humanize_env_key(key: str) -> str: + return key.replace("_", " ").title().replace("Llm", "LLM").replace("Api", "API").replace("Gpu", "GPU") + + +def _is_secret_field(key: str, definition: Optional[dict[str, Any]] = None) -> bool: + if definition is not None and "secret" in definition: + return bool(definition.get("secret")) + + upper_key = key.upper() + if "PUBLIC_KEY" in upper_key: + return False + return bool(_SENSITIVE_ENV_KEY_RE.search(upper_key)) + + +def _slugify(text: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") + + +def _load_env_schema() -> tuple[dict[str, Any], set[str]]: + schema_path = _resolve_template_path(".env.schema.json") + if not schema_path.exists(): + return {}, set() + + try: + schema = json.loads(schema_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, ValueError): + return {}, set() + + properties = schema.get("properties", {}) + required = set(schema.get("required", [])) + if not isinstance(properties, dict): + properties = {} + return properties, required + + +def _build_env_sections(schema_keys: list[str]) -> list[dict[str, Any]]: + example_path = _resolve_template_path(".env.example") + if not example_path.exists(): + return [{ + "id": "configuration", + "title": "Configuration", + "keys": schema_keys, + }] + + try: + lines = example_path.read_text(encoding="utf-8").splitlines() + except OSError: + return [{ + "id": "configuration", + "title": "Configuration", + "keys": schema_keys, + }] + + sections: list[dict[str, Any]] = [] + section_index: dict[str, dict[str, Any]] = {} + current = {"id": "configuration", "title": "Configuration", "keys": []} + sections.append(current) + section_index[current["id"]] = current + + def ensure_section(title: str) -> dict[str, Any]: + slug = _slugify(title) or "configuration" + if slug in section_index: + return section_index[slug] + section = {"id": slug, "title": title, "keys": []} + sections.append(section) + section_index[slug] = section + return section + + idx = 0 + while idx < len(lines): + if ( + idx + 2 < len(lines) + and lines[idx].lstrip().startswith("#") + and set(lines[idx].replace("#", "").strip()) <= {"═"} + and lines[idx + 1].lstrip().startswith("#") + and set(lines[idx + 2].replace("#", "").strip()) <= {"═"} + ): + title = lines[idx + 1].lstrip("#").strip() + if title: + current = ensure_section(title) + idx += 3 + continue + + match = _ENV_ASSIGNMENT_RE.match(lines[idx]) or _ENV_COMMENTED_ASSIGNMENT_RE.match(lines[idx]) + if match: + key = match.group(1) + if key in schema_keys and key not in current["keys"]: + current["keys"].append(key) + idx += 1 + + remaining = [key for key in schema_keys if not any(key in section["keys"] for section in sections)] + if remaining: + extra = ensure_section("Advanced") + extra["keys"].extend(remaining) + + return [section for section in sections if section["keys"]] + + +def _build_env_fields( + schema_properties: dict[str, Any], + required_keys: set[str], + values: dict[str, str], +) -> dict[str, dict[str, Any]]: + fields: dict[str, dict[str, Any]] = {} + + for key, definition in schema_properties.items(): + field_type = definition.get("type", "string") + value = values.get(key, "") + fields[key] = { + "key": key, + "label": _humanize_env_key(key), + "type": field_type, + "description": definition.get("description", ""), + "required": key in required_keys, + "secret": _is_secret_field(key, definition), + "enum": definition.get("enum", []), + "default": definition.get("default"), + "value": value, + "hasValue": value != "", + } + + for key, value in values.items(): + if key in fields: + fields[key]["value"] = value + fields[key]["hasValue"] = value != "" + continue + fields[key] = { + "key": key, + "label": _humanize_env_key(key), + "type": "string", + "description": "Local override not described by the built-in schema.", + "required": False, + "secret": _is_secret_field(key), + "enum": [], + "default": None, + "value": value, + "hasValue": value != "", + } + + return fields + + +def _validate_env_values( + values: dict[str, str], + fields: dict[str, dict[str, Any]], + parse_issues: Optional[list[dict[str, Any]]] = None, +) -> list[dict[str, Any]]: + issues = list(parse_issues or []) + + for key, field in fields.items(): + value = values.get(key, "") + field_type = field.get("type", "string") + required = field.get("required", False) + enum_values = field.get("enum") or [] + + if value == "": + if required: + issues.append({"key": key, "message": "Required value is missing."}) + continue + + if enum_values and value not in enum_values: + issues.append({"key": key, "message": f"Must be one of: {', '.join(enum_values)}."}) + continue + + if field_type == "integer": + try: + int(str(value).strip()) + except (TypeError, ValueError): + issues.append({"key": key, "message": "Must be a whole number."}) + elif field_type == "boolean": + if _normalize_bool(value) is None: + issues.append({"key": key, "message": "Must be true or false."}) + + return issues + + +def _serialize_form_values( + raw_values: dict[str, Any], + fields: dict[str, dict[str, Any]], + current_values: Optional[dict[str, str]] = None, +) -> dict[str, str]: + serialized: dict[str, str] = {} + current_values = current_values or {} + + for key, field in fields.items(): + value = raw_values.get(key, current_values.get(key, "")) + if value is None: + serialized[key] = current_values.get(key, "") if field.get("secret") else "" + continue + + field_type = field.get("type", "string") + if field.get("secret") and str(value).strip() == "": + serialized[key] = current_values.get(key, "") + continue + if field_type == "boolean": + normalized = _normalize_bool(value) + serialized[key] = normalized if normalized is not None else str(value).strip() + elif field_type == "integer": + serialized[key] = str(value).strip() + else: + serialized[key] = str(value) + + return serialized + + +def _render_env_from_values(values: dict[str, str]) -> str: + example_path = _resolve_template_path(".env.example") + seen: set[str] = set() + output_lines: list[str] = [] + + if example_path.exists(): + try: + example_lines = example_path.read_text(encoding="utf-8").splitlines() + except OSError: + example_lines = [] + else: + example_lines = [] + + for line in example_lines: + assignment = _ENV_ASSIGNMENT_RE.match(line) + commented_assignment = _ENV_COMMENTED_ASSIGNMENT_RE.match(line) + + if assignment: + key = assignment.group(1) + output_lines.append(f"{key}={values.get(key, '')}") + seen.add(key) + continue + + if commented_assignment: + key = commented_assignment.group(1) + seen.add(key) + value = values.get(key, "") + if value != "": + output_lines.append(f"{key}={value}") + else: + output_lines.append(line) + continue + + output_lines.append(line) + + extras = [(key, value) for key, value in values.items() if key not in seen and value != ""] + if extras: + if output_lines and output_lines[-1] != "": + output_lines.append("") + output_lines.extend([ + "# Additional Local Overrides", + "# Values below were preserved because they are not part of .env.example.", + ]) + for key, value in extras: + output_lines.append(f"{key}={value}") + + return "\n".join(output_lines).rstrip() + "\n" + + +def _clear_settings_caches(): + for key in ("settings_summary", "settings_env", "status"): + _cache._store.pop(key, None) + + +def _build_settings_env_payload(*, raw_text: Optional[str] = None, backup_path: Optional[str] = None) -> dict: + env_path = _resolve_runtime_env_path() + if raw_text is None: + try: + raw_text = env_path.read_text(encoding="utf-8") + except OSError: + raw_text = "" + + values, parse_issues = _parse_env_text(raw_text) + schema_properties, required_keys = _load_env_schema() + fields = _build_env_fields(schema_properties, required_keys, values) + sections = _build_env_sections(list(fields.keys())) + issues = _validate_env_values(values, fields, parse_issues) + public_fields: dict[str, dict[str, Any]] = {} + public_values: dict[str, str] = {} + + for key, field in fields.items(): + public_field = {**field} + if field.get("secret"): + public_field["value"] = "" + public_values[key] = "" + else: + public_values[key] = field["value"] + public_fields[key] = public_field + + return { + "path": _relative_install_path(env_path), + "raw": "", + "values": public_values, + "fields": public_fields, + "sections": sections, + "issues": issues, + "saveHint": "Saving writes the .env file directly, keeps existing secret values when left blank, never sends stored secrets back to the browser, and stores a timestamped backup under data/config-backups first.", + "restartHint": "Most DreamServer services need a rebuild or restart before changed values fully take effect.", + "backupPath": backup_path, + } + + +def _relative_install_path(path: Path) -> str: + try: + return str(path.relative_to(_resolve_install_root())).replace("\\", "/") + except ValueError: + return str(path).replace("\\", "/") + + +def _resolve_env_backup_root() -> Path: + backup_root = Path(DATA_DIR) / "config-backups" + backup_root.mkdir(parents=True, exist_ok=True) + return backup_root + + +def _display_backup_path(path: Path) -> str: + data_root = Path(DATA_DIR) + try: + return f"data/{path.relative_to(data_root).as_posix()}" + except ValueError: + return str(path).replace("\\", "/") + + +def _write_text_atomic(path: Path, raw_text: str): + temp_path = path.with_name(f"{path.name}.tmp-{os.getpid()}-{int(time.time() * 1000)}") + try: + try: + temp_path.write_text(raw_text, encoding="utf-8") + if path.exists(): + try: + shutil.copymode(path, temp_path) + except OSError: + pass + os.replace(temp_path, path) + return + except PermissionError: + if not path.exists(): + raise + path.write_text(raw_text, encoding="utf-8") + return + finally: + try: + if temp_path.exists(): + temp_path.unlink() + except OSError: + pass + + +def _prepare_env_save(payload: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]: + mode = payload.get("mode", "form") + env_path = _resolve_runtime_env_path() + current_values, _ = _read_env_map_from_path(env_path) + schema_properties, required_keys = _load_env_schema() + + if mode != "form": + raise HTTPException( + status_code=400, + detail={"message": "Only form-based editing is supported for security reasons."}, + ) + + submitted_values = payload.get("values", {}) + if not isinstance(submitted_values, dict): + raise HTTPException( + status_code=400, + detail={"message": "Form configuration payload must be an object."}, + ) + + base_fields = _build_env_fields(schema_properties, required_keys, current_values) + invalid_keys = sorted(set(submitted_values.keys()) - set(base_fields.keys())) + if invalid_keys: + return _render_env_from_values(current_values), [ + { + "key": key, + "message": "Field is not editable from the dashboard. Only schema-backed fields and existing local overrides can be changed here.", + } + for key in invalid_keys + ] + + normalized_values = _serialize_form_values(submitted_values, base_fields, current_values) + merged_values = {**current_values, **normalized_values} + merged_fields = _build_env_fields(schema_properties, required_keys, merged_values) + issues = _validate_env_values(merged_values, merged_fields) + return _render_env_from_values(merged_values), issues + # --- App --- app = FastAPI( @@ -713,6 +1174,69 @@ async def api_settings_summary(api_key: str = Depends(verify_api_key)): return result +@app.get("/api/settings/env") +async def api_settings_env(api_key: str = Depends(verify_api_key)): + cached = _cache.get("settings_env") + if cached is not None: + return cached + + result = await asyncio.to_thread(_build_settings_env_payload) + _cache.set("settings_env", result, _SETTINGS_ENV_CACHE_TTL) + return result + + +@app.put("/api/settings/env") +async def api_settings_env_save( + payload: dict[str, Any] = Body(...), + api_key: str = Depends(verify_api_key), +): + env_path = _resolve_runtime_env_path() + raw_text, issues = await asyncio.to_thread(_prepare_env_save, payload) + if issues: + raise HTTPException( + status_code=400, + detail={ + "message": "Configuration validation failed.", + "issues": issues, + }, + ) + + env_path.parent.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + backup_root = await asyncio.to_thread(_resolve_env_backup_root) + backup_path = backup_root / f".env.backup.{timestamp}" + backup_relative = None + + if env_path.exists(): + try: + shutil.copy2(env_path, backup_path) + backup_relative = _display_backup_path(backup_path) + except OSError as exc: + raise HTTPException( + status_code=500, + detail={ + "message": "Could not create a configuration backup before saving.", + "reason": str(exc), + }, + ) from exc + + try: + await asyncio.to_thread(_write_text_atomic, env_path, raw_text) + except OSError as exc: + raise HTTPException( + status_code=500, + detail={ + "message": "Could not write the updated environment file.", + "reason": str(exc), + }, + ) from exc + + _clear_settings_caches() + result = await asyncio.to_thread(_build_settings_env_payload, raw_text=raw_text, backup_path=backup_relative) + _cache.set("settings_env", result, _SETTINGS_ENV_CACHE_TTL) + return result + + # --- Service Health Polling --- async def _get_services() -> list[ServiceStatus]: diff --git a/dream-server/extensions/services/dashboard-api/tests/conftest.py b/dream-server/extensions/services/dashboard-api/tests/conftest.py index 9f626dcf..9e4f9674 100644 --- a/dream-server/extensions/services/dashboard-api/tests/conftest.py +++ b/dream-server/extensions/services/dashboard-api/tests/conftest.py @@ -3,6 +3,7 @@ import json import os import sys +import types from pathlib import Path from unittest.mock import AsyncMock, MagicMock @@ -21,6 +22,16 @@ os.environ.setdefault("DREAM_EXTENSIONS_DIR", "/tmp/dream-test-extensions") os.environ.setdefault("GPU_BACKEND", "nvidia") +if "fcntl" not in sys.modules: + try: + import fcntl # type: ignore # noqa: F401 + except ModuleNotFoundError: + sys.modules["fcntl"] = types.SimpleNamespace( + LOCK_EX=0, + LOCK_UN=0, + flock=lambda *args, **kwargs: None, + ) + FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures" diff --git a/dream-server/extensions/services/dashboard-api/tests/test_settings_env.py b/dream-server/extensions/services/dashboard-api/tests/test_settings_env.py new file mode 100644 index 00000000..7133ddc2 --- /dev/null +++ b/dream-server/extensions/services/dashboard-api/tests/test_settings_env.py @@ -0,0 +1,187 @@ +"""Security-focused tests for the Settings environment editor.""" + +import json + +import pytest + + +@pytest.fixture() +def settings_env_fixture(tmp_path, monkeypatch): + install_root = tmp_path / "dream-server" + install_root.mkdir() + data_root = tmp_path / "data" + data_root.mkdir() + + env_path = install_root / ".env" + example_path = install_root / ".env.example" + schema_path = install_root / ".env.schema.json" + + env_path.write_text( + "OPENAI_API_KEY=sk-live-secret\n" + "LLM_BACKEND=local\n" + "WEBUI_AUTH=true\n", + encoding="utf-8", + ) + + example_path.write_text( + "# ════════════════════════════════\n" + "# LLM Settings\n" + "# ════════════════════════════════\n" + "OPENAI_API_KEY=\n" + "LLM_BACKEND=local\n" + "WEBUI_AUTH=true\n", + encoding="utf-8", + ) + + schema_path.write_text( + json.dumps( + { + "type": "object", + "properties": { + "OPENAI_API_KEY": { + "type": "string", + "description": "Key used for cloud LLM providers.", + "secret": True, + }, + "LLM_BACKEND": { + "type": "string", + "description": "Primary LLM backend mode.", + "enum": ["local", "cloud"], + "default": "local", + }, + "WEBUI_AUTH": { + "type": "boolean", + "description": "Require login for the WebUI.", + "default": True, + }, + }, + } + ), + encoding="utf-8", + ) + + monkeypatch.setattr("main._resolve_install_root", lambda: install_root) + monkeypatch.setattr("main._resolve_runtime_env_path", lambda: env_path) + monkeypatch.setattr("main.DATA_DIR", str(data_root)) + + def fake_resolve_template(name: str): + if name == ".env.example": + return example_path + if name == ".env.schema.json": + return schema_path + return install_root / name + + monkeypatch.setattr("main._resolve_template_path", fake_resolve_template) + + from main import _cache + + _cache._store.clear() + + return { + "install_root": install_root, + "data_root": data_root, + "env_path": env_path, + } + + +def test_api_settings_env_masks_secret_values(test_client, settings_env_fixture): + response = test_client.get("/api/settings/env", headers=test_client.auth_headers) + + assert response.status_code == 200 + payload = response.json() + + assert payload["path"] == ".env" + assert payload["raw"] == "" + assert payload["values"]["OPENAI_API_KEY"] == "" + assert payload["fields"]["OPENAI_API_KEY"]["value"] == "" + assert payload["fields"]["OPENAI_API_KEY"]["hasValue"] is True + assert payload["fields"]["OPENAI_API_KEY"]["secret"] is True + assert payload["values"]["LLM_BACKEND"] == "local" + assert payload["fields"]["LLM_BACKEND"]["value"] == "local" + + +def test_api_settings_env_preserves_existing_secret_when_blank(test_client, settings_env_fixture): + env_path = settings_env_fixture["env_path"] + + response = test_client.put( + "/api/settings/env", + headers=test_client.auth_headers, + json={ + "mode": "form", + "values": { + "OPENAI_API_KEY": "", + "LLM_BACKEND": "cloud", + "WEBUI_AUTH": "false", + }, + }, + ) + + assert response.status_code == 200 + payload = response.json() + updated_env = env_path.read_text(encoding="utf-8") + + assert "OPENAI_API_KEY=sk-live-secret" in updated_env + assert "LLM_BACKEND=cloud" in updated_env + assert "WEBUI_AUTH=false" in updated_env + assert payload["values"]["OPENAI_API_KEY"] == "" + assert payload["fields"]["OPENAI_API_KEY"]["hasValue"] is True + assert payload["backupPath"].startswith("data/config-backups/.env.backup.") + + +def test_api_settings_env_rejects_raw_mode(test_client, settings_env_fixture): + response = test_client.put( + "/api/settings/env", + headers=test_client.auth_headers, + json={"mode": "raw", "raw": "OPENAI_API_KEY=oops\n"}, + ) + + assert response.status_code == 400 + payload = response.json() + assert payload["detail"]["message"] == "Only form-based editing is supported for security reasons." + + +def test_api_settings_env_rejects_new_unknown_keys(test_client, settings_env_fixture): + response = test_client.put( + "/api/settings/env", + headers=test_client.auth_headers, + json={ + "mode": "form", + "values": { + "OPENAI_API_KEY": "", + "INJECTED_FLAG": "true", + }, + }, + ) + + assert response.status_code == 400 + payload = response.json() + assert payload["detail"]["message"] == "Configuration validation failed." + assert payload["detail"]["issues"] == [ + { + "key": "INJECTED_FLAG", + "message": "Field is not editable from the dashboard. Only schema-backed fields and existing local overrides can be changed here.", + } + ] + + +def test_api_settings_env_allows_existing_local_override(test_client, settings_env_fixture): + env_path = settings_env_fixture["env_path"] + env_path.write_text( + env_path.read_text(encoding="utf-8") + "LOCAL_OVERRIDE=keep-me\n", + encoding="utf-8", + ) + + response = test_client.put( + "/api/settings/env", + headers=test_client.auth_headers, + json={ + "mode": "form", + "values": { + "LOCAL_OVERRIDE": "updated", + }, + }, + ) + + assert response.status_code == 200 + updated_env = env_path.read_text(encoding="utf-8") + assert "LOCAL_OVERRIDE=updated" in updated_env diff --git a/dream-server/extensions/services/dashboard/src/components/__tests__/EnvEditor.test.jsx b/dream-server/extensions/services/dashboard/src/components/__tests__/EnvEditor.test.jsx new file mode 100644 index 00000000..36866361 --- /dev/null +++ b/dream-server/extensions/services/dashboard/src/components/__tests__/EnvEditor.test.jsx @@ -0,0 +1,81 @@ +import { screen } from '@testing-library/react' +import { render } from '../../test/test-utils' +import EnvEditor from '../settings/EnvEditor' // eslint-disable-line no-unused-vars + +const baseEditor = { + path: '.env', + saveHint: 'Saving keeps existing secret values when left blank.', + restartHint: 'Restart to apply service-level changes.', + backupPath: null, +} + +const baseFields = { + OPENAI_API_KEY: { + key: 'OPENAI_API_KEY', + label: 'OpenAI API Key', + type: 'string', + description: 'Cloud provider API key.', + required: false, + secret: true, + hasValue: true, + enum: [], + default: null, + }, +} + +const baseSections = [ + { + id: 'llm-settings', + title: 'LLM Settings', + keys: ['OPENAI_API_KEY'], + }, +] + +const renderEditor = (overrides = {}) => + render( + {}} + sections={baseSections} + activeSection={baseSections[0]} + onSectionChange={() => {}} + fields={baseFields} + values={{ OPENAI_API_KEY: '' }} + issues={[]} + issueMap={{}} + revealedSecrets={{}} + onToggleReveal={() => {}} + onFieldChange={() => {}} + onReload={() => {}} + onSave={() => {}} + dirty={false} + saving={false} + {...overrides} + /> + ) + +describe('EnvEditor', () => { + test('renders stored secrets as masked placeholders instead of exposing values', () => { + renderEditor() + + expect(screen.getByRole('textbox', { name: /filter configuration fields/i })).toBeInTheDocument() + expect(screen.getByPlaceholderText('Stored locally')).toBeInTheDocument() + expect(screen.getByText(/Leave blank to keep the stored secret/i)).toBeInTheDocument() + expect(screen.queryByDisplayValue('sk-live-secret')).not.toBeInTheDocument() + }) + + test('shows when a secret is not configured yet', () => { + renderEditor({ + fields: { + OPENAI_API_KEY: { + ...baseFields.OPENAI_API_KEY, + hasValue: false, + }, + }, + }) + + expect(screen.getByPlaceholderText('Not set')).toBeInTheDocument() + expect(screen.getByText(/Enter a value to store this secret/i)).toBeInTheDocument() + }) +}) diff --git a/dream-server/extensions/services/dashboard/src/components/settings/EnvEditor.jsx b/dream-server/extensions/services/dashboard/src/components/settings/EnvEditor.jsx new file mode 100644 index 00000000..4efd6a62 --- /dev/null +++ b/dream-server/extensions/services/dashboard/src/components/settings/EnvEditor.jsx @@ -0,0 +1,284 @@ +import { Search, RefreshCw, Save, Eye, EyeOff } from 'lucide-react' + +export default function EnvEditor({ + editor, + search, + onSearchChange, + sections, + activeSection, + onSectionChange, + fields, + values, + issues, + issueMap, + revealedSecrets, + onToggleReveal, + onFieldChange, + onReload, + onSave, + dirty, + saving, +}) { + const activeKeys = activeSection?.keys || [] + + return ( +
+
+
+
+

+ Local configuration +

+

+ Edit the DreamServer `.env` directly from the dashboard. +

+

+ {editor.path} +

+
+ +
+ + +
+
+ +
+ {Object.keys(fields || {}).length} fields + {issues.length} validation issue{issues.length === 1 ? '' : 's'} + {editor.backupPath ? last backup {editor.backupPath} : null} +
+
+ +
+ + +
+ + {issues.length > 0 ? ( +
+

Validation notes

+
+ {issues.slice(0, 8).map((issue, index) => ( +

+ {issue.key ? `${issue.key}: ` : ''}{issue.message} +

+ ))} +
+
+ ) : null} + +
+
+ +
+ {sections.map((section) => ( + + ))} +
+
+ +
+ {activeSection ? ( + <> +
+
+

{activeSection.id}

+

{activeSection.title}

+
+ {activeKeys.length} fields +
+ +
+
+ {activeKeys.map((key) => ( + onToggleReveal(key)} + onChange={(value) => onFieldChange(key, value)} + /> + ))} +
+
+ + ) : ( +
+ No fields match the current filter. +
+ )} +
+
+
+ ) +} + +function ToolbarButton({ icon: Icon, label, onClick, primary = false, disabled = false }) { + const cls = primary + ? 'liquid-metal-button text-white disabled:cursor-default disabled:opacity-50' + : 'border-white/10 bg-black/[0.16] text-theme-text-muted hover:text-theme-text' + return ( + + ) +} + +function Chip({ children, accent = false }) { + return ( + {children} + ) +} + +function Hint({ title, text }) { + return ( +
+

{title}

+

{text}

+
+ ) +} + +function FieldCard({ field, value, issues, revealed, onToggleReveal, onChange }) { + const hasIssues = issues.length > 0 + const isEnum = Array.isArray(field?.enum) && field.enum.length > 0 + const isBoolean = field?.type === 'boolean' + const isInteger = field?.type === 'integer' + const secretPlaceholder = field?.secret ? (field?.hasValue ? 'Stored locally' : 'Not set') : (field?.default !== undefined && field?.default !== null ? String(field.default) : '') + + return ( +
+
+
+
+

{field?.label}

+ {field?.required ? required : null} + {field?.secret ? {field?.hasValue ? 'stored' : 'secret'} : null} +
+

{field?.description || 'No description available.'}

+
+ {field?.key} +
+ +
+ {isBoolean ? ( +
+ {[ + { id: '', label: 'default' }, + { id: 'true', label: 'true' }, + { id: 'false', label: 'false' }, + ].map((option) => ( + + ))} +
+ ) : isEnum ? ( + + ) : ( +
+ onChange(event.target.value)} + placeholder={secretPlaceholder} + autoComplete="off" + className="w-full rounded-xl border border-white/8 bg-black/[0.16] px-3 py-2.5 text-sm text-theme-text outline-none focus:border-theme-accent/30" + /> + {field?.secret ? ( + + ) : null} +
+ )} +
+ + {field?.secret ? ( +

+ {field?.hasValue ? 'Leave blank to keep the stored secret. Enter a new value to replace it.' : 'Enter a value to store this secret.'} +

+ ) : field?.default !== undefined && field?.default !== null ? ( +

+ Default: {String(field.default)} +

+ ) : null} + {issues.map((issue, index) => ( +

{issue}

+ ))} +
+ ) +} + +function Badge({ children, muted = false }) { + return ( + {children} + ) +} diff --git a/dream-server/extensions/services/dashboard/src/pages/Settings.jsx b/dream-server/extensions/services/dashboard/src/pages/Settings.jsx index e156abbb..51fad635 100644 --- a/dream-server/extensions/services/dashboard/src/pages/Settings.jsx +++ b/dream-server/extensions/services/dashboard/src/pages/Settings.jsx @@ -5,20 +5,18 @@ import { RefreshCw, Download, Network, + FileText, } from 'lucide-react' import { useEffect, useState } from 'react' +import EnvEditor from '../components/settings/EnvEditor' const fetchJson = async (url, ms = 8000, options = {}) => { - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), ms) + const c = new AbortController() + const t = setTimeout(() => c.abort(), ms) try { - return await fetch(url, { - ...options, - headers: options.headers || undefined, - signal: controller.signal, - }) + return await fetch(url, { ...options, headers: options.headers || undefined, signal: c.signal }) } finally { - clearTimeout(timeout) + clearTimeout(t) } } @@ -28,10 +26,7 @@ const buildErrorFromResponse = async (response) => { const payload = await response.json() detail = payload?.detail ?? payload } catch {} - - const error = new Error( - typeof detail === 'string' ? detail : (detail?.message || `Request failed (${response.status})`) - ) + const error = new Error(typeof detail === 'string' ? detail : (detail?.message || `Request failed (${response.status})`)) error.details = typeof detail === 'object' && detail ? detail : null return error } @@ -55,110 +50,86 @@ const formatDateTime = (value) => { } const getErrorText = (err) => ( - err?.name === 'AbortError' - ? 'Request timed out' - : (err?.details?.message || err?.message || 'Failed to load settings') -) - -const getDashboardHost = () => ( - typeof window !== 'undefined' ? window.location.hostname : 'localhost' + err?.name === 'AbortError' ? 'Request timed out' : (err?.details?.message || err?.message || 'Failed to load settings') ) +const getDashboardHost = () => (typeof window !== 'undefined' ? window.location.hostname : 'localhost') const getExternalUrl = (port) => (port ? `http://${getDashboardHost()}:${port}` : null) const ROUTE_GROUP_STYLES = { - inactive: { - dot: 'bg-red-500', - text: 'text-theme-text-secondary', - line: 'rgba(239,68,68,0.26)', - }, - degraded: { - dot: 'bg-amber-400', - text: 'text-theme-text-secondary', - line: 'rgba(245,158,11,0.24)', - }, - online: { - dot: 'bg-emerald-400', - text: 'text-theme-text-secondary', - line: 'rgba(52,211,153,0.22)', - }, + inactive: { dot: 'bg-red-500', text: 'text-theme-text-secondary', line: 'rgba(239,68,68,0.26)' }, + degraded: { dot: 'bg-amber-400', text: 'text-theme-text-secondary', line: 'rgba(245,158,11,0.24)' }, + online: { dot: 'bg-emerald-400', text: 'text-theme-text-secondary', line: 'rgba(52,211,153,0.22)' }, } -const routeSeverityOrder = { - down: 0, - unhealthy: 1, - degraded: 2, - unknown: 3, - healthy: 4, -} +const routeSeverityOrder = { down: 0, unhealthy: 1, degraded: 2, unknown: 3, healthy: 4 } +const sortRoutesBySeverity = (items) => [...(items || [])].sort((a, b) => (routeSeverityOrder[a.status] ?? 9) - (routeSeverityOrder[b.status] ?? 9)) -const sortRoutesBySeverity = (items) => ( - [...(items || [])].sort((a, b) => (routeSeverityOrder[a.status] ?? 9) - (routeSeverityOrder[b.status] ?? 9)) -) +const matchesEnvSearch = (key, field, query) => { + if (!query) return true + return [key, field?.label, field?.description].filter(Boolean).join(' ').toLowerCase().includes(query) +} export default function Settings() { const [version, setVersion] = useState(null) const [storage, setStorage] = useState(null) const [services, setServices] = useState([]) + const [envEditor, setEnvEditor] = useState(null) + const [envValues, setEnvValues] = useState({}) + const [envValuesOriginal, setEnvValuesOriginal] = useState({}) + const [envSearch, setEnvSearch] = useState('') + const [envActiveSection, setEnvActiveSection] = useState(null) + const [envSaving, setEnvSaving] = useState(false) + const [envIssues, setEnvIssues] = useState([]) + const [envRevealSecrets, setEnvRevealSecrets] = useState({}) const [statusCache, setStatusCache] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [notice, setNotice] = useState(null) - useEffect(() => { - fetchSettings() - }, []) + useEffect(() => { fetchSettings() }, []) + + const applyEnvEditorPayload = (payload) => { + setEnvEditor(payload) + setEnvValues(payload?.values || {}) + setEnvValuesOriginal(payload?.values || {}) + setEnvIssues(payload?.issues || []) + setEnvRevealSecrets({}) + setEnvActiveSection(current => (current && payload?.sections?.some(section => section.id === current)) ? current : (payload?.sections?.[0]?.id || null)) + } const fetchVersionInfo = async ({ announce = false } = {}) => { try { const versionData = await fetchPayload('/api/version', 4000) - setVersion((prev) => ({ + setVersion(prev => ({ ...(prev || {}), current: versionData.current, - version: versionData.current && versionData.current !== '0.0.0' - ? versionData.current - : (prev?.version || 'Unknown'), + version: versionData.current && versionData.current !== '0.0.0' ? versionData.current : (prev?.version || 'Unknown'), latest: versionData.latest || null, - update_available: Boolean( - versionData.update_available && - versionData.latest && - versionData.current && - versionData.current !== '0.0.0' && - versionData.latest !== versionData.current - ), + update_available: Boolean(versionData.update_available && versionData.latest && versionData.current && versionData.current !== '0.0.0' && versionData.latest !== versionData.current), changelog_url: versionData.changelog_url || null, checked_at: versionData.checked_at || null, })) - - if (announce) { - setNotice({ - type: versionData.update_available ? 'warn' : 'info', - text: versionData.update_available && versionData.latest - ? `Update available: v${versionData.latest}` - : 'You are already on the latest available release.', - }) - } + if (announce) setNotice({ type: versionData.update_available ? 'warn' : 'info', text: versionData.update_available && versionData.latest ? `Update available: v${versionData.latest}` : 'You are already on the latest available release.' }) } catch (err) { - if (announce) { - setNotice({ - type: 'warn', - text: `Could not check updates right now: ${getErrorText(err)}`, - }) - } + if (announce) setNotice({ type: 'warn', text: `Could not check updates right now: ${getErrorText(err)}` }) } } + const fetchEnvEditor = async ({ announce = false } = {}) => { + const payload = await fetchPayload('/api/settings/env', 10000) + applyEnvEditorPayload(payload) + if (announce) setNotice({ type: 'info', text: 'Environment editor reloaded from disk.' }) + } + const fetchSettings = async () => { const failures = [] - try { - setLoading(true) - setError(null) - setNotice(null) - - const [summaryResult, storageResult] = await Promise.allSettled([ + setLoading(true); setError(null); setNotice(null) + const [summaryResult, storageResult, envResult] = await Promise.allSettled([ fetchPayload('/api/settings/summary', 10000), fetchPayload('/api/storage', 12000), + fetchPayload('/api/settings/env', 10000), ]) if (summaryResult.status === 'fulfilled') { @@ -171,56 +142,51 @@ export default function Settings() { uptime: formatUptime(statusData.uptime || 0), }) setServices(statusData.services || []) - } else { - failures.push(summaryResult.reason) - } - - if (storageResult.status === 'fulfilled') { - setStorage(storageResult.value) - } else { - failures.push(storageResult.reason) - } - - if (failures.length === 2) { - setError(getErrorText(failures[0])) - } else if (failures.length > 0) { - setNotice({ - type: 'warn', - text: 'Some settings details are temporarily unavailable. Showing the data that loaded successfully.', - }) - } + } else failures.push(summaryResult.reason) + + if (storageResult.status === 'fulfilled') setStorage(storageResult.value); else failures.push(storageResult.reason) + if (envResult.status === 'fulfilled') applyEnvEditorPayload(envResult.value); else failures.push(envResult.reason) + + if (failures.length === 3) setError(getErrorText(failures[0])) + else if (failures.length > 0) setNotice({ type: 'warn', text: 'Some settings details are temporarily unavailable. Showing the data that loaded successfully.' }) } catch (err) { setError(getErrorText(err)) console.error('Settings fetch error:', err) } finally { setLoading(false) } - void fetchVersionInfo() } + const handleSaveEnv = async () => { + if (!envEditor) return + setEnvSaving(true) + try { + const payload = await fetchPayload('/api/settings/env', 15000, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode: 'form', values: envValues }), + }) + applyEnvEditorPayload(payload) + setNotice({ type: 'info', text: `.env saved.${payload?.backupPath ? ` Backup: ${payload.backupPath}.` : ''} Restart or rebuild the stack to apply service-level changes.` }) + } catch (err) { + if (err?.details?.issues?.length) setEnvIssues(err.details.issues) + setNotice({ type: 'danger', text: getErrorText(err) }) + } finally { + setEnvSaving(false) + } + } + const handleExportConfig = async () => { try { const data = statusCache || (await (await fetchJson('/api/status')).json()) - const config = { - exported_at: new Date().toISOString(), - version: data.version, - tier: data.tier, - gpu: data.gpu, - services: data.services?.map((service) => ({ - name: service.name, - port: service.port, - status: service.status, - })), - model: data.model, - } - + const config = { exported_at: new Date().toISOString(), version: data.version, tier: data.tier, gpu: data.gpu, services: data.services?.map(s => ({ name: s.name, port: s.port, status: s.status })), model: data.model } const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' }) const url = URL.createObjectURL(blob) - const anchor = document.createElement('a') - anchor.href = url - anchor.download = `dream-server-config-${new Date().toISOString().slice(0, 10)}.json` - anchor.click() + const a = document.createElement('a') + a.href = url + a.download = `dream-server-config-${new Date().toISOString().slice(0, 10)}.json` + a.click() URL.revokeObjectURL(url) setNotice({ type: 'info', text: 'Configuration exported.' }) } catch (err) { @@ -229,183 +195,55 @@ export default function Settings() { } const routingGroups = [ - { - key: 'online', - label: 'Online', - tone: 'online', - services: sortRoutesBySeverity(services).filter((service) => service.status === 'healthy'), - }, - { - key: 'degraded', - label: 'Degraded', - tone: 'degraded', - services: sortRoutesBySeverity(services).filter((service) => service.status === 'degraded'), - }, - { - key: 'inactive', - label: 'Inactive', - tone: 'inactive', - services: sortRoutesBySeverity(services).filter((service) => ['down', 'unhealthy', 'unknown'].includes(service.status)), - }, + { key: 'online', label: 'Online', tone: 'online', services: sortRoutesBySeverity(services).filter(service => service.status === 'healthy') }, + { key: 'degraded', label: 'Degraded', tone: 'degraded', services: sortRoutesBySeverity(services).filter(service => service.status === 'degraded') }, + { key: 'inactive', label: 'Inactive', tone: 'inactive', services: sortRoutesBySeverity(services).filter(service => ['down', 'unhealthy', 'unknown'].includes(service.status)) }, ] - if (loading) { - return ( -
-
-
-
-
-
-
-
-
- {[...Array(5)].map((_, index) => ( -
- ))} -
-
- ) - } + const envFields = envEditor?.fields || {} + const envSections = (envEditor?.sections || []).map(section => ({ ...section, keys: section.keys.filter(key => matchesEnvSearch(key, envFields[key], envSearch.trim().toLowerCase())) })).filter(section => section.keys.length > 0) + const activeEnvSection = envSections.find(section => section.id === envActiveSection) || envSections[0] || null + const envDirty = JSON.stringify(envValues) !== JSON.stringify(envValuesOriginal) + const envIssueMap = envIssues.reduce((acc, issue) => { if (issue?.key) (acc[issue.key] ||= []).push(issue.message); return acc }, {}) + + if (loading) return ( +
+
+
{[...Array(6)].map((_, i) =>
)}
+
+ ) return (
-
-

Settings

-

Configure your Dream Server installation.

-
- +

Settings

Configure your Dream Server installation.

+
- {error ? ( - - {error} — - - ) : null} - - {notice ? ( - setNotice(null)}> - {notice.text} - - ) : null} + {error ? {error} — : null} + {notice ? setNotice(null)}>{notice.text} : null}
- -
- - - - -
-
- - {services.length > 0 ? ( - -
-
-

- route surfaces -

- - {getDashboardHost()} - -
-
- {routingGroups.map((group) => ( - - ))} -
-
-
- ) : null} - - - - - - -
-
-

- {version?.update_available && version?.latest - ? `Update available: v${version.latest}` - : `Installed version: v${version?.version || 'Unknown'}`} -

-

- {version?.checked_at - ? `Last checked: ${new Date(version.checked_at).toLocaleString()}` - : 'Checks GitHub in the background to avoid blocking the page.'} -

-
- -
-
- - - - -
-
- ) -} +
-function SettingsSection({ title, icon: Icon, children }) { - return ( -
-
- -

{title}

+ {services.length > 0 ?

route surfaces

{getDashboardHost()}
{routingGroups.map(group => )}
: null} + + {envEditor ? setEnvRevealSecrets(current => ({ ...current, [key]: !current[key] }))} onFieldChange={(key, value) => setEnvValues(current => ({ ...current, [key]: value }))} onReload={() => fetchEnvEditor({ announce: true })} onSave={handleSaveEnv} dirty={envDirty} saving={envSaving} /> : null} + + +

{version?.update_available && version?.latest ? `Update available: v${version.latest}` : `Installed version: v${version?.version || 'Unknown'}`}

{version?.checked_at ? `Last checked: ${new Date(version.checked_at).toLocaleString()}` : 'Checks GitHub in the background to avoid blocking the page.'}

+
-
{children}
) } -function InfoRow({ label, value }) { - return ( -
- {label} - {value} -
- ) -} +function SettingsSection({ title, icon: Icon, children }) { return

{title}

{children}
} +function InfoRow({ label, value }) { return
{label}{value}
} function Banner({ tone = 'info', children, onClose }) { - const classes = tone === 'danger' - ? 'border-red-500/20 bg-red-500/10 text-red-200' - : tone === 'warn' - ? 'border-yellow-500/20 bg-yellow-500/10 text-yellow-100' - : 'border-theme-accent/20 bg-theme-accent/10 text-theme-text' - - return ( -
- {children} - {onClose ? ( - - ) : null} -
- ) + const cls = tone === 'danger' ? 'border-red-500/20 bg-red-500/10 text-red-200' : tone === 'warn' ? 'border-yellow-500/20 bg-yellow-500/10 text-yellow-100' : 'border-theme-accent/20 bg-theme-accent/10 text-theme-text' + return
{children}{onClose ? : null}
} function RoutingGroup({ label, tone, services }) { @@ -414,126 +252,19 @@ function RoutingGroup({ label, tone, services }) { const primary = tone === 'online' const visible = primary ? (expanded ? services : services.slice(0, 4)) : (expanded ? services : []) const hiddenCount = Math.max(services.length - visible.length, 0) - const summary = services.slice(0, 2).map((service) => service.name).join(' · ') - - return ( -
-
-
- -

{label}

- - {services.length} {services.length === 1 ? 'route' : 'routes'} - -
- {services.length > 0 ? ( - - ) : null} -
- - {visible.length > 0 ? ( -
- {visible.map((service) => ( - - ))} - {!expanded && hiddenCount > 0 ? ( -

- +{hiddenCount} more -

- ) : null} -
- ) : services.length === 0 ? ( -

Clear

- ) : ( -
-

- {summary} - {services.length > 2 ? ` +${services.length - 2} more` : ''} -

-
- )} -
- ) + const summary = services.slice(0, 2).map(service => service.name).join(' · ') + return

{label}

{services.length} {services.length === 1 ? 'route' : 'routes'}
{services.length > 0 ? : null}
{visible.length > 0 ?
{visible.map(service => )}{!expanded && hiddenCount > 0 ?

+{hiddenCount} more

: null}
: services.length === 0 ?

Clear

:

{summary}{services.length > 2 ? ` +${services.length - 2} more` : ''}

}
} function RoutingRow({ service, tone }) { const styles = ROUTE_GROUP_STYLES[tone] const href = getExternalUrl(service.port) - - return ( -
-
- - {service.name} -
-
- {href ? ( - - :{service.port} - - ) : ( - internal - )} -
-
- ) + return
{service.name}
{href ? :{service.port} : internal}
} function StorageBlock({ storage }) { - const items = [ - ['Models', storage?.models], - ['Vector Database', storage?.vector_db], - ['Total Data', storage?.total_data], - ] - - return ( -
- {items.map(([label, data]) => ( -
-
- {label} - {data?.formatted || 'Unknown'} -
-
-
-
-
- ))} -
- ) + const items = [['Models', storage?.models], ['Vector Database', storage?.vector_db], ['Total Data', storage?.total_data]] + return
{items.map(([label, data]) =>
{label}{data?.formatted || 'Unknown'}
)}
} -function ActionButton({ icon: Icon, label, description, onClick }) { - return ( - - ) -} +function ActionButton({ icon: Icon, label, description, onClick }) { return }