feat(billing): implement tenant subscription entitlements system (milestones 0-6)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
@@ -15,9 +15,11 @@ const paths = [
|
||||
'/roles-permissions',
|
||||
'/config',
|
||||
'/definitions',
|
||||
'/documents',
|
||||
'/scale-placement',
|
||||
'/deployments',
|
||||
'/observability',
|
||||
'/drift',
|
||||
'/audit-log',
|
||||
'/settings',
|
||||
]
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
@@ -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/<tenant>/<type>/<id>/<filename></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)
|
||||
|
||||
Reference in New Issue
Block a user