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:
PTah
2026-06-25 15:34:12 +10:00
parent 5a7909bb55
commit fd1be5e7e4
9 changed files with 550 additions and 4 deletions
+12
View File
@@ -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> {
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(
hostId: number,
title: string,
+1 -1
View File
@@ -1,4 +1,4 @@
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
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}`;
+14
View File
@@ -15,6 +15,7 @@
</select>
</label>
<button type="button" class="secondary" @click="applyFilters">Найти</button>
<button type="button" @click="manualAddOpen = true">Добавить вручную</button>
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading">Загрузка</p>
@@ -108,6 +109,11 @@
</tbody>
</table>
</template>
<HostManualAddModal
:open="manualAddOpen"
@close="manualAddOpen = false"
@started="onManualAddStarted"
/>
</template>
<script setup lang="ts">
@@ -133,10 +139,12 @@ import {
} from "../utils/hostAgentUpgrade";
import {
isHostRemoteActionActive,
pollHostRemoteAction,
runHostRemoteAction,
showHostRemoteActionLog,
} from "../composables/useHostRemoteAction";
import HostAgentVersionUpgrade from "../components/HostAgentVersionUpgrade.vue";
import HostManualAddModal from "../components/HostManualAddModal.vue";
const router = useRouter();
const route = useRoute();
@@ -200,6 +208,7 @@ const initialSort = loadHostsSortPrefs();
const sortBy = ref<SortKey>(initialSort.sortBy);
const sortDir = ref<"asc" | "desc">(initialSort.sortDir);
const deletingId = ref<number | null>(null);
const manualAddOpen = ref(false);
let patchingHostRow = false;
let unsubscribeHostPatch: (() => void) | null = null;
@@ -404,6 +413,11 @@ interface HostDeleteResponse {
deleted_problems: number;
}
async function onManualAddStarted(payload: { hostId: number; title: string }) {
await loadHosts();
void pollHostRemoteAction(payload.hostId, payload.title);
}
async function confirmDelete(h: HostSummary) {
const label = h.display_name || h.hostname;
const n = h.event_count ?? 0;