diff --git a/ui/docs/EXTENSION_DEVELOPER_GUIDE.md b/ui/docs/EXTENSION_DEVELOPER_GUIDE.md new file mode 100644 index 000000000..251d32e68 --- /dev/null +++ b/ui/docs/EXTENSION_DEVELOPER_GUIDE.md @@ -0,0 +1,1540 @@ +# zrok UI Extension Developer Guide + +This guide explains how to create extensions for the zrok web UI. Extensions allow you to add custom functionality, pages, and UI components that integrate seamlessly with zrok. + +## Table of Contents + +1. [Overview](#overview) +2. [Quick Start](#quick-start) +3. [Extension Manifest](#extension-manifest) +4. [Routes](#routes) +5. [Navigation Items](#navigation-items) +6. [Panel Extensions](#panel-extensions) +7. [Slots](#slots) +8. [State Management](#state-management) +9. [Extension Context](#extension-context) +10. [Lifecycle Hooks](#lifecycle-hooks) +11. [Script Injection](#script-injection) +12. [Development Workflow](#development-workflow) +13. [Deployment](#deployment) +14. [API Reference](#api-reference) +15. [Best Practices](#best-practices) +16. [Troubleshooting](#troubleshooting) + +--- + +## Overview + +The zrok UI extension system allows third-party developers to: + +- Add new pages and routes to the UI +- Add navigation items to the navbar +- Extend existing panels with tabs or additional content +- Inject UI components into predefined slots +- Manage extension-specific state +- React to user and application events + +Extensions are TypeScript/React packages that export an `ExtensionManifest` object. At build time, extensions are bundled together with the zrok UI, resulting in a single, cohesive application. + +### Architecture + +```mermaid +flowchart TB + subgraph ext["Your Extension (separate repo)"] + manifest["ExtensionManifest"] + routes["routes"] + navItems["navItems"] + panels["panelExtensions"] + slots["slots"] + state["initialState"] + hooks["lifecycle hooks"] + + manifest --> routes + manifest --> navItems + manifest --> panels + manifest --> slots + manifest --> state + manifest --> hooks + end + + subgraph zrok["zrok UI"] + config["extensions.config.ts"] + registry["extensionRegistry"] + app["App.tsx"] + navbar["NavBar.tsx"] + console["ApiConsole.tsx"] + store["Zustand Store"] + + config -->|"register()"| registry + registry --> app + registry --> navbar + registry --> console + registry --> store + end + + ext -->|"npm install / import"| config +``` + +### Extension Lifecycle + +```mermaid +sequenceDiagram + participant App as App.tsx + participant Registry as Extension Registry + participant Store as Zustand Store + participant Extension as Your Extension + + App->>Registry: loadExtensions() + Registry->>Extension: import manifest + Extension-->>Registry: ExtensionManifest + Registry->>Store: initializeExtensionStates() + Registry->>Extension: onInit(context) + Extension-->>Store: setState(initialData) + + Note over App,Extension: User logs in + App->>Registry: notifyUserLogin(user) + Registry->>Extension: onUserLogin(user, context) + + Note over App,Extension: User logs out + App->>Registry: notifyUserLogout() + Registry->>Extension: onUserLogout(context) +``` + +--- + +## Quick Start + +### 1. Create Your Extension Package + +```bash +mkdir my-zrok-extension +cd my-zrok-extension +npm init -y +``` + +### 2. Set Up TypeScript + +Create `tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true + }, + "include": ["src"] +} +``` + +### 3. Configure package.json + +```json +{ + "name": "@myorg/zrok-my-extension", + "version": "1.0.0", + "main": "src/index.ts", + "peerDependencies": { + "react": "^18.0.0", + "@mui/material": "^6.0.0", + "@xyflow/react": "^12.0.0" + } +} +``` + +### 4. Create Your Extension Manifest + +Create `src/index.ts`: + +```typescript +import { ExtensionManifest } from '@openziti/zrok-ui/extensions'; +import MyPage from './MyPage'; + +const manifest: ExtensionManifest = { + id: 'my-extension', + name: 'My Extension', + version: '1.0.0', + + routes: [ + { + path: '/my-page', + component: MyPage, + }, + ], + + navItems: [ + { + id: 'my-nav', + label: 'My Page', + path: '/my-page', + }, + ], +}; + +export default manifest; +``` + +### 5. Create a Page Component + +Create `src/MyPage.tsx`: + +```typescript +import React from 'react'; +import { Container, Typography } from '@mui/material'; +import { ExtensionRouteProps } from '@openziti/zrok-ui/extensions'; + +const MyPage: React.FC = ({ user, context }) => { + return ( + + Hello from My Extension! + Logged in as: {user?.email} + + ); +}; + +export default MyPage; +``` + +### 6. Enable Your Extension + +In the zrok UI, edit `src/extensions.config.ts`: + +```typescript +import { extensionRegistry } from './extensions/registry'; +import myExtension from '@myorg/zrok-my-extension'; + +export function loadExtensions(): void { + extensionRegistry.register(myExtension); +} +``` + +--- + +## Extension Manifest + +The extension manifest is the main entry point for your extension. It describes what your extension provides and how it integrates with the zrok UI. + +```typescript +interface ExtensionManifest { + // Required fields + id: string; // Unique identifier (e.g., "acme-billing") + name: string; // Display name + version: string; // Semantic version + + // Optional description + description?: string; + + // UI Extensions + routes?: ExtensionRoute[]; + navItems?: ExtensionNavItem[]; + panelExtensions?: PanelExtension[]; + slots?: Record>; + + // Graph extensions + nodeTypes?: Record>; + edgeTypes?: Record>; + + // State + initialState?: Record; + + // Lifecycle hooks + onInit?: (context: ExtensionContext) => void | Promise; + onUserLogin?: (user: User, context: ExtensionContext) => void; + onUserLogout?: (context: ExtensionContext) => void; +} +``` + +--- + +## Routes + +Routes add new pages to the zrok UI. Each route maps a URL path to a React component. + +### Basic Route + +```typescript +routes: [ + { + path: '/billing', + component: BillingPage, + }, +], +``` + +### Route with Nested Paths + +```typescript +routes: [ + { path: '/billing', component: BillingDashboard }, + { path: '/billing/invoices', component: InvoiceList }, + { path: '/billing/subscription', component: SubscriptionManager }, +], +``` + +### Route Options + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `path` | `string` | required | URL path (must start with `/`) | +| `component` | `ComponentType` | required | React component to render | +| `exact` | `boolean` | `false` | Match exact path only | +| `requiresAuth` | `boolean` | `true` | Require user authentication | + +### Route Component Props + +Route components receive `ExtensionRouteProps`: + +```typescript +interface ExtensionRouteProps { + user: User | null; // Current user + context: ExtensionContext; // Extension context + logout: () => void; // Logout function +} +``` + +### Example Route Component + +```typescript +import React from 'react'; +import { AppBar, Toolbar, Button, Container } from '@mui/material'; +import { useNavigate } from 'react-router'; +import { ExtensionRouteProps } from '@openziti/zrok-ui/extensions'; + +const BillingPage: React.FC = ({ user, context, logout }) => { + const navigate = useNavigate(); + + return ( + <> + + + + + + + +

Billing

+

Welcome, {user?.email}

