From 5e866446497652bf8581bb7833fae565beb39fd6 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 20 Jun 2026 22:08:37 +0800 Subject: [PATCH] feat(dashboard): add sensitive data toggle to flow chart Add an eye toggle in the flow section header that masks sensitive node labels (users, tokens, nodes, groups, channels) in the Sankey while keeping model names visible. Masking only rewrites display text; nodes stay distinct via their key so graph structure, links, and highlighting are unaffected. --- .../dashboard/components/flow/flow-charts.tsx | 6 ++ web/default/src/features/dashboard/index.tsx | 55 ++++++++++++++++--- .../src/features/dashboard/lib/flow.ts | 51 +++++++++++++++++ web/default/src/features/dashboard/types.ts | 4 ++ web/default/src/i18n/locales/en.json | 2 + web/default/src/i18n/locales/fr.json | 2 + web/default/src/i18n/locales/ja.json | 2 + web/default/src/i18n/locales/ru.json | 2 + web/default/src/i18n/locales/vi.json | 2 + web/default/src/i18n/locales/zh.json | 2 + 10 files changed, 119 insertions(+), 9 deletions(-) diff --git a/web/default/src/features/dashboard/components/flow/flow-charts.tsx b/web/default/src/features/dashboard/components/flow/flow-charts.tsx index e57d3e8a..926042c6 100644 --- a/web/default/src/features/dashboard/components/flow/flow-charts.tsx +++ b/web/default/src/features/dashboard/components/flow/flow-charts.tsx @@ -94,6 +94,8 @@ import { FlowNodeFilterControl } from './flow-node-filter' interface FlowChartsProps { filters?: DashboardFilters + // When false, sensitive node labels are masked in the rendered Sankey. + sensitiveVisible?: boolean } const FLOW_METRIC_OPTIONS = [ @@ -340,6 +342,7 @@ export function FlowCharts(props: FlowChartsProps) { staleTime: 60_000, }) + const maskSensitive = props.sensitiveVisible === false const flowData = useMemo( () => buildDashboardFlowData(isLoading ? [] : (flowRows ?? []), metric, { @@ -351,6 +354,7 @@ export function FlowCharts(props: FlowChartsProps) { visibleStages, topNodeLimit, overflowMode, + maskSensitive, deletedTokenLabel: (tokenId) => t('Deleted ({{id}})', { id: tokenId }), otherNodeLabel: (kind) => t(FLOW_OTHER_NODE_LABEL_KEYS[kind]), }), @@ -366,6 +370,7 @@ export function FlowCharts(props: FlowChartsProps) { selectedUsers, topNodeLimit, visibleStages, + maskSensitive, t, ] ) @@ -468,6 +473,7 @@ export function FlowCharts(props: FlowChartsProps) { selectedNodes.map(flowNodeFilterKey).join(','), selectedUsers.join(','), visibleStages.join(','), + maskSensitive ? 'masked' : 'plain', flowRows?.length ?? 0, resolvedTheme, ].join('-') diff --git a/web/default/src/features/dashboard/index.tsx b/web/default/src/features/dashboard/index.tsx index 147aedb8..64f68654 100644 --- a/web/default/src/features/dashboard/index.tsx +++ b/web/default/src/features/dashboard/index.tsx @@ -18,11 +18,18 @@ For commercial licensing, please contact support@quantumnous.com */ import { useState, useCallback, useMemo, lazy, Suspense } from 'react' import { getRouteApi, useNavigate } from '@tanstack/react-router' +import { Eye, EyeOff } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useAuthStore } from '@/stores/auth-store' import { ROLE } from '@/lib/roles' +import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' import { SectionPageLayout } from '@/components/layout' import { FadeIn } from '@/components/page-transition' import { ModelsChartPreferences } from './components/models/models-chart-preferences' @@ -179,6 +186,7 @@ export function Dashboard() { } } ) + const [flowSensitiveVisible, setFlowSensitiveVisible] = useState(true) const handleFilterChange = useCallback((filters: DashboardFilters) => { setModelFilters(filters) @@ -242,14 +250,40 @@ export function Dashboard() { ) : null const flowActions = activeSection === 'flow' ? ( - + <> + + setFlowSensitiveVisible((prev) => !prev)} + aria-label={ + flowSensitiveVisible + ? t('Hide sensitive data') + : t('Show sensitive data') + } + className='text-muted-foreground hover:text-foreground size-8' + /> + } + > + {flowSensitiveVisible ? : } + + + {flowSensitiveVisible + ? t('Hide sensitive data') + : t('Show sensitive data')} + + + + ) : null const sectionActions = modelActions ?? flowActions @@ -339,7 +373,10 @@ export function Dashboard() { {activeSection === 'flow' && ( }> - + )} diff --git a/web/default/src/features/dashboard/lib/flow.ts b/web/default/src/features/dashboard/lib/flow.ts index 7d857aa8..34770b9f 100644 --- a/web/default/src/features/dashboard/lib/flow.ts +++ b/web/default/src/features/dashboard/lib/flow.ts @@ -77,6 +77,7 @@ type FlowGraphOptions = { otherNodeLabel?: (kind: FlowNodeKind) => string activeNode?: FlowNodeFilter activeLink?: FlowLinkSelection + maskSensitive?: boolean } type FlowHighlightSets = { @@ -127,6 +128,18 @@ const DEFAULT_OTHER_FLOW_NODE_LABELS: Record = { channel: 'Other channels', } +// Kinds whose labels can leak identity (people, keys, infra, business setup). +// Model names are public, so they stay visible even when masking is on. +const SENSITIVE_FLOW_KINDS = new Set([ + 'user', + 'node', + 'token', + 'group', + 'channel', +]) + +const OTHER_FLOW_NODE_ID_SET = new Set(Object.values(OTHER_FLOW_NODE_IDS)) + function numberValue(value: unknown): number { const n = Number(value) return Number.isFinite(n) ? n : 0 @@ -663,6 +676,40 @@ function buildFlowHighlightSets( } } +// Fully masks a label. Nodes stay distinct because the Sankey identifies them +// by `key` (the node id), not by this display text, so identical masked labels +// never merge. +const FLOW_MASK_TEXT = '\u2022\u2022\u2022\u2022' + +function maskFlowLabel(label: string): string { + if (label.length === 0) return label + return FLOW_MASK_TEXT +} + +// Masks sensitive node/link labels in place. Node identity (`id`) is untouched, +// so links, highlighting, and layout stay exactly the same; only the rendered +// text changes. +function maskFlowGraphLabels( + nodes: Map, + links: Map +): void { + const maskedById = new Map() + for (const node of nodes.values()) { + if (!SENSITIVE_FLOW_KINDS.has(node.kind)) continue + if (OTHER_FLOW_NODE_ID_SET.has(node.id)) continue + const masked = maskFlowLabel(node.label) + node.label = masked + maskedById.set(node.id, masked) + } + if (maskedById.size === 0) return + for (const link of links.values()) { + const sourceMasked = maskedById.get(link.source) + if (sourceMasked !== undefined) link.sourceLabel = sourceMasked + const targetMasked = maskedById.get(link.target) + if (targetMasked !== undefined) link.targetLabel = targetMasked + } +} + function applyFlowHighlights( nodes: Iterable, links: Iterable, @@ -738,6 +785,9 @@ function buildFlowGraph( addLink(links, source, target, metrics, metric, color, root.id) } } + if (options.maskSensitive) { + maskFlowGraphLabels(nodes, links) + } applyFlowHighlights( nodes.values(), links.values(), @@ -948,6 +998,7 @@ export function buildDashboardFlowData( otherNodeLabel: options.otherNodeLabel, activeNode: options.activeNode, activeLink: options.activeLink, + maskSensitive: options.maskSensitive, } ), filterOptions: { diff --git a/web/default/src/features/dashboard/types.ts b/web/default/src/features/dashboard/types.ts index df05d637..b8771df2 100644 --- a/web/default/src/features/dashboard/types.ts +++ b/web/default/src/features/dashboard/types.ts @@ -82,6 +82,10 @@ export interface FlowBuildOptions { visibleStages?: FlowNodeKind[] topNodeLimit?: number overflowMode?: FlowOverflowMode + // When true, sensitive node labels (users, tokens, nodes, groups, channels) + // are partially masked in the rendered graph while keeping node identity so + // the Sankey shape stays intact. + maskSensitive?: boolean // Resolves the label for a token whose record no longer exists (deleted). // Lets the caller inject a localized string such as "Deleted (123)". deletedTokenLabel?: (tokenId: number) => string diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 923b5fc9..5b907ec7 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -2051,6 +2051,7 @@ "Hidden — verify to reveal": "Hidden — verify to reveal", "Hide": "Hide", "Hide API key": "Hide API key", + "Hide sensitive data": "Hide sensitive data", "Hide setup guide": "Hide setup guide", "High Performance": "High Performance", "High-risk operation confirmation": "High-risk Operation Confirmation", @@ -3870,6 +3871,7 @@ "Shorten": "Shorten", "Show": "Show", "Show All": "Show All", + "Show sensitive data": "Show sensitive data", "Show all providers including unbound": "Show all providers including unbound", "Show only bound providers": "Show only bound providers", "Show or hide flow columns": "Show or hide flow columns", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 1b627834..2d08182f 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -2051,6 +2051,7 @@ "Hidden — verify to reveal": "Masqué — vérifiez pour révéler", "Hide": "Masquer", "Hide API key": "Masquer la clé API", + "Hide sensitive data": "Masquer les données sensibles", "Hide setup guide": "Masquer le guide de configuration", "High Performance": "Hautes performances", "High-risk operation confirmation": "Confirmation d'opération à haut risque", @@ -3870,6 +3871,7 @@ "Shorten": "Raccourcir", "Show": "Afficher", "Show All": "Tout afficher", + "Show sensitive data": "Afficher les données sensibles", "Show all providers including unbound": "Afficher tous les fournisseurs (y compris non liés)", "Show only bound providers": "Afficher uniquement les fournisseurs liés", "Show or hide flow columns": "Afficher ou masquer les colonnes du flux", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 4b95458b..7af0d8ea 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -2051,6 +2051,7 @@ "Hidden — verify to reveal": "非表示 — 確認して表示", "Hide": "非表示にする", "Hide API key": "APIキーを非表示", + "Hide sensitive data": "機密データを非表示", "Hide setup guide": "セットアップガイドを非表示", "High Performance": "高パフォーマンス", "High-risk operation confirmation": "高リスク操作の確認", @@ -3870,6 +3871,7 @@ "Shorten": "短縮", "Show": "表示", "Show All": "すべて表示", + "Show sensitive data": "機密データを表示", "Show all providers including unbound": "未バインドを含むすべてのプロバイダーを表示", "Show only bound providers": "バインド済みのプロバイダーのみ表示", "Show or hide flow columns": "フロー列の表示・非表示", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 2e1b32da..51edeac7 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -2051,6 +2051,7 @@ "Hidden — verify to reveal": "Скрыто — подтвердите, чтобы показать", "Hide": "Скрыть", "Hide API key": "Скрыть API ключ", + "Hide sensitive data": "Скрыть конфиденциальные данные", "Hide setup guide": "Скрыть руководство по настройке", "High Performance": "Высокая производительность", "High-risk operation confirmation": "Подтверждение высокорисковой операции", @@ -3870,6 +3871,7 @@ "Shorten": "Сократить", "Show": "Показать", "Show All": "Показать все", + "Show sensitive data": "Показать конфиденциальные данные", "Show all providers including unbound": "Показать всех провайдеров (включая непривязанные)", "Show only bound providers": "Показать только привязанных провайдеров", "Show or hide flow columns": "Показать или скрыть столбцы потока", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 15b0da7d..30ef42c7 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -2051,6 +2051,7 @@ "Hidden — verify to reveal": "Ẩn — xác minh để hiển thị", "Hide": "Ẩn", "Hide API key": "Ẩn khóa API", + "Hide sensitive data": "Ẩn dữ liệu nhạy cảm", "Hide setup guide": "Ẩn hướng dẫn thiết lập", "High Performance": "Hiệu suất cao", "High-risk operation confirmation": "Xác nhận thao tác rủi ro cao", @@ -3870,6 +3871,7 @@ "Shorten": "Rút gọn", "Show": "Hiển thị", "Show All": "Hiển thị tất cả", + "Show sensitive data": "Hiển thị dữ liệu nhạy cảm", "Show all providers including unbound": "Hiển thị tất cả nhà cung cấp (bao gồm chưa liên kết)", "Show only bound providers": "Chỉ hiển thị nhà cung cấp đã liên kết", "Show or hide flow columns": "Hiện hoặc ẩn các cột luồng", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 32ed0f6e..7cb76711 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -2051,6 +2051,7 @@ "Hidden — verify to reveal": "隐藏 — 验证以显示", "Hide": "隐藏", "Hide API key": "隐藏 API 密钥", + "Hide sensitive data": "隐藏敏感数据", "Hide setup guide": "隐藏设置引导", "High Performance": "高性能", "High-risk operation confirmation": "高危操作确认", @@ -3870,6 +3871,7 @@ "Shorten": "缩词", "Show": "显示", "Show All": "显示全部", + "Show sensitive data": "显示敏感数据", "Show all providers including unbound": "显示所有提供商(包括未绑定)", "Show only bound providers": "仅显示已绑定的提供商", "Show or hide flow columns": "显示或隐藏分流列",