2aec488226
Detect 302→303 within 1–10s, Problem with 30s dedup, rdg_flap flag. Agent command poll API, admin qwinsta/logoff from Overview. Migration 016.
871 lines
18 KiB
Vue
871 lines
18 KiB
Vue
<template>
|
||
<div class="overview-page">
|
||
<h1>Обзор</h1>
|
||
<p class="dashboard-sac-version">{{ appName }} v.{{ appVersion }}</p>
|
||
|
||
<p v-if="error" class="error">{{ error }}</p>
|
||
|
||
<p v-else-if="loading">Загрузка…</p>
|
||
|
||
<template v-else-if="data">
|
||
|
||
<div class="dashboard-cards">
|
||
|
||
<div class="dash-card">
|
||
|
||
<div class="dash-value">{{ data.events_last_24h }}</div>
|
||
|
||
<div class="dash-label">Событий за 24 ч</div>
|
||
|
||
<RouterLink to="/events">Все события →</RouterLink>
|
||
|
||
</div>
|
||
|
||
<div class="dash-card">
|
||
|
||
<div class="dash-value">{{ data.problems_open }}</div>
|
||
|
||
<div class="dash-label">Открытых проблем</div>
|
||
|
||
<RouterLink :to="{ path: '/problems', query: { status: 'open' } }">Проблемы →</RouterLink>
|
||
|
||
</div>
|
||
|
||
<div class="dash-card">
|
||
|
||
<div class="dash-value">{{ data.problems_opened_24h }}</div>
|
||
|
||
<div class="dash-label">Новых проблем за 24 ч</div>
|
||
|
||
<RouterLink :to="{ path: '/problems', query: { created_within_hours: '24' } }">Новые за 24 ч →</RouterLink>
|
||
|
||
</div>
|
||
|
||
<div class="dash-card">
|
||
|
||
<div class="dash-value">{{ data.problems_resolved_24h }}</div>
|
||
|
||
<div class="dash-label">Закрыто за 24 ч</div>
|
||
|
||
<RouterLink :to="{ path: '/problems', query: { resolved_within_hours: '24' } }">Закрытые за 24 ч →</RouterLink>
|
||
|
||
</div>
|
||
|
||
<div class="dash-card">
|
||
|
||
<div class="dash-value">{{ data.hosts_total }}</div>
|
||
|
||
<div class="dash-label">Хостов</div>
|
||
|
||
<RouterLink to="/hosts">Хосты →</RouterLink>
|
||
|
||
</div>
|
||
|
||
<div class="dash-card" :class="{ 'dash-warn': data.hosts_stale > 0 }">
|
||
|
||
<div class="dash-value">{{ data.hosts_stale }}</div>
|
||
|
||
<div class="dash-label">Устаревший heartbeat</div>
|
||
|
||
<RouterLink :to="{ path: '/hosts', query: { agent_status: 'stale' } }">Stale-хосты →</RouterLink>
|
||
|
||
</div>
|
||
|
||
<div class="dash-card">
|
||
|
||
<div class="dash-value">{{ data.heartbeats_24h }}</div>
|
||
|
||
<div class="dash-label">Heartbeat за 24 ч</div>
|
||
|
||
<RouterLink :to="{ path: '/events', query: { type: 'agent.heartbeat' } }">События →</RouterLink>
|
||
|
||
</div>
|
||
|
||
<div class="dash-card">
|
||
|
||
<div class="dash-value">{{ data.daily_reports_24h }}</div>
|
||
|
||
<div class="dash-label">Отчёты за 24 ч</div>
|
||
|
||
<RouterLink to="/reports">Отчёты →</RouterLink>
|
||
|
||
</div>
|
||
|
||
</div>
|
||
|
||
|
||
|
||
<div class="dashboard-panels">
|
||
|
||
<section class="dash-panel">
|
||
|
||
<h2>Топ хостов (24 ч)</h2>
|
||
|
||
<div v-if="data.top_hosts.length" class="dash-table-scroll">
|
||
<table class="dash-table">
|
||
|
||
<thead>
|
||
|
||
<tr>
|
||
|
||
<th>Хост</th>
|
||
|
||
<th>Событий</th>
|
||
|
||
</tr>
|
||
|
||
</thead>
|
||
|
||
<tbody>
|
||
|
||
<tr v-for="h in data.top_hosts" :key="h.host_id">
|
||
|
||
<td>
|
||
|
||
<RouterLink :to="{ path: '/events', query: { hostname: h.hostname } }">
|
||
|
||
{{ h.hostname }}
|
||
|
||
</RouterLink>
|
||
|
||
</td>
|
||
|
||
<td>{{ h.count }}</td>
|
||
|
||
</tr>
|
||
|
||
</tbody>
|
||
|
||
</table>
|
||
</div>
|
||
|
||
<p v-else class="muted">Нет событий за 24 ч</p>
|
||
|
||
</section>
|
||
|
||
|
||
|
||
<section class="dash-panel">
|
||
|
||
<h2>Топ типов (24 ч)</h2>
|
||
|
||
<div v-if="data.top_event_types.length" class="dash-table-scroll">
|
||
<table class="dash-table">
|
||
|
||
<thead>
|
||
|
||
<tr>
|
||
|
||
<th>Type</th>
|
||
|
||
<th>Событий</th>
|
||
|
||
</tr>
|
||
|
||
</thead>
|
||
|
||
<tbody>
|
||
|
||
<tr v-for="t in data.top_event_types" :key="t.type">
|
||
|
||
<td>
|
||
|
||
<RouterLink :to="{ path: '/events', query: { type: t.type } }">
|
||
|
||
<code>{{ t.type }}</code>
|
||
|
||
</RouterLink>
|
||
|
||
</td>
|
||
|
||
<td>{{ t.count }}</td>
|
||
|
||
</tr>
|
||
|
||
</tbody>
|
||
|
||
</table>
|
||
</div>
|
||
|
||
<p v-else class="muted">Нет событий за 24 ч</p>
|
||
|
||
</section>
|
||
|
||
</div>
|
||
|
||
|
||
|
||
<h2>Severity за 24 ч</h2>
|
||
|
||
<ul class="severity-list">
|
||
|
||
<li v-for="(count, sev) in data.severity_24h" :key="sev">
|
||
|
||
<RouterLink :to="{ path: '/events', query: { severity: sev } }">
|
||
|
||
<span :class="'sev-' + sev">{{ sev }}</span>: {{ count }}
|
||
|
||
</RouterLink>
|
||
|
||
</li>
|
||
|
||
<li v-if="!Object.keys(data.severity_24h).length">Нет событий</li>
|
||
|
||
</ul>
|
||
|
||
|
||
|
||
<h2>Последние события</h2>
|
||
|
||
<p class="muted dash-recent-hint">
|
||
|
||
Обновляется при поступлении событий (live, ~5 с). Время — момент приёма SAC.
|
||
|
||
</p>
|
||
|
||
<table>
|
||
|
||
<thead>
|
||
|
||
<tr>
|
||
|
||
<th>ID</th>
|
||
|
||
<th>Получено</th>
|
||
|
||
<th>Хост</th>
|
||
|
||
<th>Имя сервера</th>
|
||
|
||
<th>Пользователь</th>
|
||
|
||
<th>Версия агента</th>
|
||
|
||
<th>Severity</th>
|
||
|
||
<th>Title</th>
|
||
|
||
<th>Действия</th>
|
||
|
||
</tr>
|
||
|
||
</thead>
|
||
|
||
<tbody>
|
||
|
||
<tr v-for="e in data.recent_events" :key="e.id">
|
||
|
||
<td>
|
||
|
||
<RouterLink :to="`/events/${e.id}`">{{ e.id }}</RouterLink>
|
||
|
||
</td>
|
||
|
||
<td>{{ formatDt(e.received_at) }}</td>
|
||
|
||
<td>
|
||
|
||
<RouterLink :to="{ path: '/events', query: { hostname: e.hostname } }">
|
||
|
||
{{ e.hostname }}
|
||
|
||
</RouterLink>
|
||
|
||
</td>
|
||
|
||
<td>{{ formatServerName(e.display_name) }}</td>
|
||
|
||
<td>{{ e.actor_user || "—" }}</td>
|
||
|
||
<td>{{ e.product_version || "—" }}</td>
|
||
|
||
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
|
||
|
||
<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>
|
||
|
||
<span v-if="live" class="live-badge">live</span>
|
||
|
||
</div>
|
||
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
|
||
|
||
<script setup lang="ts">
|
||
|
||
import { onMounted, onUnmounted, ref } from "vue";
|
||
|
||
import {
|
||
apiFetch,
|
||
fetchEventAction,
|
||
getToken,
|
||
postEventLogoff,
|
||
postEventQwinsta,
|
||
type AgentCommandResponse,
|
||
type DashboardSummary,
|
||
type EventSummary,
|
||
} from "../api";
|
||
import { useSacVersion } from "../composables/useSacVersion";
|
||
import { formatServerName } from "../utils/hostDisplay";
|
||
|
||
const { appName, appVersion } = useSacVersion();
|
||
|
||
|
||
|
||
const data = ref<DashboardSummary | null>(null);
|
||
|
||
const loading = ref(false);
|
||
|
||
const error = ref("");
|
||
|
||
const live = ref(false);
|
||
|
||
const lastKnownEventDbId = ref<number | null>(null);
|
||
|
||
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");
|
||
|
||
}
|
||
|
||
|
||
|
||
function syncLastKnownFromRecent() {
|
||
|
||
const top = data.value?.recent_events?.[0];
|
||
|
||
lastKnownEventDbId.value = top?.id ?? null;
|
||
|
||
}
|
||
|
||
|
||
|
||
async function refreshRecentEvents() {
|
||
|
||
if (!data.value || refreshingRecent) return;
|
||
|
||
refreshingRecent = true;
|
||
|
||
try {
|
||
|
||
const events = await apiFetch<EventSummary[]>(
|
||
|
||
"/api/v1/dashboards/recent-events?limit=8",
|
||
|
||
);
|
||
|
||
data.value.recent_events = events;
|
||
|
||
syncLastKnownFromRecent();
|
||
|
||
} catch {
|
||
|
||
/* keep table on transient errors */
|
||
|
||
} finally {
|
||
|
||
refreshingRecent = false;
|
||
|
||
}
|
||
|
||
}
|
||
|
||
|
||
|
||
async function load() {
|
||
|
||
loading.value = true;
|
||
|
||
error.value = "";
|
||
|
||
try {
|
||
|
||
data.value = await apiFetch<DashboardSummary>("/api/v1/dashboards/summary");
|
||
|
||
syncLastKnownFromRecent();
|
||
|
||
} catch (e) {
|
||
|
||
error.value = e instanceof Error ? e.message : String(e);
|
||
|
||
} finally {
|
||
|
||
loading.value = false;
|
||
|
||
}
|
||
|
||
}
|
||
|
||
|
||
|
||
function applyDashboardCounters(msg: {
|
||
|
||
events_last_24h?: number;
|
||
|
||
problems_open?: number;
|
||
|
||
hosts_stale?: number;
|
||
|
||
heartbeats_24h?: number;
|
||
|
||
daily_reports_24h?: number;
|
||
|
||
last_event_id?: number | null;
|
||
|
||
}) {
|
||
|
||
if (!data.value) return;
|
||
|
||
if (typeof msg.events_last_24h === "number") {
|
||
|
||
data.value.events_last_24h = msg.events_last_24h;
|
||
|
||
}
|
||
|
||
if (typeof msg.problems_open === "number") {
|
||
|
||
data.value.problems_open = msg.problems_open;
|
||
|
||
}
|
||
|
||
if (typeof msg.hosts_stale === "number") {
|
||
|
||
data.value.hosts_stale = msg.hosts_stale;
|
||
|
||
}
|
||
|
||
if (typeof msg.heartbeats_24h === "number") {
|
||
|
||
data.value.heartbeats_24h = msg.heartbeats_24h;
|
||
|
||
}
|
||
|
||
if (typeof msg.daily_reports_24h === "number") {
|
||
|
||
data.value.daily_reports_24h = msg.daily_reports_24h;
|
||
|
||
}
|
||
|
||
|
||
|
||
const incoming = msg.last_event_id;
|
||
|
||
if (incoming === undefined) return;
|
||
|
||
const prev = lastKnownEventDbId.value;
|
||
|
||
if (incoming === null) {
|
||
|
||
if (prev !== null) void refreshRecentEvents();
|
||
|
||
lastKnownEventDbId.value = null;
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
if (prev === null || incoming !== prev) {
|
||
|
||
void refreshRecentEvents();
|
||
|
||
}
|
||
|
||
lastKnownEventDbId.value = incoming;
|
||
|
||
}
|
||
|
||
|
||
|
||
function connectLive() {
|
||
|
||
const token = getToken();
|
||
|
||
if (!token) return;
|
||
|
||
eventSource?.close();
|
||
|
||
const url = `/api/v1/stream/events?token=${encodeURIComponent(token)}`;
|
||
|
||
eventSource = new EventSource(url);
|
||
|
||
eventSource.onopen = () => {
|
||
|
||
live.value = true;
|
||
|
||
};
|
||
|
||
eventSource.onmessage = (ev) => {
|
||
|
||
try {
|
||
|
||
const msg = JSON.parse(ev.data) as {
|
||
|
||
type?: string;
|
||
|
||
events_last_24h?: number;
|
||
|
||
problems_open?: number;
|
||
|
||
hosts_stale?: number;
|
||
|
||
heartbeats_24h?: number;
|
||
|
||
daily_reports_24h?: number;
|
||
|
||
last_event_id?: number | null;
|
||
|
||
};
|
||
|
||
if (msg.type !== "dashboard" || !data.value) return;
|
||
|
||
applyDashboardCounters(msg);
|
||
|
||
} catch {
|
||
|
||
/* ignore malformed SSE */
|
||
|
||
}
|
||
|
||
};
|
||
|
||
eventSource.onerror = () => {
|
||
|
||
live.value = false;
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
|
||
onMounted(() => {
|
||
|
||
load().then(() => connectLive());
|
||
|
||
});
|
||
|
||
|
||
|
||
onUnmounted(() => {
|
||
|
||
stopQwinstaPoll();
|
||
|
||
eventSource?.close();
|
||
|
||
eventSource = null;
|
||
|
||
});
|
||
|
||
</script>
|
||
|
||
|
||
|
||
<style scoped>
|
||
|
||
.dash-table-scroll {
|
||
|
||
max-height: 21rem;
|
||
|
||
overflow-y: auto;
|
||
|
||
}
|
||
|
||
.dashboard-sac-version {
|
||
margin: -0.35rem 0 1rem;
|
||
color: #9aa4b2;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.dash-recent-hint {
|
||
|
||
margin: -0.25rem 0 0.75rem;
|
||
|
||
font-size: 0.9rem;
|
||
|
||
}
|
||
|
||
.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>
|
||
|
||
|