feat(web): 系统设置页编辑并校验服务配置
- 系统设置页新增服务配置表单(生产/测试地址、租户 ID、知识库 ID),保存时回显校验结果与失败原因 - 同步前端类型定义与接口调用,补齐设置相关前端用例
This commit is contained in:
@@ -118,8 +118,10 @@ describe('ModulePage', () => {
|
||||
expect(html).toContain('治理工作台')
|
||||
expect(html).toContain('P2 · 知识治理总览')
|
||||
expect(html).toContain('PP历史来源清单已接管')
|
||||
expect(html).toContain('本地测试WeKnora已连接')
|
||||
expect(html).toContain('生产WeKnora仍未连接')
|
||||
expect(html).toContain('生产写入:关闭')
|
||||
expect(html).toContain('以「系统设置 → 服务配置」的实时探测为准')
|
||||
expect(html).not.toContain('本地测试WeKnora已连接')
|
||||
expect(html).not.toContain('生产WeKnora仍未连接')
|
||||
expect(html).not.toContain('尚未连接测试或生产WeKnora')
|
||||
expect(html).toContain('任务概览')
|
||||
expect(html).toContain('Release Worker 默认关闭')
|
||||
|
||||
+1
-1
@@ -284,7 +284,7 @@ export function ModulePage({ moduleId, jobs, loading, error, onRefresh, connecti
|
||||
<section className="safety-strip">
|
||||
<div>
|
||||
<strong>运行安全</strong>
|
||||
<span>PP历史来源清单已接管;本地测试WeKnora已连接,生产WeKnora仍未连接。</span>
|
||||
<span>PP历史来源清单已接管;生产写入:{releaseCapabilities.production_release_requests_enabled ? (releaseCapabilities.production_backup_restore_verified ? '已开启' : '已开启(快照恢复未验证,仍被阻断)') : '关闭'};测试 / 生产 WeKnora 连接状态以「系统设置 → 服务配置」的实时探测为准。</span>
|
||||
</div>
|
||||
<span className="safe-badge">Release Worker 默认关闭</span>
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe,expect,it,vi } from 'vitest'
|
||||
import { SystemSettingsView, visibleSaveMessage } from './SystemSettings'
|
||||
import { AuthContext } from './auth'
|
||||
import type { MonitorOverview } from './monitor'
|
||||
import type { CatalogSource } from './sourceCatalog'
|
||||
import type { UISettings } from './uiSettings'
|
||||
@@ -8,13 +9,28 @@ import type { SourceConnectionListItem } from './sourceConnections'
|
||||
const monitor:MonitorOverview={mode:'simulation',simulated:true,observed_at:'2026-09-08T12:00:00Z',host:{cpu_percent:42,memory_used_bytes:1,memory_total_bytes:2,disk_used_bytes:1,disk_total_bytes:2,gpu_state:'unknown',gpu_detail:'未接入真实GPU监控',simulated:true},services:[{key:'mineru',name:'MinerU',state:'healthy',detail:'模拟',latency_ms:1,observed_at:'2026-09-08T12:00:00Z',simulated:true}],knowledge_bases:[],health:[{key:'mineru',name:'MinerU',liveness:'up',readiness:'blocked',reason:'服务可达但容量已满(429),暂时不可接单',latency_ms:30,observed_at:'2026-09-08T12:00:00Z',last_success_at:'2026-09-08T11:59:00Z',consecutive_failures:2,configured:true,applicable:true}]}
|
||||
const sources:CatalogSource[]=[{connection_id:'ding-1',source_type:'dingtalk',name:'公司级技术支持知识库',root_node_id:'root-1',folder_count:1079,file_count:3444,observed_at:'2026-09-09T09:30:32Z',snapshot_only:true,snapshot_status:'ready',last_refresh_duration_ms:0}]
|
||||
const connections:SourceConnectionListItem[]=[{id:'conn-ding',source_type:'dingtalk',name:'公司级技术支持知识库',enabled:true,schedule_configured:true,health_status:'healthy',item_count:3444,updated_at:'2026-09-09T09:30:32Z',next_refresh_at:'2026-09-12T10:00:00Z'},{id:'conn-man',source_type:'manual',name:'本地手工验收',enabled:false,schedule_configured:false,health_status:'unknown',item_count:5,updated_at:'2026-09-09T09:30:32Z',next_refresh_at:null}]
|
||||
const settings:UISettings={id:'settings-default',state:'active',document_refresh_seconds:15,resource_refresh_seconds:30,stalled_after_seconds:1800,finalizing_after_seconds:7200,ingestion_concurrency:2,updated_by:'system:migration',created_at:'2026-09-10T08:00:00Z',activated_at:'2026-09-10T08:00:00Z',retired_at:null,error_code:''}
|
||||
const settings:UISettings={id:'settings-default',state:'active',document_refresh_seconds:15,resource_refresh_seconds:30,stalled_after_seconds:1800,finalizing_after_seconds:7200,ingestion_concurrency:2,test_weknora_base_url:'',production_weknora_base_url:'',production_tenant_id:'',production_knowledge_base_id:'',updated_by:'system:migration',created_at:'2026-09-10T08:00:00Z',activated_at:'2026-09-10T08:00:00Z',retired_at:null,error_code:''}
|
||||
const authValue=(role:'admin'|'engineer')=>({identity:{id:`${role}-1`,username:role,display_name:role,role,must_change_password:false},ready:true,login:async()=>null,logout:async()=>{},changePassword:async()=>null,signedOut:0,notifySignedOut:()=>{}})
|
||||
describe('SystemSettingsView service configuration',()=>{
|
||||
it('lets an administrator edit exactly the four service fields in the web console',()=>{
|
||||
const configured:UISettings={...settings,test_weknora_base_url:'https://weknora.test.example',production_weknora_base_url:'https://weknora.prod.example',production_tenant_id:'10000',production_knowledge_base_id:'kb-abc123'}
|
||||
const view=(role:'admin'|'engineer')=>renderToStaticMarkup(<AuthContext.Provider value={authValue(role)}><SystemSettingsView sources={sources} connections={connections} monitor={monitor} settings={configured} productionEnabled={false} routingCapabilities={{internal_enabled:true,external_enabled:true}} loading={false} saving={false} error={null} saveMessage={null} onSave={vi.fn()} onSourcesChanged={vi.fn()} onRefresh={vi.fn()} initialSection="services" /></AuthContext.Provider>)
|
||||
const adminHtml=view('admin')
|
||||
for(const text of ['测试地址','生产地址','生产 ID','知识库 ID','保存并启用服务配置','https://weknora.test.example','https://weknora.prod.example','10000','kb-abc123','四项留空即表示未配置'])expect(adminHtml).toContain(text)
|
||||
expect(adminHtml).not.toContain('仅管理员可修改')
|
||||
// 界面只剩这四项:依赖探活矩阵与对内/对外绑定下拉不再出现在服务配置页。
|
||||
for(const removed of ['MinerU','Embedding','Redis / MQ','PostgreSQL','测试知识库映射'])expect(adminHtml).not.toContain(removed)
|
||||
const engineerHtml=view('engineer')
|
||||
expect(engineerHtml).toContain('仅管理员可修改')
|
||||
expect(engineerHtml).not.toContain('保存并启用服务配置')
|
||||
})
|
||||
})
|
||||
describe('SystemSettingsView',()=>{
|
||||
it('separates document-engineer settings from locked administrator and production controls',()=>{
|
||||
const html=renderToStaticMarkup(<SystemSettingsView sources={sources} connections={connections} monitor={monitor} settings={settings} productionEnabled={false} routingCapabilities={{internal_enabled:true,external_enabled:true}} loading={false} saving={false} error={null} saveMessage="刷新与告警参数已验证并启用:文档15秒、资源30秒、停滞30分钟、finalizing 2小时" onSave={vi.fn()} onSourcesChanged={vi.fn()} onRefresh={vi.fn()} />)
|
||||
for(const text of ['系统设置','数据源配置','服务配置','存储与备份','刷新策略','设置服务已启用','当前活动版本','公司级技术支持知识库','钉钉知识库','认证方式','端点与凭据','文档工程师可调整','管理员设置已锁定'])expect(html).toContain(text)
|
||||
const servicesHtml=renderToStaticMarkup(<SystemSettingsView sources={sources} connections={connections} monitor={monitor} settings={settings} productionEnabled={false} routingCapabilities={{internal_enabled:true,external_enabled:true}} loading={false} saving={false} error={null} saveMessage={null} onSave={vi.fn()} onSourcesChanged={vi.fn()} onRefresh={vi.fn()} initialSection="services" />)
|
||||
for(const text of ['测试 WeKnora','生产 WeKnora','MinerU','Embedding'])expect(servicesHtml).toContain(text)
|
||||
for(const text of ['测试 WeKnora','生产 WeKnora','测试地址','生产地址','生产 ID','知识库 ID'])expect(servicesHtml).toContain(text)
|
||||
const storageHtml=renderToStaticMarkup(<SystemSettingsView sources={sources} connections={connections} monitor={monitor} settings={settings} productionEnabled={false} routingCapabilities={{internal_enabled:true,external_enabled:true}} loading={false} saving={false} error={null} saveMessage={null} onSave={vi.fn()} onSourcesChanged={vi.fn()} onRefresh={vi.fn()} initialSection="storage" />)
|
||||
expect(storageHtml).toContain('生产发布请求')
|
||||
expect(storageHtml).toContain('仅保存凭据引用,不可回读')
|
||||
@@ -23,13 +39,10 @@ describe('SystemSettingsView',()=>{
|
||||
expect(visibleSaveMessage('已保存',true)).toBeNull();expect(visibleSaveMessage('已保存',false)).toBe('已保存')
|
||||
expect(html).not.toContain('本地模拟');expect(html).not.toContain('保存模拟设置')
|
||||
for(const forbidden of ['DATABASE_URL','RELEASE_CONFIG_FILE','API Key明文','secret_ref'])expect(html).not.toContain(forbidden)
|
||||
// P10 dual-state matrix: reachable-but-not-workable must be explicit (services section).
|
||||
expect(servicesHtml).toContain('可达 · 不可接单')
|
||||
expect(servicesHtml).toContain('服务可达但容量已满(429)')
|
||||
expect(servicesHtml).toContain('连续失败2次')
|
||||
expect(servicesHtml).toContain('PostgreSQL')
|
||||
expect(servicesHtml).toContain('对内解析队列:已配置')
|
||||
expect(servicesHtml).toContain('对外解析队列:已配置')
|
||||
// 两个环境卡仍由真实探测驱动(依赖矩阵搬到总览页,本页不再渲染)。
|
||||
expect(servicesHtml).toContain('解析队列 对内 已配置 · 对外 已配置')
|
||||
expect(servicesHtml).not.toContain('PostgreSQL')
|
||||
expect(servicesHtml).toContain('端点探测:暂无真实探测结果')
|
||||
// V1: left-nav section controls, per-source schedule editing, danger modal & history affordances.
|
||||
expect(html).toContain('settings-nav')
|
||||
expect(html).toContain('下次自动刷新')
|
||||
@@ -46,4 +59,28 @@ describe('SystemSettingsView',()=>{
|
||||
const html=renderToStaticMarkup(<SystemSettingsView sources={[]} connections={[]} monitor={null} settings={null} productionEnabled={false} routingCapabilities={{internal_enabled:false,external_enabled:false}} loading={false} saving={false} error="加载失败" saveMessage={null} onSave={vi.fn()} onSourcesChanged={vi.fn()} onRefresh={vi.fn()} />)
|
||||
expect(html).toContain('加载失败');expect(html).not.toContain('模拟运行中')
|
||||
})
|
||||
it('renders the WeKnora cards and production channel from live probe and the real production gate',()=>{
|
||||
const probed:MonitorOverview={...monitor,health:[
|
||||
{key:'weknora',name:'WeKnora API',liveness:'up',readiness:'ready',reason:'',latency_ms:86,observed_at:'2026-09-08T12:00:00Z',last_success_at:'2026-09-08T11:59:00Z',consecutive_failures:0,configured:true,applicable:true},
|
||||
{key:'weknora-production',name:'生产 WeKnora',liveness:'up',readiness:'ready',reason:'',latency_ms:41,observed_at:'2026-09-08T12:00:00Z',last_success_at:'2026-09-08T11:59:00Z',consecutive_failures:0,configured:true,applicable:true},
|
||||
{key:'embedding',name:'Embedding',liveness:'up',readiness:'not_applicable',reason:'未部署:本平台不使用该依赖',latency_ms:0,observed_at:'2026-09-08T12:00:00Z',last_success_at:null,consecutive_failures:0,configured:false,applicable:false}]}
|
||||
const view=(enabled:boolean,verified:boolean)=>renderToStaticMarkup(<SystemSettingsView sources={sources} connections={connections} monitor={probed} settings={settings} productionEnabled={enabled} productionBackupRestoreVerified={verified} routingCapabilities={{internal_enabled:true,external_enabled:true}} loading={false} saving={false} error={null} saveMessage={null} onSave={vi.fn()} onSourcesChanged={vi.fn()} onRefresh={vi.fn()} initialSection="services" />)
|
||||
const on=view(true,true)
|
||||
expect(on).toContain('测试 WeKnora')
|
||||
expect(on).toContain('可达 · 可接单');expect(on).toContain('延迟 86ms')
|
||||
expect(on).toContain('已启用 · 可提交生产请求');expect(on).toContain('生产写入:开启');expect(on).toContain('发布目标与凭据:管理员受限配置')
|
||||
const off=view(false,false)
|
||||
expect(off).toContain('未启用');expect(off).toContain('生产写入:关闭')
|
||||
// 卡片状态由真实探测/门禁驱动,写死的“已连接 / 未连接 / 生产映射:未配置”必须消失。
|
||||
for(const removed of ['本地真实连接','生产映射:未配置'])expect(off).not.toContain(removed)
|
||||
// 生产端点可达性来自真实探活,且只说真话:探活不等于验证过接单能力。
|
||||
expect(on).toContain('端点探测:端点可达 · 未做接单验证')
|
||||
const blocked=renderToStaticMarkup(<SystemSettingsView sources={sources} connections={connections} monitor={monitor} settings={settings} productionEnabled={true} productionBackupRestoreVerified={false} routingCapabilities={{internal_enabled:false,external_enabled:false}} loading={false} saving={false} error={null} saveMessage={null} onSave={vi.fn()} onSourcesChanged={vi.fn()} onRefresh={vi.fn()} initialSection="services" />)
|
||||
expect(blocked).toContain('已启用 · 被阻断')
|
||||
// 未配置生产端点时必须显式说「未配置」,不得靠文案含糊过去。
|
||||
expect(blocked).toContain('端点探测:暂无真实探测结果')
|
||||
const unconfigured=renderToStaticMarkup(<SystemSettingsView sources={sources} connections={connections} monitor={{...monitor,health:[{key:'weknora-production',name:'生产 WeKnora',liveness:'unknown',readiness:'not_configured',reason:'已部署但未配置探测参数',latency_ms:0,observed_at:'2026-09-08T12:00:00Z',last_success_at:null,consecutive_failures:0,configured:false,applicable:false}]}} settings={settings} productionEnabled={false} routingCapabilities={{internal_enabled:false,external_enabled:false}} loading={false} saving={false} error={null} saveMessage={null} onSave={vi.fn()} onSourcesChanged={vi.fn()} onRefresh={vi.fn()} initialSection="services" />)
|
||||
expect(unconfigured).toContain('端点探测:未配置')
|
||||
expect(unconfigured).not.toContain('端点可达')
|
||||
})
|
||||
})
|
||||
|
||||
+96
-12
@@ -4,7 +4,6 @@ import { MembersSection } from './MembersSection'
|
||||
import { DingtalkSessionControl } from './DingtalkSessionControl'
|
||||
import { DingtalkSpaceRegistrar } from './DingtalkSpaceRegistrar'
|
||||
import { FilenameFilterEditor } from './FilenameFilterEditor'
|
||||
import { RoutingBindingsSection } from './RoutingBindingsSection'
|
||||
import { useOptionalAuth } from './auth'
|
||||
import { fetchMonitorOverview } from './monitor'
|
||||
import type { MonitorOverview } from './monitor'
|
||||
@@ -13,7 +12,7 @@ import { fetchCatalogRoutingCapabilities, fetchCatalogSources } from './sourceCa
|
||||
import type { CatalogRoutingCapabilities, CatalogSource } from './sourceCatalog'
|
||||
import { activateSourceSettingsRevision, createSourceSettingsRevision, fetchSourceConnectionList, fetchSourceSettingsRevisions } from './sourceConnections'
|
||||
import type { SourceConnectionListItem, SourceSettingsRevision } from './sourceConnections'
|
||||
import { activateUISettings, createUISettingsCandidate, fetchActiveUISettings } from './uiSettings'
|
||||
import { activateUISettings, createUISettingsCandidate, fetchActiveUISettings, validKnowledgeBaseID, validProductionTenantID, validServiceEndpoint } from './uiSettings'
|
||||
import type { UISettings, UISettingsCandidateInput } from './uiSettings'
|
||||
import './systemSettings.css'
|
||||
|
||||
@@ -23,6 +22,7 @@ interface Props {
|
||||
monitor: MonitorOverview | null
|
||||
settings: UISettings | null
|
||||
productionEnabled: boolean
|
||||
productionBackupRestoreVerified?: boolean
|
||||
routingCapabilities: CatalogRoutingCapabilities
|
||||
loading: boolean
|
||||
saving: boolean
|
||||
@@ -49,6 +49,42 @@ const sourceAuthLabels: Record<string, string> = { dingtalk: '专用浏览器扫
|
||||
const secondsLabel = (value: number) => value < 60 ? `${value}秒` : value < 3600 ? `${value / 60}分钟` : `${value / 3600}小时`
|
||||
export const visibleSaveMessage = (message: string | null, dirty: boolean) => dirty ? null : message
|
||||
|
||||
type ServiceHealthView = MonitorOverview['health'][number]
|
||||
type ServiceView = MonitorOverview['services'][number]
|
||||
|
||||
// 服务配置只保留四项可写值(测试地址、生产地址、生产 ID、知识库 ID),
|
||||
// 依赖与解析队列的状态仍按真实探测呈现,不写死。
|
||||
const findHealth = (monitor: MonitorOverview | null, name: string): ServiceHealthView | undefined => (monitor?.health || []).find((item) => item.name === name || item.name.startsWith(name))
|
||||
export const healthStateLabel = (health: ServiceHealthView | undefined, service: ServiceView | undefined): string => {
|
||||
if (!health) {
|
||||
if (!service) return '待配置'
|
||||
return service.state === 'healthy' ? '正常' : service.state === 'unknown' ? '未接入' : '需关注'
|
||||
}
|
||||
if (health.readiness === 'ready') return '可达 · 可接单'
|
||||
if (health.readiness === 'not_configured') return '状态未知 · 未配置'
|
||||
if (health.readiness === 'not_applicable') return '未部署'
|
||||
return health.liveness === 'up' ? '可达 · 不可接单' : '不可达 · 不可接单'
|
||||
}
|
||||
export const healthStateDetail = (health: ServiceHealthView | undefined, service: ServiceView | undefined): string => {
|
||||
if (!health) return service?.detail || '真实 Provider 未接入'
|
||||
const success = health.last_success_at ? `最近成功 ${new Date(health.last_success_at).toLocaleTimeString('zh-CN', { hour12: false })}` : ''
|
||||
if (health.readiness === 'ready') return [`延迟 ${health.latency_ms}ms`, success].filter(Boolean).join(';')
|
||||
const failures = health.consecutive_failures ? `连续失败${health.consecutive_failures}次` : ''
|
||||
return [health.reason, success, failures].filter(Boolean).join(';')
|
||||
}
|
||||
|
||||
// 生产 WeKnora 卡片:端点可达性来自真实探测,写入门禁来自生产开关,两者分开呈现。
|
||||
// 生产侧只做探活(/health),不做一次真实读写,所以接单能力永远是「未做接单验证」,
|
||||
// 不跟着别处的「可达 · 可接单」一起含糊过去。
|
||||
export const productionWeKnoraProbe = (monitor: MonitorOverview | null): string => {
|
||||
const health = findHealth(monitor, '生产 WeKnora')
|
||||
if (!health) return '端点探测:暂无真实探测结果'
|
||||
if (!health.configured) return '端点探测:未配置'
|
||||
const label = health.liveness === 'up' ? (health.readiness === 'ready' ? '端点可达 · 未做接单验证' : '端点可达 · 不可接单') : '端点不可达'
|
||||
const detail = healthStateDetail(health, undefined)
|
||||
return `端点探测:${label}${detail ? `(${detail})` : ''}`
|
||||
}
|
||||
|
||||
const intervalChoices: { value: number; label: string }[] = [
|
||||
{ value: 0, label: '仅手动刷新' },
|
||||
{ value: 3600, label: '每小时' },
|
||||
@@ -138,7 +174,7 @@ function SourceSettingsCard({ connection, onSourcesChanged }: { connection: Sour
|
||||
</article>
|
||||
}
|
||||
|
||||
export function SystemSettingsView({ sources, connections, monitor, settings, productionEnabled, routingCapabilities, loading, saving, error, saveMessage, onSave, onSourcesChanged, onRefresh, initialSection = 'sources' }: Props) {
|
||||
export function SystemSettingsView({ sources, connections, monitor, settings, productionEnabled, productionBackupRestoreVerified = false, routingCapabilities, loading, saving, error, saveMessage, onSave, onSourcesChanged, onRefresh, initialSection = 'sources' }: Props) {
|
||||
const auth = useOptionalAuth()
|
||||
const identity = auth?.identity ?? null
|
||||
const [section, setSection] = useState<SectionKey>(initialSection)
|
||||
@@ -147,6 +183,10 @@ export function SystemSettingsView({ sources, connections, monitor, settings, pr
|
||||
const [stalled, setStalled] = useState<1800 | 3600>(settings?.stalled_after_seconds ?? 1800)
|
||||
const [finalizing, setFinalizing] = useState<7200 | 14400>(settings?.finalizing_after_seconds ?? 7200)
|
||||
const [concurrency, setConcurrency] = useState<number>(settings?.ingestion_concurrency ?? 2)
|
||||
const [endpoint, setEndpoint] = useState<string>(settings?.production_weknora_base_url ?? '')
|
||||
const [testEndpoint, setTestEndpoint] = useState<string>(settings?.test_weknora_base_url ?? '')
|
||||
const [productionTenantID, setProductionTenantID] = useState<string>(settings?.production_tenant_id ?? '')
|
||||
const [productionKnowledgeBaseID, setProductionKnowledgeBaseID] = useState<string>(settings?.production_knowledge_base_id ?? '')
|
||||
const [dirty, setDirty] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -156,10 +196,32 @@ export function SystemSettingsView({ sources, connections, monitor, settings, pr
|
||||
setStalled(settings.stalled_after_seconds)
|
||||
setFinalizing(settings.finalizing_after_seconds)
|
||||
setConcurrency(settings.ingestion_concurrency)
|
||||
setEndpoint(settings.production_weknora_base_url)
|
||||
setTestEndpoint(settings.test_weknora_base_url)
|
||||
setProductionTenantID(settings.production_tenant_id)
|
||||
setProductionKnowledgeBaseID(settings.production_knowledge_base_id)
|
||||
setDirty(false)
|
||||
}, [settings])
|
||||
|
||||
const canSave = Boolean(settings && !saving)
|
||||
const isAdmin = identity?.role === 'admin'
|
||||
const endpointValid = validServiceEndpoint(endpoint)
|
||||
const testEndpointValid = validServiceEndpoint(testEndpoint)
|
||||
const productionTenantIDValid = validProductionTenantID(productionTenantID)
|
||||
const productionKnowledgeBaseIDValid = validKnowledgeBaseID(productionKnowledgeBaseID)
|
||||
const serviceSettingsValid = endpointValid && testEndpointValid && productionTenantIDValid && productionKnowledgeBaseIDValid
|
||||
const serviceSettingsDirty = Boolean(settings && (
|
||||
testEndpoint.trim() !== settings.test_weknora_base_url ||
|
||||
endpoint.trim() !== settings.production_weknora_base_url ||
|
||||
productionTenantID.trim() !== settings.production_tenant_id ||
|
||||
productionKnowledgeBaseID.trim() !== settings.production_knowledge_base_id))
|
||||
const candidateInput = (overrides: Partial<UISettingsCandidateInput> = {}): UISettingsCandidateInput => ({
|
||||
document_refresh_seconds: documentRefresh, resource_refresh_seconds: resourceRefresh,
|
||||
stalled_after_seconds: stalled, finalizing_after_seconds: finalizing, ingestion_concurrency: concurrency,
|
||||
test_weknora_base_url: testEndpoint.trim(), production_weknora_base_url: endpoint.trim(),
|
||||
production_tenant_id: productionTenantID.trim(), production_knowledge_base_id: productionKnowledgeBaseID.trim(), ...overrides,
|
||||
})
|
||||
const saveEndpoint = () => { setDirty(false); onSave(candidateInput()) }
|
||||
return <main className="page-shell system-settings">
|
||||
<header className="hero compact"><div><h1>系统设置</h1><p className="subtitle">查看内容源、服务与备份状态;来源调度与刷新参数采用版本化保存。</p></div><button onClick={onRefresh} disabled={loading}>{loading ? '刷新中…' : '刷新状态'}</button></header>
|
||||
{error ? <div className="error-banner" role="alert">{error}</div> : null}
|
||||
@@ -176,11 +238,25 @@ export function SystemSettingsView({ sources, connections, monitor, settings, pr
|
||||
</div> : <div className="empty-state">尚无可显示的来源连接。</div>}
|
||||
</section> : null}
|
||||
{section === 'services' ? <section className="settings-panel wide" id="settings-services">
|
||||
<div className="settings-section-heading"><div><h2>服务配置</h2><p>测试、生产必须独立配置端点、凭据引用和知识库 ID;凭据只写不读。</p></div></div>
|
||||
<div className="environment-cards"><article><b>测试 WeKnora</b><span>本地真实连接</span><small>端点 / 凭据 / 知识库 ID:管理员受限配置,页面上不可回读</small></article><article className="production"><b>生产 WeKnora</b><span>未连接</span><small>生产写入:关闭;凭据不可回读</small></article></div>
|
||||
<div className="mapping-row"><span>测试知识库映射</span><b>对内解析队列:{routingCapabilities.internal_enabled ? '已配置' : '未配置'}</b><b>对外解析队列:{routingCapabilities.external_enabled ? '已配置' : '未配置'}</b><b>生产映射:未配置</b></div>
|
||||
<RoutingBindingsSection />
|
||||
<div className="dependency-grid">{['PostgreSQL', 'MinerU', 'Embedding', 'LLM API', 'Redis / MQ', 'OSS'].map((name) => { const health = (monitor.health || []).find((item) => item.name === name || item.name.startsWith(name)); const service = monitor.services.find((item) => item.name === name || item.name.startsWith(name)); const liveLabel = !health ? (service ? (service.state === 'healthy' ? '正常' : service.state === 'unknown' ? '未接入' : '需关注') : '待配置') : health.readiness === 'ready' ? '可达 · 可接单' : health.readiness === 'not_configured' ? '状态未知 · 未配置' : health.readiness === 'not_applicable' ? '未部署' : (health.liveness === 'up' ? '可达 · 不可接单' : '不可达 · 不可接单'); return <article key={name} className={health && health.readiness !== 'ready' && health.readiness !== 'not_applicable' ? 'warn' : ''}><span>{name}</span><b>{liveLabel}</b><small>{health ? [health.reason, health.last_success_at ? `最近成功 ${new Date(health.last_success_at).toLocaleTimeString('zh-CN', { hour12: false })}` : '', health.consecutive_failures ? `连续失败${health.consecutive_failures}次` : ''].filter(Boolean).join(';') || (health.readiness === 'ready' ? `延迟 ${health.latency_ms}ms` : '') : service?.detail || '真实 Provider 未接入'}</small></article> })}</div>
|
||||
<div className="settings-section-heading"><div><h2>服务配置</h2><p>只配四项:测试地址、生产地址、生产 ID、知识库 ID。保存会先生成修订、验证后原子启用,旧版本保留用于审计;凭据只写不读。</p></div></div>
|
||||
<div className="environment-cards"><article><b>测试 WeKnora</b><span>{healthStateLabel(findHealth(monitor, 'WeKnora API'), undefined)}</span><small>{`${healthStateDetail(findHealth(monitor, 'WeKnora API'), undefined) || '端点探测:未配置'};解析队列 对内 ${routingCapabilities.internal_enabled ? '已配置' : '未配置'} · 对外 ${routingCapabilities.external_enabled ? '已配置' : '未配置'}`}</small></article><article className="production"><b>生产 WeKnora</b><span>{!productionEnabled ? '未启用' : productionBackupRestoreVerified ? '已启用 · 可提交生产请求' : '已启用 · 被阻断'}</span><small>{`${productionWeKnoraProbe(monitor)};生产写入:${productionEnabled ? '开启' : '关闭'};发布目标与凭据:管理员受限配置,本页不可回读`}</small></article></div>
|
||||
<div className="safe-setting-grid production-endpoint-form">
|
||||
{isAdmin ? <>
|
||||
<label>测试地址<input value={testEndpoint} onChange={(event) => { setTestEndpoint(event.target.value); setDirty(true) }} placeholder="https://weknora.test.example" aria-invalid={!testEndpointValid} /></label>
|
||||
<label>生产地址<input value={endpoint} onChange={(event) => { setEndpoint(event.target.value); setDirty(true) }} placeholder="https://weknora.prod.example" aria-invalid={!endpointValid} /></label>
|
||||
<label>生产 ID<input value={productionTenantID} onChange={(event) => { setProductionTenantID(event.target.value); setDirty(true) }} placeholder="10000" aria-invalid={!productionTenantIDValid} /></label>
|
||||
<label>知识库 ID<input value={productionKnowledgeBaseID} onChange={(event) => { setProductionKnowledgeBaseID(event.target.value); setDirty(true) }} placeholder="生产知识库 ID" aria-invalid={!productionKnowledgeBaseIDValid} /></label>
|
||||
</> : <>
|
||||
<label>测试地址<input value={settings.test_weknora_base_url || '未配置'} readOnly disabled /></label>
|
||||
<label>生产地址<input value={settings.production_weknora_base_url || '未配置'} readOnly disabled /></label>
|
||||
<label>生产 ID<input value={settings.production_tenant_id || '未配置'} readOnly disabled /></label>
|
||||
<label>知识库 ID<input value={settings.production_knowledge_base_id || '未配置'} readOnly disabled /></label>
|
||||
</>}
|
||||
</div>
|
||||
{isAdmin ? <><button onClick={saveEndpoint} disabled={!canSave || !serviceSettingsValid || !serviceSettingsDirty}>{saving ? '正在验证并启用…' : '保存并启用服务配置'}</button>
|
||||
<small>四项留空即表示未配置。地址只接受 http(s) 源地址(不能带用户名密码、查询串或 # 片段,凭据一律走文件引用);生产 ID 是发布契约里的租户 ID,十进制数字;知识库 ID 是生产知识库标识,粘贴 URL 不会通过。保存写入服务器活动版本(版本化 + 审计 + 原子切换);后端同时对测试地址/生产地址做真实探活,并用生产地址真查这两个 ID(WeKnora 列库接口):端点回答"没有这个知识库"或租户不符会拒绝保存,端点抖动、鉴权失败或未配置生产凭据只警告不拦,核验原话与知识库名在保存后显示。</small>
|
||||
{serviceSettingsValid ? null : <p className="error-message" role="alert">格式不合法:地址需 http:// 或 https:// 开头的纯地址,不能带用户名密码、? 查询串或 # 片段;生产 ID 需为十进制数字;知识库 ID 不能含空格、/、?、# 或 \。</p>}</>
|
||||
: <small>服务配置仅管理员可修改;当前值:测试地址 {settings.test_weknora_base_url || '未配置'} · 生产地址 {settings.production_weknora_base_url || '未配置'} · 生产 ID {settings.production_tenant_id || '未配置'} · 知识库 ID {settings.production_knowledge_base_id || '未配置'}。</small>}
|
||||
</section> : null}
|
||||
{section === 'storage' ? <section className="settings-panel wide storage-settings-panel" id="settings-storage">
|
||||
<div className="settings-section-heading"><div><h2>存储与备份</h2><p>文件资源与生产发布前快照使用独立受限配置。</p></div></div>
|
||||
@@ -196,7 +272,7 @@ export function SystemSettingsView({ sources, connections, monitor, settings, pr
|
||||
<label>finalizing 告警<select value={finalizing} onChange={(event) => { setFinalizing(Number(event.target.value) as 7200 | 14400); setDirty(true) }}><option value={7200}>2小时</option><option value={14400}>4小时</option></select></label>
|
||||
<label>入库并发上限<select value={concurrency} onChange={(event) => { setConcurrency(Number(event.target.value)); setDirty(true) }}><option value={1}>1(谨慎,MinerU 并发 1)</option><option value={2}>2</option><option value={3}>3</option><option value={4}>4</option><option value={6}>6</option><option value={8}>8</option></select></label>
|
||||
</div>
|
||||
<button onClick={() => { setDirty(false); onSave({ document_refresh_seconds: documentRefresh, resource_refresh_seconds: resourceRefresh, stalled_after_seconds: stalled, finalizing_after_seconds: finalizing, ingestion_concurrency: concurrency }) }} disabled={!canSave}>{saving ? '正在验证并应用…' : '保存并应用刷新与告警参数'}</button>
|
||||
<button onClick={() => { setDirty(false); onSave(candidateInput()) }} disabled={!canSave}>{saving ? '正在验证并应用…' : '保存并应用刷新与告警参数'}</button>
|
||||
{visibleSaveMessage(saveMessage, dirty) ? <p className="success-message" role="status">{saveMessage}</p> : null}
|
||||
<small>当前:文档 {secondsLabel(settings.document_refresh_seconds)}、资源 {secondsLabel(settings.resource_refresh_seconds)}、停滞 {secondsLabel(settings.stalled_after_seconds)}、finalizing {secondsLabel(settings.finalizing_after_seconds)}、入库并发 {settings.ingestion_concurrency}。</small>
|
||||
</section> : null}
|
||||
@@ -211,6 +287,7 @@ export function SystemSettings() {
|
||||
const [monitor, setMonitor] = useState<MonitorOverview | null>(null)
|
||||
const [settings, setSettings] = useState<UISettings | null>(null)
|
||||
const [productionEnabled, setProductionEnabled] = useState(false)
|
||||
const [productionBackupRestoreVerified, setProductionBackupRestoreVerified] = useState(false)
|
||||
const [routingCapabilities, setRoutingCapabilities] = useState<CatalogRoutingCapabilities>({ internal_enabled: false, external_enabled: false })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -220,16 +297,23 @@ export function SystemSettings() {
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController(); setLoading(true)
|
||||
void Promise.all([fetchCatalogSources(controller.signal), fetchSourceConnectionList(controller.signal), fetchMonitorOverview(controller.signal), fetchReleaseCapabilities(controller.signal), fetchCatalogRoutingCapabilities(controller.signal), fetchActiveUISettings(controller.signal)]).then(([nextSources, nextConnections, overview, capabilities, routing, active]) => { if (!controller.signal.aborted) { setSources(nextSources); setConnections(nextConnections); setMonitor(overview); setProductionEnabled(capabilities.production_release_requests_enabled); setRoutingCapabilities(routing); setSettings(active); setError(null) } }).catch((cause) => { if (!controller.signal.aborted) setError(cause instanceof Error ? cause.message : '加载系统设置失败') }).finally(() => { if (!controller.signal.aborted) setLoading(false) })
|
||||
void Promise.all([fetchCatalogSources(controller.signal), fetchSourceConnectionList(controller.signal), fetchMonitorOverview(controller.signal), fetchReleaseCapabilities(controller.signal), fetchCatalogRoutingCapabilities(controller.signal), fetchActiveUISettings(controller.signal)]).then(([nextSources, nextConnections, overview, capabilities, routing, active]) => { if (!controller.signal.aborted) { setSources(nextSources); setConnections(nextConnections); setMonitor(overview); setProductionEnabled(capabilities.production_release_requests_enabled); setProductionBackupRestoreVerified(capabilities.production_backup_restore_verified); setRoutingCapabilities(routing); setSettings(active); setError(null) } }).catch((cause) => { if (!controller.signal.aborted) setError(cause instanceof Error ? cause.message : '加载系统设置失败') }).finally(() => { if (!controller.signal.aborted) setLoading(false) })
|
||||
return () => controller.abort()
|
||||
}, [revision])
|
||||
|
||||
const save = async (input: UISettingsCandidateInput) => {
|
||||
setSaving(true); setSaveMessage(null); setError(null)
|
||||
try { const candidate = await createUISettingsCandidate(input); const active = await activateUISettings(candidate.id); setSettings(active); setSaveMessage(`刷新与告警参数已验证并启用:文档${secondsLabel(active.document_refresh_seconds)}、资源${secondsLabel(active.resource_refresh_seconds)}、停滞${secondsLabel(active.stalled_after_seconds)}、finalizing ${secondsLabel(active.finalizing_after_seconds)}、入库并发 ${active.ingestion_concurrency};旧版本已保留用于审计。`) }
|
||||
try {
|
||||
const candidate = await createUISettingsCandidate(input)
|
||||
const activated = await activateUISettings(candidate.settings.id)
|
||||
const active = activated.settings
|
||||
const verification = activated.verification ?? candidate.verification
|
||||
setSettings(active)
|
||||
setSaveMessage(`设置已验证并启用:文档${secondsLabel(active.document_refresh_seconds)}、资源${secondsLabel(active.resource_refresh_seconds)}、停滞${secondsLabel(active.stalled_after_seconds)}、finalizing ${secondsLabel(active.finalizing_after_seconds)}、入库并发 ${active.ingestion_concurrency}、测试地址 ${active.test_weknora_base_url || '未配置'}、生产地址 ${active.production_weknora_base_url || '未配置'}、生产 ID ${active.production_tenant_id || '未配置'}、知识库 ID ${active.production_knowledge_base_id || '未配置'};旧版本已保留用于审计。${verification ? `核验:${verification.message}` : ''}`)
|
||||
}
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : '保存系统设置失败') }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
const reloadSources = useCallback(() => { setSaveMessage(null); setRevision((value) => value + 1) }, [])
|
||||
return <SystemSettingsView sources={sources} connections={connections} monitor={monitor} settings={settings} productionEnabled={productionEnabled} routingCapabilities={routingCapabilities} loading={loading} saving={saving} error={error} saveMessage={saveMessage} onSave={(input) => void save(input)} onSourcesChanged={reloadSources} onRefresh={reloadSources}/>
|
||||
return <SystemSettingsView sources={sources} connections={connections} monitor={monitor} settings={settings} productionEnabled={productionEnabled} productionBackupRestoreVerified={productionBackupRestoreVerified} routingCapabilities={routingCapabilities} loading={loading} saving={saving} error={error} saveMessage={saveMessage} onSave={(input) => void save(input)} onSourcesChanged={reloadSources} onRefresh={reloadSources}/>
|
||||
}
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ interface AuthContextValue {
|
||||
notifySignedOut: () => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
export const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const value = useContext(AuthContext)
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { activateUISettings, createUISettingsCandidate, fetchActiveUISettings, parseUISettingsResponse, settingsRefreshDelays } from './uiSettings'
|
||||
import { activateUISettings, createUISettingsCandidate, fetchActiveUISettings, parseServiceTargetVerification, parseUISettingsResponse, settingsRefreshDelays, UISettingsError } from './uiSettings'
|
||||
|
||||
const candidateInput = {
|
||||
document_refresh_seconds: 15, resource_refresh_seconds: 30, stalled_after_seconds: 1800,
|
||||
finalizing_after_seconds: 7200, ingestion_concurrency: 2,
|
||||
test_weknora_base_url: '', production_weknora_base_url: '', production_tenant_id: '', production_knowledge_base_id: '',
|
||||
} as const
|
||||
|
||||
const active = {
|
||||
id: 'settings-default', state: 'active', document_refresh_seconds: 15,
|
||||
resource_refresh_seconds: 30, stalled_after_seconds: 1800,
|
||||
finalizing_after_seconds: 7200, ingestion_concurrency: 2, updated_by: 'system:migration',
|
||||
finalizing_after_seconds: 7200, ingestion_concurrency: 2, production_weknora_base_url: '',
|
||||
test_weknora_base_url: '', production_tenant_id: '', production_knowledge_base_id: '',
|
||||
updated_by: 'system:migration',
|
||||
created_at: '2026-09-10T08:00:00Z', activated_at: '2026-09-10T08:00:00Z',
|
||||
retired_at: null, error_code: '',
|
||||
}
|
||||
@@ -16,6 +24,25 @@ describe('UI settings API', () => {
|
||||
expect(() => parseUISettingsResponse({ data: { ...active, document_refresh_seconds: 31 } })).toThrow('invalid UI settings response')
|
||||
})
|
||||
|
||||
it('accepts only the four service fields with their own rules', () => {
|
||||
const parsed = parseUISettingsResponse({ data: { ...active, test_weknora_base_url: 'https://weknora.test.example:8443/kb', production_tenant_id: '10000', production_knowledge_base_id: 'kb-abc_123' } })
|
||||
expect(parsed.test_weknora_base_url).toBe('https://weknora.test.example:8443/kb')
|
||||
expect(parsed.production_tenant_id).toBe('10000')
|
||||
expect(parsed.production_knowledge_base_id).toBe('kb-abc_123')
|
||||
for (const bad of ['weknora.test.example', 'https://user:pass@weknora.test.example', 'https://weknora.test.example?token=abc'])
|
||||
expect(() => parseUISettingsResponse({ data: { ...active, test_weknora_base_url: bad } })).toThrow('invalid UI settings response')
|
||||
for (const bad of ['abc', '10 000', '-1', '0', '10000000000000000000000', '10000.5'])
|
||||
expect(() => parseUISettingsResponse({ data: { ...active, production_tenant_id: bad } })).toThrow('invalid UI settings response')
|
||||
for (const bad of ['kb/abc', 'kb abc', 'kb-abc?x', 'https://weknora.prod.example/kb'])
|
||||
expect(() => parseUISettingsResponse({ data: { ...active, production_knowledge_base_id: bad } })).toThrow('invalid UI settings response')
|
||||
})
|
||||
|
||||
it('keeps a web-configured production endpoint only when it is a bare http(s) origin', () => {
|
||||
expect(parseUISettingsResponse({ data: { ...active, production_weknora_base_url: 'https://weknora.prod.example:8443/kb' } }).production_weknora_base_url).toBe('https://weknora.prod.example:8443/kb')
|
||||
for (const bad of ['weknora.prod.example', 'https://user:pass@weknora.prod.example', 'https://weknora.prod.example?token=abc', 'https://weknora.prod.example#frag', ' https://weknora.prod.example'])
|
||||
expect(() => parseUISettingsResponse({ data: { ...active, production_weknora_base_url: bad } })).toThrow('invalid UI settings response')
|
||||
})
|
||||
|
||||
it('converts the active revision into independent monitor delays', () => {
|
||||
expect(settingsRefreshDelays({ ...active, document_refresh_seconds: 30, resource_refresh_seconds: 60 })).toEqual({ documentMs: 30000, resourceMs: 60000 })
|
||||
})
|
||||
@@ -29,14 +56,59 @@ describe('UI settings API', () => {
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await fetchActiveUISettings()
|
||||
await createUISettingsCandidate({ document_refresh_seconds: 30, resource_refresh_seconds: 60, stalled_after_seconds: 3600, finalizing_after_seconds: 14400, ingestion_concurrency: 3 })
|
||||
await createUISettingsCandidate({ document_refresh_seconds: 30, resource_refresh_seconds: 60, stalled_after_seconds: 3600, finalizing_after_seconds: 14400, ingestion_concurrency: 3, test_weknora_base_url: '', production_weknora_base_url: '', production_tenant_id: '', production_knowledge_base_id: '' })
|
||||
await activateUISettings('uis-candidate')
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/ui-settings')
|
||||
expect(fetchMock.mock.calls[1][0]).toBe('/api/v1/ui-settings/candidates')
|
||||
expect(fetchMock.mock.calls[1][1]).toMatchObject({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ document_refresh_seconds: 30, resource_refresh_seconds: 60, stalled_after_seconds: 3600, finalizing_after_seconds: 14400, ingestion_concurrency: 3 }) })
|
||||
expect(fetchMock.mock.calls[1][1]).toMatchObject({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ document_refresh_seconds: 30, resource_refresh_seconds: 60, stalled_after_seconds: 3600, finalizing_after_seconds: 14400, ingestion_concurrency: 3, test_weknora_base_url: '', production_weknora_base_url: '', production_tenant_id: '', production_knowledge_base_id: '' }) })
|
||||
expect(fetchMock.mock.calls[2][0]).toBe('/api/v1/ui-settings/uis-candidate/activate')
|
||||
expect(fetchMock.mock.calls[2][1]).toMatchObject({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// 保存的返回值带上真实核验结果:界面要能显示"用的是哪个知识库、属于哪个租户"。
|
||||
it('returns the real verification block produced while saving', async () => {
|
||||
const candidate = { ...active, id: 'uis-candidate', state: 'validated', activated_at: null }
|
||||
const verification = {
|
||||
status: 'verified', message: '已核验:生产地址上存在知识库「生产知识库」,租户 10000 一致', blocked: false,
|
||||
knowledge_base_name: '生产知识库', observed_tenant_id: '10000',
|
||||
test_endpoint: { status: 'reachable', message: '测试地址可达(可列出 7 个知识库)' },
|
||||
production_endpoint: { status: 'reachable', message: '生产地址可达(可列出 7 个知识库)' },
|
||||
checked_at: '2026-09-18T09:00:00Z',
|
||||
}
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: candidate, operation: 'created', verification }), { status: 201 })))
|
||||
const saved = await createUISettingsCandidate(candidateInput)
|
||||
expect(saved.settings.id).toBe('uis-candidate')
|
||||
expect(saved.verification).toEqual(verification)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// 被拒绝时保留服务器那句话,并带上核验详情,而不是退化成 "request failed (400)"。
|
||||
it('keeps a refusal sentence and its detail instead of a status code', async () => {
|
||||
const verification = {
|
||||
status: 'missing', message: '未通过:生产地址上找不到知识库 ID「kb-prod-1」;该地址现有 1 个知识库:生产知识库(kb-prod-1)', blocked: true,
|
||||
knowledge_base_name: '', observed_tenant_id: '',
|
||||
test_endpoint: { status: 'reachable', message: '测试地址可达' },
|
||||
production_endpoint: { status: 'reachable', message: '生产地址可达' },
|
||||
checked_at: '2026-09-18T09:00:00Z',
|
||||
}
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation(() => Promise.resolve(new Response(JSON.stringify({ error: '未通过:生产地址上找不到知识库 ID「kb-prod-1」', verification }), { status: 400 }))))
|
||||
await expect(createUISettingsCandidate(candidateInput)).rejects.toThrow('找不到知识库 ID')
|
||||
const failure = await createUISettingsCandidate(candidateInput).catch((cause: unknown) => cause)
|
||||
expect(failure).toBeInstanceOf(UISettingsError)
|
||||
expect((failure as UISettingsError).verification?.status).toBe('missing')
|
||||
expect((failure as UISettingsError).verification?.blocked).toBe(true)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// 没有核验块(未配置核验器的开发 socket)或格式不对时,一律当作"没有核验",绝不编造"已核验"。
|
||||
it('treats a missing or malformed verification block as no verification', () => {
|
||||
expect(parseServiceTargetVerification(undefined)).toBeNull()
|
||||
expect(parseServiceTargetVerification({ status: 'verified' })).toBeNull()
|
||||
expect(parseServiceTargetVerification({ status: 'verified', message: 'x', blocked: false, test_endpoint: 'nope' })).toEqual({
|
||||
status: 'verified', message: 'x', blocked: false, knowledge_base_name: '', observed_tenant_id: '',
|
||||
test_endpoint: { status: '', message: '' }, production_endpoint: { status: '', message: '' }, checked_at: '',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+127
-7
@@ -7,6 +7,10 @@ export interface UISettings {
|
||||
stalled_after_seconds: 1800 | 3600
|
||||
finalizing_after_seconds: 7200 | 14400
|
||||
ingestion_concurrency: number
|
||||
test_weknora_base_url: string
|
||||
production_weknora_base_url: string
|
||||
production_tenant_id: string
|
||||
production_knowledge_base_id: string
|
||||
updated_by: string
|
||||
created_at: string
|
||||
activated_at: string | null
|
||||
@@ -20,6 +24,78 @@ export interface UISettingsCandidateInput {
|
||||
stalled_after_seconds: UISettings['stalled_after_seconds']
|
||||
finalizing_after_seconds: UISettings['finalizing_after_seconds']
|
||||
ingestion_concurrency: number
|
||||
test_weknora_base_url: string
|
||||
production_weknora_base_url: string
|
||||
production_tenant_id: string
|
||||
production_knowledge_base_id: string
|
||||
}
|
||||
|
||||
// 保存服务配置时后端会用生产地址真查一次:找到了才写入,并把知识库名回显;
|
||||
// 端点明确回答"不存在"会拒绝保存(HTTP 400 + 这句 message),端点抖动只警告不拦。
|
||||
export interface ServiceEndpointStatus {
|
||||
status: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ServiceTargetVerification {
|
||||
status: string
|
||||
message: string
|
||||
blocked: boolean
|
||||
knowledge_base_name: string
|
||||
observed_tenant_id: string
|
||||
test_endpoint: ServiceEndpointStatus
|
||||
production_endpoint: ServiceEndpointStatus
|
||||
checked_at: string
|
||||
}
|
||||
|
||||
export interface ServiceSettingsSave {
|
||||
settings: UISettings
|
||||
verification: ServiceTargetVerification | null
|
||||
}
|
||||
|
||||
export class UISettingsError extends Error {
|
||||
readonly verification: ServiceTargetVerification | null
|
||||
|
||||
constructor(message: string, verification: ServiceTargetVerification | null) {
|
||||
super(message)
|
||||
this.name = 'UISettingsError'
|
||||
this.verification = verification
|
||||
}
|
||||
}
|
||||
|
||||
// Both environments are operator-provided endpoints typed in the console.
|
||||
// Accepting only a bare http(s) origin keeps credentials, query strings and
|
||||
// fragments out of stored configuration; the backend enforces the same rule.
|
||||
export function validServiceEndpoint(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed === '') return true
|
||||
if (trimmed !== value || trimmed.length > 300) return false
|
||||
try {
|
||||
const parsed = new URL(trimmed)
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
|
||||
if (parsed.username || parsed.password || parsed.search || parsed.hash) return false
|
||||
return parsed.host.length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// The production tenant of the target WeKnora is a decimal id; a sign, space or
|
||||
// letter is a typing mistake, never a valid tenant.
|
||||
export function validProductionTenantID(value: string): boolean {
|
||||
if (value.trim() === '') return true
|
||||
if (value !== value.trim() || value.length > 20) return false
|
||||
if (!/^[0-9]+$/.test(value)) return false
|
||||
return Number(value) > 0
|
||||
}
|
||||
|
||||
// The target knowledge base is an opaque id (letters, digits, '.', '_', '-',
|
||||
// ':'). A pasted URL must not pass as an id, so paths, queries and whitespace
|
||||
// are rejected here and on the server.
|
||||
export function validKnowledgeBaseID(value: string): boolean {
|
||||
if (value.trim() === '') return true
|
||||
if (value !== value.trim() || value.length > 128) return false
|
||||
return !/[\x00\r\n\t /?#\\]/.test(value)
|
||||
}
|
||||
|
||||
export function settingsRefreshDelays(settings: Pick<UISettings, 'document_refresh_seconds' | 'resource_refresh_seconds'>) {
|
||||
@@ -43,6 +119,10 @@ export function parseUISettingsResponse(value: unknown): UISettings {
|
||||
typeof item.stalled_after_seconds !== 'number' || !stalledAfter.has(item.stalled_after_seconds) ||
|
||||
typeof item.finalizing_after_seconds !== 'number' || !finalizingAfter.has(item.finalizing_after_seconds) ||
|
||||
typeof item.ingestion_concurrency !== 'number' || !Number.isInteger(item.ingestion_concurrency) || item.ingestion_concurrency < 1 || item.ingestion_concurrency > 16 ||
|
||||
typeof item.test_weknora_base_url !== 'string' || !validServiceEndpoint(item.test_weknora_base_url) ||
|
||||
typeof item.production_weknora_base_url !== 'string' || !validServiceEndpoint(item.production_weknora_base_url) ||
|
||||
typeof item.production_tenant_id !== 'string' || !validProductionTenantID(item.production_tenant_id) ||
|
||||
typeof item.production_knowledge_base_id !== 'string' || !validKnowledgeBaseID(item.production_knowledge_base_id) ||
|
||||
typeof item.updated_by !== 'string' || typeof item.created_at !== 'string' ||
|
||||
(item.activated_at !== null && typeof item.activated_at !== 'string') ||
|
||||
(item.retired_at !== null && typeof item.retired_at !== 'string') || typeof item.error_code !== 'string') {
|
||||
@@ -56,6 +136,10 @@ export function parseUISettingsResponse(value: unknown): UISettings {
|
||||
stalled_after_seconds: item.stalled_after_seconds as UISettings['stalled_after_seconds'],
|
||||
finalizing_after_seconds: item.finalizing_after_seconds as UISettings['finalizing_after_seconds'],
|
||||
ingestion_concurrency: item.ingestion_concurrency,
|
||||
test_weknora_base_url: item.test_weknora_base_url,
|
||||
production_weknora_base_url: item.production_weknora_base_url,
|
||||
production_tenant_id: item.production_tenant_id,
|
||||
production_knowledge_base_id: item.production_knowledge_base_id,
|
||||
updated_by: item.updated_by,
|
||||
created_at: item.created_at,
|
||||
activated_at: item.activated_at as string | null,
|
||||
@@ -64,24 +148,60 @@ export function parseUISettingsResponse(value: unknown): UISettings {
|
||||
}
|
||||
}
|
||||
|
||||
function parseEndpointStatus(value: unknown): ServiceEndpointStatus {
|
||||
if (!isRecord(value) || typeof value.status !== 'string' || typeof value.message !== 'string') return { status: '', message: '' }
|
||||
return { status: value.status, message: value.message }
|
||||
}
|
||||
|
||||
// 核验块是诚实声明,解析失败按"没有核验"处理,绝不编造"已核验"。
|
||||
export function parseServiceTargetVerification(value: unknown): ServiceTargetVerification | null {
|
||||
if (!isRecord(value) || typeof value.status !== 'string' || typeof value.message !== 'string' || typeof value.blocked !== 'boolean') return null
|
||||
return {
|
||||
status: value.status,
|
||||
message: value.message,
|
||||
blocked: value.blocked,
|
||||
knowledge_base_name: typeof value.knowledge_base_name === 'string' ? value.knowledge_base_name : '',
|
||||
observed_tenant_id: typeof value.observed_tenant_id === 'string' ? value.observed_tenant_id : '',
|
||||
test_endpoint: parseEndpointStatus(value.test_endpoint),
|
||||
production_endpoint: parseEndpointStatus(value.production_endpoint),
|
||||
checked_at: typeof value.checked_at === 'string' ? value.checked_at : '',
|
||||
}
|
||||
}
|
||||
|
||||
async function readPayload(response: Response): Promise<Record<string, unknown>> {
|
||||
if (response.ok) {
|
||||
const payload: unknown = await response.json()
|
||||
return isRecord(payload) ? payload : {}
|
||||
}
|
||||
// 拒绝保存时服务器给的就是要展示的那句话,别再退化成状态码。
|
||||
const failure: unknown = await response.json().catch(() => null)
|
||||
const record = isRecord(failure) ? failure : {}
|
||||
const message = typeof record.error === 'string' && record.error.trim() !== '' ? record.error : `UI settings request failed (${response.status})`
|
||||
throw new UISettingsError(message, parseServiceTargetVerification(record.verification))
|
||||
}
|
||||
|
||||
async function readSettings(response: Response): Promise<UISettings> {
|
||||
if (!response.ok) throw new Error(`UI settings request failed (${response.status})`)
|
||||
return parseUISettingsResponse(await response.json())
|
||||
return parseUISettingsResponse(await readPayload(response))
|
||||
}
|
||||
|
||||
async function readSettingsSave(response: Response): Promise<ServiceSettingsSave> {
|
||||
const payload = await readPayload(response)
|
||||
return { settings: parseUISettingsResponse(payload), verification: parseServiceTargetVerification(payload.verification) }
|
||||
}
|
||||
|
||||
export async function fetchActiveUISettings(signal?: AbortSignal): Promise<UISettings> {
|
||||
return readSettings(await fetch('/api/v1/ui-settings', { signal }))
|
||||
}
|
||||
|
||||
export async function createUISettingsCandidate(input: UISettingsCandidateInput): Promise<UISettings> {
|
||||
return readSettings(await fetch('/api/v1/ui-settings/candidates', {
|
||||
export async function createUISettingsCandidate(input: UISettingsCandidateInput): Promise<ServiceSettingsSave> {
|
||||
return readSettingsSave(await fetch('/api/v1/ui-settings/candidates', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Governance-Action': 'ui-settings', 'Idempotency-Key': newOperationID() }, body: JSON.stringify(input),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function activateUISettings(revisionID: string): Promise<UISettings> {
|
||||
export async function activateUISettings(revisionID: string): Promise<ServiceSettingsSave> {
|
||||
if (!/^[A-Za-z0-9._:-]{2,36}$/.test(revisionID)) throw new Error('invalid UI settings revision')
|
||||
return readSettings(await fetch(`/api/v1/ui-settings/${encodeURIComponent(revisionID)}/activate`, {
|
||||
return readSettingsSave(await fetch(`/api/v1/ui-settings/${encodeURIComponent(revisionID)}/activate`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Governance-Action': 'ui-settings', 'Idempotency-Key': newOperationID() }, body: '{}',
|
||||
}))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user