feat: SSH card UX, agent version sync, persistent ssh_admin_ok (0.11.5)
Silent SSH probe on host card open; manual test feedback auto-hides. Persist ssh_admin_ok in DB (migration 019). After agent update read version from host and refresh UI.
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
"""hosts: persist Linux SSH admin check status
|
||||||
|
|
||||||
|
Revision ID: 019
|
||||||
|
Revises: 018
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "019"
|
||||||
|
down_revision: Union[str, None] = "018"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("hosts", sa.Column("ssh_admin_ok", sa.Boolean(), nullable=True))
|
||||||
|
op.add_column("hosts", sa.Column("ssh_admin_checked_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("hosts", "ssh_admin_checked_at")
|
||||||
|
op.drop_column("hosts", "ssh_admin_ok")
|
||||||
@@ -3,6 +3,8 @@ from pydantic import BaseModel
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from app.auth.jwt_auth import get_current_user, require_admin
|
from app.auth.jwt_auth import get_current_user, require_admin
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
@@ -151,9 +153,16 @@ def _host_detail_from_model(
|
|||||||
inventory=host.inventory if isinstance(host.inventory, dict) else None,
|
inventory=host.inventory if isinstance(host.inventory, dict) else None,
|
||||||
inventory_updated_at=host.inventory_updated_at,
|
inventory_updated_at=host.inventory_updated_at,
|
||||||
created_at=host.created_at,
|
created_at=host.created_at,
|
||||||
|
ssh_admin_ok=host.ssh_admin_ok,
|
||||||
|
ssh_admin_checked_at=host.ssh_admin_checked_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_ssh_admin_status(host: Host, ok: bool) -> None:
|
||||||
|
host.ssh_admin_ok = ok
|
||||||
|
host.ssh_admin_checked_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{host_id}", response_model=HostDetail)
|
@router.get("/{host_id}", response_model=HostDetail)
|
||||||
def get_host(
|
def get_host(
|
||||||
host_id: int,
|
host_id: int,
|
||||||
@@ -189,9 +198,16 @@ class HostSshActionResponse(BaseModel):
|
|||||||
stdout: str | None = None
|
stdout: str | None = None
|
||||||
stderr: str | None = None
|
stderr: str | None = None
|
||||||
exit_code: int | None = None
|
exit_code: int | None = None
|
||||||
|
product_version: str | None = None
|
||||||
|
ssh_admin_ok: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
def _ssh_action_response(result: SshCommandResult, *, attempts: list[str] | None = None) -> HostSshActionResponse:
|
def _ssh_action_response(
|
||||||
|
result: SshCommandResult,
|
||||||
|
*,
|
||||||
|
attempts: list[str] | None = None,
|
||||||
|
ssh_admin_ok: bool | None = None,
|
||||||
|
) -> HostSshActionResponse:
|
||||||
message = result.message
|
message = result.message
|
||||||
if attempts:
|
if attempts:
|
||||||
message = f"{message} (пробовали: {', '.join(attempts)})"
|
message = f"{message} (пробовали: {', '.join(attempts)})"
|
||||||
@@ -202,6 +218,8 @@ def _ssh_action_response(result: SshCommandResult, *, attempts: list[str] | None
|
|||||||
stdout=result.stdout or None,
|
stdout=result.stdout or None,
|
||||||
stderr=result.stderr or None,
|
stderr=result.stderr or None,
|
||||||
exit_code=result.exit_code,
|
exit_code=result.exit_code,
|
||||||
|
product_version=result.agent_version,
|
||||||
|
ssh_admin_ok=ssh_admin_ok,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -313,7 +331,10 @@ def test_host_ssh(
|
|||||||
if not cfg.configured:
|
if not cfg.configured:
|
||||||
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured")
|
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured")
|
||||||
|
|
||||||
return _run_ssh_action_on_host(host, cfg, action=test_ssh_connection)
|
response = _run_ssh_action_on_host(host, cfg, action=test_ssh_connection)
|
||||||
|
_set_ssh_admin_status(host, response.ok)
|
||||||
|
db.commit()
|
||||||
|
return response.model_copy(update={"ssh_admin_ok": host.ssh_admin_ok})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{host_id}/actions/agent-update", response_model=HostSshActionResponse)
|
@router.post("/{host_id}/actions/agent-update", response_model=HostSshActionResponse)
|
||||||
@@ -330,7 +351,17 @@ def update_host_agent_via_ssh(
|
|||||||
if not cfg.configured:
|
if not cfg.configured:
|
||||||
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured")
|
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured")
|
||||||
|
|
||||||
return _run_ssh_action_on_host(host, cfg, action=run_ssh_monitor_update)
|
response = _run_ssh_action_on_host(host, cfg, action=run_ssh_monitor_update)
|
||||||
|
_set_ssh_admin_status(host, response.ok)
|
||||||
|
if response.ok and response.product_version:
|
||||||
|
host.product_version = response.product_version
|
||||||
|
db.commit()
|
||||||
|
return response.model_copy(
|
||||||
|
update={
|
||||||
|
"product_version": host.product_version if response.ok else response.product_version,
|
||||||
|
"ssh_admin_ok": host.ssh_admin_ok,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{host_id}", response_model=HostDeleteResponse)
|
@router.delete("/{host_id}", response_model=HostDeleteResponse)
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ class Host(Base):
|
|||||||
inventory: Mapped[dict | None] = mapped_column(JSONB)
|
inventory: Mapped[dict | None] = mapped_column(JSONB)
|
||||||
inventory_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
inventory_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
use_sac_mode: Mapped[str | None] = mapped_column(String(32))
|
use_sac_mode: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
ssh_admin_ok: Mapped[bool | None] = mapped_column(nullable=True)
|
||||||
|
ssh_admin_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ class HostDetail(HostSummary):
|
|||||||
inventory: dict | None = None
|
inventory: dict | None = None
|
||||||
inventory_updated_at: datetime | None = None
|
inventory_updated_at: datetime | None = None
|
||||||
created_at: datetime | None = None
|
created_at: datetime | None = None
|
||||||
|
ssh_admin_ok: bool | None = None
|
||||||
|
ssh_admin_checked_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class HostListResponse(BaseModel):
|
class HostListResponse(BaseModel):
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ from dataclasses import dataclass
|
|||||||
from app.models import Host
|
from app.models import Host
|
||||||
|
|
||||||
SSH_MONITOR_UPDATE_SCRIPT = "/opt/scripts/update_ssh_monitor.sh"
|
SSH_MONITOR_UPDATE_SCRIPT = "/opt/scripts/update_ssh_monitor.sh"
|
||||||
|
SSH_MONITOR_BINARY = "/usr/local/bin/ssh-monitor"
|
||||||
|
SSH_MONITOR_VERSION_CMD = (
|
||||||
|
f"grep -m1 '^SSH_MONITOR_VERSION=' {SSH_MONITOR_BINARY} 2>/dev/null "
|
||||||
|
"| sed -E 's/^SSH_MONITOR_VERSION=[\"'\\'']*([^\"'\\'']+)[\"'\\'']*$/\\1/'"
|
||||||
|
)
|
||||||
SSH_OUTPUT_MAX_LEN = 12_000
|
SSH_OUTPUT_MAX_LEN = 12_000
|
||||||
|
_AGENT_VERSION_RE = re.compile(r"(\d+\.\d+\.\d+(?:-SAC)?)", re.IGNORECASE)
|
||||||
_IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
|
_IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
|
||||||
|
|
||||||
|
|
||||||
@@ -33,6 +39,7 @@ class SshCommandResult:
|
|||||||
stdout: str = ""
|
stdout: str = ""
|
||||||
stderr: str = ""
|
stderr: str = ""
|
||||||
exit_code: int | None = None
|
exit_code: int | None = None
|
||||||
|
agent_version: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def is_linux_host(host: Host) -> bool:
|
def is_linux_host(host: Host) -> bool:
|
||||||
@@ -233,6 +240,37 @@ def test_ssh_connection(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ssh_monitor_version_text(text: str) -> str | None:
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
match = _AGENT_VERSION_RE.search(text)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def probe_ssh_monitor_version(
|
||||||
|
*,
|
||||||
|
target: str,
|
||||||
|
user: str,
|
||||||
|
password: str,
|
||||||
|
) -> str | None:
|
||||||
|
result = run_ssh_command(
|
||||||
|
target=target,
|
||||||
|
user=user,
|
||||||
|
password=password,
|
||||||
|
remote_cmd=SSH_MONITOR_VERSION_CMD,
|
||||||
|
command_timeout_sec=30,
|
||||||
|
)
|
||||||
|
if result.ok and result.stdout.strip():
|
||||||
|
version = parse_ssh_monitor_version_text(result.stdout)
|
||||||
|
if version:
|
||||||
|
return version
|
||||||
|
if result.stdout or result.stderr:
|
||||||
|
return parse_ssh_monitor_version_text(f"{result.stdout}\n{result.stderr}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def run_ssh_monitor_update(
|
def run_ssh_monitor_update(
|
||||||
*,
|
*,
|
||||||
target: str,
|
target: str,
|
||||||
@@ -258,7 +296,7 @@ def run_ssh_monitor_update(
|
|||||||
exit_code=probe.exit_code,
|
exit_code=probe.exit_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
return run_ssh_command(
|
updated = run_ssh_command(
|
||||||
target=target,
|
target=target,
|
||||||
user=user,
|
user=user,
|
||||||
password=password,
|
password=password,
|
||||||
@@ -266,3 +304,21 @@ def run_ssh_monitor_update(
|
|||||||
command_timeout_sec=900,
|
command_timeout_sec=900,
|
||||||
need_root=True,
|
need_root=True,
|
||||||
)
|
)
|
||||||
|
if not updated.ok:
|
||||||
|
return updated
|
||||||
|
|
||||||
|
version = probe_ssh_monitor_version(target=target, user=user, password=password)
|
||||||
|
if not version:
|
||||||
|
version = parse_ssh_monitor_version_text(updated.stdout)
|
||||||
|
if version:
|
||||||
|
message = f"{updated.message}\nagent version: {version}"
|
||||||
|
return SshCommandResult(
|
||||||
|
ok=True,
|
||||||
|
message=message,
|
||||||
|
target=updated.target,
|
||||||
|
stdout=updated.stdout,
|
||||||
|
stderr=updated.stderr,
|
||||||
|
exit_code=updated.exit_code,
|
||||||
|
agent_version=version,
|
||||||
|
)
|
||||||
|
return updated
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
||||||
|
|
||||||
APP_NAME = "Security Alert Center"
|
APP_NAME = "Security Alert Center"
|
||||||
APP_VERSION = "0.11.2"
|
APP_VERSION = "0.11.5"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
|
|||||||
|
|
||||||
|
|
||||||
def test_version_constants():
|
def test_version_constants():
|
||||||
assert APP_VERSION == "0.11.2"
|
assert APP_VERSION == "0.11.5"
|
||||||
assert APP_NAME == "Security Alert Center"
|
assert APP_NAME == "Security Alert Center"
|
||||||
assert APP_VERSION_LABEL == "Security Alert Center v.0.11.2"
|
assert APP_VERSION_LABEL == "Security Alert Center v.0.11.5"
|
||||||
|
|||||||
@@ -116,6 +116,11 @@ def test_host_ssh_test_success(jwt_headers, client, db_session, monkeypatch):
|
|||||||
assert body["ok"] is True
|
assert body["ok"] is True
|
||||||
assert "hostname=ubabuba" in body["message"]
|
assert "hostname=ubabuba" in body["message"]
|
||||||
assert body["target"] == "ubabuba"
|
assert body["target"] == "ubabuba"
|
||||||
|
assert body["ssh_admin_ok"] is True
|
||||||
|
|
||||||
|
db_session.refresh(host)
|
||||||
|
assert host.ssh_admin_ok is True
|
||||||
|
assert host.ssh_admin_checked_at is not None
|
||||||
|
|
||||||
|
|
||||||
def test_host_agent_update_success(jwt_headers, client, db_session, monkeypatch):
|
def test_host_agent_update_success(jwt_headers, client, db_session, monkeypatch):
|
||||||
@@ -149,3 +154,5 @@ def test_host_agent_update_success(jwt_headers, client, db_session, monkeypatch)
|
|||||||
body = response.json()
|
body = response.json()
|
||||||
assert body["ok"] is True
|
assert body["ok"] is True
|
||||||
assert body["target"] == "ubabuba"
|
assert body["target"] == "ubabuba"
|
||||||
|
if "product_version" in body:
|
||||||
|
assert body["product_version"] is None or isinstance(body["product_version"], str)
|
||||||
|
|||||||
@@ -182,13 +182,21 @@ def test_run_ssh_monitor_update_runs_script(monkeypatch):
|
|||||||
target="h",
|
target="h",
|
||||||
stdout="ready\n",
|
stdout="ready\n",
|
||||||
)
|
)
|
||||||
|
if calls == 2:
|
||||||
assert kwargs["remote_cmd"] == "/opt/scripts/update_ssh_monitor.sh"
|
assert kwargs["remote_cmd"] == "/opt/scripts/update_ssh_monitor.sh"
|
||||||
assert kwargs.get("need_root") is True
|
assert kwargs.get("need_root") is True
|
||||||
return ssh_connect.SshCommandResult(
|
return ssh_connect.SshCommandResult(
|
||||||
ok=True,
|
ok=True,
|
||||||
message="SSH OK",
|
message="SSH OK",
|
||||||
target="ubabuba",
|
target="ubabuba",
|
||||||
stdout="SUMMARY updated\n",
|
stdout="SUMMARY updated 2.1.0-SAC\n",
|
||||||
|
exit_code=0,
|
||||||
|
)
|
||||||
|
return ssh_connect.SshCommandResult(
|
||||||
|
ok=True,
|
||||||
|
message="ok",
|
||||||
|
target="ubabuba",
|
||||||
|
stdout='2.1.0-SAC\n',
|
||||||
exit_code=0,
|
exit_code=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -196,4 +204,11 @@ def test_run_ssh_monitor_update_runs_script(monkeypatch):
|
|||||||
|
|
||||||
result = run_ssh_monitor_update(target="ubabuba", user="root", password="pw")
|
result = run_ssh_monitor_update(target="ubabuba", user="root", password="pw")
|
||||||
assert result.ok is True
|
assert result.ok is True
|
||||||
assert calls == 2
|
assert result.agent_version == "2.1.0-SAC"
|
||||||
|
assert calls == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_ssh_monitor_version_text():
|
||||||
|
assert ssh_connect.parse_ssh_monitor_version_text("SSH_MONITOR_VERSION=2.1.0-SAC") == "2.1.0-SAC"
|
||||||
|
assert ssh_connect.parse_ssh_monitor_version_text("SUMMARY updated 2.1.1-SAC done") == "2.1.1-SAC"
|
||||||
|
assert ssh_connect.parse_ssh_monitor_version_text("no version") is None
|
||||||
|
|||||||
+5
-1
@@ -1,4 +1,4 @@
|
|||||||
const TOKEN_KEY = "sac_token";
|
const TOKEN_KEY = "sac_token";
|
||||||
const ROLE_KEY = "sac_role";
|
const ROLE_KEY = "sac_role";
|
||||||
|
|
||||||
export function getToken(): string | null {
|
export function getToken(): string | null {
|
||||||
@@ -227,6 +227,8 @@ export interface HostSummary {
|
|||||||
last_daily_report_at: string | null;
|
last_daily_report_at: string | null;
|
||||||
last_inventory_at?: string | null;
|
last_inventory_at?: string | null;
|
||||||
agent_status: "online" | "stale" | "unknown";
|
agent_status: "online" | "stale" | "unknown";
|
||||||
|
ssh_admin_ok?: boolean | null;
|
||||||
|
ssh_admin_checked_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HostDetail extends HostSummary {
|
export interface HostDetail extends HostSummary {
|
||||||
@@ -474,6 +476,8 @@ export interface HostSshActionResult {
|
|||||||
stdout: string | null;
|
stdout: string | null;
|
||||||
stderr: string | null;
|
stderr: string | null;
|
||||||
exit_code: number | null;
|
exit_code: number | null;
|
||||||
|
product_version?: string | null;
|
||||||
|
ssh_admin_ok?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function testHostSsh(hostId: number): Promise<HostSshActionResult> {
|
export function testHostSsh(hostId: number): Promise<HostSshActionResult> {
|
||||||
|
|||||||
@@ -198,6 +198,10 @@ button:disabled {
|
|||||||
color: #ff6b6b;
|
color: #ff6b6b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: #3fb950;
|
||||||
|
}
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
|||||||
@@ -42,3 +42,14 @@ export function isAgentVersionOutdated(
|
|||||||
if (lag === null) return true;
|
if (lag === null) return true;
|
||||||
return lag >= lagThreshold;
|
return lag >= lagThreshold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isAgentVersionNewer(
|
||||||
|
candidate: string | null | undefined,
|
||||||
|
current: string | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
const next = parseAgentVersion(candidate);
|
||||||
|
const prev = parseAgentVersion(current);
|
||||||
|
if (!next) return false;
|
||||||
|
if (!prev) return true;
|
||||||
|
return compareVersions(next, prev) > 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { HostDetail } from "../api";
|
||||||
|
|
||||||
|
type HostPatchListener = (detail: HostDetail) => void;
|
||||||
|
|
||||||
|
const listeners = new Set<HostPatchListener>();
|
||||||
|
|
||||||
|
export function subscribeHostPatch(listener: HostPatchListener): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emitHostPatch(detail: HostDetail): void {
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener(detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { HostDetail, HostSummary } from "../api";
|
import type { HostDetail, HostSummary } from "../api";
|
||||||
|
import { isAgentVersionNewer } from "./agentVersion";
|
||||||
|
|
||||||
/** Обновляет поля строки списка хостов из GET /hosts/{id} (без перезагрузки таблицы). */
|
/** Обновляет поля строки списка хостов из GET /hosts/{id} (без перезагрузки таблицы). */
|
||||||
export function patchHostSummaryFromDetail(target: HostSummary, detail: HostDetail): void {
|
export function patchHostSummaryFromDetail(target: HostSummary, detail: HostDetail): void {
|
||||||
@@ -13,3 +14,14 @@ export function patchHostSummaryFromDetail(target: HostSummary, detail: HostDeta
|
|||||||
target.last_inventory_at = detail.last_inventory_at;
|
target.last_inventory_at = detail.last_inventory_at;
|
||||||
target.agent_status = detail.agent_status;
|
target.agent_status = detail.agent_status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function bumpLatestAgentVersion(
|
||||||
|
latest: Record<string, string> | undefined,
|
||||||
|
product: string,
|
||||||
|
version: string | null | undefined,
|
||||||
|
): void {
|
||||||
|
if (!latest || !version) return;
|
||||||
|
if (isAgentVersionNewer(version, latest[product])) {
|
||||||
|
latest[product] = version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
|
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
|
||||||
export const APP_NAME = "Security Alert Center";
|
export const APP_NAME = "Security Alert Center";
|
||||||
export const APP_VERSION = "0.11.2";
|
export const APP_VERSION = "0.11.5";
|
||||||
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||||
|
|||||||
@@ -59,14 +59,22 @@
|
|||||||
<p v-if="winRmTestMessage" :class="winRmTestOk ? 'success' : 'error'">{{ winRmTestMessage }}</p>
|
<p v-if="winRmTestMessage" :class="winRmTestOk ? 'success' : 'error'">{{ winRmTestMessage }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isLinuxHost" class="host-actions">
|
<div v-if="isLinuxHost" class="host-actions">
|
||||||
|
<div class="host-actions-row">
|
||||||
|
<div class="host-action-item">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="secondary"
|
class="secondary"
|
||||||
:disabled="testingSsh"
|
:disabled="testingSsh"
|
||||||
@click="runSshTest"
|
@click="runSshTestManual"
|
||||||
>
|
>
|
||||||
{{ testingSsh ? "Проверка…" : "Проверить SSH" }}
|
{{ testingSsh ? "Проверка…" : "Проверить SSH" }}
|
||||||
</button>
|
</button>
|
||||||
|
<span
|
||||||
|
v-if="sshVerified"
|
||||||
|
class="host-action-ok"
|
||||||
|
:title="sshVerifiedTitle"
|
||||||
|
>✓</span>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="secondary"
|
class="secondary"
|
||||||
@@ -75,8 +83,15 @@
|
|||||||
>
|
>
|
||||||
{{ updatingAgent ? "Обновление…" : "Обновить ssh-monitor" }}
|
{{ updatingAgent ? "Обновление…" : "Обновить ssh-monitor" }}
|
||||||
</button>
|
</button>
|
||||||
<p v-if="sshActionMessage" :class="sshActionOk ? 'success' : 'error'">{{ sshActionMessage }}</p>
|
</div>
|
||||||
<pre v-if="sshActionOutput" class="host-ssh-output">{{ sshActionOutput }}</pre>
|
<div v-if="showSshTestFeedback && (sshTestMessage || sshTestOutput)" class="host-action-feedback">
|
||||||
|
<p :class="sshTestOk ? 'success' : 'error'">{{ sshTestMessage }}</p>
|
||||||
|
<pre v-if="sshTestOutput" class="host-ssh-output">{{ sshTestOutput }}</pre>
|
||||||
|
</div>
|
||||||
|
<div v-if="agentUpdateMessage || agentUpdateOutput" class="host-action-feedback">
|
||||||
|
<p :class="agentUpdateOk ? 'success' : 'error'">{{ agentUpdateMessage }}</p>
|
||||||
|
<pre v-if="agentUpdateOutput" class="host-ssh-output">{{ agentUpdateOutput }}</pre>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -189,7 +204,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from "vue";
|
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
|
import { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
|
||||||
import {
|
import {
|
||||||
@@ -202,6 +217,9 @@ import {
|
|||||||
type EventListResponse,
|
type EventListResponse,
|
||||||
type HostDetail,
|
type HostDetail,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
|
import { emitHostPatch } from "../utils/hostPatchBus";
|
||||||
|
|
||||||
|
const SSH_TEST_FEEDBACK_MS = 60_000;
|
||||||
|
|
||||||
const props = defineProps<{ id: string }>();
|
const props = defineProps<{ id: string }>();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -219,9 +237,15 @@ const winRmTestMessage = ref("");
|
|||||||
const winRmTestOk = ref(false);
|
const winRmTestOk = ref(false);
|
||||||
const testingSsh = ref(false);
|
const testingSsh = ref(false);
|
||||||
const updatingAgent = ref(false);
|
const updatingAgent = ref(false);
|
||||||
const sshActionMessage = ref("");
|
const showSshTestFeedback = ref(false);
|
||||||
const sshActionOk = ref(false);
|
const sshTestMessage = ref("");
|
||||||
const sshActionOutput = ref("");
|
const sshTestOk = ref(false);
|
||||||
|
const sshTestOutput = ref("");
|
||||||
|
const agentUpdateMessage = ref("");
|
||||||
|
const agentUpdateOk = ref(false);
|
||||||
|
const agentUpdateOutput = ref("");
|
||||||
|
let sshTestHideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let sshProbeSeq = 0;
|
||||||
let refreshingLive = false;
|
let refreshingLive = false;
|
||||||
|
|
||||||
useSacLiveEventStream(() => {
|
useSacLiveEventStream(() => {
|
||||||
@@ -244,6 +268,16 @@ const isLinuxHost = computed(() => {
|
|||||||
return h.product === "ssh-monitor";
|
return h.product === "ssh-monitor";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sshVerified = computed(() => host.value?.ssh_admin_ok === true);
|
||||||
|
|
||||||
|
const sshVerifiedTitle = computed(() => {
|
||||||
|
const checkedAt = host.value?.ssh_admin_checked_at;
|
||||||
|
if (checkedAt) {
|
||||||
|
return `SSH доступен (проверено ${formatDt(checkedAt)})`;
|
||||||
|
}
|
||||||
|
return "SSH доступен";
|
||||||
|
});
|
||||||
|
|
||||||
const inventory = computed(() => host.value?.inventory ?? null);
|
const inventory = computed(() => host.value?.inventory ?? null);
|
||||||
|
|
||||||
const windowsFields = computed(() => {
|
const windowsFields = computed(() => {
|
||||||
@@ -319,21 +353,89 @@ function agentLabel(status: string) {
|
|||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadHost() {
|
function clearSshTestFeedbackTimer() {
|
||||||
loading.value = true;
|
if (sshTestHideTimer != null) {
|
||||||
error.value = "";
|
clearTimeout(sshTestHideTimer);
|
||||||
winRmTestMessage.value = "";
|
sshTestHideTimer = null;
|
||||||
sshActionMessage.value = "";
|
|
||||||
sshActionOutput.value = "";
|
|
||||||
try {
|
|
||||||
host.value = await fetchHost(hostId.value);
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e instanceof Error ? e.message : "Ошибка загрузки хоста";
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLinuxHostDetail(detail: HostDetail): boolean {
|
||||||
|
if ((detail.os_family || "").toLowerCase() === "linux") return true;
|
||||||
|
return detail.product === "ssh-monitor";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySshAdminStatus(result: { ok: boolean; ssh_admin_ok?: boolean | null }) {
|
||||||
|
if (!host.value) return;
|
||||||
|
host.value = {
|
||||||
|
...host.value,
|
||||||
|
ssh_admin_ok: result.ssh_admin_ok ?? result.ok,
|
||||||
|
ssh_admin_checked_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSshTestFeedbackHide() {
|
||||||
|
clearSshTestFeedbackTimer();
|
||||||
|
sshTestHideTimer = setTimeout(() => {
|
||||||
|
sshTestMessage.value = "";
|
||||||
|
sshTestOutput.value = "";
|
||||||
|
showSshTestFeedback.value = false;
|
||||||
|
sshTestHideTimer = null;
|
||||||
|
}, SSH_TEST_FEEDBACK_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSshTest(options: { silent?: boolean } = {}) {
|
||||||
|
const silent = options.silent === true;
|
||||||
|
const probeId = ++sshProbeSeq;
|
||||||
|
|
||||||
|
if (!silent) {
|
||||||
|
testingSsh.value = true;
|
||||||
|
clearSshTestFeedbackTimer();
|
||||||
|
sshTestMessage.value = "";
|
||||||
|
sshTestOutput.value = "";
|
||||||
|
showSshTestFeedback.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await testHostSsh(hostId.value);
|
||||||
|
if (probeId !== sshProbeSeq) return;
|
||||||
|
|
||||||
|
applySshAdminStatus(result);
|
||||||
|
|
||||||
|
if (!silent) {
|
||||||
|
showSshTestFeedback.value = true;
|
||||||
|
sshTestOk.value = result.ok;
|
||||||
|
sshTestMessage.value = result.message;
|
||||||
|
sshTestOutput.value = formatSshOutput(result);
|
||||||
|
if (result.ok) {
|
||||||
|
scheduleSshTestFeedbackHide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (probeId !== sshProbeSeq) return;
|
||||||
|
if (host.value) {
|
||||||
|
host.value = { ...host.value, ssh_admin_ok: false, ssh_admin_checked_at: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
if (!silent) {
|
||||||
|
showSshTestFeedback.value = true;
|
||||||
|
sshTestOk.value = false;
|
||||||
|
sshTestMessage.value = e instanceof Error ? e.message : "Ошибка проверки SSH";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!silent && probeId === sshProbeSeq) {
|
||||||
|
testingSsh.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSshTestManual() {
|
||||||
|
void runSshTest({ silent: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeSshOnOpen() {
|
||||||
|
await runSshTest({ silent: true });
|
||||||
|
}
|
||||||
|
|
||||||
async function runWinRmTest() {
|
async function runWinRmTest() {
|
||||||
testingWinRm.value = true;
|
testingWinRm.value = true;
|
||||||
winRmTestMessage.value = "";
|
winRmTestMessage.value = "";
|
||||||
@@ -363,38 +465,59 @@ function formatSshOutput(result: { stdout: string | null; stderr: string | null;
|
|||||||
return parts.join("\n\n");
|
return parts.join("\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runSshTest() {
|
function applyHostDetail(detail: HostDetail) {
|
||||||
testingSsh.value = true;
|
host.value = detail;
|
||||||
sshActionMessage.value = "";
|
emitHostPatch(detail);
|
||||||
sshActionOutput.value = "";
|
}
|
||||||
|
|
||||||
|
async function loadHost() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
winRmTestMessage.value = "";
|
||||||
|
sshProbeSeq += 1;
|
||||||
|
clearSshTestFeedbackTimer();
|
||||||
|
showSshTestFeedback.value = false;
|
||||||
|
sshTestMessage.value = "";
|
||||||
|
sshTestOutput.value = "";
|
||||||
|
agentUpdateMessage.value = "";
|
||||||
|
agentUpdateOutput.value = "";
|
||||||
try {
|
try {
|
||||||
const result = await testHostSsh(hostId.value);
|
const detail = await fetchHost(hostId.value);
|
||||||
sshActionOk.value = result.ok;
|
applyHostDetail(detail);
|
||||||
sshActionMessage.value = result.message;
|
if (isLinuxHostDetail(detail)) {
|
||||||
sshActionOutput.value = formatSshOutput(result);
|
void probeSshOnOpen();
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
sshActionOk.value = false;
|
error.value = e instanceof Error ? e.message : "Ошибка загрузки хоста";
|
||||||
sshActionMessage.value = e instanceof Error ? e.message : "Ошибка проверки SSH";
|
|
||||||
} finally {
|
} finally {
|
||||||
testingSsh.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runAgentUpdate() {
|
async function runAgentUpdate() {
|
||||||
updatingAgent.value = true;
|
updatingAgent.value = true;
|
||||||
sshActionMessage.value = "";
|
agentUpdateMessage.value = "";
|
||||||
sshActionOutput.value = "";
|
agentUpdateOutput.value = "";
|
||||||
try {
|
try {
|
||||||
const result = await updateHostAgentViaSsh(hostId.value);
|
const result = await updateHostAgentViaSsh(hostId.value);
|
||||||
sshActionOk.value = result.ok;
|
agentUpdateOk.value = result.ok;
|
||||||
sshActionMessage.value = result.message;
|
agentUpdateMessage.value = result.message;
|
||||||
sshActionOutput.value = formatSshOutput(result);
|
agentUpdateOutput.value = formatSshOutput(result);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
host.value = await fetchHost(hostId.value);
|
const detail = await fetchHost(hostId.value);
|
||||||
|
if (result.product_version) {
|
||||||
|
detail.product_version = result.product_version;
|
||||||
|
}
|
||||||
|
if (result.ssh_admin_ok != null) {
|
||||||
|
detail.ssh_admin_ok = result.ssh_admin_ok;
|
||||||
|
}
|
||||||
|
applyHostDetail(detail);
|
||||||
|
} else if (host.value && result.ssh_admin_ok != null) {
|
||||||
|
host.value = { ...host.value, ssh_admin_ok: result.ssh_admin_ok };
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
sshActionOk.value = false;
|
agentUpdateOk.value = false;
|
||||||
sshActionMessage.value = e instanceof Error ? e.message : "Ошибка обновления агента";
|
agentUpdateMessage.value = e instanceof Error ? e.message : "Ошибка обновления агента";
|
||||||
} finally {
|
} finally {
|
||||||
updatingAgent.value = false;
|
updatingAgent.value = false;
|
||||||
}
|
}
|
||||||
@@ -407,7 +530,7 @@ async function refreshFromLatestEvent() {
|
|||||||
const recent = await fetchRecentEvents(1);
|
const recent = await fetchRecentEvents(1);
|
||||||
const ev = recent[0];
|
const ev = recent[0];
|
||||||
if (!ev || ev.host_id !== hostId.value) return;
|
if (!ev || ev.host_id !== hostId.value) return;
|
||||||
host.value = await fetchHost(hostId.value);
|
applyHostDetail(await fetchHost(hostId.value));
|
||||||
if (eventsPage.value === 1) {
|
if (eventsPage.value === 1) {
|
||||||
await loadEvents(1);
|
await loadEvents(1);
|
||||||
}
|
}
|
||||||
@@ -441,6 +564,10 @@ onMounted(async () => {
|
|||||||
await loadEvents(1);
|
await loadEvents(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearSshTestFeedbackTimer();
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.params.id,
|
() => route.params.id,
|
||||||
async () => {
|
async () => {
|
||||||
@@ -470,12 +597,35 @@ watch(
|
|||||||
|
|
||||||
.host-actions {
|
.host-actions {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-actions-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.host-action-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-action-ok {
|
||||||
|
color: #3fb950;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-action-feedback {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.host-ssh-output {
|
.host-ssh-output {
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
max-height: 16rem;
|
max-height: 16rem;
|
||||||
|
|||||||
@@ -96,17 +96,19 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from "vue";
|
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
|
import { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
|
||||||
import {
|
import {
|
||||||
apiFetch,
|
apiFetch,
|
||||||
fetchHost,
|
fetchHost,
|
||||||
fetchRecentEvents,
|
fetchRecentEvents,
|
||||||
|
type HostDetail,
|
||||||
type HostListResponse,
|
type HostListResponse,
|
||||||
type HostSummary,
|
type HostSummary,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import { patchHostSummaryFromDetail } from "../utils/hostsLiveUpdate";
|
import { bumpLatestAgentVersion, patchHostSummaryFromDetail } from "../utils/hostsLiveUpdate";
|
||||||
|
import { subscribeHostPatch } from "../utils/hostPatchBus";
|
||||||
import { isAgentVersionOutdated } from "../utils/agentVersion";
|
import { isAgentVersionOutdated } from "../utils/agentVersion";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -172,6 +174,16 @@ const sortBy = ref<SortKey>(initialSort.sortBy);
|
|||||||
const sortDir = ref<"asc" | "desc">(initialSort.sortDir);
|
const sortDir = ref<"asc" | "desc">(initialSort.sortDir);
|
||||||
const deletingId = ref<number | null>(null);
|
const deletingId = ref<number | null>(null);
|
||||||
let patchingHostRow = false;
|
let patchingHostRow = false;
|
||||||
|
let unsubscribeHostPatch: (() => void) | null = null;
|
||||||
|
|
||||||
|
function applyHostPatchFromDetail(detail: HostDetail) {
|
||||||
|
if (!data.value) return;
|
||||||
|
const row = data.value.items.find((h) => h.id === detail.id);
|
||||||
|
if (row) {
|
||||||
|
patchHostSummaryFromDetail(row, detail);
|
||||||
|
bumpLatestAgentVersion(data.value.latest_agent_versions, row.product, detail.product_version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { live } = useSacLiveEventStream(() => {
|
const { live } = useSacLiveEventStream(() => {
|
||||||
void patchHostRowFromLatestEvent();
|
void patchHostRowFromLatestEvent();
|
||||||
@@ -369,6 +381,12 @@ async function confirmDelete(h: HostSummary) {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
syncFromRoute();
|
syncFromRoute();
|
||||||
loadHosts();
|
loadHosts();
|
||||||
|
unsubscribeHostPatch = subscribeHostPatch(applyHostPatchFromDetail);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
unsubscribeHostPatch?.();
|
||||||
|
unsubscribeHostPatch = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
Reference in New Issue
Block a user