feat: phase 1C JWT UI, events/hosts API, Vue frontend

This commit is contained in:
PTah
2026-05-26 21:46:34 +10:00
parent d8a8329397
commit bdfc7016e6
27 changed files with 2498 additions and 23 deletions
+3 -1
View File
@@ -8,7 +8,9 @@ venv/
!.env.example
deploy/.env
# Node / frontend (будущее)
# Node / frontend
frontend/node_modules/
frontend/dist/
node_modules/
dist/
.nuxt/
+3 -1
View File
@@ -12,7 +12,9 @@
## Статус проекта
**Фаза 1B:** backend (FastAPI ingest), развёртывание **native** (systemd + PostgreSQL + nginx). Параллельно — `UseSAC` в агентах (фаза 1A).
**Фаза 1B:** ingest на prod (`sac.kalinamall.ru`) — готово.
**Фаза 1C (в работе):** Vue UI (события, хосты), JWT-вход.
**Фаза 1A (в работе):** `UseSAC` / `--check-sac` в [ssh-monitor](https://git.kalinamall.ru/PapaTramp/ssh-monitor).
**Подготовка сервера Ubuntu:** [docs/install-ubuntu-24.04-native.md](docs/install-ubuntu-24.04-native.md)
+31
View File
@@ -0,0 +1,31 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from app.auth.jwt_auth import create_access_token
from app.config import get_settings
router = APIRouter(prefix="/auth", tags=["auth"])
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
@router.post("/login", response_model=TokenResponse)
def login(body: LoginRequest) -> TokenResponse:
settings = get_settings()
if not settings.sac_admin_password:
raise HTTPException(
status_code=503,
detail="SAC_ADMIN_PASSWORD is not configured",
)
if body.username != settings.sac_admin_username or body.password != settings.sac_admin_password:
raise HTTPException(status_code=401, detail="Invalid username or password")
token = create_access_token(body.username)
return TokenResponse(access_token=token)
+113 -2
View File
@@ -1,12 +1,17 @@
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import func, select
from sqlalchemy.orm import Session, joinedload
from app.auth.api_key import get_api_key_auth
from app.auth.jwt_auth import get_current_user
from app.config import get_settings
from app.database import get_db
from app.models import Event, Host
from app.schemas.list_models import EventDetail, EventListResponse, EventSummary
from app.services.ingest import ingest_event
from app.services.schema_validate import validate_event_payload
@@ -41,3 +46,109 @@ def post_event(
created=created,
sac_event_url=f"{base}/api/v1/events/{event.id}",
)
def _parse_optional_dt(value: str | None) -> datetime | None:
if not value:
return None
return datetime.fromisoformat(value.replace("Z", "+00:00"))
@router.get("", response_model=EventListResponse)
def list_events(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
severity: str | None = None,
type: str | None = None,
host_id: int | None = None,
hostname: str | None = None,
from_time: str | None = Query(None, alias="from"),
to_time: str | None = Query(None, alias="to"),
q: str | None = None,
db: Session = Depends(get_db),
_user: str = Depends(get_current_user),
) -> EventListResponse:
stmt = select(Event).join(Host).options(joinedload(Event.host))
count_stmt = select(func.count()).select_from(Event).join(Host)
if severity:
stmt = stmt.where(Event.severity == severity)
count_stmt = count_stmt.where(Event.severity == severity)
if type:
stmt = stmt.where(Event.type == type)
count_stmt = count_stmt.where(Event.type == type)
if host_id is not None:
stmt = stmt.where(Event.host_id == host_id)
count_stmt = count_stmt.where(Event.host_id == host_id)
if hostname:
like = f"%{hostname}%"
stmt = stmt.where(Host.hostname.ilike(like))
count_stmt = count_stmt.where(Host.hostname.ilike(like))
dt_from = _parse_optional_dt(from_time)
dt_to = _parse_optional_dt(to_time)
if dt_from:
stmt = stmt.where(Event.occurred_at >= dt_from)
count_stmt = count_stmt.where(Event.occurred_at >= dt_from)
if dt_to:
stmt = stmt.where(Event.occurred_at <= dt_to)
count_stmt = count_stmt.where(Event.occurred_at <= dt_to)
if q:
like = f"%{q}%"
stmt = stmt.where(Event.summary.ilike(like) | Event.title.ilike(like))
count_stmt = count_stmt.where(Event.summary.ilike(like) | Event.title.ilike(like))
total = db.scalar(count_stmt) or 0
rows = db.scalars(
stmt.order_by(Event.occurred_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
).all()
items = [
EventSummary(
id=e.id,
event_id=e.event_id,
host_id=e.host_id,
hostname=e.host.hostname,
occurred_at=e.occurred_at,
received_at=e.received_at,
category=e.category,
type=e.type,
severity=e.severity,
title=e.title,
summary=e.summary,
)
for e in rows
]
return EventListResponse(items=items, total=total, page=page, page_size=page_size)
@router.get("/{event_db_id}", response_model=EventDetail)
def get_event(
event_db_id: int,
db: Session = Depends(get_db),
_user: str = Depends(get_current_user),
) -> EventDetail:
event = db.scalar(
select(Event).where(Event.id == event_db_id).options(joinedload(Event.host))
)
if event is None:
raise HTTPException(status_code=404, detail="Event not found")
return EventDetail(
id=event.id,
event_id=event.event_id,
host_id=event.host_id,
hostname=event.host.hostname,
occurred_at=event.occurred_at,
received_at=event.received_at,
category=event.category,
type=event.type,
severity=event.severity,
title=event.title,
summary=event.summary,
details=event.details,
raw=event.raw,
dedup_key=event.dedup_key,
correlation_id=event.correlation_id,
payload=event.payload,
)
+59
View File
@@ -0,0 +1,59 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.auth.jwt_auth import get_current_user
from app.database import get_db
from app.models import Event, Host
from app.schemas.list_models import HostListResponse, HostSummary
router = APIRouter(prefix="/hosts", tags=["hosts"])
@router.get("", response_model=HostListResponse)
def list_hosts(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
product: str | None = None,
hostname: str | None = None,
db: Session = Depends(get_db),
_user: str = Depends(get_current_user),
) -> HostListResponse:
stmt = select(Host)
count_stmt = select(func.count()).select_from(Host)
if product:
stmt = stmt.where(Host.product == product)
count_stmt = count_stmt.where(Host.product == product)
if hostname:
like = f"%{hostname}%"
stmt = stmt.where(Host.hostname.ilike(like))
count_stmt = count_stmt.where(Host.hostname.ilike(like))
total = db.scalar(count_stmt) or 0
rows = db.scalars(
stmt.order_by(Host.last_seen_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
).all()
items: list[HostSummary] = []
for host in rows:
event_count = db.scalar(
select(func.count()).select_from(Event).where(Event.host_id == host.id)
)
items.append(
HostSummary(
id=host.id,
hostname=host.hostname,
display_name=host.display_name,
os_family=host.os_family,
product=host.product,
product_version=host.product_version,
ipv4=host.ipv4,
last_seen_at=host.last_seen_at,
event_count=int(event_count or 0),
)
)
return HostListResponse(items=items, total=total, page=page, page_size=page_size)
+3 -1
View File
@@ -1,7 +1,9 @@
from fastapi import APIRouter
from app.api.v1 import events, health
from app.api.v1 import auth, events, health, hosts
api_router = APIRouter()
api_router.include_router(health.router)
api_router.include_router(auth.router)
api_router.include_router(events.router)
api_router.include_router(hosts.router)
+39
View File
@@ -0,0 +1,39 @@
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from app.config import get_settings
bearer_scheme = HTTPBearer(auto_error=False)
def create_access_token(subject: str) -> str:
settings = get_settings()
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
payload = {"sub": subject, "exp": expire}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
) -> str:
if credentials is None or credentials.scheme.lower() != "bearer":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
)
settings = get_settings()
try:
payload = jwt.decode(
credentials.credentials,
settings.jwt_secret,
algorithms=[settings.jwt_algorithm],
)
username = payload.get("sub")
if not username:
raise HTTPException(status_code=401, detail="Invalid token")
return str(username)
except JWTError as exc:
raise HTTPException(status_code=401, detail="Invalid token") from exc
+9
View File
@@ -1,7 +1,9 @@
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlalchemy import select
from app.api.v1.router import api_router
@@ -10,6 +12,8 @@ from app.config import get_settings
from app.database import SessionLocal
from app.models import ApiKey
ROOT = Path(__file__).resolve().parents[2]
def bootstrap_api_key() -> None:
settings = get_settings()
@@ -60,6 +64,11 @@ def create_app() -> FastAPI:
app.include_router(api_router, prefix="/api/v1")
app.include_router(health_router)
frontend_dist = ROOT / "frontend" / "dist"
if frontend_dist.is_dir():
app.mount("/", StaticFiles(directory=str(frontend_dist), html=True), name="ui")
return app
+55
View File
@@ -0,0 +1,55 @@
from datetime import datetime
from pydantic import BaseModel, Field
class HostSummary(BaseModel):
id: int
hostname: str
display_name: str | None
os_family: str
product: str
product_version: str | None
ipv4: str | None
last_seen_at: datetime
event_count: int | None = None
model_config = {"from_attributes": True}
class HostListResponse(BaseModel):
items: list[HostSummary]
total: int
page: int
page_size: int
class EventSummary(BaseModel):
id: int
event_id: str
host_id: int
hostname: str
occurred_at: datetime
received_at: datetime
category: str
type: str
severity: str
title: str
summary: str
model_config = {"from_attributes": True}
class EventDetail(EventSummary):
details: dict | None = None
raw: dict | None = None
dedup_key: str | None = None
correlation_id: str | None = None
payload: dict
class EventListResponse(BaseModel):
items: list[EventSummary]
total: int
page: int
page_size: int
+24 -5
View File
@@ -146,6 +146,7 @@ sudo chmod 600 /opt/security-alert-center/config/sac-api.env
- `JWT_SECRET``openssl rand -hex 32`
- `SAC_PUBLIC_URL``https://sac.kalinamall.ru`
- `SAC_BOOTSTRAP_API_KEY``python3.12 -c "import secrets; print('sac_'+secrets.token_urlsafe(32))"`
- `SAC_ADMIN_PASSWORD` — пароль входа в веб-UI (отдельно от API key агентов)
- `EVENT_SCHEMA_PATH=/opt/security-alert-center/schemas/event-schema-v1.json`
---
@@ -291,7 +292,24 @@ sudo certbot --nginx -d sac.kalinamall.ru
---
## 10. Проверка
## 10. Сборка веб-UI (фаза 1C)
```bash
sudo apt install -y nodejs npm
cd /opt/security-alert-center
sudo -u sac git pull
cd frontend
sudo -u sac npm ci
sudo -u sac npm run build
ls -la dist/index.html
sudo systemctl restart sac-api
```
В `config/sac-api.env` задайте `SAC_ADMIN_PASSWORD`, затем откройте `https://sac.kalinamall.ru/` (логин `SAC_ADMIN_USERNAME`, по умолчанию `admin`).
---
## 11. Проверка
Локально на сервере:
@@ -330,7 +348,7 @@ curl -sS -X POST http://127.0.0.1:8000/api/v1/events \
---
## 11. Резервное копирование
## 12. Резервное копирование
```bash
sudo tee /etc/cron.d/sac-backup <<'EOF'
@@ -342,7 +360,7 @@ EOF
---
## 12. Обновление SAC
## 13. Обновление SAC
```bash
cd /opt/security-alert-center
@@ -354,7 +372,7 @@ sudo systemctl restart sac-api
---
## 13. Чеклист
## 14. Чеклист
- [ ] Ubuntu 24.04, timezone
- [ ] ufw: 22, 80, 443
@@ -365,11 +383,12 @@ sudo systemctl restart sac-api
- [ ] `sac-api` active (systemd)
- [ ] nginx + TLS
- [ ] `/health` ok, ingest 202
- [ ] `frontend/dist` собран, UI открывается, вход admin OK
- [ ] cron backup
---
## 14. Устранение неполадок
## 15. Устранение неполадок
| Симптом | Действие |
|---------|----------|
+13 -13
View File
@@ -21,10 +21,10 @@
| # | Задача | Статус |
|---|--------|--------|
| 1A.1 | Параметры `UseSAC`, `SAC_URL`, `SAC_API_KEY`, spool | |
| 1A.2 | `build_sac_event()` + `send_sac_event()` по schema v1 | |
| 1A.3 | `notify_or_sac()`: off / dual / exclusive / fallback | |
| 1A.4 | `--check-sac` / `Test-SacConnection` | |
| 1A.1 | Параметры `UseSAC`, `SAC_URL`, `SAC_API_KEY`, spool | 🔄 ssh-monitor |
| 1A.2 | `build_sac_event()` + `send_sac_event()` по schema v1 | 🔄 `sac-client.sh` |
| 1A.3 | `notify_or_sac()`: off / dual / exclusive / fallback | 🔄 |
| 1A.4 | `--check-sac` / `Test-SacConnection` | 🔄 ssh-monitor `--check-sac` |
| 1A.5 | README агентов | ⏳ |
**Выход:** на тестовом хосте `dual` шлёт JSON в SAC + Telegram.
@@ -35,11 +35,11 @@
| # | Задача | Статус |
|---|--------|--------|
| 1B.1 | Scaffold backend FastAPI | 🔄 |
| 1B.2 | PostgreSQL + Alembic (hosts, events, api_keys) | 🔄 |
| 1B.3 | `POST /api/v1/events`, `GET /health` | 🔄 |
| 1B.4 | systemd + nginx + [install-ubuntu-24.04-native.md](install-ubuntu-24.04-native.md) | 🔄 |
| 1B.5 | Валидация JSON Schema v1 | |
| 1B.1 | Scaffold backend FastAPI | |
| 1B.2 | PostgreSQL + Alembic (hosts, events, api_keys) | |
| 1B.3 | `POST /api/v1/events`, `GET /health` | ✅ (prod) |
| 1B.4 | systemd + nginx + [install-ubuntu-24.04-native.md](install-ubuntu-24.04-native.md) | |
| 1B.5 | Валидация JSON Schema v1 | |
**Выход:** сервер Ubuntu принимает события от curl и от агентов в `dual`.
@@ -47,10 +47,10 @@
## Фаза 1C. SAC — MVP UI и оповещения
| # | Задача |
|---|--------|
| 1C.1 | Frontend Vue: Events, Hosts |
| 1C.2 | Auth JWT, admin bootstrap |
| # | Задача | Статус |
|---|--------|--------|
| 1C.1 | Frontend Vue: Events, Hosts | 🔄 |
| 1C.2 | Auth JWT, admin bootstrap | 🔄 |
| 1C.3 | Problems (базовые правила) |
| 1C.4 | Telegram из SAC |
| 1C.5 | Dashboard (3 виджета), SSE |
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Security Alert Center</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1561
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "sac-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.7.2",
"vite": "^6.0.3",
"vue-tsc": "^2.2.0"
}
}
+27
View File
@@ -0,0 +1,27 @@
<template>
<div class="layout">
<nav v-if="showNav">
<span class="brand">SAC</span>
<RouterLink to="/events">События</RouterLink>
<RouterLink to="/hosts">Хосты</RouterLink>
<button type="button" class="secondary" @click="logout">Выход</button>
</nav>
<RouterView />
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { clearToken, getToken } from "./api";
const route = useRoute();
const router = useRouter();
const showNav = computed(() => route.path !== "/login" && !!getToken());
function logout() {
clearToken();
router.push("/login");
}
</script>
+86
View File
@@ -0,0 +1,86 @@
const TOKEN_KEY = "sac_token";
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY);
}
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();
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
const res = await fetch(path, { ...init, headers });
if (res.status === 401) {
clearToken();
window.location.href = "/login";
throw new Error("Unauthorized");
}
if (!res.ok) {
const text = await res.text();
throw new Error(text || res.statusText);
}
return res.json() as Promise<T>;
}
export interface TokenResponse {
access_token: string;
token_type: string;
}
export interface EventSummary {
id: number;
event_id: string;
host_id: number;
hostname: string;
occurred_at: string;
received_at: string;
category: string;
type: string;
severity: string;
title: string;
summary: string;
}
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;
product: string;
product_version: string | null;
ipv4: string | null;
last_seen_at: string;
event_count: number | null;
}
export interface HostListResponse {
items: HostSummary[];
total: number;
page: number;
page_size: number;
}
+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");
+25
View File
@@ -0,0 +1,25 @@
import { createRouter, createWebHistory } from "vue-router";
import { getToken } from "./api";
import EventsView from "./views/EventsView.vue";
import EventDetailView from "./views/EventDetailView.vue";
import HostsView from "./views/HostsView.vue";
import LoginView from "./views/LoginView.vue";
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", redirect: "/events" },
{ path: "/login", component: LoginView },
{ path: "/events", component: EventsView },
{ path: "/events/:id", component: EventDetailView, props: true },
{ path: "/hosts", component: HostsView },
],
});
router.beforeEach((to) => {
if (to.path === "/login") return true;
if (!getToken()) return { path: "/login" };
return true;
});
export default router;
+117
View File
@@ -0,0 +1,117 @@
: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;
}
.layout {
max-width: 1200px;
margin: 0 auto;
padding: 1rem 1.25rem 2rem;
}
nav {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #2a3441;
}
nav .brand {
font-weight: 700;
color: #fff;
margin-right: auto;
}
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;
}
.sev-critical,
.sev-high {
color: #ff6b6b;
}
.sev-warning {
color: #ffc857;
}
.sev-info {
color: #7eb8ff;
}
.card {
background: #1a2332;
border: 1px solid #2a3441;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
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;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.error {
color: #ff6b6b;
}
pre {
overflow: auto;
font-size: 0.8rem;
background: #0f1419;
padding: 0.75rem;
border-radius: 6px;
}
+46
View File
@@ -0,0 +1,46 @@
<template>
<p><RouterLink to="/events"> События</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>
<div class="card">
<p><strong>ID:</strong> {{ event.id }} · <strong>event_id:</strong> {{ event.event_id }}</p>
<p><strong>Хост:</strong> {{ event.hostname }} · <strong>Severity:</strong>
<span :class="'sev-' + event.severity">{{ event.severity }}</span>
</p>
<p><strong>Type:</strong> <code>{{ event.type }}</code></p>
<p><strong>Occurred:</strong> {{ event.occurred_at }}</p>
<p>{{ event.summary }}</p>
</div>
<h2>details</h2>
<pre>{{ JSON.stringify(event.details, null, 2) }}</pre>
<h2>payload</h2>
<pre>{{ JSON.stringify(event.payload, null, 2) }}</pre>
</template>
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from "vue";
import { apiFetch, type EventDetail } from "../api";
const props = defineProps<{ id: string }>();
const event = ref<EventDetail | null>(null);
const loading = ref(false);
const error = ref("");
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>
+96
View File
@@ -0,0 +1,96 @@
<template>
<h1>События</h1>
<div class="filters">
<input v-model="q" placeholder="Поиск в summary/title" @keyup.enter="load(1)" />
<select v-model="severity" @change="load(1)">
<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="load(1)" />
<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>
<thead>
<tr>
<th>ID</th>
<th>Время</th>
<th>Хост</th>
<th>Severity</th>
<th>Type</th>
<th>Title</th>
</tr>
</thead>
<tbody>
<tr v-for="e in data?.items ?? []" :key="e.id">
<td>
<RouterLink :to="`/events/${e.id}`">{{ e.id }}</RouterLink>
</td>
<td>{{ formatDt(e.occurred_at) }}</td>
<td>{{ e.hostname }}</td>
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
<td><code>{{ e.type }}</code></td>
<td>{{ e.title }}</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 } from "vue";
import { apiFetch, type EventListResponse } from "../api";
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("");
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 (severity.value) params.set("severity", severity.value);
if (typeFilter.value) params.set("type", typeFilter.value);
if (q.value.trim()) params.set("q", q.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(() => load(1));
</script>
+56
View File
@@ -0,0 +1,56 @@
<template>
<h1>Хосты</h1>
<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>Hostname</th>
<th>Product</th>
<th>OS</th>
<th>IPv4</th>
<th>Last seen</th>
<th>Events</th>
</tr>
</thead>
<tbody>
<tr v-for="h in data?.items ?? []" :key="h.id">
<td>{{ h.id }}</td>
<td>{{ h.display_name || h.hostname }}</td>
<td>{{ h.product }} {{ h.product_version || "" }}</td>
<td>{{ h.os_family }}</td>
<td>{{ h.ipv4 || "—" }}</td>
<td>{{ formatDt(h.last_seen_at) }}</td>
<td>{{ h.event_count ?? 0 }}</td>
</tr>
</tbody>
</table>
</template>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { apiFetch, type HostListResponse } from "../api";
const data = ref<HostListResponse | null>(null);
const loading = ref(false);
const error = ref("");
function formatDt(iso: string) {
return new Date(iso).toLocaleString("ru-RU");
}
onMounted(async () => {
loading.value = true;
try {
data.value = await apiFetch<HostListResponse>("/api/v1/hosts?page=1&page_size=100");
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка";
} finally {
loading.value = false;
}
});
</script>
+48
View File
@@ -0,0 +1,48 @@
<template>
<div class="card" style="max-width: 360px; margin: 4rem auto">
<h1 style="margin-top: 0">Вход в SAC</h1>
<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, setToken, 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 }),
});
setToken(data.access_token);
await router.push("/events");
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка входа";
} finally {
loading.value = false;
}
}
</script>
+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"]
}
+17
View File
@@ -0,0 +1,17 @@
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",
},
},
build: {
outDir: "dist",
emptyOutDir: true,
},
});