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:
@@ -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,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}`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user