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:
2026-06-20 01:01:56 +10:00
parent 6fea8262fb
commit 2eb06acb5b
17 changed files with 423 additions and 70 deletions
@@ -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")
+34 -3
View File
@@ -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)
+2
View File
@@ -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())
+2
View File
@@ -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):
+57 -1
View File
@@ -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 -1
View File
@@ -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}"
+2 -2
View File
@@ -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)
+20 -5
View File
@@ -182,13 +182,21 @@ def test_run_ssh_monitor_update_runs_script(monkeypatch):
target="h", target="h",
stdout="ready\n", stdout="ready\n",
) )
assert kwargs["remote_cmd"] == "/opt/scripts/update_ssh_monitor.sh" if calls == 2:
assert kwargs.get("need_root") is True assert kwargs["remote_cmd"] == "/opt/scripts/update_ssh_monitor.sh"
assert kwargs.get("need_root") is True
return ssh_connect.SshCommandResult(
ok=True,
message="SSH OK",
target="ubabuba",
stdout="SUMMARY updated 2.1.0-SAC\n",
exit_code=0,
)
return ssh_connect.SshCommandResult( return ssh_connect.SshCommandResult(
ok=True, ok=True,
message="SSH OK", message="ok",
target="ubabuba", target="ubabuba",
stdout="SUMMARY updated\n", 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
View File
@@ -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> {
+4
View File
@@ -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;
+11
View File
@@ -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;
}
+16
View File
@@ -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);
}
}
+12
View File
@@ -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 -1
View File
@@ -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}`;
+204 -54
View File
@@ -59,24 +59,39 @@
<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">
<button <div class="host-actions-row">
type="button" <div class="host-action-item">
class="secondary" <button
:disabled="testingSsh" type="button"
@click="runSshTest" class="secondary"
> :disabled="testingSsh"
{{ testingSsh ? "Проверка…" : "Проверить SSH" }} @click="runSshTestManual"
</button> >
<button {{ testingSsh ? "Проверка…" : "Проверить SSH" }}
type="button" </button>
class="secondary" <span
:disabled="updatingAgent" v-if="sshVerified"
@click="runAgentUpdate" class="host-action-ok"
> :title="sshVerifiedTitle"
{{ updatingAgent ? "Обновление…" : "Обновить ssh-monitor" }} ></span>
</button> </div>
<p v-if="sshActionMessage" :class="sshActionOk ? 'success' : 'error'">{{ sshActionMessage }}</p> <button
<pre v-if="sshActionOutput" class="host-ssh-output">{{ sshActionOutput }}</pre> type="button"
class="secondary"
:disabled="updatingAgent"
@click="runAgentUpdate"
>
{{ updatingAgent ? "Обновление…" : "Обновить ssh-monitor" }}
</button>
</div>
<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;
+20 -2
View File
@@ -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(