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
+
+
+ Clear all
+
+
+
+
+
+
+ Save current filters
+
+
+ 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"
+ />
+
+ Save preset
+
+
+
+
+
+
+ Saved presets
+
+
+ setSelectedPresetId(event.target.value)}
+ className="w-full rounded-md border border-stellar-border bg-stellar-dark px-3 py-2 text-sm text-stellar-text-primary focus:outline-none focus:ring-2 focus:ring-stellar-blue"
+ >
+ Select preset
+ {savedPresets.map((preset) => (
+
+ {preset.name}
+
+ ))}
+
+
+ Apply
+
+
+ Delete
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Status
+
+ onStatusChange(event.target.value as FilterStatus)}
+ className="w-full rounded-md border border-stellar-border bg-stellar-dark px-3 py-2 text-sm text-stellar-text-primary focus:outline-none focus:ring-2 focus:ring-stellar-blue"
+ >
+ {STATUS_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+ Time range
+
+ {TIME_RANGE_OPTIONS.map((option) => (
+ onTimeRangeChange(option.value)}
+ aria-pressed={filters.timeRange === option.value}
+ className={`rounded-md border px-3 py-2 text-sm transition-colors ${
+ filters.timeRange === option.value
+ ? "border-stellar-blue bg-stellar-blue/20 text-stellar-text-primary"
+ : "border-stellar-border bg-stellar-dark text-stellar-text-secondary hover:text-stellar-text-primary"
+ }`}
+ >
+ {option.label}
+
+ ))}
+
+
+
+
+ );
+}
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.
+
+ Clear 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."}
)}