diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx
index 96438e8..948d00e 100644
--- a/dashboard/src/App.tsx
+++ b/dashboard/src/App.tsx
@@ -22,6 +22,7 @@ import { ExportHistoryPage } from './pages/ExportHistoryPage';
import { NotificationSearchPage } from './pages/NotificationSearchPage';
import { NotificationPreferencesPage } from './pages/NotificationPreferencesPage';
import { TemplatesPage } from './pages/TemplatesPage';
+import { ChannelDetailsPage } from './pages/ChannelDetailsPage';
import { ThemeToggle } from './components/ThemeToggle';
import { MobileNavDrawer, NAV_ITEMS, type Tab } from './components/MobileNavDrawer';
import { ToastProvider } from './context/ToastContext';
@@ -42,7 +43,8 @@ type Tab =
| 'export-history'
| 'search'
| 'preferences'
- | 'templates';
+ | 'templates'
+ | 'channels';
const TAB_ITEMS: { id: Tab; label: string }[] = [
{ id: 'explorer', label: 'Event Explorer' },
@@ -231,6 +233,15 @@ export function App() {
>
Templates
+
{tab === 'explorer' && (
@@ -382,6 +393,17 @@ function renderPanel(tab: Tab, events: any[]) {
>
+ )}
+ {tab === 'timeline' && }
+ {tab === 'activity' && }
+ {tab === 'webhooks' && }
+ {tab === 'export-history' && }
+ {tab === 'search' && }
+ {tab === 'preferences' && }
+ {tab === 'templates' && }
+ {tab === 'channels' && }
+
+ );
);
case 'timeline':
return ;
diff --git a/dashboard/src/index.css b/dashboard/src/index.css
index 7b60066..7e0a641 100644
--- a/dashboard/src/index.css
+++ b/dashboard/src/index.css
@@ -5611,6 +5611,399 @@ body.event-explorer--resizing {
}
}
+/* ── Issue #496: Channel Details Page ──────────────────────────────────────── */
+
+.cd-page {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 24px;
+ display: grid;
+ gap: 28px;
+}
+
+.cd-page__header {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 16px;
+}
+
+.cd-page__eyebrow {
+ margin: 0 0 6px;
+ font-size: 0.82rem;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+ color: #9aa0a6;
+}
+
+.cd-page__title {
+ margin: 0 0 8px;
+ font-size: 1.75rem;
+ font-weight: 700;
+}
+
+.cd-page__lead {
+ margin: 0;
+ color: #b0b8c2;
+ max-width: 56rem;
+}
+
+/* Error banner */
+.cd-error-banner {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 14px 18px;
+ border-radius: 12px;
+ background: rgba(244, 67, 54, 0.1);
+ border: 1px solid rgba(244, 67, 54, 0.25);
+ color: #ff6b6b;
+ font-size: 0.95rem;
+}
+
+.cd-error-banner__retry {
+ margin-left: auto;
+ background: transparent;
+ border: 1px solid rgba(255, 107, 107, 0.4);
+ border-radius: 8px;
+ color: #ff6b6b;
+ cursor: pointer;
+ padding: 6px 14px;
+ font-size: 0.88rem;
+ transition: background 0.15s;
+}
+
+.cd-error-banner__retry:hover {
+ background: rgba(255, 107, 107, 0.1);
+}
+
+/* Summary strip */
+.cd-summary-strip {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 16px;
+ background: rgba(255, 255, 255, 0.02);
+ overflow: hidden;
+}
+
+.cd-summary-strip__item {
+ flex: 1 1 140px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ padding: 20px 16px;
+ border-right: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.cd-summary-strip__item:last-child {
+ border-right: none;
+}
+
+.cd-summary-strip__value {
+ font-size: 1.6rem;
+ font-weight: 700;
+ color: #e8eaed;
+}
+
+.cd-summary-strip__value--active {
+ color: #34c759;
+}
+
+.cd-summary-strip__label {
+ font-size: 0.82rem;
+ color: #9aa0a6;
+ text-align: center;
+ letter-spacing: 0.03em;
+}
+
+/* Channel grid */
+.cd-channel-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ gap: 20px;
+}
+
+/* Channel card */
+.cd-channel-card {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 22px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 18px;
+ background: rgba(255, 255, 255, 0.025);
+ transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
+}
+
+.cd-channel-card:hover {
+ border-color: rgba(255, 255, 255, 0.16);
+ transform: translateY(-2px);
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
+}
+
+/* Card header */
+.cd-channel-card__header {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding-bottom: 16px;
+ border-bottom: 2px solid transparent;
+}
+
+.cd-channel-card__icon {
+ font-size: 2rem;
+ line-height: 1;
+ flex-shrink: 0;
+}
+
+.cd-channel-card__header-text {
+ flex: 1;
+ min-width: 0;
+}
+
+.cd-channel-card__title {
+ margin: 0 0 4px;
+ font-size: 1.15rem;
+ font-weight: 700;
+}
+
+.cd-channel-card__protocol {
+ margin: 0;
+ font-size: 0.82rem;
+ color: #9aa0a6;
+ letter-spacing: 0.03em;
+}
+
+/* Status badge */
+.cd-channel-card__status {
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 12px;
+ border-radius: 999px;
+ font-size: 0.78rem;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+}
+
+.cd-channel-card__status::before {
+ content: '';
+ display: block;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: currentColor;
+}
+
+.cd-channel-card__status--active {
+ background: rgba(52, 199, 89, 0.12);
+ color: #34c759;
+}
+
+.cd-channel-card__status--degraded {
+ background: rgba(244, 180, 0, 0.12);
+ color: #f4b400;
+}
+
+.cd-channel-card__status--inactive {
+ background: rgba(255, 255, 255, 0.06);
+ color: #9aa0a6;
+}
+
+/* Description */
+.cd-channel-card__desc {
+ margin: 0;
+ font-size: 0.9rem;
+ color: #b0b8c2;
+ line-height: 1.55;
+}
+
+/* Subscriber row */
+.cd-channel-card__subscribers {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 12px 16px;
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.cd-channel-card__subscribers-label {
+ font-size: 0.88rem;
+ color: #9aa0a6;
+}
+
+.cd-channel-card__subscribers-count {
+ font-size: 1.5rem;
+ font-weight: 700;
+}
+
+/* Delivery rate */
+.cd-channel-card__rate-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.cd-channel-card__rate-label {
+ font-size: 0.85rem;
+ color: #9aa0a6;
+}
+
+.cd-channel-card__rate-pct {
+ font-size: 0.95rem;
+ font-weight: 600;
+ color: #e8eaed;
+}
+
+/* Delivery bar */
+.cd-delivery-bar {
+ height: 6px;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.07);
+ overflow: hidden;
+}
+
+.cd-delivery-bar__fill {
+ height: 100%;
+ border-radius: 999px;
+ background: linear-gradient(90deg, #34c759, #5b7dff);
+ transition: width 0.6s ease;
+}
+
+/* Stats grid */
+.cd-channel-card__stats {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 8px;
+}
+
+.cd-stat-card {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+ padding: 12px 8px;
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ text-align: center;
+}
+
+.cd-stat-card__label {
+ margin: 0;
+ font-size: 0.72rem;
+ color: #9aa0a6;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.cd-stat-card__value {
+ margin: 0;
+ font-size: 1.05rem;
+ font-weight: 700;
+ color: #e8eaed;
+}
+
+.cd-stat-card__sub {
+ margin: 0;
+ font-size: 0.72rem;
+ color: #f4b400;
+}
+
+/* Skeleton */
+.cd-channel-card--skeleton {
+ pointer-events: none;
+}
+
+.cd-skeleton-line {
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.05);
+ position: relative;
+ overflow: hidden;
+}
+
+.cd-skeleton-line::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.07), transparent);
+ animation: shimmer 1.4s infinite;
+}
+
+.cd-skeleton-line--title {
+ height: 28px;
+ width: 60%;
+}
+
+.cd-skeleton-line--body {
+ height: 14px;
+ width: 100%;
+}
+
+.cd-skeleton-line--short {
+ width: 80%;
+}
+
+.cd-skeleton-line--bar {
+ height: 6px;
+ width: 100%;
+ border-radius: 999px;
+}
+
+.cd-skeleton-stats {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 8px;
+}
+
+.cd-skeleton-line--stat {
+ height: 56px;
+ border-radius: 12px;
+}
+
+/* Responsive */
+@media (max-width: 900px) {
+ .cd-channel-grid {
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ }
+
+ .cd-channel-card__stats {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ .cd-skeleton-stats {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+@media (max-width: 600px) {
+ .cd-page {
+ padding: 16px;
+ }
+
+ .cd-summary-strip__item {
+ flex: 1 1 100px;
+ padding: 14px 12px;
+ }
+
+ .cd-summary-strip__value {
+ font-size: 1.25rem;
+ }
+
+ .cd-channel-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .cd-channel-card__stats {
+ grid-template-columns: repeat(2, 1fr);
+ }
@media (max-width: 640px) {
.notif-search-page {
padding: 16px;
diff --git a/dashboard/src/pages/ChannelDetailsPage.tsx b/dashboard/src/pages/ChannelDetailsPage.tsx
new file mode 100644
index 0000000..cfe13a8
--- /dev/null
+++ b/dashboard/src/pages/ChannelDetailsPage.tsx
@@ -0,0 +1,290 @@
+import { useState, useEffect } from 'react';
+
+// ── Types ────────────────────────────────────────────────────────────────────
+
+interface ChannelStats {
+ sent: number;
+ delivered: number;
+ failed: number;
+ pending: number;
+ deliveryRate: number;
+}
+
+interface ChannelDetail {
+ key: string;
+ label: string;
+ description: string;
+ icon: string;
+ color: string;
+ protocol: string;
+ status: 'active' | 'degraded' | 'inactive';
+ subscriberCount: number;
+ stats: ChannelStats;
+}
+
+// ── Mock data (mirrors the channelDefinitions pattern used across the dashboard) ──
+
+const MOCK_CHANNELS: ChannelDetail[] = [
+ {
+ key: 'inApp',
+ label: 'In-App',
+ description:
+ 'Push notifications delivered directly inside the Notify-Chain dashboard. No external credentials required — subscribers receive alerts in real time while the app is open.',
+ icon: '🔔',
+ color: '#5b7dff',
+ protocol: 'WebSocket / SSE',
+ status: 'active',
+ subscriberCount: 3_842,
+ stats: { sent: 128_450, delivered: 127_901, failed: 549, pending: 12, deliveryRate: 99.57 },
+ },
+ {
+ key: 'email',
+ label: 'Email',
+ description:
+ 'Notifications dispatched via SMTP to verified recipient email addresses. Supports HTML templates with dynamic variable substitution.',
+ icon: '✉️',
+ color: '#34a853',
+ protocol: 'SMTP / Sendgrid',
+ status: 'active',
+ subscriberCount: 2_197,
+ stats: { sent: 74_320, delivered: 72_810, failed: 1_510, pending: 87, deliveryRate: 97.97 },
+ },
+ {
+ key: 'discord',
+ label: 'Discord',
+ description:
+ 'Event payloads forwarded to Discord channels via webhook URLs. Formatted as rich embeds with colour-coded severity levels.',
+ icon: '💬',
+ color: '#5865f2',
+ protocol: 'Discord Webhooks',
+ status: 'active',
+ subscriberCount: 1_564,
+ stats: { sent: 53_200, delivered: 52_900, failed: 300, pending: 4, deliveryRate: 99.44 },
+ },
+ {
+ key: 'telegram',
+ label: 'Telegram',
+ description:
+ 'Alerts sent to Telegram bots or groups via the Bot API. Subscribers configure their chat handle and receive formatted Markdown messages.',
+ icon: '📨',
+ color: '#0088cc',
+ protocol: 'Telegram Bot API',
+ status: 'degraded',
+ subscriberCount: 887,
+ stats: { sent: 29_100, delivered: 27_650, failed: 1_450, pending: 210, deliveryRate: 95.02 },
+ },
+];
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+function formatNumber(n: number): string {
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
+ return String(n);
+}
+
+function statusLabel(s: ChannelDetail['status']): string {
+ return s === 'active' ? 'Active' : s === 'degraded' ? 'Degraded' : 'Inactive';
+}
+
+// ── Sub-components ────────────────────────────────────────────────────────────
+
+function StatCard({ label, value, sub }: { label: string; value: string; sub?: string }) {
+ return (
+
+
{label}
+
{value}
+ {sub &&
{sub}
}
+
+ );
+}
+
+function DeliveryBar({ rate }: { rate: number }) {
+ return (
+
+
= 98 ? 1 : rate >= 95 ? 0.85 : 0.65 }}
+ />
+
+ );
+}
+
+function ChannelCard({ ch }: { ch: ChannelDetail }) {
+ return (
+
+ {/* Header */}
+
+
{ch.icon}
+
+
{ch.label}
+
{ch.protocol}
+
+
+ {statusLabel(ch.status)}
+
+
+
+ {/* Description */}
+ {ch.description}
+
+ {/* Subscriber count */}
+
+ Subscribers
+
+ {formatNumber(ch.subscriberCount)}
+
+
+
+ {/* Delivery rate bar */}
+
+ Delivery rate
+ {ch.stats.deliveryRate.toFixed(2)}%
+
+
+
+ {/* Stats grid */}
+
+
+
+ 0 ? `${((ch.stats.failed / ch.stats.sent) * 100).toFixed(1)}%` : undefined}
+ />
+
+
+
+ );
+}
+
+function ChannelCardSkeleton() {
+ return (
+
+
+
+
+
+
+ {[1, 2, 3, 4].map((i) => (
+
+ ))}
+
+
+ );
+}
+
+// ── Summary strip ─────────────────────────────────────────────────────────────
+
+function SummaryStrip({ channels }: { channels: ChannelDetail[] }) {
+ const totalSubs = channels.reduce((s, c) => s + c.subscriberCount, 0);
+ const totalSent = channels.reduce((s, c) => s + c.stats.sent, 0);
+ const totalFailed = channels.reduce((s, c) => s + c.stats.failed, 0);
+ const overallRate = ((totalSent - totalFailed) / totalSent) * 100;
+
+ return (
+
+
+ {channels.length}
+ Channels
+
+
+ {formatNumber(totalSubs)}
+ Total subscribers
+
+
+ {formatNumber(totalSent)}
+ Total sent
+
+
+ {overallRate.toFixed(2)}%
+ Overall delivery rate
+
+
+
+ {channels.filter((c) => c.status === 'active').length}
+
+ Active
+
+
+ );
+}
+
+// ── Page ──────────────────────────────────────────────────────────────────────
+
+export function ChannelDetailsPage() {
+ const [channels, setChannels] = useState
([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ setLoading(true);
+ setError(null);
+
+ // Simulate async API fetch — replace with real endpoint when available
+ const timer = window.setTimeout(() => {
+ if (cancelled) return;
+ try {
+ setChannels(MOCK_CHANNELS);
+ } catch {
+ setError('Failed to load channel data. Please try again.');
+ } finally {
+ setLoading(false);
+ }
+ }, 600);
+
+ return () => {
+ cancelled = true;
+ window.clearTimeout(timer);
+ };
+ }, []);
+
+ function handleRetry() {
+ setChannels([]);
+ setLoading(true);
+ setError(null);
+ window.setTimeout(() => {
+ setChannels(MOCK_CHANNELS);
+ setLoading(false);
+ }, 600);
+ }
+
+ return (
+
+ {/* Page header */}
+
+
+ {/* Error banner */}
+ {error && (
+
+ Error: {error}
+
+
+ )}
+
+ {/* Summary strip */}
+ {!loading && !error && }
+
+ {/* Channel grid */}
+
+ {loading
+ ? [1, 2, 3, 4].map((i) => )
+ : channels.map((ch) => )}
+
+
+ );
+}