feat(api, web): enhance agent management and linting capabilities
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 2m17s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Added a new linting command for OpenAPI specifications in the package.json, improving code quality checks.
- Updated frontend documentation to clarify component usage and structure, including detailed descriptions for `SettingsShell` and `Auth callback`.
- Refactored agent-related API routes to streamline control-plane functionalities, consolidating multiple routes for better organization.
- Improved error handling in the API to provide more informative responses for validation errors, enhancing user feedback during interactions.

These changes enhance the overall development experience and improve the management of agents within the application.
This commit is contained in:
Denozordec
2026-07-30 14:13:05 +07:00
parent fb95ef22b3
commit f160992d94
68 changed files with 3155 additions and 6899 deletions
+3 -4
View File
@@ -36,9 +36,8 @@ Monorepo — [`frontend-monorepo.mdc`](frontend-monorepo.mdc). ReUI — [`reui-m
| `PageShell` | `page-shell.tsx` |
| `ResourcePage` | `reui-kit/resource-page.tsx` |
| `OpsDashboard` / `KpiStatGrid` / `QuickActionGrid` | `reui-kit/ops-dashboard.tsx`, `kpi-stat-grid.tsx`, `quick-action-grid.tsx` |
| `KanbanBoard` | `reui-kit/kanban-board.tsx` |
| `DetailPanel` | `reui-kit/detail-panel.tsx` |
| `SettingsShell` | `reui-kit/settings-shell.tsx` |
| `SettingsShell` | `reui-kit/settings-shell.tsx` (multi-tab; single-page settings — PageShell + Frame) |
| `EmptyState` | `empty-state.tsx` |
| `QueryState` | `query-state.tsx` |
| `ConfirmDialog` | `confirm-dialog.tsx` |
@@ -85,10 +84,10 @@ pnpm dlx shadcn@latest apply b2fA --only theme -y
| Зона | Файл | Preview |
|------|------|---------|
| Shell | `layout/app-shell.tsx` | [app-shell-12](https://reui.io/preview/base/app-shell-12) |
| Login | `routes/login.tsx` | [auth-13](https://reui.io/preview/base/auth-13) |
| Auth callback | `routes/auth.callback.tsx` (SSO portal) | [auth-13](https://reui.io/preview/base/auth-13) · [empty-state-12](https://reui.io/preview/base/empty-state-12) |
| Dashboard | `routes/_auth/index.tsx` | [stats-12](https://reui.io/preview/base/stats-12) / dashboard-1 |
| Lists | `ResourcePage` | [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) |
| Settings | `settings/integrations.tsx` | [settings-16](https://reui.io/preview/base/settings-16) |
| Settings | `routes/_auth/settings.tsx` | [settings-16](https://reui.io/preview/base/settings-16) |
## Чеклист
+6 -19
View File
@@ -48,11 +48,9 @@ apps/web/src/components/ ← shared + domain + layout
page-shell.tsx
reui-kit/
resource-page.tsx ← list: Frame + line Tabs + Filters + DataGrid
kanban-board.tsx ← kanban + KanbanBoardSkeleton
detail-panel.tsx ← detail: Frame header/metrics
settings-shell.tsx
settings-shell.tsx ← multi-tab settings (tabs required; no phantom routes)
ops-dashboard.tsx ← KPI stats-12 + charts
catalog-board-toggle.tsx
empty-state.tsx
query-state.tsx
confirm-dialog.tsx
@@ -70,12 +68,10 @@ apps/web/src/components/ ← shared + domain + layout
|---------|--------|-----------|
| Page wrapper | `PageShell` | — |
| List page | `ResourcePage` | ReUI `Frame` + `data-grid` + `filters` + shadcn `Tabs` `variant="line"` |
| Catalog / Board | `CatalogBoardToggle` + `ResourcePage` / `KanbanBoard` | `?view=board` на `/groups`, `/services` |
| Kanban | `KanbanBoard` / `KanbanBoardSkeleton` | ReUI `kanban` + `Frame` |
| Detail | `DetailPanel` | ReUI `Frame` |
| Settings | `SettingsShell` | — |
| Settings | PageShell + Frame + `SettingRow` (или `SettingsShell` при 2+ секциях) | — |
| Dashboard KPI | `OpsDashboard` / `KpiStatGrid` | ReUI Frame [stats-12](https://reui.io/preview/base/stats-12) hybrid |
| Quick Actions | `QuickActionGrid` | Frame tiles + Badge «Перейти» |
| Quick Actions | `QuickActionGrid` (gated `show_quick_actions`) | Frame tiles + Badge «Перейти» |
| Empty | `EmptyState` | `Empty` |
| Loading / Error | `QueryState` / kit skeletons | `Skeleton`, `Alert` |
| Status | `StatusBadge` | ReUI `Badge` (`success`/`info`/`warning`) |
@@ -95,8 +91,6 @@ apps/web/src/components/ ← shared + domain + layout
Max 1 primary (`default`) на экран; остальные `outline` / `ghost`.
Toggle «Доска» / «К каталогу» — всегда `outline` в `primaryAction` / `KanbanBoard.toolbarActions` (не отдельный Frame-shell).
## Line tabs (project standard)
Эталон: [c-tabs-2](https://reui.io/preview/base/components/c-tabs-2) + counted [filtering-2](https://reui.io/preview/base/data-grid-filtering-2).
@@ -116,16 +110,9 @@ Toggle «Доска» / «К каталогу» — всегда `outline` в `p
- Active state — Base UI `data-active`, не Radix `data-[state=active]`
- Не трогать internals `reui/date-selector`
## Catalog / Board (`/groups`, `/services`)
## Agents list view
| Режим | UI | Search |
|-------|-----|--------|
| Catalog (default) | `ResourcePage` + primary create | omit / `view=catalog` |
| Board | `KanbanBoard` DnD | `?view=board` |
- Groups tabs: Все / С доменами / Пустые
- Services tabs: Все / Включены / Выключены / Без группы
- DnD только на board; kanban hooks/cards не удалять
`?view=cards|table` на `/agents` — `AgentsFleetChrome` (cards) / `ResourcePage` (table). Board/kanban в EvoFirewall нет.
## Dashboard KPI
@@ -178,7 +165,7 @@ Shared chrome (vps-tracker / CFDM / EvoBGP): см. [`docs/ui-design-contract.md`
Каждый блок: **default, hover, focus, disabled, empty, loading, error**.
- **Loading** — `Skeleton` / `ResourcePage` skeleton / `KanbanBoardSkeleton` / `OpsDashboard` skeleton, не Spinner на странице
- **Loading** — `Skeleton` / `ResourcePage` skeleton / `OpsDashboard` skeleton, не Spinner на странице
- **Empty** — `EmptyState` с CTA
- **Zero-results** — message внутри DataGrid (+ «Сбросить»)
- **Error** — `QueryState` / `Alert` + `onRetry`
+2 -2
View File
@@ -61,14 +61,14 @@ alwaysApply: true
|------|------|--------|
| shadcn | `packages/ui/src/components/` | `@evofw/ui/components/*` |
| ReUI CLI | `apps/web/src/components/reui/` | `@/components/reui/*` |
| PRO blocks (reference) | `apps/web/src/components/blocks/` | adapt into kit, не копипаст в routes |
| PRO blocks (reference) | — | install via CLI → adapt into kit; do not keep demo trees in `src/components/blocks/` |
| Kit | `apps/web/src/components/reui-kit/` | `@/components/reui-kit/*` |
## Установленные ReUI (apps/web)
**Components:** `frame`, `data-grid/*`, `filters`, `kanban`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `KanbanBoard`, `DetailPanel`, `SettingsShell`
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `DetailPanel`, `SettingsShell`
**Blocks (reference):** `stats-12`, `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, …
+1 -3
View File
@@ -101,9 +101,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
return reply.sendFile('index.html')
}
return reply.code(404).send({
type: 'about:blank',
title: 'Not Found',
status: 404,
error: { code: 'NOT_FOUND', message: 'Not Found' },
})
})
+8
View File
@@ -1,5 +1,6 @@
import type { FastifyInstance } from 'fastify'
import fp from 'fastify-plugin'
import { ZodError } from 'zod'
export class AppError extends Error {
constructor(
@@ -19,6 +20,13 @@ async function errorHandlerPlugin(app: FastifyInstance) {
error: { code: err.code, message: err.message },
})
}
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 },
})
}
const e = err as { statusCode?: number; message?: string }
const status = e.statusCode ?? 500
const message =
+329
View File
@@ -0,0 +1,329 @@
import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
import {
createOverrideBodySchema,
putAgentPolicySetsBodySchema,
patchAgentBodySchema,
cloneFromBodySchema,
} from '@evofw/shared'
import { AppError } from '../plugins/error-handler.js'
import { evaluateAgentPolicy, truncateCidrs } from '../services/policy/evaluate.js'
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'
export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
opts,
) => {
const { config } = opts
app.get('/agents', async () => {
const all = repos.listAgents(app.db)
const linksByAgent = repos.mapActiveInstallLinksByAgentId(app.db)
return {
items: all.map((a) => {
const link = linksByAgent.get(a.id)
if (!link) {
return mapAgent(a)
}
const urls = buildInstallUrls(
config.publicBaseUrl,
link.id,
link.slug,
link.platform === 'mikrotik' ? 'mikrotik' : 'linux',
)
return mapAgent(a, {
installCurl: urls.curl.by_slug,
installLinkId: link.id,
})
}),
}
})
app.get<{ Params: { id: string } }>('/agents/:id', async (req) => {
const a = repos.getAgent(app.db, req.params.id)
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
return mapAgent(a)
})
app.get<{
Params: { id: string }
Querystring: { limit_cidrs?: string }
}>('/agents/:id/preview', async (req) => {
const a = repos.getAgent(app.db, req.params.id)
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const policy = evaluateAgentPolicy(app.db, a.id)
const limitRaw = Number(req.query.limit_cidrs ?? '50')
const limit = Number.isFinite(limitRaw)
? Math.min(Math.max(0, Math.floor(limitRaw)), 5000)
: 50
return {
default_action: policy.defaultAction,
hash: policy.hash,
generation: policy.generation,
sync_interval_sec: policy.syncIntervalSec,
apply_version: policy.applyVersion,
summary: {
sets: policy.summary.sets,
rules_deny: policy.summary.rulesDeny,
rules_allow: policy.summary.rulesAllow,
cidrs_deny: policy.summary.cidrsDeny,
cidrs_allow: policy.summary.cidrsAllow,
overrides: policy.summary.overrides,
conflicts_dropped: policy.summary.conflictsDropped,
},
chain: policy.chain.map((s) => ({
set_id: s.setId,
set_name: s.setName,
rule_id: s.ruleId,
action: s.action,
source_kind: s.sourceKind,
source_label: s.sourceLabel,
cidr_count: s.cidrCount,
})),
deny_cidrs: truncateCidrs(policy.denyCidrs, limit),
allow_cidrs: truncateCidrs(policy.allowCidrs, limit),
deny_cidrs_total: policy.denyCidrs.length,
allow_cidrs_total: policy.allowCidrs.length,
}
})
app.patch<{ Params: { id: string } }>('/agents/:id', async (req) => {
const body = patchAgentBodySchema.parse(req.body)
const a = repos.getAgent(app.db, req.params.id)
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const nextDefault = body.default_action
const updated = repos.updateAgent(app.db, a.id, {
name: body.name,
defaultAction: nextDefault,
settingsJson: body.settings
? JSON.stringify(body.settings)
: undefined,
policyGeneration:
nextDefault && nextDefault !== a.defaultAction
? a.policyGeneration + 1
: a.policyGeneration,
})
auditMutation(app, config, req, {
action: 'agent.update',
targetType: 'app_resource',
targetId: a.id,
summary: `Обновлён агент: ${updated!.name}`,
details: {
agent_id: a.id,
default_action: nextDefault,
name: body.name,
},
})
return mapAgent(updated!)
})
app.post<{ Params: { id: string } }>('/agents/:id/approve', async (req) => {
const a = repos.getAgent(app.db, req.params.id)
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const updated = repos.updateAgent(app.db, a.id, {
status: 'approved',
approvedAt: new Date().toISOString(),
})
repos.ensureSharedSetAssigned(app.db, a.id)
auditMutation(app, config, req, {
action: 'agent.approve',
targetType: 'app_resource',
targetId: a.id,
summary: `Агент одобрен: ${updated!.name}`,
details: { agent_id: a.id },
})
return mapAgent(updated!)
})
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)
const updated = repos.updateAgent(app.db, a.id, {
status: 'revoked',
revokedAt: new Date().toISOString(),
})
auditMutation(app, config, req, {
action: 'agent.revoke',
severity: 'warning',
targetType: 'app_resource',
targetId: a.id,
summary: `Агент отозван: ${updated!.name}`,
details: { agent_id: a.id },
})
return mapAgent(updated!)
})
app.delete<{ Params: { id: string } }>('/agents/:id', async (req) => {
const a = repos.getAgent(app.db, req.params.id)
repos.deleteAgent(app.db, req.params.id)
if (a) {
auditMutation(app, config, req, {
action: 'agent.delete',
severity: 'warning',
targetType: 'app_resource',
targetId: a.id,
summary: `Агент удалён: ${a.name}`,
details: { agent_id: a.id },
})
}
return { ok: true }
})
app.post<{ Params: { id: string; sourceId: string } }>(
'/agents/:id/clone-from/:sourceId',
async (req) => {
const body = cloneFromBodySchema.parse(req.body ?? {})
const updated = repos.cloneRulesFrom(
app.db,
req.params.sourceId,
req.params.id,
body.include_overrides ?? false,
)
if (!updated) throw new AppError('NOT_FOUND', 'Agent not found', 404)
auditMutation(app, config, req, {
action: 'agent.clone_rules',
targetType: 'app_resource',
targetId: updated.id,
summary: `Правила скопированы с ${req.params.sourceId} на ${updated.name}`,
details: {
agent_id: updated.id,
source_agent_id: req.params.sourceId,
include_overrides: body.include_overrides ?? false,
},
})
return mapAgent(updated)
},
)
app.get<{ Params: { id: string } }>(
'/agents/:id/overrides',
async (req) => ({
items: repos.listOverrides(app.db, req.params.id).map((o) => ({
id: o.id,
agent_id: o.agentId,
cidr: o.cidr,
action: o.action,
comment: o.comment,
created_at: o.createdAt,
})),
}),
)
app.post<{ Params: { id: string } }>(
'/agents/:id/overrides',
async (req) => {
const body = createOverrideBodySchema.parse(req.body)
const a = repos.getAgent(app.db, req.params.id)
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const row = repos.insertOverride(app.db, {
id: crypto.randomUUID(),
agentId: a.id,
cidr: body.cidr,
action: body.action,
comment: body.comment ?? null,
createdByUserId: req.authUser?.id,
createdAt: new Date().toISOString(),
})
repos.bumpAgentGeneration(app.db, a.id)
auditMutation(app, config, req, {
action: 'override.create',
targetType: 'app_resource',
targetId: row!.id,
summary: `Override ${body.action} ${body.cidr} для ${a.name}`,
details: {
override_id: row!.id,
agent_id: a.id,
cidr: body.cidr,
action: body.action,
},
})
return {
id: row!.id,
agent_id: row!.agentId,
cidr: row!.cidr,
action: row!.action,
comment: row!.comment,
created_at: row!.createdAt,
}
},
)
app.delete<{ Params: { id: string; overrideId: string } }>(
'/agents/:id/overrides/:overrideId',
async (req) => {
repos.deleteOverride(app.db, req.params.overrideId)
repos.bumpAgentGeneration(app.db, req.params.id)
auditMutation(app, config, req, {
action: 'override.delete',
severity: 'warning',
targetType: 'app_resource',
targetId: req.params.overrideId,
summary: `Override удалён у агента ${req.params.id}`,
details: {
override_id: req.params.overrideId,
agent_id: req.params.id,
},
})
return { ok: true }
},
)
app.put<{ Params: { id: string } }>(
'/agents/:id/policy-sets',
async (req) => {
const a = repos.getAgent(app.db, req.params.id)
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const body = putAgentPolicySetsBodySchema.parse(req.body)
for (const setId of body.set_ids) {
if (!repos.getPolicySet(app.db, setId)) {
throw new AppError('NOT_FOUND', `Policy set not found: ${setId}`, 404)
}
}
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,
)
}
auditMutation(app, config, req, {
action: 'agent.policy_sets.update',
targetType: 'app_resource',
targetId: a.id,
summary: `Наборы политик агента ${a.name} обновлены`,
details: { agent_id: a.id, set_ids: body.set_ids },
})
return {
items: repos.listSetsForAgent(app.db, a.id).map((s) => ({
set_id: s.setId,
sort: s.sort,
name: s.name,
description: s.description,
enabled: s.enabled === 1,
})),
}
},
)
app.get<{ Params: { id: string } }>(
'/agents/:id/policy-sets',
async (req) => {
const a = repos.getAgent(app.db, req.params.id)
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
return {
items: repos.listSetsForAgent(app.db, a.id).map((s) => ({
set_id: s.setId,
sort: s.sort,
name: s.name,
description: s.description,
enabled: s.enabled === 1,
})),
}
},
)
}
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
import type { AppConfig } from '../config.js'
export const dashboardRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
opts,
) => {
const { config } = opts
app.get('/dashboard', async () => {
const all = repos.listAgents(app.db)
const now = Date.now()
const online = all.filter((a) => {
if (!a.lastSeenAt || a.status !== 'approved') return false
return now - Date.parse(a.lastSeenAt) < 5 * 60_000
})
return {
agents_total: all.length,
agents_approved: all.filter((a) => a.status === 'approved').length,
agents_online: online.length,
agents_pending: all.filter((a) => a.status === 'pending').length,
packets_dropped: all.reduce(
(s, a) => s + (a.totalPacketsDropped ?? a.lastApplyPacketsDropped ?? 0),
0,
),
packets_accepted: all.reduce(
(s, a) => s + (a.totalPacketsAccepted ?? a.lastApplyPacketsAccepted ?? 0),
0,
),
lists_total: repos.listIpLists(app.db).length,
}
})
app.get('/install-context', async () => {
const seed =
repos.getSetting(app.db, 'enroll_seed') || config.enrollSeed
return {
suggested_cp_url: config.publicBaseUrl,
enroll_seed: seed,
install_sh_url: `${config.publicBaseUrl}/v1/agent/install.sh`,
mikrotik_url: `${config.publicBaseUrl}/v1/agent/mikrotik-install.rsc`,
sync_interval_sec: Number(
repos.getSetting(app.db, 'agent_sync_interval_sec') || '60',
),
}
})
}
+88
View File
@@ -0,0 +1,88 @@
import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
import { createInstallLinkBodySchema } from '@evofw/shared'
import { AppError } from '../plugins/error-handler.js'
import {
mapInstallLink,
randomToken,
} from '../services/install-links.js'
import { hashToken } from '../plugins/auth.js'
import type { AppConfig } from '../config.js'
import { auditMutation } from '../services/audit.js'
export const installLinksRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
opts,
) => {
const { config } = opts
app.get('/install-links', async () => ({
items: repos
.listInstallLinks(app.db)
.map((row) => mapInstallLink(row, config.publicBaseUrl)),
}))
app.post('/install-links', async (req, reply) => {
const body = createInstallLinkBodySchema.parse(req.body)
const linkId = randomToken(10)
const slug = randomToken(16)
if (
repos.getInstallLink(app.db, linkId) ||
repos.getInstallLinkBySlug(app.db, slug)
) {
throw new AppError('CONFLICT', 'Retry create (id collision)', 409)
}
const agentId = crypto.randomUUID()
const inviteToken = `invite:${agentId}`
const now = new Date().toISOString()
const name = body.name.trim()
const platform = body.platform ?? 'linux'
app.sqlite.transaction(() => {
repos.insertAgent(app.db, {
id: agentId,
name,
hostname: null,
platform,
tokenPrefix: inviteToken.slice(0, 12),
tokenHash: hashToken(inviteToken),
status: 'invited',
defaultAction: 'accept',
policyGeneration: 1,
clientVersion: null,
settingsJson: '{}',
createdAt: now,
})
repos.insertInstallLink(app.db, {
id: linkId,
slug,
clientName: name,
platform,
agentId,
createdAt: now,
useCount: 0,
})
})()
const row = repos.getInstallLink(app.db, linkId)!
auditMutation(app, config, req, {
action: 'agent.create',
targetType: 'app_resource',
targetId: agentId,
summary: `Создан агент (invite): ${name}`,
details: { agent_id: agentId, platform, install_link_id: linkId },
})
return reply.code(201).send(mapInstallLink(row, config.publicBaseUrl))
})
app.delete<{ Params: { id: string } }>(
'/install-links/:id',
async (req) => {
const row = repos.getInstallLink(app.db, req.params.id)
if (!row) throw new AppError('NOT_FOUND', 'Install link not found', 404)
const updated = repos.revokeInstallLink(app.db, row.id)
return mapInstallLink(updated!, config.publicBaseUrl)
},
)
}
@@ -0,0 +1,48 @@
import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
import { AppError } from '../plugins/error-handler.js'
export const integrationsEvobgpRoutes: FastifyPluginAsync = async (app) => {
/** Proxy EvoBGP communities for UI autocomplete. */
app.get('/integrations/evobgp/communities', async () => {
const apiUrl = repos.getSetting(app.db, 'evobgp_api_url')
const token = repos.getSetting(app.db, 'evobgp_api_token')
if (!apiUrl || !token) {
throw new AppError(
'VALIDATION_ERROR',
'Настройте evobgp_api_url и evobgp_api_token',
400,
)
}
const base = apiUrl.replace(/\/$/, '')
const res = await fetch(`${base}/v1/communities?limit=200`, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
},
signal: AbortSignal.timeout(20_000),
})
if (!res.ok) {
throw new AppError(
'UPSTREAM_ERROR',
`EvoBGP communities HTTP ${res.status}`,
502,
)
}
const data = (await res.json()) as {
items?: {
id?: string
community?: string
title?: string | null
}[]
}
const items = (data.items ?? [])
.filter((x) => x.id && x.community)
.map((x) => ({
id: x.id!,
community: x.community!,
title: x.title ?? null,
}))
return { items }
})
}
+185
View File
@@ -0,0 +1,185 @@
import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
import {
createIpListBodySchema,
listEntriesBodySchema,
deleteListEntryBodySchema,
isManualListType,
} from '@evofw/shared'
import { AppError } from '../plugins/error-handler.js'
import { refreshIpList } from '../services/lists/refresh.js'
import {
addListEntries,
deleteListEntry,
mapListDetail,
} from '../services/lists/entries.js'
import type { AppConfig } from '../config.js'
import { auditMutation } from '../services/audit.js'
export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
opts,
) => {
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.post('/lists', async (req) => {
const body = createIpListBodySchema.parse(req.body)
const type =
body.type === 'domains' ? 'static' : body.type
const id = crypto.randomUUID()
const list = repos.insertIpList(app.db, {
id,
name: body.name,
type,
configJson: JSON.stringify(body.config ?? {}),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
if (body.entries?.length && isManualListType(type)) {
try {
await addListEntries(app.db, id, { values: body.entries })
} catch (err) {
repos.deleteIpList(app.db, id)
throw new AppError(
'VALIDATION_ERROR',
err instanceof Error ? err.message : String(err),
400,
)
}
} else if (!isManualListType(type)) {
await refreshIpList(app.db, id)
}
auditMutation(app, config, req, {
action: 'list.create',
targetType: 'app_resource',
targetId: list!.id,
summary: `Создан список: ${list!.name}`,
details: { list_id: list!.id, type: list!.type },
})
return {
id: list!.id,
name: list!.name,
type: list!.type,
config_json: list!.configJson,
created_at: list!.createdAt,
updated_at: list!.updatedAt,
}
})
app.get<{ Params: { id: string } }>('/lists/:id', async (req) => {
const detail = mapListDetail(app.db, req.params.id)
if (!detail) throw new AppError('NOT_FOUND', 'List not found', 404)
return detail
})
app.post<{ Params: { id: string } }>(
'/lists/:id/entries',
async (req) => {
const l = repos.getIpList(app.db, req.params.id)
if (!l) throw new AppError('NOT_FOUND', 'List not found', 404)
const body = listEntriesBodySchema.parse(req.body)
try {
const result = await addListEntries(app.db, l.id, {
values: body.values,
items: body.items,
})
auditMutation(app, config, req, {
action: 'list.entries.add',
targetType: 'app_resource',
targetId: l.id,
summary: `Добавлены записи в список: ${l.name}`,
details: {
list_id: l.id,
entry_count: result.entries.length,
},
})
return mapListDetail(app.db, l.id) ?? result
} catch (err) {
throw new AppError(
'VALIDATION_ERROR',
err instanceof Error ? err.message : String(err),
400,
)
}
},
)
app.delete<{ Params: { id: string } }>(
'/lists/:id/entries',
async (req) => {
const l = repos.getIpList(app.db, req.params.id)
if (!l) throw new AppError('NOT_FOUND', 'List not found', 404)
const body = deleteListEntryBodySchema.parse(req.body)
try {
await deleteListEntry(app.db, l.id, body.value)
auditMutation(app, config, req, {
action: 'list.entries.delete',
severity: 'warning',
targetType: 'app_resource',
targetId: l.id,
summary: `Удалена запись из списка: ${l.name}`,
details: { list_id: l.id, value: body.value },
})
return mapListDetail(app.db, l.id)
} catch (err) {
throw new AppError(
'VALIDATION_ERROR',
err instanceof Error ? err.message : String(err),
400,
)
}
},
)
app.post<{ Params: { id: string } }>('/lists/:id/refresh', async (req) => {
const l = repos.getIpList(app.db, req.params.id)
await refreshIpList(app.db, req.params.id)
const detail = mapListDetail(app.db, req.params.id)
if (!detail) throw new AppError('NOT_FOUND', 'List not found', 404)
auditMutation(app, config, req, {
action: 'list.refresh',
targetType: 'app_resource',
targetId: req.params.id,
summary: `Обновлён список: ${l?.name ?? req.params.id}`,
details: { list_id: req.params.id },
})
return detail
})
app.delete<{ Params: { id: string } }>('/lists/:id', async (req) => {
const l = repos.getIpList(app.db, req.params.id)
repos.deleteIpList(app.db, req.params.id)
if (l) {
auditMutation(app, config, req, {
action: 'list.delete',
severity: 'warning',
targetType: 'app_resource',
targetId: l.id,
summary: `Список удалён: ${l.name}`,
details: { list_id: l.id },
})
}
return { ok: true }
})
}
+103
View File
@@ -0,0 +1,103 @@
import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
import {
createPolicySetBodySchema,
patchPolicySetBodySchema,
} from '@evofw/shared'
import { AppError } from '../plugins/error-handler.js'
import type { AppConfig } from '../config.js'
import { auditMutation } from '../services/audit.js'
import { mapPolicySet, mapPolicySets } from '../services/row-mappers.js'
export const policySetsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
opts,
) => {
const { config } = opts
app.get('/policy-sets', async () => ({
items: mapPolicySets(app.db, repos.listPolicySets(app.db)),
}))
app.get<{ Params: { id: string } }>('/policy-sets/:id', async (req) => {
const s = repos.getPolicySet(app.db, req.params.id)
if (!s) throw new AppError('NOT_FOUND', 'Policy set not found', 404)
return {
...mapPolicySet(s, app.db),
agent_ids: repos.listAgentIdsForSet(app.db, s.id),
}
})
app.post('/policy-sets', async (req) => {
const body = createPolicySetBodySchema.parse(req.body)
const row = repos.insertPolicySet(app.db, {
id: crypto.randomUUID(),
name: body.name.trim(),
description: body.description ?? null,
enabled: body.enabled === false ? 0 : 1,
policyMode: 'blacklist',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
auditMutation(app, config, req, {
action: 'policy_set.create',
targetType: 'app_resource',
targetId: row!.id,
summary: `Создан набор политик: ${row!.name}`,
details: { set_id: row!.id },
})
return mapPolicySet(row!, app.db)
})
app.patch<{ Params: { id: string } }>('/policy-sets/:id', async (req) => {
const body = patchPolicySetBodySchema.parse(req.body)
const s = repos.getPolicySet(app.db, req.params.id)
if (!s) throw new AppError('NOT_FOUND', 'Policy set not found', 404)
const updated = repos.updatePolicySet(app.db, s.id, {
name: body.name?.trim(),
description: body.description,
enabled: body.enabled === undefined ? undefined : body.enabled ? 1 : 0,
})
if (body.enabled !== undefined) {
repos.bumpAgentsForSet(app.db, s.id)
}
auditMutation(app, config, req, {
action: 'policy_set.update',
targetType: 'app_resource',
targetId: s.id,
summary: `Обновлён набор политик: ${updated!.name}`,
details: {
set_id: s.id,
enabled: body.enabled,
name: body.name,
},
})
return mapPolicySet(updated!, app.db)
})
app.delete<{ Params: { id: string } }>('/policy-sets/:id', async (req) => {
const s = repos.getPolicySet(app.db, req.params.id)
try {
const agentIds = repos.listAgentIdsForSet(app.db, req.params.id)
repos.deletePolicySet(app.db, req.params.id)
for (const id of agentIds) repos.bumpAgentGeneration(app.db, id)
if (s) {
auditMutation(app, config, req, {
action: 'policy_set.delete',
severity: 'warning',
targetType: 'app_resource',
targetId: s.id,
summary: `Набор политик удалён: ${s.name}`,
details: { set_id: s.id, agents_affected: agentIds.length },
})
}
} catch (err) {
throw new AppError(
'VALIDATION_ERROR',
err instanceof Error ? err.message : String(err),
400,
)
}
return { ok: true }
})
}
+199
View File
@@ -0,0 +1,199 @@
import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
import {
createPolicyRuleBodySchema,
patchPolicyRuleBodySchema,
reorderPolicyRulesBodySchema,
} from '@evofw/shared'
import { AppError } from '../plugins/error-handler.js'
import {
resolveAndStoreHostnameRule,
resolveHostnameToCidrs,
} 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'
export const rulesRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
opts,
) => {
const { config } = opts
app.get<{ Params: { id: string } }>(
'/policy-sets/:id/rules',
async (req) => {
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)),
}
},
)
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)),
}
}
const items = repos
.listPolicyRules(app.db, req.query.set_id)
.map((r) => mapPolicyRule(r, app.db))
return { items }
},
)
app.post('/rules', async (req) => {
const body = createPolicyRuleBodySchema.parse(req.body)
const set = repos.getPolicySet(app.db, body.set_id)
if (!set) throw new AppError('NOT_FOUND', 'Policy set not found', 404)
const hostname = body.hostname?.trim() || null
const cidr = body.cidr?.trim() || null
const listId = body.list_id?.trim() || null
if (hostname) {
try {
await resolveHostnameToCidrs(hostname)
} catch (err) {
throw new AppError(
'VALIDATION_ERROR',
err instanceof Error ? err.message : String(err),
400,
)
}
}
if (listId && !repos.getIpList(app.db, listId)) {
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 row = repos.insertPolicyRule(app.db, {
id,
setId: body.set_id,
priority,
action: body.action,
enabled: body.enabled === false ? 0 : 1,
listId,
cidr,
hostname,
comment: body.comment ?? null,
createdByUserId: req.authUser?.id,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
if (hostname) {
try {
await resolveAndStoreHostnameRule(app.db, id, hostname)
} catch (err) {
repos.deletePolicyRule(app.db, id)
throw new AppError(
'VALIDATION_ERROR',
err instanceof Error ? err.message : String(err),
400,
)
}
}
repos.bumpAgentsForSet(app.db, body.set_id)
auditMutation(app, config, req, {
action: 'rule.create',
targetType: 'app_resource',
targetId: row!.id,
summary: `Создано правило ${body.action} в наборе ${set.name}`,
details: {
rule_id: row!.id,
set_id: body.set_id,
action: body.action,
priority,
},
})
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)
auditMutation(app, config, req, {
action: 'rule.update',
targetType: 'app_resource',
targetId: rule.id,
summary: `Обновлено правило ${rule.id}`,
details: {
rule_id: rule.id,
set_id: rule.setId,
enabled: body.enabled,
action: body.action,
priority: body.priority,
},
})
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)
auditMutation(app, config, req, {
action: 'rule.reorder',
targetType: 'app_resource',
targetId: s.id,
summary: `Порядок правил изменён в наборе ${s.name}`,
details: { set_id: s.id, ordered_ids: body.ordered_ids },
})
return {
items: repos
.listPolicyRules(app.db, s.id)
.map((r) => mapPolicyRule(r, app.db)),
}
},
)
app.delete<{ Params: { id: string } }>('/rules/:id', async (req) => {
const rule = repos.getPolicyRule(app.db, req.params.id)
if (!rule) throw new AppError('NOT_FOUND', 'Rule not found', 404)
repos.deletePolicyRule(app.db, req.params.id)
repos.bumpAgentsForSet(app.db, rule.setId)
auditMutation(app, config, req, {
action: 'rule.delete',
severity: 'warning',
targetType: 'app_resource',
targetId: rule.id,
summary: `Правило удалено из набора ${rule.setId}`,
details: { rule_id: rule.id, set_id: rule.setId },
})
return { ok: true }
})
}
+34
View File
@@ -0,0 +1,34 @@
import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
import { putSettingsBodySchema } from '@evofw/shared'
import type { AppConfig } from '../config.js'
export const settingsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
opts,
) => {
const { config } = opts
app.get('/settings', async () => {
const rows = repos.listSettings(app.db)
const map: Record<string, string> = {}
for (const r of rows) {
if (r.key === 'evobgp_api_token' && r.value) {
map[r.key] = '********'
} else {
map[r.key] = r.value
}
}
if (!map.enroll_seed) map.enroll_seed = config.enrollSeed
return map
})
app.put('/settings', async (req) => {
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)
}
return { ok: true }
})
}
+61
View File
@@ -0,0 +1,61 @@
import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
import { AppError } from '../plugins/error-handler.js'
import type { AppConfig } from '../config.js'
import { auditMutation } from '../services/audit.js'
import { mapAgent } from '../services/row-mappers.js'
export const statsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
opts,
) => {
const { config } = opts
app.get<{ Params: { id: string } }>('/agents/:id/stats', async (req) => ({
items: repos.listStatsSamples(app.db, req.params.id).map((s) => ({
id: s.id,
agent_id: s.agentId,
packets_dropped: s.packetsDropped,
packets_accepted: s.packetsAccepted,
prefix_count: s.prefixCount,
kernel_method: s.kernelMethod,
recorded_at: s.recordedAt,
})),
}))
app.post<{ Params: { id: string } }>(
'/agents/:id/stats/reset',
async (req) => {
const agent = repos.getAgent(app.db, req.params.id)
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const updated = repos.updateAgent(app.db, agent.id, {
lastApplyPacketsDropped: 0,
lastApplyPacketsAccepted: 0,
totalPacketsDropped: 0,
totalPacketsAccepted: 0,
})
repos.deleteStatsSamplesForAgent(app.db, agent.id)
auditMutation(app, config, req, {
action: 'agent.stats_reset',
severity: 'info',
targetType: 'app_resource',
targetId: agent.id,
summary: `Сброшена статистика counters агента ${agent.name}`,
details: { agent_id: agent.id },
})
return mapAgent(updated!)
},
)
app.get('/stats/recent', async () => ({
items: repos.listRecentStats(app.db).map((s) => ({
id: s.id,
agent_id: s.agentId,
packets_dropped: s.packetsDropped,
packets_accepted: s.packetsAccepted,
prefix_count: s.prefixCount,
kernel_method: s.kernelMethod,
recorded_at: s.recordedAt,
})),
}))
}
+106
View File
@@ -0,0 +1,106 @@
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('agents CRUD critical paths', () => {
const appPromise = buildApp({ memory: true, config: testConfig })
afterAll(async () => {
const app = await appPromise
await app.close()
})
it('creates invite, approves, lists with install curl', async () => {
const app = await appPromise
await app.ready()
const created = await app.inject({
method: 'POST',
url: '/api/v1/install-links',
payload: { name: 'ops-01', platform: 'linux' },
})
expect(created.statusCode).toBe(201)
const link = created.json() as { agent_id: string }
const approve = await app.inject({
method: 'POST',
url: `/api/v1/agents/${link.agent_id}/approve`,
})
expect(approve.statusCode).toBe(200)
const list = await app.inject({ method: 'GET', url: '/api/v1/agents' })
expect(list.statusCode).toBe(200)
const items = (list.json() as { items: { id: string; status: string }[] })
.items
const agent = items.find((a) => a.id === link.agent_id)
expect(agent?.status).toBe('approved')
})
it('creates policy set + rule and reorders', async () => {
const app = await appPromise
await app.ready()
const setRes = await app.inject({
method: 'POST',
url: '/api/v1/policy-sets',
payload: { name: 'test-set' },
})
expect(setRes.statusCode).toBe(200)
const set = setRes.json() as { id: string }
const r1 = await app.inject({
method: 'POST',
url: '/api/v1/rules',
payload: {
set_id: set.id,
action: 'deny',
cidr: '1.1.1.1/32',
},
})
expect(r1.statusCode).toBe(200)
const rule1 = r1.json() as { id: string }
const r2 = await app.inject({
method: 'POST',
url: '/api/v1/rules',
payload: {
set_id: set.id,
action: 'allow',
cidr: '8.8.8.8/32',
},
})
expect(r2.statusCode).toBe(200)
const rule2 = r2.json() as { id: string }
const reorder = await app.inject({
method: 'PUT',
url: `/api/v1/policy-sets/${set.id}/rules/reorder`,
payload: {
ordered_ids: [rule2.id, rule1.id],
},
})
expect(reorder.statusCode).toBe(200)
const rules = await app.inject({
method: 'GET',
url: `/api/v1/policy-sets/${set.id}/rules`,
})
expect(rules.statusCode).toBe(200)
const items = (rules.json() as { items: { id: string }[] }).items
expect(items[0]?.id).toBe(rule2.id)
})
})
+2 -5
View File
@@ -11,10 +11,7 @@ import {
type ListEntryInput,
} from '@evofw/shared'
import { resolveHostnameToCidrs } from '../policy/resolve-hostname.js'
function uniq(cidrs: string[]): string[] {
return [...new Set(cidrs.map((c) => c.trim()).filter(Boolean))].sort()
}
import { uniqCidrs } from '../uniq.js'
export function getListConfig(list: {
configJson: string
@@ -129,7 +126,7 @@ export async function rebuildManualListEntries(
delete config.domains
repos.updateIpList(db, listId, { configJson: JSON.stringify(config) })
const cidrs = uniq(all)
const cidrs = uniqCidrs(all)
repos.replaceIpListEntries(db, listId, cidrs)
return cidrs
} finally {
+4 -7
View File
@@ -7,10 +7,7 @@ import {
rebuildListCascade,
rebuildManualListEntries,
} from './entries.js'
function uniq(cidrs: string[]): string[] {
return [...new Set(cidrs.map((c) => c.trim()).filter(Boolean))].sort()
}
import { uniqCidrs } from '../uniq.js'
function hashCidrs(cidrs: string[]): string {
return `sha256:${createHash('sha256').update(cidrs.join('\n')).digest('hex')}`
@@ -48,7 +45,7 @@ async function fetchJsonUrl(url: string): Promise<string[]> {
}
}
}
return uniq(out)
return uniqCidrs(out)
}
const UUID_RE =
@@ -113,9 +110,9 @@ async function fetchEvobgpCommunity(
}
let cidrs: string[] = []
if (Array.isArray(data.prefixes) && data.prefixes.length > 0) {
cidrs = uniq(data.prefixes)
cidrs = uniqCidrs(data.prefixes)
} else if (Array.isArray(data.items)) {
cidrs = uniq(data.items.map((i) => i.prefix ?? '').filter(Boolean))
cidrs = uniqCidrs(data.items.map((i) => i.prefix ?? '').filter(Boolean))
}
return { cidrs, resolvedId }
}
+3 -14
View File
@@ -6,6 +6,7 @@ import {
legacyModeFromDefaultAction,
type DefaultAction,
} from '@evofw/shared'
import { uniqCidrs } from '../uniq.js'
export const POLICY_APPLY_VERSION = 2 as const
@@ -42,18 +43,6 @@ export type EvaluatedPolicy = {
}
}
function uniq(cidrs: string[]): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const c of cidrs) {
const t = c.trim()
if (!t || seen.has(t)) continue
seen.add(t)
out.push(t)
}
return out.sort()
}
function expandList(db: Db, listId: string | null | undefined): string[] {
if (!listId) return []
return repos.listIpListEntries(db, listId).map((e) => e.cidr)
@@ -159,9 +148,9 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
})
}
const denyCidrs = uniq(deny)
const denyCidrs = uniqCidrs(deny)
const denySet = new Set(denyCidrs)
const allowRaw = uniq(allow)
const allowRaw = uniqCidrs(allow)
const allowCidrs = allowRaw.filter((c) => !denySet.has(c))
const conflictsDropped = allowRaw.length - allowCidrs.length
const defaultAction = resolveDefaultAction(agent.defaultAction)
@@ -2,10 +2,7 @@ import { resolve4, resolve6 } from 'node:dns/promises'
import { createHash } from 'node:crypto'
import type { Db } from '@evofw/db'
import { repos } from '@evofw/db'
function uniq(cidrs: string[]): string[] {
return [...new Set(cidrs.map((c) => c.trim()).filter(Boolean))].sort()
}
import { uniqCidrs } from '../uniq.js'
function hashCidrs(cidrs: string[]): string {
return `sha256:${createHash('sha256').update(cidrs.join('\n')).digest('hex')}`
@@ -30,7 +27,7 @@ export async function resolveHostnameToCidrs(hostname: string): Promise<string[]
/* ignore AAAA failures */
}
const cidrs = uniq(out)
const cidrs = uniqCidrs(out)
if (cidrs.length === 0) {
throw new Error(`DNS resolve failed for ${host}: no A/AAAA records`)
}
+87
View File
@@ -0,0 +1,87 @@
import { repos } from '@evofw/db'
export function mapAgent(
a: NonNullable<ReturnType<typeof repos.getAgent>>,
opts?: { installCurl?: string | null; installLinkId?: string | null },
) {
const defaultAction =
a.defaultAction === 'drop' ? ('drop' as const) : ('accept' as const)
return {
id: a.id,
name: a.name,
hostname: a.hostname,
platform: a.platform,
token_prefix: a.tokenPrefix,
status: a.status,
default_action: defaultAction,
policy_mode: defaultAction === 'drop' ? ('whitelist' as const) : ('blacklist' as const),
policy_generation: a.policyGeneration,
last_seen_at: a.lastSeenAt,
last_seen_ip: a.lastSeenIp,
last_apply_at: a.lastApplyAt,
last_apply_status: a.lastApplyStatus,
last_apply_error: a.lastApplyError,
last_apply_prefix_count: a.lastApplyPrefixCount,
last_apply_packets_dropped: a.lastApplyPacketsDropped,
last_apply_packets_accepted: a.lastApplyPacketsAccepted,
total_packets_dropped: a.totalPacketsDropped ?? 0,
total_packets_accepted: a.totalPacketsAccepted ?? 0,
last_apply_kernel_method: a.lastApplyKernelMethod,
client_version: a.clientVersion,
created_at: a.createdAt,
approved_at: a.approvedAt,
revoked_at: a.revokedAt,
install_curl: opts?.installCurl ?? null,
install_link_id: opts?.installLinkId ?? null,
}
}
export function mapPolicySet(
s: NonNullable<ReturnType<typeof repos.getPolicySet>>,
db: Parameters<typeof repos.countRulesInSet>[0],
counts?: { rules: number; agents: number },
) {
return {
id: s.id,
name: s.name,
description: s.description,
enabled: s.enabled === 1,
rules_count: counts?.rules ?? repos.countRulesInSet(db, s.id),
agents_count: counts?.agents ?? repos.countAgentsForSet(db, s.id),
created_at: s.createdAt,
updated_at: s.updatedAt,
}
}
export function mapPolicySets(
db: Parameters<typeof repos.countRulesInSet>[0],
sets: NonNullable<ReturnType<typeof repos.getPolicySet>>[],
) {
const counts = repos.countRulesAndAgentsBySetIds(
db,
sets.map((s) => s.id),
)
return sets.map((s) => mapPolicySet(s, db, counts.get(s.id)))
}
export function mapPolicyRule(
r: NonNullable<ReturnType<typeof repos.getPolicyRule>>,
db: Parameters<typeof repos.listResolvedForRule>[0],
) {
return {
id: r.id,
set_id: r.setId,
priority: r.priority,
action: r.action,
enabled: r.enabled !== 0,
list_id: r.listId,
cidr: r.cidr,
hostname: r.hostname,
resolved_count: r.hostname
? repos.listResolvedForRule(db, r.id).length
: undefined,
comment: r.comment,
created_at: r.createdAt,
updated_at: r.updatedAt,
}
}
@@ -0,0 +1,82 @@
import { describe, it, expect, afterAll } from 'vitest'
import { buildApp } from '../app.js'
import type { AppConfig } from '../config.js'
import { repos } from '@evofw/db'
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('settings + bumpAgentsForList', () => {
const appPromise = buildApp({ memory: true, config: testConfig })
afterAll(async () => {
const app = await appPromise
await app.close()
})
it('rejects unknown settings keys', async () => {
const app = await appPromise
await app.ready()
const res = await app.inject({
method: 'PUT',
url: '/api/v1/settings',
payload: { unknown_key: 'x' },
})
expect(res.statusCode).toBeGreaterThanOrEqual(400)
})
it('accepts show_quick_actions', async () => {
const app = await appPromise
await app.ready()
const res = await app.inject({
method: 'PUT',
url: '/api/v1/settings',
payload: { show_quick_actions: 'false' },
})
expect(res.statusCode).toBe(200)
const get = await app.inject({ method: 'GET', url: '/api/v1/settings' })
expect(get.json().show_quick_actions).toBe('false')
})
it('bumpAgentsForList no-ops when list has no rules', async () => {
const app = await appPromise
await app.ready()
const db = app.db
const agent = repos.insertAgent(db, {
id: 'ag-bump-1',
name: 'bump-test',
platform: 'linux',
tokenPrefix: 'tok',
tokenHash: 'hash-bump-1',
status: 'approved',
defaultAction: 'accept',
policyGeneration: 1,
settingsJson: '{}',
})
expect(agent?.policyGeneration).toBe(1)
const list = repos.insertIpList(db, {
id: 'list-unused',
name: 'unused',
type: 'static',
configJson: '{}',
})
expect(list).toBeTruthy()
repos.bumpAgentsForList(db, 'list-unused')
const after = repos.getAgent(db, 'ag-bump-1')
expect(after?.policyGeneration).toBe(1)
})
})
+17
View File
@@ -0,0 +1,17 @@
/** Trim, drop empty, dedupe (order not guaranteed — sorted for stable hashes). */
export function uniqCidrs(cidrs: readonly string[]): string[] {
return [...new Set(cidrs.map((c) => c.trim()).filter(Boolean))].sort()
}
/** Trim, drop empty, dedupe preserving first-seen order. */
export function uniqCidrsPreserveOrder(cidrs: readonly string[]): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const c of cidrs) {
const t = c.trim()
if (!t || seen.has(t)) continue
seen.add(t)
out.push(t)
}
return out
}
+154 -124
View File
@@ -1,11 +1,16 @@
import { useEffect, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { Copy } from 'lucide-react'
import { FormSheet } from '@/components/form-sheet'
import { LoadingButton } from '@/components/loading-button'
import { apiFetch } from '@/lib/api'
import type { InstallLink } from '@evofw/shared'
import { Button } from '@evofw/ui/components/button'
import { Field, FieldLabel } from '@evofw/ui/components/field'
import { Field, FieldError, FieldLabel } from '@evofw/ui/components/field'
import { Input } from '@evofw/ui/components/input'
import { ScrollArea } from '@evofw/ui/components/scroll-area'
import {
@@ -24,20 +29,26 @@ import {
SheetTitle,
} from '@evofw/ui/components/sheet'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Controller } from 'react-hook-form'
/**
* Create agent install invite — Sheet (single form, no wizard).
* Preview: https://reui.io/preview/base/sheet-8 · sheet-1
* Create agent install invite — FormSheet (create) + Sheet (install curl).
* Preview: https://reui.io/preview/base/sheet-8 · form-7
* Docs: https://ui.shadcn.com/docs/components/base/sheet
*/
type Platform = 'linux' | 'mikrotik'
const PLATFORM_ITEMS = [
{ value: 'linux', label: 'Linux' },
{ value: 'mikrotik', label: 'MikroTik' },
] as const
const createAgentSchema = z.object({
name: z.string().trim().min(1, 'Укажите имя'),
platform: z.enum(['linux', 'mikrotik']),
})
type CreateAgentValues = z.infer<typeof createAgentSchema>
interface AddAgentSheetProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -46,23 +57,28 @@ interface AddAgentSheetProps {
export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
const qc = useQueryClient()
const { copyToClipboard } = useCopyToClipboard()
const [name, setName] = useState('web-01')
const [platform, setPlatform] = useState<Platform>('linux')
const [created, setCreated] = useState<InstallLink | null>(null)
const form = useForm<CreateAgentValues>({
resolver: zodResolver(createAgentSchema),
defaultValues: { name: 'web-01', platform: 'linux' },
})
useEffect(() => {
if (!open) {
setCreated(null)
setName('web-01')
setPlatform('linux')
form.reset({ name: 'web-01', platform: 'linux' })
}
}, [open])
}, [open, form])
const create = useMutation({
mutationFn: () =>
mutationFn: (values: CreateAgentValues) =>
apiFetch<InstallLink>('/api/v1/install-links', {
method: 'POST',
body: JSON.stringify({ name: name.trim(), platform }),
body: JSON.stringify({
name: values.name.trim(),
platform: values.platform,
}),
}),
onSuccess: (link) => {
setCreated(link)
@@ -72,126 +88,140 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
onError: (e: Error) => toast.error(e.message),
})
const canCreate = Boolean(name.trim()) && !create.isPending
function handleCopy(text: string) {
if (!text) return
copyToClipboard(text)
toast.success('Скопировано')
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0">
<SheetTitle>
{created ? 'Команда установки' : 'Добавить агента'}
</SheetTitle>
<SheetDescription>
{created
? 'Агент уже в списке (Invited). Скопируйте one-liner и выполните на хосте.'
: 'Создайте агента и короткую install-ссылку.'}
</SheetDescription>
</SheetHeader>
<ScrollArea className="flex-1 px-4">
<div className="grid auto-rows-min gap-4 py-2 pb-4">
{!created ? (
<>
<Field>
<FieldLabel htmlFor="agent-name">Имя клиента</FieldLabel>
<Input
id="agent-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="web-01"
/>
</Field>
<Field>
<FieldLabel>Платформа</FieldLabel>
<Select
items={[...PLATFORM_ITEMS]}
value={platform}
onValueChange={(v) => {
if (v === 'linux' || v === 'mikrotik') setPlatform(v)
}}
if (created) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0">
<SheetTitle>Команда установки</SheetTitle>
<SheetDescription>
Агент уже в списке (Invited). Скопируйте one-liner и выполните на
хосте.
</SheetDescription>
</SheetHeader>
<ScrollArea className="flex-1 px-4">
<div className="grid auto-rows-min gap-4 py-2 pb-4">
<Field>
<FieldLabel>По id</FieldLabel>
<div className="flex flex-col gap-2">
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs break-all whitespace-pre-wrap">
{created.curl?.by_id}
</pre>
<Button
type="button"
variant="outline"
size="sm"
className="self-start"
onClick={() => handleCopy(created.curl?.by_id ?? '')}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PLATFORM_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</>
) : (
<>
<Field>
<FieldLabel>По id</FieldLabel>
<div className="flex flex-col gap-2">
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs break-all whitespace-pre-wrap">
{created.curl?.by_id}
</pre>
<Button
type="button"
variant="outline"
size="sm"
className="self-start"
onClick={() => handleCopy(created.curl?.by_id ?? '')}
>
<Copy data-icon="inline-start" />
Копировать
</Button>
</div>
</Field>
<Field>
<FieldLabel>Короткий slug</FieldLabel>
<div className="flex flex-col gap-2">
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs break-all whitespace-pre-wrap">
{created.curl?.by_slug}
</pre>
<Button
type="button"
variant="outline"
size="sm"
className="self-start"
onClick={() => handleCopy(created.curl?.by_slug ?? '')}
>
<Copy data-icon="inline-start" />
Копировать
</Button>
</div>
</Field>
</>
)}
</div>
</ScrollArea>
<Copy data-icon="inline-start" />
Копировать
</Button>
</div>
</Field>
<Field>
<FieldLabel>Короткий slug</FieldLabel>
<div className="flex flex-col gap-2">
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs break-all whitespace-pre-wrap">
{created.curl?.by_slug}
</pre>
<Button
type="button"
variant="outline"
size="sm"
className="self-start"
onClick={() => handleCopy(created.curl?.by_slug ?? '')}
>
<Copy data-icon="inline-start" />
Копировать
</Button>
</div>
</Field>
</div>
</ScrollArea>
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
<Button
variant="outline"
onClick={() => {
setCreated(null)
form.reset({ name: 'web-01', platform: 'linux' })
}}
>
Ещё агент
</Button>
<Button onClick={() => onOpenChange(false)}>Готово</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
{created ? (
<>
<Button variant="outline" onClick={() => setCreated(null)}>
Ещё агент
</Button>
<Button onClick={() => onOpenChange(false)}>Готово</Button>
</>
) : (
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button disabled={!canCreate} onClick={() => create.mutate()}>
Создать
</Button>
</>
return (
<FormSheet
open={open}
onOpenChange={onOpenChange}
title="Добавить агента"
description="Создайте агента и короткую install-ссылку."
form={form}
onSubmit={(values) => create.mutateAsync(values)}
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Отмена
</Button>
<LoadingButton type="submit" isLoading={create.isPending}>
Создать
</LoadingButton>
</>
}
>
<Field data-invalid={Boolean(form.formState.errors.name) || undefined}>
<FieldLabel htmlFor="agent-name">Имя клиента</FieldLabel>
<Input
id="agent-name"
placeholder="web-01"
aria-invalid={Boolean(form.formState.errors.name) || undefined}
{...form.register('name')}
/>
<FieldError errors={[form.formState.errors.name]} />
</Field>
<Field>
<FieldLabel>Платформа</FieldLabel>
<Controller
control={form.control}
name="platform"
render={({ field }) => (
<Select
items={[...PLATFORM_ITEMS]}
value={field.value}
onValueChange={(v) => {
if (v === 'linux' || v === 'mikrotik') field.onChange(v)
}}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PLATFORM_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</SheetFooter>
</SheetContent>
</Sheet>
/>
</Field>
</FormSheet>
)
}
@@ -25,7 +25,6 @@ import { cn } from '@evofw/ui/lib/utils'
/**
* 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
* Reference: apps/web/src/components/blocks/card-3/components/investor-card.tsx
*/
const packetFmt = new Intl.NumberFormat('ru-RU', {
@@ -0,0 +1,119 @@
import type { ReactNode } from 'react'
import { FilterIcon, SearchIcon } from 'lucide-react'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import {
Filters,
type Filter,
type FilterFieldConfig,
} from '@/components/reui/filters'
import { Frame, FramePanel } from '@/components/reui/frame'
import { Button } from '@evofw/ui/components/button'
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from '@evofw/ui/components/input-group'
import { Separator } from '@evofw/ui/components/separator'
/**
* Shared agents fleet chrome (tabs + filters + search) — cards view.
* Table view uses the same DNA via ResourcePage.
* Preview: https://reui.io/preview/base/data-grid-filtering-2
* · https://reui.io/preview/base/solution-agents-1
*/
export type AgentsFleetTab = {
id: string
label: string
count?: number
}
export interface AgentsFleetChromeProps {
tabs: AgentsFleetTab[]
activeTab: string
onTabChange: (tabId: string) => void
filterFields: FilterFieldConfig[]
filters: Filter[]
onFiltersChange: (filters: Filter[]) => void
onClearFilters?: () => void
searchQuery: string
onSearchChange: (query: string) => void
searchPlaceholder?: string
toolbarExtra?: ReactNode
children: ReactNode
}
export function AgentsFleetChrome({
tabs,
activeTab,
onTabChange,
filterFields,
filters,
onFiltersChange,
onClearFilters,
searchQuery,
onSearchChange,
searchPlaceholder = 'Поиск агентов…',
toolbarExtra,
children,
}: AgentsFleetChromeProps) {
const hasActiveFilters = filters.length > 0 || searchQuery.trim().length > 0
return (
<Frame dense variant="default" spacing="sm" className="w-full">
<FramePanel className="p-0 shadow-none!">
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
<CountedLineTabs
tabs={tabs}
value={activeTab}
onValueChange={onTabChange}
/>
</div>
<Separator />
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<Filters
filters={filters}
fields={filterFields}
onChange={onFiltersChange}
size="default"
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" />
Фильтры
</Button>
}
/>
<InputGroup className="h-8 max-w-xs min-w-[12rem] flex-1">
<InputGroupAddon align="inline-start">
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
/>
</InputGroup>
</div>
<div className="flex flex-wrap items-center gap-2">
{toolbarExtra}
{hasActiveFilters && onClearFilters ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onClearFilters}
>
Сбросить
</Button>
) : null}
</div>
</div>
<div className="px-(--frame-panel-header-px) pb-(--frame-panel-header-py)">
{children}
</div>
</FramePanel>
</Frame>
)
}
@@ -1,64 +0,0 @@
import { type ReactNode } from "react"
import { BarChart3Icon, WalletIcon, CircleDollarSignIcon } from "lucide-react"
export interface StatItem {
value: string
label: string
}
export interface FundingSource {
name: string
amount: string
icon: ReactNode
tileClassName: string
}
export const PROFILE = {
name: "Mara Alves",
status: "Open to Proposals",
email: "malves@reui-capital.io",
avatarSrc:
"https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=160&h=160&dpr=2&q=80",
}
export const STATS: StatItem[] = [
{
value: "87",
label: "Deals",
},
{
value: "$7.2M",
label: "Avg. Ticket",
},
{
value: "$415M",
label: "Total Fund",
},
]
export const FUNDING_SOURCES: FundingSource[] = [
{
name: "Northline Ventures",
amount: "$7,840,000",
tileClassName: "bg-invert text-invert-foreground",
icon: (
<BarChart3Icon aria-hidden="true" />
),
},
{
name: "Bluepeak Capital",
amount: "$3,260,000",
tileClassName: "bg-[oklch(0.54_0.25_292)] text-[oklch(0.98_0.01_292)]",
icon: (
<WalletIcon aria-hidden="true" />
),
},
{
name: "Everfield Equity",
amount: "$1,180,000",
tileClassName: "bg-[oklch(0.78_0.18_79)] text-[oklch(0.99_0.01_88)]",
icon: (
<CircleDollarSignIcon aria-hidden="true" />
),
},
]
@@ -1,150 +0,0 @@
import { type ReactNode } from "react"
import { Badge } from "@/components/reui/badge"
import { Frame, FramePanel } from "@/components/reui/frame"
import { cn } from "@evofw/ui/lib/utils"
import { AspectRatio } from "@evofw/ui/components/aspect-ratio"
import { Button } from "@evofw/ui/components/button"
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemSeparator,
ItemTitle,
} from "@evofw/ui/components/item"
import { Separator } from "@evofw/ui/components/separator"
import { FUNDING_SOURCES, PROFILE, STATS } from "./data"
import { CircleCheckIcon, MessageSquareIcon } from "lucide-react"
function FundingIcon({
className,
children,
}: {
className: string
children: ReactNode
}) {
return (
<span
className={cn(
"border-background flex size-8 shrink-0 items-center justify-center rounded-lg border-2 shadow-[0_1px_3px_0_oklch(0.2_0.01_260/0.16)] [&_svg]:size-4",
className
)}
>
{children}
</span>
)
}
export function InvestorCard() {
return (
<Frame
spacing="xs"
className="bg-muted/50 dark:bg-muted/10 w-full max-w-[408px]"
>
<FramePanel className="text-card-foreground isolate px-4 py-4 sm:px-5">
<div className="relative z-10 flex flex-col gap-5">
{/* Header */}
<div className="relative isolate overflow-visible">
<div className="relative z-10 flex items-center gap-5">
<AspectRatio
ratio={1}
className="border-background bg-muted/60 size-20 shrink-0 overflow-hidden rounded-lg border-4 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border"
>
<img
src={PROFILE.avatarSrc}
alt={PROFILE.name}
className="size-full rounded-lg object-cover object-top"
loading="lazy"
/>
</AspectRatio>
<div className="flex min-w-0 flex-col gap-2">
<h2 className="truncate text-sm leading-tight font-semibold">
{PROFILE.name}
</h2>
<Badge variant="success-light" radius="full">
<CircleCheckIcon aria-hidden="true" />
{PROFILE.status}
</Badge>
<p className="text-muted-foreground truncate text-xs">
{PROFILE.email}
</p>
</div>
</div>
</div>
{/* Stats */}
<div className="bg-muted/60 grid grid-cols-[1fr_auto_1fr_auto_1fr] overflow-hidden rounded-lg border">
{STATS.map((stat, index) => (
<div key={stat.label} className="contents">
<Item
variant="muted"
size="sm"
className="justify-center bg-transparent px-2 py-3"
>
<ItemContent className="items-center gap-1">
<ItemTitle className="text-sm leading-none font-medium tabular-nums">
{stat.value}
</ItemTitle>
<ItemDescription className="line-clamp-1 text-xs leading-tight">
{stat.label}
</ItemDescription>
</ItemContent>
</Item>
{index < STATS.length - 1 ? (
<Separator orientation="vertical" className="my-2.5" />
) : null}
</div>
))}
</div>
{/* Funding Sources */}
<ItemGroup className="gap-0!">
{FUNDING_SOURCES.map((source, index) => (
<div key={source.name} className="contents">
<Item size="sm" className="flex-nowrap px-0 py-0">
<ItemMedia>
<FundingIcon className={source.tileClassName}>
{source.icon}
</FundingIcon>
</ItemMedia>
<ItemContent className="min-w-0">
<ItemTitle className="w-full min-w-0">
<span className="truncate text-sm leading-5 font-medium">
{source.name}
</span>
</ItemTitle>
</ItemContent>
<ItemActions className="shrink-0">
<span className="text-sm leading-5 font-medium tabular-nums">
{source.amount}
</span>
</ItemActions>
</Item>
{index < FUNDING_SOURCES.length - 1 ? (
<ItemSeparator className="my-2.5 border-t border-dashed bg-transparent" />
) : null}
</div>
))}
</ItemGroup>
{/* Action */}
<Button
type="button"
variant="outline"
size="lg"
className="h-10! w-full"
>
<MessageSquareIcon aria-hidden="true" />
Send Funding Proposal
</Button>
</div>
</FramePanel>
</Frame>
)
}
@@ -1,9 +0,0 @@
import { InvestorCard } from "./components/investor-card"
export function Page() {
return (
<div className="flex min-h-svh w-full items-center justify-center p-6">
<InvestorCard />
</div>
)
}
@@ -1,469 +0,0 @@
import { type ReactNode } from "react"
import { CircleCheckIcon, InfoIcon, CreditCardIcon, SendIcon, DatabaseIcon, BookOpenIcon, SparklesIcon, FileTextIcon } from "lucide-react"
export type RunStatusId = "running" | "waiting" | "failed" | "completed"
export type AgentName =
| "Refund Resolver"
| "Outreach Composer"
| "Data Steward"
| "Invoice Reconciler"
| "Lead Enricher"
| "Contract Summarizer"
export type RunAttention =
| "Escalated"
| "Needs approval"
| "Retrying"
| "On track"
| "Done"
export type RunEnvironment = "Production" | "Staging" | "Development"
export interface RunOwner {
id: string
name: string
initials: string
avatarSrc?: string
role: string
}
export interface RunStatusGroup {
id: RunStatusId
label: "Running" | "Waiting" | "Failed" | "Completed"
summary: string
focus: string
order: number
}
export interface AgentRun {
id: string
runKey: string
title: string
context: string
statusId: RunStatusId
agent: AgentName
environment: RunEnvironment
attention: RunAttention
owner: RunOwner | null
owners: RunOwner[]
stepProgress: number | null
startedAt: string
startedLabel: string
}
export interface RunGroupRow {
kind: "group"
id: string
group: RunStatusGroup
subRows?: RunItemRow[]
}
export interface RunItemRow {
kind: "run"
id: string
group: RunStatusGroup
run: AgentRun
}
export type RunTableRow = RunGroupRow | RunItemRow
export const RUN_STATUS_ORDER: RunStatusId[] = [
"running",
"waiting",
"failed",
"completed",
]
export const RUN_ATTENTION_OPTIONS: RunAttention[] = [
"Escalated",
"Needs approval",
"Retrying",
"On track",
"Done",
]
export const TOAST_SUCCESS_ICON = (
<CircleCheckIcon className="size-[18px] text-green-600" aria-hidden="true" />
)
export const TOAST_INFO_ICON = (
<InfoIcon className="text-muted-foreground size-[18px]" aria-hidden="true" />
)
export const AGENT_DETAILS: Record<
AgentName,
{ label: AgentName; icon: ReactNode }
> = {
"Refund Resolver": {
label: "Refund Resolver",
icon: (
<CreditCardIcon className="size-3.5" aria-hidden="true" />
),
},
"Outreach Composer": {
label: "Outreach Composer",
icon: (
<SendIcon className="size-3.5" aria-hidden="true" />
),
},
"Data Steward": {
label: "Data Steward",
icon: (
<DatabaseIcon className="size-3.5" aria-hidden="true" />
),
},
"Invoice Reconciler": {
label: "Invoice Reconciler",
icon: (
<BookOpenIcon className="size-3.5" aria-hidden="true" />
),
},
"Lead Enricher": {
label: "Lead Enricher",
icon: (
<SparklesIcon className="size-3.5" aria-hidden="true" />
),
},
"Contract Summarizer": {
label: "Contract Summarizer",
icon: (
<FileTextIcon className="size-3.5" aria-hidden="true" />
),
},
}
export const RUN_STATUS_GROUPS: RunStatusGroup[] = [
{
id: "running",
label: "Running",
summary: "Live executions streaming steps right now",
focus: "Throughput",
order: 1,
},
{
id: "waiting",
label: "Waiting",
summary: "Queued behind approvals, rate limits, or schedules",
focus: "Backlog",
order: 2,
},
{
id: "failed",
label: "Failed",
summary: "Stopped runs awaiting retry or escalation",
focus: "Recovery",
order: 3,
},
{
id: "completed",
label: "Completed",
summary: "Finished in the last 24 hours",
focus: "Audit",
order: 4,
},
]
const MAYA: RunOwner = {
id: "owner-maya",
name: "Maya Perez",
initials: "MP",
avatarSrc:
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
role: "Operations lead",
}
const NOA: RunOwner = {
id: "owner-noa",
name: "Noa Kim",
initials: "NK",
avatarSrc:
"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=96&h=96&dpr=2&q=80",
role: "Reliability engineer",
}
const EMIL: RunOwner = {
id: "owner-emil",
name: "Emil Novak",
initials: "EN",
avatarSrc:
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
role: "Platform engineer",
}
const LARA: RunOwner = {
id: "owner-lara",
name: "Lara Chen",
initials: "LC",
avatarSrc:
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=96&h=96&dpr=2&q=80",
role: "Support automation",
}
const PAVEL: RunOwner = {
id: "owner-pavel",
name: "Pavel Singh",
initials: "PS",
avatarSrc:
"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=96&h=96&dpr=2&q=80",
role: "Growth engineer",
}
const JONAS: RunOwner = {
id: "owner-jonas",
name: "Jonas Reed",
initials: "JR",
role: "Safety reviewer",
}
export const RUN_OWNERS: RunOwner[] = [MAYA, NOA, EMIL, LARA, PAVEL, JONAS]
export const AGENT_RUNS: AgentRun[] = [
// Running
{
id: "run-4831",
runKey: "RUN-4831",
title: "Refund duplicate charge on ORD-99214 for Acme Corp",
context:
"Step 4 of 6 · Matching the second transaction in the payment ledger",
statusId: "running",
agent: "Refund Resolver",
environment: "Production",
attention: "On track",
owner: MAYA,
owners: [],
stepProgress: 62,
startedAt: "2026-06-10T12:40:00Z",
startedLabel: "2m ago",
},
{
id: "run-4830",
runKey: "RUN-4830",
title: "Draft renewal outreach for 38 Q3 accounts",
context: "Step 2 of 5 · Pulling usage summaries for segment B",
statusId: "running",
agent: "Outreach Composer",
environment: "Production",
attention: "On track",
owner: PAVEL,
owners: [],
stepProgress: 35,
startedAt: "2026-06-10T12:34:00Z",
startedLabel: "8m ago",
},
{
id: "run-4829",
runKey: "RUN-4829",
title: "Dedupe 412 contacts imported from the spring webinar",
context: "Step 3 of 4 · Merging 57 confirmed duplicate pairs",
statusId: "running",
agent: "Data Steward",
environment: "Staging",
attention: "On track",
owner: EMIL,
owners: [],
stepProgress: 70,
startedAt: "2026-06-10T12:28:00Z",
startedLabel: "14m ago",
},
{
id: "run-4828",
runKey: "RUN-4828",
title: "Refund damaged item claim on ORD-99187 for Globex",
context: "Step 5 of 6 · Waiting on finance approval for the $1,240 credit",
statusId: "running",
agent: "Refund Resolver",
environment: "Production",
attention: "Needs approval",
owner: MAYA,
owners: [MAYA, JONAS],
stepProgress: 81,
startedAt: "2026-06-10T12:20:00Z",
startedLabel: "22m ago",
},
{
id: "run-4827",
runKey: "RUN-4827",
title: "Summarize the Meridian master services agreement",
context: "Step 1 of 3 · Splitting 84 pages into clause sections",
statusId: "running",
agent: "Contract Summarizer",
environment: "Development",
attention: "On track",
owner: LARA,
owners: [],
stepProgress: 15,
startedAt: "2026-06-10T12:11:00Z",
startedLabel: "31m ago",
},
// Waiting
{
id: "run-4826",
runKey: "RUN-4826",
title: "Reconcile 23 June freight invoices",
context: "Queued 18 min · Position 2 in the finance lane",
statusId: "waiting",
agent: "Invoice Reconciler",
environment: "Production",
attention: "On track",
owner: NOA,
owners: [],
stepProgress: null,
startedAt: "2026-06-10T12:24:00Z",
startedLabel: "18m ago",
},
{
id: "run-4825",
runKey: "RUN-4825",
title: "Send onboarding nudges to 112 trial signups",
context: "Step 1 of 4 · Rate limited by the email provider, retry at 13:05",
statusId: "waiting",
agent: "Outreach Composer",
environment: "Production",
attention: "Retrying",
owner: PAVEL,
owners: [],
stepProgress: 10,
startedAt: "2026-06-10T12:16:00Z",
startedLabel: "26m ago",
},
{
id: "run-4824",
runKey: "RUN-4824",
title: "Enrich 64 new leads from the partner webinar",
context: "Queued 32 min · Waiting for the enrichment API window",
statusId: "waiting",
agent: "Lead Enricher",
environment: "Staging",
attention: "On track",
owner: PAVEL,
owners: [],
stepProgress: null,
startedAt: "2026-06-10T12:10:00Z",
startedLabel: "32m ago",
},
{
id: "run-4823",
runKey: "RUN-4823",
title: "Archive conversations older than 180 days",
context: "Queued 1 h · Scheduled for the low traffic window at 02:00",
statusId: "waiting",
agent: "Data Steward",
environment: "Production",
attention: "On track",
owner: EMIL,
owners: [],
stepProgress: null,
startedAt: "2026-06-10T11:42:00Z",
startedLabel: "1h ago",
},
// Failed
{
id: "run-4822",
runKey: "RUN-4822",
title: "Refund partial return on ORD-99102 for Acme Corp",
context:
"Failed at step 3 of 6 · The processor declined the partial capture",
statusId: "failed",
agent: "Refund Resolver",
environment: "Production",
attention: "Escalated",
owner: MAYA,
owners: [MAYA, NOA],
stepProgress: 48,
startedAt: "2026-06-10T11:38:00Z",
startedLabel: "1h ago",
},
{
id: "run-4821",
runKey: "RUN-4821",
title: "Match 9 supplier invoices to June purchase orders",
context: "Failed at step 2 of 5 · Two invoices reference a closed PO",
statusId: "failed",
agent: "Invoice Reconciler",
environment: "Production",
attention: "Escalated",
owner: NOA,
owners: [],
stepProgress: 32,
startedAt: "2026-06-10T10:55:00Z",
startedLabel: "2h ago",
},
{
id: "run-4820",
runKey: "RUN-4820",
title: "Summarize 3 renewal contracts for legal review",
context:
"Failed at step 2 of 3 · The Globex renewal PDF is password protected",
statusId: "failed",
agent: "Contract Summarizer",
environment: "Development",
attention: "Retrying",
owner: LARA,
owners: [],
stepProgress: 55,
startedAt: "2026-06-10T09:48:00Z",
startedLabel: "3h ago",
},
// Completed
{
id: "run-4819",
runKey: "RUN-4819",
title: "Send weekly digest to 1,847 subscribers",
context: "Done in 4m 12s · 1,812 delivered, 35 bounced",
statusId: "completed",
agent: "Outreach Composer",
environment: "Production",
attention: "Done",
owner: PAVEL,
owners: [],
stepProgress: 100,
startedAt: "2026-06-09T16:05:00Z",
startedLabel: "Yesterday",
},
{
id: "run-4818",
runKey: "RUN-4818",
title: "Rebuild the product catalog embeddings",
context: "Done in 18m · 3,072 vectors refreshed",
statusId: "completed",
agent: "Data Steward",
environment: "Staging",
attention: "Done",
owner: EMIL,
owners: [],
stepProgress: 100,
startedAt: "2026-06-09T14:30:00Z",
startedLabel: "Yesterday",
},
{
id: "run-4817",
runKey: "RUN-4817",
title: "Score 240 inbound leads from the pricing page",
context: "Done in 6m 40s · 31 leads routed to sales",
statusId: "completed",
agent: "Lead Enricher",
environment: "Production",
attention: "Done",
owner: JONAS,
owners: [],
stepProgress: 100,
startedAt: "2026-06-09T11:20:00Z",
startedLabel: "Yesterday",
},
{
id: "run-4816",
runKey: "RUN-4816",
title: "Refund cancelled subscription for Globex",
context: "Done in 1m 05s · $89 credited to the original card",
statusId: "completed",
agent: "Refund Resolver",
environment: "Production",
attention: "Done",
owner: MAYA,
owners: [],
stepProgress: 100,
startedAt: "2026-06-08T15:42:00Z",
startedLabel: "Mon",
},
]
@@ -1,614 +0,0 @@
"use no memo"
import { memo, type ComponentProps } from "react"
import { Badge } from "@/components/reui/badge"
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
import { type ColumnDef } from "@tanstack/react-table"
import { cn } from "@evofw/ui/lib/utils"
import {
Avatar,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarImage,
} from "@evofw/ui/components/avatar"
import { Button } from "@evofw/ui/components/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@evofw/ui/components/dropdown-menu"
import { Item, ItemMedia } from "@evofw/ui/components/item"
import {
AGENT_DETAILS,
type AgentRun,
type RunAttention,
type RunEnvironment,
type RunGroupRow,
type RunItemRow,
type RunOwner,
type RunStatusId,
type RunTableRow,
} from "./data"
import { ChevronRightIcon, ArrowRightIcon, CalendarClockIcon, MoreHorizontalIcon, EyeIcon, CopyIcon, RefreshCwIcon, PauseIcon } from "lucide-react"
export type RunAction = "open" | "copy" | "retry" | "pause"
const attentionVariant: Record<
RunAttention,
ComponentProps<typeof Badge>["variant"]
> = {
Escalated: "destructive-light",
"Needs approval": "warning-light",
Retrying: "info-light",
"On track": "success-light",
Done: "secondary",
}
const environmentDotClass: Record<RunEnvironment, string> = {
Production: "bg-primary",
Staging: "bg-warning",
Development: "bg-info",
}
const agentTileClass = "bg-background border-border text-foreground"
const statusDotClass: Record<RunStatusId, string> = {
running: "bg-sky-500",
waiting: "bg-muted-foreground/70",
failed: "bg-destructive",
completed: "bg-success",
}
function isRunRow(row: RunTableRow): row is RunItemRow {
return row.kind === "run"
}
function getGroupRuns(row: RunGroupRow) {
return row.subRows?.map((item) => item.run) ?? []
}
function getGroupAttention(runs: AgentRun[]): RunAttention {
if (runs.some((run) => run.attention === "Escalated")) return "Escalated"
if (runs.some((run) => run.attention === "Needs approval")) {
return "Needs approval"
}
if (runs.some((run) => run.attention === "Retrying")) return "Retrying"
if (runs.length > 0 && runs.every((run) => run.attention === "Done")) {
return "Done"
}
return "On track"
}
function getLatestRun(runs: AgentRun[]) {
return runs.reduce<AgentRun | undefined>((latest, run) => {
if (!latest || run.startedAt.localeCompare(latest.startedAt) > 0) return run
return latest
}, undefined)
}
function getRunOwners(run: AgentRun) {
if (run.owners.length > 0) return run.owners
return run.owner ? [run.owner] : []
}
function stepRingColor(rate: number) {
if (rate >= 75) return "text-emerald-500"
if (rate >= 40) return "text-amber-500"
return "text-rose-500"
}
const OwnerAvatar = memo(function OwnerAvatar({
owner,
className,
}: {
owner: RunOwner | null
className?: string
}) {
return (
<Avatar className={cn("size-5 shrink-0", className)}>
{owner?.avatarSrc ? (
<AvatarImage src={owner.avatarSrc} alt={owner.name} />
) : null}
<AvatarFallback className="text-[9px]">
{owner?.initials ?? "--"}
</AvatarFallback>
</Avatar>
)
})
function EnvironmentBadge({ environment }: { environment: RunEnvironment }) {
return (
<Badge variant="outline" className="bg-background gap-1.5">
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
environmentDotClass[environment]
)}
aria-hidden="true"
/>
{environment}
</Badge>
)
}
function AttentionBadge({ attention }: { attention: RunAttention }) {
return <Badge variant={attentionVariant[attention]}>{attention}</Badge>
}
function StatusMark({ statusId }: { statusId: RunStatusId }) {
return (
<span
className={cn("size-2.5 shrink-0 rounded-full", statusDotClass[statusId])}
aria-hidden="true"
/>
)
}
function GroupExpandButton({
label,
expanded,
onToggle,
}: {
label: string
expanded: boolean
onToggle: () => void
}) {
return (
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={expanded ? `Collapse ${label}` : `Expand ${label}`}
aria-expanded={expanded}
className="text-muted-foreground hover:text-foreground size-6 shrink-0 p-0 shadow-none"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onToggle()
}}
>
<ChevronRightIcon className={cn(
"size-3.5 shrink-0 transition-transform duration-150",
expanded && "rotate-90"
)} aria-hidden="true" />
</Button>
)
}
function StatusGroupCell({
row,
expanded,
onToggle,
}: {
row: RunGroupRow
expanded: boolean
onToggle: () => void
}) {
return (
<div data-run-row="group" className="flex min-w-0 items-center gap-1.5">
<GroupExpandButton
label={row.group.label}
expanded={expanded}
onToggle={onToggle}
/>
<StatusMark statusId={row.group.id} />
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="text-foreground shrink-0 truncate text-sm font-medium">
{row.group.label}
</span>
<Badge variant="outline" className="shrink-0">
{row.subRows?.length ?? 0}
</Badge>
</div>
</div>
)
}
function AgentIcon({ run }: { run: AgentRun }) {
return (
<Item
render={<span />}
className={cn(
"p-0",
"flex size-7 shrink-0 items-center justify-center border [&_svg]:opacity-95",
agentTileClass
)}
>
<ItemMedia variant="icon" className="size-auto">
{AGENT_DETAILS[run.agent].icon}
</ItemMedia>
</Item>
)
}
function RunTitleAffordance({ title }: { title: string }) {
return (
<span className="group/run-title flex min-w-0 items-center gap-1">
<span
data-slot="run-title"
className="hover:text-primary text-foreground min-w-0 cursor-pointer truncate py-0.25 transition-colors"
>
{title}
</span>
<ArrowRightIcon className="size-3 shrink-0 -translate-x-1 opacity-0 transition-all group-hover/run-title:translate-x-0 group-hover/run-title:opacity-100" aria-hidden="true" />
</span>
)
}
function RunTitleCell({
run,
showContext,
}: {
run: AgentRun
showContext: boolean
}) {
return (
<div data-run-row="run" className="flex min-w-0 items-center gap-3">
<AgentIcon run={run} />
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="min-w-0 text-sm leading-5 font-medium">
<RunTitleAffordance title={run.title} />
</div>
{showContext ? (
<p className="text-muted-foreground truncate text-xs leading-4">
{run.context}
</p>
) : null}
</div>
</div>
)
}
function RunCell({
row,
expanded,
onToggle,
showContext,
}: {
row: RunTableRow
expanded: boolean
onToggle: () => void
showContext: boolean
}) {
if (isRunRow(row)) {
return <RunTitleCell run={row.run} showContext={showContext} />
}
return <StatusGroupCell row={row} expanded={expanded} onToggle={onToggle} />
}
function OwnerCell({ run }: { run: AgentRun }) {
const owners = getRunOwners(run)
const visibleOwners = owners.slice(0, 3)
const overflowCount = Math.max(0, owners.length - visibleOwners.length)
if (owners.length === 0) {
return (
<div className="flex min-w-0 items-center justify-end">
<OwnerAvatar owner={null} />
<span className="sr-only">Unassigned owner</span>
</div>
)
}
return (
<div className="flex min-w-0 items-center justify-end">
<AvatarGroup
aria-label={`Assigned owners for ${run.runKey}`}
className="-space-x-1"
>
{visibleOwners.map((owner) => (
<OwnerAvatar
key={owner.id}
owner={owner}
className="ring-background ring-2"
/>
))}
{overflowCount > 0 ? (
<AvatarGroupCount className="bg-background size-5 border text-[9px] tabular-nums">
+{overflowCount}
</AvatarGroupCount>
) : null}
</AvatarGroup>
<span className="sr-only">
{owners.map((owner) => owner.name).join(",")}
</span>
</div>
)
}
function StartedCell({ run }: { run: AgentRun }) {
return (
<Badge variant="outline" className="bg-background gap-1.5 font-normal">
<CalendarClockIcon className="text-muted-foreground size-3.5" aria-hidden="true" />
<span className="tabular-nums">{run.startedLabel}</span>
</Badge>
)
}
function StepProgressCell({ run }: { run: AgentRun }) {
if (run.stepProgress === null) {
return (
<div className="flex justify-end">
<span className="text-muted-foreground text-sm tabular-nums">-</span>
</div>
)
}
const rate = run.stepProgress
const radius = 9
const circumference = 2 * Math.PI * radius
const dashOffset = circumference - (rate / 100) * circumference
return (
<div
className="flex items-center justify-end gap-2"
aria-label={`${rate}% of steps complete`}
>
<svg
viewBox="0 0 24 24"
className={cn("size-5 shrink-0", stepRingColor(rate))}
aria-hidden="true"
>
<circle
cx="12"
cy="12"
r={radius}
fill="none"
className="stroke-border"
strokeWidth="2.5"
/>
<circle
cx="12"
cy="12"
r={radius}
fill="none"
className="stroke-current"
strokeWidth="2.5"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
transform="rotate(-90 12 12)"
/>
</svg>
<span className="text-foreground text-sm tabular-nums">{rate}%</span>
</div>
)
}
function GroupSummaryCell({ row }: { row: RunGroupRow }) {
return (
<div className="relative h-4 min-w-0">
<span className="text-muted-foreground pointer-events-none absolute top-1/2 right-0 w-80 max-w-[calc(100vw-8rem)] -translate-y-1/2 truncate text-right text-sm leading-4">
{row.group.summary}
</span>
</div>
)
}
function RunActionsCell({
run,
onAction,
}: {
run: AgentRun
onAction: (action: RunAction, run: AgentRun) => void
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={`Actions for ${run.runKey}`}
/>
}
>
<MoreHorizontalIcon aria-hidden="true" />
</DropdownMenuTrigger>
{/* Content */}
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => onAction("open", run)}>
<EyeIcon aria-hidden="true" />
Open run
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onAction("copy", run)}>
<CopyIcon aria-hidden="true" />
Copy run id
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onAction("retry", run)}>
<RefreshCwIcon aria-hidden="true" />
Retry run
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onAction("pause", run)}>
<PauseIcon aria-hidden="true" />
Pause run
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export function createRunColumns({
showContext,
onAction,
}: {
showContext: boolean
onAction: (action: RunAction, run: AgentRun) => void
}): ColumnDef<RunTableRow>[] {
return [
{
accessorFn: (row) => (isRunRow(row) ? row.run.title : row.group.label),
id: "run",
header: ({ column }) => (
<DataGridColumnHeader title="Run" column={column} />
),
cell: ({ row }) => (
<RunCell
row={row.original}
expanded={row.getIsExpanded()}
onToggle={row.getToggleExpandedHandler()}
showContext={showContext}
/>
),
enableHiding: false,
enableSorting: false,
minSize: 300,
meta: {
headerTitle: "Run",
autoSize: true,
},
},
{
accessorFn: (row) =>
isRunRow(row)
? getRunOwners(row.run)
.map((owner) => owner.name)
.join(",") || "Unassigned"
: row.group.focus,
id: "owner",
header: ({ column }) => (
<DataGridColumnHeader title="Owner" column={column} />
),
cell: ({ row }) =>
isRunRow(row.original) ? (
<OwnerCell run={row.original.run} />
) : (
<span className="sr-only">{row.original.group.focus}</span>
),
size: 120,
enableSorting: false,
meta: {
headerTitle: "Owner",
},
},
{
accessorFn: (row) =>
isRunRow(row) ? row.run.environment : row.group.focus,
id: "environment",
header: ({ column }) => (
<DataGridColumnHeader title="Env" column={column} />
),
cell: ({ row }) =>
isRunRow(row.original) ? (
<div className="flex justify-end">
<EnvironmentBadge environment={row.original.run.environment} />
</div>
) : (
<span className="sr-only">{row.original.group.focus}</span>
),
size: 96,
enableSorting: false,
meta: {
headerTitle: "Env",
},
},
{
accessorFn: (row) =>
isRunRow(row)
? row.run.startedAt
: (getLatestRun(getGroupRuns(row))?.startedAt ?? ""),
id: "started",
header: ({ column }) => (
<DataGridColumnHeader title="Started" column={column} />
),
cell: ({ row }) => {
if (isRunRow(row.original)) {
return (
<div className="flex justify-end">
<StartedCell run={row.original.run} />
</div>
)
}
return (
<span className="sr-only">
Latest {getLatestRun(getGroupRuns(row.original))?.startedLabel}
</span>
)
},
size: 92,
enableSorting: false,
meta: {
headerTitle: "Started",
},
},
{
accessorFn: (row) =>
isRunRow(row)
? (row.run.stepProgress ?? -1)
: Math.round(
getGroupRuns(row).reduce(
(sum, run) => sum + (run.stepProgress ?? 0),
0
) / Math.max(getGroupRuns(row).length, 1)
),
id: "steps",
header: ({ column }) => (
<DataGridColumnHeader title="Steps" column={column} />
),
cell: ({ row }) => {
if (isRunRow(row.original)) {
return <StepProgressCell run={row.original.run} />
}
return null
},
size: 84,
enableSorting: false,
meta: {
headerTitle: "Steps",
},
},
{
accessorFn: (row) =>
isRunRow(row)
? row.run.attention
: getGroupAttention(getGroupRuns(row)),
id: "attention",
header: ({ column }) => (
<DataGridColumnHeader title="Attention" column={column} />
),
cell: ({ row }) =>
isRunRow(row.original) ? (
<div className="flex justify-end">
<AttentionBadge attention={row.original.run.attention} />
</div>
) : (
<span className="sr-only">
{getGroupAttention(getGroupRuns(row.original))}
</span>
),
size: 80,
enableSorting: false,
meta: {
headerTitle: "Attention",
},
},
{
id: "actions",
header: "",
cell: ({ row }) =>
isRunRow(row.original) ? (
<div className="flex justify-end">
<RunActionsCell run={row.original.run} onAction={onAction} />
</div>
) : (
<GroupSummaryCell row={row.original} />
),
size: 60,
enableHiding: false,
enableSorting: false,
},
]
}
@@ -1,565 +0,0 @@
"use client"
"use no memo"
import { useCallback, useMemo, useState, type ComponentProps } from "react"
import { Badge } from "@/components/reui/badge"
import {
DataGrid,
DataGridContainer,
} from "@/components/reui/data-grid/data-grid"
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
import {
getCoreRowModel,
getExpandedRowModel,
useReactTable,
type ExpandedState,
} from "@tanstack/react-table"
import { toast } from "sonner"
import { cn } from "@evofw/ui/lib/utils"
import { Button } from "@evofw/ui/components/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@evofw/ui/components/dropdown-menu"
import { Field, FieldGroup, FieldLabel } from "@evofw/ui/components/field"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@evofw/ui/components/input-group"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@evofw/ui/components/popover"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evofw/ui/components/select"
import { Switch } from "@evofw/ui/components/switch"
import {
AGENT_RUNS,
RUN_ATTENTION_OPTIONS,
RUN_STATUS_GROUPS,
TOAST_INFO_ICON,
TOAST_SUCCESS_ICON,
type AgentRun,
type RunAttention,
type RunGroupRow,
type RunItemRow,
type RunStatusGroup,
type RunTableRow,
} from "./data"
import { createRunColumns, type RunAction } from "./run-queue-columns"
import { SearchIcon, XIcon, BellIcon, Settings2Icon, PlusIcon } from "lucide-react"
type TableDensity = "compact" | "comfortable"
const TABLE_DENSITY_OPTIONS: { value: TableDensity; label: string }[] = [
{ value: "compact", label: "Compact" },
{ value: "comfortable", label: "Comfortable" },
]
function getRunSearchBlob(run: AgentRun) {
return [
run.runKey,
run.title,
run.context,
run.agent,
run.environment,
run.attention,
run.owner?.name,
run.owner?.role,
run.owners.map((owner) => owner.name).join(" "),
run.owners.map((owner) => owner.role).join(" "),
run.stepProgress === null ? "queued" : `${run.stepProgress}%`,
]
.filter(Boolean)
.join(" ")
.toLowerCase()
}
function buildRunRows(runs: AgentRun[]): RunGroupRow[] {
return RUN_STATUS_GROUPS.map((group) => {
const subRows: RunItemRow[] = runs
.filter((run) => run.statusId === group.id)
.map((run) => ({
kind: "run",
id: run.id,
group,
run,
}))
const row: RunGroupRow = {
kind: "group",
id: group.id,
group,
subRows,
}
return row
}).filter((row) => (row.subRows?.length ?? 0) > 0)
}
function getExpandedGroupState(rows: RunGroupRow[]): ExpandedState {
return rows.reduce<Record<string, boolean>>((expanded, row) => {
expanded[row.id] = true
return expanded
}, {})
}
function isExpanded(expanded: ExpandedState, rowId: string) {
if (expanded === true) return true
return expanded[rowId] === true
}
function getGroupRunCount(group: RunStatusGroup, runs: AgentRun[]) {
return runs.filter((run) => run.statusId === group.id).length
}
function getAttentionCount(runs: AgentRun[], attention: RunAttention) {
return runs.filter((run) => run.attention === attention).length
}
function getUnassignedRunCount(runs: AgentRun[]) {
return runs.filter((run) => !run.owner && run.owners.length === 0).length
}
function RunMetric({
label,
value,
variant = "secondary",
}: {
label: string
value: number
variant?: ComponentProps<typeof Badge>["variant"]
}) {
return (
<div className="flex min-w-0 items-center gap-2 sm:border-l sm:pl-3 sm:first:border-l-0 sm:first:pl-0">
<span className="text-muted-foreground truncate text-xs font-medium">
{label}
</span>
<Badge variant={variant}>{value}</Badge>
</div>
)
}
export function RunQueue() {
const [searchQuery, setSearchQuery] = useState("")
const [selectedAttention, setSelectedAttention] = useState<RunAttention[]>([])
const [showContext, setShowContext] = useState(true)
const [tableDensity, setTableDensity] = useState<TableDensity>("compact")
const [expandedRows, setExpandedRows] = useState<ExpandedState>(() =>
getExpandedGroupState(buildRunRows(AGENT_RUNS))
)
const filteredRuns = useMemo(() => {
const normalizedQuery = searchQuery.trim().toLowerCase()
return AGENT_RUNS.filter((run) => {
if (
normalizedQuery.length > 0 &&
!getRunSearchBlob(run).includes(normalizedQuery)
) {
return false
}
if (
selectedAttention.length > 0 &&
!selectedAttention.includes(run.attention)
) {
return false
}
return true
})
}, [searchQuery, selectedAttention])
const groupedRows = useMemo(() => buildRunRows(filteredRuns), [filteredRuns])
const allGroupsExpanded =
groupedRows.length > 0 &&
groupedRows.every((row) => isExpanded(expandedRows, row.id))
const activeFilterCount = selectedAttention.length
const escalatedRunCount = getAttentionCount(filteredRuns, "Escalated")
const approvalRunCount = getAttentionCount(filteredRuns, "Needs approval")
const unassignedRunCount = getUnassignedRunCount(filteredRuns)
const handleAttentionToggle = useCallback(
(attention: RunAttention, checked: boolean) => {
setSelectedAttention((current) => {
if (checked) {
return current.includes(attention) ? current : [...current, attention]
}
return current.filter((item) => item !== attention)
})
},
[]
)
const handleToggleGroups = useCallback(() => {
setExpandedRows(allGroupsExpanded ? {} : getExpandedGroupState(groupedRows))
}, [allGroupsExpanded, groupedRows])
const handleRunAction = useCallback((action: RunAction, run: AgentRun) => {
if (action === "open") {
toast.info("Open run", {
description: `${run.runKey} / ${run.title}`,
icon: TOAST_INFO_ICON,
})
return
}
if (action === "copy") {
if (typeof navigator !== "undefined" && navigator.clipboard) {
void navigator.clipboard.writeText(run.runKey)
}
toast.success("Run id copied", {
description: run.runKey,
icon: TOAST_SUCCESS_ICON,
})
return
}
if (action === "retry") {
toast.success("Run requeued", {
description: `${run.runKey} replays from its last successful checkpoint.`,
icon: TOAST_SUCCESS_ICON,
})
return
}
toast.info("Run paused", {
description: `${run.runKey} holds after its current step until resumed.`,
icon: TOAST_INFO_ICON,
})
}, [])
const handleNewRun = useCallback(() => {
toast.info("New run", {
description: "Connect this action to your agent launch flow.",
icon: TOAST_INFO_ICON,
})
}, [])
const columns = useMemo(
() =>
createRunColumns({
showContext,
onAction: handleRunAction,
}),
[handleRunAction, showContext]
)
const table = useReactTable({
data: groupedRows,
columns,
getRowId: (row) => row.id,
getSubRows: (row) =>
row.kind === "group"
? (row.subRows as RunTableRow[] | undefined)
: undefined,
getRowCanExpand: (row) =>
row.original.kind === "group" && Boolean(row.original.subRows?.length),
state: {
expanded: expandedRows,
},
onExpandedChange: setExpandedRows,
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
})
function clearFilters() {
setSearchQuery("")
setSelectedAttention([])
}
return (
<DataGrid
table={table}
recordCount={filteredRuns.length}
emptyMessage="No runs match this view. Clear the search or attention filters."
tableLayout={{
dense: tableDensity === "compact",
rowBorder: true,
headerSticky: false,
columnsVisibility: false,
columnsResizable: false,
columnsMovable: false,
width: "fixed",
}}
tableClassNames={{
body: "[&>tr:has(>td:only-child:empty)]:hidden",
bodyRow: cn(
"group/run-row [&>td]:h-9 [&:has([data-run-row=group])>td]:h-11 [&:has([data-run-row=group])>td]:bg-muted/45 [&:has([data-run-row=group])>td]:shadow-none [&:has([data-run-row=group])>td]:hover:bg-muted/45",
showContext && "[&>td]:h-12"
),
edgeCell: "first:ps-3 last:pe-3 lg:first:ps-4 lg:last:pe-4",
}}
>
<section className="flex w-full max-w-7xl flex-col px-4 py-8 sm:px-6 lg:px-8">
{/* Header */}
<div className="mb-4 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="flex min-w-0 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-xl font-semibold tracking-tight">
Run Queue
</h2>
<span className="flex items-center gap-1.5">
<span
className="relative flex size-2 shrink-0"
aria-hidden="true"
>
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500/60" />
<span className="relative inline-flex size-2 rounded-full bg-emerald-500" />
</span>
<span className="text-muted-foreground hidden text-sm sm:block">
Live
</span>
</span>
</div>
<p className="text-muted-foreground max-w-2xl text-sm">
Triage the live agent run backlog.
</p>
</div>
<div className="flex min-w-0 flex-wrap items-center gap-3">
<RunMetric label="Runs" value={filteredRuns.length} />
<RunMetric
label="Escalated"
value={escalatedRunCount}
variant={
escalatedRunCount > 0 ? "destructive-light" : "secondary"
}
/>
<RunMetric
label="Needs approval"
value={approvalRunCount}
variant={approvalRunCount > 0 ? "warning-light" : "secondary"}
/>
<RunMetric label="Unassigned" value={unassignedRunCount} />
</div>
</div>
{/* Toolbar */}
<div className="bg-muted/20 flex flex-col gap-3 border-y px-3 py-3 lg:flex-row lg:items-center lg:justify-between">
<InputGroup className="w-full min-w-0 lg:max-w-sm">
<InputGroupAddon align="inline-start">
<SearchIcon className="text-muted-foreground size-4" aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search runs..."
aria-label="Search agent runs"
/>
{searchQuery.length > 0 ? (
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label="Clear search"
onClick={() => setSearchQuery("")}
>
<XIcon className="size-4" aria-hidden="true" />
</InputGroupButton>
</InputGroupAddon>
) : null}
</InputGroup>
<div className="flex min-w-0 flex-wrap items-center gap-1.5 lg:justify-end">
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={
<Button type="button" variant="outline">
<BellIcon data-icon="inline-start" aria-hidden="true" />
Attention
{activeFilterCount > 0 ? (
<Badge variant="secondary">{activeFilterCount}</Badge>
) : null}
</Button>
}
/>
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuGroup>
<DropdownMenuLabel>Attention</DropdownMenuLabel>
{RUN_ATTENTION_OPTIONS.map((attention) => (
<DropdownMenuCheckboxItem
key={attention}
checked={selectedAttention.includes(attention)}
closeOnClick={false}
onCheckedChange={(checked) =>
handleAttentionToggle(attention, checked === true)
}
>
{attention}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{activeFilterCount > 0 ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
closeOnClick={false}
onClick={() => setSelectedAttention([])}
>
Reset attention
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
<Popover>
<PopoverTrigger
render={
<Button type="button" variant="outline">
<Settings2Icon data-icon="inline-start" aria-hidden="true" />
Display
</Button>
}
/>
<PopoverContent align="end" className="w-[300px] p-0">
<FieldGroup className="gap-3 px-3.5 py-3">
<div className="flex flex-col gap-2">
<div className="text-muted-foreground text-xs font-medium">
Table
</div>
<div className="flex flex-col">
<Field
orientation="horizontal"
className="min-h-9 items-center justify-between gap-3"
>
<FieldLabel className="text-sm font-normal">
Density
</FieldLabel>
<Select
value={tableDensity}
onValueChange={(value) =>
setTableDensity(value as TableDensity)
}
>
<SelectTrigger
size="sm"
className="w-[132px] shrink-0"
>
<SelectValue>
{
TABLE_DENSITY_OPTIONS.find(
(option) => option.value === tableDensity
)?.label
}
</SelectValue>
</SelectTrigger>
<SelectContent align="end">
<SelectGroup>
{TABLE_DENSITY_OPTIONS.map((option) => (
<SelectItem
key={option.value}
value={option.value}
>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field
orientation="horizontal"
className="min-h-9 items-center justify-between gap-3"
>
<FieldLabel className="text-sm font-normal">
Latest step line
</FieldLabel>
<Switch
size="sm"
checked={showContext}
onCheckedChange={setShowContext}
aria-label="Toggle the latest step context line"
/>
</Field>
</div>
</div>
</FieldGroup>
</PopoverContent>
</Popover>
<Button
type="button"
variant="outline"
onClick={handleToggleGroups}
>
{allGroupsExpanded ? "Collapse groups" : "Expand groups"}
</Button>
{searchQuery.length > 0 || selectedAttention.length > 0 ? (
<Button type="button" variant="ghost" onClick={clearFilters}>
Clear
</Button>
) : null}
<Button type="button" onClick={handleNewRun}>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
New run
</Button>
</div>
</div>
{/* Content */}
<DataGridContainer className="border-b">
<DataGridScrollArea>
<DataGridTable renderHeader={false} />
</DataGridScrollArea>
</DataGridContainer>
{/* Footer */}
<div className="text-muted-foreground mt-3 flex flex-col gap-2 text-sm lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="text-foreground font-medium">Queue Mix</span>
{RUN_STATUS_GROUPS.map((group) => (
<Badge key={group.id} variant="outline">
{group.label}: {getGroupRunCount(group, filteredRuns)}
</Badge>
))}
</div>
<div className="flex min-w-0 flex-wrap items-center gap-2 text-xs">
<span className="text-foreground font-medium">
{filteredRuns.length} visible
</span>
<span className="text-muted-foreground">
of {AGENT_RUNS.length} runs
</span>
<Badge
variant={
activeFilterCount > 0 || searchQuery.length > 0
? "info-light"
: "secondary"
}
>
{activeFilterCount > 0 || searchQuery.length > 0
? "Filtered"
: "All runs"}
</Badge>
</div>
</div>
</section>
</DataGrid>
)
}
@@ -1,15 +0,0 @@
import { RunQueue } from "./components/run-queue"
export function Page() {
return (
<main
className="bg-background mx-auto flex min-h-svh w-full max-w-[1320px] items-start justify-center p-3 md:p-4"
aria-labelledby="page-heading"
>
<h1 id="page-heading" className="sr-only">
Run Queue
</h1>
<RunQueue />
</main>
)
}
@@ -1,260 +0,0 @@
import { type ReactNode } from "react"
import { CircleCheckIcon, InfoIcon } from "lucide-react"
export type RunStatus = "running" | "failed" | "completed"
export type RunPriority = "low" | "medium" | "high" | "urgent"
export type RetryPolicy = "none" | "fixed" | "exponential"
export type StepStatus = "completed" | "active" | "failed" | "pending"
export type ToolCallStatus = "Succeeded" | "Failed"
export type RunSelectOption<TValue extends string = string> = {
value: TValue
label: string
description?: string
}
export type RunOwner = {
id: string
name: string
role: string
initials: string
avatarSrc?: string
}
export type RunSettingsValue = {
status: RunStatus
priority: RunPriority
ownerIds: string[]
retryPolicy: RetryPolicy
maxRetries: string
notifyOnFailure: boolean
}
export type ToolCall = {
id: string
name: string
status: ToolCallStatus
latency: string
note?: string
}
export type RunStep = {
id: number
title: string
status: StepStatus
duration?: string
summary: string
toolCalls: ToolCall[]
error?: { title: string; detail: string }
}
export const TOAST_SUCCESS_ICON = (
<CircleCheckIcon className="size-[18px] text-green-600" aria-hidden="true" />
)
export const TOAST_INFO_ICON = (
<InfoIcon className="text-muted-foreground size-[18px]" aria-hidden="true" />
)
export const RUN_IDENTITY = {
key: "RUN-4822",
agent: "Refund Resolver",
environment: "Production",
}
export const RUN_TIMESTAMPS = {
started: "Jun 10, 12:38",
lastActivity: "8 min ago",
}
export const STATUS_OPTIONS: RunSelectOption<RunStatus>[] = [
{
value: "running",
label: "Running",
description: "Executing steps and streaming output.",
},
{
value: "failed",
label: "Failed",
description: "Stopped at a step and waiting on an operator.",
},
{
value: "completed",
label: "Completed",
description: "Finished every step and wrote its results.",
},
]
export const PRIORITY_OPTIONS: RunSelectOption<RunPriority>[] = [
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "urgent", label: "Urgent" },
]
export const RETRY_POLICY_OPTIONS: RunSelectOption<RetryPolicy>[] = [
{
value: "none",
label: "No retries",
description: "Fail the run on the first step error.",
},
{
value: "fixed",
label: "Fixed delay",
description: "Retry every 30 seconds.",
},
{
value: "exponential",
label: "Exponential backoff",
description: "Retry at 30s, 2m, then 8m.",
},
]
export const RUN_OWNERS: RunOwner[] = [
{
id: "owner-maya",
name: "Maya Perez",
role: "Operations lead",
initials: "MP",
avatarSrc:
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
},
{
id: "owner-noa",
name: "Noa Kim",
role: "Reliability engineer",
initials: "NK",
avatarSrc:
"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=96&h=96&dpr=2&q=80",
},
{
id: "owner-emil",
name: "Emil Novak",
role: "Platform engineer",
initials: "EN",
avatarSrc:
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
},
{
id: "owner-lara",
name: "Lara Chen",
role: "Support automation",
initials: "LC",
avatarSrc:
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=96&h=96&dpr=2&q=80",
},
{
id: "owner-pavel",
name: "Pavel Singh",
role: "Growth engineer",
initials: "PS",
avatarSrc:
"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=96&h=96&dpr=2&q=80",
},
{
id: "owner-jonas",
name: "Jonas Reed",
role: "Safety reviewer",
initials: "JR",
},
]
export const DEFAULT_RUN_SETTINGS: RunSettingsValue = {
status: "failed",
priority: "high",
ownerIds: ["owner-maya", "owner-noa"],
retryPolicy: "exponential",
maxRetries: "3",
notifyOnFailure: true,
}
export const RUN_STEPS: RunStep[] = [
{
id: 1,
title: "Plan the refund approach",
status: "completed",
duration: "14s",
summary: "Matched the return to its original charge and picked a partial capture.",
toolCalls: [
{
id: "call-1a",
name: "kb.search",
status: "Succeeded",
latency: "320ms",
note: "2 refund runbooks retrieved",
},
{
id: "call-1b",
name: "orders.lookup",
status: "Succeeded",
latency: "410ms",
note: "ORD-99102, 3 line items",
},
],
},
{
id: 2,
title: "Validate the refund policy",
status: "completed",
duration: "9s",
summary: "Partial return of $182.40 fits the 30 day window and needs no second approval.",
toolCalls: [
{
id: "call-2a",
name: "policies.refunds.check",
status: "Succeeded",
latency: "280ms",
note: "under the $500 finance threshold",
},
],
},
{
id: 3,
title: "Capture the partial refund",
status: "failed",
duration: "12s",
summary: "The processor declined the partial capture on the original card.",
error: {
title: "Partial Capture Declined",
detail:
"payments.refunds.create returned 402 card_declined. Retry with a manual amount or credit the account instead.",
},
toolCalls: [
{
id: "call-3a",
name: "payments.refunds.create",
status: "Failed",
latency: "6.1s",
note: "402 card_declined, attempt 1 of 3",
},
{
id: "call-3b",
name: "payments.refunds.create",
status: "Failed",
latency: "5.8s",
note: "402 card_declined, attempt 2 of 3",
},
],
},
{
id: 4,
title: "Verify the refund landed",
status: "pending",
summary: "Waits for the capture to settle before checking the ledger.",
toolCalls: [],
},
{
id: 5,
title: "Notify the customer",
status: "pending",
summary: "Sends the confirmation email with the credited amount.",
toolCalls: [],
},
{
id: 6,
title: "Write back to the order record",
status: "pending",
summary: "Marks ORD-99102 refunded and closes the return.",
toolCalls: [],
},
]
@@ -1,222 +0,0 @@
import { useEffect, useRef, type ReactNode } from "react"
import { cn } from "@evofw/ui/lib/utils"
import { Button } from "@evofw/ui/components/button"
import { Field, FieldTitle } from "@evofw/ui/components/field"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
} from "@evofw/ui/components/input-group"
import { Item, ItemMedia } from "@evofw/ui/components/item"
import { Spinner } from "@evofw/ui/components/spinner"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@evofw/ui/components/tooltip"
import { InfoIcon, PencilIcon, XIcon, CheckIcon } from "lucide-react"
interface EditableDetailRowProps {
label: string
hint?: string
editing?: boolean
display: ReactNode
renderEdit?: (active: boolean) => ReactNode
align?: "center" | "start"
actionsDisabled?: boolean
saving?: boolean
onEdit?: () => void
onCancel?: () => void
onSave?: () => void
}
function RowHint({ label, children }: { label: string; children: string }) {
return (
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:text-foreground -my-1 shrink-0"
aria-label={label}
/>
}
>
<InfoIcon aria-hidden="true" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-64 text-xs leading-relaxed">
{children}
</TooltipContent>
</Tooltip>
)
}
export function EditableDetailRow({
label,
hint,
editing = false,
display,
renderEdit,
align = "center",
actionsDisabled = false,
saving = false,
onEdit,
onCancel,
onSave,
}: EditableDetailRowProps) {
const editable = Boolean(renderEdit && onEdit && onCancel && onSave)
const controlsDisabled = actionsDisabled || saving
const controlActive = !controlsDisabled
const editActionsDisabled = !editing || controlsDisabled
const editRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!editing) {
return
}
const frame = requestAnimationFrame(() => {
const control = editRef.current?.querySelector<HTMLElement>(
[
"[data-slot='input-group-control']:not(:disabled)",
"[data-slot='combobox-chip-input']:not(:disabled)",
"button:not(:disabled)",
"input:not(:disabled)",
].join(",")
)
control?.focus({ preventScroll: true })
})
return () => cancelAnimationFrame(frame)
}, [editing])
return (
<Field
className={cn(
"group/row grid gap-x-2 gap-y-1 px-4 py-1.5 sm:grid-cols-[minmax(7.75rem,0.5fr)_minmax(0,1.5fr)] sm:gap-4",
align === "start" ? "sm:items-start" : "sm:items-center"
)}
>
<FieldTitle
className={cn(
"text-muted-foreground flex min-w-0 items-center gap-1 text-sm font-normal",
align === "start" && "sm:min-h-8"
)}
>
<span className="min-w-0 truncate">{label}</span>
{hint ? <RowHint label={`${label} info`}>{hint}</RowHint> : null}
</FieldTitle>
{renderEdit ? (
<div
className={cn(
"relative col-start-1 row-start-2 min-h-8 min-w-0 sm:col-start-2 sm:row-start-1",
align === "start" ? "items-start" : "items-center"
)}
>
<button
type="button"
disabled={!editable || controlsDisabled || editing}
aria-label={`Edit ${label}`}
aria-hidden={editing}
className={cn(
"group/value flex w-full min-w-0 rounded-md border border-transparent px-2.5 text-left transition-[opacity,color,background-color] duration-150 outline-none",
align === "start"
? "h-auto min-h-8 items-start py-1"
: "h-8 items-center",
editable &&
"hover:bg-muted/40 active:bg-muted/40 sm:group-hover/row:bg-muted/40 focus-visible:border-transparent! focus-visible:ring-0! focus-visible:outline-none!",
controlsDisabled && "pointer-events-none",
editing
? "pointer-events-none absolute inset-x-0 top-0 opacity-0"
: "relative opacity-100"
)}
onClick={onEdit}
>
<span
className={cn(
"flex min-w-0",
align === "start" ? "items-start" : "items-center"
)}
>
{display}
</span>
{editable ? (
<Item
render={<span />}
className={cn(
"p-0",
"text-muted-foreground ml-1.5 flex size-5 shrink-0 items-center justify-center opacity-100 transition-opacity sm:opacity-0 sm:group-hover/row:opacity-100 sm:group-focus-visible/value:opacity-100",
controlsDisabled && "invisible opacity-0 sm:opacity-0"
)}
>
<ItemMedia variant="icon" className="size-auto">
<PencilIcon className="size-3.5" aria-hidden="true" />
</ItemMedia>
</Item>
) : null}
</button>
<div
ref={editRef}
aria-hidden={!editing}
inert={!editing ? true : undefined}
className={cn(
"min-w-0 transition-opacity duration-150",
editing
? "relative opacity-100"
: "pointer-events-none absolute inset-x-0 top-0 opacity-0"
)}
>
<InputGroup
className={cn(
"has-[[data-slot=input-group-control]:focus-visible]:border-input! box-border w-full has-[[data-slot=input-group-control]:focus-visible]:shadow-none! has-[[data-slot=input-group-control]:focus-visible]:ring-0!",
align === "start" ? "h-auto! min-h-8! items-start" : "h-8"
)}
>
{renderEdit(controlActive)}
{editable ? (
<InputGroupAddon
align="inline-end"
className={cn(
"gap-1 pr-2",
align === "start" && "self-start pt-1"
)}
>
<InputGroupButton
size="icon-xs"
aria-label={`Discard ${label}`}
disabled={editActionsDisabled}
onClick={onCancel}
>
<XIcon className="size-4" aria-hidden="true" />
</InputGroupButton>
<InputGroupButton
size="icon-xs"
aria-label={saving ? `Saving ${label}` : `Save ${label}`}
disabled={editActionsDisabled}
onClick={onSave}
>
{saving ? (
<Spinner className="size-3.5" />
) : (
<CheckIcon className="size-4" aria-hidden="true" />
)}
</InputGroupButton>
</InputGroupAddon>
) : null}
</InputGroup>
</div>
</div>
) : (
<div className="col-start-1 row-start-2 flex min-h-8 min-w-0 items-center px-2.5 sm:col-start-2 sm:row-start-1">
{display}
</div>
)}
</Field>
)
}
@@ -1,23 +0,0 @@
import { RunFacts } from "./run-facts"
import { RunHeader } from "./run-header"
import { RunTrace } from "./run-trace"
// One run's full story: header with live actions, then the step trace (with
// each step's tool calls inline) beside the row-editable run settings panel.
export function RunDetail() {
return (
<div className="@container flex w-full flex-col gap-4">
<RunHeader />
<div className="grid grid-cols-1 items-start gap-4 @4xl:grid-cols-3">
<div className="min-w-0 @4xl:col-span-2">
<RunTrace />
</div>
<div className="min-w-0">
<RunFacts />
</div>
</div>
</div>
)
}
@@ -1,703 +0,0 @@
"use client"
import {
Fragment,
useEffect,
useRef,
useState,
type FormEvent,
type ReactNode,
} from "react"
import { Badge, badgeVariants } from "@/components/reui/badge"
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import { toast } from "sonner"
import { cn } from "@evofw/ui/lib/utils"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@evofw/ui/components/avatar"
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from "@evofw/ui/components/combobox"
import { FieldLabel } from "@evofw/ui/components/field"
import {
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@evofw/ui/components/input-group"
import {
Item,
ItemContent,
ItemDescription,
ItemTitle,
} from "@evofw/ui/components/item"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evofw/ui/components/select"
import { Separator } from "@evofw/ui/components/separator"
import { Switch } from "@evofw/ui/components/switch"
import { TooltipProvider } from "@evofw/ui/components/tooltip"
import {
DEFAULT_RUN_SETTINGS,
PRIORITY_OPTIONS,
RETRY_POLICY_OPTIONS,
RUN_OWNERS,
RUN_TIMESTAMPS,
STATUS_OPTIONS,
TOAST_SUCCESS_ICON,
type RunOwner,
type RunSelectOption,
type RunSettingsValue,
} from "./data"
import { EditableDetailRow } from "./editable-detail-row"
import { CircleCheckIcon, AlertTriangleIcon, CircleDotIcon, ZapIcon, RefreshCwIcon, BellIcon, HashIcon, ClockIcon } from "lucide-react"
const FORM_ID = "run-settings"
const SAVE_DELAY_MS = 1800
type EditableRowId = keyof RunSettingsValue
function cloneRunSettingField<TKey extends keyof RunSettingsValue>(
value: RunSettingsValue[TKey]
): RunSettingsValue[TKey] {
if (Array.isArray(value)) {
return [...value] as RunSettingsValue[TKey]
}
return value
}
function cloneRunSettings(value: RunSettingsValue): RunSettingsValue {
return {
...value,
ownerIds: [...value.ownerIds],
}
}
function getOptionLabel<TValue extends string>(
options: RunSelectOption<TValue>[],
value: TValue
) {
return options.find((option) => option.value === value)?.label ?? value
}
function getOwners(ids: string[]) {
const selectedIds = new Set(ids)
return RUN_OWNERS.filter((owner) => selectedIds.has(owner.id))
}
function DetailValue({
icon,
children,
className,
}: {
icon?: ReactNode
children: ReactNode
className?: string
}) {
return (
<span
className={cn(
"text-foreground flex min-h-8 min-w-0 items-center gap-2 text-sm font-medium",
className
)}
>
{icon ? (
<span className="text-muted-foreground flex shrink-0 items-center">
{icon}
</span>
) : null}
<span className="min-w-0 truncate">{children}</span>
</span>
)
}
function StatusBadge({ status }: { status: RunSettingsValue["status"] }) {
if (status === "completed") {
return (
<Badge variant="success-light">
<CircleCheckIcon aria-hidden="true" />
Completed
</Badge>
)
}
if (status === "failed") {
return (
<Badge variant="destructive-light">
<AlertTriangleIcon aria-hidden="true" />
Failed
</Badge>
)
}
return (
<Badge variant="info-light">
<CircleDotIcon aria-hidden="true" />
Running
</Badge>
)
}
function PriorityValue({
priority,
}: {
priority: RunSettingsValue["priority"]
}) {
return (
<DetailValue
icon={
<ZapIcon className="size-4" aria-hidden="true" />
}
>
{getOptionLabel(PRIORITY_OPTIONS, priority)}
</DetailValue>
)
}
function RetryPolicyValue({
retryPolicy,
}: {
retryPolicy: RunSettingsValue["retryPolicy"]
}) {
return (
<DetailValue
icon={
<RefreshCwIcon className="size-4" aria-hidden="true" />
}
>
{getOptionLabel(RETRY_POLICY_OPTIONS, retryPolicy)}
</DetailValue>
)
}
function NotifyValue({
notifyOnFailure,
}: {
notifyOnFailure: RunSettingsValue["notifyOnFailure"]
}) {
return (
<DetailValue
icon={
<BellIcon className="size-4" aria-hidden="true" />
}
>
{notifyOnFailure ? "Notify owners" : "Silent"}
</DetailValue>
)
}
function SelectEditor<TValue extends string>({
id,
value,
options,
disabled,
renderValue,
renderOption,
onValueChange,
}: {
id: string
value: TValue
options: RunSelectOption<TValue>[]
disabled: boolean
renderValue?: (value: TValue) => ReactNode
renderOption?: (option: RunSelectOption<TValue>) => ReactNode
onValueChange: (value: TValue) => void
}) {
return (
<Select
value={value}
disabled={disabled}
onValueChange={(nextValue) => nextValue && onValueChange(nextValue)}
>
<SelectTrigger
id={id}
size="sm"
disabled={disabled}
className="h-8 min-w-0 flex-1 border-0 px-2.5 shadow-none focus:ring-0 focus-visible:ring-0"
>
<SelectValue>
{renderValue ? renderValue(value) : getOptionLabel(options, value)}
</SelectValue>
</SelectTrigger>
<SelectContent className="w-(--anchor-width)">
<SelectGroup>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{renderOption ? renderOption(option) : option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}
function OwnersCombobox({
selectedOwnerIds,
disabled,
onValueChange,
}: {
selectedOwnerIds: string[]
disabled: boolean
onValueChange: (ownerIds: string[]) => void
}) {
const anchor = useComboboxAnchor()
const selectedOwners = getOwners(selectedOwnerIds)
return (
<Combobox
multiple
items={RUN_OWNERS}
value={selectedOwners}
disabled={disabled}
itemToStringValue={(owner: RunOwner) => owner.name}
isItemEqualToValue={(item, value) => item.id === value.id}
onValueChange={(owners) => onValueChange(owners.map((owner) => owner.id))}
>
<ComboboxChips
ref={anchor}
className="h-auto! min-h-8! flex-1 flex-wrap! items-center gap-1.5 overflow-visible border-0 bg-transparent px-2 py-1! shadow-none ring-0 focus-within:ring-0 has-data-[slot=combobox-chip]:pl-1"
>
<ComboboxValue>
{(owners: RunOwner[]) => (
<Fragment>
{owners.map((owner) => (
<ComboboxChip
key={owner.id}
showRemove={true}
className={cn(
badgeVariants({ variant: "outline" }),
"min-w-0 gap-1.5"
)}
>
<Avatar className="size-4">
{owner.avatarSrc ? (
<AvatarImage src={owner.avatarSrc} alt={owner.name} />
) : null}
<AvatarFallback className="text-[8px]">
{owner.initials}
</AvatarFallback>
</Avatar>
{owner.name}
</ComboboxChip>
))}
<ComboboxChipsInput
disabled={disabled}
placeholder=""
className="min-w-20 flex-1 bg-transparent"
/>
</Fragment>
)}
</ComboboxValue>
</ComboboxChips>
<ComboboxContent
anchor={anchor}
className="max-w-(--anchor-width) min-w-(--anchor-width)"
>
<ComboboxEmpty>No owners found.</ComboboxEmpty>
<ComboboxList>
{(owner) => (
<ComboboxItem key={owner.id} value={owner}>
<Item size="xs" className="p-0">
<Avatar className="size-6">
{owner.avatarSrc ? (
<AvatarImage src={owner.avatarSrc} alt={owner.name} />
) : null}
<AvatarFallback className="text-[10px]">
{owner.initials}
</AvatarFallback>
</Avatar>
<ItemContent>
<ItemTitle className="whitespace-nowrap">
{owner.name}
</ItemTitle>
<ItemDescription>{owner.role}</ItemDescription>
</ItemContent>
</Item>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
)
}
function OwnerList({ owners }: { owners: RunOwner[] }) {
return (
<span className="flex min-h-6 min-w-0 flex-wrap items-center gap-1.5 overflow-visible">
{owners.map((owner) => (
<Badge key={owner.id} variant="outline" className="min-w-0 gap-1.5">
<Avatar className="size-3.5">
{owner.avatarSrc ? (
<AvatarImage src={owner.avatarSrc} alt={owner.name} />
) : null}
<AvatarFallback className="text-[8px]">
{owner.initials}
</AvatarFallback>
</Avatar>
<span className="max-w-32 truncate">{owner.name}</span>
</Badge>
))}
</span>
)
}
export function RunFacts() {
const [settings, setSettings] = useState<RunSettingsValue>(() =>
cloneRunSettings(DEFAULT_RUN_SETTINGS)
)
const [draft, setDraft] = useState<RunSettingsValue>(() =>
cloneRunSettings(DEFAULT_RUN_SETTINGS)
)
const [editingRows, setEditingRows] = useState<EditableRowId[]>([])
const [savingRows, setSavingRows] = useState<EditableRowId[]>([])
const saveTimersRef = useRef<number[]>([])
const owners = getOwners(settings.ownerIds)
const hasEditingRows = editingRows.length > 0
const isSaving = savingRows.length > 0
useEffect(() => {
return () => {
saveTimersRef.current.forEach((timer) => window.clearTimeout(timer))
}
}, [])
function beginRowEditing(rowId: EditableRowId) {
if (isSaving) {
return
}
setDraft((currentDraft) => ({
...currentDraft,
[rowId]: cloneRunSettingField(settings[rowId]),
}))
setEditingRows((currentRows) =>
currentRows.includes(rowId) ? currentRows : [...currentRows, rowId]
)
}
function cancelRowEditing(rowId: EditableRowId) {
if (isSaving) {
return
}
setDraft((currentDraft) => ({
...currentDraft,
[rowId]: cloneRunSettingField(settings[rowId]),
}))
setEditingRows((currentRows) =>
currentRows.filter((currentRow) => currentRow !== rowId)
)
}
function updateDraft<TKey extends keyof RunSettingsValue>(
key: TKey,
value: RunSettingsValue[TKey]
) {
setDraft((currentDraft) => ({
...currentDraft,
[key]: value,
}))
}
function saveRowEditing(rowId: EditableRowId) {
if (isSaving) {
return
}
const nextValue = cloneRunSettingField(draft[rowId])
setSavingRows([rowId])
const timer = window.setTimeout(() => {
setSettings((currentSettings) => ({
...currentSettings,
[rowId]: nextValue,
}))
setEditingRows((currentRows) =>
currentRows.filter((currentRow) => currentRow !== rowId)
)
setSavingRows([])
saveTimersRef.current = saveTimersRef.current.filter(
(currentTimer) => currentTimer !== timer
)
toast.success("Run setting saved", {
description: "RUN-4822 applies it from the next step on.",
icon: TOAST_SUCCESS_ICON,
})
}, SAVE_DELAY_MS)
saveTimersRef.current = [...saveTimersRef.current, timer]
}
function saveAllEditing() {
if (!hasEditingRows || isSaving) {
return
}
const rowIds = [...editingRows]
const nextSettings = cloneRunSettings(draft)
setSavingRows(rowIds)
const timer = window.setTimeout(() => {
setSettings(nextSettings)
setEditingRows([])
setSavingRows([])
saveTimersRef.current = saveTimersRef.current.filter(
(currentTimer) => currentTimer !== timer
)
toast.success("Run settings saved", {
description: "RUN-4822 applies them from the next step on.",
icon: TOAST_SUCCESS_ICON,
})
}, SAVE_DELAY_MS)
saveTimersRef.current = [...saveTimersRef.current, timer]
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
saveAllEditing()
}
function getEditableRowProps(rowId: EditableRowId) {
return {
editing: editingRows.includes(rowId),
actionsDisabled: isSaving,
saving: savingRows.includes(rowId),
onEdit: () => beginRowEditing(rowId),
onCancel: () => cancelRowEditing(rowId),
onSave: () => saveRowEditing(rowId),
}
}
return (
<TooltipProvider delay={180}>
<form id={FORM_ID} onSubmit={handleSubmit} className="w-full">
<Frame stacked spacing="sm" className="group w-full">
<FrameHeader>
<div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>Run Settings</FrameTitle>
<FrameDescription className="hidden truncate sm:block">
Applied to this run's retries and notifications.
</FrameDescription>
</div>
</FrameHeader>
<FramePanel className="p-0">
<EditableDetailRow
label="Status"
{...getEditableRowProps("status")}
display={<StatusBadge status={settings.status} />}
renderEdit={(active) => (
<SelectEditor
id="run-settings-status"
value={draft.status}
options={STATUS_OPTIONS}
disabled={!active}
renderValue={(value) => <StatusBadge status={value} />}
renderOption={(option) => (
<StatusBadge status={option.value} />
)}
onValueChange={(value) => updateDraft("status", value)}
/>
)}
/>
<Separator />
<EditableDetailRow
label="Priority"
{...getEditableRowProps("priority")}
display={<PriorityValue priority={settings.priority} />}
renderEdit={(active) => (
<SelectEditor
id="run-settings-priority"
value={draft.priority}
options={PRIORITY_OPTIONS}
disabled={!active}
renderValue={(value) => <PriorityValue priority={value} />}
renderOption={(option) => (
<PriorityValue priority={option.value} />
)}
onValueChange={(value) => updateDraft("priority", value)}
/>
)}
/>
<Separator />
<EditableDetailRow
label="Owners"
align="start"
hint="Notified on failures and approvals."
{...getEditableRowProps("ownerIds")}
display={<OwnerList owners={owners} />}
renderEdit={(active) => (
<OwnersCombobox
selectedOwnerIds={draft.ownerIds}
disabled={!active}
onValueChange={(ownerIds) =>
updateDraft("ownerIds", ownerIds)
}
/>
)}
/>
<Separator />
<EditableDetailRow
label="Retry Policy"
{...getEditableRowProps("retryPolicy")}
display={<RetryPolicyValue retryPolicy={settings.retryPolicy} />}
renderEdit={(active) => (
<SelectEditor
id="run-settings-retry-policy"
value={draft.retryPolicy}
options={RETRY_POLICY_OPTIONS}
disabled={!active}
renderValue={(value) => (
<RetryPolicyValue retryPolicy={value} />
)}
renderOption={(option) => (
<span className="flex min-w-0 flex-col">
<span className="truncate">{option.label}</span>
<span className="text-muted-foreground truncate text-xs">
{option.description}
</span>
</span>
)}
onValueChange={(value) => updateDraft("retryPolicy", value)}
/>
)}
/>
<Separator />
<EditableDetailRow
label="Max Retries"
hint="Step 3 has used 2 of 3 retries."
{...getEditableRowProps("maxRetries")}
display={
<DetailValue
icon={
<HashIcon className="size-4" aria-hidden="true" />
}
>
{settings.maxRetries} per step
</DetailValue>
}
renderEdit={(active) => (
<>
<InputGroupAddon>
<InputGroupText>
<HashIcon className="text-muted-foreground size-4" aria-hidden="true" />
</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id="run-settings-max-retries"
value={draft.maxRetries}
disabled={!active}
inputMode="numeric"
onChange={(event) =>
updateDraft("maxRetries", event.target.value)
}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault()
saveRowEditing("maxRetries")
}
if (event.key === "Escape") {
event.preventDefault()
cancelRowEditing("maxRetries")
}
}}
className="h-8 min-w-0 bg-transparent text-left text-sm font-medium focus-visible:ring-0!"
/>
</>
)}
/>
<Separator />
<EditableDetailRow
label="On Failure"
{...getEditableRowProps("notifyOnFailure")}
display={
<NotifyValue notifyOnFailure={settings.notifyOnFailure} />
}
renderEdit={(active) => (
<div className="flex h-8 min-w-0 flex-1 items-center justify-between gap-3 px-2.5">
<FieldLabel htmlFor="run-settings-notify">
<NotifyValue notifyOnFailure={draft.notifyOnFailure} />
</FieldLabel>
<Switch
id="run-settings-notify"
size="sm"
checked={draft.notifyOnFailure}
disabled={!active}
onCheckedChange={(checked) =>
updateDraft("notifyOnFailure", checked)
}
/>
</div>
)}
/>
<Separator />
<EditableDetailRow
label="Started"
display={
<DetailValue
icon={
<ClockIcon className="size-4" aria-hidden="true" />
}
>
{RUN_TIMESTAMPS.started}
</DetailValue>
}
/>
<Separator />
<EditableDetailRow
label="Last Activity"
display={
<DetailValue
icon={
<CircleCheckIcon className="size-4" aria-hidden="true" />
}
>
{RUN_TIMESTAMPS.lastActivity}
</DetailValue>
}
/>
</FramePanel>
</Frame>
</form>
</TooltipProvider>
)
}
@@ -1,167 +0,0 @@
import { Badge } from "@/components/reui/badge"
import { toast } from "sonner"
import {
Avatar,
AvatarFallback,
AvatarGroup,
AvatarImage,
} from "@evofw/ui/components/avatar"
import { Button } from "@evofw/ui/components/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@evofw/ui/components/dropdown-menu"
import { Separator } from "@evofw/ui/components/separator"
import {
RUN_IDENTITY,
RUN_OWNERS,
TOAST_INFO_ICON,
TOAST_SUCCESS_ICON,
} from "./data"
import { ArrowLeftIcon, AlertTriangleIcon, CircleCheckIcon, RefreshCwIcon, PauseIcon, MoreHorizontalIcon, DownloadIcon, CopyIcon } from "lucide-react"
// Content-level secondary header (navbar-5 run-control grammar, non-sticky):
// left is the queue context and run identity, right is the live action bar.
const WATCHING_OWNERS = RUN_OWNERS.filter((owner) =>
["owner-maya", "owner-noa"].includes(owner.id)
)
export function RunHeader() {
function handleApprove() {
toast.success("Run approved", {
description: `${RUN_IDENTITY.key} continues past the approval gate.`,
icon: TOAST_SUCCESS_ICON,
})
}
function handleRetryStep() {
toast.success("Step 3 requeued", {
description:
"Capture the partial refund replays with exponential backoff.",
icon: TOAST_SUCCESS_ICON,
})
}
function handlePause() {
toast.info("Run paused", {
description: `${RUN_IDENTITY.key} holds before step 4 until resumed.`,
icon: TOAST_INFO_ICON,
})
}
function handleExportTrace() {
toast.info("Trace exported", {
description: "The step and tool call trace is ready as JSON.",
icon: TOAST_INFO_ICON,
})
}
function handleCopyRunId() {
if (typeof navigator !== "undefined" && navigator.clipboard) {
void navigator.clipboard.writeText(RUN_IDENTITY.key)
}
toast.success("Run id copied", {
description: RUN_IDENTITY.key,
icon: TOAST_SUCCESS_ICON,
})
}
return (
<header className="flex flex-wrap items-center justify-between gap-3">
{/* Left: queue context + run identity */}
<div className="flex min-w-0 items-center gap-1.5">
<Button type="button" size="icon-sm" variant="ghost" aria-label="Back to the run queue">
<ArrowLeftIcon className="size-4" aria-hidden="true" />
</Button>
<div className="flex min-w-0 flex-col gap-0.5">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="text-muted-foreground text-sm">Run Queue /</span>
<h2 className="text-foreground text-lg leading-tight font-semibold tracking-tight">
{RUN_IDENTITY.key}
</h2>
<Badge variant="destructive-light">
<AlertTriangleIcon aria-hidden="true" />
Failed
</Badge>
</div>
<p className="text-muted-foreground min-w-0 truncate text-sm">
{RUN_IDENTITY.agent} · {RUN_IDENTITY.environment}
</p>
</div>
</div>
{/* Right: watching owners + run action bar */}
<div className="flex shrink-0 items-center gap-2">
<AvatarGroup aria-label="Owners watching this run" className="-space-x-1">
{WATCHING_OWNERS.map((owner) => (
<Avatar key={owner.id} className="ring-background size-6 ring-2">
{owner.avatarSrc ? (
<AvatarImage src={owner.avatarSrc} alt={owner.name} />
) : null}
<AvatarFallback className="text-[10px]">
{owner.initials}
</AvatarFallback>
</Avatar>
))}
</AvatarGroup>
<Separator orientation="vertical" className="my-auto h-4" />
<Button type="button" variant="outline" size="sm" onClick={handleApprove}>
<CircleCheckIcon className="size-4" aria-hidden="true" />
<span className="hidden md:block">Approve</span>
</Button>
<Button type="button" size="sm" onClick={handleRetryStep}>
<RefreshCwIcon className="size-4" aria-hidden="true" />
<span className="hidden md:block">Retry Step</span>
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Pause the run"
onClick={handlePause}
>
<PauseIcon className="size-4" aria-hidden="true" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="More run actions"
/>
}
>
<MoreHorizontalIcon className="size-4" aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8} className="w-48">
<DropdownMenuGroup>
<DropdownMenuItem onClick={handleExportTrace}>
<DownloadIcon className="opacity-60" aria-hidden="true" />
Export Trace
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCopyRunId}>
<CopyIcon className="opacity-60" aria-hidden="true" />
Copy Run Id
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
)
}
@@ -1,212 +0,0 @@
"use client"
import { Badge, type BadgeProps } from "@/components/reui/badge"
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import {
Timeline,
TimelineContent,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from "@/components/reui/timeline"
import { CheckIcon, ChevronRightIcon, CircleIcon, XIcon } from "lucide-react"
import { toast } from "sonner"
import { cn } from "@evofw/ui/lib/utils"
import {
Alert,
AlertAction,
AlertDescription,
AlertTitle,
} from "@/components/reui/alert"
import { Button } from "@evofw/ui/components/button"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@evofw/ui/components/collapsible"
import { Spinner } from "@evofw/ui/components/spinner"
import {
RUN_STEPS,
TOAST_SUCCESS_ICON,
type RunStep,
type StepStatus,
type ToolCall,
} from "./data"
import { AlertCircleIcon } from "lucide-react"
const toolCallVariant: Record<ToolCall["status"], BadgeProps["variant"]> = {
Succeeded: "success-light",
Failed: "destructive-light",
}
const FAILED_STEP = RUN_STEPS.find((step) => step.status === "failed")
const COMPLETED_STEPS = RUN_STEPS.filter(
(step) => step.status === "completed"
).length
function StatusIcon({ status }: { status: StepStatus }) {
if (status === "completed") {
return <CheckIcon className="size-3" />
}
if (status === "active") {
return <Spinner className="size-3" />
}
if (status === "failed") {
return <XIcon className="size-3" />
}
return <CircleIcon className="size-3" />
}
function ToolCallRow({ call }: { call: ToolCall }) {
return (
<div className="flex min-w-0 flex-wrap items-center justify-between gap-x-3 gap-y-1">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-foreground truncate font-mono text-xs font-medium">
{call.name}
</span>
{call.note ? (
<span className="text-muted-foreground truncate text-xs">
{call.note}
</span>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="text-muted-foreground text-xs tabular-nums">
{call.latency}
</span>
<Badge variant={toolCallVariant[call.status]}>{call.status}</Badge>
</div>
</div>
)
}
function StepBody({
step,
onRetryStep,
}: {
step: RunStep
onRetryStep: (step: RunStep) => void
}) {
return (
<div className="space-y-3">
<p className="text-muted-foreground text-xs leading-5">{step.summary}</p>
{step.toolCalls.length > 0 ? (
<div className="space-y-2.5 border-t pt-2.5">
{step.toolCalls.map((call) => (
<ToolCallRow key={call.id} call={call} />
))}
</div>
) : null}
{step.error ? (
<Alert variant="destructive">
<AlertCircleIcon aria-hidden="true" />
<AlertTitle>{step.error.title}</AlertTitle>
<AlertAction>
<Button type="button" size="xs" onClick={() => onRetryStep(step)}>
Retry Step
</Button>
</AlertAction>
<AlertDescription>{step.error.detail}</AlertDescription>
</Alert>
) : null}
</div>
)
}
export function RunTrace() {
function handleRetryStep(step: RunStep) {
toast.success(`Step ${step.id} requeued`, {
description: `${step.title} replays with exponential backoff, attempt 3 of 3.`,
icon: TOAST_SUCCESS_ICON,
})
}
return (
<Frame stacked spacing="sm" className="w-full">
<FrameHeader>
<div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>Step Trace</FrameTitle>
<FrameDescription className="hidden truncate sm:block">
Failed at step 3 of 6
</FrameDescription>
</div>
</FrameHeader>
<FramePanel>
<Timeline defaultValue={COMPLETED_STEPS}>
{RUN_STEPS.map((step) => (
<TimelineItem key={step.id} step={step.id}>
<TimelineHeader>
<TimelineSeparator className="bg-border group-data-[orientation=vertical]/timeline:h-[calc(100%-1.25rem-0.5rem)] group-data-[orientation=vertical]/timeline:translate-y-6" />
<div className="flex min-w-0 flex-wrap items-center gap-2">
<TimelineTitle className="text-sm font-semibold">
{step.title}
</TimelineTitle>
{step.duration ? (
<span className="text-muted-foreground text-xs tabular-nums">
{step.duration}
</span>
) : null}
</div>
<TimelineIndicator
className={cn(
"bg-muted text-muted-foreground group-data-completed/timeline-item:bg-primary group-data-completed/timeline-item:text-primary-foreground flex size-5 items-center justify-center border-none",
step.status === "failed" &&
"bg-destructive/10 text-destructive dark:bg-destructive/20"
)}
>
<StatusIcon status={step.status} />
</TimelineIndicator>
</TimelineHeader>
<TimelineContent className="mt-2">
<Frame stacked dense spacing="sm">
<Collapsible
defaultOpen={step.status === "failed"}
className="group/collapsible"
>
<CollapsibleTrigger
type="button"
className="flex w-full"
aria-label={`Toggle ${step.title} tool calls`}
>
<FrameHeader className="flex grow flex-row items-center justify-between gap-2">
<span className="text-muted-foreground min-w-0 truncate text-sm font-medium">
{step.toolCalls.length === 1
? "1 tool call"
: `${step.toolCalls.length} tool calls`}
</span>
<ChevronRightIcon
className="text-muted-foreground size-4 shrink-0 transition-transform duration-200 group-data-open/collapsible:rotate-90"
aria-hidden="true"
/>
</FrameHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<FramePanel className="space-y-3">
<StepBody step={step} onRetryStep={handleRetryStep} />
</FramePanel>
</CollapsibleContent>
</Collapsible>
</Frame>
</TimelineContent>
</TimelineItem>
))}
</Timeline>
</FramePanel>
</Frame>
)
}
@@ -1,15 +0,0 @@
import { RunDetail } from "./components/run-detail"
export function Page() {
return (
<main
className="bg-background mx-auto flex min-h-svh w-full max-w-[1320px] items-start justify-center p-3 md:p-4"
aria-labelledby="page-heading"
>
<h1 id="page-heading" className="sr-only">
Run Detail
</h1>
<RunDetail />
</main>
)
}
@@ -1,263 +0,0 @@
import { type ReactNode } from "react"
import { type BadgeProps } from "@/components/reui/badge"
import { CircleCheckIcon, AlertCircleIcon, InfoIcon, SmartphoneIcon, MailIcon, CalendarIcon, MessageSquareTextIcon, MonitorIcon } from "lucide-react"
// ── Toast feedback icons ─────────────────────────────────────────────────────
// Success toasts use a green check; the destructive toast uses the alert glyph
// with text-destructive; message toasts use a muted info icon.
export const TOAST_SUCCESS_ICON = (
<CircleCheckIcon className="size-[18px] text-green-600" aria-hidden="true" />
)
export const TOAST_ERROR_ICON = (
<AlertCircleIcon className="text-destructive size-[18px]" aria-hidden="true" />
)
export const TOAST_MESSAGE_ICON = (
<InfoIcon className="text-muted-foreground size-[18px]" aria-hidden="true" />
)
// Mid-market B2B SaaS sales world pack. Each rep keeps one portrait across the
// block so the owner avatar, task owner, and activity authors stay one identity
// (Emma Wilson is the deliberate initials-only fallback).
const PEOPLE = {
mira: {
id: "mira-stone",
name: "Mira Stone",
role: "Account Executive",
initials: "MS",
avatar:
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
},
leo: {
id: "leo-grant",
name: "Leo Grant",
role: "Account Executive",
initials: "LG",
avatar:
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
},
nora: {
id: "nora-vale",
name: "Nora Vale",
role: "Sales Manager",
initials: "NV",
avatar:
"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=96&h=96&dpr=2&q=80",
},
sana: {
id: "sana-qureshi",
name: "Sana Qureshi",
role: "Account Executive",
initials: "SQ",
avatar:
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=96&h=96&dpr=2&q=80",
},
emma: {
id: "emma-wilson",
name: "Emma Wilson",
role: "Sales Development Rep",
initials: "EW",
},
} as const
// ── Deal stage ───────────────────────────────────────────────────────────────
export type DealStage =
| "Qualified"
| "Discovery"
| "Proposal"
| "Negotiation"
| "Closed Won"
| "Closed Lost"
export const stageVariant: Record<DealStage, BadgeProps["variant"]> = {
Qualified: "secondary",
Discovery: "secondary",
Proposal: "info-light",
Negotiation: "warning-light",
"Closed Won": "success-light",
"Closed Lost": "destructive-light",
}
// Win probability per stage; the Select moves both the badge and the progress.
export const stageProbability: Record<DealStage, number> = {
Qualified: 20,
Discovery: 35,
Proposal: 55,
Negotiation: 75,
"Closed Won": 100,
"Closed Lost": 0,
}
export const STAGE_OPTIONS: { value: DealStage; label: DealStage }[] = [
{ value: "Qualified", label: "Qualified" },
{ value: "Discovery", label: "Discovery" },
{ value: "Proposal", label: "Proposal" },
{ value: "Negotiation", label: "Negotiation" },
{ value: "Closed Won", label: "Closed Won" },
{ value: "Closed Lost", label: "Closed Lost" },
]
// ── Key fields ───────────────────────────────────────────────────────────────
export type TextSegments = string | readonly string[]
export type DealField = {
label: string
value: TextSegments
}
// ── Deal team ────────────────────────────────────────────────────────────────
export type DealOwner = {
id: string
name: string
role: string
initials: string
avatar?: string
}
// ── Next step / task ─────────────────────────────────────────────────────────
export type TaskStatus = "Due today" | "Upcoming" | "Overdue"
export const taskStatusVariant: Record<TaskStatus, BadgeProps["variant"]> = {
"Due today": "warning-light",
Upcoming: "secondary",
Overdue: "destructive-light",
}
export type DealTask = {
id: string
name: string
type: string
due: TextSegments
status: TaskStatus
owner: DealOwner
}
// ── Activity log (timeline-1 grammar) ────────────────────────────────────────
export type ActivityKind = "call" | "email" | "meeting" | "note" | "demo"
export const activityKindIcon: Record<ActivityKind, ReactNode> = {
call: (
<SmartphoneIcon className="size-3.5" aria-hidden="true" />
),
email: (
<MailIcon className="size-3.5" aria-hidden="true" />
),
meeting: (
<CalendarIcon className="size-3.5" aria-hidden="true" />
),
note: (
<MessageSquareTextIcon className="size-3.5" aria-hidden="true" />
),
demo: (
<MonitorIcon className="size-3.5" aria-hidden="true" />
),
}
export const activityKindIndicatorClass: Record<ActivityKind, string> = {
call: "border-success/20 bg-success/10 text-success dark:bg-success/15",
email: "border-info/20 bg-info/10 text-info dark:bg-info/15",
meeting: "border-primary/20 bg-primary/10 text-primary dark:bg-primary/15",
note: "border-warning/20 bg-warning/10 text-warning dark:bg-warning/15",
demo: "border-info/20 bg-info/10 text-info dark:bg-info/15",
}
export type DealActivity = {
id: string
kind: ActivityKind
title: TextSegments
detail?: string
author: DealOwner
timeLabel: string
}
// ── The deal on the sheet ────────────────────────────────────────────────────
export type DealDetail = {
id: string
name: TextSegments
stage: DealStage
amount: string
delta: string
prior: string
closeLine: TextSegments
fields: DealField[]
financials: DealField[]
owners: DealOwner[]
ownerId: string
noteAuthor: DealOwner
note: string
nextStep: DealTask
}
export const DEAL: DealDetail = {
id: "DEAL-4821",
name: ["Brightwave Media", "Pro Annual"],
stage: "Negotiation",
amount: "$48,000",
delta: "+$6K",
prior: "vs $42K at proposal",
closeLine: ["Closes Jun 28", "41 days in pipeline"],
fields: [
{ label: "Account", value: "Brightwave Media" },
{ label: "Primary contact", value: ["Daniel Cho", "VP Marketing"] },
{ label: "Close date", value: "Jun 28, 2026" },
{ label: "Plan", value: ["Pro Annual", "45 seats"] },
{ label: "Source", value: "Inbound" },
{ label: "Deal ID", value: "DEAL-4821" },
],
financials: [
{ label: "Amount", value: "$48,000 ARR" },
{ label: "Forecast", value: ["Commit", "$36K weighted"] },
{ label: "Discount", value: "12% off list" },
{ label: "Term", value: ["12 months", "auto-renew"] },
],
owners: [PEOPLE.mira, PEOPLE.nora, PEOPLE.emma],
ownerId: PEOPLE.mira.id,
noteAuthor: PEOPLE.mira,
note: "Daniel confirmed budget for 45 seats. Legal reviewing the MSA, redlines back by Jun 24. Security questionnaire cleared on Jun 16.",
nextStep: {
id: "TASK-318",
name: "Send revised order form",
type: "Email",
due: ["Due today", "4:00 PM"],
status: "Due today",
owner: PEOPLE.mira,
},
}
export const DEAL_ACTIVITY: DealActivity[] = [
{
id: "act-1",
kind: "call",
title: ["Pricing call", "Connected"],
detail:
"Daniel Cho agreed to 45 seats at 12% off. Asked for revised order form by end of week.",
author: PEOPLE.mira,
timeLabel: "2h ago",
},
{
id: "act-2",
kind: "email",
title: "Security questionnaire returned",
detail: "SOC 2 report and DPA sent to Brightwave legal. No open items.",
author: PEOPLE.nora,
timeLabel: "Yesterday",
},
{
id: "act-3",
kind: "demo",
title: ["Workflow demo", "6 attendees"],
detail:
"Walked the Brightwave team through automations and reporting. Strong interest from ops.",
author: PEOPLE.mira,
timeLabel: "Jun 11",
},
{
id: "act-4",
kind: "meeting",
title: "Discovery with VP Marketing",
detail: "Scoped 45 seats across two teams. Budget cycle closes end of Q2.",
author: PEOPLE.emma,
timeLabel: "Jun 4",
},
]
@@ -1,617 +0,0 @@
import { useState, type ReactNode } from "react"
import { Badge } from "@/components/reui/badge"
import {
Timeline,
TimelineContent,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from "@/components/reui/timeline"
import { toast } from "sonner"
import { cn } from "@evofw/ui/lib/utils"
import {
Avatar,
AvatarFallback,
AvatarGroup,
AvatarImage,
} from "@evofw/ui/components/avatar"
import { Button } from "@evofw/ui/components/button"
import { Progress } from "@evofw/ui/components/progress"
import { ScrollArea } from "@evofw/ui/components/scroll-area"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evofw/ui/components/select"
import { Separator } from "@evofw/ui/components/separator"
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@evofw/ui/components/sheet"
import {
activityKindIcon,
activityKindIndicatorClass,
DEAL,
DEAL_ACTIVITY,
STAGE_OPTIONS,
stageProbability,
stageVariant,
taskStatusVariant,
TOAST_ERROR_ICON,
TOAST_MESSAGE_ICON,
TOAST_SUCCESS_ICON,
type DealActivity,
type DealField,
type DealOwner,
type DealStage,
type DealTask,
type TextSegments,
} from "./data"
import { ListChecksIcon, PanelRightIcon, CircleDollarSignIcon, XIcon, CheckIcon, PlusIcon } from "lucide-react"
// Section wrapper: the px-5 rhythm and Separator between blocks are lifted from
// sheet-7's notification list so the drawer keeps one vertical spine.
function Section({
label,
action,
children,
}: {
label?: string
action?: ReactNode
children: ReactNode
}) {
return (
<div className="flex flex-col gap-3 px-5 py-4">
{label ? (
<div className="flex items-center justify-between gap-2">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{label}
</h3>
{action}
</div>
) : null}
{children}
</div>
)
}
function DotSeparator({ className }: { className?: string }) {
return (
<span
className={cn(
"bg-muted-foreground/40 size-1 shrink-0 rounded-full",
className
)}
aria-hidden="true"
/>
)
}
function SegmentedText({
value,
className,
}: {
value: TextSegments
className?: string
}) {
const segments = Array.isArray(value) ? value : [value]
if (segments.length === 1) {
return <span className={className}>{segments[0]}</span>
}
return (
<span
className={cn(
"inline-flex min-w-0 flex-wrap items-center gap-x-1.5",
className
)}
>
{segments.map((segment, index) => (
<span
key={`${segment}-${index}`}
className="inline-flex min-w-0 items-center gap-x-1.5"
>
{index > 0 ? <DotSeparator /> : null}
<span className="min-w-0 truncate">{segment}</span>
</span>
))}
</span>
)
}
function formatText(value: TextSegments) {
return Array.isArray(value) ? value.join(", ") : value
}
// MiniProgress: the hatched track + Progress indicator copied verbatim from
// sheet-7; the win-probability bar is half of the block's signature.
function MiniProgress({ value }: { value: number }) {
const indicatorColor =
value >= 80
? "**:data-[slot=progress-indicator]:bg-success"
: value >= 50
? "**:data-[slot=progress-indicator]:bg-primary"
: "**:data-[slot=progress-indicator]:bg-warning"
return (
<div className="bg-muted/55 relative mt-1.5 h-1 overflow-hidden rounded-full">
<div
className="text-muted-foreground pointer-events-none absolute inset-0 bg-[repeating-linear-gradient(-45deg,currentColor_0,currentColor_1px,transparent_0,transparent_4px)] opacity-20"
aria-hidden="true"
/>
<Progress
value={value}
className={cn(
"absolute inset-0 gap-0",
"**:data-[slot=progress-track]:h-full **:data-[slot=progress-track]:rounded-none **:data-[slot=progress-track]:bg-transparent",
"**:data-[slot=progress-indicator]:rounded-none",
indicatorColor
)}
/>
</div>
)
}
// FactRow: key fields as a label/value dl, the grammar from solution-agents-7's
// run-detail sheet.
function FactRow({ fact }: { fact: DealField }) {
return (
<div className="contents">
<dt className="text-muted-foreground text-sm">{fact.label}</dt>
<dd className="text-foreground min-w-0 text-sm">
<SegmentedText value={fact.value} />
</dd>
</div>
)
}
// Owner avatars: sheet-7's AvatarGroup grammar; Emma Wilson resolves to initials
// as the deliberate fallback. The deal-team avatars are the other half of the
// signature.
function OwnerAvatars({
owners,
ownerId,
}: {
owners: DealOwner[]
ownerId: string
}) {
const owner = owners.find((person) => person.id === ownerId)
return (
<div className="flex items-center justify-between gap-3">
<AvatarGroup className="-space-x-1">
{owners.map((person) => (
<Avatar key={person.id} className="size-6">
{person.avatar ? (
<AvatarImage src={person.avatar} alt={person.name} />
) : null}
<AvatarFallback className="text-[10px]">
{person.initials}
</AvatarFallback>
</Avatar>
))}
</AvatarGroup>
{owner ? (
<p className="text-muted-foreground inline-flex min-w-0 flex-wrap items-center justify-end gap-x-1.5 text-xs">
<span className="text-foreground font-medium">{owner.name}</span>
<DotSeparator />
<span className="truncate">owns</span>
</p>
) : null}
</div>
)
}
// Next-step row: sheet-7's notification-item grammar (colored icon cell +
// content column with a title/badge row and a muted meta line), reskinned to the
// deal's one open task. No hand-rolled card or icon box.
function NextStepRow({ task }: { task: DealTask }) {
return (
<div className="flex items-start gap-3">
<div className="text-warning relative grid size-5 shrink-0 place-items-center">
<span className="grid size-5 place-items-center leading-none [&_svg]:block [&_svg]:size-4">
<ListChecksIcon aria-hidden="true" />
</span>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-start justify-between gap-2">
<p className="text-foreground min-w-0 text-sm leading-5 font-medium">
{task.name}
</p>
<Badge variant={taskStatusVariant[task.status]}>{task.status}</Badge>
</div>
<p className="text-muted-foreground inline-flex flex-wrap items-center gap-x-1.5 text-xs">
<span>{task.type}</span>
<DotSeparator />
<SegmentedText value={task.due} />
</p>
<div className="text-muted-foreground mt-0.5 flex items-center gap-1.5 text-xs">
<Avatar className="size-5">
{task.owner.avatar ? (
<AvatarImage src={task.owner.avatar} alt={task.owner.name} />
) : null}
<AvatarFallback className="text-[9px]">
{task.owner.initials}
</AvatarFallback>
</Avatar>
<span>{task.owner.name}</span>
</div>
</div>
</div>
)
}
// ActivityRow: timeline-1's TimelineItem / Header / Indicator grammar (via the
// agents-7 ladder) reskinned to one logged activity.
function ActivityRow({
activity,
step,
}: {
activity: DealActivity
step: number
}) {
return (
<TimelineItem
step={step}
className="has-[+[data-completed]]:[&_[data-slot=timeline-separator]]:bg-border group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=vertical]/timeline:not-last:pb-6"
>
<TimelineHeader className="flex min-w-0 items-start justify-between gap-2.5">
<TimelineSeparator className="bg-border group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.25rem-1rem)] group-data-[orientation=vertical]/timeline:translate-y-7" />
<TimelineIndicator
className={cn(
"group-data-completed/timeline-item:border-border flex size-5 items-center justify-center rounded-full border group-data-[orientation=vertical]/timeline:-left-6 [&_svg]:size-3",
activityKindIndicatorClass[activity.kind]
)}
>
{activityKindIcon[activity.kind]}
</TimelineIndicator>
<TimelineTitle className="min-w-0 text-sm leading-5">
<SegmentedText
value={activity.title}
className="text-foreground font-medium"
/>
</TimelineTitle>
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
{activity.timeLabel}
</span>
</TimelineHeader>
<TimelineContent className="flex min-w-0 flex-col items-start gap-2 pb-1">
{activity.detail ? (
<p className="text-muted-foreground max-w-[52ch] text-sm leading-5">
{activity.detail}
</p>
) : null}
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
<Avatar className="size-5">
{activity.author.avatar ? (
<AvatarImage
src={activity.author.avatar}
alt={activity.author.name}
/>
) : null}
<AvatarFallback className="text-[9px]">
{activity.author.initials}
</AvatarFallback>
</Avatar>
<span>{activity.author.name}</span>
</div>
</TimelineContent>
</TimelineItem>
)
}
export function DealDetailSheet() {
const [open, setOpen] = useState(true)
// savedStage is the committed baseline; stage is the in-flight selection.
const [savedStage, setSavedStage] = useState<DealStage>(DEAL.stage)
const [stage, setStage] = useState<DealStage>(DEAL.stage)
const probability = stageProbability[stage]
const dirty = stage !== savedStage
function handleStageChange(value: string | null) {
if (value !== null) {
setStage(value as DealStage)
}
}
function handleSave() {
setSavedStage(stage)
toast.success("Deal updated", {
description: `${formatText(DEAL.name)} moved to ${stage}, ${stageProbability[stage]}% win probability.`,
icon: TOAST_SUCCESS_ICON,
})
}
function handleLogActivity() {
toast.message("Activity logged", {
description:
"Call with Daniel Cho saved to the Brightwave Media timeline.",
icon: TOAST_MESSAGE_ICON,
})
}
function handleAddNote() {
toast.message("Note added", {
description: "Saved to DEAL-4821. Visible to the deal team on next sync.",
icon: TOAST_MESSAGE_ICON,
})
}
function handleWon() {
setStage("Closed Won")
setSavedStage("Closed Won")
toast.success("Deal marked Won", {
description: `${formatText(DEAL.name)} booked at ${DEAL.amount} ARR. Closes Jun 28.`,
icon: TOAST_SUCCESS_ICON,
})
}
function handleLost() {
setStage("Closed Lost")
setSavedStage("Closed Lost")
toast.error("Deal marked Lost", {
description:
"Brightwave Media moved to Closed Lost. Add a reason on next sync.",
icon: TOAST_ERROR_ICON,
})
}
return (
<Sheet open={open} onOpenChange={setOpen}>
{/* Actions */}
<div className="flex min-h-[360px] items-center justify-center">
<SheetTrigger
render={
<Button
type="button"
variant="outline"
size="lg"
onClick={() => setOpen(true)}
>
<PanelRightIcon data-icon="inline-start" aria-hidden="true" />
Open Deal
</Button>
}
/>
</div>
{/* Content */}
<SheetContent
side="right"
showCloseButton={false}
initialFocus={false}
className="bg-popover inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(30rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none"
>
{/* Header */}
<SheetHeader className="shrink-0 gap-0 border-b p-0">
<div className="flex min-h-9 items-center justify-between gap-2 px-4">
<span className="text-muted-foreground inline-flex items-center gap-2 text-xs">
<CircleDollarSignIcon className="size-3.5" aria-hidden="true" />
{DEAL.id}
</span>
<SheetClose
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Close deal detail"
className="shrink-0"
>
<XIcon aria-hidden="true" />
</Button>
}
/>
</div>
<div className="flex min-w-0 flex-col gap-1.5 px-4 pt-0 pb-3">
<div className="flex min-w-0 items-center gap-2">
<SheetTitle className="min-w-0 flex-1 truncate text-lg font-semibold tracking-tight">
<SegmentedText value={DEAL.name} className="flex-nowrap" />
</SheetTitle>
<Badge variant={stageVariant[stage]}>{stage}</Badge>
</div>
<SheetDescription className="text-muted-foreground text-xs">
<SegmentedText value={DEAL.closeLine} />
</SheetDescription>
<div className="flex items-center gap-2 pt-0.5">
<Button type="button" size="sm" onClick={handleWon}>
<CheckIcon data-icon="inline-start" aria-hidden="true" />
Mark Won
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={handleLost}
>
Mark Lost
</Button>
</div>
</div>
</SheetHeader>
{/* Body */}
<div className="min-h-0 flex-1">
<ScrollArea className="h-full min-h-0">
{/* Hero: amount, delta, and the win-probability progress */}
<Section>
<div className="flex items-end justify-between gap-3">
<div className="flex items-baseline gap-2">
<span className="text-foreground text-4xl font-semibold tracking-tight tabular-nums">
{DEAL.amount}
</span>
<Badge variant="success-light">{DEAL.delta}</Badge>
</div>
<span className="text-muted-foreground pb-1 text-xs">
{DEAL.prior}
</span>
</div>
<MiniProgress value={probability} />
<div className="text-muted-foreground flex items-center justify-between gap-2 text-xs">
<span>Win probability</span>
<span className="tabular-nums">{probability}%</span>
</div>
</Section>
<Separator className="opacity-60" />
{/* Inline stage change */}
<Section label="Stage">
<div className="flex items-center gap-2">
<Select
value={stage}
onValueChange={handleStageChange}
items={STAGE_OPTIONS}
>
<SelectTrigger
id="deal-stage"
aria-label="Change deal stage"
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{STAGE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{dirty ? (
<Button type="button" size="sm" onClick={handleSave}>
Save
</Button>
) : null}
</div>
{dirty ? (
<p className="text-warning inline-flex items-center gap-1.5 text-xs">
<span
className="bg-warning size-1.5 shrink-0 rounded-full"
aria-hidden="true"
/>
<span>Unsaved stage change</span>
<DotSeparator className="bg-warning/70" />
<span>win probability now {probability}%</span>
</p>
) : null}
</Section>
<Separator className="opacity-60" />
{/* Key fields */}
<Section label="Key Fields">
<dl className="grid grid-cols-[7.5rem_1fr] gap-x-4 gap-y-2.5">
{DEAL.fields.map((field) => (
<FactRow key={field.label} fact={field} />
))}
</dl>
</Section>
<Separator className="opacity-60" />
{/* Deal team */}
<Section label="Deal Team">
<OwnerAvatars owners={DEAL.owners} ownerId={DEAL.ownerId} />
</Section>
<Separator className="opacity-60" />
{/* Next step */}
<Section
label="Next Step"
action={
<Button
type="button"
variant="ghost"
size="xs"
onClick={handleLogActivity}
>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Log activity
</Button>
}
>
<NextStepRow task={DEAL.nextStep} />
</Section>
<Separator className="opacity-60" />
{/* Financials */}
<Section label="Financials">
<div className="grid grid-cols-[7.5rem_1fr] gap-x-4 gap-y-2.5">
{DEAL.financials.map((field) => (
<FactRow key={field.label} fact={field} />
))}
</div>
</Section>
<Separator className="opacity-60" />
{/* Note */}
<Section
label="Note"
action={
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleAddNote}
>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Add note
</Button>
}
>
<div className="flex flex-col gap-2">
<p className="text-foreground text-sm leading-5">{DEAL.note}</p>
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
<Avatar className="size-5">
{DEAL.noteAuthor.avatar ? (
<AvatarImage
src={DEAL.noteAuthor.avatar}
alt={DEAL.noteAuthor.name}
/>
) : null}
<AvatarFallback className="text-[9px]">
{DEAL.noteAuthor.initials}
</AvatarFallback>
</Avatar>
<span>{DEAL.noteAuthor.name}</span>
</div>
</div>
</Section>
<Separator className="opacity-60" />
{/* Activity log */}
<Section label="Activity">
<Timeline defaultValue={0}>
{DEAL_ACTIVITY.map((item, index) => (
<ActivityRow key={item.id} activity={item} step={index + 1} />
))}
</Timeline>
</Section>
</ScrollArea>
</div>
</SheetContent>
</Sheet>
)
}
@@ -1,15 +0,0 @@
import { DealDetailSheet } from "./components/deal-detail-sheet"
export function Page() {
return (
<main
className="flex min-h-svh w-full items-center justify-center p-6 sm:p-10 md:p-12"
aria-labelledby="page-heading"
>
<h1 id="page-heading" className="sr-only">
Deal detail
</h1>
<DealDetailSheet />
</main>
)
}
@@ -1,75 +0,0 @@
export const SHIPMENT = {
id: "SHP-3827462",
status: "Shipped" as const,
placedDate: "2022-01-01",
orderId: "SO-AMS-4620",
carrier: { name: "DHL Global" },
route: {
from: "1234 Industrial Way, Dallas, TX 75201",
to: "8458 Sunset Blvd #209, Los Angeles, CA 90069",
},
}
// ─────────────────────────────────────────────────────────────────────────
// Status stepper
// ─────────────────────────────────────────────────────────────────────────
export type StepState = "done" | "active" | "pending"
export interface ShippingStep {
label: string
state: StepState
}
export const SHIPPING_STEPS: ShippingStep[] = [
{ label: "Picking", state: "done" },
{ label: "Packed", state: "done" },
{ label: "Shipping", state: "active" },
{ label: "Delivered", state: "pending" },
]
// ─────────────────────────────────────────────────────────────────────────
// Shipping data summary
// ─────────────────────────────────────────────────────────────────────────
export const SHIPPING_DATA = {
totalTime: "19 days, 7 hours",
depTime: "01 Aug, 2025 09:17",
expArrival: "17 Apr, 2025 12:00",
trackingNo: "1Z999AA10123456784",
}
// ─────────────────────────────────────────────────────────────────────────
// Shipping log timeline
// ─────────────────────────────────────────────────────────────────────────
export interface LogEntry {
event: string
datetime: string
description: string
location?: string
}
export const SHIPPING_LOG: LogEntry[] = [
{
event: "Order Placed",
datetime: "28 Jul, 2025 10:02",
description: "Shipment information received by seller",
location: "Silicon Valley, CA",
},
{
event: "Picking",
datetime: "28 Jul, 2025 11:02",
description: "Items being picked from inventory",
},
{
event: "Packed",
datetime: "28 Jul, 2025 12:27",
description: "Shipment information received by seller",
},
{
event: "Shipped",
datetime: "28 Jul, 2025 14:27",
description: "Package handed off to carrier",
},
]
@@ -1,428 +0,0 @@
"use client"
import { useState } from "react"
import { Badge } from "@/components/reui/badge"
import {
Frame,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import {
Timeline,
TimelineContent,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
} from "@/components/reui/timeline"
import { cn } from "@evofw/ui/lib/utils"
import { Button } from "@evofw/ui/components/button"
import { ScrollArea } from "@evofw/ui/components/scroll-area"
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@evofw/ui/components/sheet"
import {
SHIPMENT,
SHIPPING_DATA,
SHIPPING_LOG,
SHIPPING_STEPS,
type ShippingStep,
} from "./data"
import { CheckIcon, MapPinIcon, XIcon } from "lucide-react"
const ECOMMERCE_LINK_CLASS_NAME =
"underline-offset-4 transition-colors hover:text-primary hover:underline"
const FRAME_PANEL_RESET = "shadow-none!"
// ─────────────────────────────────────────────────────────────────────────
// Carrier chip - mini DHL logo (yellow plate + red italic "DHL" + 3 red bars)
// ─────────────────────────────────────────────────────────────────────────
function DhlLogo(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 40 12"
role="img"
aria-label="DHL"
shapeRendering="geometricPrecision"
{...props}
>
<rect width="40" height="12" rx="1.5" fill="#FFCC00" />
<text
x="3"
y="9.4"
fontFamily="Arial Black, Arial, Helvetica, sans-serif"
fontSize="8"
fontWeight="900"
fontStyle="italic"
letterSpacing="-0.2"
fill="#D40511"
>
DHL
</text>
<g fill="#D40511">
<polygon points="24,3.5 33,3.5 32,4.7 23,4.7" />
<polygon points="25,5.4 34,5.4 33,6.6 24,6.6" />
<polygon points="26,7.3 35,7.3 34,8.5 25,8.5" />
</g>
</svg>
)
}
function CarrierChip({ name }: { name: string }) {
return (
<div className="border-border bg-background inline-flex items-center gap-2 rounded-md border px-2.5 py-1">
<DhlLogo className="h-3 w-auto" />
<span className="text-foreground text-sm font-medium">{name}</span>
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────
// Route + status stepper
// ─────────────────────────────────────────────────────────────────────────
// Shared timeline classes - both Route and Shipping Log use the same pattern.
const TL_ITEM_BASE =
"group-data-[orientation=vertical]/timeline:ms-5"
const TL_SEP_CLASS =
"bg-input group-data-[orientation=vertical]/timeline:-left-3 group-data-[orientation=vertical]/timeline:-translate-x-1/2 group-data-[orientation=vertical]/timeline:h-[calc(100%-1rem)] group-data-[orientation=vertical]/timeline:translate-y-4.5"
const TL_IND_CLASS =
"bg-input size-2 border-0 group-data-[orientation=vertical]/timeline:-left-3 group-data-[orientation=vertical]/timeline:-translate-x-1/2 group-data-[orientation=vertical]/timeline:top-1.5"
function RouteAndStepper() {
const stops = [SHIPMENT.route.from, SHIPMENT.route.to]
return (
<Frame dense spacing="sm" className="w-full">
<FramePanel className={cn("flex flex-col gap-4", FRAME_PANEL_RESET)}>
{/* Route stops */}
<div className="flex items-start justify-between gap-4">
<Timeline defaultValue={0} className="min-w-0 flex-1 gap-0">
{stops.map((address, index) => {
const isLast = index === stops.length - 1
return (
<TimelineItem
key={address}
step={index + 1}
className={cn(
TL_ITEM_BASE,
isLast
? "group-data-[orientation=vertical]/timeline:pb-0"
: "group-data-[orientation=vertical]/timeline:not-last:pb-2"
)}
>
{!isLast ? (
<TimelineSeparator className={TL_SEP_CLASS} />
) : null}
<TimelineIndicator className={TL_IND_CLASS} />
<TimelineContent className="text-foreground text-sm leading-snug">
{address}
</TimelineContent>
</TimelineItem>
)
})}
</Timeline>
<CarrierChip name={SHIPMENT.carrier.name} />
</div>
{/* Stepper */}
<Stepper steps={SHIPPING_STEPS} />
</FramePanel>
</Frame>
)
}
function Stepper({ steps }: { steps: ShippingStep[] }) {
return (
<div className="flex flex-col gap-2">
<div className="grid grid-cols-4 gap-2">
{steps.map((step, i) => (
<span
key={i}
className={cn(
"h-1.5 rounded-full",
step.state === "pending" ? "bg-muted" : "bg-emerald-500"
)}
aria-hidden="true"
/>
))}
</div>
<div className="grid grid-cols-4 gap-2">
{steps.map((step) => (
<div
key={step.label}
className="flex items-center gap-1.5 text-xs"
>
<StepIcon state={step.state} />
<span
className={cn(
step.state === "pending"
? "text-muted-foreground"
: "text-foreground font-medium"
)}
>
{step.label}
</span>
</div>
))}
</div>
</div>
)
}
function StepIcon({ state }: { state: ShippingStep["state"] }) {
if (state === "done") {
return (
<span
className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-emerald-500 text-white"
aria-hidden="true"
>
<CheckIcon className="size-2.5" />
</span>
)
}
if (state === "active") {
return (
<span
className="inline-flex size-4 shrink-0 items-center justify-center rounded-full border-2 border-emerald-500 text-emerald-500"
aria-hidden="true"
>
<CheckIcon className="size-2.5" />
</span>
)
}
return (
<span
className="border-muted-foreground/40 inline-flex size-4 shrink-0 rounded-full border-2"
aria-hidden="true"
/>
)
}
// ─────────────────────────────────────────────────────────────────────────
// Shipping data summary
// ─────────────────────────────────────────────────────────────────────────
function ShippingDataSection() {
const stats = [
{ label: "Total Time", value: SHIPPING_DATA.totalTime },
{ label: "Dep. Time", value: SHIPPING_DATA.depTime },
{ label: "Exp. Arrival", value: SHIPPING_DATA.expArrival },
{
label: "Tracking No.",
value: (
<span className="text-foreground font-mono text-sm">
{SHIPPING_DATA.trackingNo}
</span>
),
},
]
return (
<Frame dense spacing="sm" className="w-full">
<FrameHeader className="flex-row items-center justify-between gap-3">
<FrameTitle className="text-sm">Shipping Data</FrameTitle>
</FrameHeader>
<FramePanel className={FRAME_PANEL_RESET}>
<div className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-4">
{stats.map((stat) => (
<div key={stat.label} className="flex min-w-0 flex-col gap-1">
<span className="text-muted-foreground text-xs">
{stat.label}
</span>
<div className="text-foreground min-w-0 text-sm font-medium">
{stat.value}
</div>
</div>
))}
</div>
</FramePanel>
</Frame>
)
}
// ─────────────────────────────────────────────────────────────────────────
// Shipping log timeline
// ─────────────────────────────────────────────────────────────────────────
function ShippingLogSection() {
return (
<Frame dense spacing="sm" className="w-full">
<FrameHeader className="flex-row items-center justify-between gap-3">
<FrameTitle className="text-sm">Shipping Log</FrameTitle>
</FrameHeader>
<FramePanel className={FRAME_PANEL_RESET}>
<Timeline defaultValue={0} className="gap-0">
{SHIPPING_LOG.map((entry, index) => {
const isLast = index === SHIPPING_LOG.length - 1
return (
<TimelineItem
key={`${entry.event}-${entry.datetime}`}
step={index + 1}
className={cn(
TL_ITEM_BASE,
isLast
? "group-data-[orientation=vertical]/timeline:pb-0"
: "group-data-[orientation=vertical]/timeline:not-last:pb-4"
)}
>
{!isLast ? (
<TimelineSeparator className={TL_SEP_CLASS} />
) : null}
<TimelineIndicator className={TL_IND_CLASS} />
<TimelineContent className="text-foreground">
<div className="min-w-0 space-y-0.5">
<div className="flex flex-wrap items-baseline gap-x-2">
<span className="text-foreground text-sm font-medium leading-snug">
{entry.event}
</span>
<time
dateTime={entry.datetime}
className="text-muted-foreground text-xs tabular-nums"
>
{entry.datetime}
</time>
</div>
<p className="text-muted-foreground text-xs leading-snug">
{entry.description}
</p>
{entry.location ? (
<p className="text-muted-foreground inline-flex items-center gap-1 text-xs">
<MapPinIcon className="size-3" aria-hidden="true" />
{entry.location}
</p>
) : null}
</div>
</TimelineContent>
</TimelineItem>
)
})}
</Timeline>
</FramePanel>
</Frame>
)
}
// ─────────────────────────────────────────────────────────────────────────
// Sheet shell
// ─────────────────────────────────────────────────────────────────────────
export function TrackShippingSheet() {
const [open, setOpen] = useState(true)
return (
<Sheet open={open} onOpenChange={setOpen}>
<div className="flex min-h-[360px] items-center justify-center">
<SheetTrigger
render={
<Button type="button" variant="outline" size="lg">
Open Track Shipping
</Button>
}
/>
</div>
<SheetContent
side="right"
showCloseButton={false}
initialFocus={false}
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(48rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none"
>
{/* Header */}
<SheetHeader className="shrink-0 gap-0 border-b p-0">
<div className="flex min-h-11 items-center justify-between gap-2 border-b px-4">
<SheetTitle className="truncate text-sm font-medium leading-5">
Track Shipping
</SheetTitle>
<SheetClose
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Close sheet"
className="shrink-0"
>
<XIcon aria-hidden="true" />
</Button>
}
/>
</div>
<SheetDescription className="sr-only">
Shipment {SHIPMENT.id} live tracking and shipping log.
</SheetDescription>
<div className="flex flex-col gap-2 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-foreground text-xl font-semibold tracking-tight">
{SHIPMENT.id}
</h2>
<Badge variant="success-light">{SHIPMENT.status}</Badge>
</div>
<p className="text-muted-foreground inline-flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs">
<span>Placed</span>
<span className="text-foreground font-medium">
{SHIPMENT.placedDate}
</span>
<span
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span>Order ID</span>
<a
href={`#order-${SHIPMENT.orderId.toLowerCase()}`}
className={cn(
"text-foreground font-medium",
ECOMMERCE_LINK_CLASS_NAME
)}
>
{SHIPMENT.orderId}
</a>
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
nativeButton={false}
variant="ghost"
size="sm"
render={<a href="#cancel-order">Cancel Order</a>}
/>
<Button type="button" variant="outline" size="sm">
Notify Customer
</Button>
</div>
</div>
</SheetHeader>
{/* Body - single column with one ScrollArea (works for both mobile and desktop) */}
<div className="min-h-0 flex-1">
<ScrollArea className="h-full">
<div className="flex flex-col gap-3 px-4 py-4">
<RouteAndStepper />
<ShippingDataSection />
<ShippingLogSection />
</div>
</ScrollArea>
</div>
{/* Footer - full-width Close button */}
<SheetFooter className="bg-muted/40 shrink-0 border-t px-4 py-3">
<SheetClose
render={
<Button type="button" variant="outline" className="w-full">
Close
</Button>
}
/>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
@@ -1,9 +0,0 @@
import { TrackShippingSheet } from "./components/track-shipping-sheet"
export function Page() {
return (
<main className="flex min-h-svh w-full items-center justify-center p-6 sm:p-10 md:p-12">
<TrackShippingSheet />
</main>
)
}
@@ -1,12 +1,17 @@
import type { ReactNode } from 'react'
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
import { PaletteIcon, SettingsIcon } from 'lucide-react'
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
@@ -14,31 +19,17 @@ export interface SettingsTabConfig {
icon?: ReactNode
}
const DEFAULT_TABS: SettingsTabConfig[] = [
{
id: 'appearance',
to: '/settings/appearance',
label: 'Внешний вид',
icon: <PaletteIcon className="size-4" aria-hidden="true" />,
},
{
id: 'integrations',
to: '/settings/integrations',
label: 'Интеграции',
icon: <SettingsIcon className="size-4" aria-hidden="true" />,
},
]
interface SettingsShellProps {
title?: string
description?: string
tabs?: SettingsTabConfig[]
/** Required — no phantom default routes. */
tabs: SettingsTabConfig[]
}
export function SettingsShell({
title = 'Настройки',
description = 'Внешний вид и интеграции',
tabs = DEFAULT_TABS,
description = 'Конфигурация control plane',
tabs,
}: SettingsShellProps) {
const isMobile = useIsMobile()
const pathname = useRouterState({ select: (s) => s.location.pathname })
+2
View File
@@ -0,0 +1,2 @@
/** Project surface lock — ops screens use ReUI Frame only. */
export const UI_SURFACE = 'frame' as const
+45 -109
View File
@@ -1,24 +1,21 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useCallback, useMemo, useState } from 'react'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import {
Check,
CheckCircle2,
CircleAlertIcon,
FilterIcon,
Inbox,
LayoutGridIcon,
ListIcon,
Plus,
SearchIcon,
ShieldIcon,
TableIcon,
UserPlus,
WifiOff,
} from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import { Filters } from '@/components/reui/filters'
import {
applyFiltersToData,
KpiStatGrid,
@@ -30,7 +27,6 @@ import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import {
@@ -38,26 +34,21 @@ import {
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { AddAgentSheet } from '@/components/agents/add-agent-sheet'
import { AgentCardsGrid } from '@/components/agents/agent-cards-grid'
import { AgentDetailSheet } from '@/components/agents/agent-detail-sheet'
import { AgentFleetDataGrid } from '@/components/agents/agent-fleet-data-grid'
import { AgentsFleetChrome } from '@/components/agents/agents-fleet-chrome'
import {
computeFleetCounts,
fleetKpiCards,
} from '@/components/agents/agents-fleet-kpis'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { agentsQueryOptions } from '@/queries'
import { QueryState } from '@/components/query-state'
import { agentsQueryOptions, settingsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@evofw/ui/components/button'
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from '@evofw/ui/components/input-group'
import { Separator } from '@evofw/ui/components/separator'
import {
ToggleGroup,
ToggleGroupItem,
@@ -104,6 +95,7 @@ function AgentsPage() {
const { agent: detailAgentId, view } = Route.useSearch()
const qc = useQueryClient()
const agentsQ = useQuery(agentsQueryOptions())
const settingsQ = useQuery(settingsQueryOptions())
const { copyToClipboard } = useCopyToClipboard()
const [createOpen, setCreateOpen] = useState(false)
const [deleteAgentId, setDeleteAgentId] = useState<string | null>(null)
@@ -112,6 +104,7 @@ function AgentsPage() {
const [activeTab, setActiveTab] = useState('all')
const detailOpen = Boolean(detailAgentId)
const showQuickActions = settingsQ.data?.show_quick_actions !== 'false'
const setSearch = useCallback(
(next: Partial<AgentsSearch>) => {
@@ -410,7 +403,7 @@ function AgentsPage() {
<KpiStatGrid cards={kpiCards} isLoading={agentsQ.isLoading} />
<QuickActionGrid actions={quickActions} />
{showQuickActions ? <QuickActionGrid actions={quickActions} /> : null}
{counts.pending > 0 ? (
<Frame dense spacing="sm">
@@ -482,101 +475,44 @@ function AgentsPage() {
onCopyInstall={handleCopyInstall}
onDelete={handleDeleteAgent}
/>
) : agentsQ.isError ? (
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<span>
{agentsQ.error?.message ?? 'Не удалось загрузить данные'}
</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void agentsQ.refetch()}
>
Повторить
</Button>
</AlertDescription>
</Alert>
) : (
<Frame dense variant="default" spacing="sm" className="w-full">
<FramePanel className="p-0 shadow-none!">
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
<CountedLineTabs
tabs={countedTabs}
value={activeTab}
onValueChange={setActiveTab}
/>
</div>
<Separator />
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<Filters
filters={filters}
fields={filterFields}
onChange={setFilters}
size="default"
trigger={
<Button
type="button"
variant="outline"
aria-label="Фильтры"
>
<FilterIcon className="size-4" aria-hidden="true" />
Фильтры
</Button>
}
/>
<InputGroup className="h-8 max-w-xs min-w-[12rem] flex-1">
<InputGroupAddon align="inline-start">
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Поиск агентов…"
aria-label="Поиск агентов…"
/>
</InputGroup>
</div>
<div className="flex flex-wrap items-center gap-2">
{viewToggle}
{filters.length > 0 || searchQuery ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClearFilters}
>
Сбросить
</Button>
) : null}
</div>
</div>
<div className="px-(--frame-panel-header-px) pb-(--frame-panel-header-py)">
<AgentCardsGrid
agents={filteredItems}
selectedId={detailOpen ? detailAgentId : null}
onSelect={handleSelectAgent}
onDelete={handleDeleteAgent}
isLoading={agentsQ.isLoading}
emptyTitle={
items.length === 0
? 'Нет агентов'
: 'Нет записей по фильтрам'
}
emptyDescription={
items.length === 0
? 'Создайте агента — он появится в каталоге как Invited с командой установки.'
: undefined
}
emptyAction={items.length === 0 ? addButton : undefined}
/>
</div>
</FramePanel>
</Frame>
<QueryState
isError={agentsQ.isError}
error={agentsQ.error}
onRetry={() => void agentsQ.refetch()}
>
<AgentsFleetChrome
tabs={countedTabs}
activeTab={activeTab}
onTabChange={setActiveTab}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={handleClearFilters}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
toolbarExtra={viewToggle}
>
<AgentCardsGrid
agents={filteredItems}
selectedId={detailOpen ? detailAgentId : null}
onSelect={handleSelectAgent}
onDelete={handleDeleteAgent}
isLoading={agentsQ.isLoading}
emptyTitle={
items.length === 0
? 'Нет агентов'
: 'Нет записей по фильтрам'
}
emptyDescription={
items.length === 0
? 'Создайте агента — он появится в каталоге как Invited с командой установки.'
: undefined
}
emptyAction={items.length === 0 ? addButton : undefined}
/>
</AgentsFleetChrome>
</QueryState>
)}
<AddAgentSheet open={createOpen} onOpenChange={setCreateOpen} />
+10 -33
View File
@@ -31,8 +31,9 @@ import {
import {
dashboardQueryOptions,
agentsQueryOptions,
recentStatsQueryOptions,
settingsQueryOptions,
} from '@/queries'
import { AgentsFleetChart } from '@/components/agents/agents-fleet-chart'
import { agentTrafficDropped } from '@/components/agents/agent-traffic'
import type { Agent } from '@evofw/shared'
@@ -45,10 +46,11 @@ const EMPTY_AGENTS: Agent[] = []
function DashboardPage() {
const dash = useQuery(dashboardQueryOptions())
const agents = useQuery(agentsQueryOptions())
const stats = useQuery(recentStatsQueryOptions())
const settingsQ = useQuery(settingsQueryOptions())
const d = dash.data
const items = agents.data?.items ?? EMPTY_AGENTS
const showQuickActions = settingsQ.data?.show_quick_actions !== 'false'
const pending = useMemo(
() => items.filter((a) => a.status === 'pending'),
@@ -59,10 +61,6 @@ function DashboardPage() {
[items],
)
const previewAgents = useMemo(() => items.slice(0, 8), [items])
const samples = useMemo(
() => (stats.data?.items ?? []).slice(0, 10),
[stats.data?.items],
)
const kpiCards: KpiStatCard[] = useMemo(
() => [
@@ -145,9 +143,14 @@ function DashboardPage() {
<OpsDashboard
isLoading={dash.isLoading}
kpiCards={kpiCards}
afterKpi={<QuickActionGrid actions={quickActions} />}
afterKpi={
showQuickActions ? (
<QuickActionGrid actions={quickActions} />
) : undefined
}
charts={
<>
<AgentsFleetChart />
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Агенты</FrameTitle>
@@ -193,32 +196,6 @@ function DashboardPage() {
)}
</FramePanel>
</Frame>
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Последние samples</FrameTitle>
</FrameHeader>
<FramePanel>
<div className="flex flex-col gap-2">
{samples.length === 0 ? (
<p className="text-muted-foreground text-sm">Нет данных</p>
) : (
samples.map((s, i) => (
<div
key={`${s.agent_id}-${s.recorded_at}-${i}`}
className="flex items-center justify-between gap-2 text-sm"
>
<span className="text-muted-foreground text-xs">
{s.recorded_at}
</span>
<span className="tabular-nums">
{s.packets_dropped} / {s.packets_accepted}
</span>
</div>
))
)}
</div>
</FramePanel>
</Frame>
</>
}
queue={
+5 -3
View File
@@ -23,6 +23,7 @@ import {
AutocompleteItem,
AutocompleteList,
} from '@/components/reui/autocomplete'
import { LoadingButton } from '@/components/loading-button'
import { Button } from '@evofw/ui/components/button'
import { Field, FieldLabel } from '@evofw/ui/components/field'
import { Input } from '@evofw/ui/components/input'
@@ -364,12 +365,13 @@ function ListsPage() {
<Button variant="outline" onClick={() => setCreateOpen(false)}>
Отмена
</Button>
<Button
disabled={!canCreate || create.isPending}
<LoadingButton
disabled={!canCreate}
isLoading={create.isPending}
onClick={() => create.mutate()}
>
Создать
</Button>
</LoadingButton>
</SheetFooter>
</SheetContent>
</Sheet>
+32 -4
View File
@@ -10,11 +10,17 @@ import {
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { LoadingButton } from '@/components/loading-button'
import { SettingRow } from '@/components/setting-row'
import { settingsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button'
import { Input } from '@evofw/ui/components/input'
import { Switch } from '@evofw/ui/components/switch'
/**
* Control plane settings — Frame + SettingRow.
* Preview: https://reui.io/preview/base/settings-16 · settings-3
*/
export const Route = createFileRoute('/_auth/settings')({
component: SettingsPage,
@@ -43,6 +49,8 @@ function SettingsPage() {
onError: (e: Error) => toast.error(e.message),
})
const showQuickActions = form.show_quick_actions !== 'false'
const fields = [
{
key: 'enroll_seed',
@@ -89,7 +97,7 @@ function SettingsPage() {
title={f.label}
description={f.hint}
labelFor={f.key}
last={i === fields.length - 1}
last={false}
>
<Input
id={f.key}
@@ -101,13 +109,33 @@ function SettingsPage() {
/>
</SettingRow>
))}
<SettingRow
title="Быстрые действия"
description="Показывать Quick Actions на панели и у агентов"
last
>
<Switch
checked={showQuickActions}
onCheckedChange={(checked) =>
setForm((prev) => ({
...prev,
show_quick_actions: checked ? 'true' : 'false',
}))
}
aria-label="Быстрые действия"
/>
</SettingRow>
</div>
</FramePanel>
</Frame>
<div className="flex justify-end">
<Button onClick={() => save.mutate()} disabled={save.isPending}>
<LoadingButton
onClick={() => save.mutate()}
isLoading={save.isPending}
loadingLabel="Сохранение…"
>
Сохранить
</Button>
</LoadingButton>
</div>
</PageShell>
)
+30 -7
View File
@@ -1,5 +1,13 @@
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute, Link } from '@tanstack/react-router'
import { KeyRoundIcon } from 'lucide-react'
import { EmptyState } from '@/components/empty-state'
import { setToken } from '@/lib/auth'
import { Button } from '@evofw/ui/components/button'
/**
* Portal SSO callback — token from hash fragment.
* Preview: https://reui.io/preview/base/auth-13 · https://reui.io/preview/base/empty-state-12
*/
export const Route = createFileRoute('/auth/callback')({
component: AuthCallback,
@@ -9,17 +17,32 @@ function AuthCallback() {
const hash = typeof window !== 'undefined' ? window.location.hash : ''
const params = new URLSearchParams(hash.replace(/^#/, ''))
const token = params.get('access_token')
if (token) {
setToken(token)
window.location.replace('/')
} else {
return (
<div className="flex min-h-svh items-center justify-center p-6">
<p className="text-muted-foreground text-sm">
Нет access_token в URL. Войдите через auth-portal.
</p>
<div className="bg-background flex min-h-svh items-center justify-center p-6">
<EmptyState
icon={KeyRoundIcon}
title="Вход…"
description="Перенаправляем в EvoFirewall"
stackedIcon
/>
</div>
)
}
return null
return (
<div className="bg-background flex min-h-svh items-center justify-center p-6">
<EmptyState
icon={KeyRoundIcon}
title="Нет access_token"
description="Войдите через auth-portal и вернитесь с токеном в URL."
action={
<Button render={<Link to="/" />}>На главную</Button>
}
/>
</div>
)
}
+6 -1
View File
@@ -5,4 +5,9 @@
- [EvoBGP](integrate-evobgp.md)
- [Agents](agents.md)
- [UI design contract](ui-design-contract.md)
- [OpenAPI](openapi.yaml)
- [OpenAPI](openapi.yaml)**HTTP SoT** (пути/операции). Живая валидация request bodies — Zod в `@evofw/shared`.
```bash
# Lint OpenAPI (requires npx)
npx --yes @redocly/cli lint docs/openapi.yaml
```
+419 -7
View File
@@ -1,58 +1,418 @@
openapi: 3.0.3
info:
title: EvoFirewall API
version: 0.1.0
description: Centralized firewall control plane
version: 0.2.0
description: |
Centralized firewall control plane.
**HTTP SoT:** this OpenAPI document.
**Runtime validation:** Zod schemas in `@evofw/shared` (authoritative for request bodies).
servers:
- url: http://localhost:8080
description: Local API
security: []
paths:
/health:
get:
summary: Liveness
tags: [system]
responses:
'200':
description: OK
/api/v1/dashboard:
get:
summary: Dashboard KPI
tags: [ops]
security: [{ bearerAuth: [] }]
responses:
'200':
description: Stats
/api/v1/install-context:
get:
summary: Public install base URL + enroll seed hint
tags: [ops]
security: [{ bearerAuth: [] }]
responses:
'200':
description: Install context
/api/v1/install-links:
get:
summary: List install links
tags: [agents]
security: [{ bearerAuth: [] }]
responses:
'200':
description: Links
post:
summary: Create install link + invited agent
tags: [agents]
security: [{ bearerAuth: [] }]
responses:
'201':
description: Created
/api/v1/install-links/{id}:
delete:
summary: Revoke install link
tags: [agents]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
responses:
'200':
description: Revoked
/api/v1/agents:
get:
summary: List agents
tags: [agents]
security: [{ bearerAuth: [] }]
responses:
'200':
description: Agents
/api/v1/agents/{id}:
get:
summary: Get agent
tags: [agents]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
responses:
'200':
description: Agent
patch:
summary: Update agent
tags: [agents]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
responses:
'200':
description: Updated
delete:
summary: Delete agent
tags: [agents]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
responses:
'200':
description: Deleted
/api/v1/agents/{id}/approve:
post:
summary: Approve pending/invited agent
tags: [agents]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
responses:
'200':
description: Approved
/api/v1/agents/{id}/revoke:
post:
summary: Revoke agent
tags: [agents]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
responses:
'200':
description: Revoked
/api/v1/agents/{id}/preview:
get:
summary: Policy preview (truncated CIDRs)
tags: [agents]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
- name: limit_cidrs
in: query
schema: { type: integer, default: 50, maximum: 5000 }
responses:
'200':
description: Preview
/api/v1/agents/{id}/policy-sets:
get:
summary: Assigned policy sets
tags: [agents]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
responses:
'200':
description: Sets
put:
summary: Replace assigned policy sets (order = sort)
tags: [agents]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
responses:
'200':
description: Updated
/api/v1/agents/{id}/overrides:
get:
summary: List IP overrides
tags: [agents]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
responses:
'200':
description: Overrides
post:
summary: Create override
tags: [agents]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
responses:
'200':
description: Created
/api/v1/agents/{id}/overrides/{overrideId}:
delete:
summary: Delete override
tags: [agents]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
- name: overrideId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Deleted
/api/v1/lists:
get:
summary: List IP lists
tags: [lists]
security: [{ bearerAuth: [] }]
responses:
'200':
description: Lists
post:
summary: Create IP list
tags: [lists]
security: [{ bearerAuth: [] }]
responses:
'200':
description: Created
/api/v1/lists/{id}:
get:
summary: List detail + entries
tags: [lists]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
responses:
'200':
description: Detail
delete:
summary: Delete list
tags: [lists]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
responses:
'200':
description: Deleted
/api/v1/lists/{id}/refresh:
post:
summary: Refresh remote/json/evobgp list
tags: [lists]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
responses:
'200':
description: Refreshed
/api/v1/lists/{id}/entries:
post:
summary: Add manual entries
tags: [lists]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
responses:
'200':
description: Updated
delete:
summary: Delete entry
tags: [lists]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
responses:
'200':
description: Deleted
/api/v1/policy-sets:
get:
summary: List policy sets
tags: [policies]
security: [{ bearerAuth: [] }]
responses:
'200':
description: Sets
post:
summary: Create policy set
tags: [policies]
security: [{ bearerAuth: [] }]
responses:
'200':
description: Created
/api/v1/policy-sets/{id}:
get:
summary: Get policy set
tags: [policies]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
responses:
'200':
description: Set
patch:
summary: Patch policy set
tags: [policies]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
responses:
'200':
description: Updated
delete:
summary: Delete policy set
tags: [policies]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
responses:
'200':
description: Deleted
/api/v1/policy-sets/{id}/rules:
get:
summary: Rules in set
tags: [policies]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
responses:
'200':
description: Rules
/api/v1/policy-sets/{id}/rules/reorder:
put:
summary: Reorder rules in a set
tags: [policies]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
responses:
'200':
description: Reordered
/api/v1/rules:
get:
summary: List policy rules
summary: List policy rules (optional set filter)
tags: [policies]
security: [{ bearerAuth: [] }]
responses:
'200':
description: Rules
post:
summary: Create policy rule
tags: [policies]
security: [{ bearerAuth: [] }]
responses:
'200':
description: Created
/api/v1/rules/{id}:
patch:
summary: Patch rule
tags: [policies]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
responses:
'200':
description: Updated
delete:
summary: Delete rule
tags: [policies]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
responses:
'200':
description: Deleted
/api/v1/stats/recent:
get:
summary: Recent apply samples
tags: [ops]
security: [{ bearerAuth: [] }]
responses:
'200':
description: Samples
/api/v1/integrations/evobgp/communities:
get:
summary: Proxy EvoBGP communities
tags: [integrations]
security: [{ bearerAuth: [] }]
responses:
'200':
description: Communities
/api/v1/settings:
get:
summary: Settings map (token masked)
tags: [settings]
security: [{ bearerAuth: [] }]
responses:
'200':
description: Settings
put:
summary: Update whitelisted settings keys
tags: [settings]
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties:
type: string
description: Keys — enroll_seed, evobgp_api_url, evobgp_api_token, agent_sync_interval_sec, show_quick_actions
responses:
'200':
description: Saved
'400':
description: Unknown key / validation
/api/v1/audit:
get:
summary: Local audit log (dual-write source fw)
summary: Local audit log
tags: [ops]
security: [{ bearerAuth: [] }]
parameters:
- name: action
@@ -67,31 +427,83 @@ paths:
responses:
'200':
description: Audit entries
/v1/agent/install.sh:
get:
summary: Agent install script
tags: [agent-public]
responses:
'200':
description: Shell script
/v1/agent/enroll:
post:
summary: Enroll agent (public + seed)
summary: Enroll agent (X-EvoFW-Seed)
tags: [agent-public]
responses:
'201':
description: Pending agent
/v1/agent/policy:
get:
summary: Evaluated policy for agent
tags: [agent]
security: [{ agentToken: [] }]
responses:
'200':
description: Policy
/v1/agent/apply-report:
/v1/agent/policy.rsc:
get:
summary: MikroTik RSC policy
tags: [agent]
security: [{ agentToken: [] }]
responses:
'200':
description: RSC text
/v1/agent/heartbeat:
post:
summary: Apply report + packet stats
summary: Agent heartbeat
tags: [agent]
security: [{ agentToken: [] }]
responses:
'200':
description: OK
/v1/agent/apply-report:
post:
summary: Apply report + packet stats
tags: [agent]
security: [{ agentToken: [] }]
responses:
'200':
description: OK
components:
parameters:
Id:
name: id
in: path
required: true
schema: { type: string }
securitySchemes:
bearerAuth:
type: http
scheme: bearer
description: Portal JWT (app id fw)
agentToken:
type: http
scheme: bearer
description: Agent enroll token
tags:
- name: system
- name: ops
- name: agents
- name: lists
- name: policies
- name: settings
- name: integrations
- name: agent-public
- name: agent
+2 -1
View File
@@ -6,7 +6,8 @@
"dev": "turbo run dev",
"build": "turbo run build",
"test": "turbo run test",
"lint": "turbo run lint"
"lint": "turbo run lint",
"openapi:lint": "npx --yes @redocly/cli lint docs/openapi.yaml"
},
"devDependencies": {
"turbo": "^2.5.4"
+33
View File
@@ -0,0 +1,33 @@
import { eq, desc } from 'drizzle-orm'
import type { Db } from '../client.js'
import { agents } from '../schema.js'
export function listAgents(db: Db) {
return db.select().from(agents).orderBy(desc(agents.createdAt)).all()
}
export function getAgent(db: Db, id: string) {
return db.select().from(agents).where(eq(agents.id, id)).get()
}
export function getAgentByTokenHash(db: Db, tokenHash: string) {
return db.select().from(agents).where(eq(agents.tokenHash, tokenHash)).get()
}
export function insertAgent(db: Db, row: typeof agents.$inferInsert) {
db.insert(agents).values(row).run()
return getAgent(db, row.id)
}
export function updateAgent(
db: Db,
id: string,
patch: Partial<typeof agents.$inferInsert>,
) {
db.update(agents).set(patch).where(eq(agents.id, id)).run()
return getAgent(db, id)
}
export function deleteAgent(db: Db, id: string) {
db.delete(agents).where(eq(agents.id, id)).run()
}
+153 -568
View File
@@ -1,571 +1,153 @@
import { eq, and, desc, asc, sql, count, inArray } from 'drizzle-orm'
import type { Db } from '../client.js'
export {
listAgents,
getAgent,
getAgentByTokenHash,
insertAgent,
updateAgent,
deleteAgent,
} from './agents.js'
export {
listIpLists,
getIpList,
insertIpList,
updateIpList,
deleteIpList,
listIpListEntries,
countEntriesByListIds,
replaceIpListEntries,
} from './lists.js'
export {
bumpAgentGeneration,
bumpAgentsForSet,
bumpAllApprovedAgents,
bumpAgentsForList,
listPolicySets,
getPolicySet,
insertPolicySet,
updatePolicySet,
deletePolicySet,
countRulesInSet,
countAgentsForSet,
countRulesAndAgentsBySetIds,
listAgentIdsForSet,
listSetsForAgent,
setAgentPolicySets,
ensureSharedSetAssigned,
listPolicyRules,
listPolicyRulesForAgent,
getPolicyRule,
insertPolicyRule,
updatePolicyRule,
reorderPolicyRules,
nextRulePriority,
deletePolicyRule,
listHostnameRules,
listResolvedForRule,
replaceResolvedForRule,
listOverrides,
insertOverride,
deleteOverride,
cloneRulesFrom,
} from './policy.js'
export {
insertStatsSample,
listStatsSamples,
listRecentStats,
deleteStatsSamplesForAgent,
} from './stats.js'
export {
getSetting,
setSetting,
listSettings,
} from './settings.js'
export {
listInstallLinks,
getInstallLink,
getInstallLinkBySlug,
getInstallLinkByAgentId,
mapActiveInstallLinksByAgentId,
insertInstallLink,
revokeInstallLink,
touchInstallLink,
} from './install-links.js'
import {
agents,
ipLists,
ipListEntries,
policySets,
agentPolicySets,
policyRules,
policyRuleResolved,
ipOverrides,
agentStatsSamples,
agentInstallLinks,
settings,
SHARED_POLICY_SET_ID,
} from '../schema.js'
export function listAgents(db: Db) {
return db.select().from(agents).orderBy(desc(agents.createdAt)).all()
}
export function getAgent(db: Db, id: string) {
return db.select().from(agents).where(eq(agents.id, id)).get()
}
export function getAgentByTokenHash(db: Db, tokenHash: string) {
return db.select().from(agents).where(eq(agents.tokenHash, tokenHash)).get()
}
export function insertAgent(db: Db, row: typeof agents.$inferInsert) {
db.insert(agents).values(row).run()
return getAgent(db, row.id)
}
export function updateAgent(
db: Db,
id: string,
patch: Partial<typeof agents.$inferInsert>,
) {
db.update(agents).set(patch).where(eq(agents.id, id)).run()
return getAgent(db, id)
}
export function deleteAgent(db: Db, id: string) {
db.delete(agents).where(eq(agents.id, id)).run()
}
export function bumpAgentGeneration(db: Db, id: string) {
db.update(agents)
.set({ policyGeneration: sql`${agents.policyGeneration} + 1` })
.where(eq(agents.id, id))
.run()
}
export function bumpAgentsForSet(db: Db, setId: string) {
const rows = db
.select({ agentId: agentPolicySets.agentId })
.from(agentPolicySets)
.where(eq(agentPolicySets.setId, setId))
.all()
for (const r of rows) bumpAgentGeneration(db, r.agentId)
}
export function bumpAllApprovedAgents(db: Db) {
const rows = db
.select({ id: agents.id })
.from(agents)
.where(eq(agents.status, 'approved'))
.all()
for (const r of rows) bumpAgentGeneration(db, r.id)
}
/** Bump agents that have a policy rule referencing this IP list. */
export function bumpAgentsForList(db: Db, listId: string) {
const rules = db
.select({ setId: policyRules.setId })
.from(policyRules)
.where(eq(policyRules.listId, listId))
.all()
const setIds = [...new Set(rules.map((r) => r.setId))]
if (setIds.length === 0) {
bumpAllApprovedAgents(db)
return
}
for (const setId of setIds) bumpAgentsForSet(db, setId)
}
export function listIpLists(db: Db) {
return db.select().from(ipLists).orderBy(desc(ipLists.createdAt)).all()
}
export function getIpList(db: Db, id: string) {
return db.select().from(ipLists).where(eq(ipLists.id, id)).get()
}
export function insertIpList(db: Db, row: typeof ipLists.$inferInsert) {
db.insert(ipLists).values(row).run()
return getIpList(db, row.id)
}
export function updateIpList(
db: Db,
id: string,
patch: Partial<typeof ipLists.$inferInsert>,
) {
db.update(ipLists)
.set({ ...patch, updatedAt: new Date().toISOString() })
.where(eq(ipLists.id, id))
.run()
return getIpList(db, id)
}
export function deleteIpList(db: Db, id: string) {
db.delete(ipLists).where(eq(ipLists.id, id)).run()
}
export function listIpListEntries(db: Db, listId: string) {
return db
.select()
.from(ipListEntries)
.where(eq(ipListEntries.listId, listId))
.all()
}
export function replaceIpListEntries(db: Db, listId: string, cidrs: string[]) {
db.delete(ipListEntries).where(eq(ipListEntries.listId, listId)).run()
const now = new Date().toISOString()
for (const cidr of cidrs) {
db.insert(ipListEntries)
.values({
id: crypto.randomUUID(),
listId,
cidr,
createdAt: now,
})
.run()
}
}
/* ── Policy sets ── */
export function listPolicySets(db: Db) {
return db.select().from(policySets).orderBy(asc(policySets.name)).all()
}
export function getPolicySet(db: Db, id: string) {
return db.select().from(policySets).where(eq(policySets.id, id)).get()
}
export function insertPolicySet(db: Db, row: typeof policySets.$inferInsert) {
db.insert(policySets).values(row).run()
return getPolicySet(db, row.id)
}
export function updatePolicySet(
db: Db,
id: string,
patch: Partial<typeof policySets.$inferInsert>,
) {
db.update(policySets)
.set({ ...patch, updatedAt: new Date().toISOString() })
.where(eq(policySets.id, id))
.run()
return getPolicySet(db, id)
}
export function deletePolicySet(db: Db, id: string) {
if (id === SHARED_POLICY_SET_ID) {
throw new Error('cannot delete shared default set')
}
db.delete(policySets).where(eq(policySets.id, id)).run()
}
export function countRulesInSet(db: Db, setId: string): number {
const row = db
.select({ n: count() })
.from(policyRules)
.where(eq(policyRules.setId, setId))
.get()
return row?.n ?? 0
}
export function countAgentsForSet(db: Db, setId: string): number {
const row = db
.select({ n: count() })
.from(agentPolicySets)
.where(eq(agentPolicySets.setId, setId))
.get()
return row?.n ?? 0
}
export function listAgentIdsForSet(db: Db, setId: string): string[] {
return db
.select({ agentId: agentPolicySets.agentId })
.from(agentPolicySets)
.where(eq(agentPolicySets.setId, setId))
.all()
.map((r) => r.agentId)
}
export function listSetsForAgent(db: Db, agentId: string) {
return db
.select({
setId: agentPolicySets.setId,
sort: agentPolicySets.sort,
name: policySets.name,
description: policySets.description,
enabled: policySets.enabled,
policyMode: policySets.policyMode,
})
.from(agentPolicySets)
.innerJoin(policySets, eq(agentPolicySets.setId, policySets.id))
.where(eq(agentPolicySets.agentId, agentId))
.orderBy(asc(agentPolicySets.sort), asc(policySets.name))
.all()
}
/** Replace agent↔set assignments; set_ids order = sort. */
export function setAgentPolicySets(db: Db, agentId: string, setIds: string[]) {
for (const setId of setIds) {
if (!getPolicySet(db, setId)) {
throw new Error(`policy set not found: ${setId}`)
}
}
db.delete(agentPolicySets).where(eq(agentPolicySets.agentId, agentId)).run()
setIds.forEach((setId, i) => {
db.insert(agentPolicySets)
.values({ agentId, setId, sort: i * 10 })
.run()
})
bumpAgentGeneration(db, agentId)
}
export function ensureSharedSetAssigned(db: Db, agentId: string) {
const existing = db
.select()
.from(agentPolicySets)
.where(
and(
eq(agentPolicySets.agentId, agentId),
eq(agentPolicySets.setId, SHARED_POLICY_SET_ID),
),
)
.get()
if (existing) return
db.insert(agentPolicySets)
.values({ agentId, setId: SHARED_POLICY_SET_ID, sort: 0 })
.run()
}
/* ── Policy rules ── */
export function listPolicyRules(db: Db, setId?: string) {
if (setId) {
return db
.select()
.from(policyRules)
.where(eq(policyRules.setId, setId))
.orderBy(asc(policyRules.priority))
.all()
}
return db.select().from(policyRules).orderBy(asc(policyRules.priority)).all()
}
export function listPolicyRulesForAgent(db: Db, agentId: string) {
const assignments = listSetsForAgent(db, agentId).filter((s) => s.enabled === 1)
if (assignments.length === 0) return []
const setIds = assignments.map((a) => a.setId)
const sortBySet = new Map(assignments.map((a) => [a.setId, a.sort]))
const rules = db
.select()
.from(policyRules)
.where(inArray(policyRules.setId, setIds))
.all()
return rules
.filter((r) => r.enabled !== 0)
.map((r) => ({
...r,
_setSort: sortBySet.get(r.setId) ?? 0,
}))
.sort((a, b) => a._setSort - b._setSort || a.priority - b.priority)
}
export function getPolicyRule(db: Db, id: string) {
return db.select().from(policyRules).where(eq(policyRules.id, id)).get()
}
export function insertPolicyRule(
db: Db,
row: typeof policyRules.$inferInsert,
) {
db.insert(policyRules).values(row).run()
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) {
db.delete(policyRules).where(eq(policyRules.id, id)).run()
}
export function listHostnameRules(db: Db) {
return db
.select()
.from(policyRules)
.where(sql`${policyRules.hostname} IS NOT NULL AND trim(${policyRules.hostname}) != ''`)
.all()
}
export function listResolvedForRule(db: Db, ruleId: string) {
return db
.select()
.from(policyRuleResolved)
.where(eq(policyRuleResolved.ruleId, ruleId))
.all()
}
export function replaceResolvedForRule(db: Db, ruleId: string, cidrs: string[]) {
db.delete(policyRuleResolved)
.where(eq(policyRuleResolved.ruleId, ruleId))
.run()
const now = new Date().toISOString()
for (const cidr of cidrs) {
db.insert(policyRuleResolved)
.values({
id: crypto.randomUUID(),
ruleId,
cidr,
resolvedAt: now,
})
.run()
}
}
export function listOverrides(db: Db, agentId: string) {
return db
.select()
.from(ipOverrides)
.where(eq(ipOverrides.agentId, agentId))
.all()
}
export function insertOverride(
db: Db,
row: typeof ipOverrides.$inferInsert,
) {
db.insert(ipOverrides).values(row).run()
return db.select().from(ipOverrides).where(eq(ipOverrides.id, row.id)).get()
}
export function deleteOverride(db: Db, id: string) {
db.delete(ipOverrides).where(eq(ipOverrides.id, id)).run()
}
export function insertStatsSample(
db: Db,
row: typeof agentStatsSamples.$inferInsert,
) {
db.insert(agentStatsSamples).values(row).run()
}
export function listStatsSamples(db: Db, agentId: string, limit = 100) {
return db
.select()
.from(agentStatsSamples)
.where(eq(agentStatsSamples.agentId, agentId))
.orderBy(desc(agentStatsSamples.recordedAt))
.limit(limit)
.all()
}
export function listRecentStats(db: Db, limit = 500) {
return db
.select()
.from(agentStatsSamples)
.orderBy(desc(agentStatsSamples.recordedAt))
.limit(limit)
.all()
}
export function deleteStatsSamplesForAgent(db: Db, agentId: string) {
db.delete(agentStatsSamples)
.where(eq(agentStatsSamples.agentId, agentId))
.run()
}
export function getSetting(db: Db, key: string): string {
const row = db.select().from(settings).where(eq(settings.key, key)).get()
return row?.value ?? ''
}
export function setSetting(db: Db, key: string, value: string) {
const now = new Date().toISOString()
const existing = db.select().from(settings).where(eq(settings.key, key)).get()
if (existing) {
db.update(settings)
.set({ value, updatedAt: now })
.where(eq(settings.key, key))
.run()
} else {
db.insert(settings).values({ key, value, updatedAt: now }).run()
}
}
export function listSettings(db: Db) {
return db.select().from(settings).all()
}
/** Clone set assignments (+ optional overrides) and policy mode from source agent. */
export function cloneRulesFrom(
db: Db,
sourceAgentId: string,
targetAgentId: string,
includeOverrides: boolean,
) {
const source = getAgent(db, sourceAgentId)
const target = getAgent(db, targetAgentId)
if (!source || !target) return null
const sourceSets = listSetsForAgent(db, sourceAgentId)
setAgentPolicySets(
db,
targetAgentId,
sourceSets.map((s) => s.setId),
)
if (includeOverrides) {
db.delete(ipOverrides).where(eq(ipOverrides.agentId, targetAgentId)).run()
for (const o of listOverrides(db, sourceAgentId)) {
db.insert(ipOverrides)
.values({
id: crypto.randomUUID(),
agentId: targetAgentId,
cidr: o.cidr,
action: o.action,
comment: o.comment,
createdAt: new Date().toISOString(),
})
.run()
}
}
updateAgent(db, targetAgentId, {
defaultAction: source.defaultAction,
policyGeneration: (target.policyGeneration ?? 1) + 1,
})
return getAgent(db, targetAgentId)
}
export function listInstallLinks(db: Db) {
return db
.select()
.from(agentInstallLinks)
.orderBy(desc(agentInstallLinks.createdAt))
.all()
}
export function getInstallLink(db: Db, id: string) {
return db
.select()
.from(agentInstallLinks)
.where(eq(agentInstallLinks.id, id))
.get()
}
export function getInstallLinkBySlug(db: Db, slug: string) {
return db
.select()
.from(agentInstallLinks)
.where(eq(agentInstallLinks.slug, slug))
.get()
}
export function getInstallLinkByAgentId(db: Db, agentId: string) {
return db
.select()
.from(agentInstallLinks)
.where(
and(
eq(agentInstallLinks.agentId, agentId),
sql`${agentInstallLinks.revokedAt} IS NULL`,
),
)
.orderBy(desc(agentInstallLinks.createdAt))
.get()
}
export function insertInstallLink(
db: Db,
row: typeof agentInstallLinks.$inferInsert,
) {
db.insert(agentInstallLinks).values(row).run()
return getInstallLink(db, row.id)
}
export function revokeInstallLink(db: Db, id: string) {
const now = new Date().toISOString()
db.update(agentInstallLinks)
.set({ revokedAt: now })
.where(eq(agentInstallLinks.id, id))
.run()
return getInstallLink(db, id)
}
export function touchInstallLink(db: Db, id: string) {
const now = new Date().toISOString()
db.update(agentInstallLinks)
.set({
lastUsedAt: now,
useCount: sql`${agentInstallLinks.useCount} + 1`,
})
.where(eq(agentInstallLinks.id, id))
.run()
return getInstallLink(db, id)
}
listAgents,
getAgent,
getAgentByTokenHash,
insertAgent,
updateAgent,
deleteAgent,
} from './agents.js'
import {
listIpLists,
getIpList,
insertIpList,
updateIpList,
deleteIpList,
listIpListEntries,
countEntriesByListIds,
replaceIpListEntries,
} from './lists.js'
import {
bumpAgentGeneration,
bumpAgentsForSet,
bumpAllApprovedAgents,
bumpAgentsForList,
listPolicySets,
getPolicySet,
insertPolicySet,
updatePolicySet,
deletePolicySet,
countRulesInSet,
countAgentsForSet,
countRulesAndAgentsBySetIds,
listAgentIdsForSet,
listSetsForAgent,
setAgentPolicySets,
ensureSharedSetAssigned,
listPolicyRules,
listPolicyRulesForAgent,
getPolicyRule,
insertPolicyRule,
updatePolicyRule,
reorderPolicyRules,
nextRulePriority,
deletePolicyRule,
listHostnameRules,
listResolvedForRule,
replaceResolvedForRule,
listOverrides,
insertOverride,
deleteOverride,
cloneRulesFrom,
} from './policy.js'
import {
insertStatsSample,
listStatsSamples,
listRecentStats,
deleteStatsSamplesForAgent,
} from './stats.js'
import {
getSetting,
setSetting,
listSettings,
} from './settings.js'
import {
listInstallLinks,
getInstallLink,
getInstallLinkBySlug,
getInstallLinkByAgentId,
mapActiveInstallLinksByAgentId,
insertInstallLink,
revokeInstallLink,
touchInstallLink,
} from './install-links.js'
export const repos = {
listAgents,
@@ -584,6 +166,7 @@ export const repos = {
updateIpList,
deleteIpList,
listIpListEntries,
countEntriesByListIds,
replaceIpListEntries,
listPolicySets,
getPolicySet,
@@ -592,6 +175,7 @@ export const repos = {
deletePolicySet,
countRulesInSet,
countAgentsForSet,
countRulesAndAgentsBySetIds,
listAgentIdsForSet,
listSetsForAgent,
setAgentPolicySets,
@@ -622,9 +206,10 @@ export const repos = {
getInstallLink,
getInstallLinkBySlug,
getInstallLinkByAgentId,
mapActiveInstallLinksByAgentId,
insertInstallLink,
revokeInstallLink,
touchInstallLink,
}
export { SHARED_POLICY_SET_ID }
export { SHARED_POLICY_SET_ID } from '../schema.js'
@@ -0,0 +1,86 @@
import { eq, and, desc, sql } from 'drizzle-orm'
import type { Db } from '../client.js'
import { agentInstallLinks } from '../schema.js'
export function listInstallLinks(db: Db) {
return db
.select()
.from(agentInstallLinks)
.orderBy(desc(agentInstallLinks.createdAt))
.all()
}
export function getInstallLink(db: Db, id: string) {
return db
.select()
.from(agentInstallLinks)
.where(eq(agentInstallLinks.id, id))
.get()
}
export function getInstallLinkBySlug(db: Db, slug: string) {
return db
.select()
.from(agentInstallLinks)
.where(eq(agentInstallLinks.slug, slug))
.get()
}
export function getInstallLinkByAgentId(db: Db, agentId: string) {
return db
.select()
.from(agentInstallLinks)
.where(
and(
eq(agentInstallLinks.agentId, agentId),
sql`${agentInstallLinks.revokedAt} IS NULL`,
),
)
.orderBy(desc(agentInstallLinks.createdAt))
.get()
}
/** Latest active install link per agent (one query). */
export function mapActiveInstallLinksByAgentId(db: Db) {
const rows = db
.select()
.from(agentInstallLinks)
.where(sql`${agentInstallLinks.revokedAt} IS NULL`)
.orderBy(desc(agentInstallLinks.createdAt))
.all()
const map = new Map<string, (typeof rows)[number]>()
for (const row of rows) {
if (!row.agentId) continue
if (!map.has(row.agentId)) map.set(row.agentId, row)
}
return map
}
export function insertInstallLink(
db: Db,
row: typeof agentInstallLinks.$inferInsert,
) {
db.insert(agentInstallLinks).values(row).run()
return getInstallLink(db, row.id)
}
export function revokeInstallLink(db: Db, id: string) {
const now = new Date().toISOString()
db.update(agentInstallLinks)
.set({ revokedAt: now })
.where(eq(agentInstallLinks.id, id))
.run()
return getInstallLink(db, id)
}
export function touchInstallLink(db: Db, id: string) {
const now = new Date().toISOString()
db.update(agentInstallLinks)
.set({
lastUsedAt: now,
useCount: sql`${agentInstallLinks.useCount} + 1`,
})
.where(eq(agentInstallLinks.id, id))
.run()
return getInstallLink(db, id)
}
+76
View File
@@ -0,0 +1,76 @@
import { eq, desc, count, inArray } from 'drizzle-orm'
import type { Db } from '../client.js'
import { ipLists, ipListEntries } from '../schema.js'
export function listIpLists(db: Db) {
return db.select().from(ipLists).orderBy(desc(ipLists.createdAt)).all()
}
export function getIpList(db: Db, id: string) {
return db.select().from(ipLists).where(eq(ipLists.id, id)).get()
}
export function insertIpList(db: Db, row: typeof ipLists.$inferInsert) {
db.insert(ipLists).values(row).run()
return getIpList(db, row.id)
}
export function updateIpList(
db: Db,
id: string,
patch: Partial<typeof ipLists.$inferInsert>,
) {
db.update(ipLists)
.set({ ...patch, updatedAt: new Date().toISOString() })
.where(eq(ipLists.id, id))
.run()
return getIpList(db, id)
}
export function deleteIpList(db: Db, id: string) {
db.delete(ipLists).where(eq(ipLists.id, id)).run()
}
export function listIpListEntries(db: Db, listId: string) {
return db
.select()
.from(ipListEntries)
.where(eq(ipListEntries.listId, listId))
.all()
}
/** Entry counts for many lists in one query. */
export function countEntriesByListIds(
db: Db,
listIds: string[],
): Map<string, number> {
const map = new Map<string, number>()
if (listIds.length === 0) return map
const rows = db
.select({
listId: ipListEntries.listId,
n: count(),
})
.from(ipListEntries)
.where(inArray(ipListEntries.listId, listIds))
.groupBy(ipListEntries.listId)
.all()
for (const r of rows) map.set(r.listId, r.n)
return map
}
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()
}
+395
View File
@@ -0,0 +1,395 @@
import { eq, and, asc, sql, count, inArray } from 'drizzle-orm'
import type { Db } from '../client.js'
import {
agents,
policySets,
agentPolicySets,
policyRules,
policyRuleResolved,
ipOverrides,
SHARED_POLICY_SET_ID,
} from '../schema.js'
import { getAgent, updateAgent } from './agents.js'
export function bumpAgentGeneration(db: Db, id: string) {
db.update(agents)
.set({ policyGeneration: sql`${agents.policyGeneration} + 1` })
.where(eq(agents.id, id))
.run()
}
export function bumpAgentsForSet(db: Db, setId: string) {
const rows = db
.select({ agentId: agentPolicySets.agentId })
.from(agentPolicySets)
.where(eq(agentPolicySets.setId, setId))
.all()
for (const r of rows) bumpAgentGeneration(db, r.agentId)
}
export function bumpAllApprovedAgents(db: Db) {
const rows = db
.select({ id: agents.id })
.from(agents)
.where(eq(agents.status, 'approved'))
.all()
for (const r of rows) bumpAgentGeneration(db, r.id)
}
/** Bump agents that have a policy rule referencing this IP list. */
export function bumpAgentsForList(db: Db, listId: string) {
const rules = db
.select({ setId: policyRules.setId })
.from(policyRules)
.where(eq(policyRules.listId, listId))
.all()
const setIds = [...new Set(rules.map((r) => r.setId))]
// Unused list (no rules) — do not churn all approved agents.
if (setIds.length === 0) return
for (const setId of setIds) bumpAgentsForSet(db, setId)
}
/* ── Policy sets ── */
export function listPolicySets(db: Db) {
return db.select().from(policySets).orderBy(asc(policySets.name)).all()
}
export function getPolicySet(db: Db, id: string) {
return db.select().from(policySets).where(eq(policySets.id, id)).get()
}
export function insertPolicySet(db: Db, row: typeof policySets.$inferInsert) {
db.insert(policySets).values(row).run()
return getPolicySet(db, row.id)
}
export function updatePolicySet(
db: Db,
id: string,
patch: Partial<typeof policySets.$inferInsert>,
) {
db.update(policySets)
.set({ ...patch, updatedAt: new Date().toISOString() })
.where(eq(policySets.id, id))
.run()
return getPolicySet(db, id)
}
export function deletePolicySet(db: Db, id: string) {
if (id === SHARED_POLICY_SET_ID) {
throw new Error('cannot delete shared default set')
}
db.delete(policySets).where(eq(policySets.id, id)).run()
}
export function countRulesInSet(db: Db, setId: string): number {
const row = db
.select({ n: count() })
.from(policyRules)
.where(eq(policyRules.setId, setId))
.get()
return row?.n ?? 0
}
export function countAgentsForSet(db: Db, setId: string): number {
const row = db
.select({ n: count() })
.from(agentPolicySets)
.where(eq(agentPolicySets.setId, setId))
.get()
return row?.n ?? 0
}
/** Rule + agent counts for many sets in two queries. */
export function countRulesAndAgentsBySetIds(
db: Db,
setIds: string[],
): Map<string, { rules: number; agents: number }> {
const map = new Map<string, { rules: number; agents: number }>()
for (const id of setIds) map.set(id, { rules: 0, agents: 0 })
if (setIds.length === 0) return map
const ruleRows = db
.select({ setId: policyRules.setId, n: count() })
.from(policyRules)
.where(inArray(policyRules.setId, setIds))
.groupBy(policyRules.setId)
.all()
for (const r of ruleRows) {
const cur = map.get(r.setId) ?? { rules: 0, agents: 0 }
cur.rules = r.n
map.set(r.setId, cur)
}
const agentRows = db
.select({ setId: agentPolicySets.setId, n: count() })
.from(agentPolicySets)
.where(inArray(agentPolicySets.setId, setIds))
.groupBy(agentPolicySets.setId)
.all()
for (const r of agentRows) {
const cur = map.get(r.setId) ?? { rules: 0, agents: 0 }
cur.agents = r.n
map.set(r.setId, cur)
}
return map
}
export function listAgentIdsForSet(db: Db, setId: string): string[] {
return db
.select({ agentId: agentPolicySets.agentId })
.from(agentPolicySets)
.where(eq(agentPolicySets.setId, setId))
.all()
.map((r) => r.agentId)
}
export function listSetsForAgent(db: Db, agentId: string) {
return db
.select({
setId: agentPolicySets.setId,
sort: agentPolicySets.sort,
name: policySets.name,
description: policySets.description,
enabled: policySets.enabled,
policyMode: policySets.policyMode,
})
.from(agentPolicySets)
.innerJoin(policySets, eq(agentPolicySets.setId, policySets.id))
.where(eq(agentPolicySets.agentId, agentId))
.orderBy(asc(agentPolicySets.sort), asc(policySets.name))
.all()
}
/** Replace agent↔set assignments; set_ids order = sort. */
export function setAgentPolicySets(db: Db, agentId: string, setIds: string[]) {
for (const setId of setIds) {
if (!getPolicySet(db, setId)) {
throw new Error(`policy set not found: ${setId}`)
}
}
db.delete(agentPolicySets).where(eq(agentPolicySets.agentId, agentId)).run()
setIds.forEach((setId, i) => {
db.insert(agentPolicySets)
.values({ agentId, setId, sort: i * 10 })
.run()
})
bumpAgentGeneration(db, agentId)
}
export function ensureSharedSetAssigned(db: Db, agentId: string) {
const existing = db
.select()
.from(agentPolicySets)
.where(
and(
eq(agentPolicySets.agentId, agentId),
eq(agentPolicySets.setId, SHARED_POLICY_SET_ID),
),
)
.get()
if (existing) return
db.insert(agentPolicySets)
.values({ agentId, setId: SHARED_POLICY_SET_ID, sort: 0 })
.run()
}
/* ── Policy rules ── */
export function listPolicyRules(db: Db, setId?: string) {
if (setId) {
return db
.select()
.from(policyRules)
.where(eq(policyRules.setId, setId))
.orderBy(asc(policyRules.priority))
.all()
}
return db.select().from(policyRules).orderBy(asc(policyRules.priority)).all()
}
export function listPolicyRulesForAgent(db: Db, agentId: string) {
const assignments = listSetsForAgent(db, agentId).filter((s) => s.enabled === 1)
if (assignments.length === 0) return []
const setIds = assignments.map((a) => a.setId)
const sortBySet = new Map(assignments.map((a) => [a.setId, a.sort]))
const rules = db
.select()
.from(policyRules)
.where(inArray(policyRules.setId, setIds))
.all()
return rules
.filter((r) => r.enabled !== 0)
.map((r) => ({
...r,
_setSort: sortBySet.get(r.setId) ?? 0,
}))
.sort((a, b) => a._setSort - b._setSort || a.priority - b.priority)
}
export function getPolicyRule(db: Db, id: string) {
return db.select().from(policyRules).where(eq(policyRules.id, id)).get()
}
export function insertPolicyRule(
db: Db,
row: typeof policyRules.$inferInsert,
) {
db.insert(policyRules).values(row).run()
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) {
db.delete(policyRules).where(eq(policyRules.id, id)).run()
}
export function listHostnameRules(db: Db) {
return db
.select()
.from(policyRules)
.where(sql`${policyRules.hostname} IS NOT NULL AND trim(${policyRules.hostname}) != ''`)
.all()
}
export function listResolvedForRule(db: Db, ruleId: string) {
return db
.select()
.from(policyRuleResolved)
.where(eq(policyRuleResolved.ruleId, ruleId))
.all()
}
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()
}
export function listOverrides(db: Db, agentId: string) {
return db
.select()
.from(ipOverrides)
.where(eq(ipOverrides.agentId, agentId))
.all()
}
export function insertOverride(
db: Db,
row: typeof ipOverrides.$inferInsert,
) {
db.insert(ipOverrides).values(row).run()
return db.select().from(ipOverrides).where(eq(ipOverrides.id, row.id)).get()
}
export function deleteOverride(db: Db, id: string) {
db.delete(ipOverrides).where(eq(ipOverrides.id, id)).run()
}
/** Clone set assignments (+ optional overrides) and policy mode from source agent. */
export function cloneRulesFrom(
db: Db,
sourceAgentId: string,
targetAgentId: string,
includeOverrides: boolean,
) {
const source = getAgent(db, sourceAgentId)
const target = getAgent(db, targetAgentId)
if (!source || !target) return null
const sourceSets = listSetsForAgent(db, sourceAgentId)
setAgentPolicySets(
db,
targetAgentId,
sourceSets.map((s) => s.setId),
)
if (includeOverrides) {
db.delete(ipOverrides).where(eq(ipOverrides.agentId, targetAgentId)).run()
for (const o of listOverrides(db, sourceAgentId)) {
db.insert(ipOverrides)
.values({
id: crypto.randomUUID(),
agentId: targetAgentId,
cidr: o.cidr,
action: o.action,
comment: o.comment,
createdAt: new Date().toISOString(),
})
.run()
}
}
updateAgent(db, targetAgentId, {
defaultAction: source.defaultAction,
policyGeneration: (target.policyGeneration ?? 1) + 1,
})
return getAgent(db, targetAgentId)
}
+25
View File
@@ -0,0 +1,25 @@
import { eq } from 'drizzle-orm'
import type { Db } from '../client.js'
import { settings } from '../schema.js'
export function getSetting(db: Db, key: string): string {
const row = db.select().from(settings).where(eq(settings.key, key)).get()
return row?.value ?? ''
}
export function setSetting(db: Db, key: string, value: string) {
const now = new Date().toISOString()
const existing = db.select().from(settings).where(eq(settings.key, key)).get()
if (existing) {
db.update(settings)
.set({ value, updatedAt: now })
.where(eq(settings.key, key))
.run()
} else {
db.insert(settings).values({ key, value, updatedAt: now }).run()
}
}
export function listSettings(db: Db) {
return db.select().from(settings).all()
}
+35
View File
@@ -0,0 +1,35 @@
import { eq, desc } from 'drizzle-orm'
import type { Db } from '../client.js'
import { agentStatsSamples } from '../schema.js'
export function insertStatsSample(
db: Db,
row: typeof agentStatsSamples.$inferInsert,
) {
db.insert(agentStatsSamples).values(row).run()
}
export function listStatsSamples(db: Db, agentId: string, limit = 100) {
return db
.select()
.from(agentStatsSamples)
.where(eq(agentStatsSamples.agentId, agentId))
.orderBy(desc(agentStatsSamples.recordedAt))
.limit(limit)
.all()
}
export function listRecentStats(db: Db, limit = 500) {
return db
.select()
.from(agentStatsSamples)
.orderBy(desc(agentStatsSamples.recordedAt))
.limit(limit)
.all()
}
export function deleteStatsSamplesForAgent(db: Db, agentId: string) {
db.delete(agentStatsSamples)
.where(eq(agentStatsSamples.agentId, agentId))
.run()
}
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { putSettingsBodySchema } from './contracts.js'
describe('putSettingsBodySchema', () => {
it('accepts whitelisted keys', () => {
const parsed = putSettingsBodySchema.parse({
enroll_seed: 'x',
show_quick_actions: 'true',
})
expect(parsed.show_quick_actions).toBe('true')
})
it('rejects unknown keys', () => {
expect(() =>
putSettingsBodySchema.parse({ evil_key: 'nope' }),
).toThrow()
})
})
+24
View File
@@ -320,6 +320,29 @@ export const evobgpCommunitySchema = z.object({
title: z.string().nullable().optional(),
})
/** Whitelist keys for PUT /api/v1/settings. */
export const SETTINGS_KEYS = [
'enroll_seed',
'evobgp_api_url',
'evobgp_api_token',
'agent_sync_interval_sec',
'show_quick_actions',
] as const
export const putSettingsBodySchema = z
.record(z.string(), z.string())
.superRefine((obj, ctx) => {
for (const key of Object.keys(obj)) {
if (!(SETTINGS_KEYS as readonly string[]).includes(key)) {
ctx.addIssue({
code: 'custom',
message: `Unknown settings key: ${key}`,
path: [key],
})
}
}
})
export type Agent = z.infer<typeof agentSchema>
export type IpList = z.infer<typeof ipListSchema>
export type PolicyRule = z.infer<typeof policyRuleSchema>
@@ -331,3 +354,4 @@ export type DashboardStats = z.infer<typeof dashboardStatsSchema>
export type InstallLink = z.infer<typeof installLinkSchema>
export type EvobgpCommunity = z.infer<typeof evobgpCommunitySchema>
export type DefaultAction = z.infer<typeof defaultActionSchema>
export type PutSettingsBody = z.infer<typeof putSettingsBodySchema>
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { permissionForRequest } from '@evofw/shared'
describe('permissionForRequest', () => {
it('maps install-links to agents permissions', () => {
expect(permissionForRequest('GET', '/api/v1/install-links')).toBe(
'fw:agents:read',
)
expect(permissionForRequest('POST', '/api/v1/install-links')).toBe(
'fw:agents:write',
)
})
it('maps integrations to lists read / settings admin', () => {
expect(
permissionForRequest('GET', '/api/v1/integrations/evobgp/communities'),
).toBe('fw:lists:read')
expect(
permissionForRequest('POST', '/api/v1/integrations/evobgp/refresh'),
).toBe('fw:settings:admin')
})
})
+4 -1
View File
@@ -29,7 +29,7 @@ export function permissionForRequest(
const m = method.toUpperCase()
const write = m !== 'GET' && m !== 'HEAD' && m !== 'OPTIONS'
if (path.startsWith('/api/v1/agents')) {
if (path.startsWith('/api/v1/agents') || path.startsWith('/api/v1/install-links')) {
return write ? 'fw:agents:write' : 'fw:agents:read'
}
if (path.startsWith('/api/v1/lists')) {
@@ -45,6 +45,9 @@ export function permissionForRequest(
if (path.startsWith('/api/v1/stats') || path.startsWith('/api/v1/dashboard')) {
return 'fw:stats:read'
}
if (path.startsWith('/api/v1/integrations')) {
return write ? 'fw:settings:admin' : 'fw:lists:read'
}
if (path.startsWith('/api/v1/settings') || path.startsWith('/api/v1/install-context')) {
return write ? 'fw:settings:admin' : 'fw:settings:read'
}
+13
View File
@@ -0,0 +1,13 @@
# See https://redocly.com/docs/cli/configuration/
extends:
- recommended
rules:
no-unused-components: off
security-defined: warn
operation-4xx-response: off
operation-operationId: off
tag-description: off
info-license: off
operation-summary: warn
no-server-example.com: off