feat(settings): System tab — services, email, integrations, paths, deployment
This commit is contained in:
parent
acda1e8f5a
commit
e63473360e
4 changed files with 578 additions and 3 deletions
233
dev-api.py
233
dev-api.py
|
|
@ -1192,3 +1192,236 @@ def byok_ack(payload: ByokAckPayload):
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(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"}
|
||||||
|
|
|
||||||
|
|
@ -71,3 +71,13 @@ describe('useSystemStore — BYOK gate', () => {
|
||||||
expect(store.backends).toEqual(original)
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ import { useApiFetch } from '../../composables/useApi'
|
||||||
const CLOUD_BACKEND_IDS = ['anthropic', 'openai']
|
const CLOUD_BACKEND_IDS = ['anthropic', 'openai']
|
||||||
|
|
||||||
export interface Backend { id: string; enabled: boolean; priority: number }
|
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', () => {
|
export const useSystemStore = defineStore('settings/system', () => {
|
||||||
const backends = ref<Backend[]>([])
|
const backends = ref<Backend[]>([])
|
||||||
|
|
@ -16,6 +19,18 @@ export const useSystemStore = defineStore('settings/system', () => {
|
||||||
const saveError = ref<string | null>(null)
|
const saveError = ref<string | null>(null)
|
||||||
const loadError = ref<string | null>(null)
|
const loadError = ref<string | null>(null)
|
||||||
|
|
||||||
|
const services = ref<Service[]>([])
|
||||||
|
const emailConfig = ref<Record<string, unknown>>({})
|
||||||
|
const integrations = ref<Integration[]>([])
|
||||||
|
const serviceErrors = ref<Record<string, string>>({})
|
||||||
|
const emailSaving = ref(false)
|
||||||
|
const emailError = ref<string | null>(null)
|
||||||
|
// File paths + deployment
|
||||||
|
const filePaths = ref<Record<string, string>>({})
|
||||||
|
const deployConfig = ref<Record<string, unknown>>({})
|
||||||
|
const filePathsSaving = ref(false)
|
||||||
|
const deploySaving = ref(false)
|
||||||
|
|
||||||
async function loadLlm() {
|
async function loadLlm() {
|
||||||
loadError.value = null
|
loadError.value = null
|
||||||
const { data, error } = await useApiFetch<{ backends: Backend[]; byok_acknowledged: string[] }>('/api/settings/system/llm')
|
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.'
|
if (error) saveError.value = 'Save failed — please try again.'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Services, email, integrations added in Task 6
|
async function loadServices() {
|
||||||
return { backends, byokAcknowledged, byokPending, saving, saveError, loadError, loadLlm, trySave, confirmByok, cancelByok }
|
const { data } = await useApiFetch<Service[]>('/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<Record<string, unknown>>('/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<Integration[]>('/api/settings/system/integrations')
|
||||||
|
if (data) integrations.value = data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFilePaths() {
|
||||||
|
const { data } = await useApiFetch<Record<string, string>>('/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<Record<string, unknown>>('/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,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,144 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Services section -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Services</h3>
|
||||||
|
<p class="section-note">Port-based status. Start/Stop via Docker Compose.</p>
|
||||||
|
<div class="service-grid">
|
||||||
|
<div v-for="svc in store.services" :key="svc.name" class="service-card">
|
||||||
|
<div class="service-header">
|
||||||
|
<span class="service-dot" :class="svc.running ? 'dot-running' : 'dot-stopped'"></span>
|
||||||
|
<span class="service-name">{{ svc.name }}</span>
|
||||||
|
<span class="service-port">:{{ svc.port }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="service-note">{{ svc.note }}</p>
|
||||||
|
<div class="service-actions">
|
||||||
|
<button v-if="!svc.running" @click="store.startService(svc.name)" class="btn-start">Start</button>
|
||||||
|
<button v-else @click="store.stopService(svc.name)" class="btn-stop">Stop</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="store.serviceErrors[svc.name]" class="error">{{ store.serviceErrors[svc.name] }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Email section -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Email (IMAP)</h3>
|
||||||
|
<p class="section-note">Used for email sync in the Interviews pipeline.</p>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>IMAP Host</label>
|
||||||
|
<input v-model="(store.emailConfig as any).host" placeholder="imap.gmail.com" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Port</label>
|
||||||
|
<input v-model.number="(store.emailConfig as any).port" type="number" placeholder="993" />
|
||||||
|
</div>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" v-model="(store.emailConfig as any).ssl" /> Use SSL
|
||||||
|
</label>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Username</label>
|
||||||
|
<input v-model="(store.emailConfig as any).username" type="email" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Password / App Password</label>
|
||||||
|
<input v-model="(store.emailConfig as any).password" type="password" />
|
||||||
|
<span class="field-hint">Gmail: use an App Password, not your account password.</span>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Sent Folder</label>
|
||||||
|
<input v-model="(store.emailConfig as any).sent_folder" placeholder="[Gmail]/Sent Mail" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Lookback Days</label>
|
||||||
|
<input v-model.number="(store.emailConfig as any).lookback_days" type="number" placeholder="30" />
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="store.saveEmail()" :disabled="store.emailSaving" class="btn-primary">
|
||||||
|
{{ store.emailSaving ? 'Saving…' : 'Save Email Config' }}
|
||||||
|
</button>
|
||||||
|
<button @click="handleTestEmail" class="btn-secondary">Test Connection</button>
|
||||||
|
<span v-if="emailTestResult !== null" :class="emailTestResult ? 'test-ok' : 'test-fail'">
|
||||||
|
{{ emailTestResult ? '✓ Connected' : '✗ Failed' }}
|
||||||
|
</span>
|
||||||
|
<p v-if="store.emailError" class="error">{{ store.emailError }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Integrations -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Integrations</h3>
|
||||||
|
<div v-if="store.integrations.length === 0" class="empty-note">No integrations registered.</div>
|
||||||
|
<div v-for="integration in store.integrations" :key="integration.id" class="integration-card">
|
||||||
|
<div class="integration-header">
|
||||||
|
<span class="integration-name">{{ integration.name }}</span>
|
||||||
|
<span :class="['status-badge', integration.connected ? 'badge-connected' : 'badge-disconnected']">
|
||||||
|
{{ integration.connected ? 'Connected' : 'Disconnected' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!integration.connected" class="integration-form">
|
||||||
|
<div v-for="field in integration.fields" :key="field.key" class="field-row">
|
||||||
|
<label>{{ field.label }}</label>
|
||||||
|
<input v-model="integrationInputs[integration.id + ':' + field.key]"
|
||||||
|
:type="field.type === 'password' ? 'password' : 'text'" />
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="connectIntegration(integration.id)" class="btn-primary">Connect</button>
|
||||||
|
<button @click="testIntegration(integration.id)" class="btn-secondary">Test</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<button @click="disconnectIntegration(integration.id)" class="btn-danger">Disconnect</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- File Paths -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>File Paths</h3>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Documents Directory</label>
|
||||||
|
<input v-model="(store.filePaths as any).docs_dir" placeholder="/Library/Documents/JobSearch" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Data Directory</label>
|
||||||
|
<input v-model="(store.filePaths as any).data_dir" placeholder="data/" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Model Directory</label>
|
||||||
|
<input v-model="(store.filePaths as any).model_dir" placeholder="/Library/Assets/LLM" />
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="store.saveFilePaths()" :disabled="store.filePathsSaving" class="btn-primary">
|
||||||
|
{{ store.filePathsSaving ? 'Saving…' : 'Save Paths' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Deployment / Server -->
|
||||||
|
<section class="form-section">
|
||||||
|
<h3>Deployment / Server</h3>
|
||||||
|
<p class="section-note">Restart required for changes to take effect.</p>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Base URL Path</label>
|
||||||
|
<input v-model="(store.deployConfig as any).base_url_path" placeholder="/peregrine" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Server Host</label>
|
||||||
|
<input v-model="(store.deployConfig as any).server_host" placeholder="0.0.0.0" />
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Server Port</label>
|
||||||
|
<input v-model.number="(store.deployConfig as any).server_port" type="number" placeholder="8502" />
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button @click="store.saveDeployConfig()" :disabled="store.deploySaving" class="btn-primary">
|
||||||
|
{{ store.deploySaving ? 'Saving…' : 'Save (requires restart)' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- BYOK Modal -->
|
<!-- BYOK Modal -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div v-if="store.byokPending.length > 0" class="modal-overlay" @click.self="store.cancelByok()">
|
<div v-if="store.byokPending.length > 0" class="modal-overlay" @click.self="store.cancelByok()">
|
||||||
|
|
@ -79,6 +217,7 @@
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useSystemStore } from '../../stores/settings/system'
|
import { useSystemStore } from '../../stores/settings/system'
|
||||||
import { useAppConfigStore } from '../../stores/appConfig'
|
import { useAppConfigStore } from '../../stores/appConfig'
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
|
||||||
const store = useSystemStore()
|
const store = useSystemStore()
|
||||||
const config = useAppConfigStore()
|
const config = useAppConfigStore()
|
||||||
|
|
@ -121,7 +260,55 @@ async function handleConfirmByok() {
|
||||||
byokConfirmed.value = false
|
byokConfirmed.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => store.loadLlm())
|
const emailTestResult = ref<boolean | null>(null)
|
||||||
|
const integrationInputs = ref<Record<string, string>>({})
|
||||||
|
|
||||||
|
async function handleTestEmail() {
|
||||||
|
const result = await store.testEmail()
|
||||||
|
emailTestResult.value = result?.ok ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectIntegration(id: string) {
|
||||||
|
const integration = store.integrations.find(i => i.id === id)
|
||||||
|
if (!integration) return
|
||||||
|
const payload: Record<string, string> = {}
|
||||||
|
for (const field of integration.fields) {
|
||||||
|
payload[field.key] = integrationInputs.value[`${id}:${field.key}`] ?? ''
|
||||||
|
}
|
||||||
|
const { data } = await useApiFetch<{ok: boolean; error?: string}>(
|
||||||
|
`/api/settings/system/integrations/${id}/connect`,
|
||||||
|
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }
|
||||||
|
)
|
||||||
|
if (data?.ok) await store.loadIntegrations()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testIntegration(id: string) {
|
||||||
|
const integration = store.integrations.find(i => i.id === id)
|
||||||
|
if (!integration) return
|
||||||
|
const payload: Record<string, string> = {}
|
||||||
|
for (const field of integration.fields) {
|
||||||
|
payload[field.key] = integrationInputs.value[`${id}:${field.key}`] ?? ''
|
||||||
|
}
|
||||||
|
await useApiFetch(`/api/settings/system/integrations/${id}/test`,
|
||||||
|
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disconnectIntegration(id: string) {
|
||||||
|
await useApiFetch(`/api/settings/system/integrations/${id}/disconnect`, { method: 'POST' })
|
||||||
|
await store.loadIntegrations()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await store.loadLlm()
|
||||||
|
await Promise.all([
|
||||||
|
store.loadServices(),
|
||||||
|
store.loadEmail(),
|
||||||
|
store.loadIntegrations(),
|
||||||
|
store.loadFilePaths(),
|
||||||
|
store.loadDeployConfig(),
|
||||||
|
])
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -153,4 +340,31 @@ h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3, 16px); col
|
||||||
.byok-warning { background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.3); border-radius: 6px; padding: 10px 12px; color: #fbbf24 !important; }
|
.byok-warning { background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.3); border-radius: 6px; padding: 10px 12px; color: #fbbf24 !important; }
|
||||||
.checkbox-row { display: flex; align-items: flex-start; gap: 8px; font-size: 0.85rem; color: var(--color-text-primary, #e2e8f0); cursor: pointer; margin: 16px 0; }
|
.checkbox-row { display: flex; align-items: flex-start; gap: 8px; font-size: 0.85rem; color: var(--color-text-primary, #e2e8f0); cursor: pointer; margin: 16px 0; }
|
||||||
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
|
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
|
||||||
|
.service-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
||||||
|
.service-card { background: var(--color-surface-2, rgba(255,255,255,0.04)); border: 1px solid var(--color-border, rgba(255,255,255,0.08)); border-radius: 8px; padding: 14px; }
|
||||||
|
.service-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||||
|
.service-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.dot-running { background: #22c55e; box-shadow: 0 0 6px rgba(34,197,94,0.5); }
|
||||||
|
.dot-stopped { background: #64748b; }
|
||||||
|
.service-name { font-weight: 600; font-size: 0.88rem; color: var(--color-text-primary, #e2e8f0); }
|
||||||
|
.service-port { font-size: 0.75rem; color: var(--color-text-secondary, #64748b); font-family: monospace; }
|
||||||
|
.service-note { font-size: 0.75rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 10px; }
|
||||||
|
.service-actions { display: flex; gap: 6px; }
|
||||||
|
.btn-start { padding: 4px 12px; border-radius: 4px; background: rgba(34,197,94,0.15); color: #22c55e; border: 1px solid rgba(34,197,94,0.3); cursor: pointer; font-size: 0.78rem; }
|
||||||
|
.btn-stop { padding: 4px 12px; border-radius: 4px; background: rgba(239,68,68,0.1); color: #f87171; border: 1px solid rgba(239,68,68,0.2); cursor: pointer; font-size: 0.78rem; }
|
||||||
|
.field-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 14px; }
|
||||||
|
.field-row label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); }
|
||||||
|
.field-row input { background: var(--color-surface-2, rgba(255,255,255,0.05)); border: 1px solid var(--color-border, rgba(255,255,255,0.12)); border-radius: 6px; color: var(--color-text-primary, #e2e8f0); padding: 7px 10px; font-size: 0.88rem; }
|
||||||
|
.field-hint { font-size: 0.72rem; color: var(--color-text-secondary, #64748b); margin-top: 3px; }
|
||||||
|
.btn-secondary { padding: 9px 18px; background: transparent; border: 1px solid var(--color-border, rgba(255,255,255,0.2)); border-radius: 7px; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.88rem; }
|
||||||
|
.btn-danger { padding: 6px 14px; border-radius: 6px; background: rgba(239,68,68,0.1); color: #ef4444; border: 1px solid rgba(239,68,68,0.25); cursor: pointer; font-size: 0.82rem; }
|
||||||
|
.test-ok { color: #22c55e; font-size: 0.85rem; }
|
||||||
|
.test-fail { color: #ef4444; font-size: 0.85rem; }
|
||||||
|
.integration-card { background: var(--color-surface-2, rgba(255,255,255,0.04)); border: 1px solid var(--color-border, rgba(255,255,255,0.08)); border-radius: 8px; padding: 16px; margin-bottom: 12px; }
|
||||||
|
.integration-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||||
|
.integration-name { font-weight: 600; font-size: 0.9rem; color: var(--color-text-primary, #e2e8f0); }
|
||||||
|
.status-badge { font-size: 0.72rem; padding: 2px 8px; border-radius: 10px; }
|
||||||
|
.badge-connected { background: rgba(34,197,94,0.15); color: #22c55e; border: 1px solid rgba(34,197,94,0.3); }
|
||||||
|
.badge-disconnected { background: rgba(100,116,139,0.15); color: #94a3b8; border: 1px solid rgba(100,116,139,0.2); }
|
||||||
|
.empty-note { font-size: 0.85rem; color: var(--color-text-secondary, #94a3b8); padding: 16px 0; }
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue