forked from bai/curriculum-project-hub
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:
@@ -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>,
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user