feat(settings): System tab — services, email, integrations, paths, deployment

This commit is contained in:
pyr0ball 2026-03-22 13:25:38 -07:00
parent 5afb752be6
commit bce997e596
4 changed files with 578 additions and 3 deletions

View file

@ -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"}

View file

@ -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)
})
})

View file

@ -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<Backend[]>([])
@ -16,6 +19,18 @@ export const useSystemStore = defineStore('settings/system', () => {
const saveError = 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() {
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<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,
}
})

View file

@ -44,6 +44,144 @@
</div>
</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 -->
<Teleport to="body">
<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 { useSystemStore } from '../../stores/settings/system'
import { useAppConfigStore } from '../../stores/appConfig'
import { useApiFetch } from '../../composables/useApi'
const store = useSystemStore()
const config = useAppConfigStore()
@ -121,7 +260,55 @@ async function handleConfirmByok() {
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>
<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; }
.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; }
.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>