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:
+5
-1
@@ -1,4 +1,4 @@
|
||||
const TOKEN_KEY = "sac_token";
|
||||
const TOKEN_KEY = "sac_token";
|
||||
const ROLE_KEY = "sac_role";
|
||||
|
||||
export function getToken(): string | null {
|
||||
@@ -227,6 +227,8 @@ export interface HostSummary {
|
||||
last_daily_report_at: string | null;
|
||||
last_inventory_at?: string | null;
|
||||
agent_status: "online" | "stale" | "unknown";
|
||||
ssh_admin_ok?: boolean | null;
|
||||
ssh_admin_checked_at?: string | null;
|
||||
}
|
||||
|
||||
export interface HostDetail extends HostSummary {
|
||||
@@ -474,6 +476,8 @@ export interface HostSshActionResult {
|
||||
stdout: string | null;
|
||||
stderr: string | null;
|
||||
exit_code: number | null;
|
||||
product_version?: string | null;
|
||||
ssh_admin_ok?: boolean | null;
|
||||
}
|
||||
|
||||
export function testHostSsh(hostId: number): Promise<HostSshActionResult> {
|
||||
|
||||
@@ -198,6 +198,10 @@ button:disabled {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #3fb950;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow: auto;
|
||||
font-size: 0.8rem;
|
||||
|
||||
@@ -42,3 +42,14 @@ export function isAgentVersionOutdated(
|
||||
if (lag === null) return true;
|
||||
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 { isAgentVersionNewer } from "./agentVersion";
|
||||
|
||||
/** Обновляет поля строки списка хостов из GET /hosts/{id} (без перезагрузки таблицы). */
|
||||
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.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 */
|
||||
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}`;
|
||||
|
||||
@@ -59,24 +59,39 @@
|
||||
<p v-if="winRmTestMessage" :class="winRmTestOk ? 'success' : 'error'">{{ winRmTestMessage }}</p>
|
||||
</div>
|
||||
<div v-if="isLinuxHost" class="host-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="secondary"
|
||||
:disabled="testingSsh"
|
||||
@click="runSshTest"
|
||||
>
|
||||
{{ testingSsh ? "Проверка…" : "Проверить SSH" }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary"
|
||||
:disabled="updatingAgent"
|
||||
@click="runAgentUpdate"
|
||||
>
|
||||
{{ updatingAgent ? "Обновление…" : "Обновить ssh-monitor" }}
|
||||
</button>
|
||||
<p v-if="sshActionMessage" :class="sshActionOk ? 'success' : 'error'">{{ sshActionMessage }}</p>
|
||||
<pre v-if="sshActionOutput" class="host-ssh-output">{{ sshActionOutput }}</pre>
|
||||
<div class="host-actions-row">
|
||||
<div class="host-action-item">
|
||||
<button
|
||||
type="button"
|
||||
class="secondary"
|
||||
:disabled="testingSsh"
|
||||
@click="runSshTestManual"
|
||||
>
|
||||
{{ testingSsh ? "Проверка…" : "Проверить SSH" }}
|
||||
</button>
|
||||
<span
|
||||
v-if="sshVerified"
|
||||
class="host-action-ok"
|
||||
:title="sshVerifiedTitle"
|
||||
>✓</span>
|
||||
</div>
|
||||
<button
|
||||
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>
|
||||
|
||||
@@ -189,7 +204,7 @@
|
||||
</template>
|
||||
|
||||
<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 { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
|
||||
import {
|
||||
@@ -202,6 +217,9 @@ import {
|
||||
type EventListResponse,
|
||||
type HostDetail,
|
||||
} from "../api";
|
||||
import { emitHostPatch } from "../utils/hostPatchBus";
|
||||
|
||||
const SSH_TEST_FEEDBACK_MS = 60_000;
|
||||
|
||||
const props = defineProps<{ id: string }>();
|
||||
const route = useRoute();
|
||||
@@ -219,9 +237,15 @@ const winRmTestMessage = ref("");
|
||||
const winRmTestOk = ref(false);
|
||||
const testingSsh = ref(false);
|
||||
const updatingAgent = ref(false);
|
||||
const sshActionMessage = ref("");
|
||||
const sshActionOk = ref(false);
|
||||
const sshActionOutput = ref("");
|
||||
const showSshTestFeedback = ref(false);
|
||||
const sshTestMessage = 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;
|
||||
|
||||
useSacLiveEventStream(() => {
|
||||
@@ -244,6 +268,16 @@ const isLinuxHost = computed(() => {
|
||||
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 windowsFields = computed(() => {
|
||||
@@ -319,21 +353,89 @@ function agentLabel(status: string) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
async function loadHost() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
winRmTestMessage.value = "";
|
||||
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 clearSshTestFeedbackTimer() {
|
||||
if (sshTestHideTimer != null) {
|
||||
clearTimeout(sshTestHideTimer);
|
||||
sshTestHideTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
testingWinRm.value = true;
|
||||
winRmTestMessage.value = "";
|
||||
@@ -363,38 +465,59 @@ function formatSshOutput(result: { stdout: string | null; stderr: string | null;
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
async function runSshTest() {
|
||||
testingSsh.value = true;
|
||||
sshActionMessage.value = "";
|
||||
sshActionOutput.value = "";
|
||||
function applyHostDetail(detail: HostDetail) {
|
||||
host.value = detail;
|
||||
emitHostPatch(detail);
|
||||
}
|
||||
|
||||
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 {
|
||||
const result = await testHostSsh(hostId.value);
|
||||
sshActionOk.value = result.ok;
|
||||
sshActionMessage.value = result.message;
|
||||
sshActionOutput.value = formatSshOutput(result);
|
||||
const detail = await fetchHost(hostId.value);
|
||||
applyHostDetail(detail);
|
||||
if (isLinuxHostDetail(detail)) {
|
||||
void probeSshOnOpen();
|
||||
}
|
||||
} catch (e) {
|
||||
sshActionOk.value = false;
|
||||
sshActionMessage.value = e instanceof Error ? e.message : "Ошибка проверки SSH";
|
||||
error.value = e instanceof Error ? e.message : "Ошибка загрузки хоста";
|
||||
} finally {
|
||||
testingSsh.value = false;
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runAgentUpdate() {
|
||||
updatingAgent.value = true;
|
||||
sshActionMessage.value = "";
|
||||
sshActionOutput.value = "";
|
||||
agentUpdateMessage.value = "";
|
||||
agentUpdateOutput.value = "";
|
||||
try {
|
||||
const result = await updateHostAgentViaSsh(hostId.value);
|
||||
sshActionOk.value = result.ok;
|
||||
sshActionMessage.value = result.message;
|
||||
sshActionOutput.value = formatSshOutput(result);
|
||||
agentUpdateOk.value = result.ok;
|
||||
agentUpdateMessage.value = result.message;
|
||||
agentUpdateOutput.value = formatSshOutput(result);
|
||||
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) {
|
||||
sshActionOk.value = false;
|
||||
sshActionMessage.value = e instanceof Error ? e.message : "Ошибка обновления агента";
|
||||
agentUpdateOk.value = false;
|
||||
agentUpdateMessage.value = e instanceof Error ? e.message : "Ошибка обновления агента";
|
||||
} finally {
|
||||
updatingAgent.value = false;
|
||||
}
|
||||
@@ -407,7 +530,7 @@ async function refreshFromLatestEvent() {
|
||||
const recent = await fetchRecentEvents(1);
|
||||
const ev = recent[0];
|
||||
if (!ev || ev.host_id !== hostId.value) return;
|
||||
host.value = await fetchHost(hostId.value);
|
||||
applyHostDetail(await fetchHost(hostId.value));
|
||||
if (eventsPage.value === 1) {
|
||||
await loadEvents(1);
|
||||
}
|
||||
@@ -441,6 +564,10 @@ onMounted(async () => {
|
||||
await loadEvents(1);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearSshTestFeedbackTimer();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.params.id,
|
||||
async () => {
|
||||
@@ -470,12 +597,35 @@ watch(
|
||||
|
||||
.host-actions {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.host-actions-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
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 {
|
||||
margin-top: 0.75rem;
|
||||
max-height: 16rem;
|
||||
|
||||
@@ -96,17 +96,19 @@
|
||||
</template>
|
||||
|
||||
<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 { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
|
||||
import {
|
||||
apiFetch,
|
||||
fetchHost,
|
||||
fetchRecentEvents,
|
||||
type HostDetail,
|
||||
type HostListResponse,
|
||||
type HostSummary,
|
||||
} from "../api";
|
||||
import { patchHostSummaryFromDetail } from "../utils/hostsLiveUpdate";
|
||||
import { bumpLatestAgentVersion, patchHostSummaryFromDetail } from "../utils/hostsLiveUpdate";
|
||||
import { subscribeHostPatch } from "../utils/hostPatchBus";
|
||||
import { isAgentVersionOutdated } from "../utils/agentVersion";
|
||||
|
||||
const router = useRouter();
|
||||
@@ -172,6 +174,16 @@ const sortBy = ref<SortKey>(initialSort.sortBy);
|
||||
const sortDir = ref<"asc" | "desc">(initialSort.sortDir);
|
||||
const deletingId = ref<number | null>(null);
|
||||
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(() => {
|
||||
void patchHostRowFromLatestEvent();
|
||||
@@ -369,6 +381,12 @@ async function confirmDelete(h: HostSummary) {
|
||||
onMounted(() => {
|
||||
syncFromRoute();
|
||||
loadHosts();
|
||||
unsubscribeHostPatch = subscribeHostPatch(applyHostPatchFromDetail);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
unsubscribeHostPatch?.();
|
||||
unsubscribeHostPatch = null;
|
||||
});
|
||||
|
||||
watch(
|
||||
|
||||
Reference in New Issue
Block a user