feat(billing): implement tenant subscription entitlements system (milestones 0-6)
Some checks failed
ci / ui (push) Failing after 28s
ci / rust (push) Failing after 2m40s
images / build-and-push (push) Failing after 19s

This commit is contained in:
2026-03-30 18:41:23 +03:00
parent 5992044b7e
commit 2595e7f1c5
63 changed files with 8448 additions and 321 deletions

View File

@@ -26,6 +26,48 @@ async function apiJson<T>(path: string): Promise<T> {
}
}
async function apiJsonWithHeaders<T>(path: string, extra: HeadersInit): Promise<T> {
const controller = new AbortController()
const t = window.setTimeout(() => controller.abort(), 5000)
const token = getAccessToken()
const headers: HeadersInit = { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...extra }
try {
const res = await apiFetch(`${baseUrl()}${path}`, {
headers,
signal: controller.signal,
useLastCorrelationId: true,
useLastTraceparent: true,
})
return (await res.json()) as T
} finally {
window.clearTimeout(t)
}
}
async function apiFetchWithHeaders(path: string, init: RequestInit, extra: Record<string, string>) {
const controller = new AbortController()
const t = window.setTimeout(() => controller.abort(), 15000)
const token = getAccessToken()
const headers = new Headers(init.headers)
if (token) headers.set('authorization', `Bearer ${token}`)
for (const [k, v] of Object.entries(extra)) headers.set(k, v)
try {
return await apiFetch(`${baseUrl()}${path}`, {
...init,
headers,
signal: controller.signal,
useLastCorrelationId: true,
useLastTraceparent: true,
})
} finally {
window.clearTimeout(t)
}
}
async function apiPostJson<T>(path: string, body: unknown, idempotencyKey?: string): Promise<T> {
const controller = new AbortController()
const t = window.setTimeout(() => controller.abort(), 2000)
@@ -100,6 +142,65 @@ export function getFleetSnapshot(): Promise<FleetSnapshot> {
return apiJson('/admin/v1/fleet/snapshot')
}
export type DriftKind = 'missing' | 'extra' | 'unhealthy' | 'version_mismatch'
export type DriftResponse = {
summary: Record<string, number>
items: Array<{ kind: DriftKind; service: string; details: unknown }>
}
export function getPlatformDrift(): Promise<DriftResponse> {
return apiJson('/admin/v1/platform/drift')
}
export type ConfigDomain = 'routing' | 'placement'
export type ConfigGetResponse = {
domain: ConfigDomain
revision: number
source: unknown
value: unknown
}
export function listConfigDomains(): Promise<{ domains: ConfigDomain[] }> {
return apiJson('/admin/v1/config')
}
export function getConfig(domain: ConfigDomain): Promise<ConfigGetResponse> {
return apiJson(`/admin/v1/config/${domain}`)
}
export function startConfigValidateJob(args: {
domain: ConfigDomain
reason: string
value: unknown
idempotencyKey: string
}): Promise<{ job_id: string }> {
return apiPostJson('/admin/v1/jobs/config/validate', { domain: args.domain, reason: args.reason, value: args.value }, args.idempotencyKey)
}
export function startConfigApplyJob(args: {
domain: ConfigDomain
reason: string
expectedRevision?: number
value: unknown
idempotencyKey: string
}): Promise<{ job_id: string }> {
return apiPostJson(
'/admin/v1/jobs/config/apply',
{ domain: args.domain, reason: args.reason, expected_revision: args.expectedRevision, value: args.value },
args.idempotencyKey,
)
}
export function startConfigRollbackJob(args: {
domain: ConfigDomain
reason: string
idempotencyKey: string
}): Promise<{ job_id: string }> {
return apiPostJson('/admin/v1/jobs/config/rollback', { domain: args.domain, reason: args.reason }, args.idempotencyKey)
}
export function getPlacement(kind: 'aggregate' | 'projection' | 'runner'): Promise<PlacementResponse> {
return apiJson(`/admin/v1/placement/${kind}`)
}
@@ -177,3 +278,111 @@ export function getSwarmServices(): Promise<{ services: SwarmService[] }> {
export function getSwarmTasks(serviceName: string): Promise<{ service: string; tasks: SwarmTask[] }> {
return apiJson(`/admin/v1/swarm/services/${encodeURIComponent(serviceName)}/tasks`)
}
export type DocumentObject = {
key: string
size: number
last_modified?: string | null
}
export function listDocuments(args: { tenantId: string; prefix?: string }): Promise<{ objects: DocumentObject[] }> {
const qs = args.prefix ? `?prefix=${encodeURIComponent(args.prefix)}` : ''
return apiJsonWithHeaders(`/admin/v1/tenants/${encodeURIComponent(args.tenantId)}/docs${qs}`, {
'x-tenant-id': args.tenantId,
})
}
export async function uploadDocument(args: {
tenantId: string
docType: string
docId: string
filename: string
file: File
}): Promise<{ key: string; sha256: string }> {
const path = `/admin/v1/tenants/${encodeURIComponent(args.tenantId)}/docs/${encodeURIComponent(
args.docType,
)}/${encodeURIComponent(args.docId)}/${encodeURIComponent(args.filename)}`
const res = await apiFetchWithHeaders(
path,
{
method: 'PUT',
headers: { 'content-type': args.file.type || 'application/octet-stream' },
body: args.file,
},
{ 'x-tenant-id': args.tenantId },
)
return (await res.json()) as { key: string; sha256: string }
}
export async function downloadDocument(args: { tenantId: string; key: string }): Promise<Blob> {
const res = await apiFetchWithHeaders(
`/admin/v1/tenants/${encodeURIComponent(args.tenantId)}/docs/object/${encodeURIComponent(args.key)}`,
{ method: 'GET' },
{ 'x-tenant-id': args.tenantId },
)
return await res.blob()
}
export async function deleteDocument(args: { tenantId: string; key: string }): Promise<void> {
await apiFetchWithHeaders(
`/admin/v1/tenants/${encodeURIComponent(args.tenantId)}/docs/object/${encodeURIComponent(args.key)}`,
{ method: 'DELETE' },
{ 'x-tenant-id': args.tenantId },
)
}
export type PresignResponse = {
method: 'PUT' | 'GET'
url: string
key: string
}
export function presignUpload(args: {
tenantId: string
docType: string
docId?: string
filename: string
contentType?: string
}): Promise<PresignResponse> {
return apiPostJsonWithTenant(`/admin/v1/tenants/${encodeURIComponent(args.tenantId)}/docs/presign/upload`, args.tenantId, {
doc_type: args.docType,
doc_id: args.docId,
filename: args.filename,
content_type: args.contentType,
})
}
export function presignDownload(args: { tenantId: string; key: string }): Promise<PresignResponse> {
return apiPostJsonWithTenant(
`/admin/v1/tenants/${encodeURIComponent(args.tenantId)}/docs/presign/download`,
args.tenantId,
{ key: args.key },
)
}
async function apiPostJsonWithTenant<T>(path: string, tenantId: string, body: unknown): Promise<T> {
const controller = new AbortController()
const t = window.setTimeout(() => controller.abort(), 5000)
const token = getAccessToken()
const headers: HeadersInit = {
'content-type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
'x-tenant-id': tenantId,
}
try {
const res = await apiFetch(`${baseUrl()}${path}`, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: controller.signal,
useLastCorrelationId: true,
useLastTraceparent: true,
})
return (await res.json()) as T
} finally {
window.clearTimeout(t)
}
}

View File

@@ -16,9 +16,11 @@ const navItems: NavItem[] = [
{ label: 'Roles & Permissions', to: '/roles-permissions' },
{ label: 'Config', to: '/config' },
{ label: 'Definitions', to: '/definitions' },
{ label: 'Documents', to: '/documents' },
{ label: 'Scale & Placement', to: '/scale-placement' },
{ label: 'Deployments', to: '/deployments' },
{ label: 'Observability', to: '/observability' },
{ label: 'Platform Drift', to: '/drift' },
{ label: 'Audit Log', to: '/audit-log' },
{ label: 'Settings', to: '/settings' },
]

View File

@@ -15,9 +15,11 @@ const paths = [
'/roles-permissions',
'/config',
'/definitions',
'/documents',
'/scale-placement',
'/deployments',
'/observability',
'/drift',
'/audit-log',
'/settings',
]

View File

@@ -6,10 +6,12 @@ import {
DefinitionsPage,
DeploymentDetailPage,
DeploymentsPage,
DocumentsPage,
JobPage,
NotFoundPage,
ObservabilityPage,
OverviewPage,
PlatformDriftPage,
RolesPermissionsPage,
ScalePlacementPage,
SessionsPage,
@@ -30,10 +32,12 @@ export const routes: RouteObject[] = [
{ path: 'roles-permissions', element: <RolesPermissionsPage /> },
{ path: 'config', element: <ConfigPage /> },
{ path: 'definitions', element: <DefinitionsPage /> },
{ path: 'documents', element: <DocumentsPage /> },
{ path: 'scale-placement', element: <ScalePlacementPage /> },
{ path: 'deployments', element: <DeploymentsPage /> },
{ path: 'deployments/:serviceName', element: <DeploymentDetailPage /> },
{ path: 'observability', element: <ObservabilityPage /> },
{ path: 'drift', element: <PlatformDriftPage /> },
{ path: 'audit-log', element: <AuditLogPage /> },
{ path: 'jobs/:jobId', element: <JobPage /> },
{ path: 'settings', element: <SettingsPage /> },

View File

@@ -9,6 +9,18 @@ import {
listAudit,
getSwarmServices,
getSwarmTasks,
listConfigDomains,
getConfig,
startConfigValidateJob,
startConfigApplyJob,
startConfigRollbackJob,
getPlatformDrift,
listDocuments,
uploadDocument,
downloadDocument,
deleteDocument,
presignUpload,
presignDownload,
startTenantDrainJob,
startTenantMigrateJob,
type FleetSnapshot,
@@ -18,6 +30,10 @@ import {
type AuditEvent,
type SwarmService,
type SwarmTask,
type DocumentObject,
type ConfigDomain,
type ConfigGetResponse,
type DriftResponse,
} from './api/control'
import { getAccessToken, setAccessToken } from './auth/token'
import { Button, Code, ErrorText, Modal, MutedText, Table, TextInput } from './components/primitives'
@@ -226,13 +242,443 @@ export function RolesPermissionsPage() {
}
export function ConfigPage() {
return <PageShell title="Config" />
const [domains, setDomains] = useState<ConfigDomain[] | undefined>(undefined)
const [selected, setSelected] = useState<ConfigDomain>('routing')
const [cfg, setCfg] = useState<ConfigGetResponse | undefined>(undefined)
const [draft, setDraft] = useState('')
const [reason, setReason] = useState('')
const [error, setError] = useState<string | undefined>(undefined)
const [busy, setBusy] = useState(false)
const navigate = useNavigate()
function newIdempotencyKey() {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
return `${Date.now()}-${Math.random().toString(16).slice(2)}`
}
useEffect(() => {
let cancelled = false
listConfigDomains()
.then((d) => {
if (cancelled) return
setDomains(d.domains)
if (d.domains.length > 0 && !d.domains.includes(selected)) {
setSelected(d.domains[0] ?? 'routing')
}
})
.catch((e: unknown) => {
if (cancelled) return
setError(e instanceof Error ? e.message : 'failed to load domains')
})
return () => {
cancelled = true
}
}, [])
async function refresh(domain: ConfigDomain) {
setBusy(true)
try {
const c = await getConfig(domain)
setCfg(c)
setDraft(JSON.stringify(c.value ?? null, null, 2))
setError(undefined)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'failed to load config')
} finally {
setBusy(false)
}
}
useEffect(() => {
void refresh(selected)
}, [selected])
return (
<PageShell title="Config">
{error ? <ErrorText>{error}</ErrorText> : null}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 980 }}>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label htmlFor="domain" style={{ fontSize: 12, color: '#666' }}>
Domain
</label>
<select
id="domain"
value={selected}
onChange={(e) => setSelected(e.target.value as ConfigDomain)}
style={{ padding: '8px 10px', borderRadius: 10, border: '1px solid #ddd' }}
disabled={!domains || domains.length === 0}
>
{(domains ?? ['routing', 'placement']).map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
</div>
<Button onClick={() => void refresh(selected)} disabled={busy}>
Refresh
</Button>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 320 }}>
<label htmlFor="reason" style={{ fontSize: 12, color: '#666' }}>
Reason (required for jobs)
</label>
<TextInput id="reason" value={reason} onChange={setReason} placeholder="why are you changing this?" />
</div>
</div>
<MutedText>
Current revision: <Code>{String(cfg?.revision ?? '')}</Code>
</MutedText>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label htmlFor="json" style={{ fontSize: 12, color: '#666' }}>
JSON
</label>
<textarea
id="json"
value={draft}
onChange={(e) => setDraft(e.target.value)}
spellCheck={false}
style={{
width: '100%',
minHeight: 340,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: 12,
borderRadius: 12,
border: '1px solid #ddd',
padding: 12,
}}
/>
</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<Button
disabled={busy || reason.trim().length === 0}
onClick={async () => {
setBusy(true)
try {
const value = JSON.parse(draft || 'null') as unknown
const job = await startConfigValidateJob({
domain: selected,
reason,
value,
idempotencyKey: newIdempotencyKey(),
})
navigate(`/jobs/${job.job_id}`)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'validate failed')
} finally {
setBusy(false)
}
}}
>
Validate
</Button>
<Button
disabled={busy || reason.trim().length === 0}
onClick={async () => {
setBusy(true)
try {
const value = JSON.parse(draft || 'null') as unknown
const job = await startConfigApplyJob({
domain: selected,
reason,
expectedRevision: cfg?.revision,
value,
idempotencyKey: newIdempotencyKey(),
})
navigate(`/jobs/${job.job_id}`)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'apply failed')
} finally {
setBusy(false)
}
}}
>
Apply
</Button>
<Button
disabled={busy || reason.trim().length === 0}
onClick={async () => {
setBusy(true)
try {
const job = await startConfigRollbackJob({
domain: selected,
reason,
idempotencyKey: newIdempotencyKey(),
})
navigate(`/jobs/${job.job_id}`)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'rollback failed')
} finally {
setBusy(false)
}
}}
>
Rollback (to last backup)
</Button>
</div>
</div>
</PageShell>
)
}
export function DefinitionsPage() {
return <PageShell title="Definitions" />
}
export function DocumentsPage() {
const [tenantId, setTenantId] = useState('')
const [docType, setDocType] = useState('deployments')
const [docId, setDocId] = useState('')
const [prefix, setPrefix] = useState('')
const [file, setFile] = useState<File | undefined>(undefined)
const [objects, setObjects] = useState<DocumentObject[] | undefined>(undefined)
const [error, setError] = useState<string | undefined>(undefined)
const [busy, setBusy] = useState(false)
const [confirmDelete, setConfirmDelete] = useState<{ key: string } | undefined>(undefined)
const [usePresign, setUsePresign] = useState(false)
function newId(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
return `${Date.now()}-${Math.random().toString(16).slice(2)}`
}
async function refresh() {
const tid = tenantId.trim()
if (!tid) return
setBusy(true)
try {
const d = await listDocuments({ tenantId: tid, prefix: prefix.trim() || undefined })
setObjects(d.objects)
setError(undefined)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'failed to load')
} finally {
setBusy(false)
}
}
return (
<PageShell title="Documents">
{error ? <ErrorText>{error}</ErrorText> : null}
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, maxWidth: 880 }}>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label htmlFor="tenantId" style={{ fontSize: 12, color: '#666' }}>
Tenant ID
</label>
<TextInput id="tenantId" value={tenantId} onChange={setTenantId} placeholder="uuid" />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label htmlFor="prefix" style={{ fontSize: 12, color: '#666' }}>
Prefix (optional)
</label>
<TextInput
id="prefix"
value={prefix}
onChange={setPrefix}
placeholder="e.g. deployments/"
/>
</div>
<Button onClick={refresh} disabled={busy || !tenantId.trim()}>
Refresh
</Button>
</div>
<div style={{ borderTop: '1px solid #eee', paddingTop: 12 }} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label htmlFor="docType" style={{ fontSize: 12, color: '#666' }}>
Doc type
</label>
<TextInput id="docType" value={docType} onChange={setDocType} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label htmlFor="docId" style={{ fontSize: 12, color: '#666' }}>
Doc id
</label>
<TextInput id="docId" value={docId} onChange={setDocId} placeholder="auto" />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label htmlFor="file" style={{ fontSize: 12, color: '#666' }}>
File
</label>
<input
id="file"
type="file"
onChange={(e) => {
const f = e.target.files?.item(0) ?? undefined
setFile(f)
}}
/>
</div>
<Button
disabled={busy || !tenantId.trim() || !docType.trim() || !file}
onClick={async () => {
const tid = tenantId.trim()
if (!tid || !file) return
setBusy(true)
try {
const id = docId.trim() || newId()
if (!usePresign) {
await uploadDocument({
tenantId: tid,
docType: docType.trim(),
docId: id,
filename: file.name,
file,
})
} else {
const p = await presignUpload({
tenantId: tid,
docType: docType.trim(),
docId: id,
filename: file.name,
contentType: file.type || 'application/octet-stream',
})
await fetch(p.url, {
method: 'PUT',
headers: { 'content-type': file.type || 'application/octet-stream' },
body: file,
})
}
setDocId(id)
await refresh()
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'upload failed')
} finally {
setBusy(false)
}
}}
>
{usePresign ? 'Upload (presigned)' : 'Upload'}
</Button>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
id="usePresign"
type="checkbox"
checked={usePresign}
onChange={(e) => setUsePresign(e.target.checked)}
/>
<label htmlFor="usePresign" style={{ fontSize: 12, color: '#666' }}>
Use presigned URLs (recommended for large files)
</label>
</div>
<MutedText>
Documents are stored under <Code>docs/&lt;tenant&gt;/&lt;type&gt;/&lt;id&gt;/&lt;filename&gt;</Code>.
</MutedText>
</div>
<div style={{ borderTop: '1px solid #eee', paddingTop: 12 }} />
{!objects ? <div>{tenantId.trim() ? 'No data loaded.' : 'Enter a tenant id to list documents.'}</div> : null}
{objects ? (
<Table
columns={['Key', 'Size', 'Last Modified', 'Actions']}
rows={objects.map((o) => [
<Code key="k">{o.key}</Code>,
String(o.size ?? 0),
o.last_modified ?? '',
<div key="a" style={{ display: 'flex', gap: 8 }}>
<Button
onClick={async () => {
const tid = tenantId.trim()
if (!tid) return
setBusy(true)
try {
const blob = !usePresign
? await downloadDocument({ tenantId: tid, key: o.key })
: await (async () => {
const p = await presignDownload({ tenantId: tid, key: o.key })
const res = await fetch(p.url, { method: 'GET' })
return await res.blob()
})()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const name = o.key.split('/').slice(-1)[0] ?? 'download'
a.download = name
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
} finally {
setBusy(false)
}
}}
disabled={busy}
>
{usePresign ? 'Download (presigned)' : 'Download'}
</Button>
<Button
disabled={busy}
onClick={() => {
setConfirmDelete({ key: o.key })
}}
>
Delete
</Button>
</div>,
])}
/>
) : null}
</div>
<Modal
open={!!confirmDelete}
title="Confirm delete"
onClose={() => setConfirmDelete(undefined)}
footer={
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<Button onClick={() => setConfirmDelete(undefined)} disabled={busy}>
Cancel
</Button>
<Button
disabled={busy || !tenantId.trim() || !confirmDelete}
onClick={async () => {
const tid = tenantId.trim()
const k = confirmDelete?.key
if (!tid || !k) return
setBusy(true)
try {
await deleteDocument({ tenantId: tid, key: k })
setConfirmDelete(undefined)
await refresh()
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'delete failed')
} finally {
setBusy(false)
}
}}
>
Delete permanently
</Button>
</div>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<MutedText>
Tenant: <Code>{tenantId.trim() || '(unset)'}</Code>
</MutedText>
<MutedText>
Key: <Code>{confirmDelete?.key}</Code>
</MutedText>
</div>
</Modal>
</PageShell>
)
}
export function ScalePlacementPage() {
const [aggregate, setAggregate] = useState<PlacementResponse | undefined>(undefined)
const [projection, setProjection] = useState<PlacementResponse | undefined>(undefined)
@@ -332,6 +778,53 @@ export function ObservabilityPage() {
return <PageShell title="Observability" />
}
export function PlatformDriftPage() {
const [data, setData] = useState<DriftResponse | undefined>(undefined)
const [error, setError] = useState<string | undefined>(undefined)
useEffect(() => {
let cancelled = false
getPlatformDrift()
.then((d) => {
if (cancelled) return
setError(undefined)
setData(d)
})
.catch((e: unknown) => {
if (cancelled) return
setError(e instanceof Error ? e.message : 'failed to load')
})
return () => {
cancelled = true
}
}, [])
return (
<PageShell title="Platform Drift">
{error ? <ErrorText>{error}</ErrorText> : null}
{!data ? <div>Loading</div> : null}
{data ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Table
columns={['Kind', 'Count']}
rows={Object.entries(data.summary ?? {}).map(([k, v]) => [k, String(v)])}
/>
<Table
columns={['Kind', 'Service', 'Details']}
rows={data.items.map((i, idx) => [
i.kind,
<Code key={`svc-${idx}`}>{i.service}</Code>,
<pre key={`d-${idx}`} style={{ margin: 0, fontSize: 12, overflowX: 'auto' }}>
{JSON.stringify(i.details, null, 2)}
</pre>,
])}
/>
</div>
) : null}
</PageShell>
)
}
export function AuditLogPage() {
const [data, setData] = useState<AuditEvent[] | undefined>(undefined)
const [error, setError] = useState<string | undefined>(undefined)