From bdc85e4083ce6138d80fe9578dffcd25382f13fe Mon Sep 17 00:00:00 2001 From: Khatija Fatima Date: Sat, 6 Jun 2026 12:42:35 +0530 Subject: [PATCH] feat: add plugin health dashboard with runnable, degraded, and blocked states (#229) --- docs/plugin_health_dashboard.md | 34 ++ frontend/src/App.tsx | 2 + frontend/src/components/Sidebar.tsx | 1 + frontend/src/pages/PluginHealth.tsx | 337 ++++++++++++++++++ frontend/src/routes.ts | 1 + .../testing/unit/pages/PluginHealth.test.tsx | 217 +++++++++++ 6 files changed, 592 insertions(+) create mode 100644 frontend/src/pages/PluginHealth.tsx create mode 100644 frontend/testing/unit/pages/PluginHealth.test.tsx diff --git a/docs/plugin_health_dashboard.md b/docs/plugin_health_dashboard.md index 81d2c06c..2cac7f52 100644 --- a/docs/plugin_health_dashboard.md +++ b/docs/plugin_health_dashboard.md @@ -41,3 +41,37 @@ python scripts/plugin_health_dashboard.py --format json --output plugin_health_r ## Notes The script does not write generated reports by default. Reports are printed to stdout unless an explicit `--output` path is provided. + +## UI Dashboard + +SecuScan also provides a browser-based Plugin Health Dashboard at **`/plugins`** in the frontend. + +### Health states + +| State | Meaning | +| --- | --- | +| **Runnable** | Plugin is fully available and can be executed | +| **Degraded** | Plugin is missing one or more system binaries, such as `nmap` or `nikto` | +| **Blocked** | Plugin is blocked by operator capability policy, such as `SECUSCAN_DENIED_CAPABILITIES=exploit` | + +### Navigation + +Access the dashboard from the sidebar under **Monitor → Plugin Health**, or navigate directly to `/plugins`. + +Each plugin card shows: +- Health state badge +- Plugin category and safety level +- Missing binary dependencies (degraded plugins) +- Operator guidance message where available +- Click-through to the plugin's configuration page + +### Operator capability policy + +Plugins can be blocked at the operator level by setting the `SECUSCAN_DENIED_CAPABILITIES` environment variable. For example: + +```bash +SECUSCAN_DENIED_CAPABILITIES=exploit,intrusive +``` + +Plugins requiring denied capabilities will appear in the **Blocked** group on the dashboard. +See `docs/plugin-validation.md` for the full list of supported capabilities. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d20131e3..ae1f599b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import Settings from './pages/Settings' import Scans from './pages/Scans' import TaskDetails from './pages/TaskDetails' import Workflows from './pages/Workflows' +import PluginHealth from './pages/PluginHealth' import ApiKeySetupScreen from './components/ApiKeySetupScreen' import { ThemeProvider } from './components/ThemeContext' @@ -27,6 +28,7 @@ export function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 99bf5e2b..b5f9f702 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -162,6 +162,7 @@ export default function Sidebar() { + diff --git a/frontend/src/pages/PluginHealth.tsx b/frontend/src/pages/PluginHealth.tsx new file mode 100644 index 00000000..37974db8 --- /dev/null +++ b/frontend/src/pages/PluginHealth.tsx @@ -0,0 +1,337 @@ +import React, { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { listPlugins, PluginListItem } from '../api' +import { routePath } from '../routes' + +// ─── Health state derivation ───────────────────────────────────────────────── + +type HealthState = 'runnable' | 'degraded' | 'blocked' + +function getHealthState(plugin: PluginListItem): HealthState { + if (plugin.availability.runnable) return 'runnable' + if (plugin.availability.missing_binaries && plugin.availability.missing_binaries.length > 0) { + return 'degraded' + } + return 'blocked' +} + +// ─── Style helpers ──────────────────────────────────────────────────────────── + +const stateConfig: Record = { + runnable: { + label: 'Runnable', + color: 'bg-rag-green', + chip: 'bg-rag-green text-black', + rail: 'bg-rag-green', + accent: 'text-rag-green', + icon: 'check_circle', + emptyText: 'No plugins are currently runnable.', + }, + degraded: { + label: 'Degraded', + color: 'bg-rag-amber', + chip: 'bg-rag-amber text-black', + rail: 'bg-rag-amber', + accent: 'text-rag-amber', + icon: 'warning', + emptyText: 'No plugins are in a degraded state.', + }, + blocked: { + label: 'Blocked', + color: 'bg-rag-red', + chip: 'bg-rag-red text-black', + rail: 'bg-rag-red', + accent: 'text-rag-red', + icon: 'block', + emptyText: 'No plugins are blocked.', + }, +} + +// ─── Sub-components ────────────────────────────────────────────────────────── + +interface PluginCardProps { + plugin: PluginListItem + state: HealthState + onNavigate: () => void +} + +function PluginCard({ plugin, state, onNavigate }: PluginCardProps) { + const cfg = stateConfig[state] + + return ( + + ) +} + +interface HealthGroupProps { + state: HealthState + plugins: PluginListItem[] + onNavigate: (pluginId: string) => void +} + +function HealthGroup({ state, plugins, onNavigate }: HealthGroupProps) { + const cfg = stateConfig[state] + + return ( +
+ {/* Group header */} +
+ {cfg.icon} +
+

+ {cfg.label} +

+

+ {plugins.length} plugin{plugins.length !== 1 ? 's' : ''} +

+
+
+ {plugins.length} +
+
+ + {/* Plugin cards */} + {plugins.length === 0 ? ( +
+

{cfg.emptyText}

+
+ ) : ( +
+ {plugins.map((plugin) => ( + onNavigate(plugin.id)} + /> + ))} +
+ )} +
+ ) +} + +// ─── Main page ──────────────────────────────────────────────────────────────── + +export default function PluginHealth() { + const navigate = useNavigate() + const [plugins, setPlugins] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + function fetchPlugins() { + setLoading(true) + setError(null) + listPlugins() + .then((data) => setPlugins(data.plugins || [])) + .catch(() => setError('Failed to load plugin health data.')) + .finally(() => setLoading(false)) + } + + useEffect(() => { + fetchPlugins() + }, []) + + const grouped = { + runnable: plugins.filter((p) => getHealthState(p) === 'runnable'), + degraded: plugins.filter((p) => getHealthState(p) === 'degraded'), + blocked: plugins.filter((p) => getHealthState(p) === 'blocked'), + } + + function handleNavigate(pluginId: string) { + navigate(routePath.scanTool(pluginId)) + } + + return ( +
+
+ + {/* Header */} +
+
+ Plugin_Registry_v1.0 +
+
+
+

