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:
parent
7a2ab0bb46
commit
b8f766fb74
5 changed files with 824 additions and 0 deletions
|
|
@ -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_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_domain ON blocklist_candidates(domain_or_ip);
|
||||||
CREATE INDEX IF NOT EXISTS idx_blocklist_tenant ON blocklist_candidates(tenant_id);
|
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 = """
|
_CONTEXT_SCHEMA_SQLITE = """
|
||||||
|
|
|
||||||
84
app/rest.py
84
app/rest.py
|
|
@ -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.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.nl_source import interpret as _nl_interpret
|
||||||
from app.services import orchard as _orchard
|
from app.services import orchard as _orchard
|
||||||
|
from app.services import ssh_targets as _ssh_targets
|
||||||
from app.services.incidents import (
|
from app.services.incidents import (
|
||||||
build_bundle,
|
build_bundle,
|
||||||
create_incident,
|
create_incident,
|
||||||
|
|
@ -801,6 +802,89 @@ class BatchGleanRequest(BaseModel):
|
||||||
entries: list[BatchEntry]
|
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 ──────────────────────────────────────────────
|
# ── Setup / Onboarding wizard ──────────────────────────────────────────────
|
||||||
|
|
||||||
class SetupWriteBody(BaseModel):
|
class SetupWriteBody(BaseModel):
|
||||||
|
|
|
||||||
265
app/services/ssh_targets.py
Normal file
265
app/services/ssh_targets.py
Normal 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
245
tests/test_ssh_targets.py
Normal 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")
|
||||||
|
|
@ -282,6 +282,118 @@
|
||||||
</div>
|
</div>
|
||||||
</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
|
<p
|
||||||
v-if="saveStatus"
|
v-if="saveStatus"
|
||||||
role="status"
|
role="status"
|
||||||
|
|
@ -322,6 +434,19 @@ interface Prefs {
|
||||||
device_names: string
|
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 }[] = [
|
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: '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' },
|
{ 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 showPiholeKey = ref(false)
|
||||||
const piholeStatus = ref<{ ok: boolean; msg: string } | null>(null)
|
const piholeStatus = ref<{ ok: boolean; msg: string } | null>(null)
|
||||||
const newRule = ref<SeverityOverride>({ name: '', pattern: '', override_severity: 'WARN', enabled: true })
|
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 entryPointBtnRefs = ref<HTMLButtonElement[]>([])
|
||||||
|
|
||||||
const entryPointOptions = [
|
const entryPointOptions = [
|
||||||
|
|
@ -391,6 +532,7 @@ onMounted(async () => {
|
||||||
const res = await fetch(`${BASE}/api/settings`)
|
const res = await fetch(`${BASE}/api/settings`)
|
||||||
if (res.ok) prefs.value = await res.json()
|
if (res.ok) prefs.value = await res.json()
|
||||||
} catch { /* non-critical — defaults stay */ }
|
} catch { /* non-critical — defaults stay */ }
|
||||||
|
await loadSshTargets()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function patch(body: Partial<Prefs>) {
|
async function patch(body: Partial<Prefs>) {
|
||||||
|
|
@ -490,4 +632,78 @@ async function testPihole() {
|
||||||
piholeStatus.value = { ok: false, msg: 'Network error' }
|
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>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue