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:
@@ -1,5 +1,5 @@
|
|||||||
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
||||||
|
|
||||||
APP_NAME = "Security Alert Center"
|
APP_NAME = "Security Alert Center"
|
||||||
APP_VERSION = "0.20.3"
|
APP_VERSION = "0.20.4"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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,4 +1,4 @@
|
|||||||
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
|
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
|
||||||
export const APP_NAME = "Security Alert Center";
|
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}`;
|
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||||
|
|||||||
@@ -303,50 +303,11 @@
|
|||||||
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div v-if="qwinstaModal.open" class="modal-backdrop" @click.self="closeQwinstaModal">
|
<RdgQwinstaModal
|
||||||
<div class="modal-card qwinsta-modal" role="dialog" aria-modal="true">
|
:modal="qwinstaModal"
|
||||||
<h3>qwinsta — {{ qwinstaModal.clientHostname || qwinstaModal.target || `#${qwinstaModal.eventId}` }}</h3>
|
@close="closeQwinstaModal"
|
||||||
<p v-if="qwinstaModal.internalIp" class="muted qwinsta-meta">Клиентский ПК: {{ qwinstaModal.internalIp }}</p>
|
@logoff="runLogoff"
|
||||||
<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">
|
<div class="filters" style="margin-top: 1rem">
|
||||||
|
|
||||||
@@ -368,20 +329,17 @@ import { onMounted, onUnmounted, ref } from "vue";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
apiFetch,
|
apiFetch,
|
||||||
fetchEventAction,
|
|
||||||
getToken,
|
getToken,
|
||||||
postEventLogoff,
|
|
||||||
postEventQwinsta,
|
|
||||||
type AgentCommandResponse,
|
|
||||||
type DashboardSummary,
|
type DashboardSummary,
|
||||||
type EventSummary,
|
type EventSummary,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
|
import RdgQwinstaModal from "../components/RdgQwinstaModal.vue";
|
||||||
|
import { useRdgQwinsta } from "../composables/useRdgQwinsta";
|
||||||
import { useSacVersion } from "../composables/useSacVersion";
|
import { useSacVersion } from "../composables/useSacVersion";
|
||||||
import { formatServerName } from "../utils/hostDisplay";
|
import { formatServerName } from "../utils/hostDisplay";
|
||||||
|
|
||||||
const { appName, appVersion } = useSacVersion();
|
const { appName, appVersion } = useSacVersion();
|
||||||
|
const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta();
|
||||||
|
|
||||||
|
|
||||||
const data = ref<DashboardSummary | null>(null);
|
const data = ref<DashboardSummary | null>(null);
|
||||||
|
|
||||||
@@ -397,174 +355,6 @@ let eventSource: EventSource | null = null;
|
|||||||
|
|
||||||
let refreshingRecent = false;
|
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) {
|
function formatDt(iso: string) {
|
||||||
|
|
||||||
return new Date(iso).toLocaleString("ru-RU");
|
return new Date(iso).toLocaleString("ru-RU");
|
||||||
@@ -790,13 +580,8 @@ onMounted(() => {
|
|||||||
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|
||||||
stopQwinstaPoll();
|
|
||||||
|
|
||||||
eventSource?.close();
|
eventSource?.close();
|
||||||
|
|
||||||
eventSource = null;
|
eventSource = null;
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
@@ -836,49 +621,6 @@ onUnmounted(() => {
|
|||||||
padding: 0.2rem 0.55rem;
|
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>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,21 @@
|
|||||||
<p v-else-if="loading">Загрузка…</p>
|
<p v-else-if="loading">Загрузка…</p>
|
||||||
<template v-else-if="event">
|
<template v-else-if="event">
|
||||||
<h1>{{ event.title }}</h1>
|
<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">
|
<div class="card report-meta">
|
||||||
<dl class="detail-fields">
|
<dl class="detail-fields">
|
||||||
<div class="detail-field">
|
<div class="detail-field">
|
||||||
@@ -74,7 +89,9 @@
|
|||||||
import { computed, onMounted, ref, watch } from "vue";
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { apiFetch, type EventDetail } from "../api";
|
import { apiFetch, type EventDetail } from "../api";
|
||||||
|
import RdgQwinstaModal from "../components/RdgQwinstaModal.vue";
|
||||||
import ReportBodyCard from "../components/ReportBodyCard.vue";
|
import ReportBodyCard from "../components/ReportBodyCard.vue";
|
||||||
|
import { useRdgQwinsta } from "../composables/useRdgQwinsta";
|
||||||
import { eventsBackQueryFromDetail } from "../utils/eventsListQuery";
|
import { eventsBackQueryFromDetail } from "../utils/eventsListQuery";
|
||||||
import { dailyReportTypeLabel, isDailyReportType } from "../utils/reportDisplay";
|
import { dailyReportTypeLabel, isDailyReportType } from "../utils/reportDisplay";
|
||||||
|
|
||||||
@@ -84,6 +101,7 @@ const route = useRoute();
|
|||||||
const event = ref<EventDetail | null>(null);
|
const event = ref<EventDetail | null>(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
|
const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta();
|
||||||
|
|
||||||
const fromReports = computed(() => route.query.from === "reports");
|
const fromReports = computed(() => route.query.from === "reports");
|
||||||
const isReport = computed(() => (event.value ? isDailyReportType(event.value.type) : false));
|
const isReport = computed(() => (event.value ? isDailyReportType(event.value.type) : false));
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
<th>Severity</th>
|
<th>Severity</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
|
<th>Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -53,9 +54,25 @@
|
|||||||
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
|
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
|
||||||
<td><code>{{ e.type }}</code></td>
|
<td><code>{{ e.type }}</code></td>
|
||||||
<td>{{ e.title }}</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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<RdgQwinstaModal
|
||||||
|
:modal="qwinstaModal"
|
||||||
|
@close="closeQwinstaModal"
|
||||||
|
@logoff="runLogoff"
|
||||||
|
/>
|
||||||
<div class="filters" style="margin-top: 1rem">
|
<div class="filters" style="margin-top: 1rem">
|
||||||
<button type="button" class="secondary" :disabled="page <= 1" @click="load(page - 1)">Назад</button>
|
<button type="button" class="secondary" :disabled="page <= 1" @click="load(page - 1)">Назад</button>
|
||||||
<span>Стр. {{ page }}</span>
|
<span>Стр. {{ page }}</span>
|
||||||
@@ -75,6 +92,8 @@
|
|||||||
import { computed, ref, watch } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { apiFetch, type EventListResponse } from "../api";
|
import { apiFetch, type EventListResponse } from "../api";
|
||||||
|
import RdgQwinstaModal from "../components/RdgQwinstaModal.vue";
|
||||||
|
import { useRdgQwinsta } from "../composables/useRdgQwinsta";
|
||||||
import {
|
import {
|
||||||
apiParamsFromState,
|
apiParamsFromState,
|
||||||
buildEventsListQuery,
|
buildEventsListQuery,
|
||||||
@@ -86,6 +105,7 @@ import { formatServerName } from "../utils/hostDisplay";
|
|||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta();
|
||||||
|
|
||||||
const data = ref<EventListResponse | null>(null);
|
const data = ref<EventListResponse | null>(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -217,4 +237,13 @@ watch(
|
|||||||
.events-date-field input[type="date"] {
|
.events-date-field input[type="date"] {
|
||||||
min-width: 9.5rem;
|
min-width: 9.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.events-actions {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.events-qwinsta-btn {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user