设置页加「运行并发」一节:全站同时运行的上限,交互、应用、定时、系统四类各自的上限和优先级,旁边写各类现在在执行、在等名额的个数(/system 的 cluster.runs);管理员可改,搭建者只读,自己读写 concurrency 一组、带离开前确认(同评测那一节)

This commit is contained in:
ThearchyHelios
2026-10-19 11:40:53 +08:00
parent 87f0d62f9b
commit f2134753a4
2 changed files with 148 additions and 0 deletions
+145
View File
@@ -0,0 +1,145 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client'
import { Field, Notice, SectionBar, Spinner, confirmDialog } from '../components/ui'
import { errorMessage } from '../lib/errors'
import { useLeaveGuard } from '../lib/leave'
import { CONCURRENCY_CLASSES, CONCURRENCY_TEXT } from '../lib/termsConcurrency'
import { atLeast, usePrincipal } from '../store/auth'
const T = CONCURRENCY_TEXT
type Klass = (typeof CONCURRENCY_CLASSES)[number]
interface Draft { total: string; limits: Record<Klass, string>; priorities: Record<Klass, string> }
type Now = Partial<Record<Klass, { running?: number; waiting?: number }>>
const draftOf = (group: Record<string, any> | undefined): Draft => ({
total: group?.max_total == null ? '' : String(group.max_total),
limits: Object.fromEntries(CONCURRENCY_CLASSES.map((k) => [k, String(group?.classes?.[k]?.limit ?? '')])) as Record<Klass, string>,
priorities: Object.fromEntries(CONCURRENCY_CLASSES.map((k) => [k, String(group?.classes?.[k]?.priority ?? '')])) as Record<Klass, string>,
})
const changedCount = (a: Draft, b: Draft): number =>
(a.total.trim() !== b.total ? 1 : 0)
+ CONCURRENCY_CLASSES.filter((k) => a.limits[k].trim() !== b.limits[k]).length
+ CONCURRENCY_CLASSES.filter((k) => a.priorities[k].trim() !== b.priorities[k]).length
/**
* 设置页「偏好设置」里「运行并发」一节(docs/design/orchestration-round §5 W2-b):全站总上限,交互、应用、定时、系统四类各自的
* 上限和优先级,旁边写各类现在在跑、在等几个(/system 的 cluster.runs)。
*
* 自己读、自己存(PUT /settings 只带 concurrency 一组),带自己的离开前确认,同评测那一节的做法。搭建者只读、管理员写
*/
export function ConcurrencySettingsSection() {
const me = usePrincipal()
const admin = atLeast(me, 'admin')
const [saved, setSaved] = useState<Draft | null>(null)
const [draft, setDraft] = useState<Draft | null>(null)
const [now, setNow] = useState<Now | null>(null)
const [loadError, setLoadError] = useState<unknown>(null)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [flash, setFlash] = useState(false)
const loadNow = () => api.system().then((s) => setNow(s?.cluster?.runs?.classes ?? null), () => setNow(null))
useEffect(() => {
api.settings.get().then((v) => {
setSaved(draftOf(v?.concurrency))
setLoadError(null)
}, setLoadError)
void loadNow()
}, [])
const cur = draft ?? saved
const changed = !!(cur && saved) && changedCount(cur, saved) > 0
useLeaveGuard(changed && !saving, () => confirmDialog({
title: T.leaveTitle(cur && saved ? changedCount(cur, saved) : 0),
consequences: T.leaveConsequences, confirmLabel: T.discard, danger: true,
}))
const save = async () => {
if (!cur || !changed) return
setSaving(true)
setError(null)
try {
const value = {
max_total: Number(cur.total.trim()),
classes: Object.fromEntries(CONCURRENCY_CLASSES.map((k) => [k, {
limit: Number(cur.limits[k].trim()), priority: Number(cur.priorities[k].trim()),
}])),
}
const out = await api.settings.put({ concurrency: value })
setSaved(draftOf(out?.concurrency ?? value))
setDraft(null)
setFlash(true)
setTimeout(() => setFlash(false), 1600)
void loadNow()
} catch (e) {
setError(errorMessage(e))
} finally {
setSaving(false)
}
}
if (loadError || !cur) return null
const edit = (patch: Partial<Draft>) => {
setError(null)
setDraft({ ...cur, ...patch, limits: { ...cur.limits, ...(patch.limits ?? {}) },
priorities: { ...cur.priorities, ...(patch.priorities ?? {}) } })
}
return (
<section data-concurrency-settings="">
<SectionBar title={T.title} hint={T.hint} />
<fieldset disabled={!admin || saving} className="m-0 min-w-0 space-y-3 border-0 p-0">
<Field label={T.maxTotal} htmlFor="pref-concurrency-total">
{(p) => (
<input {...p} className="field tnum max-w-40" inputMode="numeric" value={cur.total}
onChange={(e) => edit({ total: e.target.value })} data-concurrency-setting="max_total" />
)}
</Field>
<div className="space-y-2">
{CONCURRENCY_CLASSES.map((k) => {
const info = T.classes[k]
const live = now?.[k]
return (
<div key={k} className="grid grid-cols-[8rem_1fr_1fr] items-end gap-3 max-sm:grid-cols-1" data-concurrency-class={k}>
<div className="text-xs">
<div>{info.label}</div>
<div className="text-2xs leading-relaxed text-faint">{info.hint}</div>
{live && <div className="text-2xs text-dim" data-concurrency-now={k}>{T.now(live.running ?? 0, live.waiting ?? 0)}</div>}
</div>
<Field label={T.limit} htmlFor={`pref-concurrency-${k}-limit`}>
{(p) => (
<input {...p} className="field tnum max-w-32" inputMode="numeric" value={cur.limits[k]}
onChange={(e) => edit({ limits: { [k]: e.target.value } as Record<Klass, string> })}
data-concurrency-setting={`${k}.limit`} />
)}
</Field>
<Field label={T.priority} htmlFor={`pref-concurrency-${k}-priority`} hint={k === 'interactive' ? T.priorityHint : undefined}>
{(p) => (
<input {...p} className="field tnum max-w-32" inputMode="numeric" value={cur.priorities[k]}
onChange={(e) => edit({ priorities: { [k]: e.target.value } as Record<Klass, string> })}
data-concurrency-setting={`${k}.priority`} />
)}
</Field>
</div>
)
})}
</div>
</fieldset>
{!admin && <p className="mt-2 text-2xs text-faint">{T.readOnly}</p>}
{error && <Notice tone="err" className="mt-2" attr={{ 'data-concurrency-settings-error': '' }}>{error}</Notice>}
{admin && (changed || flash) && (
<div className="mt-3 flex items-center justify-end gap-2" data-concurrency-settings-bar="">
{flash && !changed && <span className="text-2xs text-[var(--st-done)]">{T.saved}</span>}
{changed && (
<>
<button type="button" className="btn btn-sm" onClick={() => { setDraft(null); setError(null) }} disabled={saving}>{T.discard}</button>
<button type="button" className="btn btn-primary btn-sm" onClick={() => void save()} disabled={saving} data-concurrency-settings-save="">
{saving && <Spinner size={11} />} {saving ? T.saving : T.save}
</button>
</>
)}
</div>
)}
</section>
)
}
+3
View File
@@ -34,6 +34,7 @@ import {
} from './VerifySettings'
import { DraftModelSection } from './DraftModelSettings'
import { EvalSettingsSection } from './EvalSettings'
import { ConcurrencySettingsSection } from './ConcurrencySettings'
import { GeneralTab } from './settings/GeneralTab'
import { IdentityTab } from './settings/IdentityTab'
import { NotifyTab } from './settings/NotifyTab'
@@ -1204,6 +1205,8 @@ function PrefsTab() {
{values.eval && <EvalSettingsSection />}
{values.concurrency && <ConcurrencySettingsSection />}
{values.ledger && <LedgerSection cur={ledgerCur} errors={ledgerErrs} onEdit={editLedger} />}
{values.catalog_draft && (