+ Plugin{' '} + + Health + +

+

+ Operational visibility // {plugins.length} total plugins registered +

+
+ + {/* Summary metrics */} +
+ {(['runnable', 'degraded', 'blocked'] as HealthState[]).map((state) => { + const cfg = stateConfig[state] + return ( +
+

+ {cfg.label} +

+

+ {String(grouped[state].length).padStart(2, '0')} +

+
+ ) + })} +
+
+ + {/* Refresh button */} +
+ +
+
+ + {/* Loading */} + {loading && ( +
+

+ Scanning plugin registry... +

+
+ )} + + {/* Error */} + {!loading && error && ( +
+ error +
+

+ Plugin_Registry_Retrieval_Failed +

+

{error}

+
+ +
+ )} + + {/* Health groups */} + {!loading && !error && ( +
+ {(['blocked', 'degraded', 'runnable'] as HealthState[]).map((state) => ( + + ))} +
+ )} + +
+
+ ) +} diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index eaa6bcd7..b28a59ab 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -9,6 +9,7 @@ export const routes = { workflows: '/workflows', settings: '/settings', task: '/task/:taskId', + plugins: '/plugins', } as const export const routePath = { diff --git a/frontend/testing/unit/pages/PluginHealth.test.tsx b/frontend/testing/unit/pages/PluginHealth.test.tsx new file mode 100644 index 00000000..6a833bc9 --- /dev/null +++ b/frontend/testing/unit/pages/PluginHealth.test.tsx @@ -0,0 +1,217 @@ +import React from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { MemoryRouter } from 'react-router-dom' +import PluginHealth from '../../../src/pages/PluginHealth' + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('../../../src/api', () => ({ + listPlugins: vi.fn(), +})) + +vi.mock('../../../src/routes', () => ({ + routePath: { scanTool: (id: string) => `/toolkit/${id}` }, +})) + +import { listPlugins } from '../../../src/api' + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +function makePlugin(overrides: any = {}) { + return { + id: `plugin-${Math.random().toString(36).slice(2)}`, + name: 'Test Plugin', + description: 'A test plugin', + category: 'network', + safety_level: 'safe', + enabled: true, + icon: 'terminal', + requires_consent: false, + availability: { + runnable: true, + missing_binaries: [], + status: 'ok', + guidance: null, + }, + ...overrides, + } +} + +function makeRunnable(overrides: any = {}) { + return makePlugin({ availability: { runnable: true, missing_binaries: [], status: 'ok', guidance: null }, ...overrides }) +} + +function makeDegraded(missingBinaries = ['nmap'], overrides: any = {}) { + return makePlugin({ + availability: { runnable: false, missing_binaries: missingBinaries, status: 'degraded', guidance: 'Install nmap to enable this plugin.' }, + ...overrides, + }) +} + +function makeBlocked(overrides: any = {}) { + return makePlugin({ + availability: { runnable: false, missing_binaries: [], status: 'blocked', guidance: null }, + ...overrides, + }) +} + +function mockApi(plugins: ReturnType[]) { + vi.mocked(listPlugins).mockResolvedValue({ plugins, total: plugins.length } as any) +} + +function renderPage() { + return render( + + + + ) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('PluginHealth — plugin health dashboard', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders the page header', () => { + mockApi([]) + renderPage() + expect(screen.getByRole('heading', { name: /Plugin/i })).toBeInTheDocument() + }) + + it('shows loading state while fetching', () => { + vi.mocked(listPlugins).mockReturnValue(new Promise(() => {})) + renderPage() + expect(screen.getByText(/Scanning plugin registry/i)).toBeInTheDocument() + }) + + it('shows error state and retry button when fetch fails', async () => { + vi.mocked(listPlugins).mockRejectedValue(new Error('Network error')) + renderPage() + await waitFor(() => expect(screen.getByText(/Plugin_Registry_Retrieval_Failed/i)).toBeInTheDocument()) + expect(screen.getByRole('button', { name: /Retry/i })).toBeInTheDocument() + }) + + it('retry button re-fetches plugins', async () => { + vi.mocked(listPlugins).mockRejectedValueOnce(new Error('fail')) + mockApi([makeRunnable({ name: 'Recovered Plugin' })]) + renderPage() + + await waitFor(() => expect(screen.getByRole('button', { name: /Retry/i })).toBeInTheDocument()) + await userEvent.click(screen.getByRole('button', { name: /Retry/i })) + + await waitFor(() => expect(screen.getByText('Recovered Plugin')).toBeInTheDocument()) + }) + + it('renders runnable plugins in the Runnable group', async () => { + mockApi([makeRunnable({ name: 'Nmap Scanner' })]) + renderPage() + await waitFor(() => expect(screen.getByText('Nmap Scanner')).toBeInTheDocument()) + expect(screen.getByRole('heading', { name: /Runnable/i })).toBeInTheDocument() + }) + + it('renders degraded plugins with missing binaries', async () => { + mockApi([makeDegraded(['nmap', 'masscan'], { name: 'Port Scanner' })]) + renderPage() + + await waitFor(() => expect(screen.getByText('Port Scanner')).toBeInTheDocument()) + expect(screen.getByText('nmap')).toBeInTheDocument() + expect(screen.getByText('masscan')).toBeInTheDocument() + expect(screen.getByText(/Missing Dependencies/i)).toBeInTheDocument() + }) + + it('renders blocked plugins with blocked status', async () => { + mockApi([makeBlocked({ name: 'Exploit Plugin' })]) + renderPage() + + await waitFor(() => expect(screen.getByText('Exploit Plugin')).toBeInTheDocument()) + expect(screen.getAllByText('Blocked').length).toBeGreaterThan(0) + }) + + it('shows guidance text for degraded plugins', async () => { + mockApi([makeDegraded(['nmap'], { name: 'Degraded Plugin' })]) + renderPage() + + await waitFor(() => expect(screen.getByText('Install nmap to enable this plugin.')).toBeInTheDocument()) + }) + + it('shows correct summary counts in header metrics', async () => { + mockApi([ + makeRunnable({ id: 'r1' }), + makeRunnable({ id: 'r2' }), + makeDegraded(['nmap'], { id: 'd1' }), + makeBlocked({ id: 'b1' }), + ]) + renderPage() + + await waitFor(() => expect(screen.queryByText(/Scanning plugin registry/i)).not.toBeInTheDocument()) + + // Summary metric cards show padded counts + const metricValues = screen.getAllByText(/^\d{2}$/) + const values = metricValues.map((el) => el.textContent) + expect(values).toContain('02') // runnable + expect(values).toContain('01') // degraded + expect(values).toContain('01') // blocked + }) + + it('shows empty state for a group with no plugins', async () => { + mockApi([makeRunnable({ id: 'r1' })]) + renderPage() + + await waitFor(() => expect(screen.queryByText(/Scanning plugin registry/i)).not.toBeInTheDocument()) + expect(screen.getByText(/No plugins are blocked/i)).toBeInTheDocument() + expect(screen.getByText(/No plugins are in a degraded state/i)).toBeInTheDocument() + }) + + it('groups blocked plugins before degraded before runnable', async () => { + mockApi([ + makeRunnable({ id: 'r1', name: 'Alpha Runnable' }), + makeDegraded(['nmap'], { id: 'd1', name: 'Beta Degraded' }), + makeBlocked({ id: 'b1', name: 'Gamma Blocked' }), + ]) + renderPage() + + await waitFor(() => expect(screen.getByText('Alpha Runnable')).toBeInTheDocument()) + + const headings = screen.getAllByRole('heading').map((h) => h.textContent) + const blockedIdx = headings.findIndex((h) => /blocked/i.test(h || '')) + const degradedIdx = headings.findIndex((h) => /degraded/i.test(h || '')) + const runnableIdx = headings.findIndex((h) => /runnable/i.test(h || '')) + + expect(blockedIdx).toBeLessThan(degradedIdx) + expect(degradedIdx).toBeLessThan(runnableIdx) + }) + + it('clicking a plugin card navigates to toolkit route', async () => { + const plugin = makeRunnable({ id: 'nmap-plugin', name: 'Navigate Test' }) + mockApi([plugin]) + + const { container } = renderPage() + await waitFor(() => expect(screen.getByText('Navigate Test')).toBeInTheDocument()) + + const card = container.querySelector('button[type="button"]') as HTMLButtonElement + expect(card).toBeTruthy() + await userEvent.click(card) + // Navigation is handled by useNavigate — just verify the click doesn't throw + }) + + it('negative: does not show missing_binaries section for runnable plugins', async () => { + mockApi([makeRunnable({ name: 'Clean Plugin' })]) + renderPage() + + await waitFor(() => expect(screen.getByText('Clean Plugin')).toBeInTheDocument()) + expect(screen.queryByText(/Missing Dependencies/i)).not.toBeInTheDocument() + }) + + it('negative: does not show guidance for plugins with no guidance', async () => { + mockApi([makeBlocked({ name: 'Silent Block' })]) + renderPage() + + await waitFor(() => expect(screen.getByText('Silent Block')).toBeInTheDocument()) + // Guidance section should not appear since guidance is null + expect(screen.queryByText('Install')).not.toBeInTheDocument() + }) +})