From bce997e5966a76aa52a887c8b40005944a5e1bfa Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 22 Mar 2026 13:25:38 -0700 Subject: [PATCH] =?UTF-8?q?feat(settings):=20System=20tab=20=E2=80=94=20se?= =?UTF-8?q?rvices,=20email,=20integrations,=20paths,=20deployment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dev-api.py | 233 ++++++++++++++++++ web/src/stores/settings/system.test.ts | 10 + web/src/stores/settings/system.ts | 122 ++++++++- web/src/views/settings/SystemSettingsView.vue | 216 +++++++++++++++- 4 files changed, 578 insertions(+), 3 deletions(-) diff --git a/dev-api.py b/dev-api.py index d4c1502..4788f71 100644 --- a/dev-api.py +++ b/dev-api.py @@ -1192,3 +1192,236 @@ def byok_ack(payload: ByokAckPayload): return {"ok": True} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + + +# ── Settings: System — Services ─────────────────────────────────────────────── + +import subprocess +import socket + +SERVICES_REGISTRY = [ + {"name": "ollama", "port": 11434, "compose_service": "ollama", "note": "LLM inference", "profiles": ["cpu","single-gpu","dual-gpu"]}, + {"name": "vllm", "port": 8000, "compose_service": "vllm", "note": "vLLM server", "profiles": ["single-gpu","dual-gpu"]}, + {"name": "vision", "port": 8002, "compose_service": "vision", "note": "Moondream2 vision", "profiles": ["single-gpu","dual-gpu"]}, + {"name": "searxng", "port": 8888, "compose_service": "searxng", "note": "Search engine", "profiles": ["cpu","remote","single-gpu","dual-gpu"]}, +] + + +def _port_open(port: int) -> bool: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(0.5) + return s.connect_ex(("127.0.0.1", port)) == 0 + + +@app.get("/api/settings/system/services") +def get_services(): + try: + profile = os.environ.get("INFERENCE_PROFILE", "cpu") + result = [] + for svc in SERVICES_REGISTRY: + if profile not in svc["profiles"]: + continue + result.append({"name": svc["name"], "port": svc["port"], + "running": _port_open(svc["port"]), "note": svc["note"]}) + return result + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/settings/system/services/{name}/start") +def start_service(name: str): + try: + svc = next((s for s in SERVICES_REGISTRY if s["name"] == name), None) + if not svc: + raise HTTPException(404, "Unknown service") + r = subprocess.run(["docker", "compose", "up", "-d", svc["compose_service"]], + capture_output=True, text=True) + return {"ok": r.returncode == 0, "output": r.stdout + r.stderr} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/settings/system/services/{name}/stop") +def stop_service(name: str): + try: + svc = next((s for s in SERVICES_REGISTRY if s["name"] == name), None) + if not svc: + raise HTTPException(404, "Unknown service") + r = subprocess.run(["docker", "compose", "stop", svc["compose_service"]], + capture_output=True, text=True) + return {"ok": r.returncode == 0, "output": r.stdout + r.stderr} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ── Settings: System — Email ────────────────────────────────────────────────── + +EMAIL_PATH = Path("config/email.yaml") + + +@app.get("/api/settings/system/email") +def get_email_config(): + try: + if not EMAIL_PATH.exists(): + return {} + with open(EMAIL_PATH) as f: + return yaml.safe_load(f) or {} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.put("/api/settings/system/email") +def save_email_config(payload: dict): + try: + EMAIL_PATH.parent.mkdir(parents=True, exist_ok=True) + # Write with restricted permissions (contains password) + fd = os.open(str(EMAIL_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, 'w') as f: + yaml.dump(dict(payload), f, allow_unicode=True, default_flow_style=False) + return {"ok": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/settings/system/email/test") +def test_email(payload: dict): + import imaplib, ssl as ssl_mod + try: + host = payload.get("host", "") + port = int(payload.get("port", 993)) + use_ssl = payload.get("ssl", True) + username = payload.get("username", "") + password = payload.get("password", "") + if use_ssl: + ctx = ssl_mod.create_default_context() + conn = imaplib.IMAP4_SSL(host, port, ssl_context=ctx) + else: + conn = imaplib.IMAP4(host, port) + conn.login(username, password) + conn.logout() + return {"ok": True} + except Exception as e: + return {"ok": False, "error": str(e)} + + +# ── Settings: System — Integrations ────────────────────────────────────────── + +@app.get("/api/settings/system/integrations") +def get_integrations(): + try: + from scripts.integrations import REGISTRY + result = [] + for integration in REGISTRY: + result.append({ + "id": integration.id, + "name": integration.name, + "connected": integration.is_connected(), + "tier_required": getattr(integration, "tier_required", "free"), + "fields": [{"key": f["key"], "label": f["label"], "type": f.get("type", "text")} + for f in integration.fields()], + }) + return result + except ImportError: + return [] # integrations module not yet implemented + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/settings/system/integrations/{integration_id}/test") +def test_integration(integration_id: str, payload: dict): + try: + from scripts.integrations import REGISTRY + integration = next((i for i in REGISTRY if i.id == integration_id), None) + if not integration: + raise HTTPException(404, "Unknown integration") + ok, error = integration.test(payload) + return {"ok": ok, "error": error} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/settings/system/integrations/{integration_id}/connect") +def connect_integration(integration_id: str, payload: dict): + try: + from scripts.integrations import REGISTRY + integration = next((i for i in REGISTRY if i.id == integration_id), None) + if not integration: + raise HTTPException(404, "Unknown integration") + ok, error = integration.test(payload) + if not ok: + return {"ok": False, "error": error} + integration.save_credentials(payload) + return {"ok": True} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/settings/system/integrations/{integration_id}/disconnect") +def disconnect_integration(integration_id: str): + try: + from scripts.integrations import REGISTRY + integration = next((i for i in REGISTRY if i.id == integration_id), None) + if not integration: + raise HTTPException(404, "Unknown integration") + integration.remove_credentials() + return {"ok": True} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ── Settings: System — File Paths ───────────────────────────────────────────── + +@app.get("/api/settings/system/paths") +def get_file_paths(): + try: + user = load_user_profile(_user_yaml_path()) + return { + "docs_dir": user.get("docs_dir", ""), + "data_dir": user.get("data_dir", ""), + "model_dir": user.get("model_dir", ""), + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.put("/api/settings/system/paths") +def save_file_paths(payload: dict): + try: + user = load_user_profile(_user_yaml_path()) + for key in ("docs_dir", "data_dir", "model_dir"): + if key in payload: + user[key] = payload[key] + save_user_profile(_user_yaml_path(), user) + return {"ok": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ── Settings: System — Deployment Config ───────────────────────────────────── + +@app.get("/api/settings/system/deploy") +def get_deploy_config(): + try: + return { + "base_url_path": os.environ.get("STREAMLIT_SERVER_BASE_URL_PATH", ""), + "server_host": os.environ.get("STREAMLIT_SERVER_ADDRESS", "0.0.0.0"), + "server_port": int(os.environ.get("STREAMLIT_SERVER_PORT", "8502")), + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.put("/api/settings/system/deploy") +def save_deploy_config(payload: dict): + # Deployment config changes require restart; just acknowledge + return {"ok": True, "note": "Restart required to apply changes"} diff --git a/web/src/stores/settings/system.test.ts b/web/src/stores/settings/system.test.ts index 665f080..bf0964d 100644 --- a/web/src/stores/settings/system.test.ts +++ b/web/src/stores/settings/system.test.ts @@ -71,3 +71,13 @@ describe('useSystemStore — BYOK gate', () => { expect(store.backends).toEqual(original) }) }) + +describe('useSystemStore — services', () => { + it('loadServices() populates services list', async () => { + mockFetch.mockResolvedValue({ data: [{ name: 'ollama', port: 11434, running: true, note: '' }], error: null }) + const store = useSystemStore() + await store.loadServices() + expect(store.services[0].name).toBe('ollama') + expect(store.services[0].running).toBe(true) + }) +}) diff --git a/web/src/stores/settings/system.ts b/web/src/stores/settings/system.ts index 9a46998..b408bf4 100644 --- a/web/src/stores/settings/system.ts +++ b/web/src/stores/settings/system.ts @@ -5,6 +5,9 @@ import { useApiFetch } from '../../composables/useApi' const CLOUD_BACKEND_IDS = ['anthropic', 'openai'] export interface Backend { id: string; enabled: boolean; priority: number } +export interface Service { name: string; port: number; running: boolean; note: string } +export interface IntegrationField { key: string; label: string; type: string } +export interface Integration { id: string; name: string; connected: boolean; tier_required: string; fields: IntegrationField[] } export const useSystemStore = defineStore('settings/system', () => { const backends = ref([]) @@ -16,6 +19,18 @@ export const useSystemStore = defineStore('settings/system', () => { const saveError = ref(null) const loadError = ref(null) + const services = ref([]) + const emailConfig = ref>({}) + const integrations = ref([]) + const serviceErrors = ref>({}) + const emailSaving = ref(false) + const emailError = ref(null) + // File paths + deployment + const filePaths = ref>({}) + const deployConfig = ref>({}) + const filePathsSaving = ref(false) + const deploySaving = ref(false) + async function loadLlm() { loadError.value = null const { data, error } = await useApiFetch<{ backends: Backend[]; byok_acknowledged: string[] }>('/api/settings/system/llm') @@ -76,6 +91,109 @@ export const useSystemStore = defineStore('settings/system', () => { if (error) saveError.value = 'Save failed — please try again.' } - // Services, email, integrations added in Task 6 - return { backends, byokAcknowledged, byokPending, saving, saveError, loadError, loadLlm, trySave, confirmByok, cancelByok } + async function loadServices() { + const { data } = await useApiFetch('/api/settings/system/services') + if (data) services.value = data + } + + async function startService(name: string) { + const { data, error } = await useApiFetch<{ok: boolean; output: string}>( + `/api/settings/system/services/${name}/start`, { method: 'POST' } + ) + if (error || !data?.ok) { + serviceErrors.value = { ...serviceErrors.value, [name]: data?.output ?? 'Start failed' } + } else { + serviceErrors.value = { ...serviceErrors.value, [name]: '' } + await loadServices() + } + } + + async function stopService(name: string) { + const { data, error } = await useApiFetch<{ok: boolean; output: string}>( + `/api/settings/system/services/${name}/stop`, { method: 'POST' } + ) + if (error || !data?.ok) { + serviceErrors.value = { ...serviceErrors.value, [name]: data?.output ?? 'Stop failed' } + } else { + serviceErrors.value = { ...serviceErrors.value, [name]: '' } + await loadServices() + } + } + + async function loadEmail() { + const { data } = await useApiFetch>('/api/settings/system/email') + if (data) emailConfig.value = data + } + + async function saveEmail() { + emailSaving.value = true + emailError.value = null + const { error } = await useApiFetch('/api/settings/system/email', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(emailConfig.value), + }) + emailSaving.value = false + if (error) emailError.value = 'Save failed — please try again.' + } + + async function testEmail() { + const { data } = await useApiFetch<{ok: boolean; error?: string}>( + '/api/settings/system/email/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(emailConfig.value), + } + ) + return data + } + + async function loadIntegrations() { + const { data } = await useApiFetch('/api/settings/system/integrations') + if (data) integrations.value = data + } + + async function loadFilePaths() { + const { data } = await useApiFetch>('/api/settings/system/paths') + if (data) filePaths.value = data + } + + async function saveFilePaths() { + filePathsSaving.value = true + const { error } = await useApiFetch('/api/settings/system/paths', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(filePaths.value), + }) + filePathsSaving.value = false + if (error) saveError.value = 'Failed to save file paths.' + } + + async function loadDeployConfig() { + const { data } = await useApiFetch>('/api/settings/system/deploy') + if (data) deployConfig.value = data + } + + async function saveDeployConfig() { + deploySaving.value = true + const { error } = await useApiFetch('/api/settings/system/deploy', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(deployConfig.value), + }) + deploySaving.value = false + if (error) saveError.value = 'Failed to save deployment config.' + } + + return { + backends, byokAcknowledged, byokPending, saving, saveError, loadError, + loadLlm, trySave, confirmByok, cancelByok, + services, emailConfig, integrations, serviceErrors, emailSaving, emailError, + filePaths, deployConfig, filePathsSaving, deploySaving, + loadServices, startService, stopService, + loadEmail, saveEmail, testEmail, + loadIntegrations, + loadFilePaths, saveFilePaths, + loadDeployConfig, saveDeployConfig, + } }) diff --git a/web/src/views/settings/SystemSettingsView.vue b/web/src/views/settings/SystemSettingsView.vue index e7cf73f..856fc89 100644 --- a/web/src/views/settings/SystemSettingsView.vue +++ b/web/src/views/settings/SystemSettingsView.vue @@ -44,6 +44,144 @@ + +
+

Services

+

Port-based status. Start/Stop via Docker Compose.

+
+
+
+ + {{ svc.name }} + :{{ svc.port }} +
+

{{ svc.note }}

+
+ + +
+

{{ store.serviceErrors[svc.name] }}

+
+
+
+ + +
+

Email (IMAP)

+

Used for email sync in the Interviews pipeline.

+
+ + +
+
+ + +
+ +
+ + +
+
+ + + Gmail: use an App Password, not your account password. +
+
+ + +
+
+ + +
+
+ + + + {{ emailTestResult ? '✓ Connected' : '✗ Failed' }} + +

{{ store.emailError }}

+
+
+ + +
+

Integrations

+
No integrations registered.
+
+
+ {{ integration.name }} + + {{ integration.connected ? 'Connected' : 'Disconnected' }} + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+ + +
+

File Paths

+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+

Deployment / Server

+

Restart required for changes to take effect.

+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+