feat: qwinsta/logoff buttons on events list and detail (0.20.4)

RDG flap actions were only on the dashboard; add shared composable and
modal so operators can reset stuck sessions from События and event detail.
This commit is contained in:
2026-06-20 17:24:16 +10:00
parent 34a1670ea9
commit a35e50ccd1
8 changed files with 397 additions and 268 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
APP_NAME = "Security Alert Center"
APP_VERSION = "0.20.3"
APP_VERSION = "0.20.4"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
+116
View File
@@ -0,0 +1,116 @@
<template>
<div v-if="modal.open" class="modal-backdrop" @click.self="emit('close')">
<div class="modal-card qwinsta-modal" role="dialog" aria-modal="true">
<h3>qwinsta {{ modal.clientHostname || modal.target || `#${modal.eventId}` }}</h3>
<p v-if="modal.internalIp" class="muted qwinsta-meta">Клиентский ПК: {{ modal.internalIp }}</p>
<p v-if="modal.error" class="error">{{ modal.error }}</p>
<p v-else-if="modal.loading">Ожидание ответа агента</p>
<template v-else>
<pre v-if="modal.stdout" class="qwinsta-raw">{{ modal.stdout }}</pre>
<p v-else class="muted">Нет вывода</p>
<table v-if="modal.sessions.length" class="dash-table">
<thead>
<tr>
<th>SESSION</th>
<th>USER</th>
<th>ID</th>
<th>STATE</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="s in modal.sessions" :key="s.id">
<td>{{ s.sessionName }}</td>
<td>{{ s.userName }}</td>
<td>{{ s.id }}</td>
<td>{{ s.state }}</td>
<td>
<button
type="button"
class="danger"
:disabled="modal.logoffId === s.id"
@click="emit('logoff', s.id)"
>
logoff
</button>
</td>
</tr>
</tbody>
</table>
</template>
<div class="modal-actions">
<button type="button" class="secondary" @click="emit('close')">Закрыть</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { QwinstaSessionRow } from "../composables/useRdgQwinsta";
defineProps<{
modal: {
open: boolean;
eventId: number;
clientHostname: string;
target: string;
internalIp: string;
loading: boolean;
error: string;
stdout: string;
sessions: QwinstaSessionRow[];
logoffId: number | null;
};
}>();
const emit = defineEmits<{
close: [];
logoff: [sessionId: number];
}>();
</script>
<style scoped>
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal-card {
background: var(--card-bg, #1e2430);
border: 1px solid var(--border, #3a4556);
border-radius: 8px;
max-width: 720px;
width: 100%;
max-height: 85vh;
overflow: auto;
padding: 1rem 1.25rem;
}
.qwinsta-meta {
margin: 0 0 0.75rem;
font-size: 0.9rem;
}
.qwinsta-raw {
font-size: 0.8rem;
max-height: 12rem;
overflow: auto;
white-space: pre-wrap;
background: #0d1117;
padding: 0.75rem;
border-radius: 4px;
}
.modal-actions {
margin-top: 1rem;
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
</style>
+186
View File
@@ -0,0 +1,186 @@
import { onUnmounted, ref } from "vue";
import {
fetchEventAction,
postEventLogoff,
postEventQwinsta,
type AgentCommandResponse,
type EventSummary,
} from "../api";
export type QwinstaSessionRow = {
sessionName: string;
userName: string;
id: number;
state: string;
};
function parseQwinstaSessions(stdout: string, actorUser: string): QwinstaSessionRow[] {
const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
const rows: QwinstaSessionRow[] = [];
for (const line of lines) {
if (/^SESSION/i.test(line) || /^---/.test(line)) continue;
const parts = line.split(/\s+/);
if (parts.length < 4) continue;
const sessionName = parts[0];
const userName = parts[1];
const id = Number.parseInt(parts[2], 10);
const state = parts.slice(3).join(" ");
if (!Number.isFinite(id)) continue;
const norm = (s: string) => s.replace(/^B26\\/i, "").toLowerCase();
const matchUser = actorUser && norm(userName).includes(norm(actorUser));
if (actorUser && !matchUser) continue;
rows.push({ sessionName, userName, id, state });
}
if (rows.length === 0 && stdout.trim()) {
for (const line of lines) {
if (/^SESSION/i.test(line) || /^---/.test(line)) continue;
const parts = line.split(/\s+/);
if (parts.length < 4) continue;
const id = Number.parseInt(parts[2], 10);
if (!Number.isFinite(id)) continue;
rows.push({
sessionName: parts[0],
userName: parts[1],
id,
state: parts.slice(3).join(" "),
});
}
}
return rows;
}
export function useRdgQwinsta() {
let qwinstaPollTimer: ReturnType<typeof setInterval> | null = null;
const qwinstaLoadingId = ref<number | null>(null);
const qwinstaModal = ref({
open: false,
eventId: 0,
actorUser: "",
commandUuid: "",
target: "",
clientHostname: "",
internalIp: "",
loading: false,
error: "",
stdout: "",
sessions: [] as QwinstaSessionRow[],
logoffId: null as number | null,
});
function stopQwinstaPoll() {
if (qwinstaPollTimer) {
clearInterval(qwinstaPollTimer);
qwinstaPollTimer = null;
}
}
function closeQwinstaModal() {
stopQwinstaPoll();
qwinstaModal.value.open = false;
}
function applyCommandResult(eventId: number, actorUser: string, cmd: AgentCommandResponse) {
if (cmd.status === "pending") return false;
qwinstaModal.value.loading = false;
if (cmd.status === "failed") {
qwinstaModal.value.error = cmd.result_stderr || cmd.result_stdout || "Команда завершилась с ошибкой";
return true;
}
qwinstaModal.value.stdout = cmd.result_stdout || "";
qwinstaModal.value.sessions = parseQwinstaSessions(qwinstaModal.value.stdout, actorUser);
return true;
}
function applyCommandMeta(modal: typeof qwinstaModal.value, cmd: AgentCommandResponse) {
modal.target = cmd.target || "";
modal.clientHostname = cmd.client_hostname || "";
modal.internalIp = cmd.internal_ip || "";
}
function pollQwinstaCommand(eventId: number, commandUuid: string, actorUser: string) {
stopQwinstaPoll();
qwinstaPollTimer = setInterval(async () => {
try {
const cmd = await fetchEventAction(eventId, commandUuid);
if (applyCommandResult(eventId, actorUser, cmd)) {
stopQwinstaPoll();
}
} catch (e) {
qwinstaModal.value.loading = false;
qwinstaModal.value.error = e instanceof Error ? e.message : "Ошибка опроса команды";
stopQwinstaPoll();
}
}, 2000);
}
async function runQwinsta(event: EventSummary) {
qwinstaLoadingId.value = event.id;
qwinstaModal.value = {
open: true,
eventId: event.id,
actorUser: event.actor_user || "",
commandUuid: "",
target: "",
clientHostname: "",
internalIp: "",
loading: true,
error: "",
stdout: "",
sessions: [],
logoffId: null,
};
try {
const cmd = await postEventQwinsta(event.id);
applyCommandMeta(qwinstaModal.value, cmd);
qwinstaModal.value.commandUuid = cmd.command_uuid;
if (applyCommandResult(event.id, event.actor_user || "", cmd)) {
return;
}
pollQwinstaCommand(event.id, cmd.command_uuid, event.actor_user || "");
} catch (e) {
qwinstaModal.value.loading = false;
qwinstaModal.value.error = e instanceof Error ? e.message : "Не удалось отправить qwinsta";
} finally {
qwinstaLoadingId.value = null;
}
}
async function runLogoff(sessionId: number) {
const modal = qwinstaModal.value;
if (!modal.eventId) return;
if (!window.confirm(`Завершить сеанс ID ${sessionId}?`)) return;
modal.logoffId = sessionId;
modal.error = "";
try {
const cmd = await postEventLogoff(modal.eventId, sessionId);
applyCommandMeta(modal, cmd);
if (cmd.status === "failed") {
modal.error = cmd.result_stderr || cmd.result_stdout || "logoff failed";
return;
}
modal.loading = true;
modal.stdout = "";
modal.sessions = [];
const again = await postEventQwinsta(modal.eventId);
applyCommandMeta(modal, again);
if (applyCommandResult(modal.eventId, modal.actorUser, again)) {
return;
}
pollQwinstaCommand(modal.eventId, again.command_uuid, modal.actorUser);
} catch (e) {
modal.error = e instanceof Error ? e.message : "logoff error";
} finally {
modal.logoffId = null;
}
}
onUnmounted(() => stopQwinstaPoll());
return {
qwinstaLoadingId,
qwinstaModal,
closeQwinstaModal,
runQwinsta,
runLogoff,
};
}
+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.1";
export const APP_VERSION = "0.20.4";
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
+8 -266
View File
@@ -303,50 +303,11 @@
</table>
<div v-if="qwinstaModal.open" class="modal-backdrop" @click.self="closeQwinstaModal">
<div class="modal-card qwinsta-modal" role="dialog" aria-modal="true">
<h3>qwinsta {{ qwinstaModal.clientHostname || qwinstaModal.target || `#${qwinstaModal.eventId}` }}</h3>
<p v-if="qwinstaModal.internalIp" class="muted qwinsta-meta">Клиентский ПК: {{ qwinstaModal.internalIp }}</p>
<p v-if="qwinstaModal.error" class="error">{{ qwinstaModal.error }}</p>
<p v-else-if="qwinstaModal.loading">Ожидание ответа агента</p>
<template v-else>
<pre v-if="qwinstaModal.stdout" class="qwinsta-raw">{{ qwinstaModal.stdout }}</pre>
<p v-else class="muted">Нет вывода</p>
<table v-if="qwinstaModal.sessions.length" class="dash-table">
<thead>
<tr>
<th>SESSION</th>
<th>USER</th>
<th>ID</th>
<th>STATE</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="s in qwinstaModal.sessions" :key="s.id">
<td>{{ s.sessionName }}</td>
<td>{{ s.userName }}</td>
<td>{{ s.id }}</td>
<td>{{ s.state }}</td>
<td>
<button
type="button"
class="danger"
:disabled="qwinstaModal.logoffId === s.id"
@click="runLogoff(s.id)"
>
logoff
</button>
</td>
</tr>
</tbody>
</table>
</template>
<div class="modal-actions">
<button type="button" class="secondary" @click="closeQwinstaModal">Закрыть</button>
</div>
</div>
</div>
<RdgQwinstaModal
:modal="qwinstaModal"
@close="closeQwinstaModal"
@logoff="runLogoff"
/>
<div class="filters" style="margin-top: 1rem">
@@ -368,20 +329,17 @@ import { onMounted, onUnmounted, ref } from "vue";
import {
apiFetch,
fetchEventAction,
getToken,
postEventLogoff,
postEventQwinsta,
type AgentCommandResponse,
type DashboardSummary,
type EventSummary,
} from "../api";
import RdgQwinstaModal from "../components/RdgQwinstaModal.vue";
import { useRdgQwinsta } from "../composables/useRdgQwinsta";
import { useSacVersion } from "../composables/useSacVersion";
import { formatServerName } from "../utils/hostDisplay";
const { appName, appVersion } = useSacVersion();
const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta();
const data = ref<DashboardSummary | null>(null);
@@ -397,174 +355,6 @@ let eventSource: EventSource | null = null;
let refreshingRecent = false;
let qwinstaPollTimer: ReturnType<typeof setInterval> | null = null;
const qwinstaLoadingId = ref<number | null>(null);
type QwinstaSessionRow = {
sessionName: string;
userName: string;
id: number;
state: string;
};
const qwinstaModal = ref({
open: false,
eventId: 0,
actorUser: "",
commandUuid: "",
target: "",
clientHostname: "",
internalIp: "",
loading: false,
error: "",
stdout: "",
sessions: [] as QwinstaSessionRow[],
logoffId: null as number | null,
});
function parseQwinstaSessions(stdout: string, actorUser: string): QwinstaSessionRow[] {
const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
const rows: QwinstaSessionRow[] = [];
for (const line of lines) {
if (/^SESSION/i.test(line) || /^---/.test(line)) continue;
const parts = line.split(/\s+/);
if (parts.length < 4) continue;
const sessionName = parts[0];
const userName = parts[1];
const id = Number.parseInt(parts[2], 10);
const state = parts.slice(3).join(" ");
if (!Number.isFinite(id)) continue;
const norm = (s: string) => s.replace(/^B26\\/i, "").toLowerCase();
const matchUser = actorUser && norm(userName).includes(norm(actorUser));
if (actorUser && !matchUser) continue;
rows.push({ sessionName, userName, id, state });
}
if (rows.length === 0 && stdout.trim()) {
for (const line of lines) {
if (/^SESSION/i.test(line) || /^---/.test(line)) continue;
const parts = line.split(/\s+/);
if (parts.length < 4) continue;
const id = Number.parseInt(parts[2], 10);
if (!Number.isFinite(id)) continue;
rows.push({
sessionName: parts[0],
userName: parts[1],
id,
state: parts.slice(3).join(" "),
});
}
}
return rows;
}
function stopQwinstaPoll() {
if (qwinstaPollTimer) {
clearInterval(qwinstaPollTimer);
qwinstaPollTimer = null;
}
}
function closeQwinstaModal() {
stopQwinstaPoll();
qwinstaModal.value.open = false;
}
function applyCommandResult(eventId: number, actorUser: string, cmd: AgentCommandResponse) {
if (cmd.status === "pending") return false;
qwinstaModal.value.loading = false;
if (cmd.status === "failed") {
qwinstaModal.value.error = cmd.result_stderr || cmd.result_stdout || "Команда завершилась с ошибкой";
return true;
}
qwinstaModal.value.stdout = cmd.result_stdout || "";
qwinstaModal.value.sessions = parseQwinstaSessions(qwinstaModal.value.stdout, actorUser);
return true;
}
function pollQwinstaCommand(eventId: number, commandUuid: string, actorUser: string) {
stopQwinstaPoll();
qwinstaPollTimer = setInterval(async () => {
try {
const cmd = await fetchEventAction(eventId, commandUuid);
if (applyCommandResult(eventId, actorUser, cmd)) {
stopQwinstaPoll();
}
} catch (e) {
qwinstaModal.value.loading = false;
qwinstaModal.value.error = e instanceof Error ? e.message : "Ошибка опроса команды";
stopQwinstaPoll();
}
}, 2000);
}
function applyCommandMeta(modal: typeof qwinstaModal.value, cmd: AgentCommandResponse) {
modal.target = cmd.target || "";
modal.clientHostname = cmd.client_hostname || "";
modal.internalIp = cmd.internal_ip || "";
}
async function runQwinsta(event: EventSummary) {
qwinstaLoadingId.value = event.id;
qwinstaModal.value = {
open: true,
eventId: event.id,
actorUser: event.actor_user || "",
commandUuid: "",
target: "",
clientHostname: "",
internalIp: "",
loading: true,
error: "",
stdout: "",
sessions: [],
logoffId: null,
};
try {
const cmd = await postEventQwinsta(event.id);
applyCommandMeta(qwinstaModal.value, cmd);
qwinstaModal.value.commandUuid = cmd.command_uuid;
if (applyCommandResult(event.id, event.actor_user || "", cmd)) {
return;
}
pollQwinstaCommand(event.id, cmd.command_uuid, event.actor_user || "");
} catch (e) {
qwinstaModal.value.loading = false;
qwinstaModal.value.error = e instanceof Error ? e.message : "Не удалось отправить qwinsta";
} finally {
qwinstaLoadingId.value = null;
}
}
async function runLogoff(sessionId: number) {
const modal = qwinstaModal.value;
if (!modal.eventId) return;
if (!window.confirm(`Завершить сеанс ID ${sessionId}?`)) return;
modal.logoffId = sessionId;
modal.error = "";
try {
const cmd = await postEventLogoff(modal.eventId, sessionId);
applyCommandMeta(modal, cmd);
if (cmd.status === "failed") {
modal.error = cmd.result_stderr || cmd.result_stdout || "logoff failed";
return;
}
modal.loading = true;
modal.stdout = "";
modal.sessions = [];
const again = await postEventQwinsta(modal.eventId);
applyCommandMeta(modal, again);
if (applyCommandResult(modal.eventId, modal.actorUser, again)) {
return;
}
pollQwinstaCommand(modal.eventId, again.command_uuid, modal.actorUser);
} catch (e) {
modal.error = e instanceof Error ? e.message : "logoff error";
} finally {
modal.logoffId = null;
}
}
function formatDt(iso: string) {
return new Date(iso).toLocaleString("ru-RU");
@@ -790,13 +580,8 @@ onMounted(() => {
onUnmounted(() => {
stopQwinstaPoll();
eventSource?.close();
eventSource = null;
});
</script>
@@ -836,49 +621,6 @@ onUnmounted(() => {
padding: 0.2rem 0.55rem;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal-card {
background: var(--card-bg, #1e2430);
border: 1px solid var(--border, #3a4556);
border-radius: 8px;
max-width: 720px;
width: 100%;
max-height: 85vh;
overflow: auto;
padding: 1rem 1.25rem;
}
.qwinsta-meta {
margin: 0 0 0.75rem;
font-size: 0.9rem;
}
.qwinsta-raw {
font-size: 0.8rem;
max-height: 12rem;
overflow: auto;
background: #0d1117;
padding: 0.75rem;
border-radius: 4px;
}
.modal-actions {
margin-top: 1rem;
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
</style>
+18
View File
@@ -7,6 +7,21 @@
<p v-else-if="loading">Загрузка</p>
<template v-else-if="event">
<h1>{{ event.title }}</h1>
<p v-if="event.rdg_flap" class="event-rdg-actions">
<button
type="button"
class="secondary"
:disabled="qwinstaLoadingId === event.id"
@click="runQwinsta(event)"
>
qwinsta (RDG flap)
</button>
</p>
<RdgQwinstaModal
:modal="qwinstaModal"
@close="closeQwinstaModal"
@logoff="runLogoff"
/>
<div class="card report-meta">
<dl class="detail-fields">
<div class="detail-field">
@@ -74,7 +89,9 @@
import { computed, onMounted, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { apiFetch, type EventDetail } from "../api";
import RdgQwinstaModal from "../components/RdgQwinstaModal.vue";
import ReportBodyCard from "../components/ReportBodyCard.vue";
import { useRdgQwinsta } from "../composables/useRdgQwinsta";
import { eventsBackQueryFromDetail } from "../utils/eventsListQuery";
import { dailyReportTypeLabel, isDailyReportType } from "../utils/reportDisplay";
@@ -84,6 +101,7 @@ const route = useRoute();
const event = ref<EventDetail | null>(null);
const loading = ref(false);
const error = ref("");
const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta();
const fromReports = computed(() => route.query.from === "reports");
const isReport = computed(() => (event.value ? isDailyReportType(event.value.type) : false));
+29
View File
@@ -38,6 +38,7 @@
<th>Severity</th>
<th>Type</th>
<th>Title</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
@@ -53,9 +54,25 @@
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
<td><code>{{ e.type }}</code></td>
<td>{{ e.title }}</td>
<td class="events-actions">
<button
v-if="e.rdg_flap"
type="button"
class="secondary events-qwinsta-btn"
:disabled="qwinstaLoadingId === e.id"
@click="runQwinsta(e)"
>
qwinsta
</button>
</td>
</tr>
</tbody>
</table>
<RdgQwinstaModal
:modal="qwinstaModal"
@close="closeQwinstaModal"
@logoff="runLogoff"
/>
<div class="filters" style="margin-top: 1rem">
<button type="button" class="secondary" :disabled="page <= 1" @click="load(page - 1)">Назад</button>
<span>Стр. {{ page }}</span>
@@ -75,6 +92,8 @@
import { computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { apiFetch, type EventListResponse } from "../api";
import RdgQwinstaModal from "../components/RdgQwinstaModal.vue";
import { useRdgQwinsta } from "../composables/useRdgQwinsta";
import {
apiParamsFromState,
buildEventsListQuery,
@@ -86,6 +105,7 @@ import { formatServerName } from "../utils/hostDisplay";
const route = useRoute();
const router = useRouter();
const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta();
const data = ref<EventListResponse | null>(null);
const loading = ref(false);
@@ -217,4 +237,13 @@ watch(
.events-date-field input[type="date"] {
min-width: 9.5rem;
}
.events-actions {
white-space: nowrap;
}
.events-qwinsta-btn {
font-size: 0.85rem;
padding: 0.2rem 0.55rem;
}
</style>
+38
View File
@@ -0,0 +1,38 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Удалённый sudo на SAC-сервере без интерактивного ввода.
Пароль: Password из ssh-setup/local/kalinamall-sac.conf
#>
[CmdletBinding()]
param(
[string]$HostAlias = 'sac.kalinamall.ru',
[Parameter(Mandatory = $true)]
[string]$RemoteCommand,
[string]$PasswordFile = (Join-Path $HOME 'Projects\Answer.and.other.shit\scripts\ssh-setup\local\kalinamall-sac.conf')
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Read-ConfigFile {
param([string]$Path)
$data = @{}
if (-not (Test-Path -LiteralPath $Path)) { throw "Config not found: $Path" }
Get-Content -LiteralPath $Path | ForEach-Object {
if ($_ -match '^([^#=]+)=(.*)$') {
$data[$Matches[1].Trim()] = $Matches[2].Trim()
}
}
return $data
}
$cfg = Read-ConfigFile -Path $PasswordFile
$password = $cfg['Password']
if (-not $password) {
throw "Password not found in $PasswordFile"
}
$inner = $RemoteCommand.Replace("'", "'\\''")
$remote = "sudo -S -p '' bash -lc '$inner'"
$password | & ssh -o BatchMode=yes -o ConnectTimeout=15 $HostAlias $remote