feat: manual host add from Hosts list via WinRM/SSH (0.4.5)
Add host button probes Windows by hostname or Linux by IP, registers host in SAC and deploys agent in background. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from typing import Literal
|
||||||
from pydantic import BaseModel
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -176,6 +178,11 @@ def list_hosts(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HostManualAddBody(BaseModel):
|
||||||
|
platform: Literal["windows", "linux"]
|
||||||
|
target: str = Field(..., min_length=1, max_length=253)
|
||||||
|
|
||||||
|
|
||||||
def _host_detail_from_model(
|
def _host_detail_from_model(
|
||||||
host: Host,
|
host: Host,
|
||||||
*,
|
*,
|
||||||
@@ -298,6 +305,46 @@ class HostRemoteActionStartResponse(BaseModel):
|
|||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/manual-add",
|
||||||
|
response_model=HostRemoteActionStartResponse,
|
||||||
|
status_code=status.HTTP_202_ACCEPTED,
|
||||||
|
)
|
||||||
|
def post_host_manual_add(
|
||||||
|
body: HostManualAddBody,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> HostRemoteActionStartResponse:
|
||||||
|
from app.services.host_manual_add import ManualHostAddError, prepare_manual_host_add
|
||||||
|
|
||||||
|
try:
|
||||||
|
host = prepare_manual_host_add(db, platform=body.platform, target=body.target)
|
||||||
|
except ManualHostAddError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
if body.platform == "windows":
|
||||||
|
title = f"Ручное добавление Windows: {host.hostname}"
|
||||||
|
else:
|
||||||
|
title = f"Ручное добавление Linux: {host.hostname}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
start_host_remote_action(
|
||||||
|
db,
|
||||||
|
host,
|
||||||
|
title=title,
|
||||||
|
runner=run_agent_fallback_action,
|
||||||
|
)
|
||||||
|
except RemoteActionAlreadyRunningError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return HostRemoteActionStartResponse(
|
||||||
|
status="running",
|
||||||
|
host_id=host.id,
|
||||||
|
title=title,
|
||||||
|
message="Проверка пройдена, установка агента запущена на сервере SAC",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class HostRemoteActionJobResponse(BaseModel):
|
class HostRemoteActionJobResponse(BaseModel):
|
||||||
active: bool
|
active: bool
|
||||||
host_id: int
|
host_id: int
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""Probe remote host and register in SAC before agent deploy (WinRM / SSH)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import Host
|
||||||
|
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
|
||||||
|
from app.services.linux_admin_settings import get_effective_linux_admin_config
|
||||||
|
from app.services.ssh_connect import _is_ipv4, _ssh_output_hostname, test_ssh_connection
|
||||||
|
from app.services.win_admin_settings import get_effective_win_admin_config
|
||||||
|
from app.services.winrm_connect import test_winrm_connection
|
||||||
|
|
||||||
|
_IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
|
||||||
|
|
||||||
|
|
||||||
|
class ManualHostAddError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def validate_windows_target(target: str) -> str:
|
||||||
|
text = (target or "").strip()
|
||||||
|
if not text:
|
||||||
|
raise ManualHostAddError("Укажите имя Windows-ПК")
|
||||||
|
if _IPV4_RE.match(text):
|
||||||
|
raise ManualHostAddError(
|
||||||
|
"Для Windows укажите имя ПК (NetBIOS/DNS), не IP — WinRM с Kerberos по IP ненадёжен"
|
||||||
|
)
|
||||||
|
short = text.split(".")[0].split("\\")[-1].strip()
|
||||||
|
if not short or " " in short:
|
||||||
|
raise ManualHostAddError("Некорректное имя хоста")
|
||||||
|
return short
|
||||||
|
|
||||||
|
|
||||||
|
def validate_linux_target(target: str) -> str:
|
||||||
|
text = (target or "").strip()
|
||||||
|
if not text:
|
||||||
|
raise ManualHostAddError("Укажите IP или hostname Linux-хоста")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def probe_windows_host(db: Session, hostname: str) -> str:
|
||||||
|
cfg = get_effective_win_admin_config(db)
|
||||||
|
if not cfg.configured:
|
||||||
|
raise ManualHostAddError("Windows domain admin не настроен в SAC")
|
||||||
|
probe = test_winrm_connection(
|
||||||
|
target=hostname,
|
||||||
|
user=cfg.user,
|
||||||
|
password=cfg.password,
|
||||||
|
)
|
||||||
|
if not probe.ok:
|
||||||
|
raise ManualHostAddError(probe.message)
|
||||||
|
return (probe.hostname or hostname).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def probe_linux_host(db: Session, target: str) -> tuple[str, str | None]:
|
||||||
|
cfg = get_effective_linux_admin_config(db)
|
||||||
|
if not cfg.configured:
|
||||||
|
raise ManualHostAddError("Linux SSH admin не настроен в SAC")
|
||||||
|
probe = test_ssh_connection(
|
||||||
|
target=target,
|
||||||
|
user=cfg.user,
|
||||||
|
password=cfg.password,
|
||||||
|
)
|
||||||
|
if not probe.ok:
|
||||||
|
raise ManualHostAddError(probe.message)
|
||||||
|
hostname = _ssh_output_hostname(probe.stdout) or target
|
||||||
|
ipv4 = target if _is_ipv4(target) else None
|
||||||
|
return hostname, ipv4
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_manual_host(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
hostname: str,
|
||||||
|
product: str,
|
||||||
|
os_family: str,
|
||||||
|
ipv4: str | None = None,
|
||||||
|
) -> Host:
|
||||||
|
host = db.scalar(
|
||||||
|
select(Host).where(Host.hostname == hostname, Host.product == product).limit(1)
|
||||||
|
)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if host is None:
|
||||||
|
host = Host(
|
||||||
|
hostname=hostname,
|
||||||
|
os_family=os_family,
|
||||||
|
product=product,
|
||||||
|
ipv4=ipv4,
|
||||||
|
last_seen_at=now,
|
||||||
|
)
|
||||||
|
db.add(host)
|
||||||
|
else:
|
||||||
|
host.os_family = os_family
|
||||||
|
if ipv4:
|
||||||
|
host.ipv4 = ipv4
|
||||||
|
host.last_seen_at = now
|
||||||
|
db.flush()
|
||||||
|
return host
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_manual_host_add(db: Session, *, platform: str, target: str) -> Host:
|
||||||
|
platform_norm = (platform or "").strip().lower()
|
||||||
|
if platform_norm == "windows":
|
||||||
|
win_target = validate_windows_target(target)
|
||||||
|
hostname = probe_windows_host(db, win_target)
|
||||||
|
return get_or_create_manual_host(
|
||||||
|
db,
|
||||||
|
hostname=hostname,
|
||||||
|
product=PRODUCT_RDP,
|
||||||
|
os_family="windows",
|
||||||
|
)
|
||||||
|
if platform_norm == "linux":
|
||||||
|
linux_target = validate_linux_target(target)
|
||||||
|
hostname, ipv4 = probe_linux_host(db, linux_target)
|
||||||
|
return get_or_create_manual_host(
|
||||||
|
db,
|
||||||
|
hostname=hostname,
|
||||||
|
product=PRODUCT_SSH,
|
||||||
|
os_family="linux",
|
||||||
|
ipv4=ipv4,
|
||||||
|
)
|
||||||
|
raise ManualHostAddError("platform должен быть windows или linux")
|
||||||
@@ -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.4.4"
|
APP_VERSION = "0.4.5"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""Manual host add: validation, probe, deploy kick-off."""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models import Host
|
||||||
|
from app.services.host_manual_add import (
|
||||||
|
ManualHostAddError,
|
||||||
|
prepare_manual_host_add,
|
||||||
|
validate_linux_target,
|
||||||
|
validate_windows_target,
|
||||||
|
)
|
||||||
|
from app.services.winrm_connect import WinRmTestResult
|
||||||
|
from tests.test_agent_update import wait_remote_job
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_windows_rejects_ip():
|
||||||
|
with pytest.raises(ManualHostAddError, match="не IP"):
|
||||||
|
validate_windows_target("10.10.36.9")
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_windows_accepts_hostname():
|
||||||
|
assert validate_windows_target("WORKSTATION-01") == "WORKSTATION-01"
|
||||||
|
assert validate_windows_target(r"B26\WORKSTATION-01") == "WORKSTATION-01"
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_linux_accepts_ip():
|
||||||
|
assert validate_linux_target("10.10.36.9") == "10.10.36.9"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_manual_host_windows(db_session, monkeypatch):
|
||||||
|
monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin")
|
||||||
|
monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw")
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
with patch("app.services.host_manual_add.test_winrm_connection") as mock_win:
|
||||||
|
mock_win.return_value = WinRmTestResult(
|
||||||
|
ok=True,
|
||||||
|
message="WinRM OK, hostname=WORKSTATION-01",
|
||||||
|
target="WORKSTATION-01",
|
||||||
|
hostname="WORKSTATION-01",
|
||||||
|
)
|
||||||
|
host = prepare_manual_host_add(
|
||||||
|
db_session,
|
||||||
|
platform="windows",
|
||||||
|
target="WORKSTATION-01",
|
||||||
|
)
|
||||||
|
assert host.hostname == "WORKSTATION-01"
|
||||||
|
assert host.product == "rdp-login-monitor"
|
||||||
|
assert host.os_family == "windows"
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_manual_add_windows(jwt_headers, client, db_session, monkeypatch):
|
||||||
|
monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin")
|
||||||
|
monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw")
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
with patch("app.services.host_manual_add.test_winrm_connection") as mock_win:
|
||||||
|
mock_win.return_value = WinRmTestResult(
|
||||||
|
ok=True,
|
||||||
|
message="WinRM OK, hostname=NEW-PC",
|
||||||
|
target="NEW-PC",
|
||||||
|
hostname="NEW-PC",
|
||||||
|
)
|
||||||
|
with patch("app.services.agent_update.run_winrm_rdp_monitor_update") as mock_deploy:
|
||||||
|
from app.services.winrm_connect import WinRmCmdResult
|
||||||
|
|
||||||
|
mock_deploy.return_value = WinRmCmdResult(
|
||||||
|
ok=True,
|
||||||
|
message="deploy ok",
|
||||||
|
target="NEW-PC",
|
||||||
|
stdout="2.1.8-SAC",
|
||||||
|
)
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/hosts/manual-add",
|
||||||
|
json={"platform": "windows", "target": "NEW-PC"},
|
||||||
|
headers=jwt_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 202
|
||||||
|
body = response.json()
|
||||||
|
assert body["status"] == "running"
|
||||||
|
host_id = body["host_id"]
|
||||||
|
host = db_session.get(Host, host_id)
|
||||||
|
assert host is not None
|
||||||
|
assert host.hostname == "NEW-PC"
|
||||||
|
job = wait_remote_job(client, host_id, jwt_headers)
|
||||||
|
assert job["ok"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_manual_add_rejects_windows_ip(jwt_headers, client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/hosts/manual-add",
|
||||||
|
json={"platform": "windows", "target": "192.168.1.10"},
|
||||||
|
headers=jwt_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "не IP" in response.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_manual_add_linux(jwt_headers, client, db_session, monkeypatch):
|
||||||
|
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "root")
|
||||||
|
monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "pw")
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
with patch("app.services.host_manual_add.test_ssh_connection") as mock_ssh:
|
||||||
|
from app.services.ssh_connect import SshCommandResult
|
||||||
|
|
||||||
|
mock_ssh.return_value = SshCommandResult(
|
||||||
|
ok=True,
|
||||||
|
message="SSH OK, hostname=linux-srv",
|
||||||
|
target="10.10.36.9",
|
||||||
|
stdout="linux-srv\n",
|
||||||
|
)
|
||||||
|
with patch("app.services.agent_update.run_ssh_monitor_update") as mock_update:
|
||||||
|
mock_update.return_value = SshCommandResult(
|
||||||
|
ok=True,
|
||||||
|
message="updated",
|
||||||
|
target="10.10.36.9",
|
||||||
|
agent_version="2.1.5-SAC",
|
||||||
|
)
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/hosts/manual-add",
|
||||||
|
json={"platform": "linux", "target": "10.10.36.9"},
|
||||||
|
headers=jwt_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 202
|
||||||
|
host_id = response.json()["host_id"]
|
||||||
|
host = db_session.get(Host, host_id)
|
||||||
|
assert host.hostname == "linux-srv"
|
||||||
|
assert host.ipv4 == "10.10.36.9"
|
||||||
|
job = wait_remote_job(client, host_id, jwt_headers, timeout=8.0)
|
||||||
|
assert job["ok"] is True
|
||||||
@@ -575,6 +575,18 @@ export function runHostAgentUpdateFallback(hostId: number): Promise<HostRemoteAc
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HostManualAddRequest {
|
||||||
|
platform: "windows" | "linux";
|
||||||
|
target: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addHostManually(body: HostManualAddRequest): Promise<HostRemoteActionStartResult> {
|
||||||
|
return apiFetch<HostRemoteActionStartResult>("/api/v1/hosts/manual-add", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchHostRemoteJob(hostId: number): Promise<HostRemoteActionJobStatus> {
|
export function fetchHostRemoteJob(hostId: number): Promise<HostRemoteActionJobStatus> {
|
||||||
return apiFetch<HostRemoteActionJobStatus>(`/api/v1/hosts/${hostId}/actions/remote-job`);
|
return apiFetch<HostRemoteActionJobStatus>(`/api/v1/hosts/${hostId}/actions/remote-job`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="open" class="modal-backdrop" @click.self="emit('close')">
|
||||||
|
<div class="modal-card host-manual-add-modal" role="dialog" aria-modal="true" aria-label="Добавить хост вручную">
|
||||||
|
<h3>Добавить хост вручную</h3>
|
||||||
|
<p class="muted">
|
||||||
|
SAC проверит подключение и установит агент (RDP-login-monitor или ssh-monitor) через WinRM / SSH.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<fieldset class="platform-fieldset">
|
||||||
|
<legend>Тип хоста</legend>
|
||||||
|
<label class="platform-option">
|
||||||
|
<input v-model="platform" type="radio" value="windows" :disabled="submitting" />
|
||||||
|
Windows — только по имени ПК (WinRM)
|
||||||
|
</label>
|
||||||
|
<label class="platform-option">
|
||||||
|
<input v-model="platform" type="radio" value="linux" :disabled="submitting" />
|
||||||
|
Linux — IP или hostname (SSH)
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<label class="target-label">
|
||||||
|
{{ platform === "windows" ? "Имя ПК (NetBIOS / DNS)" : "IP или hostname" }}
|
||||||
|
<input
|
||||||
|
v-model="target"
|
||||||
|
type="text"
|
||||||
|
:placeholder="platform === 'windows' ? 'WORKSTATION-01' : '10.10.36.9'"
|
||||||
|
:disabled="submitting"
|
||||||
|
@keyup.enter="submit"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
<p v-else-if="submitting" class="muted">Проверка WinRM/SSH и запуск установки…</p>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="secondary" :disabled="submitting" @click="emit('close')">Отмена</button>
|
||||||
|
<button type="button" :disabled="submitting || !target.trim()" @click="submit">
|
||||||
|
{{ submitting ? "Выполняется…" : "Добавить и установить" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
import { addHostManually, ApiError } from "../api";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: [];
|
||||||
|
started: [payload: { hostId: number; title: string }];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const platform = ref<"windows" | "linux">("windows");
|
||||||
|
const target = ref("");
|
||||||
|
const submitting = ref(false);
|
||||||
|
const error = ref("");
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(isOpen) => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
platform.value = "windows";
|
||||||
|
target.value = "";
|
||||||
|
submitting.value = false;
|
||||||
|
error.value = "";
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const trimmed = target.value.trim();
|
||||||
|
if (!trimmed || submitting.value) return;
|
||||||
|
submitting.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
const result = await addHostManually({
|
||||||
|
platform: platform.value,
|
||||||
|
target: trimmed,
|
||||||
|
});
|
||||||
|
emit("started", { hostId: result.host_id, title: result.title });
|
||||||
|
emit("close");
|
||||||
|
} catch (e) {
|
||||||
|
error.value =
|
||||||
|
e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Не удалось добавить хост";
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card {
|
||||||
|
width: min(100%, 28rem);
|
||||||
|
padding: 1rem 1.1rem 1.1rem;
|
||||||
|
background: var(--card-bg, #1e2430);
|
||||||
|
border: 1px solid var(--border, #3a4556);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card h3 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-fieldset {
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--border, #3a4556);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-fieldset legend {
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #9aa4b2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-option {
|
||||||
|
display: block;
|
||||||
|
margin: 0.35rem 0;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.target-label {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.target-label input {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -163,6 +163,54 @@ async function executeRemoteAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function pollHostRemoteAction(
|
||||||
|
hostId: number,
|
||||||
|
title: string,
|
||||||
|
): Promise<HostRemoteActionJobStatus | null> {
|
||||||
|
const existingPromise = hostRunningPromises.get(hostId);
|
||||||
|
if (existingPromise) {
|
||||||
|
const existing = findLoadingLogForHost(hostId);
|
||||||
|
if (existing) {
|
||||||
|
existing.open = true;
|
||||||
|
}
|
||||||
|
return existingPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry: HostRemoteActionLogEntry = {
|
||||||
|
id: newLogId(),
|
||||||
|
hostId,
|
||||||
|
open: true,
|
||||||
|
loading: true,
|
||||||
|
ok: null,
|
||||||
|
title,
|
||||||
|
message: "Выполняется на удалённом хосте…",
|
||||||
|
output: "",
|
||||||
|
};
|
||||||
|
hostRemoteActionLogs.push(entry);
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
|
try {
|
||||||
|
const job = await pollRemoteJob(hostId, entry);
|
||||||
|
finishEntry(entry, job);
|
||||||
|
await patchHostFromJob(hostId, job);
|
||||||
|
return job;
|
||||||
|
} catch (e) {
|
||||||
|
entry.loading = false;
|
||||||
|
entry.ok = false;
|
||||||
|
entry.message = e instanceof Error ? e.message : "Ошибка выполнения";
|
||||||
|
entry.open = true;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
hostRunningPromises.set(hostId, promise);
|
||||||
|
try {
|
||||||
|
return await promise;
|
||||||
|
} finally {
|
||||||
|
hostRunningPromises.delete(hostId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function runHostRemoteAction(
|
export async function runHostRemoteAction(
|
||||||
hostId: number,
|
hostId: number,
|
||||||
title: string,
|
title: string,
|
||||||
|
|||||||
@@ -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.4.4";
|
export const APP_VERSION = "0.4.5";
|
||||||
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button type="button" class="secondary" @click="applyFilters">Найти</button>
|
<button type="button" class="secondary" @click="applyFilters">Найти</button>
|
||||||
|
<button type="button" @click="manualAddOpen = true">Добавить вручную</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
<p v-else-if="loading">Загрузка…</p>
|
<p v-else-if="loading">Загрузка…</p>
|
||||||
@@ -108,6 +109,11 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</template>
|
</template>
|
||||||
|
<HostManualAddModal
|
||||||
|
:open="manualAddOpen"
|
||||||
|
@close="manualAddOpen = false"
|
||||||
|
@started="onManualAddStarted"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -133,10 +139,12 @@ import {
|
|||||||
} from "../utils/hostAgentUpgrade";
|
} from "../utils/hostAgentUpgrade";
|
||||||
import {
|
import {
|
||||||
isHostRemoteActionActive,
|
isHostRemoteActionActive,
|
||||||
|
pollHostRemoteAction,
|
||||||
runHostRemoteAction,
|
runHostRemoteAction,
|
||||||
showHostRemoteActionLog,
|
showHostRemoteActionLog,
|
||||||
} from "../composables/useHostRemoteAction";
|
} from "../composables/useHostRemoteAction";
|
||||||
import HostAgentVersionUpgrade from "../components/HostAgentVersionUpgrade.vue";
|
import HostAgentVersionUpgrade from "../components/HostAgentVersionUpgrade.vue";
|
||||||
|
import HostManualAddModal from "../components/HostManualAddModal.vue";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -200,6 +208,7 @@ const initialSort = loadHostsSortPrefs();
|
|||||||
const sortBy = ref<SortKey>(initialSort.sortBy);
|
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);
|
||||||
|
const manualAddOpen = ref(false);
|
||||||
let patchingHostRow = false;
|
let patchingHostRow = false;
|
||||||
let unsubscribeHostPatch: (() => void) | null = null;
|
let unsubscribeHostPatch: (() => void) | null = null;
|
||||||
|
|
||||||
@@ -404,6 +413,11 @@ interface HostDeleteResponse {
|
|||||||
deleted_problems: number;
|
deleted_problems: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onManualAddStarted(payload: { hostId: number; title: string }) {
|
||||||
|
await loadHosts();
|
||||||
|
void pollHostRemoteAction(payload.hostId, payload.title);
|
||||||
|
}
|
||||||
|
|
||||||
async function confirmDelete(h: HostSummary) {
|
async function confirmDelete(h: HostSummary) {
|
||||||
const label = h.display_name || h.hostname;
|
const label = h.display_name || h.hostname;
|
||||||
const n = h.event_count ?? 0;
|
const n = h.event_count ?? 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user