feat: org capacity policy admin surface

ADR-0022 / Spec.System.Capacity pins layered capacity limits: a platform
ceiling per dimension is unbreakable, and each organization may only set a
lower `organizationLimit`. The effective limit is the minimum of the two
(`LayeredLimit.effective`); dimensions with no org override fall back to
the platform ceiling. No dimension may be unlimited (a ceiling must exist
before an org limit can be set, `LayeredLimit.Valid`).

Add the backend: the pinned 23 CapacityDimension set + labels
(src/capacity/dimensions.ts), platform ceilings sourced from existing
runtime env vars plus `HUB_CEILING_<DIMENSION>` (src/capacity/ceilings.ts),
the OrganizationCapacityPolicy prisma model + migration, the
getCapacityPolicy/setCapacityPolicy service enforcing LayeredLimit.Valid,
and org-admin GET/PUT /api/org/:orgSlug/capacity-policy routes wired into
the org route tree.

Add the admin-web surface: CapacityDimension/CapacityPolicyView api client
types, capacityPolicy/setCapacityPolicy methods, a `容量` nav entry, and a
capacity page that lists every dimension with its platform ceiling (or
`未配置` when unset), an org-limit input (disabled until a ceiling exists),
and a live effective-value column. Saving sends the partial limits map;
the service rejects values above the ceiling or for unconfigured dimensions.
This commit is contained in:
2026-07-14 21:23:41 +08:00
parent adce8fb6f5
commit ab9dfad53a
10 changed files with 545 additions and 0 deletions
+41
View File
@@ -188,6 +188,42 @@ export interface UsageReport {
totals: UsageTotals;
}
export type CapacityDimension =
| 'requestRate'
| 'requestBodySize'
| 'agentConcurrency'
| 'admissionQueueLength'
| 'admissionQueueWait'
| 'fileSize'
| 'attachmentCount'
| 'archiveExpansion'
| 'projectStorage'
| 'organizationStorage'
| 'memberCount'
| 'projectCount'
| 'teamCount'
| 'folderCount'
| 'sessionCount'
| 'runWallTime'
| 'runTurns'
| 'runToolCalls'
| 'toolWallTime'
| 'runOutputSize'
| 'processMemory'
| 'processCpu'
| 'processCount';
export interface CapacityDimensionRow {
dimension: CapacityDimension;
platformCeiling: number | null;
organizationLimit: number | null;
effective: number | null;
}
export interface CapacityPolicyView {
dimensions: CapacityDimensionRow[];
}
// --- API ---
export const api = {
@@ -297,4 +333,9 @@ export const api = {
) => put(`${orgBase(slug)}/feishu-application-connection`, body) as Promise<FeishuApplicationConnection>,
disableFeishuApplication: (slug: string) =>
del(`${orgBase(slug)}/feishu-application-connection`) as Promise<FeishuApplicationConnection>,
capacityPolicy: (slug: string) =>
get(`${orgBase(slug)}/capacity-policy`) as Promise<CapacityPolicyView>,
setCapacityPolicy: (slug: string, body: { limits: Partial<Record<CapacityDimension, number | null>> }) =>
put(`${orgBase(slug)}/capacity-policy`, body) as Promise<CapacityPolicyView>,
};
+1
View File
@@ -23,6 +23,7 @@
{ key: 'members', label: '成员', icon: 'members' as const },
{ key: 'teams', label: '团队', icon: 'teams' as const },
{ key: 'projects', label: '项目', icon: 'projects' as const },
{ key: 'capacity', label: '容量', icon: 'overview' as const },
{ key: 'provider', label: '供应方', icon: 'provider' as const },
{ key: 'feishu', label: '飞书', icon: 'feishu' as const },
];
@@ -0,0 +1,158 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type CapacityDimension, type CapacityPolicyView } from '$lib/api';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
const DIMENSION_LABELS: Record<CapacityDimension, string> = {
requestRate: 'HTTP 请求速率',
requestBodySize: '请求体大小',
agentConcurrency: '智能体并发',
admissionQueueLength: '接纳队列长度',
admissionQueueWait: '接纳队列等待',
fileSize: '单文件大小',
attachmentCount: '每消息附件数',
archiveExpansion: '归档展开',
projectStorage: '项目存储',
organizationStorage: '组织存储',
memberCount: '成员数',
projectCount: '项目数',
teamCount: '团队数',
folderCount: '文件夹数',
sessionCount: '会话数',
runWallTime: '单次运行墙钟',
runTurns: '单次运行轮次',
runToolCalls: '单次运行工具调用',
toolWallTime: '工具墙钟',
runOutputSize: '运行输出大小',
processMemory: '进程内存',
processCpu: '进程 CPU',
processCount: '进程数',
};
let view = $state<CapacityPolicyView | null>(null);
let drafts = $state<Partial<Record<CapacityDimension, string>>>({});
let loading = $state(true);
let saving = $state(false);
let error = $state<string | null>(null);
async function load() {
loading = true;
error = null;
try {
view = await api.capacityPolicy(slug);
drafts = {};
for (const row of view.dimensions) {
drafts[row.dimension] = row.organizationLimit === null ? '' : String(row.organizationLimit);
}
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function save() {
if (!view) return;
saving = true;
const limits: Partial<Record<CapacityDimension, number | null>> = {};
for (const row of view.dimensions) {
const raw = drafts[row.dimension];
if (raw === undefined || raw.trim() === '') {
limits[row.dimension] = null;
} else {
const n = Number.parseInt(raw.trim(), 10);
limits[row.dimension] = Number.isFinite(n) ? n : null;
}
}
try {
view = await api.setCapacityPolicy(slug, { limits });
drafts = {};
for (const row of view.dimensions) {
drafts[row.dimension] = row.organizationLimit === null ? '' : String(row.organizationLimit);
}
toastSuccess('容量策略已保存');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader title="容量策略" description="平台上限不可突破;组织可设更低限制(取两者较低值)。">
{#snippet actions()}
<button class="saas-btn-primary" disabled={saving || !view} onclick={save}>
{saving ? '保存中…' : '保存'}
</button>
{/snippet}
</PageHeader>
<p class="saas-muted mb-4">
未配置平台上限的维度暂不可设置组织限制(见 ADR-0022 / <code>LayeredLimit.Valid</code>)。
</p>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else if view}
{#if view.dimensions.length === 0}
<EmptyState title="暂无容量维度" description="容量维度由 spec 定义。" />
{:else}
<div class="saas-card overflow-hidden">
<table class="data-table">
<thead>
<tr>
<th>维度</th>
<th>平台上限</th>
<th>组织限制</th>
<th>有效值</th>
</tr>
</thead>
<tbody>
{#each view.dimensions as row}
<tr>
<td class="font-medium">{DIMENSION_LABELS[row.dimension] ?? row.dimension}</td>
<td class="tabular-nums">
{#if row.platformCeiling === null}
<span class="text-surface-500">未配置</span>
{:else}
{row.platformCeiling}
{/if}
</td>
<td>
<input
class="saas-input w-32"
type="number"
min="1"
placeholder="(用平台上限)"
disabled={row.platformCeiling === null}
bind:value={drafts[row.dimension]}
/>
</td>
<td class="tabular-nums text-surface-700">
{row.platformCeiling === null
? '—'
: drafts[row.dimension] && drafts[row.dimension] !== ''
? Math.min(row.platformCeiling, Number.parseInt(drafts[row.dimension]!, 10) || row.platformCeiling)
: row.platformCeiling}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{:else}
<EmptyState title="容量数据不可用" description="无法加载容量策略。" />
{/if}