chore(home): mirror from kalinamall (9883e6a) with papatramp URLs

This commit is contained in:
2026-07-14 20:43:52 +10:00
commit ed4e78f6c3
312 changed files with 42790 additions and 0 deletions
+9
View File
@@ -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
+16
View File
@@ -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>
+1578
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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

+11
View File
@@ -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

+28
View File
@@ -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>
+825
View File
@@ -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[];
}
+237
View File
@@ -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>
+54
View File
@@ -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>
+199
View File
@@ -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);
}
+188
View File
@@ -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 };
}
+57
View File
@@ -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 };
}
+53
View File
@@ -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];
}
+6
View File
@@ -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");
+72
View File
@@ -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 */
}
}
+465
View File
@@ -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;
}
+66
View File
@@ -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;
}
+80
View File
@@ -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;
}
+94
View File
@@ -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;
}
+37
View File
@@ -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;
}
+5
View File
@@ -0,0 +1,5 @@
/** Display name from host card (SAC «Имя сервера»). */
export function formatServerName(displayName?: string | null): string {
const name = displayName?.trim();
return name || "—";
}
+16
View File
@@ -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);
}
}
+30
View File
@@ -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;
}
}
+32
View File
@@ -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.");
}
+191
View File
@@ -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;
}
+32
View File
@@ -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;
}
}
+17
View File
@@ -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;
}
+4
View File
@@ -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}`;
+664
View File
@@ -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>
+124
View File
@@ -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>
+162
View File
@@ -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">302303 за несколько секунд возможна зависшая сессия на ПК пользователя.</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>
+303
View File
@@ -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
+557
View File
@@ -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>
+83
View File
@@ -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>
+136
View File
@@ -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>
+228
View File
@@ -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>
+147
View File
@@ -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
+320
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+17
View File
@@ -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"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+18
View File
@@ -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,
},
});