diff --git a/CHANGELOG.md b/CHANGELOG.md index 1283a4fa8..5670d0c3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Topology navigation: the sidebar's Topology entry expands to Request flow, Infra layout and Service map, and opens automatically on any topology page, so the infrastructure layout graph and service map no longer require the small in-page buttons. - User guide: `docs-site/` is published as this repository's GitHub Pages site (deployed automatically on pushes to `dev`, or manually via workflow dispatch), retargeted from the previous personal-domain deployment — site URL, navigation/landing GitHub links, robots.txt sitemap and alert runbook cross-references now point at this repository, and the previous dashboard link and personal analytics tracker are removed. ### Fixed @@ -682,6 +683,7 @@ First release of the **v2 line** (versioned independently from the v1 1.x line, ### Added +- 토폴로지 내비게이션: 사이드바의 토폴로지 항목을 펼치면 요청 흐름·인프라 배치·서비스 맵으로 바로 이동할 수 있고, 토폴로지 화면에 있으면 자동으로 펼쳐집니다. 인프라 배치 그래프와 서비스 맵을 찾기 위해 화면 안의 작은 버튼을 쓰지 않아도 됩니다. - 사용자 가이드: `docs-site/`를 이 저장소의 GitHub Pages 사이트로 게시합니다(`dev` 브랜치 push 시 자동 배포, 또는 워크플로 수동 실행). 이전 개인 도메인 배포 대상을 이 저장소 기준으로 재조정하여 사이트 URL, 내비게이션·랜딩 페이지의 GitHub 링크, robots.txt sitemap, 알림 런북 상호 참조가 이 저장소를 가리키며, 기존 대시보드 링크와 개인 애널리틱스 트래커는 제거합니다. ### 수정 diff --git a/web/components/shell/MobileTopBar.tsx b/web/components/shell/MobileTopBar.tsx index 83e9a7dfe..18196ca45 100644 --- a/web/components/shell/MobileTopBar.tsx +++ b/web/components/shell/MobileTopBar.tsx @@ -15,6 +15,8 @@ const ROUTE_TKEY: Record = { '/cost': 'nav.cost', '/bedrock': 'nav.bedrock', '/topology': 'nav.topology', + '/topology/infra': 'nav.topologyInfra', + '/topology/services': 'nav.topologyServices', '/security': 'nav.security', '/compliance': 'nav.compliance', '/customization': 'nav.customAgents', diff --git a/web/components/shell/Sidebar.test.tsx b/web/components/shell/Sidebar.test.tsx index d10d03d58..5462decd6 100644 --- a/web/components/shell/Sidebar.test.tsx +++ b/web/components/shell/Sidebar.test.tsx @@ -65,3 +65,30 @@ describe('collapsible inventory groups', () => { expect(fixed).toContain("'/integrations'"); }); }); + +// Topology sub-pages were reachable only from small in-page buttons; the sidebar now +// lists them under a collapsible Topology entry. +describe('topology sub-navigation', () => { + const src = read('./Sidebar.tsx'); + const fixed = src.slice(src.indexOf('const FIXED'), src.indexOf('];', src.indexOf('const FIXED'))); + it('lists request flow, infra layout and service map under Topology', () => { + for (const href of ["'/topology/infra'", "'/topology/services'"]) expect(fixed).toContain(href); + for (const key of ['nav.topologyFlow', 'nav.topologyInfra', 'nav.topologyServices']) expect(fixed).toContain(key); + }); + it('renders FIXED children as a collapsible, instance-scoped panel that opens on topology paths', () => { + expect(src).toContain('item.children ? renderFixedGroup(item)'); + expect(src).toContain('${uid}-fixed-'); + expect(src).toMatch(/item\.children && underPath\(path, item\.href\)/); + }); + it('marks the header active when the panel is collapsed on a child page', () => { + // Collapsed panels unmount their children, so the header must carry aria-current. + expect(src).toContain('underPath(path, item.href) && (!open || !childActive)'); + }); + it('defines localized sub-nav labels', async () => { + const { translate } = await import('@/lib/i18n'); + for (const key of ['nav.topologyFlow', 'nav.topologyInfra', 'nav.topologyServices']) { + expect(translate('en', key)).not.toBe(key); + expect(translate('ko', key)).not.toBe(translate('en', key)); + } + }); +}); diff --git a/web/components/shell/Sidebar.tsx b/web/components/shell/Sidebar.tsx index 18ae4484c..f85711c96 100644 --- a/web/components/shell/Sidebar.tsx +++ b/web/components/shell/Sidebar.tsx @@ -24,8 +24,12 @@ import ThemeToggle from '@/components/shell/ThemeToggle'; import ScopeSelector from '@/components/shell/ScopeSelector'; import { cn } from '@/lib/cn'; -// Fixed top-level pages. `tkey` resolves the label via i18n. -const FIXED: { href: string; tkey: string; icon: LucideIcon }[] = [ +type FixedLink = { href: string; tkey: string; icon: LucideIcon }; +type FixedItem = FixedLink & { children?: FixedLink[] }; // one level only — the renderer does not nest + +// Fixed top-level pages. `tkey` resolves the label via i18n. `children` renders the +// entry as a collapsible group so sub-pages are reachable from the sidebar. +const FIXED: FixedItem[] = [ { href: '/', tkey: 'nav.overview', icon: LayoutDashboard }, { href: '/ai-diagnosis', tkey: 'nav.aiDiagnosis', icon: Stethoscope }, { href: '/assistant', tkey: 'nav.assistant', icon: MessagesSquare }, @@ -33,7 +37,11 @@ const FIXED: { href: string; tkey: string; icon: LucideIcon }[] = [ { href: '/cost', tkey: 'nav.cost', icon: DollarSign }, { href: '/bedrock', tkey: 'nav.bedrock', icon: Gauge }, { href: '/agentcore', tkey: 'nav.agentcore', icon: Cpu }, - { href: '/topology', tkey: 'nav.topology', icon: Network }, + { href: '/topology', tkey: 'nav.topology', icon: Network, children: [ + { href: '/topology', tkey: 'nav.topologyFlow', icon: Route }, + { href: '/topology/infra', tkey: 'nav.topologyInfra', icon: Layers }, + { href: '/topology/services', tkey: 'nav.topologyServices', icon: Boxes }, + ] }, { href: '/security', tkey: 'nav.security', icon: Shield }, { href: '/compliance', tkey: 'nav.compliance', icon: FileSearch }, { href: '/integrations', tkey: 'nav.integrations', icon: Cable }, @@ -94,6 +102,8 @@ const FEATURE_ICON: Record = { const STORAGE_KEY = 'awsops:nav:expanded'; const gId = (slug: string) => `g:${slug}`; const sId = (key: string) => `s:${key}`; +const fId = (href: string) => `f:${href}`; +const underPath = (path: string, href: string) => path === href || path.startsWith(`${href}/`); // Seed expand state from the active path (pure, identical on server + client → no // hydration mismatch). localStorage is merged in only after mount. @@ -104,6 +114,7 @@ function seedFromPath(path: string): Set { s.add(gId(active.slug)); if (active.subgroupKey) s.add(sId(active.subgroupKey)); } + for (const item of FIXED) if (item.children && underPath(path, item.href)) s.add(fId(item.href)); return s; } @@ -168,14 +179,9 @@ export default function Sidebar({ onNavigate, className, persist = true }: { onN // Navigating into a group (or its subgroup) re-seeds it open — manual collapse // persists until the next navigation into that group. useEffect(() => { - const active = groupForPath(path); - if (!active) return; - setExpanded((prev) => { - const next = new Set(prev); - next.add(gId(active.slug)); - if (active.subgroupKey) next.add(sId(active.subgroupKey)); - return next; - }); + const seed = seedFromPath(path); + if (!seed.size) return; + setExpanded((prev) => new Set([...prev, ...seed])); }, [path]); const toggle = (id: string) => @@ -203,6 +209,56 @@ export default function Sidebar({ onNavigate, className, persist = true }: { onN /> ); + // A FIXED entry with children: header link + chevron toggle + child panel. A child is + // active on an exact match; the header is active on other sub-pages (e.g. a + // resource's relationship graph) and whenever the panel is collapsed, so the + // location is never unmarked. + function renderFixedGroup(item: FixedItem) { + const children = item.children!; + const label = t(item.tkey); + const open = expanded.has(fId(item.href)); + const panelId = `${uid}-fixed-${item.href.replace(/\W+/g, '-')}`; + const childActive = children.some((c) => path === c.href); + const headerActive = underPath(path, item.href) && (!open || !childActive); + const Icon = item.icon; + return ( +
+
+ { setExpanded((p) => new Set(p).add(fId(item.href))); onNavigate?.(); }} + aria-current={headerActive ? 'page' : undefined} + className={cn( + 'flex min-w-0 flex-1 items-center gap-2.5 rounded-md px-2.5 py-[7px] text-[13px] font-medium no-underline transition-colors duration-[120ms]', + headerActive ? 'bg-chrome-active text-chrome-active-fg shadow-sm' : 'text-chrome-fg-muted hover:bg-chrome-active/40 hover:text-chrome-fg', + )} + > + + {label} + + +
+ {open && ( +
+ {children.map((c) => ( + + ))} +
+ )} +
+ ); + } + function renderGroup(g: NavGroupNode) { const label = t(g.labelKey); @@ -309,7 +365,7 @@ export default function Sidebar({ onNavigate, className, persist = true }: { onN {/* Nav */}