feat: host session list/terminate and event session actions (0.20.28)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
PTah
2026-06-22 10:13:42 +10:00
parent ca0255e13b
commit d848d94604
12 changed files with 773 additions and 5 deletions
+50
View File
@@ -573,6 +573,56 @@ export function fetchHostRemoteJob(hostId: number): Promise<HostRemoteActionJobS
return apiFetch<HostRemoteActionJobStatus>(`/api/v1/hosts/${hostId}/actions/remote-job`);
}
export interface HostSessionItem {
session_id: string;
user: string;
tty?: string | null;
state?: string | null;
source_ip?: string | null;
session_name?: string | null;
}
export interface HostSessionsResponse {
ok: boolean;
message: string;
target?: string | null;
sessions: HostSessionItem[];
}
export interface HostSessionTerminateResponse {
ok: boolean;
message: string;
target?: string | null;
stdout?: string | null;
stderr?: string | null;
}
export function fetchHostSessions(hostId: number): Promise<HostSessionsResponse> {
return apiFetch<HostSessionsResponse>(`/api/v1/hosts/${hostId}/actions/sessions/list`, {
method: "POST",
});
}
export function terminateHostSession(
hostId: number,
sessionId: string,
): Promise<HostSessionTerminateResponse> {
return apiFetch<HostSessionTerminateResponse>(`/api/v1/hosts/${hostId}/actions/sessions/terminate`, {
method: "POST",
body: JSON.stringify({ session_id: sessionId }),
});
}
export function postEventTerminateSession(
eventId: number,
sessionId?: string,
): Promise<HostSessionTerminateResponse> {
return apiFetch<HostSessionTerminateResponse>(`/api/v1/events/${eventId}/actions/terminate-session`, {
method: "POST",
body: JSON.stringify(sessionId ? { session_id: sessionId } : {}),
});
}
export interface HostAgentConfigResponse {
config_revision: number;
settings: Record<string, unknown>;
@@ -0,0 +1,114 @@
<template>
<section class="card host-sessions">
<h2>Залогиненные пользователи</h2>
<p class="muted">
Текущие сессии на хосте (Linux: loginctl, Windows: qwinsta). Требуются учётные данные admin в настройках SAC.
</p>
<p v-if="error" class="error">{{ error }}</p>
<template v-if="loaded">
<p v-if="!sessions.length" class="muted">Активных сессий не найдено.</p>
<table v-else>
<thead>
<tr>
<th>Пользователь</th>
<th>Session</th>
<th>TTY / имя</th>
<th>Состояние</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
<tr v-for="s in sessions" :key="s.session_id + s.user">
<td>{{ s.user }}</td>
<td><code>{{ s.session_id }}</code></td>
<td>{{ s.tty || s.session_name || "—" }}</td>
<td>{{ s.state || "—" }}</td>
<td>
<button
type="button"
class="secondary"
:disabled="terminatingId === s.session_id"
@click="terminate(s.session_id)"
>
{{ terminatingId === s.session_id ? "…" : "Завершить" }}
</button>
</td>
</tr>
</tbody>
</table>
<p v-if="message" :class="messageOk ? 'success' : 'error'">{{ message }}</p>
</template>
<button type="button" class="secondary" :disabled="loading" @click="load">
{{ loading ? "Загрузка" : loaded ? "Обновить" : "Показать пользователей" }}
</button>
</section>
</template>
<script setup lang="ts">
import { ref } from "vue";
import {
fetchHostSessions,
terminateHostSession,
type HostSessionItem,
} from "../api";
const props = defineProps<{ hostId: number }>();
const loading = ref(false);
const loaded = ref(false);
const sessions = ref<HostSessionItem[]>([]);
const error = ref("");
const message = ref("");
const messageOk = ref(true);
const terminatingId = ref<string | null>(null);
async function load() {
loading.value = true;
error.value = "";
message.value = "";
try {
const res = await fetchHostSessions(props.hostId);
if (!res.ok) {
error.value = res.message;
sessions.value = [];
} else {
sessions.value = res.sessions;
loaded.value = true;
}
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка загрузки сессий";
} finally {
loading.value = false;
}
}
async function terminate(sessionId: string) {
if (!window.confirm(`Завершить сессию ${sessionId}?`)) return;
terminatingId.value = sessionId;
message.value = "";
try {
const res = await terminateHostSession(props.hostId, sessionId);
messageOk.value = res.ok;
message.value = res.message;
if (res.ok) {
sessions.value = sessions.value.filter((s) => s.session_id !== sessionId);
}
} catch (e) {
messageOk.value = false;
message.value = e instanceof Error ? e.message : "Ошибка завершения сессии";
} finally {
terminatingId.value = null;
}
}
</script>
<style scoped>
.host-sessions table {
width: 100%;
margin: 0.75rem 0;
}
.host-sessions button.secondary {
margin-top: 0.5rem;
}
</style>
+16
View File
@@ -0,0 +1,16 @@
import type { EventSummary } from "../api";
const LINUX_SESSION_EVENT_TYPES = new Set([
"session.logind.new",
"ssh.login.success",
"privilege.sudo.command",
]);
const WINDOWS_SESSION_EVENT_TYPES = new Set(["rdp.login.success"]);
export function eventSupportsSessionTerminate(event: Pick<EventSummary, "type">): boolean {
return (
LINUX_SESSION_EVENT_TYPES.has(event.type) ||
WINDOWS_SESSION_EVENT_TYPES.has(event.type)
);
}
+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.20.27";
export const APP_VERSION = "0.20.28";
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
+28 -1
View File
@@ -67,6 +67,15 @@
>
qwinsta
</button>
<button
v-if="eventSupportsSessionTerminate(e)"
type="button"
class="secondary events-qwinsta-btn"
:disabled="sessionTerminateLoadingId === e.id"
@click="runSessionTerminate(e)"
>
{{ sessionTerminateLoadingId === e.id ? "…" : "Завершить" }}
</button>
</td>
</tr>
</tbody>
@@ -95,7 +104,7 @@
import { computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
import { apiFetch, type EventListResponse } from "../api";
import { apiFetch, postEventTerminateSession, type EventListResponse, type EventSummary } from "../api";
import RdgFlapBadge from "../components/RdgFlapBadge.vue";
import RdgQwinstaModal from "../components/RdgQwinstaModal.vue";
import { useRdgQwinsta } from "../composables/useRdgQwinsta";
@@ -108,10 +117,12 @@ import {
type EventsListState,
} from "../utils/eventsListQuery";
import { formatServerName } from "../utils/hostDisplay";
import { eventSupportsSessionTerminate } from "../utils/sessionActions";
const route = useRoute();
const router = useRouter();
const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta();
const sessionTerminateLoadingId = ref<number | null>(null);
const data = ref<EventListResponse | null>(null);
const loading = ref(false);
@@ -205,6 +216,22 @@ useSacLiveEventStream(() => {
void load(undefined, { syncUrl: false, silent: true });
});
async function runSessionTerminate(event: EventSummary) {
const label = event.actor_user || event.title;
if (!window.confirm(`Завершить сессию пользователя ${label}?`)) return;
sessionTerminateLoadingId.value = event.id;
try {
const res = await postEventTerminateSession(event.id);
if (!res.ok) {
window.alert(res.message);
}
} catch (e) {
window.alert(e instanceof Error ? e.message : "Ошибка завершения сессии");
} finally {
sessionTerminateLoadingId.value = null;
}
}
function applyFilters() {
load(1);
}
+3
View File
@@ -166,6 +166,8 @@
</form>
</section>
<HostSessionsPanel v-if="showAgentConfig" :host-id="host.id" />
<section v-if="inventory" class="host-inventory card">
<h2>Железо и ПО</h2>
<div v-if="windowsInfo" class="inv-block">
@@ -294,6 +296,7 @@ import {
type HostDetail,
} from "../api";
import { emitHostPatch } from "../utils/hostPatchBus";
import HostSessionsPanel from "../components/HostSessionsPanel.vue";
const HOST_ACTION_FEEDBACK_MS = 30_000;