feat: SSH target manager — GUI editor for remote host configuration (#24)

- app/services/ssh_targets.py: full CRUD service with lazy paramiko
  import, key-path validation, permission warning, and test_connection
- app/db/schema.py: ssh_targets table (id, label, host, port, user,
  key_path, last_tested, last_ok, last_error, timestamps)
- app/rest.py: GET/POST /api/ssh-targets, PATCH/DELETE /{id},
  POST /{id}/test — key contents never returned in any response
- web/src/views/SettingsView.vue: Remote Hosts section with add/edit
  form, inline connection status badges, test-connection flow, delete
  with confirmation; new Set() pattern for reactive sshTesting state
- tests/test_ssh_targets.py: 22 tests — schema, CRUD, validation,
  key-warning, serialization, paramiko-absent path
This commit is contained in:
pyr0ball 2026-06-14 15:27:12 -07:00
parent 7a2ab0bb46
commit b8f766fb74
5 changed files with 824 additions and 0 deletions

View file

@ -144,6 +144,20 @@ CREATE INDEX IF NOT EXISTS idx_blocklist_device ON blocklist_candidates(source_
CREATE INDEX IF NOT EXISTS idx_blocklist_status ON blocklist_candidates(status);
CREATE INDEX IF NOT EXISTS idx_blocklist_domain ON blocklist_candidates(domain_or_ip);
CREATE INDEX IF NOT EXISTS idx_blocklist_tenant ON blocklist_candidates(tenant_id);
CREATE TABLE IF NOT EXISTS ssh_targets (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 22,
user TEXT NOT NULL,
key_path TEXT NOT NULL,
last_tested TEXT,
last_ok INTEGER DEFAULT NULL,
last_error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
"""
_CONTEXT_SCHEMA_SQLITE = """

View file

@ -55,6 +55,7 @@ from app.services.pihole import PiholeClient
from app.services.discover import discover_all, build_sources_yaml, validate_source, scan_log_directories
from app.services.nl_source import interpret as _nl_interpret
from app.services import orchard as _orchard
from app.services import ssh_targets as _ssh_targets
from app.services.incidents import (
build_bundle,
create_incident,
@ -801,6 +802,89 @@ class BatchGleanRequest(BaseModel):
entries: list[BatchEntry]
# ── SSH target manager ─────────────────────────────────────────────────────
class SshTargetCreate(BaseModel):
label: str
host: str
port: int = 22
user: str
key_path: str
class SshTargetUpdate(BaseModel):
label: str | None = None
host: str | None = None
port: int | None = None
user: str | None = None
key_path: str | None = None
@router.get("/api/ssh-targets")
def list_ssh_targets() -> dict:
"""List all configured SSH targets (never returns key contents)."""
targets = _ssh_targets.list_targets(DB_PATH)
return {"targets": [_ssh_targets.target_to_dict(t, include_warning=True) for t in targets]}
@router.post("/api/ssh-targets")
def create_ssh_target(body: SshTargetCreate) -> dict:
"""Create a new SSH target."""
try:
target = _ssh_targets.create_target(
DB_PATH,
label=body.label,
host=body.host,
port=body.port,
user=body.user,
key_path=body.key_path,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
d = _ssh_targets.target_to_dict(target, include_warning=True)
return d
@router.patch("/api/ssh-targets/{target_id}")
def update_ssh_target(target_id: str, body: SshTargetUpdate) -> dict:
"""Update an existing SSH target."""
try:
target = _ssh_targets.update_target(
DB_PATH,
target_id,
label=body.label,
host=body.host,
port=body.port,
user=body.user,
key_path=body.key_path,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
if target is None:
raise HTTPException(status_code=404, detail=f"SSH target {target_id!r} not found")
return _ssh_targets.target_to_dict(target, include_warning=True)
@router.delete("/api/ssh-targets/{target_id}")
def delete_ssh_target(target_id: str) -> dict:
"""Remove an SSH target."""
if not _ssh_targets.delete_target(DB_PATH, target_id):
raise HTTPException(status_code=404, detail=f"SSH target {target_id!r} not found")
return {"deleted": target_id}
@router.post("/api/ssh-targets/{target_id}/test")
def test_ssh_target(target_id: str) -> dict:
"""Test an SSH connection by running a no-op remote command.
Records the result in the DB so the UI can show a persistent status badge.
"""
try:
return _ssh_targets.test_connection(DB_PATH, target_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc))
# ── Setup / Onboarding wizard ──────────────────────────────────────────────
class SetupWriteBody(BaseModel):

265
app/services/ssh_targets.py Normal file
View file

@ -0,0 +1,265 @@
"""SSH target registry — persisted in the main SQLite DB.
Targets are stored as path references only. The private key is never
read into the database, logged, or returned by any API response.
"""
from __future__ import annotations
import os
import sqlite3
import stat
import time
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@dataclass
class SshTarget:
id: str
label: str
host: str
port: int
user: str
key_path: str
last_tested: str | None
last_ok: bool | None
last_error: str | None
created_at: str
updated_at: str
def _row_to_target(row: tuple) -> SshTarget:
return SshTarget(
id=row[0],
label=row[1],
host=row[2],
port=row[3],
user=row[4],
key_path=row[5],
last_tested=row[6],
last_ok=bool(row[7]) if row[7] is not None else None,
last_error=row[8],
created_at=row[9],
updated_at=row[10],
)
def _now() -> str:
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
# ---------------------------------------------------------------------------
# CRUD
# ---------------------------------------------------------------------------
def list_targets(db_path: Path) -> list[SshTarget]:
conn = sqlite3.connect(str(db_path), timeout=10)
rows = conn.execute(
"SELECT id, label, host, port, user, key_path, last_tested, last_ok, last_error, created_at, updated_at "
"FROM ssh_targets ORDER BY label"
).fetchall()
conn.close()
return [_row_to_target(r) for r in rows]
def get_target(db_path: Path, target_id: str) -> SshTarget | None:
conn = sqlite3.connect(str(db_path), timeout=10)
row = conn.execute(
"SELECT id, label, host, port, user, key_path, last_tested, last_ok, last_error, created_at, updated_at "
"FROM ssh_targets WHERE id = ?",
(target_id,),
).fetchone()
conn.close()
return _row_to_target(row) if row else None
def create_target(
db_path: Path,
label: str,
host: str,
port: int,
user: str,
key_path: str,
) -> SshTarget:
resolved = _validate_key_path(key_path)
now = _now()
target_id = str(uuid.uuid4())
conn = sqlite3.connect(str(db_path), timeout=10)
conn.execute(
"INSERT INTO ssh_targets (id, label, host, port, user, key_path, created_at, updated_at) "
"VALUES (?,?,?,?,?,?,?,?)",
(target_id, label, host, port, user, str(resolved), now, now),
)
conn.commit()
conn.close()
return get_target(db_path, target_id) # type: ignore[return-value]
def update_target(
db_path: Path,
target_id: str,
*,
label: str | None = None,
host: str | None = None,
port: int | None = None,
user: str | None = None,
key_path: str | None = None,
) -> SshTarget | None:
existing = get_target(db_path, target_id)
if existing is None:
return None
resolved_key = str(_validate_key_path(key_path)) if key_path else existing.key_path
conn = sqlite3.connect(str(db_path), timeout=10)
conn.execute(
"UPDATE ssh_targets SET label=?, host=?, port=?, user=?, key_path=?, updated_at=? WHERE id=?",
(
label if label is not None else existing.label,
host if host is not None else existing.host,
port if port is not None else existing.port,
user if user is not None else existing.user,
resolved_key,
_now(),
target_id,
),
)
conn.commit()
conn.close()
return get_target(db_path, target_id)
def delete_target(db_path: Path, target_id: str) -> bool:
conn = sqlite3.connect(str(db_path), timeout=10)
cur = conn.execute("DELETE FROM ssh_targets WHERE id = ?", (target_id,))
conn.commit()
conn.close()
return cur.rowcount > 0
# ---------------------------------------------------------------------------
# Test connection
# ---------------------------------------------------------------------------
def test_connection(db_path: Path, target_id: str) -> dict[str, Any]:
"""Attempt an SSH no-op and record the result.
Runs `true` on the remote host no data is pulled. Returns
{ok: bool, error: str|null, tested_at: str}.
"""
target = get_target(db_path, target_id)
if target is None:
raise KeyError(f"SSH target {target_id!r} not found")
# Lazy import — paramiko is optional
try:
from paramiko import SSHClient, AutoAddPolicy, AuthenticationException, SSHException
except ImportError:
_record_test(db_path, target_id, ok=False, error="paramiko not installed")
return {"ok": False, "error": "paramiko not installed — run: pip install paramiko", "tested_at": _now()}
key_path = str(Path(target.key_path).expanduser())
error: str | None = None
ok = False
try:
client = SSHClient()
client.set_missing_host_key_policy(AutoAddPolicy())
client.connect(
hostname=target.host,
port=target.port,
username=target.user,
key_filename=key_path,
timeout=10,
banner_timeout=10,
)
stdin, stdout, stderr = client.exec_command("true", timeout=10)
exit_code = stdout.channel.recv_exit_status()
client.close()
ok = exit_code == 0
if not ok:
error = f"Remote command exited with code {exit_code}"
except AuthenticationException:
error = f"Authentication failed — check key path and remote authorized_keys"
except SSHException as exc:
error = f"SSH error: {exc}"
except OSError as exc:
error = f"Connection failed: {exc}"
except Exception as exc:
error = f"Unexpected error: {exc}"
tested_at = _now()
_record_test(db_path, target_id, ok=ok, error=error, tested_at=tested_at)
return {"ok": ok, "error": error, "tested_at": tested_at}
def _record_test(
db_path: Path,
target_id: str,
*,
ok: bool,
error: str | None,
tested_at: str | None = None,
) -> None:
if tested_at is None:
tested_at = _now()
conn = sqlite3.connect(str(db_path), timeout=10)
conn.execute(
"UPDATE ssh_targets SET last_tested=?, last_ok=?, last_error=?, updated_at=? WHERE id=?",
(tested_at, 1 if ok else 0, error, _now(), target_id),
)
conn.commit()
conn.close()
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
def _validate_key_path(raw: str) -> Path:
"""Resolve and validate the SSH key path.
Returns the resolved Path. Raises ValueError with a user-readable message
on any problem (does not raise on world-readable just returns a warning
to the caller so the UI can display it non-blocking).
"""
p = Path(raw).expanduser()
if not p.exists():
raise ValueError(f"Key file not found: {p}")
if not p.is_file():
raise ValueError(f"Key path is not a file: {p}")
return p
def key_path_warning(key_path: str) -> str | None:
"""Return a warning string if the key file has overly permissive mode, else None."""
try:
p = Path(key_path).expanduser()
mode = p.stat().st_mode
if mode & (stat.S_IRGRP | stat.S_IWGRP | stat.S_IROTH | stat.S_IWOTH):
perms = oct(mode & 0o777)
return f"Key file permissions are too open ({perms}). SSH may refuse to use it — run: chmod 600 {p}"
except OSError:
pass
return None
def target_to_dict(t: SshTarget, include_warning: bool = False) -> dict[str, Any]:
"""Serialize a target for API responses. Never includes key contents."""
d: dict[str, Any] = {
"id": t.id,
"label": t.label,
"host": t.host,
"port": t.port,
"user": t.user,
"key_path": t.key_path,
"last_tested": t.last_tested,
"last_ok": t.last_ok,
"last_error": t.last_error,
"created_at": t.created_at,
"updated_at": t.updated_at,
}
if include_warning:
d["key_warning"] = key_path_warning(t.key_path)
return d

245
tests/test_ssh_targets.py Normal file
View file

@ -0,0 +1,245 @@
"""Tests for ssh_targets service — CRUD, validation, serialization."""
from __future__ import annotations
import stat
import sqlite3
from pathlib import Path
import pytest
def _make_db(tmp_path: Path) -> Path:
"""Create a minimal DB with the ssh_targets table via ensure_schema."""
from app.glean.pipeline import ensure_schema
db = tmp_path / "test.db"
ensure_schema(db)
return db
def _make_key(tmp_path: Path, mode: int = 0o600) -> Path:
"""Write a fake SSH private key file with the given permission mode."""
key = tmp_path / "id_ed25519"
key.write_text("-----BEGIN OPENSSH PRIVATE KEY-----\nfake\n-----END OPENSSH PRIVATE KEY-----\n")
key.chmod(mode)
return key
# ---------------------------------------------------------------------------
# Schema
# ---------------------------------------------------------------------------
class TestSchema:
def test_ssh_targets_table_exists(self, tmp_path):
db = _make_db(tmp_path)
conn = sqlite3.connect(str(db))
tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()}
assert "ssh_targets" in tables
conn.close()
def test_ssh_targets_columns(self, tmp_path):
db = _make_db(tmp_path)
conn = sqlite3.connect(str(db))
cols = {r[1] for r in conn.execute("PRAGMA table_info(ssh_targets)").fetchall()}
assert cols >= {"id", "label", "host", "port", "user", "key_path",
"last_tested", "last_ok", "last_error", "created_at", "updated_at"}
conn.close()
# ---------------------------------------------------------------------------
# CRUD
# ---------------------------------------------------------------------------
class TestCrud:
def test_create_and_list(self, tmp_path):
from app.services.ssh_targets import create_target, list_targets
db = _make_db(tmp_path)
key = _make_key(tmp_path)
t = create_target(db, label="server-01", host="10.0.0.1", port=22, user="alan", key_path=str(key))
assert t.label == "server-01"
assert t.host == "10.0.0.1"
assert t.port == 22
assert t.user == "alan"
targets = list_targets(db)
assert len(targets) == 1
assert targets[0].id == t.id
def test_create_resolves_tilde(self, tmp_path):
from app.services.ssh_targets import create_target
from unittest.mock import patch
db = _make_db(tmp_path)
key = _make_key(tmp_path)
with patch("pathlib.Path.expanduser", return_value=key):
t = create_target(db, label="x", host="h", port=22, user="u", key_path="~/id_ed25519")
assert "~" not in t.key_path
def test_get_returns_none_for_missing(self, tmp_path):
from app.services.ssh_targets import get_target
db = _make_db(tmp_path)
assert get_target(db, "nonexistent-id") is None
def test_update_partial(self, tmp_path):
from app.services.ssh_targets import create_target, update_target
db = _make_db(tmp_path)
key = _make_key(tmp_path)
t = create_target(db, label="old-label", host="10.0.0.1", port=22, user="alan", key_path=str(key))
updated = update_target(db, t.id, label="new-label")
assert updated is not None
assert updated.label == "new-label"
assert updated.host == "10.0.0.1" # unchanged
def test_update_missing_target_returns_none(self, tmp_path):
from app.services.ssh_targets import update_target
db = _make_db(tmp_path)
assert update_target(db, "no-such-id", label="x") is None
def test_delete_returns_true_on_success(self, tmp_path):
from app.services.ssh_targets import create_target, delete_target, list_targets
db = _make_db(tmp_path)
key = _make_key(tmp_path)
t = create_target(db, label="x", host="h", port=22, user="u", key_path=str(key))
assert delete_target(db, t.id) is True
assert list_targets(db) == []
def test_delete_returns_false_for_missing(self, tmp_path):
from app.services.ssh_targets import delete_target
db = _make_db(tmp_path)
assert delete_target(db, "no-such-id") is False
def test_list_sorted_by_label(self, tmp_path):
from app.services.ssh_targets import create_target, list_targets
db = _make_db(tmp_path)
key = _make_key(tmp_path)
create_target(db, label="zebra", host="h", port=22, user="u", key_path=str(key))
create_target(db, label="alpha", host="h", port=22, user="u", key_path=str(key))
labels = [t.label for t in list_targets(db)]
assert labels == ["alpha", "zebra"]
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
class TestValidation:
def test_create_raises_on_missing_key_file(self, tmp_path):
from app.services.ssh_targets import create_target
db = _make_db(tmp_path)
with pytest.raises(ValueError, match="not found"):
create_target(db, label="x", host="h", port=22, user="u", key_path="/nonexistent/key")
def test_create_raises_on_directory_as_key(self, tmp_path):
from app.services.ssh_targets import create_target
db = _make_db(tmp_path)
with pytest.raises(ValueError, match="not a file"):
create_target(db, label="x", host="h", port=22, user="u", key_path=str(tmp_path))
def test_update_raises_on_bad_key_path(self, tmp_path):
from app.services.ssh_targets import create_target, update_target
db = _make_db(tmp_path)
key = _make_key(tmp_path)
t = create_target(db, label="x", host="h", port=22, user="u", key_path=str(key))
with pytest.raises(ValueError):
update_target(db, t.id, key_path="/does/not/exist")
# ---------------------------------------------------------------------------
# Key warning
# ---------------------------------------------------------------------------
class TestKeyWarning:
def test_no_warning_for_600(self, tmp_path):
from app.services.ssh_targets import key_path_warning
key = _make_key(tmp_path, mode=0o600)
assert key_path_warning(str(key)) is None
def test_warning_for_644(self, tmp_path):
from app.services.ssh_targets import key_path_warning
key = _make_key(tmp_path, mode=0o644)
warning = key_path_warning(str(key))
assert warning is not None
assert "chmod 600" in warning
def test_no_warning_for_nonexistent_file(self, tmp_path):
from app.services.ssh_targets import key_path_warning
# Should not raise — just return None
result = key_path_warning("/nonexistent/path")
assert result is None
# ---------------------------------------------------------------------------
# Serialization
# ---------------------------------------------------------------------------
class TestTargetToDict:
def test_basic_fields_present(self, tmp_path):
from app.services.ssh_targets import create_target, target_to_dict
db = _make_db(tmp_path)
key = _make_key(tmp_path)
t = create_target(db, label="server", host="10.0.0.1", port=2222, user="admin", key_path=str(key))
d = target_to_dict(t)
assert d["label"] == "server"
assert d["host"] == "10.0.0.1"
assert d["port"] == 2222
assert d["user"] == "admin"
assert "key_path" in d
assert "key_warning" not in d # not included by default
def test_key_contents_never_in_dict(self, tmp_path):
from app.services.ssh_targets import create_target, target_to_dict
db = _make_db(tmp_path)
key = _make_key(tmp_path)
t = create_target(db, label="x", host="h", port=22, user="u", key_path=str(key))
d = target_to_dict(t, include_warning=True)
for v in d.values():
if isinstance(v, str):
assert "BEGIN" not in v, "Key contents must never be included in serialized output"
def test_include_warning_adds_field(self, tmp_path):
from app.services.ssh_targets import create_target, target_to_dict
db = _make_db(tmp_path)
key = _make_key(tmp_path, mode=0o644)
t = create_target(db, label="x", host="h", port=22, user="u", key_path=str(key))
d = target_to_dict(t, include_warning=True)
assert "key_warning" in d
assert d["key_warning"] is not None
def test_last_ok_is_none_before_test(self, tmp_path):
from app.services.ssh_targets import create_target, target_to_dict
db = _make_db(tmp_path)
key = _make_key(tmp_path)
t = create_target(db, label="x", host="h", port=22, user="u", key_path=str(key))
d = target_to_dict(t)
assert d["last_ok"] is None
assert d["last_tested"] is None
# ---------------------------------------------------------------------------
# test_connection (paramiko not available path)
# ---------------------------------------------------------------------------
class TestConnectionNoParamiko:
def test_returns_error_when_paramiko_missing(self, tmp_path):
from app.services.ssh_targets import create_target, test_connection
import sys
db = _make_db(tmp_path)
key = _make_key(tmp_path)
t = create_target(db, label="x", host="127.0.0.1", port=22, user="u", key_path=str(key))
# Temporarily hide paramiko from the import system
original = sys.modules.get("paramiko")
sys.modules["paramiko"] = None # type: ignore[assignment]
try:
result = test_connection(db, t.id)
finally:
if original is None:
del sys.modules["paramiko"]
else:
sys.modules["paramiko"] = original
assert result["ok"] is False
assert "paramiko" in result["error"].lower()
def test_raises_key_error_for_missing_target(self, tmp_path):
from app.services.ssh_targets import test_connection
db = _make_db(tmp_path)
with pytest.raises(KeyError):
test_connection(db, "no-such-id")

View file

@ -282,6 +282,118 @@
</div>
</div>
<!-- Remote Hosts (SSH targets) -->
<div>
<h2 class="text-text-primary text-sm font-semibold mb-1">Remote Hosts</h2>
<p class="text-text-dim text-xs mb-3">
SSH hosts to pull logs from. Private keys are stored as path references only key contents are never read or transmitted.
</p>
<!-- Target list -->
<div v-if="sshTargets.length > 0" class="space-y-2 mb-3">
<div
v-for="t in sshTargets"
:key="t.id"
class="rounded border border-surface-border bg-surface p-3"
>
<div class="flex items-start gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm text-text-primary font-medium">{{ t.label }}</span>
<span class="font-mono text-xs text-text-dim">{{ t.user }}@{{ t.host }}:{{ t.port }}</span>
<!-- Connection status badge -->
<span
v-if="t.last_ok === true"
class="text-[10px] px-1.5 py-0.5 rounded bg-green-900/30 text-green-400 border border-green-800/40"
>Connected</span>
<span
v-else-if="t.last_ok === false"
class="text-[10px] px-1.5 py-0.5 rounded bg-red-900/30 text-sev-error border border-red-800/40"
:title="t.last_error ?? ''"
>Unreachable</span>
<span
v-else
class="text-[10px] px-1.5 py-0.5 rounded bg-surface-raised text-text-dim border border-surface-border"
>Not tested</span>
</div>
<p class="text-xs text-text-dim font-mono mt-0.5 truncate">{{ t.key_path }}</p>
<p v-if="t.key_warning" class="text-xs text-yellow-400 mt-0.5"> {{ t.key_warning }}</p>
<!-- Test result (persistent inline, not a toast) -->
<p
v-if="sshTestResults[t.id]"
class="text-xs mt-1"
:class="sshTestResults[t.id]!.ok ? 'text-green-400' : 'text-sev-error'"
>
{{ sshTestResults[t.id]!.ok ? 'Connection OK' : sshTestResults[t.id]!.error }}
</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
@click="testSshTarget(t.id)"
:disabled="sshTesting.has(t.id)"
class="text-xs text-text-dim hover:text-accent transition-colors px-2 py-1 rounded hover:bg-surface disabled:opacity-40"
>{{ sshTesting.has(t.id) ? 'Testing…' : 'Test' }}</button>
<button
@click="editSshTarget(t)"
class="text-xs text-text-dim hover:text-accent transition-colors px-2 py-1 rounded hover:bg-surface"
>Edit</button>
<button
@click="deleteSshTarget(t.id, t.label)"
class="text-xs text-text-dim hover:text-sev-error transition-colors px-2 py-1 rounded hover:bg-surface"
>Delete</button>
</div>
</div>
</div>
</div>
<p v-else class="text-text-dim text-xs mb-3">
No remote hosts configured. Add an SSH host to pull logs from remote machines without manual file exports.
</p>
<!-- Add / Edit form -->
<div v-if="sshForm.open" class="rounded border border-surface-border bg-surface p-3 space-y-3 mb-3">
<h3 class="text-text-primary text-xs font-medium">{{ sshForm.editId ? 'Edit host' : 'Add remote host' }}</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs text-text-dim mb-1">Display name</label>
<input v-model="sshForm.label" type="text" placeholder="e.g. rack-server-01"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm text-text-primary placeholder-text-dim focus:outline-none focus:border-accent" />
</div>
<div>
<label class="block text-xs text-text-dim mb-1">Host</label>
<input v-model="sshForm.host" type="text" placeholder="192.168.1.10 or server.example.com"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm text-text-primary placeholder-text-dim focus:outline-none focus:border-accent" />
</div>
<div>
<label class="block text-xs text-text-dim mb-1">Port</label>
<input v-model.number="sshForm.port" type="number" min="1" max="65535" placeholder="22"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm text-text-primary focus:outline-none focus:border-accent" />
</div>
<div>
<label class="block text-xs text-text-dim mb-1">Username</label>
<input v-model="sshForm.user" type="text" placeholder="root or alan"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm text-text-primary placeholder-text-dim focus:outline-none focus:border-accent" />
</div>
<div class="sm:col-span-2">
<label class="block text-xs text-text-dim mb-1">SSH key path</label>
<input v-model="sshForm.key_path" type="text" placeholder="~/.ssh/id_ed25519"
class="w-full bg-surface-raised border border-surface-border rounded px-2 py-1.5 text-sm font-mono text-text-primary placeholder-text-dim focus:outline-none focus:border-accent" />
</div>
</div>
<p v-if="sshFormError" class="text-sev-error text-xs">{{ sshFormError }}</p>
<div class="flex gap-2">
<button @click="saveSshTarget" :disabled="sshFormSaving"
class="px-3 py-1.5 bg-accent text-surface text-xs rounded font-medium hover:opacity-90 transition-opacity disabled:opacity-50">
{{ sshFormSaving ? 'Saving…' : (sshForm.editId ? 'Save changes' : 'Add host') }}
</button>
<button @click="closeSshForm" class="text-text-dim hover:text-text-primary text-xs">Cancel</button>
</div>
</div>
<button v-if="!sshForm.open" @click="sshForm.open = true" class="text-accent text-xs hover:underline">
+ Add remote host
</button>
</div>
<p
v-if="saveStatus"
role="status"
@ -322,6 +434,19 @@ interface Prefs {
device_names: string
}
interface SshTarget {
id: string
label: string
host: string
port: number
user: string
key_path: string
last_tested: string | null
last_ok: boolean | null
last_error: string | null
key_warning?: string | null
}
const techLevelOptions: { value: 'homelab' | 'sysadmin' | 'executive'; label: string; desc: string }[] = [
{ value: 'homelab', label: 'Homelab', desc: 'Clear explanations — spells out service names and why each action helps' },
{ value: 'sysadmin', label: 'Sysadmin', desc: 'Technical, structured 5-section diagnosis with commands and confidence scores' },
@ -363,6 +488,22 @@ const showApiKey = ref(false)
const showPiholeKey = ref(false)
const piholeStatus = ref<{ ok: boolean; msg: string } | null>(null)
const newRule = ref<SeverityOverride>({ name: '', pattern: '', override_severity: 'WARN', enabled: true })
// SSH targets
const sshTargets = ref<SshTarget[]>([])
const sshTestResults = ref<Record<string, { ok: boolean; error: string | null }>>({})
const sshTesting = ref<Set<string>>(new Set())
const sshFormSaving = ref(false)
const sshFormError = ref<string | null>(null)
const sshForm = ref({
open: false,
editId: null as string | null,
label: '',
host: '',
port: 22,
user: '',
key_path: '',
})
const entryPointBtnRefs = ref<HTMLButtonElement[]>([])
const entryPointOptions = [
@ -391,6 +532,7 @@ onMounted(async () => {
const res = await fetch(`${BASE}/api/settings`)
if (res.ok) prefs.value = await res.json()
} catch { /* non-critical — defaults stay */ }
await loadSshTargets()
})
async function patch(body: Partial<Prefs>) {
@ -490,4 +632,78 @@ async function testPihole() {
piholeStatus.value = { ok: false, msg: 'Network error' }
}
}
// --- SSH target management ---
async function loadSshTargets() {
try {
const res = await fetch(`${BASE}/api/ssh-targets`)
if (res.ok) sshTargets.value = await res.json()
} catch { /* non-critical */ }
}
async function testSshTarget(id: string) {
sshTesting.value = new Set([...sshTesting.value, id])
try {
const res = await fetch(`${BASE}/api/ssh-targets/${id}/test`, { method: 'POST' })
const data = await res.json()
sshTestResults.value = { ...sshTestResults.value, [id]: { ok: data.ok, error: data.error ?? null } }
// Refresh list so last_ok badge updates
await loadSshTargets()
} catch {
sshTestResults.value = { ...sshTestResults.value, [id]: { ok: false, error: 'Network error' } }
} finally {
const next = new Set(sshTesting.value)
next.delete(id)
sshTesting.value = next
}
}
function editSshTarget(t: SshTarget) {
sshFormError.value = null
sshForm.value = { open: true, editId: t.id, label: t.label, host: t.host, port: t.port, user: t.user, key_path: t.key_path }
}
async function deleteSshTarget(id: string, label: string) {
if (!confirm(`Delete remote host "${label}"?`)) return
try {
await fetch(`${BASE}/api/ssh-targets/${id}`, { method: 'DELETE' })
await loadSshTargets()
} catch { /* ignore */ }
}
async function saveSshTarget() {
const f = sshForm.value
if (!f.label.trim() || !f.host.trim() || !f.user.trim() || !f.key_path.trim()) {
sshFormError.value = 'All fields are required'
return
}
sshFormSaving.value = true
sshFormError.value = null
try {
const url = f.editId ? `${BASE}/api/ssh-targets/${f.editId}` : `${BASE}/api/ssh-targets`
const method = f.editId ? 'PATCH' : 'POST'
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label: f.label, host: f.host, port: f.port, user: f.user, key_path: f.key_path }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Save failed' }))
sshFormError.value = err.detail ?? 'Save failed'
return
}
closeSshForm()
await loadSshTargets()
} catch {
sshFormError.value = 'Network error'
} finally {
sshFormSaving.value = false
}
}
function closeSshForm() {
sshForm.value = { open: false, editId: null, label: '', host: '', port: 22, user: '', key_path: '' }
sshFormError.value = null
}
</script>