feat: RDG session flap rule, qwinsta/logoff queue and dashboard UI (0.9.13)

Detect 302→303 within 1–10s, Problem with 30s dedup, rdg_flap flag.
Agent command poll API, admin qwinsta/logoff from Overview. Migration 016.
This commit is contained in:
PTah
2026-06-19 23:34:01 +10:00
parent b328d32f97
commit 2aec488226
24 changed files with 1012 additions and 25 deletions
+279 -1
View File
@@ -245,6 +245,8 @@
<th>Title</th>
<th>Действия</th>
</tr>
</thead>
@@ -281,12 +283,70 @@
<td>{{ e.title }}</td>
<td class="dash-actions">
<button
v-if="e.rdg_flap"
type="button"
class="secondary dash-qwinsta-btn"
:disabled="qwinstaLoadingId === e.id"
@click="runQwinsta(e)"
>
qwinsta
</button>
</td>
</tr>
</tbody>
</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.eventId }}</h3>
<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>
<div class="filters" style="margin-top: 1rem">
<button type="button" class="secondary" @click="load">Обновить всё</button>
@@ -305,7 +365,16 @@
import { onMounted, onUnmounted, ref } from "vue";
import { apiFetch, getToken, type DashboardSummary, type EventSummary } from "../api";
import {
apiFetch,
fetchEventAction,
getToken,
postEventLogoff,
postEventQwinsta,
type AgentCommandResponse,
type DashboardSummary,
type EventSummary,
} from "../api";
import { useSacVersion } from "../composables/useSacVersion";
import { formatServerName } from "../utils/hostDisplay";
@@ -327,6 +396,166 @@ 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: "",
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);
}
async function runQwinsta(event: EventSummary) {
qwinstaLoadingId.value = event.id;
qwinstaModal.value = {
open: true,
eventId: event.id,
actorUser: event.actor_user || "",
commandUuid: "",
loading: true,
error: "",
stdout: "",
sessions: [],
logoffId: null,
};
try {
const cmd = await postEventQwinsta(event.id);
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);
if (cmd.status === "pending") {
const deadline = Date.now() + 60000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 2000));
const polled = await fetchEventAction(modal.eventId, cmd.command_uuid);
if (polled.status !== "pending") {
if (polled.status === "failed") {
modal.error = polled.result_stderr || "logoff failed";
} else {
modal.loading = true;
modal.stdout = "";
modal.sessions = [];
const again = await postEventQwinsta(modal.eventId);
pollQwinstaCommand(modal.eventId, again.command_uuid, modal.actorUser);
}
break;
}
}
}
} 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");
@@ -553,6 +782,8 @@ onMounted(() => {
onUnmounted(() => {
stopQwinstaPoll();
eventSource?.close();
eventSource = null;
@@ -587,6 +818,53 @@ onUnmounted(() => {
}
.dash-actions {
white-space: nowrap;
}
.dash-qwinsta-btn {
font-size: 0.85rem;
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-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>