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.
This commit is contained in:
CaIon
2026-06-20 22:09:04 +08:00
parent 8ad83bf62f
commit 5e86644649
10 changed files with 119 additions and 9 deletions
@@ -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('-')
+46 -9
View File
@@ -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' ? (
<ModelsFilter
preferences={chartPreferences}
currentFilters={modelFilters}
onFilterChange={handleFilterChange}
onReset={handleResetFilters}
titleKey='Flow Filters'
descriptionKey='Filter the traffic flow view by time range and user.'
/>
<>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon'
onClick={() => setFlowSensitiveVisible((prev) => !prev)}
aria-label={
flowSensitiveVisible
? t('Hide sensitive data')
: t('Show sensitive data')
}
className='text-muted-foreground hover:text-foreground size-8'
/>
}
>
{flowSensitiveVisible ? <Eye /> : <EyeOff />}
</TooltipTrigger>
<TooltipContent>
{flowSensitiveVisible
? t('Hide sensitive data')
: t('Show sensitive data')}
</TooltipContent>
</Tooltip>
<ModelsFilter
preferences={chartPreferences}
currentFilters={modelFilters}
onFilterChange={handleFilterChange}
onReset={handleResetFilters}
titleKey='Flow Filters'
descriptionKey='Filter the traffic flow view by time range and user.'
/>
</>
) : null
const sectionActions = modelActions ?? flowActions
@@ -339,7 +373,10 @@ export function Dashboard() {
{activeSection === 'flow' && (
<FadeIn>
<Suspense fallback={<ModelChartsFallback />}>
<LazyFlowCharts filters={modelFilters} />
<LazyFlowCharts
filters={modelFilters}
sensitiveVisible={flowSensitiveVisible}
/>
</Suspense>
</FadeIn>
)}
+51
View File
@@ -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<FlowNodeKind, string> = {
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<FlowNodeKind>([
'user',
'node',
'token',
'group',
'channel',
])
const OTHER_FLOW_NODE_ID_SET = new Set<string>(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<string, DashboardFlowNode>,
links: Map<string, DashboardFlowLink>
): void {
const maskedById = new Map<string, string>()
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<DashboardFlowNode>,
links: Iterable<DashboardFlowLink>,
@@ -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: {
+4
View File
@@ -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