Files
curriculum-project-hub/hub/admin-web/src/routes/admin/capacity/+page.svelte
T
hongjr03 7f09fb1f13 feat(hub): drop redundant /admin/org/:slug path + release v0.0.35 (#11)
Silo hostname already carries tenancy. Admin SPA routes become /admin/..., legacy bookmarks redirect, login lands on /admin.

Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-19 01:36:10 +08:00

314 lines
9.5 KiB
Svelte

<script lang="ts">
import { page } from '$app/state';
import { api, type CapacityDimension, type CapacityDimensionRow, type CapacityPolicyView } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
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 org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
// Friendlier, user-facing labels. No spec jargon (墙钟 → 运行时长, etc.).
const DIMENSION_LABELS: Record<CapacityDimension, string> = {
requestRate: '请求速率',
requestBodySize: '请求体大小',
agentConcurrency: '并发数',
admissionQueueLength: '队列长度',
admissionQueueWait: '队列等待',
fileSize: '单文件大小',
attachmentCount: '附件数',
archiveExpansion: '归档展开',
projectStorage: '项目存储',
organizationStorage: '组织存储',
memberCount: '成员数',
projectCount: '项目数',
teamCount: '团队数',
folderCount: '文件夹数',
sessionCount: '会话数',
runWallTime: '运行时长',
runTurns: '对话轮次',
runToolCalls: '工具调用数',
toolWallTime: '工具执行时长',
runOutputSize: '输出大小',
processMemory: '内存',
processCpu: 'CPU',
processCount: '进程数',
};
// Logical groupings for higher information density.
const GROUPS: { title: string; dims: CapacityDimension[] }[] = [
{ title: 'HTTP 接入', dims: ['requestRate', 'requestBodySize'] },
{ title: '智能体运行', dims: ['agentConcurrency', 'runWallTime', 'runTurns', 'runToolCalls', 'toolWallTime', 'runOutputSize'] },
{ title: '接纳队列', dims: ['admissionQueueLength', 'admissionQueueWait'] },
{ title: '附件与存储', dims: ['fileSize', 'attachmentCount', 'archiveExpansion', 'projectStorage', 'organizationStorage'] },
{ title: '组织配额', dims: ['memberCount', 'projectCount', 'teamCount', 'folderCount', 'sessionCount'] },
{ title: '进程资源', dims: ['processMemory', 'processCpu', 'processCount'] },
];
let view = $state<CapacityPolicyView | null>(null);
let drafts = $state<Partial<Record<CapacityDimension, string | number>>>({});
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;
}
}
function draftText(dim: CapacityDimension): string {
const raw = drafts[dim];
if (raw === undefined || raw === null) return '';
return String(raw).trim();
}
// Inline validation: surfaced as the user edits, not on submit. Returns null
// when the draft is empty (means "use platform ceiling") or valid.
function draftError(row: CapacityDimensionRow): string | null {
const text = draftText(row.dimension);
if (text === '') return null;
const n = Number.parseInt(text, 10);
if (!Number.isFinite(n) || n < 1) return '需为正整数';
if (row.platformCeiling !== null && n > row.platformCeiling) return `不得超过 ${row.platformCeiling}`;
return null;
}
// Live effective value: min(platform ceiling, draft). Reflects the draft
// before save so the user sees the outcome as they type.
function liveEffective(row: CapacityDimensionRow): number | null {
if (row.platformCeiling === null) return null;
const text = draftText(row.dimension);
if (text === '') return row.platformCeiling;
const n = Number.parseInt(text, 10);
if (!Number.isFinite(n) || n < 1) return row.platformCeiling;
return Math.min(row.platformCeiling, n);
}
function isLowered(row: CapacityDimensionRow): boolean {
const eff = liveEffective(row);
return eff !== null && eff < row.platformCeiling!;
}
const hasErrors = $derived(
view?.dimensions.some((row) => draftError(row) !== null) ?? false,
);
async function save() {
if (!view || hasErrors) return;
saving = true;
const limits: Partial<Record<CapacityDimension, number | null>> = {};
for (const row of view.dimensions) {
const text = draftText(row.dimension);
limits[row.dimension] = text === '' ? null : Number.parseInt(text, 10);
}
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 || hasErrors} onclick={save}>
{saving ? '保存中…' : '保存'}
</button>
{/snippet}
</PageHeader>
<p class="saas-muted mb-6">
未配置平台上限的维度暂不可设置组织限制。输入框留空即沿用平台上限。
</p>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else if view}
{#if view.dimensions.length === 0}
<EmptyState title="暂无容量维度" description="容量维度由平台定义。" />
{:else}
{@const policy = view}
<div class="grid gap-4 md:grid-cols-2">
{#each GROUPS as group}
{@const rows = group.dims
.map((d) => policy.dimensions.find((r) => r.dimension === d))
.filter((r): r is CapacityDimensionRow => r !== undefined)}
{#if rows.length > 0}
<section class="saas-card">
<header class="group-header">
<h3 class="group-title">{group.title}</h3>
<span class="group-count">{rows.length}</span>
</header>
<div class="dim-list">
{#each rows as row (row.dimension)}
{@const err = draftError(row)}
{@const eff = liveEffective(row)}
{@const lowered = isLowered(row)}
{@const disabled = row.platformCeiling === null}
<div class="dim-row" class:dim-row-error={err !== null}>
<div class="dim-label">{DIMENSION_LABELS[row.dimension] ?? row.dimension}</div>
<div class="dim-ceiling">
{#if disabled}
<span class="text-surface-400">未配置</span>
{:else}
<span class="tabular-nums">平台 ≤ {row.platformCeiling}</span>
{/if}
</div>
<input
class="saas-input dim-input"
type="number"
placeholder="用平台值"
disabled={disabled}
bind:value={drafts[row.dimension]}
/>
<div class="dim-effective">
{#if eff === null}
<span class="text-surface-400"></span>
{:else if lowered}
<span class="saas-badge saas-badge-primary tabular-nums">有效 {eff}</span>
{:else}
<span class="saas-badge saas-badge-neutral tabular-nums">有效 {eff}</span>
{/if}
</div>
{#if err !== null}
<div class="dim-error">{err}</div>
{/if}
</div>
{/each}
</div>
</section>
{/if}
{/each}
</div>
{/if}
{:else}
<EmptyState title="容量数据不可用" description="无法加载容量策略。" />
{/if}
<style>
.group-header {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-surface-200);
background: var(--color-surface-100);
}
.group-title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-surface-800);
letter-spacing: 0.02em;
}
.group-count {
font-size: 0.6875rem;
color: var(--color-surface-500);
}
.dim-list {
display: flex;
flex-direction: column;
}
.dim-row {
display: grid;
grid-template-columns: minmax(5rem, 1fr) auto minmax(7rem, 8.5rem) 6.5rem;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
border-bottom: 1px solid var(--color-surface-100);
}
.dim-row:last-child {
border-bottom: none;
}
.dim-row-error {
background: color-mix(in oklab, var(--color-error-100) 40%, transparent);
}
.dim-label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-surface-900);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dim-ceiling {
font-size: 0.6875rem;
color: var(--color-surface-500);
white-space: nowrap;
}
.dim-input {
padding: 0.25rem 0.5rem;
font-size: 0.8125rem;
text-align: right;
}
.dim-effective {
display: flex;
justify-content: flex-end;
}
.dim-effective > .saas-badge {
width: 100%;
justify-content: center;
}
.dim-error {
grid-column: 1 / -1;
font-size: 0.6875rem;
color: var(--color-error-700);
padding-bottom: 0.2rem;
}
@media (max-width: 480px) {
.dim-row {
grid-template-columns: 1fr 5rem;
grid-template-rows: auto auto auto;
row-gap: 0.25rem;
}
.dim-label {
grid-column: 1;
}
.dim-ceiling {
grid-column: 2;
text-align: right;
}
.dim-input {
grid-column: 1 / -1;
}
.dim-effective {
grid-column: 1 / -1;
justify-content: flex-start;
}
.dim-effective > .saas-badge {
width: auto;
justify-content: flex-start;
}
}
</style>