diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index c4956490..16abdc5e 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -42,6 +42,7 @@ import { alertSuppressionRoutes } from "./alertSuppression.js"; import { externalDependenciesRoutes } from "./externalDependencies.routes.js"; import { providerHealthRegistryRoutes } from "./providerHealthRegistry.routes.js"; import { reconciliationRoutes } from "./reconciliation.js"; +import { statusSubscriptionsRoutes } from "./statusSubscriptions.js"; export async function registerRoutes(server: FastifyInstance) { server.register(assetsRoutes, { prefix: "/api/v1/assets" }); @@ -89,4 +90,5 @@ export async function registerRoutes(server: FastifyInstance) { server.register(externalDependenciesRoutes, { prefix: "/api/v1/external-dependencies" }); server.register(providerHealthRegistryRoutes, { prefix: "/api/v1/providers/health" }); server.register(reconciliationRoutes, { prefix: "/api/v1/reconciliation" }); + server.register(statusSubscriptionsRoutes, { prefix: "/api/v1/status-subscriptions" }); } diff --git a/backend/src/api/routes/statusSubscriptions.ts b/backend/src/api/routes/statusSubscriptions.ts new file mode 100644 index 00000000..2fd93e4c --- /dev/null +++ b/backend/src/api/routes/statusSubscriptions.ts @@ -0,0 +1,287 @@ +import type { FastifyInstance } from "fastify"; +import { + StatusSubscriptionService, + type EntityType, + type TriggerStatus, + type DeliveryChannel, + type DigestFrequency, +} from "../../services/statusSubscription.service.js"; + +const ENTITY_TYPES: EntityType[] = ["asset", "bridge", "service"]; +const TRIGGER_STATUSES: TriggerStatus[] = ["degraded", "down", "recovered", "any"]; +const DELIVERY_CHANNELS: DeliveryChannel[] = ["in_app", "email", "webhook", "discord"]; +const DIGEST_FREQUENCIES: DigestFrequency[] = ["immediate", "hourly", "daily"]; + +const subscriptionSchema = { + type: "object", + properties: { + id: { type: "string" }, + userId: { type: "string" }, + entityType: { type: "string", enum: ENTITY_TYPES }, + entityId: { type: "string" }, + triggerStatuses: { type: "array", items: { type: "string", enum: TRIGGER_STATUSES } }, + deliveryChannels: { type: "array", items: { type: "string", enum: DELIVERY_CHANNELS } }, + deliveryDestination: { type: "string" }, + digestFrequency: { type: "string", enum: DIGEST_FREQUENCIES }, + suppressDuplicatesMinutes: { type: "integer" }, + enabled: { type: "boolean" }, + createdAt: { type: "string" }, + updatedAt: { type: "string" }, + }, +} as const; + +export async function statusSubscriptionsRoutes(server: FastifyInstance) { + const service = new StatusSubscriptionService(); + + server.post<{ + Params: { userId: string }; + Body: { + entityType: EntityType; + entityId: string; + triggerStatuses?: TriggerStatus[]; + deliveryChannels?: DeliveryChannel[]; + deliveryDestination?: string; + digestFrequency?: DigestFrequency; + suppressDuplicatesMinutes?: number; + }; + }>( + "/:userId", + { + schema: { + tags: ["Status Subscriptions"], + summary: "Create a status change subscription", + params: { + type: "object", + required: ["userId"], + properties: { userId: { type: "string", minLength: 1 } }, + }, + body: { + type: "object", + required: ["entityType", "entityId"], + properties: { + entityType: { type: "string", enum: ENTITY_TYPES }, + entityId: { type: "string", minLength: 1 }, + triggerStatuses: { type: "array", items: { type: "string", enum: TRIGGER_STATUSES } }, + deliveryChannels: { type: "array", items: { type: "string", enum: DELIVERY_CHANNELS } }, + deliveryDestination: { type: "string" }, + digestFrequency: { type: "string", enum: DIGEST_FREQUENCIES }, + suppressDuplicatesMinutes: { type: "integer", minimum: 0, maximum: 10080 }, + }, + }, + response: { + 201: { type: "object", properties: { subscription: subscriptionSchema } }, + 400: { $ref: "Error#" }, + }, + }, + }, + async (request, reply) => { + const { userId } = request.params; + const sub = service.create({ userId, ...request.body }); + return reply.status(201).send({ subscription: sub }); + }, + ); + + server.get<{ Params: { userId: string } }>( + "/:userId", + { + schema: { + tags: ["Status Subscriptions"], + summary: "List all subscriptions for a user", + params: { + type: "object", + required: ["userId"], + properties: { userId: { type: "string", minLength: 1 } }, + }, + response: { + 200: { + type: "object", + properties: { subscriptions: { type: "array", items: subscriptionSchema } }, + }, + }, + }, + }, + async (request) => { + const subs = service.listByUser(request.params.userId); + return { subscriptions: subs }; + }, + ); + + server.get<{ Params: { userId: string; id: string } }>( + "/:userId/:id", + { + schema: { + tags: ["Status Subscriptions"], + summary: "Get a single subscription", + params: { + type: "object", + required: ["userId", "id"], + properties: { + userId: { type: "string" }, + id: { type: "string" }, + }, + }, + response: { + 200: { type: "object", properties: { subscription: subscriptionSchema } }, + 404: { $ref: "Error#" }, + }, + }, + }, + async (request, reply) => { + const sub = service.getById(request.params.id); + if (!sub || sub.userId !== request.params.userId) { + return reply.status(404).send({ error: "Subscription not found" }); + } + return { subscription: sub }; + }, + ); + + server.patch<{ + Params: { userId: string; id: string }; + Body: { + triggerStatuses?: TriggerStatus[]; + deliveryChannels?: DeliveryChannel[]; + deliveryDestination?: string; + digestFrequency?: DigestFrequency; + suppressDuplicatesMinutes?: number; + enabled?: boolean; + }; + }>( + "/:userId/:id", + { + schema: { + tags: ["Status Subscriptions"], + summary: "Update a subscription", + params: { + type: "object", + required: ["userId", "id"], + properties: { userId: { type: "string" }, id: { type: "string" } }, + }, + body: { + type: "object", + properties: { + triggerStatuses: { type: "array", items: { type: "string", enum: TRIGGER_STATUSES } }, + deliveryChannels: { type: "array", items: { type: "string", enum: DELIVERY_CHANNELS } }, + deliveryDestination: { type: "string" }, + digestFrequency: { type: "string", enum: DIGEST_FREQUENCIES }, + suppressDuplicatesMinutes: { type: "integer", minimum: 0, maximum: 10080 }, + enabled: { type: "boolean" }, + }, + }, + response: { + 200: { type: "object", properties: { subscription: subscriptionSchema } }, + 404: { $ref: "Error#" }, + }, + }, + }, + async (request, reply) => { + const updated = service.update(request.params.id, request.params.userId, request.body); + if (!updated) return reply.status(404).send({ error: "Subscription not found" }); + return { subscription: updated }; + }, + ); + + server.delete<{ Params: { userId: string; id: string } }>( + "/:userId/:id", + { + schema: { + tags: ["Status Subscriptions"], + summary: "Delete a subscription", + params: { + type: "object", + required: ["userId", "id"], + properties: { userId: { type: "string" }, id: { type: "string" } }, + }, + response: { + 200: { type: "object", properties: { deleted: { type: "boolean" } } }, + 404: { $ref: "Error#" }, + }, + }, + }, + async (request, reply) => { + const ok = service.delete(request.params.id, request.params.userId); + if (!ok) return reply.status(404).send({ error: "Subscription not found" }); + return { deleted: true }; + }, + ); + + server.get<{ Params: { userId: string; id: string } }>( + "/:userId/:id/audit", + { + schema: { + tags: ["Status Subscriptions"], + summary: "Get audit trail for a subscription", + params: { + type: "object", + required: ["userId", "id"], + properties: { userId: { type: "string" }, id: { type: "string" } }, + }, + response: { + 200: { + type: "object", + properties: { + audit: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string" }, + subscriptionId: { type: "string" }, + userId: { type: "string" }, + action: { type: "string" }, + detail: { type: "string" }, + timestamp: { type: "string" }, + }, + }, + }, + }, + }, + 404: { $ref: "Error#" }, + }, + }, + }, + async (request, reply) => { + const sub = service.getById(request.params.id); + if (!sub || sub.userId !== request.params.userId) { + return reply.status(404).send({ error: "Subscription not found" }); + } + return { audit: service.getAuditTrail(request.params.id) }; + }, + ); + + // Internal endpoint — called by the status-change event pipeline + server.post<{ + Body: { entityType: string; entityId: string; newStatus: string }; + }>( + "/notify", + { + schema: { + tags: ["Status Subscriptions"], + summary: "Trigger subscription notifications for a status change (internal)", + body: { + type: "object", + required: ["entityType", "entityId", "newStatus"], + properties: { + entityType: { type: "string", enum: ENTITY_TYPES }, + entityId: { type: "string" }, + newStatus: { type: "string" }, + }, + }, + response: { + 200: { + type: "object", + properties: { notified: { type: "integer" } }, + }, + 400: { $ref: "Error#" }, + }, + }, + }, + async (request, reply) => { + const { entityType, entityId, newStatus } = request.body; + if (!ENTITY_TYPES.includes(entityType as EntityType)) { + return reply.status(400).send({ error: "Invalid entityType" }); + } + const matched = service.getSubscriptionsToNotify(entityType as EntityType, entityId, newStatus); + return { notified: matched.length }; + }, + ); +} diff --git a/backend/src/services/statusSubscription.service.ts b/backend/src/services/statusSubscription.service.ts new file mode 100644 index 00000000..167b26a5 --- /dev/null +++ b/backend/src/services/statusSubscription.service.ts @@ -0,0 +1,172 @@ +import { randomUUID } from "node:crypto"; +import { logger } from "../utils/logger.js"; + +export type EntityType = "asset" | "bridge" | "service"; +export type TriggerStatus = "degraded" | "down" | "recovered" | "any"; +export type DeliveryChannel = "in_app" | "email" | "webhook" | "discord"; +export type DigestFrequency = "immediate" | "hourly" | "daily"; + +export interface StatusSubscription { + id: string; + userId: string; + entityType: EntityType; + entityId: string; + triggerStatuses: TriggerStatus[]; + deliveryChannels: DeliveryChannel[]; + deliveryDestination?: string; + digestFrequency: DigestFrequency; + suppressDuplicatesMinutes: number; + enabled: boolean; + createdAt: string; + updatedAt: string; +} + +export interface SubscriptionCreateInput { + userId: string; + entityType: EntityType; + entityId: string; + triggerStatuses?: TriggerStatus[]; + deliveryChannels?: DeliveryChannel[]; + deliveryDestination?: string; + digestFrequency?: DigestFrequency; + suppressDuplicatesMinutes?: number; +} + +export interface SubscriptionUpdateInput { + triggerStatuses?: TriggerStatus[]; + deliveryChannels?: DeliveryChannel[]; + deliveryDestination?: string; + digestFrequency?: DigestFrequency; + suppressDuplicatesMinutes?: number; + enabled?: boolean; +} + +interface AuditEntry { + id: string; + subscriptionId: string; + userId: string; + action: "created" | "updated" | "deleted" | "triggered"; + detail: string; + timestamp: string; +} + +// In-memory store — replace with a DB-backed implementation when a migration is added. +const subscriptions = new Map(); +const auditLog: AuditEntry[] = []; +const lastTriggered = new Map(); // subscriptionId → ISO timestamp + +function now(): string { + return new Date().toISOString(); +} + +function addAudit( + subscriptionId: string, + userId: string, + action: AuditEntry["action"], + detail: string, +): void { + auditLog.push({ id: randomUUID(), subscriptionId, userId, action, detail, timestamp: now() }); + if (auditLog.length > 5000) auditLog.splice(0, 1000); +} + +export class StatusSubscriptionService { + create(input: SubscriptionCreateInput): StatusSubscription { + const id = randomUUID(); + const ts = now(); + const sub: StatusSubscription = { + id, + userId: input.userId, + entityType: input.entityType, + entityId: input.entityId, + triggerStatuses: input.triggerStatuses ?? ["any"], + deliveryChannels: input.deliveryChannels ?? ["in_app"], + deliveryDestination: input.deliveryDestination, + digestFrequency: input.digestFrequency ?? "immediate", + suppressDuplicatesMinutes: input.suppressDuplicatesMinutes ?? 60, + enabled: true, + createdAt: ts, + updatedAt: ts, + }; + subscriptions.set(id, sub); + addAudit(id, input.userId, "created", `Subscribed to ${input.entityType}:${input.entityId}`); + logger.info({ subscriptionId: id, userId: input.userId }, "Status subscription created"); + return sub; + } + + getById(id: string): StatusSubscription | undefined { + return subscriptions.get(id); + } + + listByUser(userId: string): StatusSubscription[] { + return Array.from(subscriptions.values()).filter((s) => s.userId === userId); + } + + listByEntity(entityType: EntityType, entityId: string): StatusSubscription[] { + return Array.from(subscriptions.values()).filter( + (s) => s.entityType === entityType && s.entityId === entityId && s.enabled, + ); + } + + update(id: string, userId: string, input: SubscriptionUpdateInput): StatusSubscription | null { + const sub = subscriptions.get(id); + if (!sub || sub.userId !== userId) return null; + + const updated: StatusSubscription = { + ...sub, + ...(input.triggerStatuses !== undefined && { triggerStatuses: input.triggerStatuses }), + ...(input.deliveryChannels !== undefined && { deliveryChannels: input.deliveryChannels }), + ...(input.deliveryDestination !== undefined && { deliveryDestination: input.deliveryDestination }), + ...(input.digestFrequency !== undefined && { digestFrequency: input.digestFrequency }), + ...(input.suppressDuplicatesMinutes !== undefined && { suppressDuplicatesMinutes: input.suppressDuplicatesMinutes }), + ...(input.enabled !== undefined && { enabled: input.enabled }), + updatedAt: now(), + }; + subscriptions.set(id, updated); + addAudit(id, userId, "updated", `Updated subscription fields: ${Object.keys(input).join(", ")}`); + return updated; + } + + delete(id: string, userId: string): boolean { + const sub = subscriptions.get(id); + if (!sub || sub.userId !== userId) return false; + subscriptions.delete(id); + addAudit(id, userId, "deleted", `Deleted subscription for ${sub.entityType}:${sub.entityId}`); + return true; + } + + /** + * Called by the alerting layer when an entity's status changes. + * Returns subscriptions that should receive notifications, after applying + * duplicate-suppression logic. + */ + getSubscriptionsToNotify( + entityType: EntityType, + entityId: string, + newStatus: string, + ): StatusSubscription[] { + const candidates = this.listByEntity(entityType, entityId).filter((s) => { + const matches = + s.triggerStatuses.includes("any") || + s.triggerStatuses.includes(newStatus as TriggerStatus); + if (!matches) return false; + + const lastTs = lastTriggered.get(s.id); + if (lastTs) { + const elapsedMinutes = (Date.now() - new Date(lastTs).getTime()) / 60_000; + if (elapsedMinutes < s.suppressDuplicatesMinutes) return false; + } + return true; + }); + + for (const s of candidates) { + lastTriggered.set(s.id, now()); + addAudit(s.id, s.userId, "triggered", `Status changed to ${newStatus} for ${entityType}:${entityId}`); + } + + return candidates; + } + + getAuditTrail(subscriptionId: string): AuditEntry[] { + return auditLog.filter((e) => e.subscriptionId === subscriptionId); + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8de0c4f1..3dd477b2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,6 +21,9 @@ const AlertRoutingAdmin = lazy(() => import("./pages/AlertRoutingAdmin")); const SupplyChain = lazy(() => import("./pages/SupplyChain")); const ApiDocs = lazy(() => import("./pages/ApiDocs")); const Help = lazy(() => import("./pages/Help")); +const ReleaseNotes = lazy(() => import("./pages/ReleaseNotes")); +const NotificationPreferencesPage = lazy(() => import("./pages/NotificationPreferencesPage")); +const RelationshipExplorer = lazy(() => import("./pages/RelationshipExplorer")); function NotificationInitializer() { useNotifications(); @@ -58,6 +61,9 @@ function App() { } /> } /> } /> + } /> + } /> + } /> diff --git a/frontend/src/content/releaseNotesData.ts b/frontend/src/content/releaseNotesData.ts new file mode 100644 index 00000000..f5e12e55 --- /dev/null +++ b/frontend/src/content/releaseNotesData.ts @@ -0,0 +1,184 @@ +export type ReleaseTag = "breaking" | "new" | "improvement" | "fix" | "deprecated"; + +export interface ReleaseNote { + id: string; + version: string; + date: string; + summary: string; + tags: ReleaseTag[]; + entries: { + tag: ReleaseTag; + title: string; + description: string; + migrationNote?: string; + }[]; +} + +export const releaseNotes: ReleaseNote[] = [ + { + id: "v1.5.0", + version: "1.5.0", + date: "2026-05-29", + summary: "State export functions, frozen asset controls, and supply-chain graph improvements.", + tags: ["new", "improvement"], + entries: [ + { + tag: "new", + title: "State Export Functions", + description: + "Export contract state snapshots via GET /api/export/state for off-chain sync and auditing. Supports JSON and compact formats.", + }, + { + tag: "new", + title: "Frozen Asset Controls", + description: + "Administrators can now freeze and unfreeze individual assets. All asset metadata responses include a new is_frozen field.", + }, + { + tag: "improvement", + title: "Supply-Chain Graph", + description: "Improved rendering performance and edge-label legibility for large graphs.", + }, + ], + }, + { + id: "v1.4.0", + version: "1.4.0", + date: "2026-05-15", + summary: "Whitelist management, asset category and status filtering.", + tags: ["new"], + entries: [ + { + tag: "new", + title: "Whitelist Management", + description: "Add or remove asset codes from the protocol whitelist via POST /api/whitelist/add.", + }, + { + tag: "new", + title: "Asset Category Filtering", + description: + "Retrieve assets by category (stablecoin, real-world-asset, native, bridged, wrapped, other) via GET /api/assets/category/{category}.", + }, + { + tag: "new", + title: "Asset Status Filtering", + description: + "Retrieve assets by lifecycle status (active, paused, deprecated, pending-review) via GET /api/assets/status/{status}.", + }, + ], + }, + { + id: "v1.3.0", + version: "1.3.0", + date: "2026-04-28", + summary: "Alert routing engine, digest scheduling, and suppression controls.", + tags: ["new", "improvement"], + entries: [ + { + tag: "new", + title: "Alert Routing Engine", + description: + "Route alerts to specific channels based on configurable rules. Supports priority, asset type, and severity matching.", + }, + { + tag: "new", + title: "Digest Scheduling", + description: + "Subscribe to hourly or daily digests instead of real-time notifications. Configurable per user via the digest scheduler endpoint.", + }, + { + tag: "improvement", + title: "Alert Suppression Controls", + description: + "Suppress repeat alerts within a configurable time window to reduce notification fatigue.", + }, + ], + }, + { + id: "v1.2.0", + version: "1.2.0", + date: "2026-03-10", + summary: "Supply chain visualization, external dependency monitoring, and breaking pagination change.", + tags: ["new", "breaking", "improvement"], + entries: [ + { + tag: "breaking", + title: "Pagination cursor format changed", + description: + "The cursor parameter in paginated list endpoints is now base64-encoded. Clients constructing cursors manually must update their encoding.", + migrationNote: + "Replace raw cursor strings with base64(JSON.stringify({ id, createdAt })) before passing to the API.", + }, + { + tag: "new", + title: "Supply Chain Visualization", + description: + "New /supply-chain page shows cross-chain asset flow, bridge health, and dependency graphs.", + }, + { + tag: "new", + title: "External Dependency Monitoring", + description: + "Track health of external price feeds, oracles, and RPC providers. Alerts fire when a dependency degrades.", + }, + ], + }, + { + id: "v1.1.0", + version: "1.1.0", + date: "2026-01-20", + summary: "Watchlists, transaction history, and analytics dashboard.", + tags: ["new"], + entries: [ + { + tag: "new", + title: "Watchlists", + description: + "Users can create named watchlists and monitor a custom set of assets from the dashboard.", + }, + { + tag: "new", + title: "Transaction History", + description: + "Browse and filter all bridge transactions with status, amount, fee, and chain details.", + }, + { + tag: "new", + title: "Analytics Dashboard", + description: + "Volume, liquidity depth, and health score trend charts are now available on the Analytics page.", + }, + ], + }, + { + id: "v1.0.0", + version: "1.0.0", + date: "2025-12-01", + summary: "Initial public release of Bridge Watch.", + tags: ["new"], + entries: [ + { + tag: "new", + title: "Initial Release", + description: + "Bridge Watch is live. Monitor Stellar bridge assets, track incidents, and receive real-time alerts.", + }, + ], + }, +]; + +export const TAG_LABELS: Record = { + breaking: "Breaking", + new: "New", + improvement: "Improvement", + fix: "Fix", + deprecated: "Deprecated", +}; + +export const TAG_COLORS: Record = { + breaking: "bg-red-500/20 text-red-400 border border-red-500/30", + new: "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30", + improvement: "bg-blue-500/20 text-blue-400 border border-blue-500/30", + fix: "bg-yellow-500/20 text-yellow-400 border border-yellow-500/30", + deprecated: "bg-orange-500/20 text-orange-400 border border-orange-500/30", +}; diff --git a/frontend/src/pages/NotificationPreferencesPage.tsx b/frontend/src/pages/NotificationPreferencesPage.tsx new file mode 100644 index 00000000..84944dbd --- /dev/null +++ b/frontend/src/pages/NotificationPreferencesPage.tsx @@ -0,0 +1,257 @@ +import { useState } from "react"; +import { useNotificationContext } from "../hooks/useNotificationContext"; +import { usePreferences } from "../context/PreferencesContext"; +import { useToast } from "../context/ToastContext"; + +type Channel = "in_app" | "email" | "webhook"; +type Priority = "all" | "high_critical" | "critical_only"; +type DigestFrequency = "immediate" | "hourly" | "daily" | "never"; + +interface ExtendedPrefs { + channels: Channel[]; + digestFrequency: DigestFrequency; + quietHoursEnabled: boolean; + quietHoursStart: string; + quietHoursEnd: string; + priority: Priority; +} + +const STORAGE_KEY = "bridge-watch:notification-extended-prefs:v1"; + +function loadExtendedPrefs(): ExtendedPrefs { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) return JSON.parse(raw) as ExtendedPrefs; + } catch { + // fall through + } + return { + channels: ["in_app"], + digestFrequency: "immediate", + quietHoursEnabled: false, + quietHoursStart: "22:00", + quietHoursEnd: "07:00", + priority: "all", + }; +} + +function saveExtendedPrefs(prefs: ExtendedPrefs): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)); +} + +function Toggle({ + checked, + onChange, + label, +}: { + checked: boolean; + onChange: (v: boolean) => void; + label: string; +}) { + return ( + + ); +} + +export default function NotificationPreferencesPage() { + const { preferences, updatePreferences } = useNotificationContext(); + const { showSuccess } = useToast(); + const [extended, setExtended] = useState(loadExtendedPrefs); + + function update(patch: Partial) { + const next = { ...extended, ...patch }; + setExtended(next); + saveExtendedPrefs(next); + showSuccess("Preference saved."); + } + + function toggleChannel(ch: Channel) { + const next = extended.channels.includes(ch) + ? extended.channels.filter((c) => c !== ch) + : [...extended.channels, ch]; + if (next.length === 0) return; // always keep at least one + update({ channels: next }); + } + + const channelConfig: { id: Channel; label: string; description: string }[] = [ + { id: "in_app", label: "In-app", description: "Notifications appear in the sidebar drawer." }, + { id: "email", label: "Email", description: "Receive alerts via email (requires verified address)." }, + { id: "webhook", label: "Webhook", description: "POST payloads to a configured webhook endpoint." }, + ]; + + const priorityOptions: { id: Priority; label: string; description: string }[] = [ + { id: "all", label: "All severities", description: "Receive notifications for every severity level." }, + { id: "high_critical", label: "High & Critical only", description: "Skip low and medium severity events." }, + { id: "critical_only", label: "Critical only", description: "Only the most urgent alerts." }, + ]; + + const digestOptions: { id: DigestFrequency; label: string }[] = [ + { id: "immediate", label: "Immediate" }, + { id: "hourly", label: "Hourly digest" }, + { id: "daily", label: "Daily digest" }, + { id: "never", label: "Never" }, + ]; + + return ( +
+
+

+ Notification Preferences +

+

+ Choose how and when you receive alerts about asset and bridge status changes. +

+
+ + {/* Sound toggle (existing) */} +
+

Sound

+
+
+

Notification sounds

+

+ Play a chime when a new notification arrives. +

+
+ { + updatePreferences({ soundEnabled: v }); + showSuccess("Preference saved."); + }} + label="Toggle notification sounds" + /> +
+
+ + {/* Delivery channels */} +
+

Delivery channels

+

+ Select where notifications are sent. At least one channel must remain active. +

+
+ {channelConfig.map((ch) => ( +
+
+

{ch.label}

+

{ch.description}

+
+ toggleChannel(ch.id)} + label={`Toggle ${ch.label} channel`} + /> +
+ ))} +
+
+ + {/* Digest frequency */} +
+

Digest frequency

+

+ Control how often batched summaries are sent instead of individual notifications. +

+
+ {digestOptions.map((opt) => ( + + ))} +
+
+ + {/* Quiet hours */} +
+
+
+

Quiet hours

+

+ Suppress non-critical notifications during the specified time window. +

+
+ update({ quietHoursEnabled: v })} + label="Toggle quiet hours" + /> +
+ {extended.quietHoursEnabled && ( +
+ + +
+ )} +
+ + {/* Priority overrides */} +
+

Priority overrides

+

+ Limit which severity levels trigger a notification. +

+
+ {priorityOptions.map((opt) => ( + + ))} +
+
+
+ ); +} diff --git a/frontend/src/pages/RelationshipExplorer.tsx b/frontend/src/pages/RelationshipExplorer.tsx new file mode 100644 index 00000000..890b01f2 --- /dev/null +++ b/frontend/src/pages/RelationshipExplorer.tsx @@ -0,0 +1,501 @@ +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { getDependencyGraph } from "../services/api"; +import type { DependencyGraph, DependencyNodeStatus } from "../types"; + +const STATUS_COLORS: Record = { + healthy: "#22c55e", + degraded: "#f59e0b", + down: "#ef4444", + unknown: "#6b7280", +}; + +const STATUS_BADGE: Record = { + healthy: "bg-green-500/20 text-green-400 border border-green-500/30", + degraded: "bg-yellow-500/20 text-yellow-400 border border-yellow-500/30", + down: "bg-red-500/20 text-red-400 border border-red-500/30", + unknown: "bg-gray-500/20 text-gray-400 border border-gray-500/30", +}; + +type FilterStatus = DependencyNodeStatus | "all"; +type FilterType = string; + +interface LayoutNode { + id: string; + label: string; + description: string; + type: string; + status: DependencyNodeStatus; + impactHint: string; + x: number; + y: number; +} + +const GRAPH_W = 800; +const GRAPH_H = 500; +const NODE_R = 24; + +function layoutNodes(nodes: DependencyGraph["nodes"]): LayoutNode[] { + if (nodes.length === 0) return []; + + const byType = new Map(); + for (const n of nodes) { + if (!byType.has(n.type)) byType.set(n.type, []); + byType.get(n.type)!.push(n); + } + + const cols = Array.from(byType.entries()); + const colW = GRAPH_W / (cols.length + 1); + + const laid: LayoutNode[] = []; + cols.forEach(([, group], ci) => { + const rowH = GRAPH_H / (group.length + 1); + group.forEach((n, ri) => { + laid.push({ + ...n, + x: colW * (ci + 1), + y: rowH * (ri + 1), + }); + }); + }); + + return laid; +} + +function GraphNode({ + node, + selected, + dimmed, + onClick, +}: { + node: LayoutNode; + selected: boolean; + dimmed: boolean; + onClick: () => void; +}) { + const color = STATUS_COLORS[node.status] ?? STATUS_COLORS.unknown; + return ( + e.key === "Enter" && onClick()} + style={{ opacity: dimmed ? 0.3 : 1 }} + > + + + + {node.label.length > 7 ? node.label.slice(0, 6) + "…" : node.label} + + + {node.type} + + + ); +} + +function EdgeLine({ + from, + to, + kind, + highlight, +}: { + from: LayoutNode; + to: LayoutNode; + kind: string; + highlight: boolean; +}) { + const mx = (from.x + to.x) / 2; + const my = (from.y + to.y) / 2; + return ( + + + {highlight && ( + + {kind} + + )} + + ); +} + +export default function RelationshipExplorer() { + const { data, isLoading, error } = useQuery({ + queryKey: ["dependencyGraph"], + queryFn: () => getDependencyGraph(), + staleTime: 60_000, + }); + + const [query, setQuery] = useState(""); + const [filterStatus, setFilterStatus] = useState("all"); + const [filterType, setFilterType] = useState("all"); + const [selectedId, setSelectedId] = useState(null); + + const nodeTypes = useMemo(() => { + if (!data) return []; + return Array.from(new Set(data.nodes.map((n) => n.type))).sort(); + }, [data]); + + const filteredNodes = useMemo(() => { + if (!data) return []; + const q = query.trim().toLowerCase(); + return data.nodes.filter((n) => { + if (filterStatus !== "all" && n.status !== filterStatus) return false; + if (filterType !== "all" && n.type !== filterType) return false; + if (q && !n.label.toLowerCase().includes(q) && !n.description.toLowerCase().includes(q)) + return false; + return true; + }); + }, [data, query, filterStatus, filterType]); + + const filteredIds = useMemo(() => new Set(filteredNodes.map((n) => n.id)), [filteredNodes]); + + const laidOut = useMemo(() => layoutNodes(data?.nodes ?? []), [data]); + const laidOutMap = useMemo( + () => new Map(laidOut.map((n) => [n.id, n])), + [laidOut], + ); + + const visibleEdges = useMemo(() => { + if (!data) return []; + return data.edges.filter( + (e) => filteredIds.has(e.from) && filteredIds.has(e.to), + ); + }, [data, filteredIds]); + + const selectedNode = selectedId ? laidOutMap.get(selectedId) : null; + const connectedIds = useMemo(() => { + if (!selectedId || !data) return new Set(); + const ids = new Set(); + for (const e of data.edges) { + if (e.from === selectedId) ids.add(e.to); + if (e.to === selectedId) ids.add(e.from); + } + return ids; + }, [selectedId, data]); + + const selectedEdges = useMemo(() => { + if (!selectedId || !data) return []; + return data.edges.filter((e) => e.from === selectedId || e.to === selectedId); + }, [selectedId, data]); + + return ( +
+
+

+ Asset Relationship Explorer +

+

+ Visualize dependencies between assets, bridges, alerts, and services. +

+
+ + {/* Summary cards */} + {data && ( +
+ {[ + { label: "Total nodes", value: data.summary.totalNodes }, + { label: "Degraded", value: data.summary.degradedServices, color: "text-yellow-400" }, + { label: "Down", value: data.summary.downServices, color: "text-red-400" }, + ].map((s) => ( +
+

{s.label}

+

+ {s.value} +

+
+ ))} +
+ )} + + {/* Filters */} +
+
+ + + + setQuery(e.target.value)} + className="rounded-lg border border-stellar-border bg-stellar-dark pl-9 pr-3 py-1.5 text-sm text-stellar-text-primary placeholder-stellar-text-secondary focus:outline-none focus:ring-2 focus:ring-stellar-blue w-52" + /> +
+ + + + {nodeTypes.length > 0 && ( + + )} + + {selectedId && ( + + )} +
+ +
+ {/* Graph canvas */} +
+ {isLoading && ( +
+ Loading graph… +
+ )} + {error && ( +
+ {error.message} +
+ )} + {!isLoading && !error && data && ( + + + + + + + + {/* Edges */} + {visibleEdges.map((e, i) => { + const from = laidOutMap.get(e.from); + const to = laidOutMap.get(e.to); + if (!from || !to) return null; + const hl = + selectedId === e.from || + selectedId === e.to; + return ( + + ); + })} + + {/* Nodes */} + {laidOut + .filter((n) => filteredIds.has(n.id)) + .map((n) => ( + setSelectedId(selectedId === n.id ? null : n.id)} + /> + ))} + + )} + {!isLoading && !error && data && data.nodes.length === 0 && ( +
+ No dependency data available. +
+ )} +
+ + {/* Detail panel */} + {selectedNode && ( + + )} +
+ + {/* Node list (responsive fallback / accessibility) */} +
+ + Node list ({filteredNodes.length}) + +
+ + + + + + + + + + + {filteredNodes.map((n) => ( + setSelectedId(selectedId === n.id ? null : n.id)} + > + + + + + + ))} + +
LabelTypeStatusImpact
{n.label}{n.type} + + {n.status} + + {n.impactHint}
+
+
+
+ ); +} diff --git a/frontend/src/pages/ReleaseNotes.tsx b/frontend/src/pages/ReleaseNotes.tsx new file mode 100644 index 00000000..2a8fa63f --- /dev/null +++ b/frontend/src/pages/ReleaseNotes.tsx @@ -0,0 +1,206 @@ +import { useMemo, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { + releaseNotes, + TAG_COLORS, + TAG_LABELS, + type ReleaseTag, +} from "../content/releaseNotesData"; + +const ALL_TAGS: ReleaseTag[] = ["breaking", "new", "improvement", "fix", "deprecated"]; + +function TagBadge({ tag }: { tag: ReleaseTag }) { + return ( + + {TAG_LABELS[tag]} + + ); +} + +export default function ReleaseNotes() { + const [searchParams, setSearchParams] = useSearchParams(); + const [query, setQuery] = useState(searchParams.get("q") ?? ""); + const [activeTag, setActiveTag] = useState("all"); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + return releaseNotes.filter((release) => { + const tagMatch = + activeTag === "all" || + release.tags.includes(activeTag) || + release.entries.some((e) => e.tag === activeTag); + + if (!tagMatch) return false; + if (!q) return true; + + return ( + release.version.includes(q) || + release.summary.toLowerCase().includes(q) || + release.entries.some( + (e) => + e.title.toLowerCase().includes(q) || + e.description.toLowerCase().includes(q) || + (e.migrationNote?.toLowerCase().includes(q) ?? false), + ) + ); + }); + }, [query, activeTag]); + + function handleQueryChange(value: string) { + setQuery(value); + const next = new URLSearchParams(searchParams); + if (value) { + next.set("q", value); + } else { + next.delete("q"); + } + setSearchParams(next, { replace: true }); + } + + function copyLink(version: string) { + const url = new URL(window.location.href); + url.hash = `v${version}`; + navigator.clipboard.writeText(url.toString()); + } + + return ( +
+
+

Release Notes

+

+ Track product changes, API updates, and migration guidance. +

+
+ + {/* Search + filter */} +
+
+ + + + handleQueryChange(e.target.value)} + className="w-full rounded-lg border border-stellar-border bg-stellar-dark pl-10 pr-4 py-2 text-sm text-stellar-text-primary placeholder-stellar-text-secondary focus:outline-none focus:ring-2 focus:ring-stellar-blue" + /> +
+
+ + {ALL_TAGS.map((tag) => ( + + ))} +
+
+ + {filtered.length === 0 && ( +
+ No release notes match your search. +
+ )} + + {/* Timeline */} +
+ +
+ ); +}