diff --git a/frontend/docs/dashboard-customization.md b/frontend/docs/dashboard-customization.md index ac4190d6..90143e30 100644 --- a/frontend/docs/dashboard-customization.md +++ b/frontend/docs/dashboard-customization.md @@ -6,6 +6,12 @@ - Drag-and-drop reorder of widgets in the customization panel. - Per-widget size controls (`small`, `medium`, `large`). - Preset layouts (`default`, `compact`, `operations`, `analyst`). +- Asset filter panel with multi-select assets and bridges. +- Status filter (`all`, `healthy`, `warning`, `critical`). +- Time range presets (`all`, `24h`, `7d`, `30d`) applied to asset health update time. +- Saved filter presets for quick re-use. +- Clear-all action for active filters. +- URL-persisted filter state for shareable dashboard views. - Reset to default layout. - Layout export/import via JSON payload. - Local persistence through browser storage. @@ -14,9 +20,22 @@ ## Main files - `src/hooks/useDashboardLayout.ts` +- `src/hooks/useDashboardFilters.ts` - `src/components/dashboard/WidgetGallery.tsx` +- `src/components/Filters/AssetFilterPanel.tsx` - `src/pages/Dashboard.tsx` +## Dashboard filter URL parameters + +- `assets`: comma-separated asset symbols (example: `USDC,EURC`). +- `bridges`: comma-separated bridge names (example: `Circle,Wormhole`). +- `status`: one of `all`, `healthy`, `warning`, `critical`. +- `range`: one of `all`, `24h`, `7d`, `30d`. + +Example: + +`/dashboard?assets=USDC,EURC&bridges=Circle&status=warning&range=7d` + ## Persistence format ```json diff --git a/frontend/src/components/Filters/AssetFilterPanel.tsx b/frontend/src/components/Filters/AssetFilterPanel.tsx new file mode 100644 index 00000000..fe95982f --- /dev/null +++ b/frontend/src/components/Filters/AssetFilterPanel.tsx @@ -0,0 +1,264 @@ +import { useMemo, useState } from "react"; +import type { FilterStatus } from "../../types"; +import type { + DashboardFilterPreset, + DashboardFilters, + DashboardTimeRangePreset, +} from "../../hooks/useDashboardFilters"; + +interface AssetFilterPanelProps { + assets: string[]; + bridges: string[]; + filters: DashboardFilters; + savedPresets: DashboardFilterPreset[]; + hasActiveFilters: boolean; + onToggleAsset: (asset: string) => void; + onToggleBridge: (bridge: string) => void; + onStatusChange: (status: FilterStatus) => void; + onTimeRangeChange: (timeRange: DashboardTimeRangePreset) => void; + onClearAll: () => void; + onSavePreset: (name: string) => boolean; + onApplyPreset: (id: string) => void; + onDeletePreset: (id: string) => void; +} + +const STATUS_OPTIONS: Array<{ value: FilterStatus; label: string }> = [ + { value: "all", label: "All" }, + { value: "healthy", label: "Healthy" }, + { value: "warning", label: "Warning" }, + { value: "critical", label: "Critical" }, +]; + +const TIME_RANGE_OPTIONS: Array<{ value: DashboardTimeRangePreset; label: string }> = [ + { value: "all", label: "All time" }, + { value: "24h", label: "Last 24h" }, + { value: "7d", label: "Last 7d" }, + { value: "30d", label: "Last 30d" }, +]; + +function SelectionGroup({ + title, + items, + selected, + groupId, + onToggle, +}: { + title: string; + items: string[]; + selected: string[]; + groupId: string; + onToggle: (value: string) => void; +}) { + return ( +
+ {title} +
+ {items.length === 0 ? ( +

No options available

+ ) : ( + + )} +
+
+ ); +} + +export default function AssetFilterPanel({ + assets, + bridges, + filters, + savedPresets, + hasActiveFilters, + onToggleAsset, + onToggleBridge, + onStatusChange, + onTimeRangeChange, + onClearAll, + onSavePreset, + onApplyPreset, + onDeletePreset, +}: AssetFilterPanelProps) { + const [presetName, setPresetName] = useState(""); + const [selectedPresetId, setSelectedPresetId] = useState(""); + + const selectedPreset = useMemo( + () => savedPresets.find((preset) => preset.id === selectedPresetId) ?? null, + [savedPresets, selectedPresetId], + ); + + function handleSavePreset() { + const wasSaved = onSavePreset(presetName); + if (wasSaved) { + setPresetName(""); + } + } + + function handleApplyPreset() { + if (!selectedPresetId) return; + onApplyPreset(selectedPresetId); + } + + function handleDeletePreset() { + if (!selectedPreset) return; + onDeletePreset(selectedPreset.id); + setSelectedPresetId(""); + } + + return ( +
+
+

+ Filter Panel +

+ +
+ +
+
+ +
+ setPresetName(event.target.value)} + placeholder="Preset name" + className="w-full rounded-md border border-stellar-border bg-stellar-dark px-3 py-2 text-sm text-stellar-text-primary placeholder:text-stellar-text-secondary focus:outline-none focus:ring-2 focus:ring-stellar-blue" + /> + +
+
+ +
+ +
+ + + +
+
+
+ +
+ + + +
+ +
+
+ + +
+ +
+ Time range +
+ {TIME_RANGE_OPTIONS.map((option) => ( + + ))} +
+
+
+
+ ); +} diff --git a/frontend/src/hooks/useDashboardFilters.test.ts b/frontend/src/hooks/useDashboardFilters.test.ts new file mode 100644 index 00000000..98b22e51 --- /dev/null +++ b/frontend/src/hooks/useDashboardFilters.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; +import { + DEFAULT_DASHBOARD_FILTERS, + buildDashboardSearchParams, + isDashboardFilterActive, + isTimestampInRange, + parseDashboardFilters, +} from "./useDashboardFilters"; + +describe("useDashboardFilters helpers", () => { + it("parses valid URL filter params", () => { + const params = new URLSearchParams( + "assets=USDC,EURC&bridges=Circle,Wormhole&status=warning&range=7d", + ); + + const result = parseDashboardFilters(params); + + expect(result).toEqual({ + assets: ["EURC", "USDC"], + bridges: ["Circle", "Wormhole"], + status: "warning", + timeRange: "7d", + }); + }); + + it("falls back to defaults for invalid status and range", () => { + const params = new URLSearchParams("status=invalid&range=100d"); + + const result = parseDashboardFilters(params); + + expect(result).toEqual(DEFAULT_DASHBOARD_FILTERS); + }); + + it("serializes only active filters into URL params", () => { + const params = buildDashboardSearchParams({ + assets: ["EURC", "USDC", "EURC"], + bridges: ["Circle"], + status: "healthy", + timeRange: "24h", + }); + + expect(params.toString()).toBe("assets=EURC%2CUSDC&bridges=Circle&status=healthy&range=24h"); + }); + + it("evaluates time range and active filter state correctly", () => { + const now = new Date("2026-04-24T12:00:00.000Z"); + + expect(isTimestampInRange("2026-04-24T11:30:00.000Z", "24h", now)).toBe(true); + expect(isTimestampInRange("2026-04-22T11:30:00.000Z", "24h", now)).toBe(false); + expect(isTimestampInRange(undefined, "7d", now)).toBe(false); + + expect( + isDashboardFilterActive({ + assets: [], + bridges: [], + status: "all", + timeRange: "all", + }), + ).toBe(false); + + expect( + isDashboardFilterActive({ + assets: ["USDC"], + bridges: [], + status: "all", + timeRange: "all", + }), + ).toBe(true); + }); +}); diff --git a/frontend/src/hooks/useDashboardFilters.ts b/frontend/src/hooks/useDashboardFilters.ts new file mode 100644 index 00000000..a4086ec2 --- /dev/null +++ b/frontend/src/hooks/useDashboardFilters.ts @@ -0,0 +1,237 @@ +import { useCallback, useMemo } from "react"; +import { useSearchParams } from "react-router-dom"; +import type { FilterStatus } from "../types"; +import { useLocalStorageState } from "./useLocalStorageState"; + +export type DashboardTimeRangePreset = "all" | "24h" | "7d" | "30d"; + +export interface DashboardFilters { + assets: string[]; + bridges: string[]; + status: FilterStatus; + timeRange: DashboardTimeRangePreset; +} + +export interface DashboardFilterPreset { + id: string; + name: string; + filters: DashboardFilters; +} + +const FILTER_PRESET_STORAGE_KEY = "bridge-watch:dashboard-filter-presets:v1"; +const VALID_STATUSES: FilterStatus[] = ["all", "healthy", "warning", "critical"]; +const VALID_TIME_RANGES: DashboardTimeRangePreset[] = ["all", "24h", "7d", "30d"]; + +export const DEFAULT_DASHBOARD_FILTERS: DashboardFilters = { + assets: [], + bridges: [], + status: "all", + timeRange: "all", +}; + +function normalizeList(value: string | null): string[] { + if (!value) return []; + + const cleaned = value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + + return Array.from(new Set(cleaned)).sort((a, b) => a.localeCompare(b)); +} + +export function parseDashboardFilters(params: URLSearchParams): DashboardFilters { + const rawStatus = params.get("status"); + const rawTimeRange = params.get("range"); + + const status = VALID_STATUSES.includes(rawStatus as FilterStatus) + ? (rawStatus as FilterStatus) + : DEFAULT_DASHBOARD_FILTERS.status; + + const timeRange = VALID_TIME_RANGES.includes(rawTimeRange as DashboardTimeRangePreset) + ? (rawTimeRange as DashboardTimeRangePreset) + : DEFAULT_DASHBOARD_FILTERS.timeRange; + + return { + assets: normalizeList(params.get("assets")), + bridges: normalizeList(params.get("bridges")), + status, + timeRange, + }; +} + +export function buildDashboardSearchParams(filters: DashboardFilters): URLSearchParams { + const params = new URLSearchParams(); + + if (filters.assets.length > 0) { + params.set("assets", Array.from(new Set(filters.assets)).sort((a, b) => a.localeCompare(b)).join(",")); + } + + if (filters.bridges.length > 0) { + params.set("bridges", Array.from(new Set(filters.bridges)).sort((a, b) => a.localeCompare(b)).join(",")); + } + + if (filters.status !== DEFAULT_DASHBOARD_FILTERS.status) { + params.set("status", filters.status); + } + + if (filters.timeRange !== DEFAULT_DASHBOARD_FILTERS.timeRange) { + params.set("range", filters.timeRange); + } + + return params; +} + +export function isDashboardFilterActive(filters: DashboardFilters): boolean { + return ( + filters.assets.length > 0 || + filters.bridges.length > 0 || + filters.status !== "all" || + filters.timeRange !== "all" + ); +} + +export function isTimestampInRange( + timestamp: string | null | undefined, + timeRange: DashboardTimeRangePreset, + now = new Date(), +): boolean { + if (timeRange === "all") return true; + if (!timestamp) return false; + + const value = new Date(timestamp); + if (Number.isNaN(value.getTime())) return false; + + const msByRange: Record, number> = { + "24h": 24 * 60 * 60 * 1000, + "7d": 7 * 24 * 60 * 60 * 1000, + "30d": 30 * 24 * 60 * 60 * 1000, + }; + + const cutoff = now.getTime() - msByRange[timeRange]; + return value.getTime() >= cutoff; +} + +function toggleSelection(values: string[], candidate: string): string[] { + if (values.includes(candidate)) { + return values.filter((value) => value !== candidate); + } + + return [...values, candidate].sort((a, b) => a.localeCompare(b)); +} + +export function useDashboardFilters() { + const [searchParams, setSearchParams] = useSearchParams(); + const [savedPresets, setSavedPresets] = useLocalStorageState( + FILTER_PRESET_STORAGE_KEY, + [], + ); + + const filters = useMemo(() => parseDashboardFilters(searchParams), [searchParams]); + + const setFilters = useCallback( + (nextFilters: DashboardFilters) => { + const nextParams = new URLSearchParams(searchParams); + const filterParams = buildDashboardSearchParams(nextFilters); + + ["assets", "bridges", "status", "range"].forEach((key) => nextParams.delete(key)); + filterParams.forEach((value, key) => { + nextParams.set(key, value); + }); + + setSearchParams(nextParams, { replace: true }); + }, + [searchParams, setSearchParams], + ); + + const updateFilters = useCallback( + (updates: Partial) => { + setFilters({ ...filters, ...updates }); + }, + [filters, setFilters], + ); + + const toggleAsset = useCallback( + (asset: string) => { + updateFilters({ assets: toggleSelection(filters.assets, asset) }); + }, + [filters.assets, updateFilters], + ); + + const toggleBridge = useCallback( + (bridge: string) => { + updateFilters({ bridges: toggleSelection(filters.bridges, bridge) }); + }, + [filters.bridges, updateFilters], + ); + + const setStatus = useCallback( + (status: FilterStatus) => { + updateFilters({ status }); + }, + [updateFilters], + ); + + const setTimeRange = useCallback( + (timeRange: DashboardTimeRangePreset) => { + updateFilters({ timeRange }); + }, + [updateFilters], + ); + + const clearAll = useCallback(() => { + setFilters(DEFAULT_DASHBOARD_FILTERS); + }, [setFilters]); + + const savePreset = useCallback( + (name: string): boolean => { + const presetName = name.trim(); + if (!presetName) return false; + + const nextPreset: DashboardFilterPreset = { + id: `preset-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + name: presetName, + filters, + }; + + setSavedPresets((prev) => { + const remaining = prev.filter((preset) => preset.name.toLowerCase() !== presetName.toLowerCase()); + return [...remaining, nextPreset]; + }); + + return true; + }, + [filters, setSavedPresets], + ); + + const applyPreset = useCallback( + (presetId: string): boolean => { + const preset = savedPresets.find((entry) => entry.id === presetId); + if (!preset) return false; + setFilters(preset.filters); + return true; + }, + [savedPresets, setFilters], + ); + + const deletePreset = useCallback( + (presetId: string) => { + setSavedPresets((prev) => prev.filter((preset) => preset.id !== presetId)); + }, + [setSavedPresets], + ); + + return { + filters, + savedPresets, + hasActiveFilters: isDashboardFilterActive(filters), + toggleAsset, + toggleBridge, + setStatus, + setTimeRange, + clearAll, + savePreset, + applyPreset, + deletePreset, + }; +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 6a97a2a4..fb80b7b0 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -2,6 +2,11 @@ import { useMemo } from "react"; import { Link, useLocation, useNavigate } from "react-router-dom"; import { useAssetsWithHealth } from "../hooks/useAssets"; import { useBridges } from "../hooks/useBridges"; +import { + isTimestampInRange, + useDashboardFilters, + type DashboardFilters, +} from "../hooks/useDashboardFilters"; import { usePullToRefresh } from "../hooks/usePullToRefresh"; import BridgeStatusCard from "../components/BridgeStatusCard"; import WatchlistWidget from "../components/watchlist/WatchlistWidget"; @@ -11,8 +16,10 @@ import ComparativeSparklineGrid from "../components/analytics/ComparativeSparkli import { SummaryCard } from "../components/SummaryCard"; import AssetDiscoverySection from "../components/dashboard/AssetDiscoverySection"; import FavoriteTagChip from "../components/favorites/FavoriteTagChip"; +import AssetFilterPanel from "../components/Filters/AssetFilterPanel"; import { useFavorites } from "../hooks/useFavorites"; import { Tabs, TabList, Tab, TabPanel } from "../components/Tabs"; +import type { AssetWithHealth, FilterStatus } from "../types"; type DashboardView = "overview" | "assets" | "bridges"; type BridgeStatusFilter = "all" | "healthy" | "degraded" | "down" | "unknown"; @@ -53,6 +60,30 @@ function parseBridgeStatus(value: string | null): BridgeStatusFilter { return "all"; } +function getAssetStatus(score: number | null | undefined): FilterStatus | null { + if (score === null || score === undefined) return null; + if (score >= 80) return "healthy"; + if (score >= 50) return "warning"; + return "critical"; +} + +function filterAssets(assets: AssetWithHealth[], filters: DashboardFilters): AssetWithHealth[] { + const selectedAssets = new Set(filters.assets); + + return assets.filter((asset) => { + if (selectedAssets.size > 0 && !selectedAssets.has(asset.symbol)) { + return false; + } + + if (filters.status !== "all") { + const status = getAssetStatus(asset.health?.overallScore ?? null); + if (status !== filters.status) return false; + } + + return isTimestampInRange(asset.health?.lastUpdated, filters.timeRange); + }); +} + function useDashboardUrlState() { const location = useLocation(); const navigate = useNavigate(); @@ -96,6 +127,19 @@ export default function Dashboard() { refetch: refetchBridges, } = useBridges(); const dashboard = useDashboardUrlState(); + const { + filters, + savedPresets, + hasActiveFilters, + toggleAsset, + toggleBridge, + setStatus, + setTimeRange, + clearAll, + savePreset, + applyPreset, + deletePreset, + } = useDashboardFilters(); const pullToRefresh = usePullToRefresh({ enabled: true, onRefresh: async () => { @@ -103,11 +147,33 @@ export default function Dashboard() { }, }); + const availableAssets = useMemo(() => { + if (!assetsWithHealth) return []; + return [...new Set(assetsWithHealth.map((asset) => asset.symbol))].sort((a, b) => + a.localeCompare(b), + ); + }, [assetsWithHealth]); + + const availableBridges = useMemo(() => { + return [...new Set((bridgesData?.bridges ?? []).map((bridge) => bridge.name))].sort( + (a, b) => a.localeCompare(b), + ); + }, [bridgesData?.bridges]); + + const filteredAssets = useMemo( + () => filterAssets(assetsWithHealth ?? [], filters), + [assetsWithHealth, filters], + ); + const filteredBridges = useMemo(() => { let bridges = bridgesData?.bridges ?? []; if (dashboard.state.bridgeStatus !== "all") { bridges = bridges.filter((bridge) => bridge.status === dashboard.state.bridgeStatus); } + if (filters.bridges.length > 0) { + const selectedBridgeSet = new Set(filters.bridges); + bridges = bridges.filter((bridge) => selectedBridgeSet.has(bridge.name)); + } if (favoritesFilterMode === "favorites") { bridges = bridges.filter((b) => favoriteBridges.includes(b.name)); } @@ -117,19 +183,29 @@ export default function Dashboard() { dashboard.state.bridgeStatus, favoritesFilterMode, favoriteBridges, + filters.bridges, ]); const showAssets = dashboard.state.view !== "bridges"; const showBridges = dashboard.state.view !== "assets"; const sparklineItems = useMemo( () => - (assetsWithHealth ?? []).slice(0, 6).map((asset) => ({ + filteredAssets.slice(0, 6).map((asset) => ({ symbol: asset.symbol, name: asset.name ?? asset.symbol, period: "7d" as const, })), - [assetsWithHealth] + [filteredAssets], ); + const showFilteredAssetEmpty = + !assetsLoading && + hasActiveFilters && + filteredAssets.length === 0 && + (assetsWithHealth ?? []).length > 0; + const bridgeFiltersActive = + dashboard.state.bridgeStatus !== "all" || + filters.bridges.length > 0 || + favoritesFilterMode === "favorites"; return (
@@ -204,6 +280,22 @@ export default function Dashboard() {
+ + {/* Overview Stats */}

@@ -212,7 +304,13 @@ export default function Dashboard() {
sum + b.totalValueLocked, 0).toLocaleString() || "0"}`} + value={ + bridgesLoading + ? "--" + : `$${bridgesData?.bridges + .reduce((sum, b) => sum + b.totalValueLocked, 0) + .toLocaleString() || "0"}` + } loading={bridgesLoading} icon="💰" href="/bridges" @@ -226,7 +324,11 @@ export default function Dashboard() { /> b.status !== "down").length || 0} + value={ + bridgesLoading + ? "--" + : bridgesData?.bridges.filter((b: any) => b.status !== "down").length || 0 + } loading={bridgesLoading} icon="🌉" href="/bridges" @@ -249,7 +351,20 @@ export default function Dashboard() {

Asset Health

- + {showFilteredAssetEmpty ? ( +
+

No assets match the selected filters.

+ +
+ ) : ( + + )}

) : null} @@ -261,10 +376,7 @@ export default function Dashboard() {

Bridge Status

- + View all
@@ -273,26 +385,26 @@ export default function Dashboard() { ) : filteredBridges.length > 0 ? (
{filteredBridges.map((bridge) => ( - toggleFavoriteBridge(bridge.name)} - /> - } - /> - ))} + toggleFavoriteBridge(bridge.name)} + /> + } + /> + ))}
) : (

- {dashboard.state.bridgeStatus === "all" - ? "No bridge data available yet." - : `No bridges match the ${dashboard.state.bridgeStatus} filter.`} + {bridgeFiltersActive + ? "No bridges match the selected filters." + : "No bridge data available yet."}

)}