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