feat(api, web): implement policy mode management for agents and rules
- Added support for policy modes ('blacklist' and 'whitelist') in agent and policy set management.
- Updated API endpoints to handle policy mode during agent assignment and rule operations.
- Enhanced the web UI to display and manage policy modes for agents and rules, ensuring all assigned sets share a consistent mode.
- Introduced new validation to enforce single policy mode across assigned sets for agents.
- Improved error handling for policy mode conflicts and updated documentation accordingly.
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -7,6 +7,8 @@ import {
|
|||||||
createPolicySetBodySchema,
|
createPolicySetBodySchema,
|
||||||
createInstallLinkBodySchema,
|
createInstallLinkBodySchema,
|
||||||
patchPolicySetBodySchema,
|
patchPolicySetBodySchema,
|
||||||
|
patchPolicyRuleBodySchema,
|
||||||
|
reorderPolicyRulesBodySchema,
|
||||||
putAgentPolicySetsBodySchema,
|
putAgentPolicySetsBodySchema,
|
||||||
patchAgentBodySchema,
|
patchAgentBodySchema,
|
||||||
cloneFromBodySchema,
|
cloneFromBodySchema,
|
||||||
@@ -74,6 +76,8 @@ function mapPolicySet(
|
|||||||
name: s.name,
|
name: s.name,
|
||||||
description: s.description,
|
description: s.description,
|
||||||
enabled: s.enabled === 1,
|
enabled: s.enabled === 1,
|
||||||
|
policy_mode:
|
||||||
|
s.policyMode === 'whitelist' ? ('whitelist' as const) : ('blacklist' as const),
|
||||||
rules_count: repos.countRulesInSet(db, s.id),
|
rules_count: repos.countRulesInSet(db, s.id),
|
||||||
agents_count: repos.countAgentsForSet(db, s.id),
|
agents_count: repos.countAgentsForSet(db, s.id),
|
||||||
created_at: s.createdAt,
|
created_at: s.createdAt,
|
||||||
@@ -90,6 +94,7 @@ function mapPolicyRule(
|
|||||||
set_id: r.setId,
|
set_id: r.setId,
|
||||||
priority: r.priority,
|
priority: r.priority,
|
||||||
action: r.action,
|
action: r.action,
|
||||||
|
enabled: r.enabled !== 0,
|
||||||
list_id: r.listId,
|
list_id: r.listId,
|
||||||
cidr: r.cidr,
|
cidr: r.cidr,
|
||||||
hostname: r.hostname,
|
hostname: r.hostname,
|
||||||
@@ -496,6 +501,7 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
name: body.name.trim(),
|
name: body.name.trim(),
|
||||||
description: body.description ?? null,
|
description: body.description ?? null,
|
||||||
enabled: body.enabled === false ? 0 : 1,
|
enabled: body.enabled === false ? 0 : 1,
|
||||||
|
policyMode: body.policy_mode === 'whitelist' ? 'whitelist' : 'blacklist',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
@@ -510,8 +516,37 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
name: body.name?.trim(),
|
name: body.name?.trim(),
|
||||||
description: body.description,
|
description: body.description,
|
||||||
enabled: body.enabled === undefined ? undefined : body.enabled ? 1 : 0,
|
enabled: body.enabled === undefined ? undefined : body.enabled ? 1 : 0,
|
||||||
|
policyMode: body.policy_mode,
|
||||||
})
|
})
|
||||||
if (body.enabled !== undefined) repos.bumpAgentsForSet(app.db, s.id)
|
if (body.enabled !== undefined || body.policy_mode !== undefined) {
|
||||||
|
repos.bumpAgentsForSet(app.db, s.id)
|
||||||
|
}
|
||||||
|
// Sync agent.policy_mode cache when set mode changes
|
||||||
|
if (body.policy_mode) {
|
||||||
|
for (const agentId of repos.listAgentIdsForSet(app.db, s.id)) {
|
||||||
|
try {
|
||||||
|
const sets = repos.listSetsForAgent(app.db, agentId)
|
||||||
|
const modes = new Set(
|
||||||
|
sets
|
||||||
|
.filter((x) => x.enabled === 1)
|
||||||
|
.map((x) =>
|
||||||
|
x.policyMode === 'whitelist' ? 'whitelist' : 'blacklist',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (modes.size > 1) {
|
||||||
|
throw new AppError(
|
||||||
|
'VALIDATION_ERROR',
|
||||||
|
'агент имеет наборы с разными режимами — выровняйте mode',
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const mode = [...modes][0] ?? body.policy_mode
|
||||||
|
repos.updateAgent(app.db, agentId, { policyMode: mode })
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AppError) throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return mapPolicySet(updated!, app.db)
|
return mapPolicySet(updated!, app.db)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -554,7 +589,15 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
throw new AppError('NOT_FOUND', `Policy set not found: ${setId}`, 404)
|
throw new AppError('NOT_FOUND', `Policy set not found: ${setId}`, 404)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
repos.setAgentPolicySets(app.db, a.id, body.set_ids)
|
try {
|
||||||
|
repos.setAgentPolicySets(app.db, a.id, body.set_ids)
|
||||||
|
} catch (err) {
|
||||||
|
throw new AppError(
|
||||||
|
'VALIDATION_ERROR',
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
items: repos.listSetsForAgent(app.db, a.id).map((s) => ({
|
items: repos.listSetsForAgent(app.db, a.id).map((s) => ({
|
||||||
set_id: s.setId,
|
set_id: s.setId,
|
||||||
@@ -562,6 +605,8 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
name: s.name,
|
name: s.name,
|
||||||
description: s.description,
|
description: s.description,
|
||||||
enabled: s.enabled === 1,
|
enabled: s.enabled === 1,
|
||||||
|
policy_mode:
|
||||||
|
s.policyMode === 'whitelist' ? 'whitelist' : 'blacklist',
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -579,6 +624,8 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
name: s.name,
|
name: s.name,
|
||||||
description: s.description,
|
description: s.description,
|
||||||
enabled: s.enabled === 1,
|
enabled: s.enabled === 1,
|
||||||
|
policy_mode:
|
||||||
|
s.policyMode === 'whitelist' ? 'whitelist' : 'blacklist',
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -627,12 +674,16 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
throw new AppError('NOT_FOUND', 'IP list not found', 404)
|
throw new AppError('NOT_FOUND', 'IP list not found', 404)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const priority =
|
||||||
|
body.priority ?? repos.nextRulePriority(app.db, body.set_id)
|
||||||
|
|
||||||
const id = crypto.randomUUID()
|
const id = crypto.randomUUID()
|
||||||
const row = repos.insertPolicyRule(app.db, {
|
const row = repos.insertPolicyRule(app.db, {
|
||||||
id,
|
id,
|
||||||
setId: body.set_id,
|
setId: body.set_id,
|
||||||
priority: body.priority,
|
priority,
|
||||||
action: body.action,
|
action: body.action,
|
||||||
|
enabled: body.enabled === false ? 0 : 1,
|
||||||
listId,
|
listId,
|
||||||
cidr,
|
cidr,
|
||||||
hostname,
|
hostname,
|
||||||
@@ -659,6 +710,44 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
return mapPolicyRule(row!, app.db)
|
return mapPolicyRule(row!, app.db)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.patch<{ Params: { id: string } }>('/rules/:id', async (req) => {
|
||||||
|
const body = patchPolicyRuleBodySchema.parse(req.body)
|
||||||
|
const rule = repos.getPolicyRule(app.db, req.params.id)
|
||||||
|
if (!rule) throw new AppError('NOT_FOUND', 'Rule not found', 404)
|
||||||
|
const updated = repos.updatePolicyRule(app.db, rule.id, {
|
||||||
|
enabled: body.enabled === undefined ? undefined : body.enabled ? 1 : 0,
|
||||||
|
action: body.action,
|
||||||
|
comment: body.comment,
|
||||||
|
priority: body.priority,
|
||||||
|
})
|
||||||
|
repos.bumpAgentsForSet(app.db, rule.setId)
|
||||||
|
return mapPolicyRule(updated!, app.db)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put<{ Params: { id: string } }>(
|
||||||
|
'/policy-sets/:id/rules/reorder',
|
||||||
|
async (req) => {
|
||||||
|
const body = reorderPolicyRulesBodySchema.parse(req.body)
|
||||||
|
const s = repos.getPolicySet(app.db, req.params.id)
|
||||||
|
if (!s) throw new AppError('NOT_FOUND', 'Policy set not found', 404)
|
||||||
|
try {
|
||||||
|
repos.reorderPolicyRules(app.db, s.id, body.ordered_ids)
|
||||||
|
} catch (err) {
|
||||||
|
throw new AppError(
|
||||||
|
'VALIDATION_ERROR',
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
repos.bumpAgentsForSet(app.db, s.id)
|
||||||
|
return {
|
||||||
|
items: repos
|
||||||
|
.listPolicyRules(app.db, s.id)
|
||||||
|
.map((r) => mapPolicyRule(r, app.db)),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
app.delete<{ Params: { id: string } }>('/rules/:id', async (req) => {
|
app.delete<{ Params: { id: string } }>('/rules/:id', async (req) => {
|
||||||
const rule = repos.getPolicyRule(app.db, req.params.id)
|
const rule = repos.getPolicyRule(app.db, req.params.id)
|
||||||
if (!rule) throw new AppError('NOT_FOUND', 'Rule not found', 404)
|
if (!rule) throw new AppError('NOT_FOUND', 'Rule not found', 404)
|
||||||
|
|||||||
@@ -44,6 +44,19 @@ function expandRule(
|
|||||||
return expandList(db, rule.listId)
|
return expandList(db, rule.listId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Effective mode = first enabled assigned set (by sort); default blacklist. */
|
||||||
|
export function resolveAgentPolicyMode(
|
||||||
|
db: Db,
|
||||||
|
agentId: string,
|
||||||
|
): 'blacklist' | 'whitelist' {
|
||||||
|
const sets = repos
|
||||||
|
.listSetsForAgent(db, agentId)
|
||||||
|
.filter((s) => s.enabled === 1)
|
||||||
|
if (sets.length === 0) return 'blacklist'
|
||||||
|
const mode = sets[0]?.policyMode
|
||||||
|
return mode === 'whitelist' ? 'whitelist' : 'blacklist'
|
||||||
|
}
|
||||||
|
|
||||||
/** Evaluate allow/deny sets for an agent from assigned policy sets. */
|
/** Evaluate allow/deny sets for an agent from assigned policy sets. */
|
||||||
export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
|
export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
|
||||||
const agent = repos.getAgent(db, agentId)
|
const agent = repos.getAgent(db, agentId)
|
||||||
@@ -69,9 +82,12 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
|
|||||||
|
|
||||||
const denyCidrs = uniq(deny)
|
const denyCidrs = uniq(deny)
|
||||||
const allowCidrs = uniq(allow)
|
const allowCidrs = uniq(allow)
|
||||||
const policyMode = (agent.policyMode === 'whitelist'
|
const policyMode = resolveAgentPolicyMode(db, agentId)
|
||||||
? 'whitelist'
|
|
||||||
: 'blacklist') as 'blacklist' | 'whitelist'
|
// Keep agent.policy_mode cache in sync for list/API compat
|
||||||
|
if (agent.policyMode !== policyMode) {
|
||||||
|
repos.updateAgent(db, agentId, { policyMode })
|
||||||
|
}
|
||||||
|
|
||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({
|
||||||
generation: agent.policyGeneration,
|
generation: agent.policyGeneration,
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
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',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('policy set mode + rules', () => {
|
||||||
|
const appPromise = buildApp({ memory: true, config: testConfig })
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const app = await appPromise
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('set policy_mode and reorder; disabled rules skipped in policy', async () => {
|
||||||
|
const app = await appPromise
|
||||||
|
await app.ready()
|
||||||
|
|
||||||
|
const created = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/policy-sets',
|
||||||
|
payload: {
|
||||||
|
name: 'WL set',
|
||||||
|
policy_mode: 'whitelist',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(created.statusCode).toBe(200)
|
||||||
|
const set = created.json() as { id: string; policy_mode: string }
|
||||||
|
expect(set.policy_mode).toBe('whitelist')
|
||||||
|
|
||||||
|
const r1 = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/rules',
|
||||||
|
payload: {
|
||||||
|
set_id: set.id,
|
||||||
|
action: 'allow',
|
||||||
|
cidr: '10.0.0.1/32',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(r1.statusCode).toBe(200)
|
||||||
|
const rule1 = r1.json() as { id: string; enabled: boolean; priority: number }
|
||||||
|
|
||||||
|
const r2 = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/rules',
|
||||||
|
payload: {
|
||||||
|
set_id: set.id,
|
||||||
|
action: 'allow',
|
||||||
|
cidr: '10.0.0.2/32',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const rule2 = r2.json() as { id: string }
|
||||||
|
|
||||||
|
const reordered = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/v1/policy-sets/${set.id}/rules/reorder`,
|
||||||
|
payload: { ordered_ids: [rule2.id, rule1.id] },
|
||||||
|
})
|
||||||
|
expect(reordered.statusCode).toBe(200)
|
||||||
|
const items = (
|
||||||
|
reordered.json() as { items: { id: string; priority: number }[] }
|
||||||
|
).items
|
||||||
|
expect(items[0]?.id).toBe(rule2.id)
|
||||||
|
expect(items[0]!.priority).toBeLessThan(items[1]!.priority)
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/v1/rules/${rule1.id}`,
|
||||||
|
payload: { enabled: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
// enroll + approve agent, assign set
|
||||||
|
const enroll = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/agent/enroll',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-evofw-seed': 'test-seed',
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
name: 'mt-wl',
|
||||||
|
platform: 'linux',
|
||||||
|
token: 'evofw_policy_mode_token_abcdef12',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const agent = enroll.json() as { id: string }
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/v1/agents/${agent.id}/approve`,
|
||||||
|
})
|
||||||
|
const assign = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/v1/agents/${agent.id}/policy-sets`,
|
||||||
|
payload: { set_ids: [set.id] },
|
||||||
|
})
|
||||||
|
expect(assign.statusCode).toBe(200)
|
||||||
|
|
||||||
|
const policy = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/v1/agent/policy',
|
||||||
|
headers: {
|
||||||
|
authorization: 'Bearer evofw_policy_mode_token_abcdef12',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(policy.statusCode).toBe(200)
|
||||||
|
const body = policy.json() as {
|
||||||
|
policy_mode: string
|
||||||
|
allow_cidrs: string[]
|
||||||
|
}
|
||||||
|
expect(body.policy_mode).toBe('whitelist')
|
||||||
|
expect(body.allow_cidrs).toContain('10.0.0.2/32')
|
||||||
|
expect(body.allow_cidrs).not.toContain('10.0.0.1/32')
|
||||||
|
expect(rule1.enabled).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Router, Terminal } from 'lucide-react'
|
||||||
|
import { cn } from '@evofw/ui/lib/utils'
|
||||||
|
import { Item, ItemMedia } from '@evofw/ui/components/item'
|
||||||
|
|
||||||
|
type AgentPlatformIconProps = {
|
||||||
|
platform: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KPI-style platform tile for agent rows.
|
||||||
|
* Preview DNA: https://reui.io/preview/base/stats-12 · settings-14
|
||||||
|
*/
|
||||||
|
export function AgentPlatformIcon({
|
||||||
|
platform,
|
||||||
|
className,
|
||||||
|
}: AgentPlatformIconProps) {
|
||||||
|
const isMt = platform === 'mikrotik'
|
||||||
|
const Icon = isMt ? Router : Terminal
|
||||||
|
return (
|
||||||
|
<Item
|
||||||
|
className={cn(
|
||||||
|
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||||
|
isMt ? 'text-info' : 'text-muted-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-label={isMt ? 'MikroTik' : 'Linux'}
|
||||||
|
>
|
||||||
|
<ItemMedia variant="icon" className="size-auto">
|
||||||
|
<Icon aria-hidden />
|
||||||
|
</ItemMedia>
|
||||||
|
</Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function platformLabel(platform: string): string {
|
||||||
|
return platform === 'mikrotik' ? 'MikroTik' : 'Linux'
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
Children,
|
||||||
|
cloneElement,
|
||||||
|
createContext,
|
||||||
|
CSSProperties,
|
||||||
|
isValidElement,
|
||||||
|
ReactElement,
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
import {
|
||||||
|
defaultDropAnimationSideEffects,
|
||||||
|
DndContext,
|
||||||
|
DragCancelEvent,
|
||||||
|
DragEndEvent,
|
||||||
|
DragOverlay,
|
||||||
|
DragStartEvent,
|
||||||
|
DropAnimation,
|
||||||
|
KeyboardSensor,
|
||||||
|
MeasuringStrategy,
|
||||||
|
Modifiers,
|
||||||
|
MouseSensor,
|
||||||
|
TouchSensor,
|
||||||
|
UniqueIdentifier,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DraggableSyntheticListeners,
|
||||||
|
} from "@dnd-kit/core"
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
defaultAnimateLayoutChanges,
|
||||||
|
rectSortingStrategy,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
type AnimateLayoutChanges,
|
||||||
|
} from "@dnd-kit/sortable"
|
||||||
|
import { CSS } from "@dnd-kit/utilities"
|
||||||
|
import { createPortal } from "react-dom"
|
||||||
|
|
||||||
|
import { cn } from "@evofw/ui/lib/utils"
|
||||||
|
|
||||||
|
// Sortable Item Context
|
||||||
|
const SortableItemContext = createContext<{
|
||||||
|
listeners: DraggableSyntheticListeners | undefined
|
||||||
|
isDragging?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
}>({
|
||||||
|
listeners: undefined,
|
||||||
|
isDragging: false,
|
||||||
|
disabled: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const IsOverlayContext = createContext(false)
|
||||||
|
|
||||||
|
const SortableInternalContext = createContext<{
|
||||||
|
activeId: UniqueIdentifier | null
|
||||||
|
modifiers?: Modifiers
|
||||||
|
}>({
|
||||||
|
activeId: null,
|
||||||
|
modifiers: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const animateLayoutChanges: AnimateLayoutChanges = (args) =>
|
||||||
|
defaultAnimateLayoutChanges({ ...args, wasDragging: true })
|
||||||
|
|
||||||
|
const dropAnimationConfig: DropAnimation = {
|
||||||
|
sideEffects: defaultDropAnimationSideEffects({
|
||||||
|
styles: {
|
||||||
|
active: {
|
||||||
|
opacity: "0.4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOUSE_SENSOR_OPTIONS = { activationConstraint: { distance: 10 } }
|
||||||
|
const TOUCH_SENSOR_OPTIONS = {
|
||||||
|
activationConstraint: { delay: 250, tolerance: 5 },
|
||||||
|
}
|
||||||
|
const KEYBOARD_SENSOR_OPTIONS = {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
}
|
||||||
|
const MEASURING_CONFIG = {
|
||||||
|
droppable: { strategy: MeasuringStrategy.Always },
|
||||||
|
}
|
||||||
|
const STRATEGY_MAP = {
|
||||||
|
horizontal: rectSortingStrategy,
|
||||||
|
grid: rectSortingStrategy,
|
||||||
|
vertical: verticalListSortingStrategy,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// Multipurpose Sortable Component
|
||||||
|
export interface SortableCommitMeta<T> {
|
||||||
|
event: DragEndEvent
|
||||||
|
activeIndex: number
|
||||||
|
overIndex: number
|
||||||
|
previousValue: T[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SortableRootProps<T> extends Omit<
|
||||||
|
useRender.ComponentProps<"div">,
|
||||||
|
"onDragStart" | "onDragEnd" | "children"
|
||||||
|
> {
|
||||||
|
value: T[]
|
||||||
|
onValueChange: (value: T[]) => void
|
||||||
|
getItemValue: (item: T) => string
|
||||||
|
children: ReactNode
|
||||||
|
onMove?: (event: {
|
||||||
|
event: DragEndEvent
|
||||||
|
activeIndex: number
|
||||||
|
overIndex: number
|
||||||
|
}) => void
|
||||||
|
onValueCommit?: (value: T[], meta: SortableCommitMeta<T>) => void
|
||||||
|
strategy?: "horizontal" | "vertical" | "grid"
|
||||||
|
onDragStart?: (event: DragStartEvent) => void
|
||||||
|
onDragEnd?: (event: DragEndEvent) => void
|
||||||
|
onDragCancel?: (event: DragCancelEvent) => void
|
||||||
|
accessibility?: React.ComponentProps<typeof DndContext>["accessibility"]
|
||||||
|
modifiers?: Modifiers
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sortable<T>({
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
getItemValue,
|
||||||
|
className,
|
||||||
|
render,
|
||||||
|
onMove,
|
||||||
|
onValueCommit,
|
||||||
|
strategy = "vertical",
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
onDragCancel,
|
||||||
|
accessibility,
|
||||||
|
modifiers,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SortableRootProps<T>) {
|
||||||
|
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null)
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
|
useLayoutEffect(() => setMounted(true), [])
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(MouseSensor, MOUSE_SENSOR_OPTIONS),
|
||||||
|
useSensor(TouchSensor, TOUCH_SENSOR_OPTIONS),
|
||||||
|
useSensor(KeyboardSensor, KEYBOARD_SENSOR_OPTIONS)
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDragStart = useCallback(
|
||||||
|
(event: DragStartEvent) => {
|
||||||
|
setActiveId(event.active.id)
|
||||||
|
onDragStart?.(event)
|
||||||
|
},
|
||||||
|
[onDragStart]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
(event: DragEndEvent) => {
|
||||||
|
const { active, over } = event
|
||||||
|
setActiveId(null)
|
||||||
|
onDragEnd?.(event)
|
||||||
|
|
||||||
|
if (!over) return
|
||||||
|
|
||||||
|
// Handle item reordering
|
||||||
|
const activeIndex = value.findIndex(
|
||||||
|
(item: T) => getItemValue(item) === active.id
|
||||||
|
)
|
||||||
|
const overIndex = value.findIndex(
|
||||||
|
(item: T) => getItemValue(item) === over.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (activeIndex === -1 || overIndex === -1) return
|
||||||
|
|
||||||
|
if (activeIndex !== overIndex) {
|
||||||
|
if (onMove) {
|
||||||
|
onMove({ event, activeIndex, overIndex })
|
||||||
|
} else {
|
||||||
|
const newValue = arrayMove(value, activeIndex, overIndex)
|
||||||
|
onValueChange(newValue)
|
||||||
|
onValueCommit?.(newValue, {
|
||||||
|
event,
|
||||||
|
activeIndex,
|
||||||
|
overIndex,
|
||||||
|
previousValue: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[value, getItemValue, onValueChange, onMove, onDragEnd, onValueCommit]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDragCancel = useCallback(
|
||||||
|
(event: DragCancelEvent) => {
|
||||||
|
setActiveId(null)
|
||||||
|
onDragCancel?.(event)
|
||||||
|
},
|
||||||
|
[onDragCancel]
|
||||||
|
)
|
||||||
|
|
||||||
|
const itemIds = useMemo(() => {
|
||||||
|
const ids = value.map(getItemValue)
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const id of ids) {
|
||||||
|
if (seen.has(id)) {
|
||||||
|
console.warn(
|
||||||
|
`[Sortable] Duplicate item id "${id}". Item ids must be unique, or drag and drop will misbehave.`
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
seen.add(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}, [value, getItemValue])
|
||||||
|
|
||||||
|
const contextValue = useMemo(
|
||||||
|
() => ({ activeId, modifiers }),
|
||||||
|
[activeId, modifiers]
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
"data-slot": "sortable",
|
||||||
|
"data-dragging": activeId !== null,
|
||||||
|
className: cn(activeId !== null && "cursor-grabbing!", className),
|
||||||
|
children,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the active child for the overlay
|
||||||
|
const overlayContent = useMemo(() => {
|
||||||
|
if (!activeId) return null
|
||||||
|
let result: ReactNode = null
|
||||||
|
Children.forEach(children, (child) => {
|
||||||
|
if (isValidElement(child) && (child.props as any).value === activeId) {
|
||||||
|
result = cloneElement(child as ReactElement<any>, {
|
||||||
|
...(child.props as any),
|
||||||
|
className: cn((child.props as any).className, "z-50"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}, [activeId, children])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SortableInternalContext.Provider value={contextValue}>
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
modifiers={modifiers}
|
||||||
|
accessibility={accessibility}
|
||||||
|
measuring={MEASURING_CONFIG}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragCancel={handleDragCancel}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={itemIds}
|
||||||
|
strategy={STRATEGY_MAP[strategy] ?? verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
{useRender({
|
||||||
|
defaultTagName: "div",
|
||||||
|
render,
|
||||||
|
props: mergeProps<"div">(defaultProps, props),
|
||||||
|
})}
|
||||||
|
</SortableContext>
|
||||||
|
{mounted &&
|
||||||
|
createPortal(
|
||||||
|
<DragOverlay
|
||||||
|
dropAnimation={dropAnimationConfig}
|
||||||
|
modifiers={modifiers}
|
||||||
|
className={cn("z-50", activeId && "cursor-grabbing")}
|
||||||
|
>
|
||||||
|
<IsOverlayContext.Provider value={true}>
|
||||||
|
{overlayContent}
|
||||||
|
</IsOverlayContext.Provider>
|
||||||
|
</DragOverlay>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</DndContext>
|
||||||
|
</SortableInternalContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SortableItemProps extends useRender.ComponentProps<"div"> {
|
||||||
|
value: string
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableItem({
|
||||||
|
value,
|
||||||
|
className,
|
||||||
|
render,
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
}: SortableItemProps) {
|
||||||
|
const isOverlay = useContext(IsOverlayContext)
|
||||||
|
|
||||||
|
const {
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
isDragging: isSortableDragging,
|
||||||
|
} = useSortable({
|
||||||
|
id: value,
|
||||||
|
disabled: disabled || isOverlay,
|
||||||
|
animateLayoutChanges,
|
||||||
|
})
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transition,
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
} as CSSProperties
|
||||||
|
|
||||||
|
const defaultProps = isOverlay
|
||||||
|
? {
|
||||||
|
"data-slot": "sortable-item",
|
||||||
|
"data-value": value,
|
||||||
|
"data-dragging": true,
|
||||||
|
className: cn(className),
|
||||||
|
children: props.children,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
"data-slot": "sortable-item",
|
||||||
|
"data-value": value,
|
||||||
|
"data-dragging": isSortableDragging,
|
||||||
|
"data-disabled": disabled,
|
||||||
|
ref: setNodeRef,
|
||||||
|
style,
|
||||||
|
...attributes,
|
||||||
|
className: cn(
|
||||||
|
isSortableDragging && "opacity-50 z-50",
|
||||||
|
disabled && "opacity-50",
|
||||||
|
className
|
||||||
|
),
|
||||||
|
children: props.children,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SortableItemContext.Provider
|
||||||
|
value={
|
||||||
|
isOverlay
|
||||||
|
? { listeners: undefined, isDragging: true, disabled: false }
|
||||||
|
: { listeners, isDragging: isSortableDragging, disabled }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{useRender({
|
||||||
|
defaultTagName: "div",
|
||||||
|
render,
|
||||||
|
props: mergeProps<"div">(defaultProps, props),
|
||||||
|
})}
|
||||||
|
</SortableItemContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SortableItemHandleProps extends useRender.ComponentProps<"div"> {
|
||||||
|
cursor?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableItemHandle({
|
||||||
|
className,
|
||||||
|
render,
|
||||||
|
cursor = true,
|
||||||
|
...props
|
||||||
|
}: SortableItemHandleProps) {
|
||||||
|
const { listeners, isDragging, disabled } = useContext(SortableItemContext)
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
"data-slot": "sortable-item-handle",
|
||||||
|
"data-dragging": isDragging,
|
||||||
|
"data-disabled": disabled,
|
||||||
|
...listeners,
|
||||||
|
className: cn(
|
||||||
|
cursor && (isDragging ? "cursor-grabbing!" : "cursor-grab!"),
|
||||||
|
className
|
||||||
|
),
|
||||||
|
children: props.children,
|
||||||
|
}
|
||||||
|
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "div",
|
||||||
|
render,
|
||||||
|
props: mergeProps<"div">(defaultProps, props),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SortableOverlayProps extends Omit<
|
||||||
|
React.ComponentProps<typeof DragOverlay>,
|
||||||
|
"children"
|
||||||
|
> {
|
||||||
|
children?: ReactNode | ((params: { value: UniqueIdentifier }) => ReactNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableOverlay({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SortableOverlayProps) {
|
||||||
|
const { activeId, modifiers } = useContext(SortableInternalContext)
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
|
useLayoutEffect(() => setMounted(true), [])
|
||||||
|
|
||||||
|
const content =
|
||||||
|
activeId && children
|
||||||
|
? typeof children === "function"
|
||||||
|
? children({ value: activeId })
|
||||||
|
: children
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (!mounted) return null
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<DragOverlay
|
||||||
|
dropAnimation={dropAnimationConfig}
|
||||||
|
modifiers={modifiers}
|
||||||
|
className={cn("z-50", activeId && "cursor-grabbing", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<IsOverlayContext.Provider value={true}>
|
||||||
|
{content}
|
||||||
|
</IsOverlayContext.Provider>
|
||||||
|
</DragOverlay>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Sortable, SortableItem, SortableItemHandle, SortableOverlay }
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
BanIcon,
|
||||||
|
GripVerticalIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import type { PolicyRule } from '@evofw/shared'
|
||||||
|
import {
|
||||||
|
Sortable,
|
||||||
|
SortableItem,
|
||||||
|
SortableItemHandle,
|
||||||
|
} from '@/components/reui/sortable'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import { Button } from '@evofw/ui/components/button'
|
||||||
|
import { Switch } from '@evofw/ui/components/switch'
|
||||||
|
import { Item, ItemMedia } from '@evofw/ui/components/item'
|
||||||
|
import { cn } from '@evofw/ui/lib/utils'
|
||||||
|
import { apiFetch } from '@/lib/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordered firewall rules — ReUI Sortable + settings-8 DNA.
|
||||||
|
* Preview: https://reui.io/preview/base/components/c-sortable-5
|
||||||
|
* · https://reui.io/preview/base/settings-8
|
||||||
|
* Docs: https://reui.io/docs/components/base/sortable
|
||||||
|
*/
|
||||||
|
|
||||||
|
function ruleTarget(r: PolicyRule): string {
|
||||||
|
if (r.cidr) return r.cidr
|
||||||
|
if (r.hostname) return r.hostname
|
||||||
|
if (r.list_id) return `list:${r.list_id.slice(0, 8)}…`
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
type PolicyRulesSortableProps = {
|
||||||
|
setId: string
|
||||||
|
rules: PolicyRule[]
|
||||||
|
policyMode: 'blacklist' | 'whitelist'
|
||||||
|
onDelete: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PolicyRulesSortable({
|
||||||
|
setId,
|
||||||
|
rules: rulesProp,
|
||||||
|
policyMode,
|
||||||
|
onDelete,
|
||||||
|
}: PolicyRulesSortableProps) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [items, setItems] = useState(rulesProp)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setItems(rulesProp)
|
||||||
|
}, [rulesProp])
|
||||||
|
|
||||||
|
const reorder = useMutation({
|
||||||
|
mutationFn: (ordered_ids: string[]) =>
|
||||||
|
apiFetch(`/api/v1/policy-sets/${setId}/rules/reorder`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ ordered_ids }),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ['policy-sets', setId] })
|
||||||
|
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
||||||
|
},
|
||||||
|
onError: (e: Error, _vars, context) => {
|
||||||
|
toast.error(e.message)
|
||||||
|
if (context && typeof context === 'object' && 'prev' in context) {
|
||||||
|
setItems((context as { prev: PolicyRule[] }).prev)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMutate: async (ordered_ids) => {
|
||||||
|
const prev = items
|
||||||
|
const byId = new Map(items.map((r) => [r.id, r]))
|
||||||
|
setItems(ordered_ids.map((id) => byId.get(id)!).filter(Boolean))
|
||||||
|
return { prev }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggle = useMutation({
|
||||||
|
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) =>
|
||||||
|
apiFetch(`/api/v1/rules/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ['policy-sets', setId] })
|
||||||
|
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const isWl = policyMode === 'whitelist'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Frame dense spacing="sm">
|
||||||
|
<FramePanel className="flex items-center gap-3 py-3">
|
||||||
|
<Badge
|
||||||
|
variant={isWl ? 'destructive-light' : 'success-light'}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{isWl ? 'DROP' : 'ACCEPT'}
|
||||||
|
</Badge>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{isWl
|
||||||
|
? 'По умолчанию DROP — ниже только allow-правила пропускают трафик'
|
||||||
|
: 'По умолчанию ACCEPT — ниже deny-правила блокируют адреса'}
|
||||||
|
</p>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<Frame dense spacing="sm">
|
||||||
|
<FramePanel className="text-muted-foreground py-8 text-center text-sm">
|
||||||
|
Нет правил — добавьте CIDR, список или hostname
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
) : (
|
||||||
|
<Frame dense spacing="sm" stacked>
|
||||||
|
<FrameHeader className="px-4 py-3">
|
||||||
|
<FrameTitle>Правила</FrameTitle>
|
||||||
|
<FrameDescription>
|
||||||
|
Перетащите для порядка · Switch — вкл/выкл
|
||||||
|
</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="p-0">
|
||||||
|
<Sortable
|
||||||
|
value={items}
|
||||||
|
onValueChange={setItems}
|
||||||
|
getItemValue={(r) => r.id}
|
||||||
|
onValueCommit={(next, meta) => {
|
||||||
|
reorder.mutate(
|
||||||
|
next.map((r) => r.id),
|
||||||
|
{ onError: () => setItems(meta.previousValue) },
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
className="flex flex-col"
|
||||||
|
>
|
||||||
|
{items.map((r) => {
|
||||||
|
const enabled = r.enabled !== false
|
||||||
|
const isDeny = r.action === 'deny'
|
||||||
|
return (
|
||||||
|
<SortableItem
|
||||||
|
key={r.id}
|
||||||
|
value={r.id}
|
||||||
|
className={cn(
|
||||||
|
'border-border flex items-center gap-3 border-b px-3 py-2.5 last:border-b-0',
|
||||||
|
!enabled && 'opacity-60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SortableItemHandle className="text-muted-foreground hover:text-foreground cursor-grab touch-none">
|
||||||
|
<GripVerticalIcon className="size-4" />
|
||||||
|
</SortableItemHandle>
|
||||||
|
|
||||||
|
<Item
|
||||||
|
className={cn(
|
||||||
|
'bg-muted flex size-9 shrink-0 items-center justify-center border-0 p-0 [&_svg]:size-4',
|
||||||
|
isDeny ? 'text-destructive' : 'text-success',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ItemMedia variant="icon" className="size-auto">
|
||||||
|
{isDeny ? (
|
||||||
|
<BanIcon aria-hidden />
|
||||||
|
) : (
|
||||||
|
<ShieldCheckIcon aria-hidden />
|
||||||
|
)}
|
||||||
|
</ItemMedia>
|
||||||
|
</Item>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="truncate font-medium font-mono text-sm">
|
||||||
|
{ruleTarget(r)}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
isDeny ? 'destructive-light' : 'success-light'
|
||||||
|
}
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
{r.action}
|
||||||
|
</Badge>
|
||||||
|
{!enabled ? (
|
||||||
|
<Badge variant="secondary" size="xs">
|
||||||
|
Выкл
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{r.comment ? (
|
||||||
|
<span className="text-muted-foreground truncate text-xs">
|
||||||
|
{r.comment}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
checked={enabled}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
toggle.mutate({ id: r.id, enabled: v })
|
||||||
|
}
|
||||||
|
aria-label={enabled ? 'Выключить' : 'Включить'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive"
|
||||||
|
aria-label="Удалить"
|
||||||
|
onClick={() => onDelete(r.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</SortableItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Sortable>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -18,6 +18,11 @@ import {
|
|||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import {
|
||||||
|
AgentPlatformIcon,
|
||||||
|
platformLabel,
|
||||||
|
} from '@/components/agents/agent-platform-icon'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||||
@@ -67,16 +72,14 @@ function AgentDetailPage() {
|
|||||||
const assignedIds =
|
const assignedIds =
|
||||||
selectedSets ?? assignedQ.data?.items.map((i) => i.set_id) ?? []
|
selectedSets ?? assignedQ.data?.items.map((i) => i.set_id) ?? []
|
||||||
|
|
||||||
const patchMode = useMutation({
|
const revoke = useMutation({
|
||||||
mutationFn: (policy_mode: 'blacklist' | 'whitelist') =>
|
mutationFn: () =>
|
||||||
apiFetch(`/api/v1/agents/${id}`, {
|
apiFetch(`/api/v1/agents/${id}/revoke`, { method: 'POST' }),
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify({ policy_mode }),
|
|
||||||
}),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Режим обновлён')
|
toast.success('Агент отозван')
|
||||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||||
},
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
const addOverride = useMutation({
|
const addOverride = useMutation({
|
||||||
@@ -207,10 +210,21 @@ function AgentDetailPage() {
|
|||||||
<DetailPanel>
|
<DetailPanel>
|
||||||
<DetailPanel.Header
|
<DetailPanel.Header
|
||||||
title={a.name}
|
title={a.name}
|
||||||
description={`${a.platform} · gen ${a.policy_generation}`}
|
description={`${platformLabel(a.platform)} · gen ${a.policy_generation}`}
|
||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<AgentPlatformIcon platform={a.platform} />
|
||||||
<StatusBadge status={a.status} />
|
<StatusBadge status={a.status} />
|
||||||
|
{a.status === 'approved' ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => revoke.mutate()}
|
||||||
|
disabled={revoke.isPending}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -261,30 +275,32 @@ function AgentDetailPage() {
|
|||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
<Frame dense spacing="sm">
|
<Frame dense spacing="sm">
|
||||||
<FrameHeader>
|
<FrameHeader>
|
||||||
<FrameTitle>Политика</FrameTitle>
|
<FrameTitle>Режим фильтра</FrameTitle>
|
||||||
<FrameDescription>
|
<FrameDescription>
|
||||||
blacklist = deny set; whitelist = allow set + default drop
|
Задаётся наборами правил (не на агенте). Все назначенные
|
||||||
|
наборы должны иметь один режим.
|
||||||
</FrameDescription>
|
</FrameDescription>
|
||||||
</FrameHeader>
|
</FrameHeader>
|
||||||
<FramePanel>
|
<FramePanel className="flex flex-wrap items-center gap-2">
|
||||||
<div className="flex flex-wrap gap-2">
|
<Badge
|
||||||
<Button
|
variant={
|
||||||
variant={
|
a.policy_mode === 'whitelist'
|
||||||
a.policy_mode === 'blacklist' ? 'default' : 'outline'
|
? 'warning-light'
|
||||||
}
|
: 'secondary'
|
||||||
onClick={() => patchMode.mutate('blacklist')}
|
}
|
||||||
>
|
size="sm"
|
||||||
Blacklist
|
>
|
||||||
</Button>
|
{a.policy_mode === 'whitelist'
|
||||||
<Button
|
? 'Белый список'
|
||||||
variant={
|
: 'Чёрный список'}
|
||||||
a.policy_mode === 'whitelist' ? 'default' : 'outline'
|
</Badge>
|
||||||
}
|
<Button
|
||||||
onClick={() => patchMode.mutate('whitelist')}
|
size="sm"
|
||||||
>
|
variant="outline"
|
||||||
Whitelist
|
render={<Link to="/rules" />}
|
||||||
</Button>
|
>
|
||||||
</div>
|
Открыть правила
|
||||||
|
</Button>
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|
||||||
@@ -292,7 +308,7 @@ function AgentDetailPage() {
|
|||||||
<FrameHeader>
|
<FrameHeader>
|
||||||
<FrameTitle>Наборы правил</FrameTitle>
|
<FrameTitle>Наборы правил</FrameTitle>
|
||||||
<FrameDescription>
|
<FrameDescription>
|
||||||
Можно назначить несколько — мержатся при sync
|
Можно назначить несколько — мержатся при sync (один режим)
|
||||||
</FrameDescription>
|
</FrameDescription>
|
||||||
</FrameHeader>
|
</FrameHeader>
|
||||||
<FramePanel className="p-0">
|
<FramePanel className="p-0">
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Check, Copy, Plus, Trash2 } from 'lucide-react'
|
import { Check, Copy, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useCallback, useMemo, useState, type MouseEvent } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +18,10 @@ import {
|
|||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { AddAgentSheet } from '@/components/agents/add-agent-sheet'
|
import { AddAgentSheet } from '@/components/agents/add-agent-sheet'
|
||||||
|
import {
|
||||||
|
AgentPlatformIcon,
|
||||||
|
platformLabel,
|
||||||
|
} from '@/components/agents/agent-platform-icon'
|
||||||
import { agentsQueryOptions } from '@/queries'
|
import { agentsQueryOptions } from '@/queries'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||||
@@ -32,15 +36,15 @@ import type { Agent } from '@evofw/shared'
|
|||||||
/**
|
/**
|
||||||
* Agents list — ResourcePage (Frame + tabs + Filters + DataGrid).
|
* Agents list — ResourcePage (Frame + tabs + Filters + DataGrid).
|
||||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||||
* Copyable install: https://reui.io/preview/base/settings-14
|
* Row icon + copyable install: https://reui.io/preview/base/settings-14 · stats-12
|
||||||
* Empty: https://reui.io/preview/base/empty-state-7
|
* Empty: https://reui.io/preview/base/empty-state-7
|
||||||
* Create Sheet: https://reui.io/preview/base/sheet-1 · sheet-8
|
|
||||||
*/
|
*/
|
||||||
export const Route = createFileRoute('/_auth/agents/')({
|
export const Route = createFileRoute('/_auth/agents/')({
|
||||||
component: AgentsPage,
|
component: AgentsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
function AgentsPage() {
|
function AgentsPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const agentsQ = useQuery(agentsQueryOptions())
|
const agentsQ = useQuery(agentsQueryOptions())
|
||||||
const { copyToClipboard } = useCopyToClipboard()
|
const { copyToClipboard } = useCopyToClipboard()
|
||||||
@@ -59,16 +63,6 @@ function AgentsPage() {
|
|||||||
onError: (e: Error) => toast.error(e.message),
|
onError: (e: Error) => toast.error(e.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
const revoke = useMutation({
|
|
||||||
mutationFn: (id: string) =>
|
|
||||||
apiFetch(`/api/v1/agents/${id}/revoke`, { method: 'POST' }),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('Агент отозван')
|
|
||||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
|
||||||
},
|
|
||||||
onError: (e: Error) => toast.error(e.message),
|
|
||||||
})
|
|
||||||
|
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: (id: string) =>
|
mutationFn: (id: string) =>
|
||||||
apiFetch(`/api/v1/agents/${id}`, { method: 'DELETE' }),
|
apiFetch(`/api/v1/agents/${id}`, { method: 'DELETE' }),
|
||||||
@@ -127,7 +121,8 @@ function AgentsPage() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleCopyCurl = useCallback(
|
const handleCopyCurl = useCallback(
|
||||||
(curl: string) => {
|
(curl: string, e?: MouseEvent) => {
|
||||||
|
e?.stopPropagation()
|
||||||
if (!curl) return
|
if (!curl) return
|
||||||
copyToClipboard(curl)
|
copyToClipboard(curl)
|
||||||
toast.success('Скопировано')
|
toast.success('Скопировано')
|
||||||
@@ -142,25 +137,19 @@ function AgentsPage() {
|
|||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataGridColumnHeader column={column} title="Имя" />
|
<DataGridColumnHeader column={column} title="Имя" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<Link
|
const a = row.original
|
||||||
to="/agents/$id"
|
return (
|
||||||
params={{ id: row.original.id }}
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
className="min-w-0"
|
<AgentPlatformIcon platform={a.platform} />
|
||||||
>
|
<DataGridPrimaryCell
|
||||||
<DataGridPrimaryCell
|
accent="primary"
|
||||||
accent="primary"
|
title={a.name}
|
||||||
title={row.original.name}
|
subtitle={a.hostname ?? platformLabel(a.platform)}
|
||||||
subtitle={row.original.hostname ?? undefined}
|
/>
|
||||||
/>
|
</div>
|
||||||
</Link>
|
)
|
||||||
),
|
},
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'platform',
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} title="Платформа" />
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
@@ -188,7 +177,7 @@ function AgentsPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="max-w-[14rem] font-mono text-xs"
|
className="max-w-[14rem] font-mono text-xs"
|
||||||
onClick={() => handleCopyCurl(curl)}
|
onClick={(e) => handleCopyCurl(curl, e)}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -202,12 +191,6 @@ function AgentsPage() {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
accessorKey: 'policy_mode',
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} title="Режим" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
accessorKey: 'last_seen_at',
|
accessorKey: 'last_seen_at',
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
@@ -229,36 +212,41 @@ function AgentsPage() {
|
|||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
{a.status === 'pending' ? (
|
{a.status === 'pending' ? (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="icon-sm"
|
||||||
onClick={() => approve.mutate(a.id)}
|
variant="ghost"
|
||||||
|
aria-label="Approve"
|
||||||
disabled={approve.isPending}
|
disabled={approve.isPending}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
approve.mutate(a.id)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Check data-icon="inline-start" />
|
<Check className="size-3.5" />
|
||||||
Approve
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
render={<Link to="/agents/$id" params={{ id: a.id }} />}
|
|
||||||
>
|
|
||||||
Открыть
|
|
||||||
</Button>
|
|
||||||
{a.status === 'approved' ? (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => revoke.mutate(a.id)}
|
|
||||||
>
|
|
||||||
Revoke
|
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label="Открыть"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
void navigate({
|
||||||
|
to: '/agents/$id',
|
||||||
|
params: { id: a.id },
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="size-3.5" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
aria-label="Удалить"
|
aria-label="Удалить"
|
||||||
onClick={() => setDeleteId(a.id)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setDeleteId(a.id)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-3.5" />
|
<Trash2 className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -267,7 +255,7 @@ function AgentsPage() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[approve, revoke, handleCopyCurl],
|
[approve, handleCopyCurl, navigate],
|
||||||
)
|
)
|
||||||
|
|
||||||
const addButton = (
|
const addButton = (
|
||||||
@@ -296,6 +284,9 @@ function AgentsPage() {
|
|||||||
onFiltersChange={setFilters}
|
onFiltersChange={setFilters}
|
||||||
onClearFilters={() => setFilters([])}
|
onClearFilters={() => setFilters([])}
|
||||||
getFilterFieldValue={getFilterFieldValue}
|
getFilterFieldValue={getFilterFieldValue}
|
||||||
|
onRowClick={(row) =>
|
||||||
|
void navigate({ to: '/agents/$id', params: { id: row.id } })
|
||||||
|
}
|
||||||
tabs={[
|
tabs={[
|
||||||
{ id: 'all', label: 'Все' },
|
{ id: 'all', label: 'Все' },
|
||||||
{ id: 'invited', label: 'Invited' },
|
{ id: 'invited', label: 'Invited' },
|
||||||
|
|||||||
@@ -5,16 +5,16 @@ import {
|
|||||||
ListIcon,
|
ListIcon,
|
||||||
ShieldIcon,
|
ShieldIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
Trash2,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import type { ColumnDef, RowSelectionState } from '@tanstack/react-table'
|
import type { ColumnDef, RowSelectionState } from '@tanstack/react-table'
|
||||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
import { PageShell, DetailPanel } from '@/components/reui-kit'
|
||||||
import { PageShell, DetailPanel, ResourcePage } from '@/components/reui-kit'
|
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
|
import { PolicyRulesSortable } from '@/components/rules/policy-rules-sortable'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
import {
|
import {
|
||||||
agentsQueryOptions,
|
agentsQueryOptions,
|
||||||
listsQueryOptions,
|
listsQueryOptions,
|
||||||
@@ -44,11 +44,8 @@ import {
|
|||||||
} from '@evofw/ui/components/sheet'
|
} from '@evofw/ui/components/sheet'
|
||||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||||
import {
|
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
|
||||||
getCoreRowModel,
|
import type { Agent } from '@evofw/shared'
|
||||||
useReactTable,
|
|
||||||
} from '@tanstack/react-table'
|
|
||||||
import type { Agent, PolicyRule } from '@evofw/shared'
|
|
||||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||||
import {
|
import {
|
||||||
Frame,
|
Frame,
|
||||||
@@ -65,8 +62,9 @@ export const Route = createFileRoute('/_auth/rules/$setId')({
|
|||||||
type SourceKind = 'list' | 'cidr' | 'hostname'
|
type SourceKind = 'list' | 'cidr' | 'hostname'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Policy set detail — DetailPanel + ResourcePage rules + agents DataGrid.
|
* Policy set detail — Sortable rules (ReUI PRO) + agents.
|
||||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2 · stats-12 · sheet-8
|
* Preview: https://reui.io/preview/base/components/c-sortable-5 · settings-8 · settings-3
|
||||||
|
* Docs: https://reui.io/docs/components/base/sortable
|
||||||
*/
|
*/
|
||||||
function PolicySetDetailPage() {
|
function PolicySetDetailPage() {
|
||||||
const { setId } = Route.useParams()
|
const { setId } = Route.useParams()
|
||||||
@@ -77,14 +75,12 @@ function PolicySetDetailPage() {
|
|||||||
const listsQ = useQuery(listsQueryOptions())
|
const listsQ = useQuery(listsQueryOptions())
|
||||||
|
|
||||||
const [ruleOpen, setRuleOpen] = useState(false)
|
const [ruleOpen, setRuleOpen] = useState(false)
|
||||||
const [priority, setPriority] = useState('100')
|
|
||||||
const [action, setAction] = useState<'allow' | 'deny'>('deny')
|
const [action, setAction] = useState<'allow' | 'deny'>('deny')
|
||||||
const [source, setSource] = useState<SourceKind>('cidr')
|
const [source, setSource] = useState<SourceKind>('cidr')
|
||||||
const [listId, setListId] = useState('')
|
const [listId, setListId] = useState('')
|
||||||
const [cidr, setCidr] = useState('')
|
const [cidr, setCidr] = useState('')
|
||||||
const [hostname, setHostname] = useState('')
|
const [hostname, setHostname] = useState('')
|
||||||
const [selectedAgents, setSelectedAgents] = useState<string[] | null>(null)
|
const [selectedAgents, setSelectedAgents] = useState<string[] | null>(null)
|
||||||
const [ruleFilters, setRuleFilters] = useState<Filter[]>([])
|
|
||||||
const [deleteRuleId, setDeleteRuleId] = useState<string | null>(null)
|
const [deleteRuleId, setDeleteRuleId] = useState<string | null>(null)
|
||||||
|
|
||||||
const assignedIds = selectedAgents ?? setQ.data?.agent_ids ?? []
|
const assignedIds = selectedAgents ?? setQ.data?.agent_ids ?? []
|
||||||
@@ -101,7 +97,11 @@ function PolicySetDetailPage() {
|
|||||||
}, [assignedIds])
|
}, [assignedIds])
|
||||||
|
|
||||||
const patchSet = useMutation({
|
const patchSet = useMutation({
|
||||||
mutationFn: (body: { enabled?: boolean; name?: string }) =>
|
mutationFn: (body: {
|
||||||
|
enabled?: boolean
|
||||||
|
name?: string
|
||||||
|
policy_mode?: 'blacklist' | 'whitelist'
|
||||||
|
}) =>
|
||||||
apiFetch(`/api/v1/policy-sets/${setId}`, {
|
apiFetch(`/api/v1/policy-sets/${setId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -109,6 +109,7 @@ function PolicySetDetailPage() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Набор обновлён')
|
toast.success('Набор обновлён')
|
||||||
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
||||||
|
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||||
},
|
},
|
||||||
onError: (e: Error) => toast.error(e.message),
|
onError: (e: Error) => toast.error(e.message),
|
||||||
})
|
})
|
||||||
@@ -145,7 +146,6 @@ function PolicySetDetailPage() {
|
|||||||
mutationFn: () => {
|
mutationFn: () => {
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
set_id: setId,
|
set_id: setId,
|
||||||
priority: Number(priority),
|
|
||||||
action,
|
action,
|
||||||
}
|
}
|
||||||
if (source === 'list') body.list_id = listId
|
if (source === 'list') body.list_id = listId
|
||||||
@@ -179,112 +179,6 @@ function PolicySetDetailPage() {
|
|||||||
|
|
||||||
const rules = rulesQ.data?.items ?? []
|
const rules = rulesQ.data?.items ?? []
|
||||||
|
|
||||||
const ruleFilterFields: FilterFieldConfig[] = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
key: 'action',
|
|
||||||
label: 'Action',
|
|
||||||
type: 'select',
|
|
||||||
options: [
|
|
||||||
{ value: 'deny', label: 'deny' },
|
|
||||||
{ value: 'allow', label: 'allow' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'source',
|
|
||||||
label: 'Источник',
|
|
||||||
type: 'select',
|
|
||||||
options: [
|
|
||||||
{ value: 'list', label: 'list' },
|
|
||||||
{ value: 'cidr', label: 'CIDR' },
|
|
||||||
{ value: 'hostname', label: 'DNS' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
const getRuleFilterValue = useCallback((item: PolicyRule, field: string) => {
|
|
||||||
if (field === 'action') return item.action
|
|
||||||
if (field === 'source') {
|
|
||||||
if (item.hostname) return 'hostname'
|
|
||||||
if (item.cidr) return 'cidr'
|
|
||||||
return 'list'
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const ruleColumns: ColumnDef<PolicyRule>[] = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
accessorKey: 'priority',
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} title="Prio" />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="tabular-nums">{row.original.priority}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'action',
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} title="Action" />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => <StatusBadge status={row.original.action} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'source',
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} title="Источник" />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const r = row.original
|
|
||||||
if (r.hostname) {
|
|
||||||
return (
|
|
||||||
<DataGridPrimaryCell
|
|
||||||
accent="mono"
|
|
||||||
title={r.hostname}
|
|
||||||
subtitle={
|
|
||||||
typeof r.resolved_count === 'number'
|
|
||||||
? `${r.resolved_count} IP`
|
|
||||||
: 'DNS'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (r.cidr) {
|
|
||||||
return <DataGridPrimaryCell accent="mono" title={r.cidr} />
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<DataGridPrimaryCell
|
|
||||||
accent="mono"
|
|
||||||
title={`list:${r.list_id?.slice(0, 8) ?? '—'}…`}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
enableSorting: false,
|
|
||||||
header: () => <span className="sr-only">Действия</span>,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-destructive"
|
|
||||||
aria-label="Удалить"
|
|
||||||
onClick={() => setDeleteRuleId(row.original.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
const agentColumns: ColumnDef<Agent>[] = useMemo(
|
const agentColumns: ColumnDef<Agent>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -377,6 +271,8 @@ function PolicySetDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const set = setQ.data
|
const set = setQ.data
|
||||||
|
const policyMode =
|
||||||
|
set.policy_mode === 'whitelist' ? 'whitelist' : 'blacklist'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
@@ -425,7 +321,9 @@ function PolicySetDetailPage() {
|
|||||||
{
|
{
|
||||||
id: 'status',
|
id: 'status',
|
||||||
icon: <ListIcon aria-hidden />,
|
icon: <ListIcon aria-hidden />,
|
||||||
iconClassName: set.enabled ? 'text-success' : 'text-muted-foreground',
|
iconClassName: set.enabled
|
||||||
|
? 'text-success'
|
||||||
|
: 'text-muted-foreground',
|
||||||
label: 'Статус',
|
label: 'Статус',
|
||||||
description: set.enabled ? 'Включён' : 'Выключен',
|
description: set.enabled ? 'Включён' : 'Выключен',
|
||||||
hint: set.enabled ? 'active' : 'disabled',
|
hint: set.enabled ? 'active' : 'disabled',
|
||||||
@@ -433,37 +331,69 @@ function PolicySetDetailPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DetailPanel.Section>
|
||||||
|
<Frame dense spacing="sm">
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>Режим фильтра</FrameTitle>
|
||||||
|
<FrameDescription>
|
||||||
|
Blacklist: блокировать deny. Whitelist: пропускать только
|
||||||
|
allow, остальное (forward) — DROP.
|
||||||
|
</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant={
|
||||||
|
policyMode === 'blacklist' ? 'default' : 'outline'
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
patchSet.mutate({ policy_mode: 'blacklist' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Чёрный список
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={
|
||||||
|
policyMode === 'whitelist' ? 'default' : 'outline'
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
patchSet.mutate({ policy_mode: 'whitelist' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Белый список
|
||||||
|
</Button>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
policyMode === 'whitelist'
|
||||||
|
? 'warning-light'
|
||||||
|
: 'secondary'
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{policyMode}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
</DetailPanel.Section>
|
||||||
|
|
||||||
<DetailPanel.Section
|
<DetailPanel.Section
|
||||||
title="Правила"
|
title="Правила"
|
||||||
description="Список IP, CIDR или DNS-имя (резолвится в A/AAAA)."
|
description="DnD порядок · Switch вкл/выкл. Preview: c-sortable-5 · settings-8"
|
||||||
>
|
>
|
||||||
<ResourcePage
|
<PolicyRulesSortable
|
||||||
title="Правила"
|
setId={setId}
|
||||||
hideHeader
|
rules={rules}
|
||||||
data={rules}
|
policyMode={policyMode}
|
||||||
columns={ruleColumns}
|
onDelete={(id) => setDeleteRuleId(id)}
|
||||||
getRowId={(r) => r.id}
|
|
||||||
filterFields={ruleFilterFields}
|
|
||||||
filters={ruleFilters}
|
|
||||||
onFiltersChange={setRuleFilters}
|
|
||||||
onClearFilters={() => setRuleFilters([])}
|
|
||||||
getFilterFieldValue={getRuleFilterValue}
|
|
||||||
isLoading={rulesQ.isLoading}
|
|
||||||
emptyState={{
|
|
||||||
title: 'Нет правил',
|
|
||||||
description: 'Добавьте CIDR, DNS или IP-список.',
|
|
||||||
action: (
|
|
||||||
<Button size="sm" onClick={() => setRuleOpen(true)}>
|
|
||||||
Правило
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</DetailPanel.Section>
|
</DetailPanel.Section>
|
||||||
|
|
||||||
<DetailPanel.Section
|
<DetailPanel.Section
|
||||||
title="Назначено агентам"
|
title="Назначено агентам"
|
||||||
description="Агент может иметь несколько наборов — они мержатся при sync."
|
description="Все наборы агента должны иметь один режим фильтра."
|
||||||
>
|
>
|
||||||
<Frame dense spacing="sm">
|
<Frame dense spacing="sm">
|
||||||
<FrameHeader>
|
<FrameHeader>
|
||||||
@@ -518,18 +448,10 @@ function PolicySetDetailPage() {
|
|||||||
<SheetHeader className="shrink-0">
|
<SheetHeader className="shrink-0">
|
||||||
<SheetTitle>Новое правило</SheetTitle>
|
<SheetTitle>Новое правило</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
Один источник: список, CIDR или DNS-имя
|
Один источник: список, CIDR или DNS-имя (priority — в конец)
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4 sm:grid-cols-2">
|
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4">
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="prio">Priority</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="prio"
|
|
||||||
value={priority}
|
|
||||||
onChange={(e) => setPriority(e.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>Action</FieldLabel>
|
<FieldLabel>Action</FieldLabel>
|
||||||
<Select
|
<Select
|
||||||
@@ -538,7 +460,7 @@ function PolicySetDetailPage() {
|
|||||||
if (v) setAction(v as 'allow' | 'deny')
|
if (v) setAction(v as 'allow' | 'deny')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -547,7 +469,7 @@ function PolicySetDetailPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field className="sm:col-span-2">
|
<Field>
|
||||||
<FieldLabel>Источник</FieldLabel>
|
<FieldLabel>Источник</FieldLabel>
|
||||||
<Select
|
<Select
|
||||||
value={source}
|
value={source}
|
||||||
@@ -566,7 +488,7 @@ function PolicySetDetailPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
{source === 'list' ? (
|
{source === 'list' ? (
|
||||||
<Field className="sm:col-span-2">
|
<Field>
|
||||||
<FieldLabel>Список</FieldLabel>
|
<FieldLabel>Список</FieldLabel>
|
||||||
<Select
|
<Select
|
||||||
value={listId || null}
|
value={listId || null}
|
||||||
@@ -586,7 +508,7 @@ function PolicySetDetailPage() {
|
|||||||
</Field>
|
</Field>
|
||||||
) : null}
|
) : null}
|
||||||
{source === 'cidr' ? (
|
{source === 'cidr' ? (
|
||||||
<Field className="sm:col-span-2">
|
<Field>
|
||||||
<FieldLabel htmlFor="cidr">CIDR</FieldLabel>
|
<FieldLabel htmlFor="cidr">CIDR</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
id="cidr"
|
id="cidr"
|
||||||
@@ -597,7 +519,7 @@ function PolicySetDetailPage() {
|
|||||||
</Field>
|
</Field>
|
||||||
) : null}
|
) : null}
|
||||||
{source === 'hostname' ? (
|
{source === 'hostname' ? (
|
||||||
<Field className="sm:col-span-2">
|
<Field>
|
||||||
<FieldLabel htmlFor="host">DNS-имя</FieldLabel>
|
<FieldLabel htmlFor="host">DNS-имя</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
id="host"
|
id="host"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
|
|||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { policySetsQueryOptions } from '@/queries'
|
import { policySetsQueryOptions } from '@/queries'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
@@ -142,6 +143,26 @@ function PolicySetsPage() {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'policy_mode',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Режим" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
row.original.policy_mode === 'whitelist'
|
||||||
|
? 'warning-light'
|
||||||
|
: 'secondary'
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{row.original.policy_mode === 'whitelist'
|
||||||
|
? 'whitelist'
|
||||||
|
: 'blacklist'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'rules_count',
|
accessorKey: 'rules_count',
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
|
|||||||
+4
-4
@@ -56,12 +56,12 @@ Install RSC:
|
|||||||
2. Создаёт filter-правила `evofw-*` и address-list `EVOFW_DENY` / `EVOFW_ALLOW`.
|
2. Создаёт filter-правила `evofw-*` и address-list `EVOFW_DENY` / `EVOFW_ALLOW`.
|
||||||
3. Scheduler `evofw-sync` каждую минуту: `GET /v1/agent/policy.rsc` → `/import` (списки + режим).
|
3. Scheduler `evofw-sync` каждую минуту: `GET /v1/agent/policy.rsc` → `/import` (списки + режим).
|
||||||
|
|
||||||
**Blacklist:** `drop` по `EVOFW_DENY` в `input` и `forward`.
|
**Режим фильтра** задаётся на **наборе правил** (`/rules`), не на агенте:
|
||||||
**Whitelist:** `accept` по `EVOFW_ALLOW` + catch-all `drop` только в `forward` (input не закрывается — Winbox/SSH).
|
|
||||||
|
|
||||||
Legacy: скачайте `/v1/agent/mikrotik-install.rsc`, задайте globals `EvofwCpUrl`, `EvofwSeed`, `EvofwName`, опционально `EvofwInstallLinkId`, затем `/import`.
|
- **blacklist** — по умолчанию ACCEPT; deny-CIDR блокируются
|
||||||
|
- **whitelist** — по умолчанию DROP (forward); только allow-CIDR
|
||||||
|
|
||||||
Одобрите агента в UI — после Approve sync начнёт применять политику.
|
Все наборы, назначенные агенту, должны иметь один режим.
|
||||||
|
|
||||||
## Force sync
|
## Force sync
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Mode on policy sets + per-rule enabled
|
||||||
|
|
||||||
|
ALTER TABLE policy_sets ADD COLUMN policy_mode TEXT NOT NULL DEFAULT 'blacklist';
|
||||||
|
|
||||||
|
ALTER TABLE policy_rules ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1;
|
||||||
|
|
||||||
|
-- Backfill set mode from agents that use the set (prefer whitelist if any agent has it)
|
||||||
|
UPDATE policy_sets
|
||||||
|
SET policy_mode = 'whitelist'
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT DISTINCT aps.set_id
|
||||||
|
FROM agent_policy_sets aps
|
||||||
|
INNER JOIN agents a ON a.id = aps.agent_id
|
||||||
|
WHERE a.policy_mode = 'whitelist'
|
||||||
|
);
|
||||||
@@ -206,6 +206,7 @@ export function listSetsForAgent(db: Db, agentId: string) {
|
|||||||
name: policySets.name,
|
name: policySets.name,
|
||||||
description: policySets.description,
|
description: policySets.description,
|
||||||
enabled: policySets.enabled,
|
enabled: policySets.enabled,
|
||||||
|
policyMode: policySets.policyMode,
|
||||||
})
|
})
|
||||||
.from(agentPolicySets)
|
.from(agentPolicySets)
|
||||||
.innerJoin(policySets, eq(agentPolicySets.setId, policySets.id))
|
.innerJoin(policySets, eq(agentPolicySets.setId, policySets.id))
|
||||||
@@ -214,8 +215,24 @@ export function listSetsForAgent(db: Db, agentId: string) {
|
|||||||
.all()
|
.all()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replace agent↔set assignments; set_ids order = sort. */
|
/** Replace agent↔set assignments; set_ids order = sort. All sets must share policy_mode. */
|
||||||
export function setAgentPolicySets(db: Db, agentId: string, setIds: string[]) {
|
export function setAgentPolicySets(db: Db, agentId: string, setIds: string[]) {
|
||||||
|
if (setIds.length > 0) {
|
||||||
|
const modes = new Set<string>()
|
||||||
|
for (const setId of setIds) {
|
||||||
|
const s = getPolicySet(db, setId)
|
||||||
|
if (!s) throw new Error(`policy set not found: ${setId}`)
|
||||||
|
modes.add(s.policyMode === 'whitelist' ? 'whitelist' : 'blacklist')
|
||||||
|
}
|
||||||
|
if (modes.size > 1) {
|
||||||
|
throw new Error(
|
||||||
|
'все наборы агента должны иметь один режим (blacklist или whitelist)',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const mode = [...modes][0] ?? 'blacklist'
|
||||||
|
updateAgent(db, agentId, { policyMode: mode })
|
||||||
|
}
|
||||||
|
|
||||||
db.delete(agentPolicySets).where(eq(agentPolicySets.agentId, agentId)).run()
|
db.delete(agentPolicySets).where(eq(agentPolicySets.agentId, agentId)).run()
|
||||||
setIds.forEach((setId, i) => {
|
setIds.forEach((setId, i) => {
|
||||||
db.insert(agentPolicySets)
|
db.insert(agentPolicySets)
|
||||||
@@ -270,6 +287,7 @@ export function listPolicyRulesForAgent(db: Db, agentId: string) {
|
|||||||
.all()
|
.all()
|
||||||
|
|
||||||
return rules
|
return rules
|
||||||
|
.filter((r) => r.enabled !== 0)
|
||||||
.map((r) => ({
|
.map((r) => ({
|
||||||
...r,
|
...r,
|
||||||
_setSort: sortBySet.get(r.setId) ?? 0,
|
_setSort: sortBySet.get(r.setId) ?? 0,
|
||||||
@@ -289,6 +307,57 @@ export function insertPolicyRule(
|
|||||||
return getPolicyRule(db, row.id)
|
return getPolicyRule(db, row.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updatePolicyRule(
|
||||||
|
db: Db,
|
||||||
|
id: string,
|
||||||
|
patch: Partial<typeof policyRules.$inferInsert>,
|
||||||
|
) {
|
||||||
|
db.update(policyRules)
|
||||||
|
.set({ ...patch, updatedAt: new Date().toISOString() })
|
||||||
|
.where(eq(policyRules.id, id))
|
||||||
|
.run()
|
||||||
|
return getPolicyRule(db, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renumber priorities 10, 20, … in given order. */
|
||||||
|
export function reorderPolicyRules(
|
||||||
|
db: Db,
|
||||||
|
setId: string,
|
||||||
|
orderedIds: string[],
|
||||||
|
) {
|
||||||
|
const existing = listPolicyRules(db, setId)
|
||||||
|
const existingIds = new Set(existing.map((r) => r.id))
|
||||||
|
if (
|
||||||
|
orderedIds.length !== existing.length ||
|
||||||
|
orderedIds.some((id) => !existingIds.has(id))
|
||||||
|
) {
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextRulePriority(db: Db, setId: string): number {
|
||||||
|
const rows = listPolicyRules(db, setId)
|
||||||
|
if (rows.length === 0) return 10
|
||||||
|
const max = Math.max(...rows.map((r) => r.priority))
|
||||||
|
return Math.min(10000, max + 10)
|
||||||
|
}
|
||||||
|
|
||||||
export function deletePolicyRule(db: Db, id: string) {
|
export function deletePolicyRule(db: Db, id: string) {
|
||||||
db.delete(policyRules).where(eq(policyRules.id, id)).run()
|
db.delete(policyRules).where(eq(policyRules.id, id)).run()
|
||||||
}
|
}
|
||||||
@@ -535,6 +604,9 @@ export const repos = {
|
|||||||
listPolicyRulesForAgent,
|
listPolicyRulesForAgent,
|
||||||
getPolicyRule,
|
getPolicyRule,
|
||||||
insertPolicyRule,
|
insertPolicyRule,
|
||||||
|
updatePolicyRule,
|
||||||
|
reorderPolicyRules,
|
||||||
|
nextRulePriority,
|
||||||
deletePolicyRule,
|
deletePolicyRule,
|
||||||
listHostnameRules,
|
listHostnameRules,
|
||||||
listResolvedForRule,
|
listResolvedForRule,
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export const policySets = sqliteTable('policy_sets', {
|
|||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
description: text('description'),
|
description: text('description'),
|
||||||
enabled: integer('enabled').notNull().default(1),
|
enabled: integer('enabled').notNull().default(1),
|
||||||
|
policyMode: text('policy_mode').notNull().default('blacklist'), // blacklist | whitelist
|
||||||
createdAt: text('created_at')
|
createdAt: text('created_at')
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
|
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
|
||||||
@@ -118,6 +119,7 @@ export const policyRules = sqliteTable(
|
|||||||
.references(() => policySets.id, { onDelete: 'cascade' }),
|
.references(() => policySets.id, { onDelete: 'cascade' }),
|
||||||
priority: integer('priority').notNull(),
|
priority: integer('priority').notNull(),
|
||||||
action: text('action').notNull(), // allow | deny
|
action: text('action').notNull(), // allow | deny
|
||||||
|
enabled: integer('enabled').notNull().default(1),
|
||||||
listId: text('list_id').references(() => ipLists.id, { onDelete: 'cascade' }),
|
listId: text('list_id').references(() => ipLists.id, { onDelete: 'cascade' }),
|
||||||
cidr: text('cidr'),
|
cidr: text('cidr'),
|
||||||
hostname: text('hostname'),
|
hostname: text('hostname'),
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export const policyRuleSchema = z.object({
|
|||||||
set_id: z.string(),
|
set_id: z.string(),
|
||||||
priority: z.number().int(),
|
priority: z.number().int(),
|
||||||
action: policyActionSchema,
|
action: policyActionSchema,
|
||||||
|
enabled: z.boolean().optional().default(true),
|
||||||
list_id: z.string().nullable().optional(),
|
list_id: z.string().nullable().optional(),
|
||||||
cidr: z.string().nullable().optional(),
|
cidr: z.string().nullable().optional(),
|
||||||
hostname: z.string().nullable().optional(),
|
hostname: z.string().nullable().optional(),
|
||||||
@@ -75,6 +76,7 @@ export const policySetSchema = z.object({
|
|||||||
name: z.string(),
|
name: z.string(),
|
||||||
description: z.string().nullable().optional(),
|
description: z.string().nullable().optional(),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
|
policy_mode: policyModeSchema,
|
||||||
rules_count: z.number().int().optional(),
|
rules_count: z.number().int().optional(),
|
||||||
agents_count: z.number().int().optional(),
|
agents_count: z.number().int().optional(),
|
||||||
created_at: z.string(),
|
created_at: z.string(),
|
||||||
@@ -102,19 +104,22 @@ export const createPolicySetBodySchema = z.object({
|
|||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
description: z.string().nullable().optional(),
|
description: z.string().nullable().optional(),
|
||||||
enabled: z.boolean().optional().default(true),
|
enabled: z.boolean().optional().default(true),
|
||||||
|
policy_mode: policyModeSchema.optional().default('blacklist'),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const patchPolicySetBodySchema = z.object({
|
export const patchPolicySetBodySchema = z.object({
|
||||||
name: z.string().min(1).optional(),
|
name: z.string().min(1).optional(),
|
||||||
description: z.string().nullable().optional(),
|
description: z.string().nullable().optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
|
policy_mode: policyModeSchema.optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const createPolicyRuleBodySchema = z
|
export const createPolicyRuleBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
set_id: z.string().min(1),
|
set_id: z.string().min(1),
|
||||||
priority: z.number().int().min(1).max(10000),
|
priority: z.number().int().min(1).max(10000).optional(),
|
||||||
action: policyActionSchema,
|
action: policyActionSchema,
|
||||||
|
enabled: z.boolean().optional().default(true),
|
||||||
list_id: z.string().nullable().optional(),
|
list_id: z.string().nullable().optional(),
|
||||||
cidr: z.string().nullable().optional(),
|
cidr: z.string().nullable().optional(),
|
||||||
hostname: z.string().nullable().optional(),
|
hostname: z.string().nullable().optional(),
|
||||||
@@ -132,6 +137,17 @@ export const createPolicyRuleBodySchema = z
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const patchPolicyRuleBodySchema = z.object({
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
action: policyActionSchema.optional(),
|
||||||
|
comment: z.string().nullable().optional(),
|
||||||
|
priority: z.number().int().min(1).max(10000).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const reorderPolicyRulesBodySchema = z.object({
|
||||||
|
ordered_ids: z.array(z.string()).min(1),
|
||||||
|
})
|
||||||
|
|
||||||
export const putAgentPolicySetsBodySchema = z.object({
|
export const putAgentPolicySetsBodySchema = z.object({
|
||||||
set_ids: z.array(z.string()),
|
set_ids: z.array(z.string()),
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user