+
+ + ); +}; +``` + +--- + +## Navigation Items + +Navigation items add buttons or links to the navbar. + +### Basic Nav Item + +```typescript +navItems: [ + { + id: 'billing-nav', + label: 'Billing', + path: '/billing', + }, +], +``` + +### Nav Item with Icon + +```typescript +import PaymentIcon from '@mui/icons-material/Payment'; + +navItems: [ + { + id: 'billing-nav', + label: 'Billing', + icon: PaymentIcon, + path: '/billing', + tooltip: 'Manage billing and subscription', + }, +], +``` + +### Nav Item with Click Handler + +```typescript +navItems: [ + { + id: 'refresh-nav', + label: 'Refresh', + icon: RefreshIcon, + onClick: () => window.location.reload(), + }, +], +``` + +### Nav Item Options + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `id` | `string` | required | Unique identifier | +| `label` | `string` | required | Button label | +| `icon` | `ComponentType` | - | Icon component | +| `path` | `string` | - | Route to navigate to | +| `onClick` | `() => void` | - | Custom click handler | +| `position` | `'left' \| 'right'` | `'right'` | Position in navbar | +| `tooltip` | `string` | - | Tooltip text | +| `order` | `number` | `0` | Sort order | +| `visible` | `(user, state) => boolean` | - | Visibility function | + +### Conditional Visibility + +```typescript +navItems: [ + { + id: 'admin-nav', + label: 'Admin', + path: '/admin', + visible: (user, extensionState) => { + return user?.email?.endsWith('@mycompany.com') ?? false; + }, + }, +], +``` + +--- + +## Panel Extensions + +Panel extensions add content to the side panels (Account, Environment, Share, Access). + +### Panel Extension Positions + +```mermaid +flowchart TB + subgraph panel["Side Panel"] + before["position: 'before'
Extension Content"] + subgraph tabs["Tab Bar"] + details["Details"] + tab1["Billing"] + tab2["Analytics"] + end + content["Panel Content
(or position: 'replace')"] + after["position: 'after'
Extension Content"] + + before --> tabs + tabs --> content + content --> after + end + + note["position: 'tab' adds
new tabs here"] -.-> tabs +``` + +### Adding a Tab + +```typescript +panelExtensions: [ + { + nodeTypes: ['account'], + position: 'tab', + tabLabel: 'Billing', + component: AccountBillingTab, + }, +], +``` + +### Adding Content Before/After Panel + +```typescript +panelExtensions: [ + { + nodeTypes: ['share'], + position: 'before', + component: ShareWarningBanner, + }, + { + nodeTypes: ['share'], + position: 'after', + component: ShareAnalytics, + }, +], +``` + +### Replacing a Panel + +```typescript +panelExtensions: [ + { + nodeTypes: ['account'], + position: 'replace', + component: CustomAccountPanel, + }, +], +``` + +### Panel Extension Options + +| Property | Type | Description | +|----------|------|-------------| +| `nodeTypes` | `string[]` | Node types to apply to (`['account']`, `['share']`, `['*']`) | +| `position` | `'before' \| 'after' \| 'tab' \| 'replace'` | Where to inject content | +| `component` | `ComponentType` | Component to render | +| `tabLabel` | `string` | Tab label (required for `'tab'` position) | +| `tabIcon` | `ComponentType` | Tab icon (optional) | +| `order` | `number` | Sort order | + +### Panel Extension Component Props + +```typescript +interface PanelExtensionProps { + node: Node; // Selected graph node + user: User; // Current user + context: ExtensionContext; // Extension context +} +``` + +### Example Panel Extension + +```typescript +import React from 'react'; +import { Box, Typography, Button } from '@mui/material'; +import { PanelExtensionProps } from '@openziti/zrok-ui/extensions'; + +const AccountBillingTab: React.FC = ({ node, user, context }) => { + const handleUpgrade = () => { + context.navigate('/billing/upgrade'); + }; + + return ( + + Subscription + Plan: Professional + Status: Active + + + ); +}; +``` + +--- + +## Slots + +Slots are predefined injection points in the UI where extensions can add content. + +### Slot Layout + +```mermaid +flowchart TB + subgraph navbar["NavBar"] + direction LR + logo["Logo"] + NL["NAVBAR_LEFT"] + NC["NAVBAR_CENTER"] + NR["NAVBAR_RIGHT"] + logout["Logout"] + + logo --- NL --- NC --- NR --- logout + end + + subgraph main["Main Console"] + CT["CONSOLE_TOP"] + + subgraph content["Content Area"] + direction LR + visualizer["Visualizer / Table"] + panel["Side Panel"] + CS["CONSOLE_SIDEBAR"] + end + + CB["CONSOLE_BOTTOM"] + end + + subgraph sidepanel["Panel Structure"] + PT["PANEL_TOP"] + tabs["Details | Extension Tabs"] + PB["PANEL_BOTTOM"] + end + + navbar --> main + panel -.-> sidepanel +``` + +### Available Slots + +| Slot Name | Location | Description | +|-----------|----------|-------------| +| `NAVBAR_LEFT` | Left side of navbar | After logo | +| `NAVBAR_CENTER` | Center of navbar | Between logo and controls | +| `NAVBAR_RIGHT` | Right side of navbar | Before help/logout buttons | +| `ACCOUNT_PANEL_TOP` | Top of account panel | Before panel content | +| `ACCOUNT_PANEL_BOTTOM` | Bottom of account panel | After panel content | +| `ACCOUNT_PANEL_ACTIONS` | Account panel actions | Near action buttons | +| `ENVIRONMENT_PANEL_TOP` | Top of environment panel | Before panel content | +| `ENVIRONMENT_PANEL_BOTTOM` | Bottom of environment panel | After panel content | +| `SHARE_PANEL_TOP` | Top of share panel | Before panel content | +| `SHARE_PANEL_BOTTOM` | Bottom of share panel | After panel content | +| `CONSOLE_TOP` | Top of main console | Above visualizer | +| `CONSOLE_BOTTOM` | Bottom of main console | Below visualizer | +| `CONSOLE_SIDEBAR` | Console sidebar | Additional sidebar content | +| `LOGIN_TOP` | Top of login page | Above login form | +| `LOGIN_BOTTOM` | Bottom of login page | Below login form | + +### Using Slots + +```typescript +import { SLOTS } from '@openziti/zrok-ui/extensions'; + +const manifest: ExtensionManifest = { + // ... + slots: { + [SLOTS.NAVBAR_RIGHT]: NotificationBadge, + [SLOTS.ACCOUNT_PANEL_BOTTOM]: AccountUsageStats, + }, +}; +``` + +### Slot Component Props + +```typescript +interface SlotProps { + user?: User | null; + selectedNode?: Node | null; + context: ExtensionContext; +} +``` + +### Example Slot Component + +```typescript +import React from 'react'; +import { Badge, IconButton, Tooltip } from '@mui/material'; +import NotificationsIcon from '@mui/icons-material/Notifications'; +import { SlotProps } from '@openziti/zrok-ui/extensions'; + +const NotificationBadge: React.FC = ({ user, context }) => { + if (!user) return null; + + const state = context.getState<{ unreadCount: number }>(); + + return ( + + + + + + + + ); +}; +``` + +--- + +## State Management + +Extensions can store state in the zrok UI's Zustand store. Each extension gets its own namespace. + +### State Architecture + +```mermaid +flowchart LR + subgraph store["Zustand Store"] + user["user"] + nodes["nodes"] + edges["edges"] + + subgraph extensions["extensions namespace"] + ext1["billing-extension"] + ext2["analytics-extension"] + ext3["your-extension"] + end + end + + subgraph context["Extension Context"] + getState["getState()"] + setState["setState()"] + subscribe["subscribe()"] + end + + component["Your Component"] --> context + context --> extensions +``` + +### Initial State + +Define initial state in your manifest: + +```typescript +interface MyExtensionState { + counter: number; + settings: { + enabled: boolean; + }; +} + +const manifest: ExtensionManifest = { + // ... + initialState: { + counter: 0, + settings: { + enabled: true, + }, + } satisfies MyExtensionState, +}; +``` + +### Reading State + +```typescript +// In a component +const MyComponent: React.FC<{ context: ExtensionContext }> = ({ context }) => { + const state = context.getState(); + return
Counter: {state?.counter ?? 0}
; +}; +``` + +### Writing State + +```typescript +// Update state (shallow merge) +context.setState({ counter: 5 }); + +// Update nested state +context.setState({ + settings: { + ...state?.settings, + enabled: false, + }, +}); +``` + +### Subscribing to State Changes + +```typescript +// In onInit or component useEffect +const unsubscribe = context.subscribe( + (state) => state.counter, + (newValue, oldValue) => { + console.log('Counter changed:', oldValue, '->', newValue); + } +); + +// Call unsubscribe when done +unsubscribe(); +``` + +### Using React Hook + +For React components, you can use the `useExtensionState` hook: + +```typescript +import { useExtensionState } from '@openziti/zrok-ui/extensions'; + +const MyComponent: React.FC = () => { + const { state, setState } = useExtensionState('my-extension'); + + return ( + + ); +}; +``` + +--- + +## Extension Context + +The extension context provides utilities for interacting with the zrok UI. + +### Context API + +```typescript +interface ExtensionContext { + // Extension identity + extensionId: string; + + // State management + getState(): T | undefined; + setState(state: Partial): void; + subscribe(selector, callback): () => void; + + // User access + getUser(): User | null; + subscribeToUser(callback): () => void; + + // Node selection + getSelectedNode(): Node | null; + subscribeToSelectedNode(callback): () => void; + + // Navigation + navigate(path: string): void; + + // Notifications + notify(message: string, severity?: 'info' | 'success' | 'warning' | 'error'): void; +} +``` + +### Navigation + +```typescript +// Navigate to a route +context.navigate('/billing'); + +// Navigate with the React Router hook (in components) +import { useNavigate } from 'react-router'; +const navigate = useNavigate(); +navigate('/billing'); +``` + +### Notifications + +```typescript +context.notify('Operation successful!', 'success'); +context.notify('Something went wrong', 'error'); +context.notify('Please check your input', 'warning'); +context.notify('Did you know...', 'info'); +``` + +--- + +## Lifecycle Hooks + +Lifecycle hooks let you respond to application events. + +### onInit + +Called when the extension is first loaded (after store is ready): + +```typescript +onInit: async (context) => { + console.log('Extension initializing...'); + + // Fetch initial data + const data = await fetch('/api/my-data').then(r => r.json()); + context.setState({ data }); + + // Set up subscriptions + context.subscribeToUser((user) => { + if (user) { + // User logged in + } + }); + + console.log('Extension initialized'); +}, +``` + +### onUserLogin + +Called when a user logs in: + +```typescript +onUserLogin: (user, context) => { + console.log('User logged in:', user.email); + context.notify(`Welcome, ${user.email}!`, 'success'); + + // Fetch user-specific data + fetchUserBillingData(user.token).then(data => { + context.setState({ billing: data }); + }); +}, +``` + +### onUserLogout + +Called when a user logs out: + +```typescript +onUserLogout: (context) => { + console.log('User logged out'); + + // Clear sensitive data + context.setState({ + billing: null, + preferences: null, + }); +}, +``` + +--- + +## Script Injection + +Extensions can inject scripts into the zrok UI using two approaches: + +1. **Build-time injection** - Scripts are added to index.html during the Vite build +2. **Runtime injection** - Scripts are dynamically added via React components or hooks + +### When to Use Each Approach + +| Approach | Best For | +|----------|----------| +| Build-time | Analytics, tracking pixels, third-party SDKs that need to load early | +| Runtime | Scripts that depend on user state, conditional loading, scripts that should be removed on unmount | + +### Build-Time Script Injection + +Add scripts to your manifest's `headScripts` (loads in ``) or `bodyScripts` (loads before ``): + +```typescript +import { ExtensionManifest, ScriptDefinition } from '@openziti/zrok-ui/extensions'; + +const headScripts: ScriptDefinition[] = [ + // Inline script for early initialization + { + id: 'analytics-init', + content: ` + window.analytics = window.analytics || []; + window.analytics.push(['init', { key: 'abc123' }]); + `, + }, + // External script + { + id: 'analytics-sdk', + src: 'https://cdn.example.com/analytics.js', + async: true, + }, +]; + +const bodyScripts: ScriptDefinition[] = [ + // Deferred script that runs after page loads + { + id: 'tracking', + content: ` + document.addEventListener('DOMContentLoaded', function() { + console.log('Page loaded, initializing tracking'); + }); + `, + }, +]; + +const manifest: ExtensionManifest = { + id: 'my-extension', + name: 'My Extension', + version: '1.0.0', + + headScripts, + bodyScripts, +}; + +export default manifest; +``` + +#### Enabling the Vite Plugin + +To enable build-time injection, update `vite.config.ts`: + +```typescript +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { extensionScriptsPlugin } from './vite-plugin-extension-scripts'; +import myExtension from '@myorg/my-extension'; + +export default defineConfig({ + plugins: [ + react(), + extensionScriptsPlugin({ + extensions: [myExtension], + verbose: true, // Enable to see injection logs during build + }), + ], +}); +``` + +#### ScriptDefinition Options + +| Property | Type | Description | +|----------|------|-------------| +| `src` | `string` | External script URL (mutually exclusive with `content`) | +| `content` | `string` | Inline script code (mutually exclusive with `src`) | +| `async` | `boolean` | Load script asynchronously | +| `defer` | `boolean` | Defer execution until document is parsed | +| `type` | `string` | Script type (default: `text/javascript`) | +| `id` | `string` | Script ID for identification and deduplication | +| `attributes` | `Record` | Additional HTML attributes | + +### Runtime Script Injection + +For dynamic script loading, use the `ScriptInjector` component or `useScriptInjector` hook. + +#### ScriptInjector Component (Declarative) + +Use when you want script lifecycle tied to component lifecycle: + +```typescript +import React from 'react'; +import { ScriptInjector } from '@openziti/zrok-ui/extensions'; + +const MyComponent: React.FC = () => { + const [loaded, setLoaded] = React.useState(false); + + return ( +
+ { + setLoaded(true); + console.log('Widget script loaded!'); + }} + onError={(error) => console.error('Failed to load:', error)} + removeOnUnmount={true} // Removes script when component unmounts + /> + {loaded &&
Widget loaded!
} +
+ ); +}; +``` + +#### ScriptInjector Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `src` | `string` | - | External script URL | +| `content` | `string` | - | Inline script content | +| `async` | `boolean` | `false` | Load asynchronously | +| `defer` | `boolean` | `false` | Defer execution | +| `type` | `string` | - | Script type | +| `id` | `string` | - | Script ID | +| `attributes` | `Record` | - | Additional attributes | +| `target` | `'head' \| 'body'` | `'body'` | Where to inject | +| `onLoad` | `() => void` | - | Called when script loads | +| `onError` | `(error: Error) => void` | - | Called on load error | +| `removeOnUnmount` | `boolean` | `true` | Remove script on unmount | + +#### useScriptInjector Hook (Imperative) + +Use when you need programmatic control over script injection: + +```typescript +import React, { useEffect } from 'react'; +import { useScriptInjector } from '@openziti/zrok-ui/extensions'; + +const MyComponent: React.FC = () => { + const { injectScript, removeScript, isLoaded } = useScriptInjector(); + + const handleLoadWidget = async () => { + try { + await injectScript({ + id: 'payment-widget', + src: 'https://cdn.example.com/payment.js', + async: true, + }); + console.log('Payment widget loaded!'); + } catch (error) { + console.error('Failed to load payment widget:', error); + } + }; + + const handleRemoveWidget = () => { + const removed = removeScript('payment-widget'); + console.log('Widget removed:', removed); + }; + + return ( +
+ + +
+ ); +}; +``` + +#### useScriptInjector API + +```typescript +interface UseScriptInjectorReturn { + // Inject a script, returns Promise that resolves on load + injectScript: (options: InjectScriptOptions) => Promise; + + // Remove a script by ID, returns true if found and removed + removeScript: (id: string) => boolean; + + // Remove a script by src URL + removeScriptBySrc: (src: string) => boolean; + + // Check if a script with the given ID is in the DOM + isLoaded: (id: string) => boolean; + + // Check if a script with the given src is in the DOM + isLoadedBySrc: (src: string) => boolean; +} +``` + +### Script Injection Flow + +```mermaid +flowchart TB + subgraph buildtime["Build-Time Injection"] + manifest["ExtensionManifest
headScripts / bodyScripts"] + vite["Vite Plugin
extensionScriptsPlugin"] + html["index.html
(scripts embedded)"] + + manifest --> vite + vite -->|"transformIndexHtml"| html + end + + subgraph runtime["Runtime Injection"] + comp["ScriptInjector Component"] + hook["useScriptInjector Hook"] + dom["DOM
(scripts added dynamically)"] + + comp -->|"useEffect"| dom + hook -->|"injectScript()"| dom + end + + subgraph output["Browser"] + head["
headScripts + runtime"] + body["
App + bodyScripts + runtime"] + end + + html --> head + html --> body + dom --> head + dom --> body +``` + +--- + +## Development Workflow + +```mermaid +flowchart LR + subgraph dev["Development Setup"] + ext_repo["Extension Repo"] + zrok_repo["zrok/ui"] + + ext_repo -->|"npm link"| global["Global npm"] + global -->|"npm link pkg"| zrok_repo + end + + subgraph runtime["Dev Server"] + vite["Vite Dev Server
:5173"] + api["zrok API Server
:18080"] + + vite -->|"proxy /api/v2"| api + end + + zrok_repo --> vite + browser["Browser"] --> vite +``` + +### Local Development with npm link + +1. In your extension directory: + + ```bash + cd my-extension + npm install + npm link + ``` + +2. In the zrok UI directory: + + ```bash + cd zrok/ui + npm link @myorg/zrok-my-extension + ``` + +3. Enable the extension in `src/extensions.config.ts`: + + ```typescript + import myExtension from '@myorg/zrok-my-extension'; + extensionRegistry.register(myExtension); + ``` + +4. Start the dev server: + + ```bash + npm run dev + ``` + +### Using Local Path Import + +For quicker iteration, import directly from the filesystem: + +```typescript +// In extensions.config.ts +import myExtension from '../../my-extension/src'; +extensionRegistry.register(myExtension); +``` + +### Environment Variables + +Use Vite environment variables to conditionally load extensions: + +```typescript +// In extensions.config.ts +if (import.meta.env.VITE_ENABLE_BILLING === 'true') { + import('@acme/billing-extension').then(({ default: ext }) => { + extensionRegistry.register(ext); + }); +} +``` + +Run with: + +```bash +VITE_ENABLE_BILLING=true npm run dev +``` + +--- + +## Deployment + +```mermaid +flowchart LR + subgraph sources["Source Repositories"] + zrok["zrok repo
v1.0.0"] + ext["extension repo
v2.0.0"] + end + + subgraph build["Build Process"] + clone["Clone zrok"] + install["npm install"] + config["Configure extensions"] + bundle["npm run build"] + end + + subgraph output["Output"] + dist["dist/"] + binary["zrok binary
with embedded UI"] + end + + zrok --> clone + ext -->|"npm install"| install + clone --> install + install --> config + config --> bundle + bundle --> dist + dist -->|"go build"| binary +``` + +### Option 1: Build Script + +Create a build script that combines zrok and your extension: + +```bash +#!/bin/bash +# build-with-extensions.sh + +ZROK_VERSION="${1:-main}" +EXTENSION_VERSION="${2:-latest}" + +# Clone zrok +git clone --depth 1 --branch "$ZROK_VERSION" https://github.com/openziti/zrok +cd zrok/ui + +# Install dependencies +npm install + +# Install your extension +npm install @myorg/zrok-my-extension@$EXTENSION_VERSION + +# Configure extensions +cat > src/extensions.config.ts << 'EOF' +import { extensionRegistry } from './extensions/registry'; +import myExtension from '@myorg/zrok-my-extension'; + +export function loadExtensions(): void { + extensionRegistry.register(myExtension); +} + +export { extensionRegistry }; +EOF + +# Build +npm run build + +# The built UI is in dist/ +``` + +### Option 2: Docker Build + +```dockerfile +FROM node:20 AS ui-builder + +# Clone zrok +WORKDIR /build +RUN git clone --depth 1 --branch v1.0.0 https://github.com/openziti/zrok + +# Install dependencies +WORKDIR /build/zrok/ui +RUN npm install + +# Install extension +RUN npm install @myorg/zrok-my-extension@1.0.0 + +# Configure extensions +COPY extensions.config.ts src/extensions.config.ts + +# Build UI +RUN npm run build + +# Continue with Go build... +FROM golang:1.21 AS go-builder +# ... +``` + +### Option 3: Fork and Modify + +For maximum control, fork the zrok repository and add your extension directly: + +1. Fork github.com/openziti/zrok +2. Add your extension to `ui/src/extensions.config.ts` +3. Maintain your fork with periodic merges from upstream + +--- + +## API Reference + +### Types + +```typescript +// Main manifest +interface ExtensionManifest { + id: string; + name: string; + version: string; + description?: string; + routes?: ExtensionRoute[]; + navItems?: ExtensionNavItem[]; + panelExtensions?: PanelExtension[]; + slots?: Record>; + nodeTypes?: Record>; + edgeTypes?: Record>; + initialState?: Record; + headScripts?: ScriptDefinition[]; // Build-time scripts + bodyScripts?: ScriptDefinition[]; // Build-time scripts + onInit?: (context: ExtensionContext) => void | Promise; + onUserLogin?: (user: User, context: ExtensionContext) => void; + onUserLogout?: (context: ExtensionContext) => void; +} + +// Script definition for build-time and runtime injection +interface ScriptDefinition { + src?: string; // External script URL + content?: string; // Inline script content + async?: boolean; // Async loading + defer?: boolean; // Defer execution + type?: string; // Script type + id?: string; // Script ID + attributes?: Record; // Additional attributes +} + +// Route definition +interface ExtensionRoute { + path: string; + component: ComponentType; + exact?: boolean; + requiresAuth?: boolean; +} + +// Route component props +interface ExtensionRouteProps { + user: User | null; + context: ExtensionContext; + logout: () => void; +} + +// Navigation item +interface ExtensionNavItem { + id: string; + label: string; + icon?: ComponentType<{ fontSize?: 'small' | 'medium' | 'large' }>; + path?: string; + onClick?: () => void; + position?: 'left' | 'right'; + tooltip?: string; + order?: number; + visible?: (user: User | null, extensionState: Record) => boolean; +} + +// Panel extension +interface PanelExtension { + nodeTypes: string[]; + position: 'before' | 'after' | 'tab' | 'replace'; + component: ComponentType; + tabLabel?: string; + tabIcon?: ComponentType; + order?: number; +} + +// Panel extension component props +interface PanelExtensionProps { + node: Node; + user: User; + context: ExtensionContext; +} + +// Slot component props +interface SlotProps { + user?: User | null; + selectedNode?: Node | null; + context: ExtensionContext; + [key: string]: unknown; +} + +// Extension context +interface ExtensionContext { + extensionId: string; + getState(): T | undefined; + setState(state: Partial): void; + subscribe(selector: (state: T) => unknown, callback: (value: unknown, prev: unknown) => void): () => void; + getUser(): User | null; + subscribeToUser(callback: (user: User | null) => void): () => void; + getSelectedNode(): Node | null; + subscribeToSelectedNode(callback: (node: Node | null) => void): () => void; + navigate(path: string): void; + notify(message: string, severity?: 'info' | 'success' | 'warning' | 'error'): void; +} +``` + +### Slot Constants + +```typescript +import { SLOTS } from '@openziti/zrok-ui/extensions'; + +SLOTS.NAVBAR_LEFT +SLOTS.NAVBAR_CENTER +SLOTS.NAVBAR_RIGHT +SLOTS.ACCOUNT_PANEL_TOP +SLOTS.ACCOUNT_PANEL_BOTTOM +SLOTS.ACCOUNT_PANEL_ACTIONS +SLOTS.ENVIRONMENT_PANEL_TOP +SLOTS.ENVIRONMENT_PANEL_BOTTOM +SLOTS.SHARE_PANEL_TOP +SLOTS.SHARE_PANEL_BOTTOM +SLOTS.CONSOLE_TOP +SLOTS.CONSOLE_BOTTOM +SLOTS.CONSOLE_SIDEBAR +SLOTS.LOGIN_TOP +SLOTS.LOGIN_BOTTOM +``` + +--- + +## Best Practices + +### 1. Use TypeScript + +Always define typed interfaces for your extension state: + +```typescript +interface MyExtensionState { + data: MyData | null; + loading: boolean; + error: string | null; +} +``` + +### 2. Handle Errors Gracefully + +Wrap async operations in try/catch: + +```typescript +onInit: async (context) => { + try { + const data = await fetchData(); + context.setState({ data, error: null }); + } catch (error) { + context.setState({ error: error.message }); + context.notify('Failed to load data', 'error'); + } +}, +``` + +### 3. Clean Up Subscriptions + +Always unsubscribe when appropriate: + +```typescript +onInit: (context) => { + const unsubscribe = context.subscribeToUser((user) => { + // ... + }); + + // Store unsubscribe for later cleanup if needed + context.setState({ _cleanup: unsubscribe }); +}, +``` + +### 4. Use Semantic Versioning + +Follow semver for your extension versions to help users manage updates. + +### 5. Document Your Extension + +Include a README with: +- Installation instructions +- Configuration options +- Available features +- Screenshots/examples + +### 6. Test Your Extension + +Test with different zrok versions and user scenarios: +- Fresh user (no data) +- User with existing data +- Admin vs regular user +- Error conditions + +--- + +## Troubleshooting + +### Extension Not Loading + +1. Check that the extension is registered in `extensions.config.ts` +2. Check the browser console for errors +3. Verify the manifest has required fields (`id`, `name`, `version`) + +### Routes Not Working + +1. Ensure paths start with `/` +2. Check for route conflicts with built-in routes +3. Verify `requiresAuth` is set correctly + +### State Not Persisting + +1. State is not automatically persisted to localStorage +2. Use `onUserLogin` to restore state if needed +3. Check that `setState` is called with the correct extension ID + +### Panel Tab Not Showing + +1. Verify `tabLabel` is set for tab position +2. Check that `nodeTypes` matches the selected node +3. Ensure the component doesn't throw errors + +### Nav Item Not Visible + +1. Check the `visible` function if defined +2. Verify `position` is correct +3. Check for console errors + +### TypeScript Errors + +1. Ensure path mappings are correct in `tsconfig.json` +2. Install peer dependencies +3. Use `@openziti/zrok-ui/*` import paths + +--- + +## Example Extensions + +See the `examples/demo-extension` directory in the zrok repository for a complete working example that demonstrates all extension features. + +--- + +## Support + +For questions and issues: +- GitHub Issues: https://github.com/openziti/zrok/issues +- Documentation: https://docs.zrok.io diff --git a/ui/examples/demo-extension/README.md b/ui/examples/demo-extension/README.md new file mode 100644 index 000000000..6825d629f --- /dev/null +++ b/ui/examples/demo-extension/README.md @@ -0,0 +1,128 @@ +# Demo Extension for zrok UI + +This is an example extension demonstrating the capabilities of the zrok UI extension system. + +## Features Demonstrated + +- **Custom Routes**: Adds `/demo` and `/demo/settings` pages +- **Navigation Items**: Adds a "Demo" button to the navbar +- **Panel Extensions**: Adds a "Billing" tab to the Account panel +- **Slot Injections**: Adds a notification badge to the navbar +- **State Management**: Demonstrates persistent state with a counter +- **Lifecycle Hooks**: Shows `onInit`, `onUserLogin`, and `onUserLogout` + +## Development + +### Prerequisites + +- Node.js 18+ +- npm or yarn +- zrok repository cloned + +### Important Note + +This demo extension is designed to be used via **path imports** from the zrok UI project. +Do **not** run `npm install` directly in this directory. All dependencies come from the +parent `zrok/ui` project. + +### Setup for Local Development + +1. First, install dependencies in the main zrok UI: + + ```bash + cd ui + npm install + ``` + +2. Enable the extension in the zrok UI: + + Edit `ui/src/extensions.config.ts`: + + ```typescript + import { extensionRegistry } from './extensions/registry'; + import demoExtension from '../examples/demo-extension/src'; + + export function loadExtensions(): void { + extensionRegistry.register(demoExtension); + } + ``` + +3. Start the zrok UI development server: + + ```bash + cd ui + npm run dev + ``` + +4. The demo extension features should now be visible in the UI. + +## File Structure + +``` +demo-extension/ +├── package.json # Package configuration +├── tsconfig.json # TypeScript configuration +├── README.md # This file +└── src/ + ├── index.ts # Extension manifest (main entry) + ├── DemoIcon.tsx # Icon component for nav item + ├── DemoPage.tsx # Main demo page (/demo) + ├── DemoSettingsPage.tsx # Settings page (/demo/settings) + ├── AccountBillingTab.tsx # Tab added to Account panel + └── DemoNavbarSlot.tsx # Component for navbar slot +``` + +## Extension Manifest + +The main entry point (`src/index.ts`) exports an `ExtensionManifest` object: + +```typescript +const manifest: ExtensionManifest = { + id: 'demo-extension', + name: 'Demo Extension', + version: '1.0.0', + + routes: [...], + navItems: [...], + panelExtensions: [...], + slots: {...}, + initialState: {...}, + + onInit: async (context) => {...}, + onUserLogin: (user, context) => {...}, + onUserLogout: (context) => {...}, +}; +``` + +## State Management + +The extension uses a typed state interface: + +```typescript +interface DemoExtensionState { + counter: number; + lastVisited: string | null; + settings: { + enableFeatureX: boolean; + theme: 'light' | 'dark'; + }; +} +``` + +Access state in components via the context: + +```typescript +const state = context.getState(); +context.setState({ counter: state.counter + 1 }); +``` + +## Building for Production + +This extension is designed to be used as a TypeScript source during development. +For production distribution as an npm package, you would: + +1. Add a build step to compile TypeScript +2. Configure `package.json` to point to compiled output +3. Publish to npm or a private registry + +See the Extension Developer Guide for complete packaging instructions. diff --git a/ui/examples/demo-extension/package.json b/ui/examples/demo-extension/package.json new file mode 100644 index 000000000..c95bc0495 --- /dev/null +++ b/ui/examples/demo-extension/package.json @@ -0,0 +1,30 @@ +{ + "name": "@example/zrok-demo-extension", + "version": "1.0.0", + "description": "Example extension for the zrok web UI demonstrating extension capabilities", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "@mui/material": "^6.0.0", + "@mui/icons-material": "^6.0.0", + "@xyflow/react": "^12.0.0", + "react-router": "^7.0.0" + }, + "devDependencies": { + "@types/react": "^18.3.12 || ^19.0.0", + "typescript": "~5.6.2" + }, + "keywords": [ + "zrok", + "extension", + "demo" + ], + "license": "Apache-2.0", + "private": true, + "readme": "This extension is designed to be used via path imports from the zrok UI. Do not run npm install here directly. Instead, import from the parent zrok/ui project." +} diff --git a/ui/examples/demo-extension/src/AccountBillingTab.tsx b/ui/examples/demo-extension/src/AccountBillingTab.tsx new file mode 100644 index 000000000..de52c3a31 --- /dev/null +++ b/ui/examples/demo-extension/src/AccountBillingTab.tsx @@ -0,0 +1,133 @@ +/** + * Account Billing Tab + * + * A tab added to the Account panel demonstrating panel extensions. + * In a real billing extension, this would show subscription info, invoices, etc. + */ + +import React, { useState } from 'react'; +import { + Box, + Button, + Card, + CardContent, + Chip, + Divider, + List, + ListItem, + ListItemText, + Typography, +} from '@mui/material'; +import CreditCardIcon from '@mui/icons-material/CreditCard'; +import ReceiptIcon from '@mui/icons-material/Receipt'; +import { PanelExtensionProps } from '../../../src/extensions'; + +// Mock data for demonstration +const mockSubscription = { + plan: 'Professional', + status: 'active', + nextBillingDate: '2024-02-15', + amount: '$49.00/month', +}; + +const mockInvoices = [ + { id: 'INV-001', date: '2024-01-15', amount: '$49.00', status: 'paid' }, + { id: 'INV-002', date: '2023-12-15', amount: '$49.00', status: 'paid' }, + { id: 'INV-003', date: '2023-11-15', amount: '$49.00', status: 'paid' }, +]; + +const AccountBillingTab: React.FC = ({ node, user, context }) => { + const [loading, setLoading] = useState(false); + + const handleManageSubscription = () => { + context.notify('Opening subscription management...', 'info'); + // In a real extension, this would open a modal or navigate to a billing page + context.navigate('/demo'); + }; + + const handleViewInvoice = (invoiceId: string) => { + context.notify(`Viewing invoice ${invoiceId}`, 'info'); + }; + + return ( + + + + Billing & Subscription + + + + + + + Current Plan + + + + + + {mockSubscription.plan} + + + + {mockSubscription.amount} + + + + Next billing: {mockSubscription.nextBillingDate} + + + + + + + + + Recent Invoices + + + + {mockInvoices.map((invoice, index) => ( + + handleViewInvoice(invoice.id)}> + View + + } + > + + + + {index < mockInvoices.length - 1 && } + + ))} + + + + This is a demo billing tab. In a real extension, this would connect to your billing system. + + + ); +}; + +export default AccountBillingTab; diff --git a/ui/examples/demo-extension/src/DemoIcon.tsx b/ui/examples/demo-extension/src/DemoIcon.tsx new file mode 100644 index 000000000..f1b84fd7d --- /dev/null +++ b/ui/examples/demo-extension/src/DemoIcon.tsx @@ -0,0 +1,18 @@ +/** + * Demo Icon Component + * + * A simple icon component for the demo extension. + */ + +import React from 'react'; +import ScienceIcon from '@mui/icons-material/Science'; + +interface DemoIconProps { + fontSize?: 'small' | 'medium' | 'large'; +} + +const DemoIcon: React.FC = ({ fontSize = 'medium' }) => { + return ; +}; + +export default DemoIcon; diff --git a/ui/examples/demo-extension/src/DemoNavbarSlot.tsx b/ui/examples/demo-extension/src/DemoNavbarSlot.tsx new file mode 100644 index 000000000..c96d0d68b --- /dev/null +++ b/ui/examples/demo-extension/src/DemoNavbarSlot.tsx @@ -0,0 +1,37 @@ +/** + * Demo Navbar Slot Component + * + * A component injected into the NAVBAR_RIGHT slot. + * Demonstrates how extensions can inject UI into predefined slots. + */ + +import React from 'react'; +import { Badge, Button, Tooltip } from '@mui/material'; +import NotificationsIcon from '@mui/icons-material/Notifications'; +import { SlotProps } from '../../../src/extensions'; +import { DemoExtensionState } from './index'; + +const DemoNavbarSlot: React.FC = ({ user, context }) => { + // Get the counter from extension state to show as a badge + const state = context.getState(); + const counter = state?.counter ?? 0; + + const handleClick = () => { + context.notify(`You have ${counter} notifications (demo)`, 'info'); + }; + + // Only show if user is logged in + if (!user) return null; + + return ( + + + + ); +}; + +export default DemoNavbarSlot; diff --git a/ui/examples/demo-extension/src/DemoPage.tsx b/ui/examples/demo-extension/src/DemoPage.tsx new file mode 100644 index 000000000..67cea169a --- /dev/null +++ b/ui/examples/demo-extension/src/DemoPage.tsx @@ -0,0 +1,270 @@ +/** + * Demo Page Component + * + * A full page added by the demo extension, accessible at /demo. + * Demonstrates how extensions can add complete pages to the UI. + */ + +import React, { useState } from 'react'; +import { + Box, + Button, + Card, + CardContent, + Container, + Grid2, + Typography, + AppBar, + Toolbar, + Chip, +} from '@mui/material'; +import { useNavigate } from 'react-router'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import AddIcon from '@mui/icons-material/Add'; +import RemoveIcon from '@mui/icons-material/Remove'; +import SettingsIcon from '@mui/icons-material/Settings'; +import CodeIcon from '@mui/icons-material/Code'; +import { ExtensionRouteProps, ScriptInjector, useScriptInjector } from '../../../src/extensions'; +import { DemoExtensionState } from './index'; + +const DemoPage: React.FC = ({ user, context, logout }) => { + const navigate = useNavigate(); + + // Get extension state + const state = context.getState(); + const counter = state?.counter ?? 0; + const lastVisited = state?.lastVisited; + + // Runtime script injection demo + const [runtimeScriptLoaded, setRuntimeScriptLoaded] = useState(false); + const [hookScriptLoaded, setHookScriptLoaded] = useState(false); + const { injectScript, removeScript, isLoaded } = useScriptInjector(); + + const handleIncrement = () => { + context.setState({ counter: counter + 1 }); + context.notify('Counter incremented!', 'info'); + }; + + const handleDecrement = () => { + context.setState({ counter: counter - 1 }); + context.notify('Counter decremented!', 'info'); + }; + + const handleBack = () => { + navigate('/'); + }; + + const handleSettings = () => { + navigate('/demo/settings'); + }; + + // Handler for useScriptInjector hook demo + const handleInjectScript = async () => { + try { + await injectScript({ + id: 'demo-hook-injected-script', + content: ` + console.log('[Demo Extension] Script injected via useScriptInjector hook!'); + window.demoHookInjected = true; + `, + }); + setHookScriptLoaded(true); + context.notify('Script injected via hook!', 'success'); + } catch (error) { + context.notify('Failed to inject script', 'error'); + } + }; + + const handleRemoveScript = () => { + const removed = removeScript('demo-hook-injected-script'); + if (removed) { + setHookScriptLoaded(false); + context.notify('Script removed!', 'info'); + } + }; + + return ( + + + + + + Demo Extension + + + + + + + + + Demo Extension Page + + + + This page demonstrates how extensions can add complete new pages to the zrok UI. + The page has access to the current user, extension context, and can interact with + the extension's state. + + + + + + + + User Information + + + Email: {user?.email ?? 'Not logged in'} + + + Extension ID: {context.extensionId} + + {lastVisited && ( + + Last Visited: {new Date(lastVisited).toLocaleString()} + + )} + + + + + + + + + State Demo: Counter + + + {counter} + + + + + + + This counter persists in the Zustand store + + + + + + + + + + + Runtime Script Injection Demo + + + Extensions can inject scripts at runtime using the ScriptInjector component + or the useScriptInjector hook. Check the browser console to see the output. + + + + + ScriptInjector Component (Declarative) + + + The ScriptInjector component below is rendered when this page loads. + It automatically injects an inline script and removes it on unmount. + + + {/* Declarative script injection via component */} + setRuntimeScriptLoaded(true)} + removeOnUnmount={true} + /> + + + + + useScriptInjector Hook (Imperative) + + + Use the hook for programmatic control over script injection. + + + + + + + + + + + + + + + + Extension Capabilities Demonstrated + +
    +
  • Custom route at /demo
  • +
  • Navigation item in the navbar
  • +
  • State management via extension context
  • +
  • User information access
  • +
  • Notification system
  • +
  • Navigation between pages
  • +
  • Build-time script injection (headScripts/bodyScripts)
  • +
  • Runtime script injection (ScriptInjector component)
  • +
  • Imperative script injection (useScriptInjector hook)
  • +
+
+
+
+
+
+
+ ); +}; + +export default DemoPage; diff --git a/ui/examples/demo-extension/src/DemoSettingsPage.tsx b/ui/examples/demo-extension/src/DemoSettingsPage.tsx new file mode 100644 index 000000000..a24912dc9 --- /dev/null +++ b/ui/examples/demo-extension/src/DemoSettingsPage.tsx @@ -0,0 +1,136 @@ +/** + * Demo Settings Page + * + * A settings page for the demo extension. + * Demonstrates nested routes and form handling in extensions. + */ + +import React from 'react'; +import { + Box, + Button, + Card, + CardContent, + Container, + FormControlLabel, + Switch, + Typography, + AppBar, + Toolbar, + FormControl, + InputLabel, + Select, + MenuItem, +} from '@mui/material'; +import { useNavigate } from 'react-router'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import { ExtensionRouteProps } from '../../../src/extensions'; +import { DemoExtensionState } from './index'; + +const DemoSettingsPage: React.FC = ({ context }) => { + const navigate = useNavigate(); + + // Get extension state + const state = context.getState(); + const settings = state?.settings ?? { enableFeatureX: true, theme: 'light' }; + + const handleBack = () => { + navigate('/demo'); + }; + + const handleFeatureXToggle = (event: React.ChangeEvent) => { + context.setState({ + settings: { + ...settings, + enableFeatureX: event.target.checked, + }, + }); + context.notify( + `Feature X ${event.target.checked ? 'enabled' : 'disabled'}`, + 'info' + ); + }; + + const handleThemeChange = (event: any) => { + context.setState({ + settings: { + ...settings, + theme: event.target.value, + }, + }); + context.notify(`Theme changed to ${event.target.value}`, 'info'); + }; + + return ( + + + + + + Demo Extension Settings + + + + + + + Extension Settings + + + + + + Feature Toggles + + + + } + label="Enable Feature X" + /> + + + This setting demonstrates how extensions can persist settings in the store. + + + + Theme + + + + + Note: This is a demo setting and does not actually change the theme. + + + + + + + + Current State + +
+              {JSON.stringify(state, null, 2)}
+            
+
+
+
+
+ ); +}; + +export default DemoSettingsPage; diff --git a/ui/examples/demo-extension/src/index.ts b/ui/examples/demo-extension/src/index.ts new file mode 100644 index 000000000..fcb4a1af5 --- /dev/null +++ b/ui/examples/demo-extension/src/index.ts @@ -0,0 +1,184 @@ +/** + * Demo Extension for zrok UI + * + * This extension demonstrates all the extension capabilities: + * - Custom routes (pages) + * - Navigation items + * - Panel extensions (tabs) + * - Slot injections + * - State management + * - Lifecycle hooks + * - Script injection (build-time and runtime) + */ + +import { ExtensionManifest, SLOTS, ScriptDefinition } from '../../../src/extensions'; +import DemoPage from './DemoPage'; +import DemoSettingsPage from './DemoSettingsPage'; +import AccountBillingTab from './AccountBillingTab'; +import DemoNavbarSlot from './DemoNavbarSlot'; +import DemoIcon from './DemoIcon'; + +// Define the extension's state interface +export interface DemoExtensionState { + counter: number; + lastVisited: string | null; + settings: { + enableFeatureX: boolean; + theme: 'light' | 'dark'; + }; +} + +// Initial state +const initialState: DemoExtensionState = { + counter: 0, + lastVisited: null, + settings: { + enableFeatureX: true, + theme: 'light', + }, +}; + +/** + * Build-time script injection examples + * + * These scripts are injected into index.html during the Vite build process. + * Use headScripts for scripts that need to load early (analytics, polyfills). + * Use bodyScripts for scripts that can load after page content. + * + * To enable build-time injection, uncomment the extensionScriptsPlugin + * in vite.config.ts and pass this extension to it. + */ + +// Scripts to inject in - loaded early +const headScripts: ScriptDefinition[] = [ + // Example: Inline script for early initialization + { + id: 'demo-analytics-init', + content: ` + // Demo Analytics - Initialize early + window.demoAnalytics = window.demoAnalytics || []; + window.demoAnalytics.push(['init', { extensionId: 'demo-extension' }]); + console.log('[Demo Extension] Analytics initialized (build-time head script)'); + `, + }, + // Example: External script with async loading + // Uncomment to test external script loading: + // { + // id: 'demo-external-sdk', + // src: 'https://example.com/sdk.js', + // async: true, + // }, +]; + +// Scripts to inject before - loaded after page content +const bodyScripts: ScriptDefinition[] = [ + // Example: Inline script for deferred operations + { + id: 'demo-tracking-script', + content: ` + // Demo tracking - runs after page loads + document.addEventListener('DOMContentLoaded', function() { + console.log('[Demo Extension] Page loaded, tracking initialized (build-time body script)'); + if (window.demoAnalytics) { + window.demoAnalytics.push(['pageview', { page: window.location.pathname }]); + } + }); + `, + }, +]; + +const manifest: ExtensionManifest = { + id: 'demo-extension', + name: 'Demo Extension', + version: '1.0.0', + description: 'Demonstrates zrok UI extension capabilities', + + // Initial state for the extension + initialState, + + // Add new routes (pages) + routes: [ + { + path: '/demo', + component: DemoPage, + requiresAuth: true, + }, + { + path: '/demo/settings', + component: DemoSettingsPage, + requiresAuth: true, + }, + ], + + // Add navigation items to the navbar + navItems: [ + { + id: 'demo-nav', + label: 'Demo', + icon: DemoIcon, + path: '/demo', + position: 'right', + tooltip: 'Demo Extension Page', + order: 10, + }, + ], + + // Extend existing panels with tabs + panelExtensions: [ + { + nodeTypes: ['account'], + position: 'tab', + tabLabel: 'Billing', + component: AccountBillingTab, + order: 1, + }, + ], + + // Inject components into slots + slots: { + [SLOTS.NAVBAR_RIGHT]: DemoNavbarSlot, + }, + + // Build-time script injection + // These are injected into index.html when using extensionScriptsPlugin in vite.config.ts + headScripts, + bodyScripts, + + // Lifecycle hooks + onInit: async (context) => { + console.log('[Demo Extension] Initializing...'); + + // Example: Subscribe to user changes + context.subscribeToUser((user) => { + if (user) { + console.log('[Demo Extension] User logged in:', user.email); + context.setState({ lastVisited: new Date().toISOString() }); + } + }); + + // Example: Subscribe to node selection + context.subscribeToSelectedNode((node) => { + if (node) { + console.log('[Demo Extension] Node selected:', node.type, node.id); + } + }); + + console.log('[Demo Extension] Initialized successfully'); + }, + + onUserLogin: (user, context) => { + console.log('[Demo Extension] User login hook:', user.email); + context.notify(`Welcome back!`, 'success'); + }, + + onUserLogout: (context) => { + console.log('[Demo Extension] User logout hook'); + // Reset extension state on logout + context.setState({ + counter: 0, + lastVisited: null, + }); + }, +}; + +export default manifest; diff --git a/ui/examples/demo-extension/tsconfig.json b/ui/examples/demo-extension/tsconfig.json new file mode 100644 index 000000000..109f0ac28 --- /dev/null +++ b/ui/examples/demo-extension/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/ui/src/ApiConsole.tsx b/ui/src/ApiConsole.tsx index a4067ea1c..6f6a68f74 100644 --- a/ui/src/ApiConsole.tsx +++ b/ui/src/ApiConsole.tsx @@ -18,6 +18,9 @@ import {Node} from "@xyflow/react"; import {getMetadataApi} from "./model/api.ts"; import {User} from "./model/user.ts"; import {isAbortError} from "./model/errors.ts"; +import {PanelWrapper} from "./extensions/PanelWrapper.tsx"; +import {Slot} from "./extensions/SlotRenderer.tsx"; +import {SLOTS} from "./extensions/types.ts"; interface ApiConsoleProps { logout: () => void; @@ -170,10 +173,30 @@ const ApiConsole = ({ logout }: ApiConsoleProps) => { const renderSidePanel = () => { if (!selectedNode) return null; switch (selectedNode.type) { - case "account": return ; - case "environment": return ; - case "share": return ; - case "access": return ; + case "account": + return ( + + + + ); + case "environment": + return ( + + + + ); + case "share": + return ( + + + + ); + case "access": + return ( + + + + ); default: return null; } }; @@ -274,6 +297,8 @@ const ApiConsole = ({ logout }: ApiConsoleProps) => { }} > + {/* Extension slot: top of console area */} + { ) : null} + {/* Extension slot: sidebar area */} + {visualizerEnabled && selectedNode && !panelMinimized ? ( { ) : null} + {/* Extension slot: bottom of console area */} + ); } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index d91fd9b64..976590228 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,18 +1,66 @@ -import {BrowserRouter, Route, Routes} from "react-router"; +import {BrowserRouter, Route, Routes, useNavigate} from "react-router"; import ApiConsole from "./ApiConsole.tsx"; import Login from "./Login.tsx"; -import {useEffect} from "react"; +import {useEffect, useState, useCallback} from "react"; import {clearStoredUser, loadStoredUser, saveStoredUser, User} from "./model/user.ts"; import useApiConsoleStore from "./model/store.ts"; import ForgotPassword from "./ForgotPassword.tsx"; import Register from "./Register.tsx"; import ResetPassword from "./ResetPassword.tsx"; import ErrorBoundary from "./ErrorBoundary.tsx"; +import {extensionRegistry} from "./extensions/registry.ts"; +import {loadExtensions} from "./extensions.config.ts"; +import {Snackbar, Alert} from "@mui/material"; -const App = () => { +// Notification state for extensions +interface Notification { + message: string; + severity: 'info' | 'success' | 'warning' | 'error'; + open: boolean; +} + +// Inner app component that has access to router context +const AppContent = () => { + const navigate = useNavigate(); const user = useApiConsoleStore((state) => state.user); const updateUser = useApiConsoleStore((state) => state.updateUser); + const initializeExtensionStates = useApiConsoleStore((state) => state.initializeExtensionStates); + const [extensionsLoaded, setExtensionsLoaded] = useState(false); + const [notification, setNotification] = useState({ + message: '', + severity: 'info', + open: false + }); + + // Notification function for extensions + const notify = useCallback((message: string, severity: 'info' | 'success' | 'warning' | 'error' = 'info') => { + setNotification({ message, severity, open: true }); + }, []); + + const handleCloseNotification = () => { + setNotification(prev => ({ ...prev, open: false })); + }; + + // Load and initialize extensions + useEffect(() => { + const initExtensions = async () => { + // Load extension manifests + loadExtensions(); + // Initialize extension states in store + const initialStates = extensionRegistry.getInitialStates(); + initializeExtensionStates(initialStates); + + // Initialize all extensions + await extensionRegistry.initializeAll(navigate, notify); + + setExtensionsLoaded(true); + }; + + initExtensions(); + }, [navigate, notify, initializeExtensionStates]); + + // Check for stored user on mount useEffect(() => { const checkUser = () => { updateUser(loadStoredUser()); @@ -26,6 +74,17 @@ const App = () => { }; }, [updateUser]); + // Notify extensions of user changes + useEffect(() => { + if (!extensionsLoaded) return; + + if (user) { + extensionRegistry.notifyUserLogin(user); + } else { + extensionRegistry.notifyUserLogout(); + } + }, [user, extensionsLoaded]); + const login = (user: User) => { updateUser(user); saveStoredUser(user); @@ -36,18 +95,81 @@ const App = () => { clearStoredUser(); }; + // Get extension routes + const extensionRoutes = extensionRegistry.getRoutes(); + const consoleRoot = user ? : return ( - + <> } /> } /> } /> + + {/* Extension routes */} + {extensionRoutes.map((route) => { + const context = extensionRegistry.getContext(route.extensionId); + const RouteComponent = route.component; + + // Handle authentication requirement (default true) + const requiresAuth = route.requiresAuth !== false; + + if (requiresAuth && !user) { + // Redirect to login for protected routes + return ( + } + /> + ); + } + + return ( + + + + } + /> + ); + })} + + {/* Notification snackbar for extensions */} + + + {notification.message} + + + + ); +} + +const App = () => { + return ( + + ); }; diff --git a/ui/src/NavBar.tsx b/ui/src/NavBar.tsx index 7bfe7b8ee..4deb2c08d 100644 --- a/ui/src/NavBar.tsx +++ b/ui/src/NavBar.tsx @@ -1,4 +1,5 @@ import {AppBar, Box, Button, Grid2, Toolbar, Tooltip, Typography} from "@mui/material"; +import {useNavigate} from "react-router"; import LogoutIcon from "@mui/icons-material/Logout"; import VisualizerIcon from "@mui/icons-material/Hub"; import TabularIcon from "@mui/icons-material/TableRows"; @@ -9,6 +10,9 @@ import useApiConsoleStore from "./model/store.ts"; import BandwidthLimitedModal from "./BandwidthLimitedModal.tsx"; import {useEffect, useState} from "react"; import GettingStartedModal from "./GettingStartedModal.tsx"; +import {extensionRegistry} from "./extensions/registry.ts"; +import {Slot} from "./extensions/SlotRenderer.tsx"; +import {SLOTS, ExtensionNavItem} from "./extensions/types.ts"; interface NavBarProps { logout: () => void; @@ -17,8 +21,11 @@ interface NavBarProps { } const NavBar = ({ logout, visualizer, toggleMode }: NavBarProps) => { + const navigate = useNavigate(); + const user = useApiConsoleStore((state) => state.user); const nodes = useApiConsoleStore((state) => state.nodes); const limited = useApiConsoleStore((state) => state.limited); + const extensions = useApiConsoleStore((state) => state.extensions); const [limitedModalOpen, setLimitedModalOpen] = useState(false); const openLimitedModal = () => { setLimitedModalOpen(true); @@ -40,6 +47,47 @@ const NavBar = ({ logout, visualizer, toggleMode }: NavBarProps) => { } }, [limited]) + // Get extension nav items + const leftNavItems = extensionRegistry.getNavItems('left'); + const rightNavItems = extensionRegistry.getNavItems('right'); + + // Filter visible items based on visibility function + const filterVisibleItems = (items: Array) => { + return items.filter(item => { + if (item.visible) { + const extState = extensions[item.extensionId] || {}; + return item.visible(user, extState); + } + return true; + }); + }; + + const visibleLeftItems = filterVisibleItems(leftNavItems); + const visibleRightItems = filterVisibleItems(rightNavItems); + + // Render a nav item + const renderNavItem = (item: ExtensionNavItem & { extensionId: string }) => { + const handleClick = () => { + if (item.onClick) { + item.onClick(); + } else if (item.path) { + navigate(item.path); + } + }; + + const IconComponent = item.icon; + + return ( + + + + + + ); + }; + const limitedIndicator = ( @@ -81,15 +129,29 @@ const NavBar = ({ logout, visualizer, toggleMode }: NavBarProps) => { z r o k + {/* Extension slot: left side of navbar */} + + {/* Extension nav items: left position */} + {visibleLeftItems.map(renderNavItem)} + {/* Extension slot: center of navbar */} + + { limited ? limitedIndicator : null } + + {/* Extension nav items: right position */} + {visibleRightItems.map(renderNavItem)} + + {/* Extension slot: right side of navbar */} + + { !nodes || nodes.length > 1 ? helpButton : gettingStartedButton } diff --git a/ui/src/extensions.config.ts b/ui/src/extensions.config.ts new file mode 100644 index 000000000..efa338c79 --- /dev/null +++ b/ui/src/extensions.config.ts @@ -0,0 +1,56 @@ +/** + * zrok UI Extensions Configuration + * + * This file is the entry point for loading extensions into the zrok UI. + * Extensions are registered here at build time. + * + * IMPORTANT: Static imports must be at the top level of this file, + * not inside the loadExtensions() function. + */ + +import { extensionRegistry } from './extensions/registry'; + +// ============================================================== +// Import your extensions here (top-level imports) +// ============================================================== + +// Example: Import from npm package +// import billingExtension from '@acme/zrok-billing-extension'; + +// Example: Import from local path (for development) +// import demoExtension from '../examples/demo-extension/src'; + +// ============================================================== +// End of extension imports +// ============================================================== + +/** + * Load and register all extensions. + * Called during application startup. + */ +export function loadExtensions(): void { + // ============================================================== + // Register your extensions here + // ============================================================== + + // Example: Register imported extension + // extensionRegistry.register(billingExtension); + + // Example: Register demo extension + // extensionRegistry.register(demoExtension); + + // Example: Conditional loading based on environment variable + // if (import.meta.env.VITE_ENABLE_BILLING === 'true') { + // import('@acme/zrok-billing-extension').then(({ default: ext }) => { + // extensionRegistry.register(ext); + // }); + // } + + // ============================================================== + // End of extension registration + // ============================================================== + + console.log('[Extensions] Configuration loaded'); +} + +export { extensionRegistry }; diff --git a/ui/src/extensions/PanelWrapper.tsx b/ui/src/extensions/PanelWrapper.tsx new file mode 100644 index 000000000..f48168925 --- /dev/null +++ b/ui/src/extensions/PanelWrapper.tsx @@ -0,0 +1,268 @@ +/** + * PanelWrapper Component + * + * Wraps the built-in panels (AccountPanel, EnvironmentPanel, etc.) and + * injects extension panel components based on their position (before, after, tab, replace). + */ + +import React, {useState, useMemo} from 'react'; +import {Box, Tab, Tabs} from '@mui/material'; +import {Node} from '@xyflow/react'; +import {extensionRegistry} from './registry'; +import {PanelExtension, PanelExtensionProps, SLOTS} from './types'; +import {Slot} from './SlotRenderer'; +import useApiConsoleStore from '../model/store'; + +interface PanelWrapperProps { + /** The node type (account, environment, share, access) */ + nodeType: string; + /** The selected node */ + node: Node; + /** The built-in panel component */ + children: React.ReactNode; +} + +interface TabPanelProps { + children?: React.ReactNode; + value: number; + index: number; +} + +const TabPanel: React.FC = ({ children, value, index }) => { + return ( + + ); +}; + +export const PanelWrapper: React.FC = ({ + nodeType, + node, + children, +}) => { + const user = useApiConsoleStore((state) => state.user); + const [activeTab, setActiveTab] = useState(0); + + // Get panel extensions for this node type + const beforeExtensions = useMemo( + () => extensionRegistry.getPanelExtensions(nodeType, 'before'), + [nodeType] + ); + const afterExtensions = useMemo( + () => extensionRegistry.getPanelExtensions(nodeType, 'after'), + [nodeType] + ); + const tabExtensions = useMemo( + () => extensionRegistry.getPanelExtensions(nodeType, 'tab'), + [nodeType] + ); + const replaceExtensions = useMemo( + () => extensionRegistry.getPanelExtensions(nodeType, 'replace'), + [nodeType] + ); + + // Get the appropriate slot name for this panel type + const getSlotNames = () => { + switch (nodeType) { + case 'account': + return { + top: SLOTS.ACCOUNT_PANEL_TOP, + bottom: SLOTS.ACCOUNT_PANEL_BOTTOM, + actions: SLOTS.ACCOUNT_PANEL_ACTIONS, + }; + case 'environment': + return { + top: SLOTS.ENVIRONMENT_PANEL_TOP, + bottom: SLOTS.ENVIRONMENT_PANEL_BOTTOM, + }; + case 'share': + return { + top: SLOTS.SHARE_PANEL_TOP, + bottom: SLOTS.SHARE_PANEL_BOTTOM, + }; + default: + return {}; + } + }; + + const slots = getSlotNames(); + + // Render extension panel component + const renderExtension = ( + ext: PanelExtension & { extensionId: string }, + index: number + ) => { + const context = extensionRegistry.getContext(ext.extensionId); + if (!context) return null; + + const Component = ext.component; + const props: PanelExtensionProps = { + node, + user, + context, + }; + + return ( + + + + ); + }; + + // If there's a replace extension, use it instead of the built-in panel + if (replaceExtensions.length > 0) { + const ext = replaceExtensions[0]; // Only use the first replace extension + return ( + <> + {/* Slots still work with replaced panels */} + {slots.top && } + {renderExtension(ext, 0)} + {slots.bottom && } + + ); + } + + // If there are tab extensions, render as tabbed interface + if (tabExtensions.length > 0) { + const handleTabChange = (_: React.SyntheticEvent, newValue: number) => { + setActiveTab(newValue); + }; + + return ( + + {/* Slots at top */} + {slots.top && } + + {/* Before extensions */} + {beforeExtensions.map(renderExtension)} + + {/* Tabs */} + + + {tabExtensions.map((ext, index) => ( + : undefined} + iconPosition="start" + /> + ))} + + + {/* Tab panels */} + + {children} + + {tabExtensions.map((ext, index) => ( + + {renderExtension(ext, index)} + + ))} + + {/* After extensions */} + {afterExtensions.map(renderExtension)} + + {/* Slots at bottom */} + {slots.bottom && } + + ); + } + + // Default: render with before/after extensions + return ( + <> + {/* Slots at top */} + {slots.top && } + + {/* Before extensions */} + {beforeExtensions.map(renderExtension)} + + {/* Built-in panel */} + {children} + + {/* After extensions */} + {afterExtensions.map(renderExtension)} + + {/* Slots at bottom */} + {slots.bottom && } + + ); +}; + +/** + * Error boundary for extension panel components. + */ +interface ExtensionErrorBoundaryProps { + extensionId: string; + children: React.ReactNode; +} + +interface ExtensionErrorBoundaryState { + hasError: boolean; + error?: Error; +} + +class ExtensionErrorBoundary extends React.Component< + ExtensionErrorBoundaryProps, + ExtensionErrorBoundaryState +> { + constructor(props: ExtensionErrorBoundaryProps) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error): ExtensionErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { + console.error( + `[PanelWrapper] Error in extension "${this.props.extensionId}":`, + error, + errorInfo + ); + } + + render(): React.ReactNode { + if (this.state.hasError) { + if (import.meta.env.DEV) { + return ( + + Extension error: {this.props.extensionId} +
+ {this.state.error?.message} +
+ ); + } + return null; + } + + return this.props.children; + } +} + +export default PanelWrapper; diff --git a/ui/src/extensions/ScriptInjector.tsx b/ui/src/extensions/ScriptInjector.tsx new file mode 100644 index 000000000..59eec6e9c --- /dev/null +++ b/ui/src/extensions/ScriptInjector.tsx @@ -0,0 +1,170 @@ +/** + * ScriptInjector Component + * + * A React component for dynamically injecting scripts into the DOM at runtime. + * Use this when you need to load scripts after the initial page load. + * + * For scripts that need to load during initial page parse, use the + * extensionScriptsPlugin in vite.config.ts instead. + * + * @example + * ```tsx + * // Load an external script + * console.log('Script loaded!')} + * /> + * + * // Inject inline script content + * + * ``` + */ + +import { useEffect, useRef } from 'react'; +import { ScriptDefinition } from './types'; + +export interface ScriptInjectorProps extends ScriptDefinition { + /** + * Where to inject the script: 'head' or 'body'. + * Default: 'body' + */ + target?: 'head' | 'body'; + + /** + * Called when the script has loaded successfully. + * Only applicable for external scripts (with src). + */ + onLoad?: () => void; + + /** + * Called if the script fails to load. + * Only applicable for external scripts (with src). + */ + onError?: (error: Error) => void; + + /** + * If true, removes the script when the component unmounts. + * Default: true + */ + removeOnUnmount?: boolean; +} + +/** + * React component that injects a script into the DOM. + * + * The script is injected when the component mounts and optionally + * removed when the component unmounts. + */ +export function ScriptInjector({ + src, + content, + async: asyncAttr, + defer, + type, + id, + attributes, + target = 'body', + onLoad, + onError, + removeOnUnmount = true, +}: ScriptInjectorProps): null { + const scriptRef = useRef(null); + + useEffect(() => { + // Check if script with this ID already exists + if (id) { + const existing = document.getElementById(id); + if (existing) { + console.log(`[ScriptInjector] Script with id "${id}" already exists, skipping`); + return; + } + } + + // Check if script with this src already exists + if (src) { + const existing = document.querySelector(`script[src="${src}"]`); + if (existing) { + console.log(`[ScriptInjector] Script with src "${src}" already exists, skipping`); + onLoad?.(); + return; + } + } + + // Create the script element + const script = document.createElement('script'); + scriptRef.current = script; + + if (id) { + script.id = id; + } + + if (src) { + script.src = src; + } + + if (content) { + script.textContent = content; + } + + if (type) { + script.type = type; + } + + if (asyncAttr) { + script.async = true; + } + + if (defer) { + script.defer = true; + } + + // Add additional attributes + if (attributes) { + for (const [key, value] of Object.entries(attributes)) { + script.setAttribute(key, value); + } + } + + // Set up load/error handlers for external scripts + if (src) { + script.onload = () => { + onLoad?.(); + }; + + script.onerror = () => { + const error = new Error(`Failed to load script: ${src}`); + console.error(`[ScriptInjector] ${error.message}`); + onError?.(error); + }; + } + + // Inject the script + const targetElement = target === 'head' ? document.head : document.body; + targetElement.appendChild(script); + + // For inline scripts, call onLoad immediately + if (content && !src) { + onLoad?.(); + } + + // Cleanup function + return () => { + if (removeOnUnmount && scriptRef.current) { + try { + scriptRef.current.remove(); + } catch (e) { + // Script may already be removed + } + scriptRef.current = null; + } + }; + }, [src, content, asyncAttr, defer, type, id, target, removeOnUnmount]); + + // This component renders nothing + return null; +} + +export default ScriptInjector; diff --git a/ui/src/extensions/SlotRenderer.tsx b/ui/src/extensions/SlotRenderer.tsx new file mode 100644 index 000000000..c05d121ca --- /dev/null +++ b/ui/src/extensions/SlotRenderer.tsx @@ -0,0 +1,147 @@ +/** + * SlotRenderer Component + * + * Renders all extension components registered for a specific slot. + * Slots are named injection points in the UI where extensions can add content. + */ + +import React from 'react'; +import { Node } from '@xyflow/react'; +import { extensionRegistry } from './registry'; +import { SlotProps, SlotName } from './types'; +import { User } from '../model/user'; + +interface SlotRendererProps { + /** The name of the slot to render */ + name: SlotName | string; + + /** Current user (optional) */ + user?: User | null; + + /** Currently selected node (optional) */ + selectedNode?: Node | null; + + /** Additional props to pass to slot components */ + [key: string]: unknown; +} + +/** + * Renders all components registered for a given slot. + * + * @example + * ```tsx + * // In NavBar.tsx + * + * + * // In AccountPanel.tsx + * + * ``` + */ +export const Slot: React.FC = ({ + name, + user, + selectedNode, + ...additionalProps +}) => { + const components = extensionRegistry.getSlotComponents(name); + + if (components.length === 0) { + return null; + } + + return ( + <> + {components.map(({ component: Component, extensionId }, index) => { + const context = extensionRegistry.getContext(extensionId); + + if (!context) { + console.warn( + `[Slot] Extension "${extensionId}" context not found for slot "${name}"` + ); + return null; + } + + const slotProps: SlotProps = { + user, + selectedNode, + context, + ...additionalProps, + }; + + return ( + + + + ); + })} + + ); +}; + +/** + * Error boundary for slot components. + * Prevents one extension's error from breaking the entire UI. + */ +interface SlotErrorBoundaryProps { + extensionId: string; + slotName: string; + children: React.ReactNode; +} + +interface SlotErrorBoundaryState { + hasError: boolean; + error?: Error; +} + +class SlotErrorBoundary extends React.Component< + SlotErrorBoundaryProps, + SlotErrorBoundaryState +> { + constructor(props: SlotErrorBoundaryProps) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error): SlotErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { + console.error( + `[Slot] Error in extension "${this.props.extensionId}" slot "${this.props.slotName}":`, + error, + errorInfo + ); + } + + render(): React.ReactNode { + if (this.state.hasError) { + // Return null to silently fail - don't break the UI + // In development, you might want to show an error indicator + if (import.meta.env.DEV) { + return ( +
+ Extension error: {this.props.extensionId} +
+ ); + } + return null; + } + + return this.props.children; + } +} + +export default Slot; diff --git a/ui/src/extensions/context.ts b/ui/src/extensions/context.ts new file mode 100644 index 000000000..773c8f980 --- /dev/null +++ b/ui/src/extensions/context.ts @@ -0,0 +1,125 @@ +/** + * Extension Context Factory + * + * Creates context objects that extensions use to interact with the zrok UI. + * Each extension gets its own context with access to state management, + * navigation, notifications, and subscriptions. + */ + +import { ExtensionContext } from './types'; +import useApiConsoleStore from '../model/store'; + +/** + * Create an extension context for a specific extension. + * + * @param extensionId - The unique ID of the extension + * @param navigate - Navigation function (from react-router) + * @param notify - Notification function + */ +export function createExtensionContext( + extensionId: string, + navigate: (path: string) => void, + notify: (message: string, severity?: 'info' | 'success' | 'warning' | 'error') => void +): ExtensionContext { + return { + extensionId, + + getState: >(): T | undefined => { + const state = useApiConsoleStore.getState(); + return state.extensions?.[extensionId] as T | undefined; + }, + + setState: >(partialState: Partial): void => { + const { setExtensionState } = useApiConsoleStore.getState(); + setExtensionState(extensionId, partialState); + }, + + subscribe: >( + selector: (state: T) => unknown, + callback: (selectedValue: unknown, previousValue: unknown) => void + ): (() => void) => { + let previousValue: unknown; + + return useApiConsoleStore.subscribe((state) => { + const extensionState = state.extensions?.[extensionId] as T | undefined; + if (!extensionState) return; + + const selectedValue = selector(extensionState); + if (selectedValue !== previousValue) { + const prevValue = previousValue; + previousValue = selectedValue; + callback(selectedValue, prevValue); + } + }); + }, + + getUser: () => { + return useApiConsoleStore.getState().user; + }, + + subscribeToUser: (callback: (user: any) => void): (() => void) => { + let previousUser = useApiConsoleStore.getState().user; + + return useApiConsoleStore.subscribe((state) => { + if (state.user !== previousUser) { + previousUser = state.user; + callback(state.user); + } + }); + }, + + getSelectedNode: () => { + return useApiConsoleStore.getState().selectedNode; + }, + + subscribeToSelectedNode: (callback: (node: any) => void): (() => void) => { + let previousNode = useApiConsoleStore.getState().selectedNode; + + return useApiConsoleStore.subscribe((state) => { + if (state.selectedNode !== previousNode) { + previousNode = state.selectedNode; + callback(state.selectedNode); + } + }); + }, + + navigate, + + notify, + }; +} + +/** + * Hook for extensions to access their context within React components. + * Must be used within a component that has access to the extension context. + * + * @example + * ```tsx + * // In an extension component + * function MyExtensionPanel({ context }: PanelExtensionProps) { + * const { getState, setState } = context; + * + * const handleClick = () => { + * setState({ clicked: true }); + * }; + * + * return ; + * } + * ``` + */ +export function useExtensionState>( + extensionId: string +): { + state: T | undefined; + setState: (partial: Partial) => void; +} { + const state = useApiConsoleStore( + (s) => s.extensions?.[extensionId] as T | undefined + ); + const setExtensionState = useApiConsoleStore((s) => s.setExtensionState); + + return { + state, + setState: (partial: Partial) => setExtensionState(extensionId, partial), + }; +} diff --git a/ui/src/extensions/index.ts b/ui/src/extensions/index.ts new file mode 100644 index 000000000..295c4c6f1 --- /dev/null +++ b/ui/src/extensions/index.ts @@ -0,0 +1,54 @@ +/** + * zrok UI Extension System + * + * This module exports all the types, utilities, and components needed + * to create and manage extensions for the zrok web UI. + * + * @example + * ```typescript + * // In an extension's index.ts + * import { ExtensionManifest, SLOTS } from '@openziti/zrok-ui/extensions'; + * + * const manifest: ExtensionManifest = { + * id: 'my-extension', + * name: 'My Extension', + * version: '1.0.0', + * // ... + * }; + * + * export default manifest; + * ``` + */ + +// Type definitions +export type { + ExtensionManifest, + ExtensionRoute, + ExtensionRouteProps, + ExtensionNavItem, + PanelExtension, + PanelExtensionProps, + ExtensionContext, + SlotProps, + SlotName, + ScriptDefinition, +} from './types'; + +// Constants +export { SLOTS } from './types'; + +// Registry +export { extensionRegistry } from './registry'; + +// Context utilities +export { createExtensionContext, useExtensionState } from './context'; + +// Components +export { Slot } from './SlotRenderer'; +export { PanelWrapper } from './PanelWrapper'; +export { ScriptInjector } from './ScriptInjector'; +export type { ScriptInjectorProps } from './ScriptInjector'; + +// Hooks +export { useScriptInjector } from './useScriptInjector'; +export type { InjectScriptOptions, UseScriptInjectorReturn } from './useScriptInjector'; diff --git a/ui/src/extensions/registry.ts b/ui/src/extensions/registry.ts new file mode 100644 index 000000000..3b3713620 --- /dev/null +++ b/ui/src/extensions/registry.ts @@ -0,0 +1,339 @@ +/** + * zrok UI Extension Registry + * + * Central registry for managing extensions. Extensions register themselves + * here during application startup, and the UI queries the registry to + * discover routes, nav items, panel extensions, etc. + */ + +import { ComponentType } from 'react'; +import { + ExtensionManifest, + ExtensionRoute, + ExtensionNavItem, + PanelExtension, + ExtensionContext, + SlotProps, +} from './types'; +import { createExtensionContext } from './context'; + +class ExtensionRegistry { + private extensions: Map = new Map(); + private contexts: Map = new Map(); + private initialized: Set = new Set(); + + /** + * Register an extension with the registry. + * Should be called during application startup before rendering. + */ + register(manifest: ExtensionManifest): void { + if (this.extensions.has(manifest.id)) { + console.warn( + `[Extensions] Extension "${manifest.id}" is already registered. ` + + `The previous registration will be overwritten.` + ); + } + + // Validate manifest + this.validateManifest(manifest); + + this.extensions.set(manifest.id, manifest); + console.log(`[Extensions] Registered extension: ${manifest.name} v${manifest.version}`); + } + + /** + * Unregister an extension. + */ + unregister(extensionId: string): void { + this.extensions.delete(extensionId); + this.contexts.delete(extensionId); + this.initialized.delete(extensionId); + } + + /** + * Initialize all registered extensions. + * Called after the store is ready and user state is loaded. + */ + async initializeAll( + navigate: (path: string) => void, + notify: (message: string, severity?: 'info' | 'success' | 'warning' | 'error') => void + ): Promise { + for (const [id, manifest] of this.extensions) { + if (this.initialized.has(id)) continue; + + try { + const context = createExtensionContext(id, navigate, notify); + this.contexts.set(id, context); + + if (manifest.onInit) { + await manifest.onInit(context); + } + + this.initialized.add(id); + console.log(`[Extensions] Initialized: ${manifest.name}`); + } catch (error) { + console.error(`[Extensions] Failed to initialize ${manifest.name}:`, error); + } + } + } + + /** + * Get context for a specific extension. + */ + getContext(extensionId: string): ExtensionContext | undefined { + return this.contexts.get(extensionId); + } + + /** + * Get all registered extensions. + */ + getAll(): ExtensionManifest[] { + return Array.from(this.extensions.values()); + } + + /** + * Get a specific extension by ID. + */ + get(extensionId: string): ExtensionManifest | undefined { + return this.extensions.get(extensionId); + } + + /** + * Get all routes from all extensions. + */ + getRoutes(): Array { + const routes: Array = []; + + for (const [id, manifest] of this.extensions) { + if (manifest.routes) { + for (const route of manifest.routes) { + routes.push({ ...route, extensionId: id }); + } + } + } + + return routes; + } + + /** + * Get all nav items from all extensions, sorted by position and order. + */ + getNavItems(position?: 'left' | 'right'): Array { + const items: Array = []; + + for (const [id, manifest] of this.extensions) { + if (manifest.navItems) { + for (const item of manifest.navItems) { + const itemPosition = item.position || 'right'; + if (!position || itemPosition === position) { + items.push({ ...item, extensionId: id }); + } + } + } + } + + // Sort by order (default 0), then by name for stability + return items.sort((a, b) => { + const orderA = a.order ?? 0; + const orderB = b.order ?? 0; + if (orderA !== orderB) return orderA - orderB; + return a.label.localeCompare(b.label); + }); + } + + /** + * Get panel extensions for a specific node type. + */ + getPanelExtensions( + nodeType: string, + position?: PanelExtension['position'] + ): Array { + const extensions: Array = []; + + for (const [id, manifest] of this.extensions) { + if (manifest.panelExtensions) { + for (const ext of manifest.panelExtensions) { + const matchesType = ext.nodeTypes.includes('*') || ext.nodeTypes.includes(nodeType); + const matchesPosition = !position || ext.position === position; + + if (matchesType && matchesPosition) { + extensions.push({ ...ext, extensionId: id }); + } + } + } + } + + // Sort by order + return extensions.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + } + + /** + * Get all custom node types from all extensions. + */ + getNodeTypes(): Record> { + const nodeTypes: Record> = {}; + + for (const manifest of this.extensions.values()) { + if (manifest.nodeTypes) { + Object.assign(nodeTypes, manifest.nodeTypes); + } + } + + return nodeTypes; + } + + /** + * Get all custom edge types from all extensions. + */ + getEdgeTypes(): Record> { + const edgeTypes: Record> = {}; + + for (const manifest of this.extensions.values()) { + if (manifest.edgeTypes) { + Object.assign(edgeTypes, manifest.edgeTypes); + } + } + + return edgeTypes; + } + + /** + * Get components for a specific slot from all extensions. + */ + getSlotComponents(slotName: string): Array<{ + component: ComponentType; + extensionId: string; + }> { + const components: Array<{ + component: ComponentType; + extensionId: string; + }> = []; + + for (const [id, manifest] of this.extensions) { + if (manifest.slots && manifest.slots[slotName]) { + components.push({ + component: manifest.slots[slotName], + extensionId: id, + }); + } + } + + return components; + } + + /** + * Get initial state for all extensions (used when setting up the store). + */ + getInitialStates(): Record> { + const states: Record> = {}; + + for (const [id, manifest] of this.extensions) { + if (manifest.initialState) { + states[id] = manifest.initialState; + } + } + + return states; + } + + /** + * Notify all extensions of user login. + */ + notifyUserLogin(user: any): void { + for (const [id, manifest] of this.extensions) { + if (manifest.onUserLogin) { + const context = this.contexts.get(id); + if (context) { + try { + manifest.onUserLogin(user, context); + } catch (error) { + console.error(`[Extensions] Error in ${manifest.name}.onUserLogin:`, error); + } + } + } + } + } + + /** + * Notify all extensions of user logout. + */ + notifyUserLogout(): void { + for (const [id, manifest] of this.extensions) { + if (manifest.onUserLogout) { + const context = this.contexts.get(id); + if (context) { + try { + manifest.onUserLogout(context); + } catch (error) { + console.error(`[Extensions] Error in ${manifest.name}.onUserLogout:`, error); + } + } + } + } + } + + /** + * Validate an extension manifest. + */ + private validateManifest(manifest: ExtensionManifest): void { + if (!manifest.id || typeof manifest.id !== 'string') { + throw new Error('Extension manifest must have a valid "id" string'); + } + + if (!manifest.name || typeof manifest.name !== 'string') { + throw new Error(`Extension "${manifest.id}" must have a valid "name" string`); + } + + if (!manifest.version || typeof manifest.version !== 'string') { + throw new Error(`Extension "${manifest.id}" must have a valid "version" string`); + } + + // Validate routes + if (manifest.routes) { + for (const route of manifest.routes) { + if (!route.path || !route.path.startsWith('/')) { + throw new Error( + `Extension "${manifest.id}" has invalid route path: "${route.path}". ` + + `Paths must start with "/".` + ); + } + if (!route.component) { + throw new Error( + `Extension "${manifest.id}" route "${route.path}" is missing a component.` + ); + } + } + } + + // Validate panel extensions + if (manifest.panelExtensions) { + for (const ext of manifest.panelExtensions) { + if (ext.position === 'tab' && !ext.tabLabel) { + throw new Error( + `Extension "${manifest.id}" has a tab panel extension without a tabLabel.` + ); + } + } + } + + // Validate nav items + if (manifest.navItems) { + for (const item of manifest.navItems) { + if (!item.id) { + throw new Error(`Extension "${manifest.id}" has a nav item without an id.`); + } + if (!item.path && !item.onClick) { + throw new Error( + `Extension "${manifest.id}" nav item "${item.id}" must have either path or onClick.` + ); + } + } + } + } +} + +// Singleton instance +export const extensionRegistry = new ExtensionRegistry(); + +// Export for use in extensions config +export default extensionRegistry; diff --git a/ui/src/extensions/types.ts b/ui/src/extensions/types.ts new file mode 100644 index 000000000..0d32842a7 --- /dev/null +++ b/ui/src/extensions/types.ts @@ -0,0 +1,324 @@ +/** + * zrok UI Extension System - Type Definitions + * + * This module defines the interfaces and types used by the extension system. + * Extensions implement these interfaces to integrate with the zrok UI. + */ + +import { ComponentType, ReactNode } from 'react'; +import { Node, Edge } from '@xyflow/react'; +import { User } from '../model/user'; + +/** + * Main extension manifest interface. + * Every extension must export a default object implementing this interface. + */ +export interface ExtensionManifest { + /** Unique identifier for the extension (e.g., "acme-billing") */ + id: string; + + /** Human-readable display name */ + name: string; + + /** Semantic version string */ + version: string; + + /** Optional description */ + description?: string; + + /** Route extensions - add new pages to the UI */ + routes?: ExtensionRoute[]; + + /** Navigation items - add buttons/links to the navbar */ + navItems?: ExtensionNavItem[]; + + /** Panel extensions - extend or replace side panels */ + panelExtensions?: PanelExtension[]; + + /** Custom graph node types */ + nodeTypes?: Record>; + + /** Custom graph edge types */ + edgeTypes?: Record>; + + /** Slot-based UI injections */ + slots?: Record>; + + /** Initial state to add to the main store's extension namespace */ + initialState?: Record; + + /** + * Called when the extension is first loaded. + * Use this for initialization, data fetching, subscriptions, etc. + */ + onInit?: (context: ExtensionContext) => void | Promise; + + /** Called when a user logs in */ + onUserLogin?: (user: User, context: ExtensionContext) => void; + + /** Called when a user logs out */ + onUserLogout?: (context: ExtensionContext) => void; + + /** + * Scripts to inject into the element at build time. + * Use for analytics, tracking, or other scripts that need to load early. + */ + headScripts?: ScriptDefinition[]; + + /** + * Scripts to inject before at build time. + * Use for scripts that should load after the page content. + */ + bodyScripts?: ScriptDefinition[]; +} + +/** + * Defines a script to be injected into the HTML document. + * Used for both build-time and runtime script injection. + */ +export interface ScriptDefinition { + /** External script URL (mutually exclusive with content) */ + src?: string; + + /** Inline script content (mutually exclusive with src) */ + content?: string; + + /** Load script asynchronously */ + async?: boolean; + + /** Defer script execution until document is parsed */ + defer?: boolean; + + /** Script type (default: "text/javascript") */ + type?: string; + + /** Script ID for identification and deduplication */ + id?: string; + + /** Additional HTML attributes to add to the script tag */ + attributes?: Record; +} + +/** + * Defines a route (page) added by an extension. + */ +export interface ExtensionRoute { + /** URL path (e.g., "/billing", "/billing/invoices") */ + path: string; + + /** React component to render for this route */ + component: ComponentType; + + /** If true, only matches exact path (default: false) */ + exact?: boolean; + + /** If true, route requires authentication (default: true) */ + requiresAuth?: boolean; +} + +/** + * Props passed to extension route components. + */ +export interface ExtensionRouteProps { + /** Current authenticated user (null if not authenticated) */ + user: User | null; + + /** Extension context for store access */ + context: ExtensionContext; + + /** Logout function */ + logout: () => void; +} + +/** + * Defines a navigation item added by an extension. + */ +export interface ExtensionNavItem { + /** Unique identifier for this nav item */ + id: string; + + /** Display label */ + label: string; + + /** Icon component (optional) */ + icon?: ComponentType<{ fontSize?: 'small' | 'medium' | 'large' }>; + + /** Route path to navigate to (mutually exclusive with onClick) */ + path?: string; + + /** Custom click handler (mutually exclusive with path) */ + onClick?: () => void; + + /** Position in navbar: 'left' or 'right' (default: 'right') */ + position?: 'left' | 'right'; + + /** Tooltip text */ + tooltip?: string; + + /** Sort order within position (lower = earlier, default: 0) */ + order?: number; + + /** + * Visibility condition. Return false to hide the item. + * Called with current user and extension state. + */ + visible?: (user: User | null, extensionState: Record) => boolean; +} + +/** + * Defines an extension to the side panel shown when selecting nodes. + */ +export interface PanelExtension { + /** + * Node types this panel applies to. + * Use ["*"] for all node types, or specific types like ["account", "share"] + */ + nodeTypes: string[]; + + /** + * How to position this extension relative to the base panel: + * - 'before': Render above the base panel + * - 'after': Render below the base panel + * - 'tab': Add as a new tab (requires tabLabel) + * - 'replace': Replace the entire base panel + */ + position: 'before' | 'after' | 'tab' | 'replace'; + + /** Component to render */ + component: ComponentType; + + /** Tab label (required when position is 'tab') */ + tabLabel?: string; + + /** Tab icon (optional, used when position is 'tab') */ + tabIcon?: ComponentType; + + /** Sort order within position (lower = earlier, default: 0) */ + order?: number; +} + +/** + * Props passed to panel extension components. + */ +export interface PanelExtensionProps { + /** The currently selected node */ + node: Node; + + /** Current authenticated user */ + user: User | null; + + /** Extension context */ + context: ExtensionContext; +} + +/** + * Props passed to slot components. + */ +export interface SlotProps { + /** Current user (may be null for unauthenticated slots) */ + user?: User | null; + + /** Currently selected node (for node-related slots) */ + selectedNode?: Node | null; + + /** Extension context */ + context: ExtensionContext; + + /** Additional props passed to the slot */ + [key: string]: unknown; +} + +/** + * Context object provided to extensions for interacting with the zrok UI. + */ +export interface ExtensionContext { + /** The extension's ID */ + extensionId: string; + + /** + * Get the extension's state from the main store. + * Returns undefined if no state has been set. + */ + getState: >() => T | undefined; + + /** + * Update the extension's state in the main store. + * Performs a shallow merge with existing state. + */ + setState: >(state: Partial) => void; + + /** + * Subscribe to changes in the extension's state. + * Returns an unsubscribe function. + */ + subscribe: >( + selector: (state: T) => unknown, + callback: (selectedValue: unknown, previousValue: unknown) => void + ) => () => void; + + /** + * Get the current authenticated user. + */ + getUser: () => User | null; + + /** + * Subscribe to user changes (login/logout). + * Returns an unsubscribe function. + */ + subscribeToUser: (callback: (user: User | null) => void) => () => void; + + /** + * Get the currently selected node in the visualizer. + */ + getSelectedNode: () => Node | null; + + /** + * Subscribe to node selection changes. + * Returns an unsubscribe function. + */ + subscribeToSelectedNode: (callback: (node: Node | null) => void) => () => void; + + /** + * Navigate to a route programmatically. + */ + navigate: (path: string) => void; + + /** + * Show a notification/toast message. + */ + notify: (message: string, severity?: 'info' | 'success' | 'warning' | 'error') => void; +} + +/** + * Well-known slot names where extensions can inject UI. + */ +export const SLOTS = { + // NavBar slots + NAVBAR_LEFT: 'navbar-left', + NAVBAR_RIGHT: 'navbar-right', + NAVBAR_CENTER: 'navbar-center', + + // Account panel slots + ACCOUNT_PANEL_TOP: 'account-panel-top', + ACCOUNT_PANEL_BOTTOM: 'account-panel-bottom', + ACCOUNT_PANEL_ACTIONS: 'account-panel-actions', + + // Environment panel slots + ENVIRONMENT_PANEL_TOP: 'environment-panel-top', + ENVIRONMENT_PANEL_BOTTOM: 'environment-panel-bottom', + + // Share panel slots + SHARE_PANEL_TOP: 'share-panel-top', + SHARE_PANEL_BOTTOM: 'share-panel-bottom', + + // Main console area slots + CONSOLE_TOP: 'console-top', + CONSOLE_BOTTOM: 'console-bottom', + CONSOLE_SIDEBAR: 'console-sidebar', + + // Login page slots + LOGIN_TOP: 'login-top', + LOGIN_BOTTOM: 'login-bottom', +} as const; + +export type SlotName = typeof SLOTS[keyof typeof SLOTS]; diff --git a/ui/src/extensions/useScriptInjector.ts b/ui/src/extensions/useScriptInjector.ts new file mode 100644 index 000000000..cd2b208fb --- /dev/null +++ b/ui/src/extensions/useScriptInjector.ts @@ -0,0 +1,229 @@ +/** + * useScriptInjector Hook + * + * A React hook for imperatively injecting and removing scripts at runtime. + * Use this when you need programmatic control over script injection. + * + * For declarative script injection, use the component instead. + * + * @example + * ```tsx + * function MyComponent() { + * const { injectScript, removeScript, isLoaded } = useScriptInjector(); + * + * useEffect(() => { + * // Load a script when component mounts + * injectScript({ + * src: 'https://example.com/api.js', + * id: 'example-api', + * }).then(() => { + * console.log('API script loaded!'); + * }); + * + * // Cleanup when component unmounts + * return () => { + * removeScript('example-api'); + * }; + * }, []); + * + * return
Loaded: {isLoaded('example-api') ? 'Yes' : 'No'}
; + * } + * ``` + */ + +import { useCallback, useRef } from 'react'; +import { ScriptDefinition } from './types'; + +export interface InjectScriptOptions extends ScriptDefinition { + /** + * Where to inject the script: 'head' or 'body'. + * Default: 'body' + */ + target?: 'head' | 'body'; +} + +export interface UseScriptInjectorReturn { + /** + * Inject a script into the DOM. + * Returns a promise that resolves when the script loads (for external scripts) + * or immediately (for inline scripts). + */ + injectScript: (options: InjectScriptOptions) => Promise; + + /** + * Remove a script by its ID. + * Returns true if the script was found and removed. + */ + removeScript: (id: string) => boolean; + + /** + * Remove a script by its src URL. + * Returns true if the script was found and removed. + */ + removeScriptBySrc: (src: string) => boolean; + + /** + * Check if a script with the given ID has been loaded. + */ + isLoaded: (id: string) => boolean; + + /** + * Check if a script with the given src URL has been loaded. + */ + isLoadedBySrc: (src: string) => boolean; +} + +/** + * Hook for imperatively injecting and managing scripts. + */ +export function useScriptInjector(): UseScriptInjectorReturn { + // Track scripts we've injected for cleanup + const injectedScriptsRef = useRef>(new Set()); + + const injectScript = useCallback(async (options: InjectScriptOptions): Promise => { + const { + src, + content, + async: asyncAttr, + defer, + type, + id, + attributes, + target = 'body', + } = options; + + return new Promise((resolve, reject) => { + // Generate an ID if not provided + const scriptId = id || (src ? `script-${hashString(src)}` : `script-${Date.now()}`); + + // Check if script already exists + if (id) { + const existing = document.getElementById(id); + if (existing) { + console.log(`[useScriptInjector] Script with id "${id}" already exists`); + resolve(); + return; + } + } + + if (src) { + const existing = document.querySelector(`script[src="${src}"]`); + if (existing) { + console.log(`[useScriptInjector] Script with src "${src}" already exists`); + resolve(); + return; + } + } + + // Create the script element + const script = document.createElement('script'); + + script.id = scriptId; + + if (src) { + script.src = src; + } + + if (content) { + script.textContent = content; + } + + if (type) { + script.type = type; + } + + if (asyncAttr) { + script.async = true; + } + + if (defer) { + script.defer = true; + } + + // Add additional attributes + if (attributes) { + for (const [key, value] of Object.entries(attributes)) { + script.setAttribute(key, value); + } + } + + // Set up load/error handlers for external scripts + if (src) { + script.onload = () => { + injectedScriptsRef.current.add(scriptId); + resolve(); + }; + + script.onerror = () => { + const error = new Error(`Failed to load script: ${src}`); + console.error(`[useScriptInjector] ${error.message}`); + reject(error); + }; + } + + // Inject the script + const targetElement = target === 'head' ? document.head : document.body; + targetElement.appendChild(script); + + // For inline scripts, resolve immediately + if (!src) { + injectedScriptsRef.current.add(scriptId); + resolve(); + } + }); + }, []); + + const removeScript = useCallback((id: string): boolean => { + const script = document.getElementById(id); + if (script && script.tagName === 'SCRIPT') { + script.remove(); + injectedScriptsRef.current.delete(id); + return true; + } + return false; + }, []); + + const removeScriptBySrc = useCallback((src: string): boolean => { + const script = document.querySelector(`script[src="${src}"]`); + if (script) { + const id = script.id; + script.remove(); + if (id) { + injectedScriptsRef.current.delete(id); + } + return true; + } + return false; + }, []); + + const isLoaded = useCallback((id: string): boolean => { + return document.getElementById(id) !== null; + }, []); + + const isLoadedBySrc = useCallback((src: string): boolean => { + return document.querySelector(`script[src="${src}"]`) !== null; + }, []); + + return { + injectScript, + removeScript, + removeScriptBySrc, + isLoaded, + isLoadedBySrc, + }; +} + +/** + * Simple string hash for generating script IDs + */ +function hashString(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32-bit integer + } + return Math.abs(hash).toString(36); +} + +export default useScriptInjector; diff --git a/ui/src/model/store.ts b/ui/src/model/store.ts index 140410ddd..bec6fa430 100644 --- a/ui/src/model/store.ts +++ b/ui/src/model/store.ts @@ -5,6 +5,12 @@ import {Edge, Node, Viewport} from "@xyflow/react"; import {User} from "./user.ts"; import {MRT_PaginationState, MRT_SortingState} from "material-react-table"; +/** + * Extension state is stored as a record mapping extension IDs to their state. + * Each extension manages its own namespace within this record. + */ +type ExtensionStates = Record>; + type StoreState = { user: User | null; limited: boolean; @@ -18,6 +24,8 @@ type StoreState = { viewport: Viewport; pagination: MRT_PaginationState; sorting: MRT_SortingState; + /** Extension state namespace - each extension gets its own key */ + extensions: ExtensionStates; }; type StoreAction = { @@ -33,6 +41,16 @@ type StoreAction = { updateViewport: (viewport: StoreState['viewport']) => void, updatePagination: (pagination: StoreState['pagination']) => void, updateSorting: (sorting: StoreState['sorting']) => void, + /** + * Set state for a specific extension. + * Performs a shallow merge with existing extension state. + */ + setExtensionState: (extensionId: string, state: Record) => void, + /** + * Initialize extension states from registry. + * Called during app startup. + */ + initializeExtensionStates: (initialStates: ExtensionStates) => void, }; const useApiConsoleStore = create((set) => ({ @@ -48,6 +66,7 @@ const useApiConsoleStore = create((set) => ({ viewport: {x: 0, y: 0, zoom: 1}, pagination: {pageIndex: 0, pageSize: 15}, sorting: [{id: "data.label", desc: false}] as MRT_SortingState, + extensions: {}, updateUser: (user) => set({user: user}), updateLimited: (limited) => set({limited: limited}), updateGraph: (vov) => set({graph: vov}), @@ -59,7 +78,22 @@ const useApiConsoleStore = create((set) => ({ updateFocusNodeId: (focusNodeId) => set({focusNodeId: focusNodeId}), updateViewport: (viewport) => set({viewport: viewport}), updatePagination: (pagination) => set({pagination: pagination}), - updateSorting: (sorting) => set({sorting: sorting}) + updateSorting: (sorting) => set({sorting: sorting}), + setExtensionState: (extensionId, state) => set((prev) => ({ + extensions: { + ...prev.extensions, + [extensionId]: { + ...prev.extensions[extensionId], + ...state + } + } + })), + initializeExtensionStates: (initialStates) => set((prev) => ({ + extensions: { + ...initialStates, + ...prev.extensions + } + })) })); export default useApiConsoleStore; diff --git a/ui/vite-plugin-extension-scripts.ts b/ui/vite-plugin-extension-scripts.ts new file mode 100644 index 000000000..51e3adcb8 --- /dev/null +++ b/ui/vite-plugin-extension-scripts.ts @@ -0,0 +1,233 @@ +/** + * Vite Plugin: Extension Scripts + * + * Injects extension scripts into the HTML document at build time. + * This plugin reads script definitions and adds them to index.html + * during the build process. + * + * Usage in vite.config.ts: + * + * ```typescript + * import { extensionScriptsPlugin } from './vite-plugin-extension-scripts'; + * import myExtension from './path/to/extension'; + * + * export default defineConfig({ + * plugins: [ + * react(), + * extensionScriptsPlugin({ + * extensions: [myExtension], + * // Or provide scripts directly: + * // headScripts: [...], + * // bodyScripts: [...], + * }), + * ], + * }); + * ``` + */ + +import type { Plugin, IndexHtmlTransformContext } from 'vite'; + +/** + * Script definition matching the type in extensions/types.ts + */ +export interface ScriptDefinition { + src?: string; + content?: string; + async?: boolean; + defer?: boolean; + type?: string; + id?: string; + attributes?: Record; +} + +/** + * Extension manifest (simplified for plugin use) + */ +export interface ExtensionManifestWithScripts { + id: string; + headScripts?: ScriptDefinition[]; + bodyScripts?: ScriptDefinition[]; +} + +/** + * Plugin options + */ +export interface ExtensionScriptsPluginOptions { + /** + * Array of extension manifests to extract scripts from. + * The plugin will collect headScripts and bodyScripts from each. + */ + extensions?: ExtensionManifestWithScripts[]; + + /** + * Additional scripts to inject into . + * These are added after extension headScripts. + */ + headScripts?: ScriptDefinition[]; + + /** + * Additional scripts to inject before . + * These are added after extension bodyScripts. + */ + bodyScripts?: ScriptDefinition[]; + + /** + * Enable verbose logging during build. + */ + verbose?: boolean; +} + +/** + * Convert a ScriptDefinition to an HTML script tag string. + */ +function scriptToHtml(script: ScriptDefinition): string { + const attrs: string[] = []; + + if (script.id) { + attrs.push(`id="${escapeAttr(script.id)}"`); + } + + if (script.src) { + attrs.push(`src="${escapeAttr(script.src)}"`); + } + + if (script.type && script.type !== 'text/javascript') { + attrs.push(`type="${escapeAttr(script.type)}"`); + } + + if (script.async) { + attrs.push('async'); + } + + if (script.defer) { + attrs.push('defer'); + } + + // Add any additional attributes + if (script.attributes) { + for (const [key, value] of Object.entries(script.attributes)) { + attrs.push(`${escapeAttr(key)}="${escapeAttr(value)}"`); + } + } + + const attrString = attrs.length > 0 ? ' ' + attrs.join(' ') : ''; + + if (script.content) { + return `\n${script.content}\n`; + } + + return ``; +} + +/** + * Escape HTML attribute value + */ +function escapeAttr(value: string): string { + return value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); +} + +/** + * Collect scripts from extensions and additional options + */ +function collectScripts( + options: ExtensionScriptsPluginOptions, + location: 'head' | 'body' +): ScriptDefinition[] { + const scripts: ScriptDefinition[] = []; + + // Collect from extensions + if (options.extensions) { + for (const ext of options.extensions) { + const extScripts = location === 'head' ? ext.headScripts : ext.bodyScripts; + if (extScripts) { + scripts.push(...extScripts); + } + } + } + + // Add additional scripts + const additionalScripts = location === 'head' ? options.headScripts : options.bodyScripts; + if (additionalScripts) { + scripts.push(...additionalScripts); + } + + return scripts; +} + +/** + * Vite plugin for injecting extension scripts into HTML. + */ +export function extensionScriptsPlugin( + options: ExtensionScriptsPluginOptions = {} +): Plugin { + const { verbose = false } = options; + + return { + name: 'vite-plugin-extension-scripts', + + transformIndexHtml: { + order: 'post', + handler(html: string, ctx: IndexHtmlTransformContext) { + const headScripts = collectScripts(options, 'head'); + const bodyScripts = collectScripts(options, 'body'); + + if (verbose) { + console.log(`[extension-scripts] Injecting ${headScripts.length} head scripts`); + console.log(`[extension-scripts] Injecting ${bodyScripts.length} body scripts`); + } + + let result = html; + + // Inject head scripts before + if (headScripts.length > 0) { + const headHtml = headScripts + .map(scriptToHtml) + .map(s => ' ' + s) // Indent for readability + .join('\n'); + + const headComment = ''; + if (result.includes(headComment)) { + // Replace placeholder comment if present + result = result.replace(headComment, headHtml); + } else { + // Otherwise inject before + result = result.replace('', `${headHtml}\n `); + } + + if (verbose) { + console.log('[extension-scripts] Head scripts injected'); + } + } + + // Inject body scripts before + if (bodyScripts.length > 0) { + const bodyHtml = bodyScripts + .map(scriptToHtml) + .map(s => ' ' + s) // Indent for readability + .join('\n'); + + const bodyComment = ''; + if (result.includes(bodyComment)) { + // Replace placeholder comment if present + result = result.replace(bodyComment, bodyHtml); + } else { + // Otherwise inject before + result = result.replace('', `${bodyHtml}\n `); + } + + if (verbose) { + console.log('[extension-scripts] Body scripts injected'); + } + } + + return result; + }, + }, + }; +} + +export default extensionScriptsPlugin; diff --git a/ui/vite.config.ts b/ui/vite.config.ts index b19ebe751..2cc83c702 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,9 +1,35 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import { extensionScriptsPlugin } from './vite-plugin-extension-scripts' + +// ============================================================== +// Extension Script Injection Configuration +// ============================================================== +// +// To inject scripts from extensions at build time, import your +// extension manifests and pass them to extensionScriptsPlugin: +// +// import billingExtension from '@acme/zrok-billing-extension'; +// import demoExtension from './examples/demo-extension/src'; +// +// Then add to plugins array: +// extensionScriptsPlugin({ +// extensions: [billingExtension, demoExtension], +// verbose: true, // Enable to see injection logs during build +// }), +// +// ============================================================== // https://vite.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [ + react(), + // Uncomment and configure to enable build-time script injection: + // extensionScriptsPlugin({ + // extensions: [], + // verbose: true, + // }), + ], build: { rollupOptions: { output: {