feat(api, web): enhance agent management and linting capabilities
- 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:
@@ -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) |
|
||||
|
||||
## Чеклист
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
@@ -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' },
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+19
-1067
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: "[email protected]",
|
||||
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",
|
||||
},
|
||||
]
|
||||
-428
@@ -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 })
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Project surface lock — ops screens use ReUI Frame only. */
|
||||
export const UI_SURFACE = 'frame' as const
|
||||
@@ -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} />
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user