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": "显示或隐藏分流列",