feat(mcp): add governed cross-product control plane
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 1s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / build-and-deploy (push) Has been cancelled
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / tests (push) Has been cancelled

This commit is contained in:
ogt
2026-07-15 01:25:38 +08:00
parent f5243d99b9
commit 2480513061
16 changed files with 2351 additions and 19 deletions

View File

@@ -0,0 +1,516 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import {
AlertTriangle,
Boxes,
CheckCircle2,
Database,
GitBranch,
Network,
RefreshCw,
Search,
ShieldCheck,
Workflow,
XCircle,
} from 'lucide-react'
import { AppLayout } from '@/components/layout'
import { getRuntimeApiV1BaseUrl } from '@/lib/runtime-api-base'
type Filter = 'all' | 'existing' | 'candidate' | 'gap'
interface CapabilitySummary {
capability_id: string
title: string
decision: string
risk_tier: string
deployment_allowed: boolean
}
interface Capability extends CapabilitySummary {
source_type: string
catalog_source: string
catalog_url?: string | null
scope: string
data_boundary: string
version_state: string
digest_state: string
sbom_state: string
signature_state: string
rag_policy: string
blockers: string[]
}
interface ProductRow {
product_id: string
canonical_repo: string
source_control_status: string
prod_sha_short?: string
inventory_state: string
existing_capability_ids: string[]
recommended_capability_ids: string[]
rejected_capability_ids: string[]
existing_capabilities?: CapabilitySummary[]
recommended_capabilities?: CapabilitySummary[]
blockers: string[]
}
interface RuntimeReadback {
status: string
provider_registry?: {
status: string
provider_count: number
provider_names: string[]
tool_count: number
}
gateway_audit_24h?: {
status: string
call_count: number
success_count: number
blocked_count: number
failed_count: number
body_storage?: string
}
rag?: {
status: string
total_chunks: number
sources: number
}
federated_product_runtime_receipt_count?: number
federated_product_runtime_receipt_expected?: number
}
interface Overview {
generated_at: string
status: string
summary: {
product_count: number
mcp_source_detected_product_count: number
mcp_source_gap_product_count: number
quarantined_or_remediation_product_count: number
capability_count: number
external_candidate_count: number
external_promotion_ready_count: number
active_blocker_count: number
runtime_provider_count: number
runtime_tool_count: number
}
architecture: {
decision: string
gateway: string
provider_registry: string
rag: string
separate_gateway_allowed: boolean
separate_rag_database_allowed: boolean
}
security_policy: {
external_content_default: string
external_rag_auto_write_allowed: boolean
gateway_request_body_storage: string
distributed_rate_limit_required: boolean
}
supply_chain_policy: {
floating_versions_allowed: boolean
npx_yes_allowed: boolean
latest_tag_allowed: boolean
required_before_canary: string[]
}
version_lifecycle: {
desired_loop: string[]
discovery_cadence: string
runtime_state: string
active_blockers: string[]
}
runtime: RuntimeReadback
capabilities: Capability[]
products: ProductRow[]
active_blockers: string[]
next_actions: string[]
}
const toneForDecision = (decision: string) => {
if (decision === 'integrated' || decision.startsWith('federate')) {
return 'border-[#8fc29a] bg-[#f0faf2] text-[#17602a]'
}
if (decision === 'rejected' || decision === 'quarantine') {
return 'border-[#efb4a7] bg-[#fff2ef] text-[#a53f2b]'
}
return 'border-[#e8c77a] bg-[#fff9e9] text-[#7b5815]'
}
const isGap = (product: ProductRow) =>
product.inventory_state.startsWith('no_') || product.blockers.length > 0
function StatusBadge({ value }: { value: string }) {
return (
<span className={`inline-flex rounded-full border px-2.5 py-1 text-[11px] font-semibold ${toneForDecision(value)}`}>
{value.replaceAll('_', ' ')}
</span>
)
}
function MetricCard({
label,
value,
detail,
tone = 'neutral',
}: {
label: string
value: number | string
detail: string
tone?: 'neutral' | 'ok' | 'warn' | 'danger'
}) {
const toneClass = {
neutral: 'text-[#44423d]',
ok: 'text-[#17602a]',
warn: 'text-[#7b5815]',
danger: 'text-[#a53f2b]',
}[tone]
return (
<article className="rounded-2xl border border-[#dedbd1] bg-white p-4 shadow-[0_1px_2px_rgba(20,20,19,0.04)]">
<p className="text-xs font-semibold text-[#77736a]">{label}</p>
<p className={`mt-2 text-3xl font-bold tracking-tight ${toneClass}`}>{value}</p>
<p className="mt-2 text-xs leading-5 text-[#77736a]">{detail}</p>
</article>
)
}
export default function McpControlPlanePage({ params }: { params: { locale: string } }) {
const t = useTranslations('mcpControlPlane')
const [overview, setOverview] = useState<Overview | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [filter, setFilter] = useState<Filter>('all')
const loadOverview = useCallback(async () => {
setLoading(true)
setError(null)
const base = getRuntimeApiV1BaseUrl()
if (!base) {
setError(t('states.apiMissing'))
setLoading(false)
return
}
try {
const response = await fetch(`${base}/mcp-control-plane/overview`, {
cache: 'no-store',
headers: { Accept: 'application/json' },
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
setOverview(await response.json() as Overview)
} catch (cause) {
setError(cause instanceof Error ? cause.message : t('states.unknownError'))
} finally {
setLoading(false)
}
}, [t])
useEffect(() => {
void loadOverview()
}, [loadOverview])
const filteredProducts = useMemo(() => {
if (!overview) return []
if (filter === 'existing') return overview.products.filter(row => row.existing_capability_ids.length > 0)
if (filter === 'candidate') return overview.products.filter(row => row.recommended_capability_ids.length > 0)
if (filter === 'gap') return overview.products.filter(isGap)
return overview.products
}, [filter, overview])
const externalCapabilities = useMemo(
() => overview?.capabilities.filter(row => row.source_type === 'external') ?? [],
[overview],
)
const runtime = overview?.runtime
const gateway = runtime?.gateway_audit_24h
const rag = runtime?.rag
return (
<AppLayout locale={params.locale}>
<main className="min-h-[calc(100vh-68px)] overflow-x-hidden bg-[#f5f4ed] text-[#141413]">
<div className="mx-auto max-w-[1500px] p-4 sm:p-6 lg:p-8">
<header className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="max-w-3xl">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-[#d8d4c9] bg-white">
<Network className="h-5 w-5 text-[#d97757]" aria-hidden="true" />
</div>
<div>
<p className="text-[11px] font-bold uppercase tracking-[0.16em] text-[#77736a]">{t('eyebrow')}</p>
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">{t('title')}</h1>
</div>
</div>
<p className="mt-4 text-sm leading-6 text-[#5f5c55]">{t('subtitle')}</p>
</div>
<button
type="button"
onClick={() => void loadOverview()}
disabled={loading}
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-xl border border-[#cbc7bc] bg-white px-4 text-sm font-semibold text-[#44423d] transition hover:border-[#a9a49a] disabled:cursor-wait disabled:opacity-60"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} aria-hidden="true" />
{t('refresh')}
</button>
</header>
<div className="mt-5" aria-live="polite">
{loading && !overview ? (
<div className="rounded-2xl border border-[#dedbd1] bg-white p-8 text-center text-sm text-[#77736a]">
{t('states.loading')}
</div>
) : error && !overview ? (
<div role="alert" className="rounded-2xl border border-[#efb4a7] bg-[#fff2ef] p-5 text-sm text-[#8e3827]">
<div className="flex items-center gap-2 font-semibold">
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
{t('states.error')}
</div>
<p className="mt-2">{error}</p>
</div>
) : null}
</div>
{overview ? (
<div className="mt-6 space-y-6">
<section aria-labelledby="mcp-summary-title">
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 id="mcp-summary-title" className="text-lg font-bold">{t('summary.title')}</h2>
<p className="mt-1 text-xs text-[#77736a]">
{t('summary.generatedAt', { value: overview.generated_at })}
</p>
</div>
<StatusBadge value={overview.status} />
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-6">
<MetricCard label={t('metrics.products')} value={overview.summary.product_count} detail={t('metrics.productsDetail')} />
<MetricCard label={t('metrics.detected')} value={overview.summary.mcp_source_detected_product_count} detail={t('metrics.detectedDetail')} tone="ok" />
<MetricCard label={t('metrics.gaps')} value={overview.summary.mcp_source_gap_product_count} detail={t('metrics.gapsDetail')} tone="warn" />
<MetricCard label={t('metrics.providers')} value={overview.summary.runtime_provider_count} detail={t('metrics.providersDetail', { tools: overview.summary.runtime_tool_count })} tone={runtime?.status === 'ready' ? 'ok' : 'warn'} />
<MetricCard label={t('metrics.external')} value={overview.summary.external_candidate_count} detail={t('metrics.externalDetail', { ready: overview.summary.external_promotion_ready_count })} tone="warn" />
<MetricCard label={t('metrics.blockers')} value={overview.summary.active_blocker_count} detail={t('metrics.blockersDetail')} tone="danger" />
</div>
</section>
<section className="grid gap-4 lg:grid-cols-[1.4fr_1fr]" aria-labelledby="architecture-title">
<article className="rounded-2xl border border-[#b9cfea] bg-[#f4f8ff] p-5">
<div className="flex items-start gap-3">
<Workflow className="mt-0.5 h-5 w-5 shrink-0 text-[#356aa0]" aria-hidden="true" />
<div>
<p className="text-xs font-bold uppercase tracking-wider text-[#356aa0]">{t('architecture.badge')}</p>
<h2 id="architecture-title" className="mt-1 text-lg font-bold">{t('architecture.title')}</h2>
<p className="mt-2 text-sm leading-6 text-[#4e5965]">{t('architecture.detail')}</p>
</div>
</div>
<dl className="mt-4 grid gap-3 sm:grid-cols-3">
{[
[t('architecture.gateway'), overview.architecture.gateway],
[t('architecture.registry'), overview.architecture.provider_registry],
[t('architecture.rag'), overview.architecture.rag],
].map(([label, value]) => (
<div key={label} className="rounded-xl border border-[#cfdded] bg-white/80 p-3">
<dt className="text-[11px] font-semibold text-[#6a7682]">{label}</dt>
<dd className="mt-1 break-words text-xs font-semibold text-[#25384b]">{value}</dd>
</div>
))}
</dl>
</article>
<article className="rounded-2xl border border-[#dedbd1] bg-white p-5">
<div className="flex items-center gap-2">
<Database className="h-5 w-5 text-[#6d665b]" aria-hidden="true" />
<h2 className="text-lg font-bold">{t('runtime.title')}</h2>
</div>
<dl className="mt-4 grid grid-cols-2 gap-3 text-sm">
<div className="rounded-xl bg-[#f6f5ef] p-3">
<dt className="text-xs text-[#77736a]">{t('runtime.calls24h')}</dt>
<dd className="mt-1 text-xl font-bold">{gateway?.call_count ?? 0}</dd>
</div>
<div className="rounded-xl bg-[#f6f5ef] p-3">
<dt className="text-xs text-[#77736a]">{t('runtime.blocked24h')}</dt>
<dd className="mt-1 text-xl font-bold text-[#a53f2b]">{gateway?.blocked_count ?? 0}</dd>
</div>
<div className="rounded-xl bg-[#f6f5ef] p-3">
<dt className="text-xs text-[#77736a]">{t('runtime.ragChunks')}</dt>
<dd className="mt-1 text-xl font-bold">{rag?.total_chunks ?? 0}</dd>
</div>
<div className="rounded-xl bg-[#f6f5ef] p-3">
<dt className="text-xs text-[#77736a]">{t('runtime.federated')}</dt>
<dd className="mt-1 text-xl font-bold">
{runtime?.federated_product_runtime_receipt_count ?? 0}/{runtime?.federated_product_runtime_receipt_expected ?? 12}
</dd>
</div>
</dl>
<p className="mt-3 text-xs leading-5 text-[#77736a]">{t('runtime.hashOnly')}</p>
</article>
</section>
<section className="rounded-2xl border border-[#dedbd1] bg-white p-4 sm:p-5" aria-labelledby="product-matrix-title">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 id="product-matrix-title" className="text-lg font-bold">{t('products.title')}</h2>
<p className="mt-1 text-sm text-[#77736a]">{t('products.subtitle')}</p>
</div>
<div className="flex flex-wrap gap-2" role="group" aria-label={t('products.filterLabel')}>
{(['all', 'existing', 'candidate', 'gap'] as Filter[]).map(value => (
<button
key={value}
type="button"
aria-pressed={filter === value}
onClick={() => setFilter(value)}
className={`rounded-lg border px-3 py-2 text-xs font-semibold transition ${filter === value ? 'border-[#d97757] bg-[#fff4ee] text-[#a84e33]' : 'border-[#d8d4c9] bg-white text-[#666159] hover:border-[#aaa59a]'}`}
>
{t(`products.filters.${value}`)}
</button>
))}
</div>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{filteredProducts.map(product => (
<article key={product.product_id} className="rounded-xl border border-[#e2dfd6] bg-[#fbfaf6] p-4" data-testid={`mcp-product-${product.product_id}`}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="truncate font-bold">{product.product_id}</h3>
<p className="mt-1 truncate font-mono text-[11px] text-[#77736a]">{product.canonical_repo}</p>
</div>
{product.existing_capability_ids.length > 0 ? (
<CheckCircle2 className="h-5 w-5 shrink-0 text-[#2f7d54]" aria-label={t('products.hasExisting')} />
) : (
<AlertTriangle className="h-5 w-5 shrink-0 text-[#9a6a22]" aria-label={t('products.noExisting')} />
)}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<StatusBadge value={product.inventory_state} />
<span className="rounded-full border border-[#d8d4c9] bg-white px-2.5 py-1 text-[11px] text-[#666159]">
{t('products.source', { value: product.source_control_status })}
</span>
</div>
<dl className="mt-4 grid grid-cols-3 gap-2 text-center">
<div className="rounded-lg bg-white p-2"><dt className="text-[10px] text-[#77736a]">{t('products.existing')}</dt><dd className="mt-1 font-bold">{product.existing_capability_ids.length}</dd></div>
<div className="rounded-lg bg-white p-2"><dt className="text-[10px] text-[#77736a]">{t('products.recommended')}</dt><dd className="mt-1 font-bold">{product.recommended_capability_ids.length}</dd></div>
<div className="rounded-lg bg-white p-2"><dt className="text-[10px] text-[#77736a]">{t('products.blockers')}</dt><dd className="mt-1 font-bold text-[#a53f2b]">{product.blockers.length}</dd></div>
</dl>
<div className="mt-4 space-y-2">
{(product.existing_capabilities ?? []).slice(0, 2).map(item => (
<div key={item.capability_id} className="flex items-center justify-between gap-2 text-xs">
<span className="min-w-0 truncate text-[#4f4c46]">{item.title}</span>
<StatusBadge value={item.decision} />
</div>
))}
{(product.recommended_capabilities ?? []).slice(0, 2).map(item => (
<div key={item.capability_id} className="flex items-center justify-between gap-2 text-xs">
<span className="min-w-0 truncate text-[#4f4c46]">{item.title}</span>
<StatusBadge value={item.decision} />
</div>
))}
</div>
</article>
))}
</div>
</section>
<section className="min-w-0 rounded-2xl border border-[#dedbd1] bg-white p-4 sm:p-5" aria-labelledby="external-title">
<div className="flex items-start gap-3">
<Search className="mt-0.5 h-5 w-5 text-[#6d665b]" aria-hidden="true" />
<div>
<h2 id="external-title" className="text-lg font-bold">{t('external.title')}</h2>
<p className="mt-1 text-sm text-[#77736a]">{t('external.subtitle')}</p>
</div>
</div>
<div className="mt-4 max-w-full overflow-x-auto overscroll-x-contain">
<table className="min-w-[980px] w-full border-collapse text-left text-xs">
<caption className="sr-only">{t('external.caption')}</caption>
<thead>
<tr className="border-b border-[#dedbd1] text-[#6c685f]">
<th className="px-3 py-3 font-semibold">{t('external.columns.capability')}</th>
<th className="px-3 py-3 font-semibold">{t('external.columns.source')}</th>
<th className="px-3 py-3 font-semibold">{t('external.columns.decision')}</th>
<th className="px-3 py-3 font-semibold">{t('external.columns.scope')}</th>
<th className="px-3 py-3 font-semibold">{t('external.columns.version')}</th>
<th className="px-3 py-3 font-semibold">{t('external.columns.supply')}</th>
<th className="px-3 py-3 font-semibold">{t('external.columns.promote')}</th>
</tr>
</thead>
<tbody>
{externalCapabilities.map(item => (
<tr key={item.capability_id} className="border-b border-[#ece9e1] align-top last:border-0">
<td className="px-3 py-3"><div className="font-semibold text-[#302f2b]">{item.title}</div><div className="mt-1 font-mono text-[10px] text-[#8a867e]">{item.capability_id}</div></td>
<td className="px-3 py-3 text-[#5f5b54]">{item.catalog_source}</td>
<td className="px-3 py-3"><StatusBadge value={item.decision} /></td>
<td className="px-3 py-3 text-[#5f5b54]">{item.scope}</td>
<td className="px-3 py-3 text-[#5f5b54]">{item.version_state}</td>
<td className="px-3 py-3 text-[#5f5b54]">{item.digest_state} / {item.sbom_state} / {item.signature_state}</td>
<td className="px-3 py-3">
{item.deployment_allowed ? <CheckCircle2 className="h-4 w-4 text-[#2f7d54]" aria-label={t('external.allowed')} /> : <XCircle className="h-4 w-4 text-[#b2432d]" aria-label={t('external.blocked')} />}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className="grid gap-4 xl:grid-cols-[1.35fr_1fr]">
<article className="rounded-2xl border border-[#dedbd1] bg-white p-5" aria-labelledby="lifecycle-title">
<div className="flex items-center gap-2">
<GitBranch className="h-5 w-5 text-[#6d665b]" aria-hidden="true" />
<h2 id="lifecycle-title" className="text-lg font-bold">{t('lifecycle.title')}</h2>
</div>
<p className="mt-2 text-sm text-[#77736a]">{t('lifecycle.subtitle', { cadence: overview.version_lifecycle.discovery_cadence })}</p>
<ol className="mt-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{overview.version_lifecycle.desired_loop.map((step, index) => (
<li key={step} className="flex items-center gap-3 rounded-xl border border-[#e2dfd6] bg-[#fbfaf6] p-3 text-xs">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[#ece8df] font-bold text-[#5f5b54]">{index + 1}</span>
<span className="font-semibold text-[#4d4a44]">{step.replaceAll('_', ' ')}</span>
</li>
))}
</ol>
<div role="status" className="mt-4 rounded-xl border border-[#e8c77a] bg-[#fff9e9] p-3 text-xs leading-5 text-[#705219]">
{t('lifecycle.runtimeState', { value: overview.version_lifecycle.runtime_state })}
</div>
</article>
<article className="rounded-2xl border border-[#dedbd1] bg-white p-5" aria-labelledby="security-title">
<div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-[#2f7d54]" aria-hidden="true" />
<h2 id="security-title" className="text-lg font-bold">{t('security.title')}</h2>
</div>
<ul className="mt-4 space-y-3 text-sm text-[#555149]">
<li className="flex gap-2"><CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-[#2f7d54]" aria-hidden="true" />{t('security.hashOnly')}</li>
<li className="flex gap-2"><CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-[#2f7d54]" aria-hidden="true" />{t('security.distributedRateLimit')}</li>
<li className="flex gap-2"><CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-[#2f7d54]" aria-hidden="true" />{t('security.quarantine')}</li>
<li className="flex gap-2"><XCircle className="mt-0.5 h-4 w-4 shrink-0 text-[#b2432d]" aria-hidden="true" />{t('security.github')}</li>
<li className="flex gap-2"><Boxes className="mt-0.5 h-4 w-4 shrink-0 text-[#7b5815]" aria-hidden="true" />{t('security.supplyChain')}</li>
</ul>
</article>
</section>
<section className="rounded-2xl border border-[#efb4a7] bg-[#fff8f6] p-5" aria-labelledby="blockers-title">
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-[#a53f2b]" aria-hidden="true" />
<h2 id="blockers-title" className="text-lg font-bold">{t('blockers.title')}</h2>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<div>
<h3 className="text-xs font-bold uppercase tracking-wide text-[#8e3827]">{t('blockers.active')}</h3>
<ul className="mt-2 space-y-2 text-xs leading-5 text-[#6a4a42]">
{overview.active_blockers.map(item => <li key={item}> {item.replaceAll('_', ' ')}</li>)}
</ul>
</div>
<div>
<h3 className="text-xs font-bold uppercase tracking-wide text-[#356aa0]">{t('blockers.next')}</h3>
<ol className="mt-2 space-y-2 text-xs leading-5 text-[#465463]">
{overview.next_actions.map((item, index) => <li key={item}>{index + 1}. {item.replaceAll('_', ' ')}</li>)}
</ol>
</div>
</div>
</section>
</div>
) : null}
</div>
</main>
</AppLayout>
)
}

View File

@@ -14,6 +14,7 @@ import {
Activity,
ArrowRight,
GitBranch,
Network,
Wrench,
Zap,
} from 'lucide-react'
@@ -36,6 +37,17 @@ export default function AutomationPage({ params }: { params: { locale: string }
const ta = useTranslations('automation')
const modules: AutomationModule[] = [
{
id: 'mcp-control-plane',
label: t('mcpControlPlane'),
description: ta('mcpControlPlaneDesc'),
href: `/${params.locale}/automation/mcp`,
icon: Network,
accentColor: 'text-[#356aa0]',
bgColor: 'bg-[#f4f8ff]',
borderColor: 'border-[#b9cfea] hover:border-[#356aa0]',
badgeColor: 'bg-[#f4f8ff] border-[#b9cfea] text-[#356aa0]',
},
{
id: 'auto-repair',
label: t('autoRepair'),
@@ -84,7 +96,7 @@ export default function AutomationPage({ params }: { params: { locale: string }
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">
AI Automation
{ta('eyebrow')}
</p>
<h1 className="text-2xl font-bold text-[#141413]">
{t('knowledgeAutomation')}
@@ -97,7 +109,7 @@ export default function AutomationPage({ params }: { params: { locale: string }
</div>
{/* Module cards */}
<div className="grid gap-4 sm:grid-cols-3">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{modules.map(({ id, label, description, href, icon: Icon, accentColor, bgColor, borderColor, badgeColor }) => (
<Link
key={id}

View File

@@ -105,10 +105,11 @@ export const PRODUCT_NAV_SECTIONS: ProductNavSection[] = [
href: '/knowledge-base',
labelKey: 'knowledgeAutomation',
Icon: BookOpen,
aliases: ['/knowledge', '/automation', '/auto-repair', '/drift', '/neural-command'],
aliases: ['/knowledge', '/automation', '/automation/mcp', '/auto-repair', '/drift', '/neural-command'],
children: [
{ id: 'knowledge', href: '/knowledge', labelKey: 'knowledge' },
{ id: 'automation', href: '/automation', labelKey: 'automation' },
{ id: 'mcp-control-plane', href: '/automation/mcp', labelKey: 'mcpControlPlane' },
{ id: 'auto-repair', href: '/auto-repair', labelKey: 'autoRepair' },
{ id: 'drift', href: '/drift', labelKey: 'drift' },
{ id: 'neural-command', href: '/neural-command', labelKey: 'neuralCommand' },