chore(home): mirror from kalinamall (9883e6a) with papatramp URLs
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# Frontend (Vue 3 + Vite)
|
||||
|
||||
**Статус:** не реализован. См. [docs/work-plan.md](../docs/work-plan.md) фаза 1.
|
||||
|
||||
Планируется:
|
||||
|
||||
- Problems, Events, Hosts, Dashboards
|
||||
- SSE live-лента
|
||||
- Auth JWT
|
||||
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#15202b" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/sac-icon.png" />
|
||||
<title>Security Alert Center</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1578
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "sac-ui",
|
||||
"private": true,
|
||||
"version": "0.11.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"dompurify": "^3.4.11",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.4.3",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="SAC">
|
||||
<rect width="512" height="512" rx="96" fill="#15202b"/>
|
||||
<g fill="none" stroke="#3db8ff" stroke-width="28" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M256 88 L360 128 V248 C360 332 316 388 256 424 C196 388 152 332 152 248 V128 Z"/>
|
||||
<circle cx="256" cy="292" r="22" fill="#3db8ff" stroke="none"/>
|
||||
<path d="M256 270 V210"/>
|
||||
<path d="M196 178 C216 158 236 148 256 148 C276 148 296 158 316 178"/>
|
||||
<path d="M176 198 C206 168 231 153 256 153 C281 153 306 168 336 198"/>
|
||||
<path d="M156 218 C196 178 226 163 256 163 C286 163 316 178 356 218"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 681 B |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div :class="showShell ? 'app-shell' : 'layout layout-login'">
|
||||
<AppSidebar v-if="showShell" />
|
||||
<main :class="showShell ? 'app-main' : ''">
|
||||
<RouterView />
|
||||
</main>
|
||||
<HostActionLogStack v-if="showShell" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { getToken } from "./api";
|
||||
import AppSidebar from "./components/AppSidebar.vue";
|
||||
import HostActionLogStack from "./components/HostActionLogStack.vue";
|
||||
import { refreshSessionRole } from "./router";
|
||||
|
||||
const route = useRoute();
|
||||
const publicLayout = computed(() => route.path === "/login" || route.meta.standalone === true);
|
||||
const showShell = computed(() => !publicLayout.value && !!getToken());
|
||||
|
||||
onMounted(() => {
|
||||
if (getToken()) {
|
||||
void refreshSessionRole();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,825 @@
|
||||
const TOKEN_KEY = "sac_token";
|
||||
const ROLE_KEY = "sac_role";
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function getRole(): string | null {
|
||||
return localStorage.getItem(ROLE_KEY);
|
||||
}
|
||||
|
||||
export function isAdmin(): boolean {
|
||||
return getRole() === "admin";
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function setRole(role: string): void {
|
||||
localStorage.setItem(ROLE_KEY, role);
|
||||
}
|
||||
|
||||
export function setAuth(token: string, role: string): void {
|
||||
setToken(token);
|
||||
setRole(role);
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(ROLE_KEY);
|
||||
}
|
||||
|
||||
export async function logoutApi(): Promise<void> {
|
||||
try {
|
||||
await fetch("/api/v1/auth/logout", { method: "POST", credentials: "same-origin" });
|
||||
} catch {
|
||||
/* ignore network errors on logout */
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export function redirectToErrorPage(status: 401 | 403 | 429): void {
|
||||
window.location.href = `/${status}`;
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
if (!headers.has("Content-Type") && init.body) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
const token = getToken();
|
||||
const hadToken = !!token;
|
||||
if (token) {
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
const res = await fetch(path, { ...init, headers, credentials: "same-origin" });
|
||||
const text = await res.text();
|
||||
if (res.status === 401) {
|
||||
if (hadToken) {
|
||||
clearToken();
|
||||
redirectToErrorPage(401);
|
||||
}
|
||||
throw new ApiError(parseApiError(text, 401) || "Unauthorized", 401);
|
||||
}
|
||||
if (res.status === 403) {
|
||||
if (hadToken) {
|
||||
redirectToErrorPage(403);
|
||||
}
|
||||
throw new ApiError(parseApiError(text, 403) || "Forbidden", 403);
|
||||
}
|
||||
if (res.status === 429) {
|
||||
redirectToErrorPage(429);
|
||||
throw new ApiError(parseApiError(text, 429) || "Too many requests", 429);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new ApiError(parseApiError(text, res.status) || res.statusText, res.status);
|
||||
}
|
||||
if (!text) {
|
||||
return undefined as T;
|
||||
}
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
username: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
username: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export function fetchMe(): Promise<MeResponse> {
|
||||
return apiFetch<MeResponse>("/api/v1/auth/me");
|
||||
}
|
||||
|
||||
export function parseApiError(text: string, status?: number): string {
|
||||
if (status === 502 || status === 503 || status === 504) {
|
||||
if (/<title>\s*502 Bad Gateway/i.test(text) || /502 Bad Gateway/i.test(text)) {
|
||||
return "Сервер SAC временно недоступен (перезапуск или обновление). Подождите 5–10 секунд и обновите страницу.";
|
||||
}
|
||||
if (/<title>\s*503 Service Unavailable/i.test(text) || /503 Service Unavailable/i.test(text)) {
|
||||
return "SAC временно перегружен. Повторите запрос через несколько секунд.";
|
||||
}
|
||||
if (/<title>\s*504 Gateway Time-out/i.test(text) || /504 Gateway Time-out/i.test(text)) {
|
||||
return "Превышено время ожидания ответа SAC. Долгая операция могла ещё выполняться на сервере.";
|
||||
}
|
||||
return "Временная ошибка шлюза SAC. Обновите страницу.";
|
||||
}
|
||||
if (!text) return "";
|
||||
if (text.trimStart().startsWith("<")) {
|
||||
return status ? `Ошибка HTTP ${status}` : "Ошибка сервера";
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(text) as { detail?: string | Array<{ msg?: string }> };
|
||||
if (typeof data.detail === "string") return data.detail;
|
||||
if (Array.isArray(data.detail)) {
|
||||
return data.detail.map((item) => item.msg ?? String(item)).join("; ");
|
||||
}
|
||||
} catch {
|
||||
/* plain text */
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export interface UserSummary {
|
||||
id: number;
|
||||
username: string;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface UserListResponse {
|
||||
items: UserSummary[];
|
||||
}
|
||||
|
||||
export interface UpdateUserPayload {
|
||||
username?: string;
|
||||
role?: string;
|
||||
password?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export function fetchUsers(): Promise<UserListResponse> {
|
||||
return apiFetch<UserListResponse>("/api/v1/users");
|
||||
}
|
||||
|
||||
export function createUser(payload: {
|
||||
username: string;
|
||||
password: string;
|
||||
role: string;
|
||||
}): Promise<UserSummary> {
|
||||
return apiFetch<UserSummary>("/api/v1/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateUser(userId: number, payload: UpdateUserPayload): Promise<UserSummary> {
|
||||
return apiFetch<UserSummary>(`/api/v1/users/${userId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export interface EventSummary {
|
||||
id: number;
|
||||
event_id: string;
|
||||
host_id: number;
|
||||
hostname: string;
|
||||
display_name?: string | null;
|
||||
product_version?: string | null;
|
||||
occurred_at: string;
|
||||
received_at: string;
|
||||
category: string;
|
||||
type: string;
|
||||
severity: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
actor_user?: string | null;
|
||||
session_duration_sec?: number | null;
|
||||
rdg_flap?: boolean;
|
||||
rdg_flap_pair_event_id?: number | null;
|
||||
rdg_flap_qwinsta_event_id?: number | null;
|
||||
rdg_access_path?: string | null;
|
||||
rdg_qwinsta_enabled?: boolean;
|
||||
session_terminated?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentCommandResponse {
|
||||
command_uuid: string;
|
||||
command_type: string;
|
||||
status: string;
|
||||
result_stdout?: string | null;
|
||||
result_stderr?: string | null;
|
||||
created_at?: string | null;
|
||||
completed_at?: string | null;
|
||||
target?: string | null;
|
||||
client_hostname?: string | null;
|
||||
internal_ip?: string | null;
|
||||
}
|
||||
|
||||
export function postEventQwinsta(eventId: number): Promise<AgentCommandResponse> {
|
||||
return apiFetch<AgentCommandResponse>(`/api/v1/events/${eventId}/actions/qwinsta`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export function postEventLogoff(eventId: number, sessionId: number): Promise<AgentCommandResponse> {
|
||||
return apiFetch<AgentCommandResponse>(`/api/v1/events/${eventId}/actions/logoff`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ session_id: sessionId }),
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchEventAction(eventId: number, commandUuid: string): Promise<AgentCommandResponse> {
|
||||
return apiFetch<AgentCommandResponse>(`/api/v1/events/${eventId}/actions/${commandUuid}`);
|
||||
}
|
||||
|
||||
export interface EventListResponse {
|
||||
items: EventSummary[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export interface EventDetail extends EventSummary {
|
||||
details: Record<string, unknown> | null;
|
||||
raw: Record<string, unknown> | null;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface HostSummary {
|
||||
id: number;
|
||||
hostname: string;
|
||||
display_name: string | null;
|
||||
os_family: string;
|
||||
os_version?: string | null;
|
||||
product: string;
|
||||
product_version: string | null;
|
||||
ipv4: string | null;
|
||||
last_seen_at: string;
|
||||
event_count: number | null;
|
||||
last_heartbeat_at: string | null;
|
||||
last_daily_report_at: string | null;
|
||||
last_inventory_at?: string | null;
|
||||
agent_status: "online" | "stale" | "unknown";
|
||||
version_status?: "ok" | "outdated" | "unknown";
|
||||
pending_agent_update?: boolean;
|
||||
ssh_admin_ok?: boolean | null;
|
||||
ssh_admin_checked_at?: string | null;
|
||||
}
|
||||
|
||||
export interface HostDetail extends HostSummary {
|
||||
agent_instance_id: string | null;
|
||||
ipv6: string | null;
|
||||
tags: string[] | null;
|
||||
use_sac_mode: string | null;
|
||||
inventory: Record<string, unknown> | null;
|
||||
inventory_updated_at: string | null;
|
||||
created_at: string | null;
|
||||
pending_update_requested_at?: string | null;
|
||||
pending_update_target_version?: string | null;
|
||||
agent_config_revision?: number;
|
||||
agent_config?: Record<string, unknown> | null;
|
||||
agent_update_state?: string | null;
|
||||
agent_update_last_at?: string | null;
|
||||
agent_update_last_error?: string | null;
|
||||
}
|
||||
|
||||
export function fetchHost(id: number): Promise<HostDetail> {
|
||||
return apiFetch<HostDetail>(`/api/v1/hosts/${id}`);
|
||||
}
|
||||
|
||||
export function fetchRecentEvents(limit = 1): Promise<EventSummary[]> {
|
||||
return apiFetch<EventSummary[]>(`/api/v1/dashboards/recent-events?limit=${limit}`);
|
||||
}
|
||||
|
||||
export interface HostListResponse {
|
||||
items: HostSummary[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
latest_agent_versions?: Record<string, string>;
|
||||
/** Эталон для «устарела»: max(git latest, min, max в fleet) */
|
||||
reference_agent_versions?: Record<string, string>;
|
||||
/** Последние версии из git (version.txt на main) */
|
||||
git_latest_rdp_version?: string | null;
|
||||
git_latest_ssh_version?: string | null;
|
||||
}
|
||||
|
||||
export interface ProblemSummary {
|
||||
id: number;
|
||||
host_id: number | null;
|
||||
hostname: string | null;
|
||||
display_name?: string | null;
|
||||
title: string;
|
||||
summary: string;
|
||||
severity: string;
|
||||
status: string;
|
||||
rule_id: string | null;
|
||||
fingerprint?: string;
|
||||
event_count?: number;
|
||||
last_seen_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProblemListResponse {
|
||||
items: ProblemSummary[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export interface ProblemEventItem {
|
||||
id: number;
|
||||
event_id: string;
|
||||
occurred_at: string;
|
||||
type: string;
|
||||
severity: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface ProblemDetail extends ProblemSummary {
|
||||
events: ProblemEventItem[];
|
||||
}
|
||||
|
||||
export function fetchProblem(id: number): Promise<ProblemDetail> {
|
||||
return apiFetch<ProblemDetail>(`/api/v1/problems/${id}`);
|
||||
}
|
||||
|
||||
export async function ackProblem(problemId: number): Promise<{ id: number; status: string }> {
|
||||
return apiFetch(`/api/v1/problems/${problemId}/ack`, { method: "POST" });
|
||||
}
|
||||
|
||||
export async function resolveProblem(problemId: number): Promise<{ id: number; status: string }> {
|
||||
return apiFetch(`/api/v1/problems/${problemId}/resolve`, { method: "POST" });
|
||||
}
|
||||
|
||||
export interface TopHostItem {
|
||||
host_id: number;
|
||||
hostname: string;
|
||||
display_name?: string | null;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface TopTypeItem {
|
||||
type: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface MobileSettings {
|
||||
devices_allowed: boolean;
|
||||
max_devices_per_user: number;
|
||||
min_app_version: string | null;
|
||||
fcm_enabled: boolean;
|
||||
fcm_configured: boolean;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface MobileEnrollmentCode {
|
||||
id: number;
|
||||
label: string;
|
||||
code_prefix: string;
|
||||
target_user_id: number | null;
|
||||
target_username: string | null;
|
||||
login_mode: string;
|
||||
max_uses: number;
|
||||
use_count: number;
|
||||
expires_at: string;
|
||||
revoked_at: string | null;
|
||||
created_at: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface MobileEnrollmentCodeCreated extends MobileEnrollmentCode {
|
||||
enrollment_code: string;
|
||||
}
|
||||
|
||||
export interface MobileDevice {
|
||||
id: number;
|
||||
user_id: number;
|
||||
username: string;
|
||||
device_uuid: string;
|
||||
display_name: string;
|
||||
platform: string;
|
||||
app_version: string | null;
|
||||
has_fcm_token: boolean;
|
||||
enrolled_at: string;
|
||||
last_seen_at: string | null;
|
||||
revoked_at: string | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export function fetchMobileSettings(): Promise<MobileSettings> {
|
||||
return apiFetch<MobileSettings>("/api/v1/mobile/settings");
|
||||
}
|
||||
|
||||
export function updateMobileSettings(payload: {
|
||||
devices_allowed: boolean;
|
||||
max_devices_per_user: number;
|
||||
min_app_version?: string | null;
|
||||
}): Promise<MobileSettings> {
|
||||
return apiFetch<MobileSettings>("/api/v1/mobile/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchMobileEnrollmentCodes(): Promise<MobileEnrollmentCode[]> {
|
||||
return apiFetch<MobileEnrollmentCode[]>("/api/v1/mobile/enrollment-codes");
|
||||
}
|
||||
|
||||
export function createMobileEnrollmentCode(payload: {
|
||||
label?: string;
|
||||
target_user_id?: number | null;
|
||||
login_mode?: string;
|
||||
max_uses?: number;
|
||||
expires_in_hours?: number;
|
||||
}): Promise<MobileEnrollmentCodeCreated> {
|
||||
return apiFetch<MobileEnrollmentCodeCreated>("/api/v1/mobile/enrollment-codes", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function revokeMobileEnrollmentCode(codeId: number): Promise<void> {
|
||||
return apiFetch<void>(`/api/v1/mobile/enrollment-codes/${codeId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function fetchMobileDevices(): Promise<MobileDevice[]> {
|
||||
return apiFetch<MobileDevice[]>("/api/v1/mobile/devices");
|
||||
}
|
||||
|
||||
export function revokeMobileDevice(deviceId: number): Promise<MobileDevice> {
|
||||
return apiFetch<MobileDevice>(`/api/v1/mobile/devices/${deviceId}/revoke`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function deleteMobileDevice(deviceId: number): Promise<void> {
|
||||
return apiFetch<void>(`/api/v1/mobile/devices/${deviceId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function testMobileDevicePush(deviceId: number): Promise<{ message: string }> {
|
||||
return apiFetch(`/api/v1/mobile/devices/${deviceId}/test-push`, { method: "POST" });
|
||||
}
|
||||
|
||||
export interface WinAdminSettings {
|
||||
configured: boolean;
|
||||
user: string | null;
|
||||
password_hint: string | null;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export function fetchWinAdminSettings(): Promise<WinAdminSettings> {
|
||||
return apiFetch<WinAdminSettings>("/api/v1/settings/win-admin");
|
||||
}
|
||||
|
||||
export function updateWinAdminSettings(payload: {
|
||||
user?: string | null;
|
||||
password?: string | null;
|
||||
}): Promise<WinAdminSettings> {
|
||||
return apiFetch<WinAdminSettings>("/api/v1/settings/win-admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export interface RdpFlapSettings {
|
||||
auto_disconnect: boolean;
|
||||
win_admin_configured: boolean;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export function fetchRdpFlapSettings(): Promise<RdpFlapSettings> {
|
||||
return apiFetch<RdpFlapSettings>("/api/v1/settings/rdp-flap");
|
||||
}
|
||||
|
||||
export function updateRdpFlapSettings(payload: { auto_disconnect: boolean }): Promise<RdpFlapSettings> {
|
||||
return apiFetch<RdpFlapSettings>("/api/v1/settings/rdp-flap", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export interface HostWinRmTestResult {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
target: string;
|
||||
hostname: string | null;
|
||||
}
|
||||
|
||||
export function testHostWinRm(hostId: number): Promise<HostWinRmTestResult> {
|
||||
return apiFetch<HostWinRmTestResult>(`/api/v1/hosts/${hostId}/actions/winrm-test`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export interface LinuxAdminSettings {
|
||||
configured: boolean;
|
||||
user: string | null;
|
||||
password_hint: string | null;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export function fetchLinuxAdminSettings(): Promise<LinuxAdminSettings> {
|
||||
return apiFetch<LinuxAdminSettings>("/api/v1/settings/linux-admin");
|
||||
}
|
||||
|
||||
export function updateLinuxAdminSettings(payload: {
|
||||
user?: string | null;
|
||||
password?: string | null;
|
||||
}): Promise<LinuxAdminSettings> {
|
||||
return apiFetch<LinuxAdminSettings>("/api/v1/settings/linux-admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export interface HostMgmtAccess {
|
||||
host_id: number;
|
||||
has_override: boolean;
|
||||
user: string | null;
|
||||
password_set: boolean;
|
||||
password_hint: string | null;
|
||||
effective_source: string;
|
||||
effective_configured: boolean;
|
||||
effective_user: string | null;
|
||||
}
|
||||
|
||||
export function fetchHostAccess(hostId: number): Promise<HostMgmtAccess> {
|
||||
return apiFetch<HostMgmtAccess>(`/api/v1/hosts/${hostId}/access`);
|
||||
}
|
||||
|
||||
export function updateHostAccess(
|
||||
hostId: number,
|
||||
payload: { user?: string | null; password?: string | null; clear?: boolean },
|
||||
): Promise<HostMgmtAccess> {
|
||||
return apiFetch<HostMgmtAccess>(`/api/v1/hosts/${hostId}/access`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export interface HostSshActionResult {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
target: string;
|
||||
stdout: string | null;
|
||||
stderr: string | null;
|
||||
exit_code: number | null;
|
||||
product_version?: string | null;
|
||||
ssh_admin_ok?: boolean | null;
|
||||
}
|
||||
|
||||
export function testHostSsh(hostId: number): Promise<HostSshActionResult> {
|
||||
return apiFetch<HostSshActionResult>(`/api/v1/hosts/${hostId}/actions/ssh-test`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export interface HostRemoteActionStartResult {
|
||||
status: string;
|
||||
host_id: number;
|
||||
title: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface HostRemoteActionJobStatus {
|
||||
active: boolean;
|
||||
host_id: number;
|
||||
hostname?: string | null;
|
||||
status?: string | null;
|
||||
title?: string;
|
||||
message?: string;
|
||||
stdout?: string | null;
|
||||
stderr?: string | null;
|
||||
output?: string | null;
|
||||
ok?: boolean | null;
|
||||
exit_code?: number | null;
|
||||
product_version?: string | null;
|
||||
agent_update_state?: string | null;
|
||||
started_at?: string | null;
|
||||
finished_at?: string | null;
|
||||
}
|
||||
|
||||
export function updateHostAgentViaSsh(hostId: number): Promise<HostRemoteActionStartResult> {
|
||||
return apiFetch<HostRemoteActionStartResult>(`/api/v1/hosts/${hostId}/actions/agent-update`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export interface HostAgentUpdateRequestResult {
|
||||
status: string;
|
||||
pending_agent_update: boolean;
|
||||
target_version: string | null;
|
||||
agent_update_state: string | null;
|
||||
}
|
||||
|
||||
export function requestHostAgentUpdate(hostId: number): Promise<HostAgentUpdateRequestResult> {
|
||||
return apiFetch<HostAgentUpdateRequestResult>(
|
||||
`/api/v1/hosts/${hostId}/actions/request-agent-update`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
export function runHostAgentUpdateFallback(hostId: number): Promise<HostRemoteActionStartResult> {
|
||||
return apiFetch<HostRemoteActionStartResult>(
|
||||
`/api/v1/hosts/${hostId}/actions/agent-update-fallback`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
export interface HostManualAddRequest {
|
||||
platform: "windows" | "linux";
|
||||
target: string;
|
||||
}
|
||||
|
||||
export function addHostManually(body: HostManualAddRequest): Promise<HostRemoteActionStartResult> {
|
||||
return apiFetch<HostRemoteActionStartResult>("/api/v1/hosts/manual-add", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchHostRemoteJob(hostId: number): Promise<HostRemoteActionJobStatus> {
|
||||
return apiFetch<HostRemoteActionJobStatus>(
|
||||
`/api/v1/hosts/${hostId}/actions/remote-job?_ts=${Date.now()}`,
|
||||
);
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
export function patchHostAgentConfig(
|
||||
hostId: number,
|
||||
settings: Record<string, unknown>,
|
||||
): Promise<HostAgentConfigResponse> {
|
||||
return apiFetch<HostAgentConfigResponse>(`/api/v1/hosts/${hostId}/agent-config`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ settings }),
|
||||
});
|
||||
}
|
||||
|
||||
export interface AgentUpdateSettings {
|
||||
mode: "gpo" | "sac";
|
||||
fallback_enabled: boolean;
|
||||
fallback_after_minutes: number;
|
||||
recommended_rdp_version: string | null;
|
||||
recommended_ssh_version: string | null;
|
||||
min_rdp_version: string | null;
|
||||
min_ssh_version: string | null;
|
||||
win_agent_update_script: string | null;
|
||||
rdp_git_repo_url: string | null;
|
||||
ssh_git_repo_url: string | null;
|
||||
git_branch: string;
|
||||
git_latest_rdp_version: string | null;
|
||||
git_latest_ssh_version: string | null;
|
||||
git_fetched_at: string | null;
|
||||
git_errors: Record<string, string>;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface AgentGitTestResult {
|
||||
git_latest_rdp_version: string | null;
|
||||
git_latest_ssh_version: string | null;
|
||||
git_fetched_at: string | null;
|
||||
git_errors: Record<string, string>;
|
||||
from_cache: boolean;
|
||||
}
|
||||
|
||||
export function fetchAgentUpdateSettings(): Promise<AgentUpdateSettings> {
|
||||
return apiFetch<AgentUpdateSettings>("/api/v1/settings/agent-updates");
|
||||
}
|
||||
|
||||
export function saveAgentUpdateSettings(
|
||||
payload: Partial<AgentUpdateSettings>,
|
||||
): Promise<AgentUpdateSettings> {
|
||||
return apiFetch<AgentUpdateSettings>("/api/v1/settings/agent-updates", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export interface AgentGitTestPayload {
|
||||
rdp_git_repo_url?: string | null;
|
||||
ssh_git_repo_url?: string | null;
|
||||
git_branch?: string | null;
|
||||
}
|
||||
|
||||
export function testAgentGitRelease(payload?: AgentGitTestPayload): Promise<AgentGitTestResult> {
|
||||
return apiFetch<AgentGitTestResult>("/api/v1/settings/agent-updates/test-git", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
export interface LoginSecuritySettings {
|
||||
ip_whitelist: string[];
|
||||
ip_whitelist_text: string;
|
||||
sync_fail2ban: boolean;
|
||||
max_failures: number;
|
||||
window_minutes: number;
|
||||
fail2ban_available: boolean;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface LoginBlockItem {
|
||||
ip_address: string;
|
||||
scope: string;
|
||||
failure_count: number | null;
|
||||
usernames: string[];
|
||||
blocked_until: string | null;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export function fetchLoginSecuritySettings(): Promise<LoginSecuritySettings> {
|
||||
return apiFetch<LoginSecuritySettings>("/api/v1/settings/login-security");
|
||||
}
|
||||
|
||||
export function saveLoginSecuritySettings(payload: {
|
||||
ip_whitelist_text: string;
|
||||
sync_fail2ban: boolean;
|
||||
}): Promise<LoginSecuritySettings> {
|
||||
return apiFetch<LoginSecuritySettings>("/api/v1/settings/login-security", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchLoginSecurityBlocks(): Promise<LoginBlockItem[]> {
|
||||
return apiFetch<LoginBlockItem[]>("/api/v1/settings/login-security/blocks");
|
||||
}
|
||||
|
||||
export function unblockLoginSecurityIp(payload: {
|
||||
ip_address: string;
|
||||
scope: "web" | "ssh" | "both";
|
||||
}): Promise<{ messages: string[] }> {
|
||||
return apiFetch<{ messages: string[] }>("/api/v1/settings/login-security/unblock", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export interface DashboardSummary {
|
||||
events_last_24h: number;
|
||||
hosts_total: number;
|
||||
hosts_stale: number;
|
||||
heartbeats_24h: number;
|
||||
daily_reports_24h: number;
|
||||
problems_open: number;
|
||||
problems_opened_24h: number;
|
||||
problems_resolved_24h: number;
|
||||
severity_24h: Record<string, number>;
|
||||
top_hosts: TopHostItem[];
|
||||
top_event_types: TopTypeItem[];
|
||||
recent_events: EventSummary[];
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<aside class="sac-sidebar">
|
||||
<div class="sac-sidebar-brand">
|
||||
<img src="/sac-icon.png" alt="" class="sac-brand-logo" width="40" height="40" />
|
||||
<div class="sac-brand-text">
|
||||
<span class="sac-brand-name">{{ appName }}</span>
|
||||
<span class="sac-brand-version">v.{{ appVersion }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="sac-sidebar-nav" aria-label="Основная навигация">
|
||||
<button
|
||||
v-if="remoteJobRunning && !remoteJobPanelOpen"
|
||||
type="button"
|
||||
class="sac-nav-item sac-remote-job-badge"
|
||||
title="Обновления агентов выполняются на сервере SAC"
|
||||
@click="showHostRemoteActionLog()"
|
||||
>
|
||||
<span class="sac-nav-icon sac-remote-job-spinner" aria-hidden="true" />
|
||||
<span class="sac-nav-label">Обновления…</span>
|
||||
</button>
|
||||
<RouterLink
|
||||
v-for="item in navItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="sac-nav-item"
|
||||
:class="{ 'is-active': isActive(item) }"
|
||||
>
|
||||
<span class="sac-nav-icon" aria-hidden="true">{{ item.icon }}</span>
|
||||
<span class="sac-nav-label">{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
<div class="sac-sidebar-footer">
|
||||
<SidebarSystemStats />
|
||||
<button type="button" class="sac-nav-item sac-nav-logout" @click="logout">
|
||||
<span class="sac-nav-icon" aria-hidden="true">⏻</span>
|
||||
<span class="sac-nav-label">Выход</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { clearToken, isAdmin, logoutApi } from "../api";
|
||||
import SidebarSystemStats from "./SidebarSystemStats.vue";
|
||||
import { useSacVersion } from "../composables/useSacVersion";
|
||||
import {
|
||||
anyRemoteActionLogOpen,
|
||||
hasActiveHostRemoteAction,
|
||||
showHostRemoteActionLog,
|
||||
} from "../composables/useHostRemoteAction";
|
||||
|
||||
const { appName, appVersion } = useSacVersion();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const remoteJobRunning = computed(() => hasActiveHostRemoteAction());
|
||||
const remoteJobPanelOpen = computed(() => anyRemoteActionLogOpen());
|
||||
|
||||
const navItems = computed(() => {
|
||||
const items = [
|
||||
{ to: "/dashboard", label: "Обзор", icon: "▦", match: "exact" as const },
|
||||
{ to: "/events", label: "События", icon: "☰", match: "prefix" as const },
|
||||
{ to: "/reports", label: "Отчёты", icon: "▤", match: "prefix" as const },
|
||||
{ to: "/problems", label: "Проблемы", icon: "!", match: "prefix" as const },
|
||||
{ to: "/hosts", label: "Хосты", icon: "⬢", match: "prefix" as const },
|
||||
];
|
||||
if (isAdmin()) {
|
||||
items.push(
|
||||
{ to: "/settings", label: "Настройки", icon: "⚙", match: "exact" as const },
|
||||
{ to: "/users", label: "Пользователи", icon: "👤", match: "exact" as const },
|
||||
);
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
function isActive(item: (typeof navItems.value)[number]) {
|
||||
if (item.match === "exact") {
|
||||
return route.path === item.to;
|
||||
}
|
||||
return route.path === item.to || route.path.startsWith(`${item.to}/`);
|
||||
}
|
||||
|
||||
function logout() {
|
||||
void logoutApi().finally(() => {
|
||||
clearToken();
|
||||
router.push("/login");
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sac-sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
align-self: flex-start;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: max-content;
|
||||
min-width: 12.5rem;
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
background: #15202b;
|
||||
border-right: 1px solid #2a3441;
|
||||
padding: 0.75rem 0.5rem;
|
||||
}
|
||||
|
||||
.sac-sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.5rem 0.75rem 0.85rem;
|
||||
border-bottom: 1px solid #2a3441;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.sac-brand-logo {
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sac-brand-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sac-brand-name {
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sac-brand-version {
|
||||
color: #9aa4b2;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sac-sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sac-sidebar-footer {
|
||||
flex-shrink: 0;
|
||||
padding-top: 0.75rem;
|
||||
margin-top: auto;
|
||||
border-top: 1px solid #2a3441;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.sac-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #c5d0dc;
|
||||
text-decoration: none;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.sac-remote-job-badge {
|
||||
color: #58a6ff;
|
||||
border-left-color: #58a6ff;
|
||||
background: rgba(88, 166, 255, 0.08);
|
||||
}
|
||||
|
||||
.sac-remote-job-spinner {
|
||||
display: inline-block;
|
||||
width: 0.85rem;
|
||||
height: 0.85rem;
|
||||
border: 2px solid #3a4556;
|
||||
border-top-color: #58a6ff;
|
||||
border-radius: 50%;
|
||||
animation: sac-remote-job-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes sac-remote-job-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.sac-nav-item:hover {
|
||||
background: #1e2d3d;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sac-nav-item.is-active {
|
||||
background: #1a3a52;
|
||||
color: #fff;
|
||||
border-left-color: #3db8ff;
|
||||
}
|
||||
|
||||
.sac-nav-icon {
|
||||
width: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
font-size: 0.95rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.sac-nav-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sac-nav-logout {
|
||||
color: #9aa4b2;
|
||||
}
|
||||
|
||||
.sac-nav-logout:hover {
|
||||
color: #ff8a8a;
|
||||
background: #2a1f24;
|
||||
border-left-color: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,415 @@
|
||||
<template>
|
||||
<div v-if="open" class="host-action-log-dock" aria-live="polite">
|
||||
<div
|
||||
class="host-action-log-panel"
|
||||
:class="{ 'host-action-log-panel-done': !isLoading && ok === true, 'host-action-log-panel-fail': !isLoading && ok === false }"
|
||||
role="dialog"
|
||||
:aria-label="displayTitle"
|
||||
>
|
||||
<div class="host-action-log-header">
|
||||
<h3>
|
||||
<span v-if="!isLoading && ok === true" class="host-action-log-mark host-action-log-mark-ok" aria-hidden="true">✓</span>
|
||||
<span v-else-if="!isLoading && ok === false" class="host-action-log-mark host-action-log-mark-fail" aria-hidden="true">✗</span>
|
||||
{{ displayTitle }}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="host-action-log-icon-btn"
|
||||
:title="isLoading ? 'Свернуть — обновление продолжится на сервере' : 'Закрыть'"
|
||||
:aria-label="isLoading ? 'Свернуть' : 'Закрыть'"
|
||||
@click="emit('close')"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="isLoading" class="host-action-log-status">
|
||||
<span class="host-action-log-spinner" aria-hidden="true" />
|
||||
Выполняется на удалённом хосте… Можно запустить обновление других хостов.
|
||||
</p>
|
||||
<p
|
||||
v-else-if="ok === true"
|
||||
class="host-action-log-message success host-action-log-result"
|
||||
>
|
||||
{{ displayMessage }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="ok === false"
|
||||
class="host-action-log-message error host-action-log-result"
|
||||
>
|
||||
{{ displayMessage }}
|
||||
</p>
|
||||
<p v-else-if="displayMessage && isLoading" class="muted host-action-log-sub host-action-log-message">
|
||||
{{ displayMessage }}
|
||||
</p>
|
||||
<p v-if="!isLoading && ok === true && autoCloseSec > 0" class="muted host-action-log-sub">
|
||||
Окно закроется автоматически через {{ autoCloseSec }} с
|
||||
</p>
|
||||
<pre
|
||||
v-show="logVisible"
|
||||
ref="logPreRef"
|
||||
class="host-action-log-output"
|
||||
>{{ displayOutput }}</pre>
|
||||
<div class="host-action-log-actions">
|
||||
<button type="button" class="secondary" @click="emit('toggle-log')">
|
||||
{{ logVisible ? "Скрыть лог" : "Показать лог" }}
|
||||
</button>
|
||||
<button type="button" class="secondary" @click="emit('close')">
|
||||
{{ isLoading ? "Свернуть" : "Закрыть" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from "vue";
|
||||
import { fetchHostRemoteJob, type HostRemoteActionJobStatus } from "../api";
|
||||
|
||||
const LOG_POLL_MS = 1500;
|
||||
const AUTO_CLOSE_MS = 30_000;
|
||||
|
||||
const props = defineProps<{
|
||||
hostId: number;
|
||||
open: boolean;
|
||||
title: string;
|
||||
loading: boolean;
|
||||
ok: boolean | null;
|
||||
message: string;
|
||||
output: string;
|
||||
logPlaceholder: string;
|
||||
logVisible: boolean;
|
||||
sessionStartedAt: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
"toggle-log": [];
|
||||
finished: [job: HostRemoteActionJobStatus];
|
||||
}>();
|
||||
|
||||
const logPreRef = ref<HTMLElement | null>(null);
|
||||
const polledOutput = ref("");
|
||||
const polledMessage = ref("");
|
||||
const isLoading = ref(true);
|
||||
const ok = ref<boolean | null>(null);
|
||||
const localTitle = ref("");
|
||||
const localMessage = ref("");
|
||||
const autoCloseSec = ref(0);
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let finishedEmitted = false;
|
||||
let sawRunning = false;
|
||||
|
||||
function isTerminalJob(job: HostRemoteActionJobStatus): boolean {
|
||||
const st = (job.status || "").toLowerCase();
|
||||
return st === "success" || st === "failed";
|
||||
}
|
||||
|
||||
function jobStartedAfterSession(job: HostRemoteActionJobStatus): boolean {
|
||||
const started = job.started_at;
|
||||
if (!started) return false;
|
||||
const jobMs = Date.parse(started);
|
||||
const sessionMs = Date.parse(props.sessionStartedAt);
|
||||
if (Number.isNaN(jobMs) || Number.isNaN(sessionMs)) return false;
|
||||
return jobMs >= sessionMs - 3000;
|
||||
}
|
||||
|
||||
function isJobRunning(job: HostRemoteActionJobStatus): boolean {
|
||||
if (job.active) return true;
|
||||
const st = (job.status || "").toLowerCase();
|
||||
if (st === "running") return true;
|
||||
return (job.agent_update_state || "").toLowerCase() === "running";
|
||||
}
|
||||
|
||||
const displayTitle = computed(() => {
|
||||
if (localTitle.value) return localTitle.value;
|
||||
return props.title;
|
||||
});
|
||||
|
||||
const displayMessage = computed(() => {
|
||||
if (localMessage.value) return localMessage.value;
|
||||
if (polledMessage.value) return polledMessage.value;
|
||||
return props.message;
|
||||
});
|
||||
|
||||
const displayOutput = computed(() => {
|
||||
const text = (polledOutput.value || props.output).trim();
|
||||
return text || props.logPlaceholder;
|
||||
});
|
||||
|
||||
function stopPoll() {
|
||||
if (pollTimer != null) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function stopCountdown() {
|
||||
if (countdownTimer != null) {
|
||||
clearInterval(countdownTimer);
|
||||
countdownTimer = null;
|
||||
}
|
||||
autoCloseSec.value = 0;
|
||||
}
|
||||
|
||||
function startCountdown() {
|
||||
stopCountdown();
|
||||
autoCloseSec.value = Math.ceil(AUTO_CLOSE_MS / 1000);
|
||||
countdownTimer = setInterval(() => {
|
||||
if (autoCloseSec.value <= 1) {
|
||||
stopCountdown();
|
||||
return;
|
||||
}
|
||||
autoCloseSec.value -= 1;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function applyFinishedJob(job: HostRemoteActionJobStatus) {
|
||||
isLoading.value = false;
|
||||
ok.value = job.ok ?? job.status === "success";
|
||||
if (job.title) localTitle.value = job.title;
|
||||
if (job.message) localMessage.value = job.message;
|
||||
const out = (job.output || job.stdout || "").trim();
|
||||
if (out) polledOutput.value = out;
|
||||
if (ok.value) startCountdown();
|
||||
}
|
||||
|
||||
async function pollJobOnce() {
|
||||
try {
|
||||
const job = await fetchHostRemoteJob(props.hostId);
|
||||
const out = (job.output || job.stdout || "").trim();
|
||||
if (out) polledOutput.value = out;
|
||||
if (job.message && isLoading.value) polledMessage.value = job.message;
|
||||
|
||||
if (isJobRunning(job)) {
|
||||
sawRunning = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const canFinish =
|
||||
isTerminalJob(job) && (sawRunning || jobStartedAfterSession(job));
|
||||
if (canFinish) {
|
||||
if (!finishedEmitted) {
|
||||
finishedEmitted = true;
|
||||
applyFinishedJob(job);
|
||||
emit("finished", job);
|
||||
}
|
||||
stopPoll();
|
||||
}
|
||||
} catch {
|
||||
/* следующий poll */
|
||||
}
|
||||
}
|
||||
|
||||
function startPoll() {
|
||||
stopPoll();
|
||||
void pollJobOnce();
|
||||
pollTimer = setInterval(() => void pollJobOnce(), LOG_POLL_MS);
|
||||
}
|
||||
|
||||
function resetLocalState() {
|
||||
finishedEmitted = false;
|
||||
sawRunning = false;
|
||||
isLoading.value = true;
|
||||
ok.value = null;
|
||||
localTitle.value = "";
|
||||
localMessage.value = "";
|
||||
polledOutput.value = props.output.trim();
|
||||
polledMessage.value = "";
|
||||
stopCountdown();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open && isLoading.value) {
|
||||
startPoll();
|
||||
} else {
|
||||
stopPoll();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.loading,
|
||||
(loading) => {
|
||||
if (loading) {
|
||||
resetLocalState();
|
||||
if (props.open) startPoll();
|
||||
return;
|
||||
}
|
||||
if (!finishedEmitted && props.ok != null) {
|
||||
finishedEmitted = true;
|
||||
isLoading.value = false;
|
||||
ok.value = props.ok;
|
||||
localTitle.value = props.title;
|
||||
localMessage.value = props.message;
|
||||
if (props.output.trim()) polledOutput.value = props.output.trim();
|
||||
if (props.ok) startCountdown();
|
||||
stopPoll();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [displayOutput.value, props.logVisible] as const,
|
||||
async () => {
|
||||
if (!props.logVisible) return;
|
||||
await nextTick();
|
||||
const el = logPreRef.value;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
},
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPoll();
|
||||
stopCountdown();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.host-action-log-dock {
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.host-action-log-panel {
|
||||
pointer-events: auto;
|
||||
background: var(--card-bg, #1e2430);
|
||||
border: 1px solid var(--border, #3a4556);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
|
||||
max-height: min(75vh, 36rem);
|
||||
overflow: auto;
|
||||
padding: 0.85rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.host-action-log-panel-done {
|
||||
border-color: #3fb950;
|
||||
}
|
||||
|
||||
.host-action-log-panel-fail {
|
||||
border-color: #f85149;
|
||||
}
|
||||
|
||||
.host-action-log-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.host-action-log-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
line-height: 1.3;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.host-action-log-mark {
|
||||
flex-shrink: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.host-action-log-mark-ok {
|
||||
color: #3fb950;
|
||||
}
|
||||
|
||||
.host-action-log-mark-fail {
|
||||
color: #f85149;
|
||||
}
|
||||
|
||||
.host-action-log-icon-btn {
|
||||
flex-shrink: 0;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #9aa4b2;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.host-action-log-icon-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #e6edf3;
|
||||
}
|
||||
|
||||
.host-action-log-status {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.65rem;
|
||||
margin: 0.65rem 0 0;
|
||||
color: #9aa4b2;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.host-action-log-sub {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.host-action-log-message {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.host-action-log-result {
|
||||
margin: 0.65rem 0 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.host-action-log-message.success {
|
||||
color: #3fb950;
|
||||
}
|
||||
|
||||
.host-action-log-message.error {
|
||||
color: #f85149;
|
||||
}
|
||||
|
||||
.host-action-log-spinner {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-top: 0.15rem;
|
||||
border: 2px solid #3a4556;
|
||||
border-top-color: #58a6ff;
|
||||
border-radius: 50%;
|
||||
animation: host-action-log-spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes host-action-log-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.host-action-log-output {
|
||||
margin-top: 0.65rem;
|
||||
max-height: min(45vh, 22rem);
|
||||
overflow: auto;
|
||||
padding: 0.65rem;
|
||||
background: #0d1117;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.host-action-log-actions {
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div v-if="hasOpenLogs" class="host-action-log-stack" aria-live="polite">
|
||||
<template v-for="entry in hostRemoteActionLogs" :key="entry.id">
|
||||
<HostActionLogModal
|
||||
v-if="entry.open"
|
||||
:host-id="entry.hostId"
|
||||
:open="true"
|
||||
:title="entry.title"
|
||||
:loading="entry.loading"
|
||||
:ok="entry.ok"
|
||||
:message="entry.message"
|
||||
:output="entry.output"
|
||||
:log-placeholder="entry.logPlaceholder"
|
||||
:log-visible="entry.logVisible"
|
||||
:session-started-at="entry.sessionStartedAt"
|
||||
@close="closeHostRemoteActionLog(entry.id)"
|
||||
@toggle-log="toggleHostRemoteActionLog(entry.id)"
|
||||
@finished="syncHostRemoteActionJobFinished(entry.id, $event)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import HostActionLogModal from "./HostActionLogModal.vue";
|
||||
import {
|
||||
closeHostRemoteActionLog,
|
||||
hostRemoteActionLogs,
|
||||
syncHostRemoteActionJobFinished,
|
||||
toggleHostRemoteActionLog,
|
||||
} from "../composables/useHostRemoteAction";
|
||||
|
||||
const hasOpenLogs = computed(() => hostRemoteActionLogs.some((e) => e.open));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.host-action-log-stack {
|
||||
position: fixed;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
align-items: flex-end;
|
||||
gap: 0.65rem;
|
||||
width: min(56rem, calc(100vw - 2rem));
|
||||
max-height: min(90vh, 48rem);
|
||||
overflow-y: auto;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.host-action-log-stack :deep(.host-action-log-panel) {
|
||||
pointer-events: auto;
|
||||
width: 100%;
|
||||
max-height: min(36rem, 45vh);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<span class="agent-version-cell">
|
||||
<span :class="{ 'agent-version-outdated': outdatedHighlight }" :title="currentTitle">
|
||||
{{ host.product_version || "—" }}
|
||||
</span>
|
||||
<button
|
||||
v-if="showUpgrade"
|
||||
type="button"
|
||||
class="agent-version-upgrade"
|
||||
:class="{ 'agent-version-upgrade--updating': updating }"
|
||||
:disabled="updating"
|
||||
:title="buttonTitle"
|
||||
@click.stop="emit('upgrade')"
|
||||
>
|
||||
<span class="agent-version-upgrade__arrow" aria-hidden="true">↑</span>
|
||||
<span class="agent-version-upgrade__target">{{ gitTargetVersion }}</span>
|
||||
</button>
|
||||
<span v-if="updating" class="agent-version-upgrade__busy muted">…</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { HostSummary } from "../api";
|
||||
import {
|
||||
gitTargetVersionForHost,
|
||||
isGitAgentUpgradeAvailable,
|
||||
type AgentGitVersions,
|
||||
} from "../utils/hostAgentUpgrade";
|
||||
|
||||
const props = defineProps<{
|
||||
host: HostSummary;
|
||||
git: AgentGitVersions | null | undefined;
|
||||
updating?: boolean;
|
||||
outdatedHighlight?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
upgrade: [];
|
||||
}>();
|
||||
|
||||
const gitTargetVersion = computed(() => gitTargetVersionForHost(props.host, props.git));
|
||||
|
||||
const showUpgrade = computed(() => isGitAgentUpgradeAvailable(props.host, props.git));
|
||||
|
||||
const currentTitle = computed(() => {
|
||||
if (!props.host.product_version) return "";
|
||||
if (showUpgrade.value && gitTargetVersion.value) {
|
||||
return `Текущая версия ${props.host.product_version}. Доступна ${gitTargetVersion.value}`;
|
||||
}
|
||||
return props.host.product_version;
|
||||
});
|
||||
|
||||
const upgradeTitle = computed(() => {
|
||||
if (!gitTargetVersion.value) return "";
|
||||
return `Обновить агент до ${gitTargetVersion.value}`;
|
||||
});
|
||||
|
||||
const buttonTitle = computed(() => {
|
||||
if (props.updating) {
|
||||
return "Обновление выполняется…";
|
||||
}
|
||||
return upgradeTitle.value;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.agent-version-cell {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.2rem 0.35rem;
|
||||
}
|
||||
|
||||
.agent-version-upgrade {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.agent-version-upgrade:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.agent-version-upgrade__arrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
background: rgba(63, 185, 80, 0.18);
|
||||
color: #3fb950;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.agent-version-upgrade__target {
|
||||
color: #3fb950;
|
||||
font-size: 0.92em;
|
||||
font-weight: 600;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.agent-version-upgrade:hover:not(:disabled) .agent-version-upgrade__target {
|
||||
color: #56d364;
|
||||
}
|
||||
|
||||
.agent-version-upgrade:hover:not(:disabled) .agent-version-upgrade__arrow {
|
||||
background: rgba(63, 185, 80, 0.28);
|
||||
}
|
||||
|
||||
.agent-version-upgrade--updating .agent-version-upgrade__arrow {
|
||||
background: rgba(139, 148, 158, 0.2);
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.agent-version-upgrade--updating .agent-version-upgrade__target {
|
||||
color: #8b949e;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.agent-version-upgrade__busy {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div v-if="open" class="modal-backdrop" @click.self="emit('close')">
|
||||
<div class="modal-card host-manual-add-modal" role="dialog" aria-modal="true" aria-label="Добавить хост вручную">
|
||||
<h3>Добавить хост вручную</h3>
|
||||
<p class="muted">
|
||||
SAC проверит подключение и установит агент (RDP-login-monitor или ssh-monitor) через WinRM / SSH.
|
||||
</p>
|
||||
|
||||
<fieldset class="platform-fieldset">
|
||||
<legend>Тип хоста</legend>
|
||||
<label class="platform-option">
|
||||
<input v-model="platform" type="radio" value="windows" :disabled="submitting" />
|
||||
Windows — только по имени ПК (WinRM)
|
||||
</label>
|
||||
<label class="platform-option">
|
||||
<input v-model="platform" type="radio" value="linux" :disabled="submitting" />
|
||||
Linux — IP или hostname (SSH)
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<label class="target-label">
|
||||
{{ platform === "windows" ? "Имя ПК (NetBIOS / DNS)" : "IP или hostname" }}
|
||||
<input
|
||||
v-model="target"
|
||||
type="text"
|
||||
:placeholder="platform === 'windows' ? 'WORKSTATION-01' : '10.10.36.9'"
|
||||
:disabled="submitting"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="submitting" class="muted">Проверка WinRM/SSH и запуск установки…</p>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="secondary" :disabled="submitting" @click="emit('close')">Отмена</button>
|
||||
<button type="button" :disabled="submitting || !target.trim()" @click="submit">
|
||||
{{ submitting ? "Выполняется…" : "Добавить и установить" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { addHostManually, ApiError } from "../api";
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
started: [payload: { hostId: number; title: string }];
|
||||
}>();
|
||||
|
||||
const platform = ref<"windows" | "linux">("windows");
|
||||
const target = ref("");
|
||||
const submitting = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(isOpen) => {
|
||||
if (!isOpen) return;
|
||||
platform.value = "windows";
|
||||
target.value = "";
|
||||
submitting.value = false;
|
||||
error.value = "";
|
||||
},
|
||||
);
|
||||
|
||||
async function submit() {
|
||||
const trimmed = target.value.trim();
|
||||
if (!trimmed || submitting.value) return;
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const result = await addHostManually({
|
||||
platform: platform.value,
|
||||
target: trimmed,
|
||||
});
|
||||
emit("started", { hostId: result.host_id, title: result.title });
|
||||
emit("close");
|
||||
} catch (e) {
|
||||
error.value =
|
||||
e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Не удалось добавить хост";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
width: min(100%, 28rem);
|
||||
padding: 1rem 1.1rem 1.1rem;
|
||||
background: var(--card-bg, #1e2430);
|
||||
border: 1px solid var(--border, #3a4556);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.modal-card h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.platform-fieldset {
|
||||
margin: 0.75rem 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border, #3a4556);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.platform-fieldset legend {
|
||||
padding: 0 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: #9aa4b2;
|
||||
}
|
||||
|
||||
.platform-option {
|
||||
display: block;
|
||||
margin: 0.35rem 0;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.target-label {
|
||||
display: block;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.target-label input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.35rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<section
|
||||
ref="rootRef"
|
||||
class="card host-sessions"
|
||||
:class="{ 'host-sessions--expanded': loaded }"
|
||||
:style="panelMinHeight ? { minHeight: panelMinHeight } : undefined"
|
||||
>
|
||||
<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>
|
||||
<div class="host-sessions-footer">
|
||||
<button type="button" class="secondary" :disabled="loading" @click="load">
|
||||
{{ loading ? "Загрузка…" : loaded ? "Обновить" : "Показать пользователей" }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import {
|
||||
fetchHostSessions,
|
||||
terminateHostSession,
|
||||
type HostSessionItem,
|
||||
} from "../api";
|
||||
|
||||
const props = defineProps<{
|
||||
hostId: number;
|
||||
heightSource?: HTMLElement | null;
|
||||
}>();
|
||||
|
||||
const rootRef = ref<HTMLElement | null>(null);
|
||||
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);
|
||||
const panelMinHeight = ref<string | undefined>(undefined);
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let observedSource: HTMLElement | null = null;
|
||||
|
||||
function updateMatchedHeight() {
|
||||
if (loaded.value) {
|
||||
panelMinHeight.value = undefined;
|
||||
return;
|
||||
}
|
||||
const source = props.heightSource;
|
||||
if (!source) {
|
||||
panelMinHeight.value = undefined;
|
||||
return;
|
||||
}
|
||||
panelMinHeight.value = `${source.offsetHeight}px`;
|
||||
}
|
||||
|
||||
function bindHeightObserver(source: HTMLElement | null | undefined) {
|
||||
if (observedSource && resizeObserver) {
|
||||
resizeObserver.unobserve(observedSource);
|
||||
observedSource = null;
|
||||
}
|
||||
if (!source || typeof ResizeObserver === "undefined") {
|
||||
updateMatchedHeight();
|
||||
return;
|
||||
}
|
||||
if (!resizeObserver) {
|
||||
resizeObserver = new ResizeObserver(() => updateMatchedHeight());
|
||||
}
|
||||
resizeObserver.observe(source);
|
||||
observedSource = source;
|
||||
updateMatchedHeight();
|
||||
}
|
||||
|
||||
function resetSessionsState() {
|
||||
loaded.value = false;
|
||||
sessions.value = [];
|
||||
error.value = "";
|
||||
message.value = "";
|
||||
terminatingId.value = 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;
|
||||
await nextTick();
|
||||
updateMatchedHeight();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.heightSource,
|
||||
(source) => {
|
||||
bindHeightObserver(source ?? null);
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.hostId,
|
||||
() => {
|
||||
resetSessionsState();
|
||||
nextTick(() => updateMatchedHeight());
|
||||
},
|
||||
);
|
||||
|
||||
watch(loaded, () => {
|
||||
nextTick(() => updateMatchedHeight());
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
bindHeightObserver(props.heightSource ?? null);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
observedSource = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.host-sessions {
|
||||
margin-top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.host-sessions:not(.host-sessions--expanded) .host-sessions-footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.host-sessions table {
|
||||
width: 100%;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.host-sessions-footer {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<span class="rdg-access-badge" :title="tooltip">{{ event.rdg_access_path }}</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { EventSummary } from "../api";
|
||||
|
||||
const props = defineProps<{
|
||||
event: EventSummary;
|
||||
}>();
|
||||
|
||||
const tooltip = computed(() => {
|
||||
const path = props.event.rdg_access_path;
|
||||
if (path === "Haproxy-RDG-Comp") {
|
||||
return "Вход на рабочий ПК через HAProxy → RD Gateway";
|
||||
}
|
||||
if (path === "RDG-Comp") {
|
||||
return "Прямой вход на рабочий ПК через RD Gateway";
|
||||
}
|
||||
return "Путь входа RDS через RD Gateway";
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rdg-access-badge {
|
||||
display: inline-block;
|
||||
margin-right: 0.45rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: #7ec8e3;
|
||||
background: rgba(126, 200, 227, 0.1);
|
||||
border: 1px solid rgba(126, 200, 227, 0.4);
|
||||
border-radius: 4px;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<span class="rdg-flap-badge" :title="tooltip">
|
||||
RDG flap
|
||||
<template v-if="event.rdg_flap_pair_event_id">
|
||||
{{ pairArrow }}
|
||||
<RouterLink :to="pairLink">{{ event.rdg_flap_pair_event_id }}</RouterLink>
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { EventSummary } from "../api";
|
||||
import { rdgFlapPairLabel } from "../utils/rdgFlap";
|
||||
|
||||
const props = defineProps<{
|
||||
event: EventSummary;
|
||||
detailLinkQuery?: Record<string, string>;
|
||||
}>();
|
||||
|
||||
const pairArrow = computed(() => rdgFlapPairLabel(props.event));
|
||||
const tooltip = computed(
|
||||
() =>
|
||||
"302→303 за 1–10 с. Возможна зависшая сессия на ПК пользователя — qwinsta/logoff.",
|
||||
);
|
||||
const pairLink = computed(() => ({
|
||||
path: `/events/${props.event.rdg_flap_pair_event_id}`,
|
||||
query: props.detailLinkQuery,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rdg-flap-badge {
|
||||
display: inline-block;
|
||||
margin-right: 0.45rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
color: #f0c674;
|
||||
background: rgba(240, 198, 116, 0.12);
|
||||
border: 1px solid rgba(240, 198, 116, 0.45);
|
||||
border-radius: 4px;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rdg-flap-badge a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
margin-left: 0.15rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
<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 / logoff — {{ 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>
|
||||
<template v-if="modal.sessions.length">
|
||||
<p class="qwinsta-hint">
|
||||
Сеансы на ПК пользователя — нажмите <strong>logoff</strong>, чтобы оборвать зависшую сессию:
|
||||
</p>
|
||||
<table class="dash-table qwinsta-sessions-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 v-else-if="modal.stdout" class="qwinsta-manual">
|
||||
<p class="muted">
|
||||
Сеансы не распознаны автоматически. Введите ID из вывода qwinsta ниже:
|
||||
</p>
|
||||
<div class="qwinsta-manual-row">
|
||||
<input
|
||||
v-model="manualSessionId"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
class="qwinsta-manual-input"
|
||||
placeholder="ID сеанса"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="danger"
|
||||
:disabled="!manualSessionId.trim() || modal.logoffId !== null"
|
||||
@click="submitManualLogoff"
|
||||
>
|
||||
logoff
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="muted">Нет вывода</p>
|
||||
|
||||
<details v-if="modal.stdout" class="qwinsta-raw-details">
|
||||
<summary>Вывод qwinsta</summary>
|
||||
<pre class="qwinsta-raw">{{ modal.stdout }}</pre>
|
||||
</details>
|
||||
</template>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="secondary" @click="emit('close')">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
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];
|
||||
}>();
|
||||
|
||||
const manualSessionId = ref("");
|
||||
|
||||
function submitManualLogoff() {
|
||||
const id = Number.parseInt(manualSessionId.value.trim(), 10);
|
||||
if (!Number.isFinite(id)) return;
|
||||
emit("logoff", id);
|
||||
}
|
||||
</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-hint {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
color: #e6edf3;
|
||||
}
|
||||
|
||||
.qwinsta-sessions-table {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.qwinsta-manual {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.qwinsta-manual-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.qwinsta-manual-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #3d4f63;
|
||||
background: #0f1419;
|
||||
color: #e6edf3;
|
||||
}
|
||||
|
||||
.qwinsta-raw-details {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.qwinsta-raw-details summary {
|
||||
cursor: pointer;
|
||||
color: #9aa4b2;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.qwinsta-raw {
|
||||
font-size: 0.8rem;
|
||||
max-height: 12rem;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
background: #0d1117;
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="report-card">
|
||||
<div v-if="statEntries.length" class="report-stats">
|
||||
<div v-for="item in statEntries" :key="item.key" class="report-stat">
|
||||
<div class="report-stat-value">{{ item.value }}</div>
|
||||
<div class="report-stat-label">{{ item.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul v-if="activeUserLines.length && !plainBody" class="report-active-users">
|
||||
<li v-for="(line, idx) in activeUserLines" :key="idx">{{ line }}</li>
|
||||
</ul>
|
||||
|
||||
<div v-if="htmlContent" class="report-body-html" v-html="htmlContent" />
|
||||
|
||||
<pre v-else-if="plainBody" class="report-body-plain">{{ plainBody }}</pre>
|
||||
|
||||
<p v-else-if="summary && summary !== 'Отчёт за сутки'" class="report-fallback">{{ summary }}</p>
|
||||
|
||||
<p v-else class="report-empty">
|
||||
Полный текст отчёта не сохранён (старая версия агента). Обновите агент и дождитесь следующего
|
||||
ежедневного отчёта.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import {
|
||||
reportActiveUserLines,
|
||||
normalizeReportPlainText,
|
||||
reportBodyFromDetails,
|
||||
reportHtmlFromDetails,
|
||||
reportStatEntries,
|
||||
reportStatsFromDetails,
|
||||
sanitizeAgentHtml,
|
||||
} from "../utils/reportDisplay";
|
||||
|
||||
const props = defineProps<{
|
||||
type: string;
|
||||
summary: string;
|
||||
details: Record<string, unknown> | null;
|
||||
}>();
|
||||
|
||||
const stats = computed(() => reportStatsFromDetails(props.details, props.type));
|
||||
const statEntries = computed(() => reportStatEntries(stats.value, props.type));
|
||||
const activeUserLines = computed(() => reportActiveUserLines(stats.value, props.type));
|
||||
|
||||
const htmlContent = computed(() => {
|
||||
const raw = reportHtmlFromDetails(props.details);
|
||||
return raw ? sanitizeAgentHtml(raw) : null;
|
||||
});
|
||||
|
||||
const plainBody = computed(() => {
|
||||
const raw = reportBodyFromDetails(props.details);
|
||||
return raw ? normalizeReportPlainText(raw) : null;
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div v-if="visible" class="sidebar-stats" aria-label="Состояние сервера SAC">
|
||||
<p class="sidebar-stats-title">Сервер</p>
|
||||
<div v-if="loading && !stats" class="sidebar-stats-muted">…</div>
|
||||
<template v-else-if="stats">
|
||||
<div v-if="stats.cpu_percent != null" class="sidebar-stat-row">
|
||||
<span class="sidebar-stat-label">CPU</span>
|
||||
<div class="sidebar-stat-bar-wrap">
|
||||
<div
|
||||
class="sidebar-stat-bar"
|
||||
:class="barClass(stats.cpu_percent)"
|
||||
:style="{ width: `${Math.min(100, stats.cpu_percent)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span class="sidebar-stat-value">{{ stats.cpu_percent }}%</span>
|
||||
</div>
|
||||
<div v-if="stats.memory" class="sidebar-stat-row">
|
||||
<span class="sidebar-stat-label">RAM</span>
|
||||
<div class="sidebar-stat-bar-wrap">
|
||||
<div
|
||||
class="sidebar-stat-bar"
|
||||
:class="barClass(stats.memory.percent)"
|
||||
:style="{ width: `${Math.min(100, stats.memory.percent)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span class="sidebar-stat-value">{{ stats.memory.percent }}%</span>
|
||||
</div>
|
||||
<div v-if="stats.disk" class="sidebar-stat-row">
|
||||
<span class="sidebar-stat-label">Диск</span>
|
||||
<div class="sidebar-stat-bar-wrap">
|
||||
<div
|
||||
class="sidebar-stat-bar"
|
||||
:class="barClass(stats.disk.percent)"
|
||||
:style="{ width: `${Math.min(100, stats.disk.percent)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span class="sidebar-stat-value">{{ stats.disk.percent }}%</span>
|
||||
</div>
|
||||
<ul class="sidebar-stats-meta">
|
||||
<li v-if="stats.database.latency_ms != null">
|
||||
БД: {{ stats.database.latency_ms }} ms
|
||||
<span v-if="stats.database.active_connections != null">
|
||||
· {{ stats.database.active_connections }} conn
|
||||
</span>
|
||||
</li>
|
||||
<li>События/ч: {{ stats.app.events_last_hour }}</li>
|
||||
<li>Problems open: {{ stats.app.problems_open }}</li>
|
||||
</ul>
|
||||
</template>
|
||||
<p v-else-if="error" class="sidebar-stats-error" :title="error">нет данных</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from "vue";
|
||||
import { apiFetch } from "../api";
|
||||
|
||||
export interface SystemStats {
|
||||
visible: boolean;
|
||||
collected_at: string;
|
||||
cpu_percent: number | null;
|
||||
memory: { used_mb: number; total_mb: number; percent: number } | null;
|
||||
disk: { path: string; used_gb: number; total_gb: number; percent: number } | null;
|
||||
database: { status: string; latency_ms: number | null; active_connections: number | null };
|
||||
app: { events_last_hour: number; problems_open: number };
|
||||
}
|
||||
|
||||
const POLL_MS = 30_000;
|
||||
|
||||
const visible = ref(false);
|
||||
const stats = ref<SystemStats | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function barClass(percent: number): string {
|
||||
if (percent >= 90) return "is-critical";
|
||||
if (percent >= 70) return "is-warn";
|
||||
return "is-ok";
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await apiFetch<SystemStats>("/api/v1/system/stats");
|
||||
visible.value = data.visible;
|
||||
if (data.visible) {
|
||||
stats.value = data;
|
||||
} else {
|
||||
stats.value = null;
|
||||
}
|
||||
error.value = "";
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Ошибка";
|
||||
visible.value = false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void refresh();
|
||||
timer = setInterval(() => void refresh(), POLL_MS);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
});
|
||||
|
||||
defineExpose({ refresh });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar-stats {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 0.65rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
background: #0f1419;
|
||||
border: 1px solid #2a3441;
|
||||
border-radius: 6px;
|
||||
font-size: 0.72rem;
|
||||
color: #9aa4b2;
|
||||
}
|
||||
|
||||
.sidebar-stats-title {
|
||||
margin: 0 0 0.45rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #7eb8ff;
|
||||
}
|
||||
|
||||
.sidebar-stat-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2rem 1fr 2.1rem;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.sidebar-stat-label {
|
||||
color: #c5d0dc;
|
||||
}
|
||||
|
||||
.sidebar-stat-bar-wrap {
|
||||
height: 0.35rem;
|
||||
background: #1a2332;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-stat-bar {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.25s ease;
|
||||
}
|
||||
|
||||
.sidebar-stat-bar.is-ok {
|
||||
background: #6bcb77;
|
||||
}
|
||||
|
||||
.sidebar-stat-bar.is-warn {
|
||||
background: #ffc857;
|
||||
}
|
||||
|
||||
.sidebar-stat-bar.is-critical {
|
||||
background: #ff6b6b;
|
||||
}
|
||||
|
||||
.sidebar-stat-value {
|
||||
text-align: right;
|
||||
color: #e8eaed;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sidebar-stats-meta {
|
||||
list-style: none;
|
||||
margin: 0.45rem 0 0;
|
||||
padding: 0;
|
||||
line-height: 1.45;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.sidebar-stats-muted,
|
||||
.sidebar-stats-error {
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,329 @@
|
||||
import { reactive } from "vue";
|
||||
import {
|
||||
ApiError,
|
||||
fetchHost,
|
||||
fetchHostRemoteJob,
|
||||
runHostAgentUpdateFallback,
|
||||
updateHostAgentViaSsh,
|
||||
type HostDetail,
|
||||
type HostRemoteActionJobStatus,
|
||||
} from "../api";
|
||||
import { emitHostPatch } from "../utils/hostPatchBus";
|
||||
|
||||
export type HostRemoteActionKind = "fallback" | "ssh-update";
|
||||
|
||||
export type HostRemoteActionLogEntry = {
|
||||
id: string;
|
||||
hostId: number;
|
||||
open: boolean;
|
||||
logVisible: boolean;
|
||||
loading: boolean;
|
||||
ok: boolean | null;
|
||||
title: string;
|
||||
message: string;
|
||||
output: string;
|
||||
/** Текст в окне лога, пока output с сервера ещё пустой */
|
||||
logPlaceholder: string;
|
||||
/** ISO-время открытия окна — отсекаем stale success от прошлого job */
|
||||
sessionStartedAt: string;
|
||||
};
|
||||
|
||||
export const hostRemoteActionLogs = reactive<HostRemoteActionLogEntry[]>([]);
|
||||
|
||||
const POLL_MS = 1500;
|
||||
const SUCCESS_AUTO_CLOSE_MS = 30_000;
|
||||
|
||||
let nextLogSeq = 1;
|
||||
const autoCloseTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const hostRunningPromises = new Map<number, Promise<HostRemoteActionJobStatus | null>>();
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function newLogId(): string {
|
||||
return `ra-${Date.now()}-${nextLogSeq++}`;
|
||||
}
|
||||
|
||||
function findLogById(entryId: string): HostRemoteActionLogEntry | undefined {
|
||||
return hostRemoteActionLogs.find((e) => e.id === entryId);
|
||||
}
|
||||
|
||||
function findLoadingLogForHost(hostId: number): HostRemoteActionLogEntry | undefined {
|
||||
return hostRemoteActionLogs.find((e) => e.hostId === hostId && e.loading);
|
||||
}
|
||||
|
||||
function clearAutoCloseTimer(entryId: string) {
|
||||
const timer = autoCloseTimers.get(entryId);
|
||||
if (timer != null) {
|
||||
clearTimeout(timer);
|
||||
autoCloseTimers.delete(entryId);
|
||||
}
|
||||
}
|
||||
|
||||
function removeClosedFinishedLog(entryId: string) {
|
||||
const idx = hostRemoteActionLogs.findIndex((e) => e.id === entryId);
|
||||
if (idx < 0) return;
|
||||
const entry = hostRemoteActionLogs[idx];
|
||||
if (!entry.loading && !entry.open) {
|
||||
hostRemoteActionLogs.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSuccessAutoClose(entryId: string) {
|
||||
clearAutoCloseTimer(entryId);
|
||||
autoCloseTimers.set(
|
||||
entryId,
|
||||
setTimeout(() => {
|
||||
const entry = findLogById(entryId);
|
||||
if (entry) {
|
||||
entry.open = false;
|
||||
removeClosedFinishedLog(entryId);
|
||||
}
|
||||
autoCloseTimers.delete(entryId);
|
||||
}, SUCCESS_AUTO_CLOSE_MS),
|
||||
);
|
||||
}
|
||||
|
||||
function applyJobToEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobStatus) {
|
||||
if (job.title) {
|
||||
entry.title = job.title;
|
||||
}
|
||||
if (job.message) {
|
||||
entry.message = job.message;
|
||||
}
|
||||
const nextOutput = (job.output || job.stdout || "").trim();
|
||||
if (nextOutput) {
|
||||
entry.output = job.output || job.stdout || "";
|
||||
}
|
||||
}
|
||||
|
||||
function finishEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobStatus) {
|
||||
if (!entry.loading && entry.ok != null) {
|
||||
return;
|
||||
}
|
||||
entry.loading = false;
|
||||
entry.ok = job.ok ?? job.status === "success";
|
||||
applyJobToEntry(entry, job);
|
||||
entry.open = true;
|
||||
if (entry.ok) {
|
||||
scheduleSuccessAutoClose(entry.id);
|
||||
}
|
||||
}
|
||||
|
||||
export function syncHostRemoteActionJobFinished(
|
||||
entryId: string,
|
||||
job: HostRemoteActionJobStatus,
|
||||
): void {
|
||||
const entry = findLogById(entryId);
|
||||
if (!entry) return;
|
||||
finishEntry(entry, job);
|
||||
void patchHostFromJob(entry.hostId, job);
|
||||
}
|
||||
|
||||
async function pollRemoteJob(hostId: number, entry: HostRemoteActionLogEntry): Promise<HostRemoteActionJobStatus> {
|
||||
for (;;) {
|
||||
const job = await fetchHostRemoteJob(hostId);
|
||||
applyJobToEntry(entry, job);
|
||||
if (!job.active && job.status !== "running") {
|
||||
return job;
|
||||
}
|
||||
await sleep(POLL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
async function patchHostFromJob(hostId: number, job: HostRemoteActionJobStatus) {
|
||||
try {
|
||||
const detail: HostDetail = await fetchHost(hostId);
|
||||
if (job.product_version) {
|
||||
detail.product_version = job.product_version;
|
||||
}
|
||||
if (job.agent_update_state) {
|
||||
detail.agent_update_state = job.agent_update_state;
|
||||
}
|
||||
if (job.ok === false && job.message) {
|
||||
detail.agent_update_last_error = job.message;
|
||||
}
|
||||
emitHostPatch(detail);
|
||||
} catch {
|
||||
/* list refresh on next open is enough */
|
||||
}
|
||||
}
|
||||
|
||||
async function startRemoteAction(hostId: number, kind: HostRemoteActionKind) {
|
||||
if (kind === "fallback") {
|
||||
await runHostAgentUpdateFallback(hostId);
|
||||
return;
|
||||
}
|
||||
await updateHostAgentViaSsh(hostId);
|
||||
}
|
||||
|
||||
async function executeRemoteAction(
|
||||
hostId: number,
|
||||
title: string,
|
||||
kind: HostRemoteActionKind,
|
||||
entry: HostRemoteActionLogEntry,
|
||||
): Promise<HostRemoteActionJobStatus | null> {
|
||||
try {
|
||||
try {
|
||||
await startRemoteAction(hostId, kind);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 409) {
|
||||
/* already running — poll existing job */
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
entry.message = "Выполняется на удалённом хосте… Можно запустить обновление других хостов.";
|
||||
const job = await pollRemoteJob(hostId, entry);
|
||||
finishEntry(entry, job);
|
||||
await patchHostFromJob(hostId, job);
|
||||
return job;
|
||||
} catch (e) {
|
||||
entry.loading = false;
|
||||
entry.ok = false;
|
||||
entry.message = e instanceof Error ? e.message : "Ошибка выполнения";
|
||||
entry.open = true;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function pollHostRemoteAction(
|
||||
hostId: number,
|
||||
title: string,
|
||||
logPlaceholder?: string,
|
||||
): Promise<HostRemoteActionJobStatus | null> {
|
||||
const existingPromise = hostRunningPromises.get(hostId);
|
||||
if (existingPromise) {
|
||||
const existing = findLoadingLogForHost(hostId);
|
||||
if (existing) {
|
||||
existing.open = true;
|
||||
}
|
||||
return existingPromise;
|
||||
}
|
||||
|
||||
const entry: HostRemoteActionLogEntry = {
|
||||
id: newLogId(),
|
||||
hostId,
|
||||
open: true,
|
||||
logVisible: false,
|
||||
loading: true,
|
||||
ok: null,
|
||||
title,
|
||||
message: "Выполняется на удалённом хосте…",
|
||||
output: "",
|
||||
logPlaceholder: logPlaceholder || "Ожидание лога с хоста…",
|
||||
sessionStartedAt: new Date().toISOString(),
|
||||
};
|
||||
hostRemoteActionLogs.push(entry);
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const job = await pollRemoteJob(hostId, entry);
|
||||
finishEntry(entry, job);
|
||||
await patchHostFromJob(hostId, job);
|
||||
return job;
|
||||
} catch (e) {
|
||||
entry.loading = false;
|
||||
entry.ok = false;
|
||||
entry.message = e instanceof Error ? e.message : "Ошибка выполнения";
|
||||
entry.open = true;
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
hostRunningPromises.set(hostId, promise);
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
hostRunningPromises.delete(hostId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runHostRemoteAction(
|
||||
hostId: number,
|
||||
title: string,
|
||||
kind: HostRemoteActionKind,
|
||||
logPlaceholder?: string,
|
||||
): Promise<HostRemoteActionJobStatus | null> {
|
||||
const existingPromise = hostRunningPromises.get(hostId);
|
||||
if (existingPromise) {
|
||||
const existing = findLoadingLogForHost(hostId);
|
||||
if (existing) {
|
||||
existing.open = true;
|
||||
}
|
||||
return existingPromise;
|
||||
}
|
||||
|
||||
const entry: HostRemoteActionLogEntry = {
|
||||
id: newLogId(),
|
||||
hostId,
|
||||
open: true,
|
||||
logVisible: false,
|
||||
loading: true,
|
||||
ok: null,
|
||||
title,
|
||||
message: "Запуск на сервере SAC…",
|
||||
output: "",
|
||||
logPlaceholder: logPlaceholder || "Ожидание лога с хоста…",
|
||||
sessionStartedAt: new Date().toISOString(),
|
||||
};
|
||||
hostRemoteActionLogs.push(entry);
|
||||
|
||||
const promise = executeRemoteAction(hostId, title, kind, entry);
|
||||
hostRunningPromises.set(hostId, promise);
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
hostRunningPromises.delete(hostId);
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleHostRemoteActionLog(entryId: string) {
|
||||
const entry = findLogById(entryId);
|
||||
if (!entry) return;
|
||||
entry.logVisible = !entry.logVisible;
|
||||
entry.open = true;
|
||||
}
|
||||
|
||||
export function closeHostRemoteActionLog(entryId: string) {
|
||||
clearAutoCloseTimer(entryId);
|
||||
const entry = findLogById(entryId);
|
||||
if (!entry) return;
|
||||
entry.open = false;
|
||||
if (!entry.loading) {
|
||||
removeClosedFinishedLog(entryId);
|
||||
}
|
||||
}
|
||||
|
||||
export function showHostRemoteActionLog(hostId?: number) {
|
||||
if (hostId != null) {
|
||||
for (const entry of hostRemoteActionLogs) {
|
||||
if (entry.hostId === hostId && (entry.loading || entry.ok != null)) {
|
||||
entry.open = true;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const entry of hostRemoteActionLogs) {
|
||||
if (entry.loading || entry.ok === false) {
|
||||
entry.open = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isHostRemoteActionActive(hostId: number): boolean {
|
||||
return hostRemoteActionLogs.some((e) => e.hostId === hostId && e.loading);
|
||||
}
|
||||
|
||||
export function hasActiveHostRemoteAction(): boolean {
|
||||
return hostRemoteActionLogs.some((e) => e.loading);
|
||||
}
|
||||
|
||||
export function hasOpenRemoteActionLogForHost(hostId: number): boolean {
|
||||
return hostRemoteActionLogs.some((e) => e.hostId === hostId && e.open);
|
||||
}
|
||||
|
||||
export function anyRemoteActionLogOpen(): boolean {
|
||||
return hostRemoteActionLogs.some((e) => e.open);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { onUnmounted, ref } from "vue";
|
||||
import {
|
||||
fetchEventAction,
|
||||
postEventLogoff,
|
||||
postEventQwinsta,
|
||||
type AgentCommandResponse,
|
||||
type EventSummary,
|
||||
} from "../api";
|
||||
import { rdgQwinstaEventId } from "../utils/rdgFlap";
|
||||
|
||||
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].replace(/^>/, "");
|
||||
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].replace(/^>/, ""),
|
||||
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) {
|
||||
const targetId = rdgQwinstaEventId(event) ?? event.id;
|
||||
qwinstaLoadingId.value = event.id;
|
||||
qwinstaModal.value = {
|
||||
open: true,
|
||||
eventId: targetId,
|
||||
actorUser: event.actor_user || "",
|
||||
commandUuid: "",
|
||||
target: "",
|
||||
clientHostname: "",
|
||||
internalIp: "",
|
||||
loading: true,
|
||||
error: "",
|
||||
stdout: "",
|
||||
sessions: [],
|
||||
logoffId: null,
|
||||
};
|
||||
try {
|
||||
const cmd = await postEventQwinsta(targetId);
|
||||
applyCommandMeta(qwinstaModal.value, cmd);
|
||||
qwinstaModal.value.commandUuid = cmd.command_uuid;
|
||||
if (applyCommandResult(targetId, event.actor_user || "", cmd)) {
|
||||
return;
|
||||
}
|
||||
pollQwinstaCommand(targetId, 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { onMounted, onUnmounted, ref } from "vue";
|
||||
import { getToken } from "../api";
|
||||
|
||||
export interface SacDashboardStreamMessage {
|
||||
type?: string;
|
||||
last_event_id?: number | null;
|
||||
}
|
||||
|
||||
/** SSE /api/v1/stream/events — вызывает onNewEvent при новом last_event_id (после первого тика). */
|
||||
export function useSacLiveEventStream(onNewEvent: () => void) {
|
||||
const live = ref(false);
|
||||
let eventSource: EventSource | null = null;
|
||||
let lastKnownEventDbId: number | null = null;
|
||||
|
||||
function handleStreamMessage(msg: SacDashboardStreamMessage) {
|
||||
if (msg.type !== "dashboard") return;
|
||||
const incoming = msg.last_event_id;
|
||||
if (incoming === undefined) return;
|
||||
|
||||
if (incoming === null) {
|
||||
if (lastKnownEventDbId !== null) {
|
||||
onNewEvent();
|
||||
}
|
||||
lastKnownEventDbId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastKnownEventDbId === null) {
|
||||
lastKnownEventDbId = incoming;
|
||||
return;
|
||||
}
|
||||
|
||||
if (incoming !== lastKnownEventDbId) {
|
||||
lastKnownEventDbId = incoming;
|
||||
onNewEvent();
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const token = getToken();
|
||||
if (!token) return;
|
||||
|
||||
eventSource?.close();
|
||||
const url = `/api/v1/stream/events`;
|
||||
eventSource = new EventSource(url);
|
||||
|
||||
eventSource.onopen = () => {
|
||||
live.value = true;
|
||||
};
|
||||
|
||||
eventSource.onmessage = (ev) => {
|
||||
try {
|
||||
handleStreamMessage(JSON.parse(ev.data) as SacDashboardStreamMessage);
|
||||
} catch {
|
||||
/* ignore malformed SSE */
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
live.value = false;
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => connect());
|
||||
|
||||
onUnmounted(() => {
|
||||
eventSource?.close();
|
||||
eventSource = null;
|
||||
live.value = false;
|
||||
});
|
||||
|
||||
return { live };
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { onMounted, ref } from "vue";
|
||||
import { APP_NAME, APP_VERSION } from "../version";
|
||||
|
||||
export interface SacVersionInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
let cached: SacVersionInfo | null = null;
|
||||
let inflight: Promise<SacVersionInfo> | null = null;
|
||||
|
||||
async function fetchSacVersion(): Promise<SacVersionInfo> {
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
if (inflight) {
|
||||
return inflight;
|
||||
}
|
||||
|
||||
inflight = (async () => {
|
||||
const fallback: SacVersionInfo = { name: APP_NAME, version: APP_VERSION };
|
||||
try {
|
||||
const response = await fetch("/health");
|
||||
if (!response.ok) {
|
||||
return fallback;
|
||||
}
|
||||
const data = (await response.json()) as { name?: string; version?: string };
|
||||
const info: SacVersionInfo = {
|
||||
name: (data.name || APP_NAME).trim() || APP_NAME,
|
||||
version: (data.version || APP_VERSION).trim() || APP_VERSION,
|
||||
};
|
||||
cached = info;
|
||||
return info;
|
||||
} catch {
|
||||
return fallback;
|
||||
} finally {
|
||||
inflight = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return inflight;
|
||||
}
|
||||
|
||||
/** Версия SAC с бэкенда (/health); fallback — frontend/src/version.ts */
|
||||
export function useSacVersion() {
|
||||
const appName = ref(APP_NAME);
|
||||
const appVersion = ref(APP_VERSION);
|
||||
|
||||
onMounted(() => {
|
||||
void fetchSacVersion().then((info) => {
|
||||
appName.value = info.name;
|
||||
appVersion.value = info.version;
|
||||
});
|
||||
});
|
||||
|
||||
return { appName, appVersion };
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export interface ErrorPageAction {
|
||||
label: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface ErrorPageConfig {
|
||||
code: number;
|
||||
title: string;
|
||||
lead: string;
|
||||
hint: string;
|
||||
primaryAction: ErrorPageAction;
|
||||
secondaryAction?: ErrorPageAction;
|
||||
}
|
||||
|
||||
export const ERROR_PAGES: Record<401 | 403 | 404 | 429, ErrorPageConfig> = {
|
||||
401: {
|
||||
code: 401,
|
||||
title: "Требуется авторизация",
|
||||
lead: "Сессия истекла, токен недействителен или вы ещё не вошли в SAC.",
|
||||
hint: "Можно: войти снова под своим логином и паролем. Нельзя: просматривать события, хосты и настройки без входа.",
|
||||
primaryAction: { label: "Войти", to: "/login" },
|
||||
},
|
||||
403: {
|
||||
code: 403,
|
||||
title: "Доступ запрещён",
|
||||
lead: "У вашей учётной записи нет прав на этот раздел или действие.",
|
||||
hint: "Можно: работать с событиями, проблемами, хостами и отчётами (роль monitor). Нельзя: настройки уведомлений, пользователи и удаление хостов — только для admin.",
|
||||
primaryAction: { label: "На обзор", to: "/dashboard" },
|
||||
secondaryAction: { label: "К событиям", to: "/events" },
|
||||
},
|
||||
404: {
|
||||
code: 404,
|
||||
title: "Страница не найдена",
|
||||
lead: "Такого адреса в интерфейсе SAC нет — ссылка устарела или опечатка в URL.",
|
||||
hint: "Можно: перейти на обзор или воспользоваться меню слева. Нельзя: открыть несуществующий маршрут — сервер отдаёт только известные страницы SPA.",
|
||||
primaryAction: { label: "На обзор", to: "/dashboard" },
|
||||
secondaryAction: { label: "К событиям", to: "/events" },
|
||||
},
|
||||
429: {
|
||||
code: 429,
|
||||
title: "Слишком много попыток входа",
|
||||
lead: "С вашего IP зафиксировано несколько неудачных попыток входа подряд.",
|
||||
hint: "Можно: подождать около 15 минут и войти с правильным паролем. Нельзя: продолжать подбор пароля — IP временно блокируется, администратор может получить алерт в Telegram.",
|
||||
primaryAction: { label: "Попробовать позже", to: "/login" },
|
||||
},
|
||||
};
|
||||
|
||||
export function getErrorPageConfig(code: number): ErrorPageConfig {
|
||||
if (code === 401 || code === 403 || code === 404 || code === 429) {
|
||||
return ERROR_PAGES[code];
|
||||
}
|
||||
return ERROR_PAGES[404];
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import "./style.css";
|
||||
|
||||
createApp(App).use(router).mount("#app");
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import { getToken, isAdmin, setRole, fetchMe } from "./api";
|
||||
import EventsView from "./views/EventsView.vue";
|
||||
import EventDetailView from "./views/EventDetailView.vue";
|
||||
import HostsView from "./views/HostsView.vue";
|
||||
import HostDetailView from "./views/HostDetailView.vue";
|
||||
import LoginView from "./views/LoginView.vue";
|
||||
import ErrorPageView from "./views/ErrorPageView.vue";
|
||||
import ProblemsView from "./views/ProblemsView.vue";
|
||||
import ProblemDetailView from "./views/ProblemDetailView.vue";
|
||||
import DashboardView from "./views/DashboardView.vue";
|
||||
import ReportsView from "./views/ReportsView.vue";
|
||||
import SettingsView from "./views/SettingsView.vue";
|
||||
import UsersView from "./views/UsersView.vue";
|
||||
|
||||
const PUBLIC_PATHS = new Set(["/login", "/401", "/403", "/404", "/429"]);
|
||||
|
||||
const errorRoute = (code: 401 | 403 | 404 | 429) => ({
|
||||
path: `/${code}`,
|
||||
component: ErrorPageView,
|
||||
props: { code },
|
||||
meta: { standalone: true },
|
||||
});
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: "/", redirect: "/dashboard" },
|
||||
{ path: "/dashboard", component: DashboardView },
|
||||
{ path: "/login", component: LoginView },
|
||||
errorRoute(401),
|
||||
errorRoute(403),
|
||||
errorRoute(404),
|
||||
errorRoute(429),
|
||||
{ path: "/events", component: EventsView },
|
||||
{ path: "/events/:id", component: EventDetailView, props: true },
|
||||
{ path: "/reports", component: ReportsView },
|
||||
{ path: "/hosts", component: HostsView },
|
||||
{ path: "/hosts/:id", component: HostDetailView, props: true },
|
||||
{ path: "/problems", component: ProblemsView },
|
||||
{ path: "/problems/:id", component: ProblemDetailView, props: true },
|
||||
{ path: "/settings", component: SettingsView, meta: { adminOnly: true } },
|
||||
{ path: "/users", component: UsersView, meta: { adminOnly: true } },
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
component: ErrorPageView,
|
||||
props: { code: 404 },
|
||||
meta: { standalone: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
if (PUBLIC_PATHS.has(to.path)) return true;
|
||||
if (!getToken()) return { path: "/login", query: { redirect: to.fullPath } };
|
||||
if (to.meta.adminOnly && !isAdmin()) {
|
||||
return { path: "/403" };
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
export async function refreshSessionRole(): Promise<void> {
|
||||
if (!getToken()) return;
|
||||
try {
|
||||
const me = await fetchMe();
|
||||
setRole(me.role);
|
||||
} catch {
|
||||
/* 401 handled in apiFetch */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
:root {
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
line-height: 1.5;
|
||||
color: #e8eaed;
|
||||
background: #0f1419;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #7eb8ff;
|
||||
}
|
||||
|
||||
/* Клик по строке хоста — как пункты левого меню (полоска слева на всю строку). */
|
||||
.sac-host-row {
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
|
||||
.sac-host-row:hover,
|
||||
.sac-host-row:focus-visible {
|
||||
background: #1e2d3d;
|
||||
box-shadow: inset 3px 0 0 #3db8ff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sac-host-row td.actions {
|
||||
cursor: default;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.sac-host-row:hover td.actions,
|
||||
.sac-host-row:focus-visible td.actions {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.layout {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 1rem 1rem 2rem;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 1rem 1.25rem 2rem;
|
||||
}
|
||||
|
||||
.layout-login {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-bottom: 1px solid #2a3441;
|
||||
}
|
||||
|
||||
th {
|
||||
color: #9aa4b2;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.th-sort {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sev-critical,
|
||||
.sev-high {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
.sev-warning {
|
||||
color: #ffc857;
|
||||
}
|
||||
.sev-info {
|
||||
color: #7eb8ff;
|
||||
}
|
||||
|
||||
.status-open {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
.status-acknowledged {
|
||||
color: #ffc857;
|
||||
}
|
||||
.status-resolved {
|
||||
color: #6bcb77;
|
||||
}
|
||||
|
||||
td.actions {
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td.actions .actions-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.actions-muted {
|
||||
color: #9aa4b2;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
p.actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #1a2332;
|
||||
border: 1px solid #2a3441;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.timeline-table td:nth-child(5) {
|
||||
max-width: 28rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
button {
|
||||
font: inherit;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #3d4f63;
|
||||
background: #0f1419;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
border-color: #3d4f63;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: transparent;
|
||||
border-color: #b91c1c;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
button.danger:hover:not(:disabled) {
|
||||
background: #3f1515;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #3fb950;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow: auto;
|
||||
font-size: 0.8rem;
|
||||
background: #0f1419;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.dashboard-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.dashboard-panels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.dash-panel {
|
||||
background: #1a2332;
|
||||
border: 1px solid #2a3441;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.dash-panel h2 {
|
||||
margin-top: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.dash-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dash-table td:last-child,
|
||||
.dash-table th:last-child {
|
||||
text-align: right;
|
||||
width: 5rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #9aa4b2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dash-card {
|
||||
background: #1a2332;
|
||||
border: 1px solid #2a3441;
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.dash-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dash-label {
|
||||
color: #9aa4b2;
|
||||
margin: 0.25rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.dash-card.dash-warn {
|
||||
border-color: #ff6b6b;
|
||||
}
|
||||
|
||||
.severity-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.agent-online {
|
||||
color: #6bcb77;
|
||||
}
|
||||
.agent-stale {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
.agent-unknown {
|
||||
color: #9aa4b2;
|
||||
}
|
||||
|
||||
.agent-version-outdated {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
background: #5c1a1a;
|
||||
color: #ffd4d4;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.live-badge {
|
||||
color: #6bcb77;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.report-intro {
|
||||
color: #9aa4b2;
|
||||
margin-top: -0.5rem;
|
||||
}
|
||||
|
||||
.reports-table .report-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reports-table .report-row:hover,
|
||||
.reports-table .report-row:focus {
|
||||
background: #1a2332;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.report-summary-cell {
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.report-card {
|
||||
background: #1a2332;
|
||||
border: 1px solid #2a3441;
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.report-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.report-stat {
|
||||
background: #0f1419;
|
||||
border: 1px solid #2a3441;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.report-stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.report-stat-label {
|
||||
font-size: 0.8rem;
|
||||
color: #9aa4b2;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.report-active-users {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.report-active-users li {
|
||||
padding: 0.2rem 0;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.report-body-html {
|
||||
line-height: 1.55;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.report-body-html .agent-report {
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.report-body-html .agent-report br {
|
||||
display: block;
|
||||
margin-bottom: 0.25em;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.report-body-plain {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
background: #0f1419;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #2a3441;
|
||||
}
|
||||
|
||||
.report-empty,
|
||||
.report-fallback {
|
||||
color: #9aa4b2;
|
||||
}
|
||||
|
||||
.report-summary-line {
|
||||
color: #9aa4b2;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.report-meta {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.detail-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 0.85rem 1.25rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.detail-fields-compact {
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
.detail-field {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-field-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.detail-field dt {
|
||||
margin: 0 0 0.2rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #9aa4b2;
|
||||
}
|
||||
|
||||
.detail-field dd {
|
||||
margin: 0;
|
||||
color: #e8edf2;
|
||||
word-break: break-word;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.detail-field dd code {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.raw-details {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.raw-details summary {
|
||||
cursor: pointer;
|
||||
color: #9aa4b2;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
const VERSION_RE = /^(\d+)\.(\d+)\.(\d+)(?:-SAC)?$/i;
|
||||
const DEFAULT_LAG_THRESHOLD = 3;
|
||||
|
||||
export interface ParsedAgentVersion {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
}
|
||||
|
||||
export function parseAgentVersion(raw: string | null | undefined): ParsedAgentVersion | null {
|
||||
if (!raw) return null;
|
||||
const match = raw.trim().match(VERSION_RE);
|
||||
if (!match) return null;
|
||||
return {
|
||||
major: Number(match[1]),
|
||||
minor: Number(match[2]),
|
||||
patch: Number(match[3]),
|
||||
};
|
||||
}
|
||||
|
||||
function compareVersions(a: ParsedAgentVersion, b: ParsedAgentVersion): number {
|
||||
if (a.major !== b.major) return a.major - b.major;
|
||||
if (a.minor !== b.minor) return a.minor - b.minor;
|
||||
return a.patch - b.patch;
|
||||
}
|
||||
|
||||
function agentVersionLag(host: ParsedAgentVersion, latest: ParsedAgentVersion): number | null {
|
||||
if (compareVersions(host, latest) > 0) return 0;
|
||||
if (host.major !== latest.major || host.minor !== latest.minor) return null;
|
||||
return latest.patch - host.patch;
|
||||
}
|
||||
|
||||
export function isAgentVersionOutdated(
|
||||
hostVersion: string | null | undefined,
|
||||
latestVersion: string | null | undefined,
|
||||
lagThreshold = DEFAULT_LAG_THRESHOLD,
|
||||
): boolean {
|
||||
const host = parseAgentVersion(hostVersion);
|
||||
const latest = parseAgentVersion(latestVersion);
|
||||
if (!host || !latest || compareVersions(host, latest) >= 0) return false;
|
||||
const lag = agentVersionLag(host, latest);
|
||||
if (lag === null) return true;
|
||||
return lag >= lagThreshold;
|
||||
}
|
||||
|
||||
export function isAgentVersionNewer(
|
||||
candidate: string | null | undefined,
|
||||
current: string | null | undefined,
|
||||
): boolean {
|
||||
const next = parseAgentVersion(candidate);
|
||||
const prev = parseAgentVersion(current);
|
||||
if (!next) return false;
|
||||
if (!prev) return true;
|
||||
return compareVersions(next, prev) > 0;
|
||||
}
|
||||
|
||||
/** Хост строго младше целевой версии (например из git), без порога lag. */
|
||||
export function isAgentVersionBehind(
|
||||
hostVersion: string | null | undefined,
|
||||
targetVersion: string | null | undefined,
|
||||
): boolean {
|
||||
const host = parseAgentVersion(hostVersion);
|
||||
const target = parseAgentVersion(targetVersion);
|
||||
if (!host || !target) return false;
|
||||
return compareVersions(host, target) < 0;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/** Query params for /events list — preserved when opening event detail and going back. */
|
||||
|
||||
import type { LocationQuery } from "vue-router";
|
||||
|
||||
export interface EventsListState {
|
||||
page: number;
|
||||
q: string;
|
||||
severity: string;
|
||||
type: string;
|
||||
hostname: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
}
|
||||
|
||||
const LIST_KEYS = ["page", "q", "severity", "type", "hostname", "from", "to"] as const;
|
||||
|
||||
function queryString(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (Array.isArray(value) && typeof value[0] === "string") return value[0];
|
||||
return "";
|
||||
}
|
||||
|
||||
export function parseEventsListQuery(query: LocationQuery): EventsListState {
|
||||
const pageRaw = parseInt(queryString(query.page), 10);
|
||||
return {
|
||||
page: Number.isFinite(pageRaw) && pageRaw > 0 ? pageRaw : 1,
|
||||
q: queryString(query.q),
|
||||
severity: queryString(query.severity),
|
||||
type: queryString(query.type),
|
||||
hostname: queryString(query.hostname),
|
||||
dateFrom: queryString(query.from),
|
||||
dateTo: queryString(query.to),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildEventsListQuery(state: EventsListState): Record<string, string> {
|
||||
const q: Record<string, string> = {};
|
||||
if (state.page > 1) q.page = String(state.page);
|
||||
if (state.q.trim()) q.q = state.q.trim();
|
||||
if (state.severity) q.severity = state.severity;
|
||||
if (state.type.trim()) q.type = state.type.trim();
|
||||
if (state.hostname.trim()) q.hostname = state.hostname.trim();
|
||||
if (state.dateFrom) q.from = state.dateFrom;
|
||||
if (state.dateTo) q.to = state.dateTo;
|
||||
return q;
|
||||
}
|
||||
|
||||
/** Subset of detail route query to restore the events list (excludes `from=reports`). */
|
||||
export function eventsBackQueryFromDetail(query: LocationQuery): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const key of LIST_KEYS) {
|
||||
const v = queryString(query[key]);
|
||||
if (v) out[key] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function apiParamsFromState(state: EventsListState, pageSize: number): URLSearchParams {
|
||||
const params = new URLSearchParams({
|
||||
page: String(state.page),
|
||||
page_size: String(pageSize),
|
||||
});
|
||||
if (state.severity) params.set("severity", state.severity);
|
||||
if (state.type.trim()) params.set("type", state.type.trim());
|
||||
if (state.hostname.trim()) params.set("hostname", state.hostname.trim());
|
||||
if (state.q.trim()) params.set("q", state.q.trim());
|
||||
if (state.dateFrom) params.set("from", state.dateFrom);
|
||||
if (state.dateTo) params.set("to", state.dateTo);
|
||||
return params;
|
||||
}
|
||||
|
||||
export function eventsListQueryMatches(state: EventsListState, query: LocationQuery): boolean {
|
||||
const built = buildEventsListQuery(state);
|
||||
const fromRoute = buildEventsListQuery(parseEventsListQuery(query));
|
||||
const keys = new Set([...Object.keys(built), ...Object.keys(fromRoute)]);
|
||||
for (const key of keys) {
|
||||
if ((built[key] ?? "") !== (fromRoute[key] ?? "")) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { HostSummary } from "../api";
|
||||
import type { HostRemoteActionKind } from "../composables/useHostRemoteAction";
|
||||
import { isAgentVersionBehind } from "./agentVersion";
|
||||
|
||||
export const PRODUCT_RDP = "rdp-login-monitor";
|
||||
export const PRODUCT_SSH = "ssh-monitor";
|
||||
|
||||
export interface AgentGitVersions {
|
||||
git_latest_rdp_version?: string | null;
|
||||
git_latest_ssh_version?: string | null;
|
||||
}
|
||||
|
||||
export function isWindowsAgentHost(host: Pick<HostSummary, "os_family" | "product">): boolean {
|
||||
if ((host.os_family || "").toLowerCase() === "windows") return true;
|
||||
return host.product === PRODUCT_RDP;
|
||||
}
|
||||
|
||||
export function isLinuxAgentHost(host: Pick<HostSummary, "os_family" | "product">): boolean {
|
||||
if ((host.os_family || "").toLowerCase() === "linux") return true;
|
||||
return host.product === PRODUCT_SSH;
|
||||
}
|
||||
|
||||
export function gitTargetVersionForHost(
|
||||
host: Pick<HostSummary, "os_family" | "product">,
|
||||
git: AgentGitVersions | null | undefined,
|
||||
): string | null {
|
||||
if (!git) return null;
|
||||
if (isWindowsAgentHost(host)) {
|
||||
return git.git_latest_rdp_version?.trim() || null;
|
||||
}
|
||||
if (isLinuxAgentHost(host)) {
|
||||
return git.git_latest_ssh_version?.trim() || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isGitAgentUpgradeAvailable(
|
||||
host: Pick<HostSummary, "os_family" | "product" | "product_version">,
|
||||
git: AgentGitVersions | null | undefined,
|
||||
): boolean {
|
||||
const target = gitTargetVersionForHost(host, git);
|
||||
if (!target || !host.product_version?.trim()) return false;
|
||||
return isAgentVersionBehind(host.product_version, target);
|
||||
}
|
||||
|
||||
export function remoteActionKindForHost(
|
||||
host: Pick<HostSummary, "os_family" | "product">,
|
||||
): HostRemoteActionKind | null {
|
||||
if (isWindowsAgentHost(host)) return "fallback";
|
||||
if (isLinuxAgentHost(host)) return "ssh-update";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function remoteActionTitleForHost(host: HostSummary): string {
|
||||
const label = host.display_name || host.hostname;
|
||||
if (isWindowsAgentHost(host)) {
|
||||
return `Обновление ${label} через WinRM`;
|
||||
}
|
||||
return `Обновление ssh-monitor (SSH): ${label}`;
|
||||
}
|
||||
|
||||
const LOG_PLACEHOLDER_SSH_UPDATE =
|
||||
"Ожидание лога с хоста…\n(обновление /var/log/update_script.log)";
|
||||
|
||||
const LOG_PLACEHOLDER_WINRM =
|
||||
"Ожидание вывода с хоста…\n(WinRM: Deploy-LoginMonitor.ps1)";
|
||||
|
||||
const LOG_PLACEHOLDER_GENERIC = "Ожидание лога с хоста…";
|
||||
|
||||
export function remoteActionLogPlaceholder(
|
||||
host: Pick<HostSummary, "os_family" | "product"> | null | undefined,
|
||||
kind: HostRemoteActionKind | null,
|
||||
): string {
|
||||
if (kind === "ssh-update") {
|
||||
return LOG_PLACEHOLDER_SSH_UPDATE;
|
||||
}
|
||||
if (host && isWindowsAgentHost(host)) {
|
||||
return LOG_PLACEHOLDER_WINRM;
|
||||
}
|
||||
if (kind === "fallback") {
|
||||
return LOG_PLACEHOLDER_SSH_UPDATE;
|
||||
}
|
||||
return LOG_PLACEHOLDER_GENERIC;
|
||||
}
|
||||
|
||||
export function remoteActionLogPlaceholderFromTitle(title: string): string {
|
||||
if (/winrm/i.test(title)) {
|
||||
return LOG_PLACEHOLDER_WINRM;
|
||||
}
|
||||
if (/ssh-monitor|\/var\/log\/update_script/i.test(title)) {
|
||||
return LOG_PLACEHOLDER_SSH_UPDATE;
|
||||
}
|
||||
return LOG_PLACEHOLDER_GENERIC;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { EventListResponse, EventSummary, HostDetail } from "../api";
|
||||
import { isAgentVersionBehind } from "./agentVersion";
|
||||
import type { AgentGitVersions } from "./hostAgentUpgrade";
|
||||
import { gitTargetVersionForHost } from "./hostAgentUpgrade";
|
||||
|
||||
/** Точечное обновление карточки хоста по событию ingest (без полного GET /hosts/{id}). */
|
||||
export function patchHostDetailFromEvent(host: HostDetail, ev: EventSummary): void {
|
||||
host.last_seen_at = ev.received_at || ev.occurred_at;
|
||||
host.event_count = (host.event_count ?? 0) + 1;
|
||||
if (ev.product_version?.trim()) {
|
||||
host.product_version = ev.product_version.trim();
|
||||
}
|
||||
}
|
||||
|
||||
export function recalcVersionStatusFromGit(
|
||||
host: HostDetail,
|
||||
git: AgentGitVersions | null | undefined,
|
||||
): void {
|
||||
const target = gitTargetVersionForHost(host, git);
|
||||
if (!host.product_version?.trim() || !target) {
|
||||
return;
|
||||
}
|
||||
host.version_status = isAgentVersionBehind(host.product_version, target) ? "outdated" : "ok";
|
||||
}
|
||||
|
||||
/** Добавляет событие в начало списка на стр. 1, если его ещё нет. */
|
||||
export function prependHostEventIfMissing(
|
||||
events: EventListResponse | null,
|
||||
ev: EventSummary,
|
||||
page: number,
|
||||
): boolean {
|
||||
if (!events || page !== 1) return false;
|
||||
if (events.items.some((row) => row.id === ev.id)) return false;
|
||||
events.items = [ev, ...events.items];
|
||||
events.total = (events.total ?? 0) + 1;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Display name from host card (SAC «Имя сервера»). */
|
||||
export function formatServerName(displayName?: string | null): string {
|
||||
const name = displayName?.trim();
|
||||
return name || "—";
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { HostDetail } from "../api";
|
||||
|
||||
type HostPatchListener = (detail: HostDetail) => void;
|
||||
|
||||
const listeners = new Set<HostPatchListener>();
|
||||
|
||||
export function subscribeHostPatch(listener: HostPatchListener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function emitHostPatch(detail: HostDetail): void {
|
||||
for (const listener of listeners) {
|
||||
listener(detail);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { HostDetail, HostSummary } from "../api";
|
||||
import { isAgentVersionNewer } from "./agentVersion";
|
||||
|
||||
/** Обновляет поля строки списка хостов из GET /hosts/{id} (без перезагрузки таблицы). */
|
||||
export function patchHostSummaryFromDetail(target: HostSummary, detail: HostDetail): void {
|
||||
target.display_name = detail.display_name;
|
||||
target.os_version = detail.os_version;
|
||||
target.product_version = detail.product_version;
|
||||
target.ipv4 = detail.ipv4;
|
||||
target.last_seen_at = detail.last_seen_at;
|
||||
target.event_count = detail.event_count;
|
||||
target.last_heartbeat_at = detail.last_heartbeat_at;
|
||||
target.last_daily_report_at = detail.last_daily_report_at;
|
||||
target.last_inventory_at = detail.last_inventory_at;
|
||||
target.agent_status = detail.agent_status;
|
||||
if (detail.version_status) {
|
||||
target.version_status = detail.version_status;
|
||||
}
|
||||
}
|
||||
|
||||
export function bumpLatestAgentVersion(
|
||||
latest: Record<string, string> | undefined,
|
||||
product: string,
|
||||
version: string | null | undefined,
|
||||
): void {
|
||||
if (!latest || !version) return;
|
||||
if (isAgentVersionNewer(version, latest[product])) {
|
||||
latest[product] = version;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { EventSummary } from "../api";
|
||||
|
||||
export function hasRdgFlapUi(event: EventSummary): boolean {
|
||||
return Boolean(event.rdg_flap || event.rdg_flap_qwinsta_event_id);
|
||||
}
|
||||
|
||||
export function hasRdgAccessPath(event: EventSummary): boolean {
|
||||
return Boolean(event.rdg_access_path);
|
||||
}
|
||||
|
||||
export function rdgAccessPathLabel(event: EventSummary): string {
|
||||
return event.rdg_access_path || "";
|
||||
}
|
||||
|
||||
export function rdgQwinstaEventId(event: EventSummary): number | null {
|
||||
if (!event.rdg_qwinsta_enabled) return null;
|
||||
return event.rdg_flap_qwinsta_event_id ?? event.id;
|
||||
}
|
||||
|
||||
/** @deprecated use rdgQwinstaEventId */
|
||||
export function rdgFlapQwinstaEventId(event: EventSummary): number | null {
|
||||
return rdgQwinstaEventId(event);
|
||||
}
|
||||
|
||||
export function rdgFlapPairLabel(event: EventSummary): string {
|
||||
if (!event.rdg_flap_pair_event_id) return "";
|
||||
return event.type === "rdg.connection.success" ? "→" : "←";
|
||||
}
|
||||
|
||||
export function isRdgConnectionEvent(event: EventSummary): boolean {
|
||||
return event.type.startsWith("rdg.connection.");
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/** Безопасный вывод HTML из агента (DOMPurify, белый список тегов). */
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
const ALLOWED_REPORT_TAGS = [
|
||||
"b",
|
||||
"strong",
|
||||
"i",
|
||||
"em",
|
||||
"u",
|
||||
"code",
|
||||
"pre",
|
||||
"br",
|
||||
"p",
|
||||
"div",
|
||||
"span",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"hr",
|
||||
];
|
||||
|
||||
export function sanitizeAgentHtml(html: string): string {
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: ALLOWED_REPORT_TAGS,
|
||||
ALLOWED_ATTR: [],
|
||||
});
|
||||
}
|
||||
export interface DailyReportStats {
|
||||
platform?: "ssh" | "windows";
|
||||
successful_logins?: number;
|
||||
failed_logins?: number;
|
||||
sudo_commands?: number;
|
||||
active_bans?: number;
|
||||
top_failed_ips?: string[];
|
||||
active_users?: string[];
|
||||
/** legacy */
|
||||
successful_ssh?: number;
|
||||
failed_ssh?: number;
|
||||
rdp_success?: number;
|
||||
rdp_failed?: number;
|
||||
unique_users?: string[];
|
||||
active_sessions?: number;
|
||||
active_sessions_rdp?: number;
|
||||
}
|
||||
|
||||
export interface NormalizedReportStat {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
export const DAILY_REPORT_TYPE_LABELS: Record<string, string> = {
|
||||
"report.daily.ssh": "Отчёты ssh клиентов",
|
||||
"report.daily.rdp": "Отчёты RDP клиентов",
|
||||
};
|
||||
|
||||
export function dailyReportTypeLabel(type: string): string {
|
||||
return DAILY_REPORT_TYPE_LABELS[type] ?? type;
|
||||
}
|
||||
|
||||
export function isDailyReportType(type: string): boolean {
|
||||
return type === "report.daily.ssh" || type === "report.daily.rdp";
|
||||
}
|
||||
|
||||
/** Текст отчёта: literal \\n и <br> из legacy-агентов → нормальные переносы. */
|
||||
export function normalizeReportPlainText(body: string): string {
|
||||
let text = body.replace(/\r\n/g, "\n");
|
||||
text = text.replace(/\\n/g, "\n");
|
||||
text = text.replace(/<br\s*\/?>/gi, "\n");
|
||||
return text;
|
||||
}
|
||||
|
||||
export function reportPlatform(type: string): "ssh" | "windows" {
|
||||
return type === "report.daily.rdp" ? "windows" : "ssh";
|
||||
}
|
||||
|
||||
/** Приводит stats агента/SAC к единым полям для карточки. */
|
||||
export function normalizeReportStats(
|
||||
stats: DailyReportStats | null,
|
||||
type: string,
|
||||
): DailyReportStats | null {
|
||||
if (!stats) return null;
|
||||
const platform = stats.platform ?? reportPlatform(type);
|
||||
const out: DailyReportStats = { ...stats, platform };
|
||||
|
||||
const asInt = (v: unknown, fallback = 0): number => {
|
||||
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||
if (typeof v === "string") {
|
||||
const n = parseInt(v.replace(/[^\d-]/g, ""), 10);
|
||||
if (Number.isFinite(n)) return n;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
if (platform === "ssh") {
|
||||
out.successful_logins = asInt(stats.successful_logins, asInt(stats.successful_ssh));
|
||||
out.failed_logins = asInt(stats.failed_logins, asInt(stats.failed_ssh));
|
||||
out.sudo_commands = asInt(stats.sudo_commands);
|
||||
} else {
|
||||
out.successful_logins = asInt(stats.successful_logins, asInt(stats.rdp_success));
|
||||
out.failed_logins = asInt(stats.failed_logins, asInt(stats.rdp_failed));
|
||||
if (!out.active_users?.length && stats.unique_users?.length) {
|
||||
out.active_users = stats.unique_users.map((u) =>
|
||||
u.startsWith("👤") ? u : `👤 ${u}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
out.active_bans = asInt(stats.active_bans);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function reportStatsFromDetails(
|
||||
details: Record<string, unknown> | null,
|
||||
type = "report.daily.ssh",
|
||||
): DailyReportStats | null {
|
||||
if (!details || typeof details.stats !== "object" || details.stats === null) {
|
||||
return null;
|
||||
}
|
||||
return normalizeReportStats(details.stats as DailyReportStats, type);
|
||||
}
|
||||
|
||||
export function reportStatEntries(
|
||||
stats: DailyReportStats | null,
|
||||
type: string,
|
||||
): NormalizedReportStat[] {
|
||||
const s = normalizeReportStats(stats, type);
|
||||
if (!s) return [];
|
||||
const platform = s.platform ?? reportPlatform(type);
|
||||
const entries: NormalizedReportStat[] = [
|
||||
{
|
||||
key: "successful",
|
||||
label: platform === "ssh" ? "Успешных SSH" : "Успешных RDP",
|
||||
value: s.successful_logins ?? 0,
|
||||
},
|
||||
{
|
||||
key: "failed",
|
||||
label: platform === "ssh" ? "Неудачных SSH" : "Неудачных RDP",
|
||||
value: s.failed_logins ?? 0,
|
||||
},
|
||||
{ key: "bans", label: "Активных банов", value: s.active_bans ?? 0 },
|
||||
];
|
||||
if (platform === "ssh") {
|
||||
entries.splice(2, 0, {
|
||||
key: "sudo",
|
||||
label: "Команд sudo",
|
||||
value: s.sudo_commands ?? 0,
|
||||
});
|
||||
}
|
||||
const users = s.active_users ?? [];
|
||||
if (users.length) {
|
||||
entries.push({
|
||||
key: "active_users",
|
||||
label: "Активные пользователи",
|
||||
value: users.length,
|
||||
});
|
||||
} else if (typeof s.active_sessions === "number" && s.active_sessions > 0) {
|
||||
entries.push({
|
||||
key: "active_sessions",
|
||||
label: "Сессий (SSH)",
|
||||
value: s.active_sessions,
|
||||
});
|
||||
} else if (typeof s.active_sessions_rdp === "number" && s.active_sessions_rdp > 0) {
|
||||
entries.push({
|
||||
key: "active_sessions_rdp",
|
||||
label: "Сессий (RDP)",
|
||||
value: s.active_sessions_rdp,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function reportHtmlFromDetails(details: Record<string, unknown> | null): string | null {
|
||||
if (!details) return null;
|
||||
const html = details.report_html;
|
||||
return typeof html === "string" && html.trim() ? html : null;
|
||||
}
|
||||
|
||||
export function reportBodyFromDetails(details: Record<string, unknown> | null): string | null {
|
||||
if (!details) return null;
|
||||
const body = details.report_body;
|
||||
return typeof body === "string" && body.trim() ? body : null;
|
||||
}
|
||||
|
||||
export function reportActiveUserLines(
|
||||
stats: DailyReportStats | null,
|
||||
type: string,
|
||||
): string[] {
|
||||
const s = normalizeReportStats(stats, type);
|
||||
if (!s?.active_users?.length) return [];
|
||||
return s.active_users;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
export function showEventSessionTerminateButton(
|
||||
event: Pick<EventSummary, "type" | "session_terminated">,
|
||||
): boolean {
|
||||
return eventSupportsSessionTerminate(event) && !event.session_terminated;
|
||||
}
|
||||
|
||||
export function markEventSessionTerminatedInList(
|
||||
items: EventSummary[] | undefined,
|
||||
eventId: number,
|
||||
): void {
|
||||
const item = items?.find((row) => row.id === eventId);
|
||||
if (item) {
|
||||
item.session_terminated = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Human-readable session_duration_sec for event tables. */
|
||||
|
||||
export function formatSessionDuration(seconds: number | null | undefined): string {
|
||||
if (seconds == null || !Number.isFinite(seconds) || seconds < 0) {
|
||||
return "—";
|
||||
}
|
||||
const total = Math.floor(seconds);
|
||||
const days = Math.floor(total / 86_400);
|
||||
const hours = Math.floor((total % 86_400) / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const secs = total % 60;
|
||||
const clock = `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
|
||||
if (days > 0) {
|
||||
return `${days}д ${clock}`;
|
||||
}
|
||||
return clock;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
|
||||
export const APP_NAME = "Security Alert Center";
|
||||
export const APP_VERSION = "0.5.17";
|
||||
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||
@@ -0,0 +1,664 @@
|
||||
<template>
|
||||
<div class="overview-page">
|
||||
<h1>Обзор</h1>
|
||||
|
||||
<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 title="Длительность сессии (из session_duration_sec)">Длительность</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>{{ formatSessionDuration(e.session_duration_sec) }}</td>
|
||||
|
||||
<td>{{ e.product_version || "—" }}</td>
|
||||
|
||||
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
|
||||
|
||||
<td>
|
||||
<RdgAccessBadge v-if="e.rdg_access_path" :event="e" />
|
||||
<RdgFlapBadge v-if="hasRdgFlapUi(e)" :event="e" />
|
||||
{{ e.title }}
|
||||
</td>
|
||||
|
||||
<td class="dash-actions">
|
||||
|
||||
<button
|
||||
v-if="rdgQwinstaEventId(e)"
|
||||
type="button"
|
||||
class="secondary dash-qwinsta-btn"
|
||||
:disabled="qwinstaLoadingId === e.id"
|
||||
title="qwinsta и завершение сеанса на рабочем ПК"
|
||||
@click="runQwinsta(e)"
|
||||
>
|
||||
Обрыв RDS
|
||||
</button>
|
||||
<button
|
||||
v-if="showEventSessionTerminateButton(e)"
|
||||
type="button"
|
||||
class="secondary dash-qwinsta-btn"
|
||||
:disabled="sessionTerminateLoadingId === e.id"
|
||||
@click="runSessionTerminate(e)"
|
||||
>
|
||||
{{ sessionTerminateLoadingId === e.id ? "…" : "Завершить" }}
|
||||
</button>
|
||||
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
<RdgQwinstaModal
|
||||
:modal="qwinstaModal"
|
||||
@close="closeQwinstaModal"
|
||||
@logoff="runLogoff"
|
||||
/>
|
||||
|
||||
<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,
|
||||
ApiError,
|
||||
getToken,
|
||||
postEventTerminateSession,
|
||||
type DashboardSummary,
|
||||
type EventSummary,
|
||||
} from "../api";
|
||||
import RdgAccessBadge from "../components/RdgAccessBadge.vue";
|
||||
import RdgFlapBadge from "../components/RdgFlapBadge.vue";
|
||||
import RdgQwinstaModal from "../components/RdgQwinstaModal.vue";
|
||||
import { useRdgQwinsta } from "../composables/useRdgQwinsta";
|
||||
import { formatServerName } from "../utils/hostDisplay";
|
||||
import { hasRdgFlapUi, rdgQwinstaEventId } from "../utils/rdgFlap";
|
||||
import { formatSessionDuration } from "../utils/sessionDuration";
|
||||
import { markEventSessionTerminatedInList, showEventSessionTerminateButton } from "../utils/sessionActions";
|
||||
|
||||
const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta();
|
||||
const sessionTerminateLoadingId = ref<number | null>(null);
|
||||
|
||||
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;
|
||||
|
||||
function formatDt(iso: string) {
|
||||
|
||||
return new Date(iso).toLocaleString("ru-RU");
|
||||
|
||||
}
|
||||
|
||||
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);
|
||||
} else {
|
||||
markEventSessionTerminatedInList(data.value?.recent_events, event.id);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 409) {
|
||||
markEventSessionTerminatedInList(data.value?.recent_events, event.id);
|
||||
}
|
||||
window.alert(e instanceof Error ? e.message : "Ошибка завершения сессии");
|
||||
} finally {
|
||||
sessionTerminateLoadingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
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`;
|
||||
|
||||
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(() => {
|
||||
eventSource?.close();
|
||||
eventSource = null;
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<style scoped>
|
||||
|
||||
.dash-table-scroll {
|
||||
|
||||
max-height: 21rem;
|
||||
|
||||
overflow-y: auto;
|
||||
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div class="error-page">
|
||||
<div class="error-card card">
|
||||
<img src="/sac-icon.png" alt="" class="error-logo" width="96" height="96" />
|
||||
<p class="error-code">{{ config.code }}</p>
|
||||
<h1 class="error-title">{{ config.title }}</h1>
|
||||
<p class="error-lead">{{ config.lead }}</p>
|
||||
<p class="error-hint">{{ config.hint }}</p>
|
||||
<div class="error-actions">
|
||||
<RouterLink :to="config.primaryAction.to" class="error-btn error-btn-primary">
|
||||
{{ config.primaryAction.label }}
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="showSecondary"
|
||||
:to="config.secondaryAction!.to"
|
||||
class="error-btn error-btn-secondary"
|
||||
>
|
||||
{{ config.secondaryAction!.label }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<p class="error-brand muted">Security Alert Center</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { getErrorPageConfig } from "../errorPages";
|
||||
|
||||
const props = defineProps<{
|
||||
code: number;
|
||||
}>();
|
||||
|
||||
const config = computed(() => getErrorPageConfig(props.code));
|
||||
|
||||
const showSecondary = computed(() => Boolean(config.value.secondaryAction));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.error-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.error-card {
|
||||
max-width: 520px;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
padding: 2rem 1.75rem;
|
||||
}
|
||||
|
||||
.error-logo {
|
||||
border-radius: 16px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
margin: 0;
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
color: #7eb8ff;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.error-lead {
|
||||
margin: 1rem 0 0;
|
||||
color: #e8eaed;
|
||||
}
|
||||
|
||||
.error-hint {
|
||||
margin: 1rem 0 0;
|
||||
padding: 0.85rem 1rem;
|
||||
background: #0f1419;
|
||||
border: 1px solid #2a3441;
|
||||
border-radius: 8px;
|
||||
color: #9aa4b2;
|
||||
font-size: 0.92rem;
|
||||
text-align: left;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem;
|
||||
justify-content: center;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.error-btn {
|
||||
display: inline-block;
|
||||
padding: 0.55rem 1.1rem;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.error-btn-primary {
|
||||
background: #2563eb;
|
||||
border: 1px solid #2563eb;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.error-btn-secondary {
|
||||
background: transparent;
|
||||
border: 1px solid #3d4f63;
|
||||
color: #e8eaed;
|
||||
}
|
||||
|
||||
.error-brand {
|
||||
margin: 1.5rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<p>
|
||||
<RouterLink v-if="fromReports" :to="{ path: '/reports', query: backToReportsQuery }">← Отчёты</RouterLink>
|
||||
<RouterLink v-else :to="{ path: '/events', query: backToEventsQuery }">← События</RouterLink>
|
||||
</p>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="loading">Загрузка…</p>
|
||||
<template v-else-if="event">
|
||||
<h1>{{ event.title }}</h1>
|
||||
<p v-if="event.rdg_access_path" class="event-rdg-access-banner">
|
||||
<RdgAccessBadge :event="event" />
|
||||
<span class="muted">Вход на рабочий ПК через RD Gateway.</span>
|
||||
</p>
|
||||
<p v-if="hasRdgFlapUi(event)" class="event-rdg-flap-banner">
|
||||
<RdgFlapBadge :event="event" :detail-link-query="backToEventsQuery" />
|
||||
<span class="muted">302→303 за несколько секунд — возможна зависшая сессия на ПК пользователя.</span>
|
||||
</p>
|
||||
<p v-if="rdgQwinstaEventId(event)" class="event-rdg-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="secondary"
|
||||
:disabled="qwinstaLoadingId === event.id"
|
||||
title="qwinsta и завершение сеанса на рабочем ПК"
|
||||
@click="runQwinsta(event)"
|
||||
>
|
||||
Обрыв RDS
|
||||
</button>
|
||||
</p>
|
||||
<RdgQwinstaModal
|
||||
:modal="qwinstaModal"
|
||||
@close="closeQwinstaModal"
|
||||
@logoff="runLogoff"
|
||||
/>
|
||||
<div class="card report-meta">
|
||||
<dl class="detail-fields">
|
||||
<div class="detail-field">
|
||||
<dt>ID</dt>
|
||||
<dd>{{ event.id }}</dd>
|
||||
</div>
|
||||
<div class="detail-field detail-field-wide">
|
||||
<dt>event_id</dt>
|
||||
<dd><code>{{ event.event_id }}</code></dd>
|
||||
</div>
|
||||
<div class="detail-field">
|
||||
<dt>Хост</dt>
|
||||
<dd>
|
||||
<RouterLink :to="{ path: '/reports', query: { hostname: event.hostname } }">
|
||||
{{ event.hostname }}
|
||||
</RouterLink>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="detail-field">
|
||||
<dt>Severity</dt>
|
||||
<dd><span :class="'sev-' + event.severity">{{ event.severity }}</span></dd>
|
||||
</div>
|
||||
<div class="detail-field">
|
||||
<dt>Тип</dt>
|
||||
<dd>
|
||||
<template v-if="isReport">{{ reportTypeLabel(event.type) }}</template>
|
||||
<code v-else>{{ event.type }}</code>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="detail-field">
|
||||
<dt>Occurred</dt>
|
||||
<dd>{{ formatDt(event.occurred_at) }}</dd>
|
||||
</div>
|
||||
<div v-if="!isReport" class="detail-field detail-field-wide">
|
||||
<dt>Summary</dt>
|
||||
<dd>{{ event.summary }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<template v-if="isReport">
|
||||
<h2>Отчёт</h2>
|
||||
<p class="report-summary-line">{{ event.summary }}</p>
|
||||
<ReportBodyCard :type="event.type" :summary="event.summary" :details="event.details" />
|
||||
</template>
|
||||
|
||||
<details v-if="event.details && !isReport" class="raw-details">
|
||||
<summary>details (JSON)</summary>
|
||||
<pre>{{ JSON.stringify(event.details, null, 2) }}</pre>
|
||||
</details>
|
||||
|
||||
<details v-if="isReport && event.details" class="raw-details">
|
||||
<summary>details (JSON)</summary>
|
||||
<pre>{{ JSON.stringify(event.details, null, 2) }}</pre>
|
||||
</details>
|
||||
|
||||
<details class="raw-details">
|
||||
<summary>payload (JSON)</summary>
|
||||
<pre>{{ JSON.stringify(event.payload, null, 2) }}</pre>
|
||||
</details>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { apiFetch, type EventDetail } from "../api";
|
||||
import RdgAccessBadge from "../components/RdgAccessBadge.vue";
|
||||
import RdgFlapBadge from "../components/RdgFlapBadge.vue";
|
||||
import RdgQwinstaModal from "../components/RdgQwinstaModal.vue";
|
||||
import ReportBodyCard from "../components/ReportBodyCard.vue";
|
||||
import { useRdgQwinsta } from "../composables/useRdgQwinsta";
|
||||
import { hasRdgFlapUi, rdgQwinstaEventId } from "../utils/rdgFlap";
|
||||
import { eventsBackQueryFromDetail } from "../utils/eventsListQuery";
|
||||
import { dailyReportTypeLabel, isDailyReportType } from "../utils/reportDisplay";
|
||||
|
||||
const props = defineProps<{ id: string }>();
|
||||
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));
|
||||
|
||||
const backToReportsQuery = computed(() => {
|
||||
const q: Record<string, string> = {};
|
||||
if (typeof route.query.type === "string" && route.query.type) q.type = route.query.type;
|
||||
if (typeof route.query.hostname === "string" && route.query.hostname) q.hostname = route.query.hostname;
|
||||
return q;
|
||||
});
|
||||
|
||||
const backToEventsQuery = computed(() => eventsBackQueryFromDetail(route.query));
|
||||
|
||||
function reportTypeLabel(type: string) {
|
||||
return dailyReportTypeLabel(type);
|
||||
}
|
||||
|
||||
function formatDt(iso: string) {
|
||||
return new Date(iso).toLocaleString("ru-RU");
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
event.value = await apiFetch<EventDetail>(`/api/v1/events/${props.id}`);
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Не найдено";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
watch(() => props.id, load);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.event-rdg-flap-banner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem 0.75rem;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<h1>События</h1>
|
||||
<div class="filters">
|
||||
<input v-model="q" placeholder="Поиск в summary/title" @keyup.enter="applyFilters" />
|
||||
<select v-model="severity" @change="applyFilters">
|
||||
<option value="">Все severity</option>
|
||||
<option>info</option>
|
||||
<option>warning</option>
|
||||
<option>high</option>
|
||||
<option>critical</option>
|
||||
</select>
|
||||
<input v-model="typeFilter" placeholder="type (префикс)" @keyup.enter="applyFilters" />
|
||||
<input v-model="hostnameFilter" placeholder="hostname / имя сервера" @keyup.enter="applyFilters" />
|
||||
<label class="events-date-field">
|
||||
<span class="events-date-label">С</span>
|
||||
<input v-model="dateFrom" type="date" @change="applyFilters" />
|
||||
</label>
|
||||
<label class="events-date-field">
|
||||
<span class="events-date-label">По</span>
|
||||
<input v-model="dateTo" type="date" @change="applyFilters" />
|
||||
</label>
|
||||
<button type="button" @click="applyFilters">Применить</button>
|
||||
<button v-if="hasActiveFilters" type="button" class="secondary" @click="clearFilters">Сбросить</button>
|
||||
</div>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="loading">Загрузка…</p>
|
||||
<template v-else>
|
||||
<p>Всего: {{ data?.total ?? 0 }}</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Время</th>
|
||||
<th>Хост</th>
|
||||
<th>Имя сервера</th>
|
||||
<th>Пользователь</th>
|
||||
<th title="Длительность сессии (из session_duration_sec)">Длительность</th>
|
||||
<th>Версия агента</th>
|
||||
<th>Severity</th>
|
||||
<th>Type</th>
|
||||
<th>Title</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="e in data?.items ?? []" :key="e.id">
|
||||
<td>
|
||||
<RouterLink :to="{ path: `/events/${e.id}`, query: detailLinkQuery }">{{ e.id }}</RouterLink>
|
||||
</td>
|
||||
<td>{{ formatDt(e.occurred_at) }}</td>
|
||||
<td>{{ e.hostname }}</td>
|
||||
<td>{{ formatServerName(e.display_name) }}</td>
|
||||
<td>{{ e.actor_user || "—" }}</td>
|
||||
<td>{{ formatSessionDuration(e.session_duration_sec) }}</td>
|
||||
<td>{{ e.product_version || "—" }}</td>
|
||||
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
|
||||
<td><code>{{ e.type }}</code></td>
|
||||
<td>
|
||||
<RdgAccessBadge v-if="e.rdg_access_path" :event="e" />
|
||||
<RdgFlapBadge v-if="hasRdgFlapUi(e)" :event="e" :detail-link-query="detailLinkQuery" />
|
||||
{{ e.title }}
|
||||
</td>
|
||||
<td class="events-actions">
|
||||
<button
|
||||
v-if="rdgQwinstaEventId(e)"
|
||||
type="button"
|
||||
class="secondary events-qwinsta-btn"
|
||||
:disabled="qwinstaLoadingId === e.id"
|
||||
title="qwinsta и завершение сеанса на рабочем ПК"
|
||||
@click="runQwinsta(e)"
|
||||
>
|
||||
Обрыв RDS
|
||||
</button>
|
||||
<button
|
||||
v-if="showEventSessionTerminateButton(e)"
|
||||
type="button"
|
||||
class="secondary events-qwinsta-btn"
|
||||
:disabled="sessionTerminateLoadingId === e.id"
|
||||
@click="runSessionTerminate(e)"
|
||||
>
|
||||
{{ sessionTerminateLoadingId === e.id ? "…" : "Завершить" }}
|
||||
</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>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary"
|
||||
:disabled="!data || page * pageSize >= data.total"
|
||||
@click="load(page + 1)"
|
||||
>
|
||||
Вперёд
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
|
||||
import { apiFetch, ApiError, postEventTerminateSession, type EventListResponse, type EventSummary } from "../api";
|
||||
import RdgAccessBadge from "../components/RdgAccessBadge.vue";
|
||||
import RdgFlapBadge from "../components/RdgFlapBadge.vue";
|
||||
import RdgQwinstaModal from "../components/RdgQwinstaModal.vue";
|
||||
import { useRdgQwinsta } from "../composables/useRdgQwinsta";
|
||||
import { hasRdgFlapUi, rdgQwinstaEventId } from "../utils/rdgFlap";
|
||||
import {
|
||||
apiParamsFromState,
|
||||
buildEventsListQuery,
|
||||
eventsListQueryMatches,
|
||||
parseEventsListQuery,
|
||||
type EventsListState,
|
||||
} from "../utils/eventsListQuery";
|
||||
import { formatServerName } from "../utils/hostDisplay";
|
||||
import { formatSessionDuration } from "../utils/sessionDuration";
|
||||
import { markEventSessionTerminatedInList, showEventSessionTerminateButton } 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);
|
||||
const error = ref("");
|
||||
const page = ref(1);
|
||||
const pageSize = 50;
|
||||
const q = ref("");
|
||||
const severity = ref("");
|
||||
const typeFilter = ref("");
|
||||
const hostnameFilter = ref("");
|
||||
const dateFrom = ref("");
|
||||
const dateTo = ref("");
|
||||
|
||||
const syncingFromRoute = ref(false);
|
||||
|
||||
const listState = computed(
|
||||
(): EventsListState => ({
|
||||
page: page.value,
|
||||
q: q.value,
|
||||
severity: severity.value,
|
||||
type: typeFilter.value,
|
||||
hostname: hostnameFilter.value,
|
||||
dateFrom: dateFrom.value,
|
||||
dateTo: dateTo.value,
|
||||
}),
|
||||
);
|
||||
|
||||
const detailLinkQuery = computed(() => buildEventsListQuery(listState.value));
|
||||
|
||||
const hasActiveFilters = computed(
|
||||
() =>
|
||||
Boolean(
|
||||
q.value.trim() ||
|
||||
severity.value ||
|
||||
typeFilter.value.trim() ||
|
||||
hostnameFilter.value.trim() ||
|
||||
dateFrom.value ||
|
||||
dateTo.value,
|
||||
),
|
||||
);
|
||||
|
||||
function formatDt(iso: string) {
|
||||
return new Date(iso).toLocaleString("ru-RU");
|
||||
}
|
||||
|
||||
function applyStateFromQuery() {
|
||||
const parsed = parseEventsListQuery(route.query);
|
||||
page.value = parsed.page;
|
||||
q.value = parsed.q;
|
||||
severity.value = parsed.severity;
|
||||
typeFilter.value = parsed.type;
|
||||
hostnameFilter.value = parsed.hostname;
|
||||
dateFrom.value = parsed.dateFrom;
|
||||
dateTo.value = parsed.dateTo;
|
||||
}
|
||||
|
||||
async function syncRouteQuery() {
|
||||
if (eventsListQueryMatches(listState.value, route.query)) return;
|
||||
syncingFromRoute.value = true;
|
||||
try {
|
||||
await router.replace({ path: "/events", query: buildEventsListQuery(listState.value) });
|
||||
} finally {
|
||||
syncingFromRoute.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function load(p?: number, options?: { syncUrl?: boolean; silent?: boolean }) {
|
||||
if (p !== undefined) page.value = p;
|
||||
if (!options?.silent) {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
}
|
||||
try {
|
||||
const params = apiParamsFromState(listState.value, pageSize);
|
||||
data.value = await apiFetch<EventListResponse>(`/api/v1/events?${params}`);
|
||||
if (options?.syncUrl !== false && !syncingFromRoute.value) {
|
||||
await syncRouteQuery();
|
||||
}
|
||||
} catch (e) {
|
||||
if (!options?.silent) {
|
||||
error.value = e instanceof Error ? e.message : "Ошибка загрузки";
|
||||
}
|
||||
} finally {
|
||||
if (!options?.silent) {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
} else {
|
||||
markEventSessionTerminatedInList(data.value?.items, event.id);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 409) {
|
||||
markEventSessionTerminatedInList(data.value?.items, event.id);
|
||||
}
|
||||
window.alert(e instanceof Error ? e.message : "Ошибка завершения сессии");
|
||||
} finally {
|
||||
sessionTerminateLoadingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
load(1);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
q.value = "";
|
||||
severity.value = "";
|
||||
typeFilter.value = "";
|
||||
hostnameFilter.value = "";
|
||||
dateFrom.value = "";
|
||||
dateTo.value = "";
|
||||
load(1);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
async () => {
|
||||
if (route.path !== "/events") return;
|
||||
const matched = eventsListQueryMatches(listState.value, route.query);
|
||||
if (!matched) {
|
||||
syncingFromRoute.value = true;
|
||||
applyStateFromQuery();
|
||||
syncingFromRoute.value = false;
|
||||
}
|
||||
if (!matched || !data.value) {
|
||||
await load(undefined, { syncUrl: false });
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.events-date-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.events-date-label {
|
||||
color: #9aa4b2;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,557 @@
|
||||
<template>
|
||||
<h1>Хосты</h1>
|
||||
<div class="filters">
|
||||
<label>
|
||||
Поиск (hostname / display name)
|
||||
<input v-model="search" type="search" placeholder="UNMS Kalina" @keyup.enter="applyFilters" />
|
||||
</label>
|
||||
<label>
|
||||
Статус агента
|
||||
<select v-model="agentStatusFilter" @change="applyFilters">
|
||||
<option value="">Все</option>
|
||||
<option value="online">online</option>
|
||||
<option value="stale">stale</option>
|
||||
<option value="unknown">unknown</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="secondary" @click="applyFilters">Найти</button>
|
||||
<button type="button" @click="manualAddOpen = true">Добавить вручную</button>
|
||||
</div>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="loading">Загрузка…</p>
|
||||
<template v-else>
|
||||
<div class="hosts-list-meta">
|
||||
<p class="hosts-list-total">
|
||||
Всего: {{ data?.total ?? 0 }}
|
||||
<span v-if="live" class="live-badge">live</span>
|
||||
</p>
|
||||
<aside
|
||||
class="agent-releases-plate"
|
||||
title="Версии из git-репозиториев агентов (version.txt, ветка main)"
|
||||
>
|
||||
<span class="agent-releases-plate__label">Последние версии агентов:</span>
|
||||
<span class="agent-releases-plate__item">
|
||||
Win: <code>{{ gitWinVersion }}</code>
|
||||
</span>
|
||||
<span class="agent-releases-plate__item">
|
||||
SSH: <code>{{ gitSshVersion }}</code>
|
||||
</span>
|
||||
</aside>
|
||||
</div>
|
||||
<table class="hosts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><button type="button" class="th-sort" @click="setSort('id')">ID {{ sortMark('id') }}</button></th>
|
||||
<th><button type="button" class="th-sort" @click="setSort('display_name')">Имя {{ sortMark('display_name') }}</button></th>
|
||||
<th><button type="button" class="th-sort" @click="setSort('hostname')">Hostname {{ sortMark('hostname') }}</button></th>
|
||||
<th><button type="button" class="th-sort" @click="setSort('product_version')">Версия агента {{ sortMark('product_version') }}</button></th>
|
||||
<th><button type="button" class="th-sort" @click="setSort('os_family')">OS {{ sortMark('os_family') }}</button></th>
|
||||
<th><button type="button" class="th-sort" @click="setSort('ipv4')">IPv4 {{ sortMark('ipv4') }}</button></th>
|
||||
<th><button type="button" class="th-sort" @click="setSort('agent_status')">Статус {{ sortMark('agent_status') }}</button></th>
|
||||
<th>Heartbeat</th>
|
||||
<th><button type="button" class="th-sort" @click="setSort('last_daily_report_at')">Отчёт {{ sortMark('last_daily_report_at') }}</button></th>
|
||||
<th><button type="button" class="th-sort" @click="setSort('last_seen_at')">Last seen {{ sortMark('last_seen_at') }}</button></th>
|
||||
<th><button type="button" class="th-sort" @click="setSort('event_count')">Events {{ sortMark('event_count') }}</button></th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="h in sortedItems"
|
||||
:key="h.id"
|
||||
class="sac-host-row"
|
||||
tabindex="0"
|
||||
:title="`Карточка хоста ${h.hostname}`"
|
||||
@click="openHost(h.id)"
|
||||
@keydown.enter="openHost(h.id)"
|
||||
>
|
||||
<td>{{ h.id }}</td>
|
||||
<td>{{ h.display_name || h.hostname }}</td>
|
||||
<td>{{ h.hostname }}</td>
|
||||
<td @click.stop>
|
||||
<HostAgentVersionUpgrade
|
||||
:host="h"
|
||||
:git="data"
|
||||
:updating="isHostRemoteActionActive(h.id)"
|
||||
:outdated-highlight="isVersionOutdated(h)"
|
||||
@upgrade="startAgentUpgrade(h)"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ h.os_family }}</td>
|
||||
<td>{{ h.ipv4 || "—" }}</td>
|
||||
<td :class="'agent-' + h.agent_status">{{ agentLabel(h.agent_status) }}</td>
|
||||
<td>{{ h.last_heartbeat_at ? formatDt(h.last_heartbeat_at) : "—" }}</td>
|
||||
<td @click.stop>
|
||||
<RouterLink
|
||||
v-if="h.last_daily_report_at"
|
||||
:to="{ path: '/reports', query: { hostname: h.hostname } }"
|
||||
>
|
||||
{{ formatDt(h.last_daily_report_at) }}
|
||||
</RouterLink>
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
<td>{{ formatDt(h.last_seen_at) }}</td>
|
||||
<td>{{ h.event_count ?? 0 }}</td>
|
||||
<td class="actions" @click.stop>
|
||||
<div class="actions-inner">
|
||||
<button
|
||||
type="button"
|
||||
class="danger"
|
||||
:disabled="deletingId === h.id"
|
||||
title="Удалить хост и все его события"
|
||||
@click="confirmDelete(h)"
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
<HostManualAddModal
|
||||
:open="manualAddOpen"
|
||||
@close="manualAddOpen = false"
|
||||
@started="onManualAddStarted"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
|
||||
import {
|
||||
apiFetch,
|
||||
ApiError,
|
||||
fetchHost,
|
||||
fetchRecentEvents,
|
||||
type HostDetail,
|
||||
type HostListResponse,
|
||||
type HostSummary,
|
||||
} from "../api";
|
||||
import { bumpLatestAgentVersion, patchHostSummaryFromDetail } from "../utils/hostsLiveUpdate";
|
||||
import { subscribeHostPatch } from "../utils/hostPatchBus";
|
||||
import { isAgentVersionOutdated } from "../utils/agentVersion";
|
||||
import {
|
||||
isGitAgentUpgradeAvailable,
|
||||
remoteActionKindForHost,
|
||||
remoteActionLogPlaceholder,
|
||||
remoteActionLogPlaceholderFromTitle,
|
||||
remoteActionTitleForHost,
|
||||
} from "../utils/hostAgentUpgrade";
|
||||
import {
|
||||
hostRemoteActionLogs,
|
||||
isHostRemoteActionActive,
|
||||
pollHostRemoteAction,
|
||||
runHostRemoteAction,
|
||||
showHostRemoteActionLog,
|
||||
} from "../composables/useHostRemoteAction";
|
||||
import HostAgentVersionUpgrade from "../components/HostAgentVersionUpgrade.vue";
|
||||
import HostManualAddModal from "../components/HostManualAddModal.vue";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const data = ref<HostListResponse | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const search = ref("");
|
||||
const agentStatusFilter = ref("");
|
||||
type SortKey =
|
||||
| "id"
|
||||
| "display_name"
|
||||
| "hostname"
|
||||
| "product_version"
|
||||
| "os_family"
|
||||
| "ipv4"
|
||||
| "agent_status"
|
||||
| "last_seen_at"
|
||||
| "event_count"
|
||||
| "last_daily_report_at";
|
||||
|
||||
const HOSTS_SORT_KEYS: SortKey[] = [
|
||||
"id",
|
||||
"display_name",
|
||||
"hostname",
|
||||
"product_version",
|
||||
"os_family",
|
||||
"ipv4",
|
||||
"agent_status",
|
||||
"last_seen_at",
|
||||
"event_count",
|
||||
"last_daily_report_at",
|
||||
];
|
||||
|
||||
const HOSTS_SORT_STORAGE_KEY = "sac_hosts_sort";
|
||||
|
||||
function loadHostsSortPrefs(): { sortBy: SortKey; sortDir: "asc" | "desc" } {
|
||||
const fallback = { sortBy: "last_seen_at" as SortKey, sortDir: "desc" as const };
|
||||
try {
|
||||
const raw = localStorage.getItem(HOSTS_SORT_STORAGE_KEY);
|
||||
if (!raw) return fallback;
|
||||
const parsed = JSON.parse(raw) as { sortBy?: string; sortDir?: string };
|
||||
if (
|
||||
parsed.sortBy &&
|
||||
HOSTS_SORT_KEYS.includes(parsed.sortBy as SortKey) &&
|
||||
(parsed.sortDir === "asc" || parsed.sortDir === "desc")
|
||||
) {
|
||||
return { sortBy: parsed.sortBy as SortKey, sortDir: parsed.sortDir };
|
||||
}
|
||||
} catch {
|
||||
/* ignore corrupt storage */
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function saveHostsSortPrefs(sortBy: SortKey, sortDir: "asc" | "desc") {
|
||||
localStorage.setItem(HOSTS_SORT_STORAGE_KEY, JSON.stringify({ sortBy, sortDir }));
|
||||
}
|
||||
|
||||
const initialSort = loadHostsSortPrefs();
|
||||
const sortBy = ref<SortKey>(initialSort.sortBy);
|
||||
const sortDir = ref<"asc" | "desc">(initialSort.sortDir);
|
||||
const deletingId = ref<number | null>(null);
|
||||
const manualAddOpen = ref(false);
|
||||
let patchingHostRow = false;
|
||||
let unsubscribeHostPatch: (() => void) | null = null;
|
||||
|
||||
function applyHostPatchFromDetail(detail: HostDetail) {
|
||||
if (!data.value) return;
|
||||
const row = data.value.items.find((h) => h.id === detail.id);
|
||||
if (row) {
|
||||
patchHostSummaryFromDetail(row, detail);
|
||||
bumpLatestAgentVersion(data.value.latest_agent_versions, row.product, detail.product_version);
|
||||
}
|
||||
}
|
||||
|
||||
const { live } = useSacLiveEventStream(() => {
|
||||
void patchHostRowFromLatestEvent();
|
||||
});
|
||||
|
||||
function formatDt(iso: string) {
|
||||
return new Date(iso).toLocaleString("ru-RU");
|
||||
}
|
||||
|
||||
function agentLabel(status: string) {
|
||||
if (status === "online") return "online";
|
||||
if (status === "stale") return "stale";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function isVersionOutdated(h: HostSummary): boolean {
|
||||
if (h.version_status === "outdated") return true;
|
||||
if (h.version_status === "ok") return false;
|
||||
const reference =
|
||||
data.value?.reference_agent_versions?.[h.product] ??
|
||||
data.value?.latest_agent_versions?.[h.product];
|
||||
return isAgentVersionOutdated(h.product_version, reference);
|
||||
}
|
||||
|
||||
function countOutdatedUpgradeableHosts(): number {
|
||||
return (data.value?.items ?? []).filter(
|
||||
(h) => isGitAgentUpgradeAvailable(h, data.value) && isVersionOutdated(h),
|
||||
).length;
|
||||
}
|
||||
|
||||
function countActiveRemoteUpgrades(): number {
|
||||
return hostRemoteActionLogs.filter((e) => e.loading).length;
|
||||
}
|
||||
|
||||
function confirmMassUpgradeIfNeeded(hostname: string): boolean {
|
||||
const active = countActiveRemoteUpgrades();
|
||||
const outdated = countOutdatedUpgradeableHosts();
|
||||
if (active >= 2) {
|
||||
return window.confirm(
|
||||
`Сейчас обновляется ${active} хост(ов). Рекомендуется пачками по 2–3. Продолжить обновление «${hostname}»?`,
|
||||
);
|
||||
}
|
||||
if (outdated > 3 && active >= 1) {
|
||||
return window.confirm(
|
||||
`Устарело агентов на странице: ${outdated}, уже идёт обновление. Продолжить «${hostname}»?`,
|
||||
);
|
||||
}
|
||||
if (outdated > 3) {
|
||||
return window.confirm(
|
||||
`Устарело агентов на странице: ${outdated}. Рекомендуется обновлять пачками по 2–3, не все сразу. Продолжить «${hostname}»?`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function startAgentUpgrade(h: HostSummary) {
|
||||
if (!isGitAgentUpgradeAvailable(h, data.value)) {
|
||||
return;
|
||||
}
|
||||
if (isHostRemoteActionActive(h.id)) {
|
||||
showHostRemoteActionLog(h.id);
|
||||
return;
|
||||
}
|
||||
if (!confirmMassUpgradeIfNeeded(h.hostname)) {
|
||||
return;
|
||||
}
|
||||
const kind = remoteActionKindForHost(h);
|
||||
if (!kind) return;
|
||||
void runHostRemoteAction(
|
||||
h.id,
|
||||
remoteActionTitleForHost(h),
|
||||
kind,
|
||||
remoteActionLogPlaceholder(h, kind),
|
||||
);
|
||||
}
|
||||
|
||||
function openHost(id: number) {
|
||||
router.push(`/hosts/${id}`);
|
||||
}
|
||||
|
||||
function agentStatusOrder(status: string) {
|
||||
if (status === "online") return 0;
|
||||
if (status === "stale") return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function setSort(key: SortKey) {
|
||||
if (sortBy.value === key) {
|
||||
sortDir.value = sortDir.value === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
sortBy.value = key;
|
||||
sortDir.value = key === "id" || key === "last_seen_at" || key === "event_count" ? "desc" : "asc";
|
||||
}
|
||||
saveHostsSortPrefs(sortBy.value, sortDir.value);
|
||||
}
|
||||
|
||||
const gitWinVersion = computed(
|
||||
() => data.value?.git_latest_rdp_version?.trim() || "—",
|
||||
);
|
||||
const gitSshVersion = computed(
|
||||
() => data.value?.git_latest_ssh_version?.trim() || "—",
|
||||
);
|
||||
|
||||
function sortMark(key: SortKey) {
|
||||
if (sortBy.value !== key) return "";
|
||||
return sortDir.value === "asc" ? "↑" : "↓";
|
||||
}
|
||||
|
||||
function compareNullableStrings(a: string | null, b: string | null) {
|
||||
const av = (a ?? "").trim().toLowerCase();
|
||||
const bv = (b ?? "").trim().toLowerCase();
|
||||
return av.localeCompare(bv, "ru");
|
||||
}
|
||||
|
||||
function compareNullableDates(a: string | null, b: string | null) {
|
||||
const av = a ? new Date(a).getTime() : 0;
|
||||
const bv = b ? new Date(b).getTime() : 0;
|
||||
return av - bv;
|
||||
}
|
||||
|
||||
const sortedItems = computed(() => {
|
||||
const items = [...(data.value?.items ?? [])];
|
||||
const key = sortBy.value;
|
||||
const dir = sortDir.value === "asc" ? 1 : -1;
|
||||
items.sort((a: HostSummary, b: HostSummary) => {
|
||||
let cmp = 0;
|
||||
switch (key) {
|
||||
case "id":
|
||||
cmp = a.id - b.id;
|
||||
break;
|
||||
case "display_name":
|
||||
cmp = compareNullableStrings(a.display_name, b.display_name);
|
||||
break;
|
||||
case "hostname":
|
||||
cmp = compareNullableStrings(a.hostname, b.hostname);
|
||||
break;
|
||||
case "product_version":
|
||||
cmp = compareNullableStrings(a.product_version, b.product_version);
|
||||
break;
|
||||
case "os_family":
|
||||
cmp = compareNullableStrings(a.os_family, b.os_family);
|
||||
break;
|
||||
case "ipv4":
|
||||
cmp = compareNullableStrings(a.ipv4, b.ipv4);
|
||||
break;
|
||||
case "agent_status":
|
||||
cmp = agentStatusOrder(a.agent_status) - agentStatusOrder(b.agent_status);
|
||||
break;
|
||||
case "event_count":
|
||||
cmp = (a.event_count ?? 0) - (b.event_count ?? 0);
|
||||
break;
|
||||
case "last_daily_report_at":
|
||||
cmp = compareNullableDates(a.last_daily_report_at, b.last_daily_report_at);
|
||||
break;
|
||||
case "last_seen_at":
|
||||
cmp = compareNullableDates(a.last_seen_at, b.last_seen_at);
|
||||
break;
|
||||
}
|
||||
if (cmp === 0) {
|
||||
cmp = a.hostname.localeCompare(b.hostname, "ru");
|
||||
}
|
||||
return cmp * dir;
|
||||
});
|
||||
return items;
|
||||
});
|
||||
|
||||
async function loadHosts(retry = 0) {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const q = search.value.trim();
|
||||
const params = new URLSearchParams({ page: "1", page_size: "100" });
|
||||
if (q) params.set("hostname", q);
|
||||
if (agentStatusFilter.value) params.set("agent_status", agentStatusFilter.value);
|
||||
data.value = await apiFetch<HostListResponse>(`/api/v1/hosts?${params}`);
|
||||
} catch (e) {
|
||||
const status = e instanceof ApiError ? e.status : 0;
|
||||
if (retry < 2 && (status === 502 || status === 503)) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 2500));
|
||||
return loadHosts(retry + 1);
|
||||
}
|
||||
error.value = e instanceof Error ? e.message : "Ошибка";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildHostsQuery(): Record<string, string> {
|
||||
const q: Record<string, string> = {};
|
||||
if (search.value.trim()) q.hostname = search.value.trim();
|
||||
if (agentStatusFilter.value) q.agent_status = agentStatusFilter.value;
|
||||
return q;
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
void router.replace({ path: "/hosts", query: buildHostsQuery() });
|
||||
void loadHosts();
|
||||
}
|
||||
|
||||
function syncFromRoute() {
|
||||
const hostname = route.query.hostname;
|
||||
if (typeof hostname === "string") search.value = hostname;
|
||||
const status = route.query.agent_status;
|
||||
agentStatusFilter.value = typeof status === "string" ? status : "";
|
||||
}
|
||||
|
||||
async function patchHostRowFromLatestEvent() {
|
||||
if (!data.value?.items.length || patchingHostRow) return;
|
||||
patchingHostRow = true;
|
||||
try {
|
||||
const recent = await fetchRecentEvents(1);
|
||||
const ev = recent[0];
|
||||
if (!ev) return;
|
||||
const row = data.value.items.find((h) => h.id === ev.host_id);
|
||||
if (!row) return;
|
||||
const detail = await fetchHost(ev.host_id);
|
||||
patchHostSummaryFromDetail(row, detail);
|
||||
} catch {
|
||||
/* keep table on transient errors */
|
||||
} finally {
|
||||
patchingHostRow = false;
|
||||
}
|
||||
}
|
||||
|
||||
interface HostDeleteResponse {
|
||||
status: string;
|
||||
host_id: number;
|
||||
hostname: string;
|
||||
deleted_events: number;
|
||||
deleted_problems: number;
|
||||
}
|
||||
|
||||
async function onManualAddStarted(payload: { hostId: number; title: string }) {
|
||||
await loadHosts();
|
||||
void pollHostRemoteAction(
|
||||
payload.hostId,
|
||||
payload.title,
|
||||
remoteActionLogPlaceholderFromTitle(payload.title),
|
||||
);
|
||||
}
|
||||
|
||||
async function confirmDelete(h: HostSummary) {
|
||||
const label = h.display_name || h.hostname;
|
||||
const n = h.event_count ?? 0;
|
||||
const msg =
|
||||
`Удалить хост «${label}» (${h.hostname})?\n\n` +
|
||||
`Будут удалены все события (${n}) и проблемы этого хоста. ` +
|
||||
`При новом ingest агент снова появится в списке.`;
|
||||
if (!window.confirm(msg)) {
|
||||
return;
|
||||
}
|
||||
deletingId.value = h.id;
|
||||
error.value = "";
|
||||
try {
|
||||
await apiFetch<HostDeleteResponse>(`/api/v1/hosts/${h.id}`, { method: "DELETE" });
|
||||
await loadHosts();
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Не удалось удалить хост";
|
||||
} finally {
|
||||
deletingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
syncFromRoute();
|
||||
loadHosts();
|
||||
unsubscribeHostPatch = subscribeHostPatch(applyHostPatchFromDetail);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
unsubscribeHostPatch?.();
|
||||
unsubscribeHostPatch = null;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [route.query.hostname, route.query.agent_status] as const,
|
||||
() => {
|
||||
syncFromRoute();
|
||||
loadHosts();
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hosts-list-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem 0.75rem;
|
||||
margin: 0.35rem 0 1rem;
|
||||
}
|
||||
|
||||
.hosts-list-total {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.agent-releases-plate {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem 0.85rem;
|
||||
margin: -0.15rem 0 0;
|
||||
padding: 0.45rem 1rem 0.5rem 1.1rem;
|
||||
border-radius: 0 0.75rem 0.75rem 0.35rem;
|
||||
border: 1px solid #2d3a4d;
|
||||
border-left: 3px solid #58a6ff;
|
||||
background: linear-gradient(135deg, #1a2332 0%, #151d28 100%);
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.35),
|
||||
0 1px 0 rgba(88, 166, 255, 0.12) inset;
|
||||
font-size: 0.9rem;
|
||||
color: #9aa4b2;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.agent-releases-plate__label {
|
||||
color: #c9d1d9;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.agent-releases-plate__item code {
|
||||
font-size: 0.88em;
|
||||
color: #79c0ff;
|
||||
background: rgba(88, 166, 255, 0.08);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="card login-card">
|
||||
<div class="login-brand">
|
||||
<img src="/sac-icon.png" alt="" class="login-brand-icon" width="64" height="64" />
|
||||
<h1 class="login-title">Вход в SAC</h1>
|
||||
<p class="login-subtitle">Security Alert Center</p>
|
||||
</div>
|
||||
<form @submit.prevent="submit">
|
||||
<p>
|
||||
<label>Логин<br />
|
||||
<input v-model="username" autocomplete="username" required />
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>Пароль<br />
|
||||
<input v-model="password" type="password" autocomplete="current-password" required />
|
||||
</label>
|
||||
</p>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<button type="submit" :disabled="loading">{{ loading ? "…" : "Войти" }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { apiFetch, setAuth, type TokenResponse } from "../api";
|
||||
|
||||
const router = useRouter();
|
||||
const username = ref("admin");
|
||||
const password = ref("");
|
||||
const error = ref("");
|
||||
const loading = ref(false);
|
||||
|
||||
async function submit() {
|
||||
error.value = "";
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await apiFetch<TokenResponse>("/api/v1/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username: username.value, password: password.value }),
|
||||
});
|
||||
setAuth(data.access_token, data.role);
|
||||
await router.push("/dashboard");
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Ошибка входа";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-card {
|
||||
max-width: 360px;
|
||||
margin: 4rem auto;
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.login-brand-icon {
|
||||
border-radius: 12px;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin: 0.35rem 0 0;
|
||||
color: #9aa4b2;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<p><RouterLink to="/problems">← Проблемы</RouterLink></p>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="loading">Загрузка…</p>
|
||||
<template v-else-if="problem">
|
||||
<h1>{{ problem.title }}</h1>
|
||||
<div class="card report-meta">
|
||||
<p>
|
||||
<strong>ID:</strong> {{ problem.id }}
|
||||
· <strong>Status:</strong>
|
||||
<span :class="'status-' + problem.status">{{ problem.status }}</span>
|
||||
· <strong>Severity:</strong>
|
||||
<span :class="'sev-' + problem.severity">{{ problem.severity }}</span>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Хост:</strong>
|
||||
<RouterLink
|
||||
v-if="problem.hostname"
|
||||
:to="{ path: '/reports', query: { hostname: problem.hostname } }"
|
||||
>
|
||||
{{ problem.hostname }}
|
||||
</RouterLink>
|
||||
<span v-else>—</span>
|
||||
· <strong>Rule:</strong> <code>{{ problem.rule_id ?? "—" }}</code>
|
||||
</p>
|
||||
<p><strong>Summary:</strong> {{ problem.summary }}</p>
|
||||
<p>
|
||||
<strong>Событий:</strong> {{ problem.event_count ?? "—" }}
|
||||
· <strong>Last seen:</strong> {{ formatDt(problem.last_seen_at ?? problem.updated_at) }}
|
||||
· <strong>Обновлено:</strong> {{ formatDt(problem.updated_at) }}
|
||||
</p>
|
||||
<p v-if="problem.fingerprint">
|
||||
<strong>Fingerprint:</strong> <code>{{ problem.fingerprint }}</code>
|
||||
</p>
|
||||
<p class="actions">
|
||||
<button
|
||||
v-if="problem.status === 'open'"
|
||||
type="button"
|
||||
class="secondary"
|
||||
:disabled="acting"
|
||||
@click="doAck"
|
||||
>
|
||||
Ack
|
||||
</button>
|
||||
<button v-if="problem.status !== 'resolved'" type="button" :disabled="acting" @click="doResolve">
|
||||
Resolve
|
||||
</button>
|
||||
<span v-else class="actions-muted">Закрыта</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2>Таймлайн событий</h2>
|
||||
<p v-if="!problem.events.length">Нет связанных событий.</p>
|
||||
<table v-else class="timeline-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Время</th>
|
||||
<th>Severity</th>
|
||||
<th>Type</th>
|
||||
<th>Title</th>
|
||||
<th>Summary</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="e in problem.events" :key="e.id">
|
||||
<td>{{ formatDt(e.occurred_at) }}</td>
|
||||
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
|
||||
<td>
|
||||
<RouterLink :to="`/events/${e.id}`"><code>{{ e.type }}</code></RouterLink>
|
||||
</td>
|
||||
<td>{{ e.title }}</td>
|
||||
<td>{{ e.summary }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { ackProblem, fetchProblem, resolveProblem, type ProblemDetail } from "../api";
|
||||
|
||||
const props = defineProps<{ id: string }>();
|
||||
|
||||
const problem = ref<ProblemDetail | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const acting = ref(false);
|
||||
|
||||
function formatDt(iso: string) {
|
||||
return new Date(iso).toLocaleString("ru-RU");
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
problem.value = await fetchProblem(Number(props.id));
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Не найдено";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doAck() {
|
||||
if (!problem.value) return;
|
||||
acting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await ackProblem(problem.value.id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Ошибка ack";
|
||||
} finally {
|
||||
acting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doResolve() {
|
||||
if (!problem.value) return;
|
||||
acting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await resolveProblem(problem.value.id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Ошибка resolve";
|
||||
} finally {
|
||||
acting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
watch(() => props.id, load);
|
||||
</script>
|
||||
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<h1>Проблемы</h1>
|
||||
<div class="filters">
|
||||
<select v-model="statusFilter" @change="applyFilters">
|
||||
<option value="">Все статусы</option>
|
||||
<option value="open">open</option>
|
||||
<option value="acknowledged">acknowledged</option>
|
||||
<option value="resolved">resolved</option>
|
||||
</select>
|
||||
<select v-model="severityFilter" @change="applyFilters">
|
||||
<option value="">Все severity</option>
|
||||
<option>info</option>
|
||||
<option>warning</option>
|
||||
<option>high</option>
|
||||
<option>critical</option>
|
||||
</select>
|
||||
<input v-model="hostnameFilter" placeholder="hostname / имя сервера" @keyup.enter="applyFilters" />
|
||||
<button type="button" @click="applyFilters">Применить</button>
|
||||
</div>
|
||||
<p v-if="timeFilterHint" class="muted">{{ timeFilterHint }}</p>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="loading">Загрузка…</p>
|
||||
<template v-else>
|
||||
<p>Всего: {{ data?.total ?? 0 }}</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Обновлено</th>
|
||||
<th>Хост</th>
|
||||
<th>Имя сервера</th>
|
||||
<th>Severity</th>
|
||||
<th>Status</th>
|
||||
<th>Rule</th>
|
||||
<th>Title</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in data?.items ?? []" :key="p.id">
|
||||
<td>
|
||||
<RouterLink :to="`/problems/${p.id}`">{{ p.id }}</RouterLink>
|
||||
</td>
|
||||
<td>{{ formatDt(p.updated_at) }}</td>
|
||||
<td>{{ p.hostname ?? "—" }}</td>
|
||||
<td>{{ formatServerName(p.display_name) }}</td>
|
||||
<td :class="'sev-' + p.severity">{{ p.severity }}</td>
|
||||
<td :class="'status-' + p.status">{{ p.status }}</td>
|
||||
<td><code>{{ p.rule_id ?? "—" }}</code></td>
|
||||
<td>
|
||||
<RouterLink :to="`/problems/${p.id}`">{{ p.title }}</RouterLink>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<div class="actions-inner">
|
||||
<button
|
||||
v-if="p.status === 'open'"
|
||||
type="button"
|
||||
class="secondary"
|
||||
:disabled="actingId === p.id"
|
||||
@click="doAck(p.id)"
|
||||
>
|
||||
Ack
|
||||
</button>
|
||||
<button
|
||||
v-if="p.status !== 'resolved'"
|
||||
type="button"
|
||||
:disabled="actingId === p.id"
|
||||
@click="doResolve(p.id)"
|
||||
>
|
||||
Resolve
|
||||
</button>
|
||||
<span v-if="p.status === 'resolved'" class="actions-muted">Закрыта</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="filters" style="margin-top: 1rem">
|
||||
<button type="button" class="secondary" :disabled="page <= 1" @click="load(page - 1)">Назад</button>
|
||||
<span>Стр. {{ page }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary"
|
||||
:disabled="!data || page * pageSize >= data.total"
|
||||
@click="load(page + 1)"
|
||||
>
|
||||
Вперёд
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ackProblem, apiFetch, resolveProblem, type ProblemListResponse } from "../api";
|
||||
import { formatServerName } from "../utils/hostDisplay";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const data = ref<ProblemListResponse | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const page = ref(1);
|
||||
const pageSize = 50;
|
||||
const statusFilter = ref("");
|
||||
const severityFilter = ref("");
|
||||
const hostnameFilter = ref("");
|
||||
const createdWithinHours = ref("");
|
||||
const resolvedWithinHours = ref("");
|
||||
const actingId = ref<number | null>(null);
|
||||
|
||||
const timeFilterHint = computed(() => {
|
||||
if (createdWithinHours.value) {
|
||||
return `Показаны активные проблемы, созданные за последние ${createdWithinHours.value} ч (без закрытых).`;
|
||||
}
|
||||
if (resolvedWithinHours.value) {
|
||||
return `Показаны проблемы, закрытые за последние ${resolvedWithinHours.value} ч.`;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
function formatDt(iso: string) {
|
||||
return new Date(iso).toLocaleString("ru-RU");
|
||||
}
|
||||
|
||||
async function load(p: number) {
|
||||
page.value = p;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page.value),
|
||||
page_size: String(pageSize),
|
||||
});
|
||||
if (statusFilter.value && !resolvedWithinHours.value) {
|
||||
params.set("status", statusFilter.value);
|
||||
}
|
||||
if (severityFilter.value) params.set("severity", severityFilter.value);
|
||||
if (hostnameFilter.value.trim()) params.set("hostname", hostnameFilter.value.trim());
|
||||
if (createdWithinHours.value) params.set("created_within_hours", createdWithinHours.value);
|
||||
if (resolvedWithinHours.value) params.set("resolved_within_hours", resolvedWithinHours.value);
|
||||
data.value = await apiFetch<ProblemListResponse>(`/api/v1/problems?${params}`);
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Ошибка загрузки";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildProblemsQuery(): Record<string, string> {
|
||||
const q: Record<string, string> = {};
|
||||
if (statusFilter.value && !resolvedWithinHours.value) q.status = statusFilter.value;
|
||||
if (severityFilter.value) q.severity = severityFilter.value;
|
||||
if (hostnameFilter.value.trim()) q.hostname = hostnameFilter.value.trim();
|
||||
if (createdWithinHours.value) q.created_within_hours = createdWithinHours.value;
|
||||
if (resolvedWithinHours.value) q.resolved_within_hours = resolvedWithinHours.value;
|
||||
return q;
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
if (resolvedWithinHours.value) createdWithinHours.value = "";
|
||||
else if (createdWithinHours.value) resolvedWithinHours.value = "";
|
||||
void router.replace({ path: "/problems", query: buildProblemsQuery() });
|
||||
void load(1);
|
||||
}
|
||||
|
||||
function syncFromRoute() {
|
||||
const status = route.query.status;
|
||||
statusFilter.value = typeof status === "string" ? status : "";
|
||||
const severity = route.query.severity;
|
||||
severityFilter.value = typeof severity === "string" ? severity : "";
|
||||
const hostname = route.query.hostname;
|
||||
hostnameFilter.value = typeof hostname === "string" ? hostname : "";
|
||||
const created = route.query.created_within_hours;
|
||||
createdWithinHours.value = typeof created === "string" ? created : "";
|
||||
const resolved = route.query.resolved_within_hours;
|
||||
resolvedWithinHours.value = typeof resolved === "string" ? resolved : "";
|
||||
if (resolvedWithinHours.value) statusFilter.value = "resolved";
|
||||
}
|
||||
|
||||
async function doAck(id: number) {
|
||||
actingId.value = id;
|
||||
error.value = "";
|
||||
try {
|
||||
await ackProblem(id);
|
||||
await load(page.value);
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Ошибка ack";
|
||||
} finally {
|
||||
actingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function doResolve(id: number) {
|
||||
actingId.value = id;
|
||||
error.value = "";
|
||||
try {
|
||||
await resolveProblem(id);
|
||||
await load(page.value);
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Ошибка resolve";
|
||||
} finally {
|
||||
actingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
syncFromRoute();
|
||||
load(1);
|
||||
});
|
||||
|
||||
watch(
|
||||
() =>
|
||||
[
|
||||
route.query.status,
|
||||
route.query.severity,
|
||||
route.query.hostname,
|
||||
route.query.created_within_hours,
|
||||
route.query.resolved_within_hours,
|
||||
] as const,
|
||||
() => {
|
||||
syncFromRoute();
|
||||
load(1);
|
||||
},
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<h1>Отчёты агентов</h1>
|
||||
<p class="report-intro">
|
||||
Ежедневные сводки с агентов. Нажмите на строку, чтобы открыть полный отчёт с форматированием.
|
||||
</p>
|
||||
|
||||
<div class="filters">
|
||||
<input v-model="hostname" placeholder="hostname / имя сервера" @keyup.enter="load(1)" />
|
||||
<select v-model="reportType" @change="load(1)">
|
||||
<option value="report.daily.ssh">Отчёты ssh клиентов</option>
|
||||
<option value="report.daily.rdp">Отчёты RDP клиентов</option>
|
||||
</select>
|
||||
<button type="button" @click="load(1)">Применить</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="loading">Загрузка…</p>
|
||||
<template v-else>
|
||||
<p>Всего: {{ data?.total ?? 0 }}</p>
|
||||
<table class="reports-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Время</th>
|
||||
<th>Хост</th>
|
||||
<th>Имя сервера</th>
|
||||
<th>Тип</th>
|
||||
<th>Сводка</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="e in data?.items ?? []"
|
||||
:key="e.id"
|
||||
class="report-row"
|
||||
tabindex="0"
|
||||
@click="openReport(e.id)"
|
||||
@keyup.enter="openReport(e.id)"
|
||||
>
|
||||
<td>{{ formatDt(e.occurred_at) }}</td>
|
||||
<td>
|
||||
<strong>{{ e.hostname }}</strong>
|
||||
</td>
|
||||
<td>{{ formatServerName(e.display_name) }}</td>
|
||||
<td>{{ reportTypeLabel(e.type) }}</td>
|
||||
<td class="report-summary-cell">{{ e.summary }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="filters" style="margin-top: 1rem">
|
||||
<button type="button" class="secondary" :disabled="page <= 1" @click="load(page - 1)">Назад</button>
|
||||
<span>Стр. {{ page }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary"
|
||||
:disabled="!data || page * pageSize >= data.total"
|
||||
@click="load(page + 1)"
|
||||
>
|
||||
Вперёд
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { apiFetch, type EventListResponse } from "../api";
|
||||
import { formatServerName } from "../utils/hostDisplay";
|
||||
import { dailyReportTypeLabel } from "../utils/reportDisplay";
|
||||
|
||||
function reportTypeLabel(type: string) {
|
||||
return dailyReportTypeLabel(type);
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const data = ref<EventListResponse | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const page = ref(1);
|
||||
const pageSize = 30;
|
||||
const hostname = ref("");
|
||||
const reportType = ref("report.daily.ssh");
|
||||
const REPORT_TYPE_KEY = "sac_reports_type";
|
||||
|
||||
function formatDt(iso: string) {
|
||||
return new Date(iso).toLocaleString("ru-RU");
|
||||
}
|
||||
|
||||
function openReport(id: number) {
|
||||
const query: Record<string, string> = { from: "reports", type: reportType.value };
|
||||
if (hostname.value.trim()) query.hostname = hostname.value.trim();
|
||||
router.push({ path: `/events/${id}`, query });
|
||||
}
|
||||
|
||||
async function load(p: number) {
|
||||
page.value = p;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page.value),
|
||||
page_size: String(pageSize),
|
||||
severity: "info",
|
||||
});
|
||||
params.set("type", reportType.value);
|
||||
if (hostname.value.trim()) {
|
||||
params.set("hostname", hostname.value.trim());
|
||||
}
|
||||
data.value = await apiFetch<EventListResponse>(`/api/v1/events?${params}`);
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Ошибка загрузки";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const h = route.query.hostname;
|
||||
if (typeof h === "string" && h) hostname.value = h;
|
||||
const t = route.query.type;
|
||||
if (typeof t === "string" && t) {
|
||||
reportType.value = t;
|
||||
} else {
|
||||
const savedType = localStorage.getItem(REPORT_TYPE_KEY);
|
||||
if (savedType === "report.daily.ssh" || savedType === "report.daily.rdp") {
|
||||
reportType.value = savedType;
|
||||
}
|
||||
}
|
||||
load(1);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.query.hostname,
|
||||
(h) => {
|
||||
if (typeof h === "string") {
|
||||
hostname.value = h;
|
||||
load(1);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(reportType, (value) => {
|
||||
localStorage.setItem(REPORT_TYPE_KEY, value);
|
||||
});
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,320 @@
|
||||
<template>
|
||||
<div class="users-page">
|
||||
<h1>Пользователи</h1>
|
||||
<p class="users-intro">
|
||||
Учётные записи входа в веб-панель SAC. Роль <strong>monitor</strong> — просмотр без раздела «Настройки».
|
||||
</p>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="success" class="success">{{ success }}</p>
|
||||
|
||||
<section class="card users-card" :class="{ 'users-card-editing': editingUser }">
|
||||
<h2>{{ editingUser ? `Редактирование: ${editingUser.username}` : "Новый пользователь" }}</h2>
|
||||
<form class="users-form" @submit.prevent="submitForm">
|
||||
<label>
|
||||
Логин
|
||||
<input v-model="form.username" required minlength="2" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
{{ editingUser ? "Новый пароль" : "Пароль" }}
|
||||
<input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
:required="!editingUser"
|
||||
minlength="8"
|
||||
autocomplete="new-password"
|
||||
:placeholder="editingUser ? 'оставьте пустым, чтобы не менять' : ''"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Роль
|
||||
<select v-model="form.role">
|
||||
<option value="monitor">monitor — просмотр</option>
|
||||
<option value="admin">admin — полный доступ</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="editingUser" class="users-inline">
|
||||
<input v-model="form.is_active" type="checkbox" />
|
||||
Активен (может входить)
|
||||
</label>
|
||||
<div class="users-actions">
|
||||
<button type="submit" :disabled="submitting">
|
||||
{{ submitting ? "Сохранение…" : editingUser ? "Сохранить изменения" : "Создать" }}
|
||||
</button>
|
||||
<button
|
||||
v-if="editingUser"
|
||||
type="button"
|
||||
class="secondary"
|
||||
:disabled="submitting"
|
||||
@click="cancelEdit"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card users-card">
|
||||
<h2>Список</h2>
|
||||
<p v-if="loading">Загрузка…</p>
|
||||
<table v-else class="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Логин</th>
|
||||
<th>Роль</th>
|
||||
<th>Статус</th>
|
||||
<th>Создан</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="u in users" :key="u.id" :class="{ 'users-row-editing': editingUser?.id === u.id }">
|
||||
<td>{{ u.username }}</td>
|
||||
<td><code>{{ u.role }}</code></td>
|
||||
<td>{{ u.is_active ? "активен" : "отключён" }}</td>
|
||||
<td>{{ formatDate(u.created_at) }}</td>
|
||||
<td>
|
||||
<button type="button" class="secondary users-edit-btn" @click="startEdit(u)">Изменить</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, reactive, ref } from "vue";
|
||||
import {
|
||||
createUser as apiCreateUser,
|
||||
fetchUsers,
|
||||
updateUser,
|
||||
type UserSummary,
|
||||
} from "../api";
|
||||
|
||||
const users = ref<UserSummary[]>([]);
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
const error = ref("");
|
||||
const success = ref("");
|
||||
const editingUser = ref<UserSummary | null>(null);
|
||||
|
||||
const form = reactive({
|
||||
username: "",
|
||||
password: "",
|
||||
role: "monitor",
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleString();
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
error.value = "";
|
||||
success.value = "";
|
||||
}
|
||||
|
||||
function resetCreateForm() {
|
||||
form.username = "";
|
||||
form.password = "";
|
||||
form.role = "monitor";
|
||||
form.is_active = true;
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await fetchUsers();
|
||||
users.value = data.items;
|
||||
if (editingUser.value) {
|
||||
const fresh = data.items.find((u) => u.id === editingUser.value?.id);
|
||||
if (fresh) {
|
||||
editingUser.value = fresh;
|
||||
fillEditForm(fresh);
|
||||
} else {
|
||||
cancelEdit();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Ошибка загрузки";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function fillEditForm(u: UserSummary) {
|
||||
form.username = u.username;
|
||||
form.password = "";
|
||||
form.role = u.role;
|
||||
form.is_active = u.is_active;
|
||||
}
|
||||
|
||||
async function startEdit(u: UserSummary) {
|
||||
clearMessages();
|
||||
editingUser.value = u;
|
||||
fillEditForm(u);
|
||||
await nextTick();
|
||||
document.querySelector(".users-card-editing")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingUser.value = null;
|
||||
resetCreateForm();
|
||||
clearMessages();
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
if (editingUser.value) {
|
||||
await saveEdit();
|
||||
} else {
|
||||
await createUser();
|
||||
}
|
||||
}
|
||||
|
||||
async function createUser() {
|
||||
submitting.value = true;
|
||||
clearMessages();
|
||||
try {
|
||||
await apiCreateUser({
|
||||
username: form.username.trim(),
|
||||
password: form.password,
|
||||
role: form.role,
|
||||
});
|
||||
success.value = `Пользователь ${form.username} создан`;
|
||||
resetCreateForm();
|
||||
await loadUsers();
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Ошибка создания";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editingUser.value) return;
|
||||
|
||||
const payload: {
|
||||
username?: string;
|
||||
role?: string;
|
||||
password?: string;
|
||||
is_active?: boolean;
|
||||
} = {};
|
||||
|
||||
const trimmedUsername = form.username.trim();
|
||||
if (trimmedUsername !== editingUser.value.username) {
|
||||
payload.username = trimmedUsername;
|
||||
}
|
||||
if (form.role !== editingUser.value.role) {
|
||||
payload.role = form.role;
|
||||
}
|
||||
if (form.is_active !== editingUser.value.is_active) {
|
||||
payload.is_active = form.is_active;
|
||||
}
|
||||
if (form.password.trim()) {
|
||||
if (form.password.length < 8) {
|
||||
error.value = "Пароль должен быть не короче 8 символов";
|
||||
return;
|
||||
}
|
||||
payload.password = form.password;
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
error.value = "Нет изменений для сохранения";
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
clearMessages();
|
||||
try {
|
||||
const updated = await updateUser(editingUser.value.id, payload);
|
||||
success.value = `Пользователь ${updated.username} обновлён`;
|
||||
editingUser.value = updated;
|
||||
fillEditForm(updated);
|
||||
await loadUsers();
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Ошибка сохранения";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadUsers);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.users-page {
|
||||
max-width: 52rem;
|
||||
}
|
||||
|
||||
.users-intro {
|
||||
color: #9aa4b2;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.users-card {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.users-card-editing {
|
||||
border-color: #3d5a80;
|
||||
box-shadow: 0 0 0 1px rgba(61, 90, 128, 0.35);
|
||||
}
|
||||
|
||||
.users-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.users-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.users-inline {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 0.5rem !important;
|
||||
}
|
||||
|
||||
.users-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.users-edit-btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.users-row-editing td {
|
||||
background: rgba(61, 90, 128, 0.15);
|
||||
}
|
||||
|
||||
.users-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.users-table th,
|
||||
.users-table td {
|
||||
text-align: left;
|
||||
padding: 0.45rem 0.5rem;
|
||||
border-bottom: 1px solid #2a3441;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff8a8a;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #7dcea0;
|
||||
}
|
||||
</style>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": "http://127.0.0.1:8000",
|
||||
"/health": "http://127.0.0.1:8000",
|
||||
"/healthz": "http://127.0.0.1:8000",
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user