Dashboard Studio: unified customization panel#5164
Conversation
- useWorkloadMonitor: wrap fetchData in useCallback with refs for params - PDDisaggregation: memoize prefillIds/decodeIds, use string key for deps - EPPRouting: remove unstable deps from effect - GitHubActivity: remove fetchGitHubData from effect deps - LatencyBreakdown/ThroughputComparison: fix empty key warnings Signed-off-by: Andrew Anderson <[email protected]>
- Layout: wrap Outlet in Suspense to prevent full-page flash on navigation - AlertsContext: memoize stats, activeAlerts, acknowledgedAlerts - StackContext: memoize liveStacks, healthyStacks, disaggregatedStacks - useInsightEnrichment: stabilize heuristicInsights dep with length key - workloads: downgrade auth errors to console.debug (5 hooks) - useTopology: suppress 401 errors in demo mode - sseClient: skip retries on 401 auth errors Signed-off-by: Andrew Anderson <[email protected]>
- Mock /prometheus/query with plausible vLLM metric values - Return transparent PNG for github.com/*.png to avoid CSP violation - Add avatars.githubusercontent.com passthrough Signed-off-by: Andrew Anderson <[email protected]>
Reintroduces the Dashboard Studio (originally #4365) with all stability fixes applied. The unified panel provides: - Card catalog with search and category filtering - Dashboard settings (name, layout, theme) - Template gallery for quick dashboard creation - AI suggestions for card placement - Widget export functionality - Navigation section for dashboard switching Includes TypeScript fixes for React 19 compatibility and test fixes. Does NOT include the memoization removal that caused infinite loops. Signed-off-by: Andrew Anderson <[email protected]>
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
✅ Deploy Preview for kubestellarconsole ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
👋 Hey @clubanderson — thanks for opening this PR!
This is an automated message. |
|
Thank you for your contribution! Your PR has been merged. Check out what's new:
Stay connected: Slack #kubestellar-dev | Multi-Cluster Survey |
|
Post-merge build verification passed ✅ Both Go and frontend builds compiled successfully against merge commit |
There was a problem hiding this comment.
Pull request overview
Reintroduces Dashboard Studio / Console Studio as a unified customization panel for dashboards, consolidating card browsing/AI suggestions/templates/navigation/widget export into a single experience while addressing prior infinite re-render concerns (memoization + dependency stabilization).
Changes:
- Replaces legacy Add Card/Templates flows with the new
DashboardCustomizerand updates FAB/keyboard shortcut behavior. - Adds “embedded” rendering mode to several existing modals/components to support inline rendering inside Console Studio.
- Introduces multiple “stabilization” changes (memoization, effect dep adjustments, demo-mode auth-error suppression) across hooks/contexts/cards.
Reviewed changes
Copilot reviewed 48 out of 48 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| web/src/mocks/handlers.ts | Adds GitHub avatar handling + Prometheus query mock for demo/test environments. |
| web/src/locales/en/common.json | Adds Console Studio strings; renames some factory tab labels. |
| web/src/lib/sseClient.ts | Skips SSE retries on auth-like errors in demo mode. |
| web/src/lib/modals/useModalNavigation.ts | Prevents Escape bubbling to parent modals in nested-modal scenarios. |
| web/src/lib/dashboards/DashboardPage.tsx | Swaps AddCard/Templates modals for DashboardCustomizer and wires URL params to sections. |
| web/src/hooks/useWorkloadMonitor.ts | Refactors polling logic to use refs/callbacks to reduce effect churn. |
| web/src/hooks/useTopology.ts | Suppresses expected demo-mode 401 logging noise. |
| web/src/hooks/useSidebarConfig.ts | Adds preview/apply flow for “generate from behavior” sidebar config changes. |
| web/src/hooks/useInsightEnrichment.ts | Attempts to reduce enrichment re-triggers by stabilizing effect deps. |
| web/src/hooks/useDashboardContext.tsx | Extends dashboard context to support opening Studio by section + widget preselect. |
| web/src/hooks/mcp/workloads.ts | Suppresses expected unauthenticated logging in demo mode. |
| web/src/contexts/StackContext.tsx | Memoizes derived arrays to prevent unstable references/loops. |
| web/src/contexts/AlertsContext.tsx | Memoizes derived alert stats/lists to stabilize context consumers. |
| web/src/components/widgets/WidgetExportModal.tsx | Adds embedded mode for inline use in Console Studio. |
| web/src/components/layout/SidebarCustomizer.tsx | Refactors dashboards/sidebar customization UI; adds preview/confirm apply flow and embedded rendering. |
| web/src/components/layout/Sidebar.tsx | Routes “Add more dashboards” to open Console Studio on Dashboards section when available. |
| web/src/components/layout/Layout.tsx | Wraps routed content (Outlet) in Suspense with skeleton fallback. |
| web/src/components/dashboard/templates.ts | Replaces emoji icons with Lucide icon names for templates/categories. |
| web/src/components/dashboard/StatBlockFactoryModal.tsx | Adds embedded mode + refactors modal content to reusable inner layout. |
| web/src/components/dashboard/shared/CardPreview.tsx | New shared mini card visualization preview extracted for reuse. |
| web/src/components/dashboard/FloatingDashboardActions.tsx | Adds unified “Studio” FAB mode (palette icon, Cmd/Ctrl+K) + inline undo/redo/reset. |
| web/src/components/dashboard/DashboardDropZone.tsx | Adds droppable “Create New Dashboard” target for drag-and-drop. |
| web/src/components/dashboard/Dashboard.tsx | Wires main dashboard to DashboardCustomizer; supports drop-to-create-dashboard; removes Templates/AddCard modals. |
| web/src/components/dashboard/customizer/sections/UnifiedCardsSection.tsx | New unified cards view combining AI suggestions + catalog browsing. |
| web/src/components/dashboard/customizer/sections/TemplateGallerySection.tsx | New embedded template/collection gallery section for Studio. |
| web/src/components/dashboard/customizer/sections/NavigationSection.tsx | New embedded navigation management section using SidebarCustomizer. |
| web/src/components/dashboard/customizer/sections/DashboardSettingsSection.tsx | New settings section (health/export/reset) component (not yet wired into Studio nav). |
| web/src/components/dashboard/customizer/sections/CardCatalogSection.tsx | New extracted browse catalog section (legacy-style) with factories + recommended cards. |
| web/src/components/dashboard/customizer/sections/AISuggestionsSection.tsx | New extracted AI suggestions section (legacy-style). |
| web/src/components/dashboard/customizer/SectionLayout.tsx | New shared layout wrapper for Studio sections. |
| web/src/components/dashboard/customizer/PreviewPanel.tsx | New right-side hover preview panel for cards. |
| web/src/components/dashboard/customizer/DashboardCustomizerSidebar.tsx | New left navigation for Studio sections. |
| web/src/components/dashboard/customizer/DashboardCustomizer.tsx | New Console Studio modal orchestration and section rendering. |
| web/src/components/dashboard/customizer/customizerNav.ts | Defines Studio nav structure (currently hard-coded labels). |
| web/src/components/dashboard/customizer/AIAssistBar.tsx | New reusable AI assist input (not yet broadly adopted by sections). |
| web/src/components/dashboard/customizer/tests/sections.test.tsx | Adds basic export/smoke tests for new section components. |
| web/src/components/dashboard/customizer/tests/DashboardCustomizer.test.tsx | Adds basic export/smoke tests for Studio components. |
| web/src/components/dashboard/customizer/tests/comprehensive.test.tsx | Adds broader structural/export/data-module tests for Studio. |
| web/src/components/dashboard/CreateDashboardModal.tsx | Adds embedded mode; switches template icons to Lucide via getIcon; removes health banner. |
| web/src/components/dashboard/CardFactoryModal.tsx | Adds embedded mode and refactors layout; updates styling sizes. |
| web/src/components/cards/llmd/ThroughputComparison.tsx | Makes legend keys robust when shortVariant is empty. |
| web/src/components/cards/llmd/PDDisaggregation.tsx | Adjusts effect deps + memoizes derived ID lists to avoid loops. |
| web/src/components/cards/llmd/LatencyBreakdown.tsx | Makes legend keys robust when shortVariant is empty. |
| web/src/components/cards/llmd/EPPRouting.tsx | Adjusts effect deps to avoid loops (currently disables deps). |
| web/src/components/cards/GitHubActivity.tsx | Adjusts polling effect deps to avoid re-render loops. |
| web/src/components/cards/CardWrapper.tsx | Routes “Export as Widget” through Console Studio when available. |
| @@ -142,7 +152,7 @@ export function useWorkloadMonitor( | |||
| intervalRef.current = null | |||
| } | |||
| } | |||
| }, [enabled, fetchData, refreshMs]) | |||
| }, [fetchData]) | |||
There was a problem hiding this comment.
The hook’s effect is guarded by initRef and only runs once. This breaks expected behavior when enabled or autoRefreshMs change after mount (e.g., the existing test case “resets state when disabled”), and it can leave the refresh interval running even after enabled becomes false.
Consider restoring an effect that reacts to enabled/refreshMs changes (clearing any existing interval and resetting state on disable), while still keeping fetchData stable via refs/useCallback if needed.
| // Stabilize with a length-based key to avoid re-firing on every new array reference | ||
| const insightsKey = heuristicInsights.length | ||
| useEffect(() => { | ||
| if (heuristicInsights.length > 0) { | ||
| if (insightsKey > 0) { | ||
| triggerEnrichment() | ||
| } | ||
| return () => { | ||
| if (requestRef.current) clearTimeout(requestRef.current) | ||
| } | ||
| }, [heuristicInsights, triggerEnrichment]) | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [insightsKey, triggerEnrichment]) |
There was a problem hiding this comment.
insightsKey is derived only from heuristicInsights.length, so the enrichment request won’t re-trigger when the insights change but the length stays the same (e.g., rerendering with a different single insight). This breaks the debounce/reset behavior verified in useInsightEnrichment.test.ts (rapid updates test) and can leave enrichments stale.
Use a stable content-based key instead (e.g., hash/join of insight IDs + lastUpdated/severity), or keep the array in deps and stabilize upstream.
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, []) |
There was a problem hiding this comment.
The periodic update effect is now useEffect(..., []) but reads stackServers from closure. If selectedStack, isDemoMode, or prometheusMetrics change, stackServers changes, but servers will keep updating from the initial stackServers only, leaving the card stale.
Include stackServers (or a stable key derived from it) in the dependency array, or store stackServers in a ref that the interval callback reads.
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, []) | |
| }, [stackServers]) |
| // Update metrics — uses Prometheus when available, falls back to simulated | ||
| useEffect(() => { | ||
| const updateMetrics = () => { | ||
| const newMetrics: Record<string, { load: number; rps: number }> = {} | ||
| dynamicNodes.forEach(node => { | ||
| if (node.type !== 'source') { | ||
| // Try to get real metrics from Prometheus | ||
| const pods = nodePodMap[node.id] | ||
| const pod = pods?.[0] | ||
| const prom = pod && prometheusMetrics?.[pod] | ||
| if (prom) { | ||
| newMetrics[node.id] = { | ||
| load: Math.round(prom.kvCacheUsage * 100), | ||
| rps: Math.round(prom.throughputTps) } | ||
| } else { | ||
| newMetrics[node.id] = { | ||
| load: Math.floor(40 + Math.random() * 50), | ||
| rps: Math.floor(80 + Math.random() * 150) } | ||
| } | ||
| } | ||
| }) | ||
| setNodeMetrics(newMetrics) | ||
|
|
||
| // Update history | ||
| setMetricsHistory(prev => { | ||
| const updated = { ...prev } | ||
| Object.entries(newMetrics).forEach(([id, m]) => { | ||
| if (!updated[id]) { | ||
| updated[id] = { load: [], rps: [] } | ||
| } | ||
| updated[id] = { | ||
| load: [...updated[id].load.slice(-19), m.load], | ||
| rps: [...updated[id].rps.slice(-19), m.rps] } | ||
| }) | ||
| return updated | ||
| }) | ||
| } | ||
|
|
||
| updateMetrics() | ||
| const interval = setInterval(updateMetrics, POLL_INTERVAL_FAST_MS) | ||
| return () => clearInterval(interval) | ||
| }, [dynamicNodes, nodePodMap, prometheusMetrics]) | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, []) |
There was a problem hiding this comment.
The metrics polling effect was changed to useEffect(..., []), but updateMetrics uses dynamicNodes, nodePodMap, and prometheusMetrics. With an empty dep array, the interval callback will keep using the initial values and won’t reflect topology changes or new Prometheus data.
Consider including a stable deps key (e.g., dynamicNodesKey, nodePodMapKey, and prometheusMetricsKey) or reading the latest values from refs inside updateMetrics instead of suppressing exhaustive-deps.
| interface DashboardCustomizerProps { | ||
| isOpen: boolean | ||
| onClose: () => void | ||
| /** Name of the dashboard being customized */ | ||
| dashboardName?: string | ||
| onAddCards: (cards: CardSuggestion[]) => void | ||
| existingCardTypes?: string[] | ||
| initialSection?: CustomizerSection | ||
| /** Pre-selected card type for widget export (from card menu "Export as Widget") */ | ||
| initialWidgetCardType?: string | ||
| initialSearch?: string | ||
| onApplyTemplate?: (template: DashboardTemplate) => void | ||
| onExport?: () => void | ||
| onReset?: () => void | ||
| isCustomized?: boolean | ||
| onUndo?: () => void | ||
| onRedo?: () => void | ||
| canUndo?: boolean | ||
| canRedo?: boolean | ||
| } | ||
|
|
||
| const SECTIONS_WITH_PREVIEW = new Set<CustomizerSection>(['cards', 'collections']) | ||
|
|
||
| export function DashboardCustomizer({ | ||
| isOpen, | ||
| onClose, | ||
| dashboardName, | ||
| onAddCards, | ||
| existingCardTypes = [], | ||
| initialSection, | ||
| initialWidgetCardType, | ||
| initialSearch = '', | ||
| onApplyTemplate, | ||
| onReset, | ||
| isCustomized = false, | ||
| onUndo, | ||
| onRedo, | ||
| canUndo = false, | ||
| canRedo = false, | ||
| }: DashboardCustomizerProps) { |
There was a problem hiding this comment.
DashboardCustomizerProps includes onExport, but the component never destructures or uses it, so the export action passed from Dashboard.tsx is currently unreachable from Console Studio. There’s also a DashboardSettingsSection component added in this PR, but it isn’t wired into the Studio nav.
Either add a Settings/Export section (and nav item) that uses onExport/onReset, or remove the prop to avoid a false API surface.
| if (sortedPaths.length > 0) { | ||
| generateFromBehavior(sortedPaths) | ||
| setGenerationResult(t('sidebar.customizer.analyzed', { count: navHistory.length })) | ||
| const preview = previewGenerateFromBehavior(sortedPaths) | ||
| if (preview.changes.length === 1 && preview.changes[0] === 'No changes needed') { | ||
| setGenerationResult('No changes needed — your sidebar already matches your usage.') | ||
| dismissTimerRef.current = setTimeout(() => setGenerationResult(null), AUTO_DISMISS_MS) | ||
| } else { | ||
| setPendingChanges(preview) | ||
| } | ||
| } else { | ||
| setGenerationResult(t('sidebar.customizer.notEnoughData')) | ||
| const AUTO_DISMISS_MS = 5000 | ||
| dismissTimerRef.current = setTimeout(() => setGenerationResult(null), AUTO_DISMISS_MS) | ||
| } |
There was a problem hiding this comment.
Many user-facing strings in this updated SidebarCustomizer are now hard-coded (e.g., “No changes needed — …”, “Applied X changes”, “Reset Sidebar”, placeholders/titles). This bypasses the existing i18n approach used elsewhere in the component (t('sidebar.customizer.*')) and makes localization inconsistent.
Prefer adding translation keys and using t(...) for these strings; also avoid redeclaring AUTO_DISMISS_MS inside the else-branch since a module-level constant already exists.
| label: string | ||
| icon: LucideIcon | ||
| /** Show a subtle divider line above this item */ | ||
| dividerBefore?: boolean | ||
| } | ||
|
|
||
| export const CUSTOMIZER_NAV: NavItem[] = [ | ||
| { id: 'cards', label: 'Add Cards', icon: LayoutGrid }, | ||
| { id: 'collections', label: 'Add Card Collections', icon: Layout }, | ||
| { id: 'dashboards', label: 'Manage Dashboards', icon: LayoutDashboard }, | ||
| { id: 'widgets', label: 'Export Widgets', icon: Download }, | ||
| { id: 'create-dashboard', label: 'Create Custom Dashboard', icon: FolderPlus, dividerBefore: true }, | ||
| { id: 'card-factory', label: 'Create Custom Card', icon: Wand2 }, | ||
| { id: 'stat-factory', label: 'Create Stat Blocks', icon: Activity }, |
There was a problem hiding this comment.
The Console Studio nav labels are hard-coded English strings. Since the app uses i18next (and this PR adds dashboard.studio.* locale keys), these labels should be sourced from translations to keep localization consistent.
Consider storing translation keys (not labels) in CUSTOMIZER_NAV and resolving them with t(...) in the sidebar component.
| label: string | |
| icon: LucideIcon | |
| /** Show a subtle divider line above this item */ | |
| dividerBefore?: boolean | |
| } | |
| export const CUSTOMIZER_NAV: NavItem[] = [ | |
| { id: 'cards', label: 'Add Cards', icon: LayoutGrid }, | |
| { id: 'collections', label: 'Add Card Collections', icon: Layout }, | |
| { id: 'dashboards', label: 'Manage Dashboards', icon: LayoutDashboard }, | |
| { id: 'widgets', label: 'Export Widgets', icon: Download }, | |
| { id: 'create-dashboard', label: 'Create Custom Dashboard', icon: FolderPlus, dividerBefore: true }, | |
| { id: 'card-factory', label: 'Create Custom Card', icon: Wand2 }, | |
| { id: 'stat-factory', label: 'Create Stat Blocks', icon: Activity }, | |
| labelKey: string | |
| icon: LucideIcon | |
| /** Show a subtle divider line above this item */ | |
| dividerBefore?: boolean | |
| } | |
| export const CUSTOMIZER_NAV: NavItem[] = [ | |
| { id: 'cards', labelKey: 'dashboard.studio.addCards', icon: LayoutGrid }, | |
| { id: 'collections', labelKey: 'dashboard.studio.addCardCollections', icon: Layout }, | |
| { id: 'dashboards', labelKey: 'dashboard.studio.manageDashboards', icon: LayoutDashboard }, | |
| { id: 'widgets', labelKey: 'dashboard.studio.exportWidgets', icon: Download }, | |
| { id: 'create-dashboard', labelKey: 'dashboard.studio.createCustomDashboard', icon: FolderPlus, dividerBefore: true }, | |
| { id: 'card-factory', labelKey: 'dashboard.studio.createCustomCard', icon: Wand2 }, | |
| { id: 'stat-factory', labelKey: 'dashboard.studio.createStatBlocks', icon: Activity }, |
| {/* Single unified search bar */} | ||
| <div className="mb-3"> | ||
| <p className="text-xs text-muted-foreground mb-2"> | ||
| Search the card catalog or describe what you need — cards will be added to the {dashboardLabel} dashboard | ||
| </p> | ||
| <div className="flex gap-2"> | ||
| <div className="relative flex-1"> | ||
| <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> | ||
| <input | ||
| ref={searchInputRef} | ||
| type="text" | ||
| value={browseSearch} | ||
| onChange={(e) => handleUnifiedSearch(e.target.value)} | ||
| onKeyDown={(e) => e.key === 'Enter' && browseSearch.trim() && handleGenerateWithQuery(browseSearch)} | ||
| placeholder="Search cards or describe what you want to monitor..." | ||
| className="w-full pl-10 pr-4 py-2 bg-secondary rounded-lg text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-purple-500/50" | ||
| /> | ||
| </div> | ||
| <button | ||
| onClick={() => handleGenerateWithQuery(browseSearch)} | ||
| disabled={!browseSearch.trim() || isGenerating} | ||
| className="px-3 py-2 bg-gradient-ks text-primary-foreground rounded-lg text-sm font-medium disabled:opacity-50 flex items-center gap-1.5 whitespace-nowrap" | ||
| title="Use AI to suggest cards based on your query" | ||
| > | ||
| {isGenerating ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Sparkles className="w-3.5 h-3.5" />} | ||
| {isGenerating ? 'Thinking...' : 'AI Suggest'} | ||
| </button> | ||
| </div> | ||
| {/* Quick AI prompts — click to auto-generate card suggestions */} | ||
| <div className="flex flex-wrap items-center gap-1.5 mt-2"> | ||
| <span className="text-xs text-muted-foreground mr-1">Try:</span> | ||
| {aiExamples.map((example) => ( |
There was a problem hiding this comment.
This section introduces several new user-facing strings as literals (e.g., helper text, placeholders, button labels like “AI Suggest”/“Thinking…”, “Try:”, “Clear & show all cards”). Given the existing i18n usage (t(...)) and the new dashboard.studio locale entries, these should be translated to avoid regressions for non-English locales.
Consider replacing these literals with t(...) keys (with sensible defaults) and/or moving them into common.json.
| {activeSection === 'collections' && onApplyTemplate && ( | ||
| <TemplateGallerySection | ||
| onReplaceWithTemplate={handleApplyTemplate} | ||
| onAddTemplate={(template) => { | ||
| // Convert template cards to CardSuggestion format and add | ||
| const cards = (template.cards || []).map(c => ({ | ||
| type: c.card_type, | ||
| title: c.card_type, | ||
| description: '', | ||
| visualization: 'status' as const, | ||
| config: c.config || {}, | ||
| })) | ||
| handleAddCards(cards) | ||
| }} |
There was a problem hiding this comment.
When using “Add” for a collection, template cards are converted to CardSuggestion with title: c.card_type and description: ''. In Dashboard.tsx, handleAddCards uses s.title as the card title, so these cards will show raw type IDs instead of the template’s intended tc.title (and lose descriptions).
Consider passing through c.title (or a formatted title) and description when building the suggestions.
🔄 Auto-Applying Copilot Code ReviewCopilot code review found 2 code suggestion(s) and 7 general comment(s). @copilot Please apply all of the following code review suggestions:
Also address these general comments:
Push all fixes in a single commit. Run Auto-generated by copilot-review-apply workflow. |
✅ Post-Merge Verification: passedCommit: |
Summary
Test plan