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:
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user