How to build React + TypeScript pages, components, hooks, and dashboards in the Powernode platform.
- What this guide covers
- Prerequisites
- Stack and architecture
- Conventions
- Feature-based layout
- Routing and pages
- Containers
- Component patterns
- Forms
- State management
- API integration
- WebSockets and realtime
- Dashboards and charts
- Admin panels and bulk operations
- Reliability UI components
- Plugin management surface
- Accessibility, theming, and i18n
- Related guides
- Materials previously at
This is the working frontend engineer's reference for the Powernode platform. It covers project layout, the React + TypeScript stack, the canonical patterns for routing, state, forms, API calls, real-time subscriptions, dashboards, and admin surfaces — and the conventions you must follow to stay shippable.
If you're building a new feature, start at Feature-based layout. If you're modifying an existing page, the Conventions table tells you the rules.
- Node 20+, a running platform (
docs/getting-started/01-quickstart.md) - Familiarity with React 19, TypeScript 5, Tailwind CSS 4
- Read the root
CLAUDE.md"Frontend Patterns" table at least once
| Layer | Tech |
|---|---|
| Framework | React 19 |
| Bundler | Vite 7 |
| Language | TypeScript 5 |
| Styling | Tailwind CSS 4 (theme classes only) |
| Global state | Redux Toolkit |
| Server state | TanStack Query (React Query) |
| Routing | React Router |
| HTTP | axios (shared client with interceptors) |
| Realtime | ActionCable singleton + typed hooks |
| Forms | useForm hook (custom) |
| Charts | Chart.js + react-chartjs-2 |
| Tests | Jest + Testing Library, Playwright for E2E |
flowchart TB
Page[Page Component]
PC[PageContainer]
Feature[Feature Component]
Hook[useFeatureData hook]
Query[React Query]
Service[Feature API Service]
HTTP[Shared apiClient]
Server[Rails API server/]
Cable[ActionCable]
WS[useWebSocket hook]
Page --> PC
PC --> Feature
Feature --> Hook
Hook --> Query
Query --> Service
Service --> HTTP
HTTP --> Server
Feature --> WS
WS --> Cable
Cable --> Server
| Pattern | Rule |
|---|---|
| Colors | Theme classes only: bg-theme-*, text-theme-* — never raw Tailwind colors |
| Navigation | Flat structure — no submenus |
| Actions | ALL page actions in PageContainer header — none in page content |
| State | Global notifications only — no local success/error toasts |
| Imports | Path aliases (@/shared/, @/features/) for cross-feature; relative within a feature |
| Logging | import { logger } from '@/shared/utils/logger' — never console.log in production |
| Types | No any. No as casts. Proper TypeScript types required. |
| Access control | Permissions only — currentUser?.permissions?.includes('users.manage'). Never roles. |
| Tabs | Per-segment URL paths (/compute/nodes) — never ?tab= query params, never internal state |
| Lists | Infinite scroll (useInfiniteResourceList + sentinel) — never Previous/Next pagination |
| Submitting changes | Run cd frontend && npx tsc --noEmit before commit |
The valid theme class roots are:
bg-theme-{primary,surface,background,background-secondary,success,warning,danger,info}text-theme-{primary,secondary,muted,inverse,success,warning,danger,info}border-theme-{primary,secondary,muted,success,warning,danger}
These do NOT exist (caught a 91-file white-on-white bug in 2026-05-04):
bg-theme-secondary,bg-theme-tertiarybg-theme-*-bg(the-bgsuffix is wrong)
When in doubt, grep the codebase for working examples before inventing a class name.
frontend/src/
├── app/ # App-level config, router, store
├── features/ # Self-contained feature domains
│ ├── account/
│ ├── admin/
│ ├── ai/
│ ├── billing/
│ ├── chat/
│ ├── devops/
│ ├── knowledge/
│ ├── settings/
│ └── ...
├── shared/ # Cross-feature primitives
│ ├── components/ # PageContainer, TabContainer, modals, buttons
│ ├── hooks/ # useForm, useWebSocket, useInfiniteResourceList
│ ├── services/ # apiClient, auth helpers
│ ├── utils/ # logger, formatters
│ └── types/
└── theme/ # Theme provider, design tokens
Each feature is self-contained: its own components, hooks, services, types, and tests. Cross-feature dependencies go through @/shared/ only.
features/widgets/
├── components/ # Widget-specific React components
├── hooks/ # useWidget, useWidgetMutations
├── services/ # widgetsApiService.ts
├── types/ # Widget, WidgetCreatePayload
├── pages/ # WidgetsListPage, WidgetDetailPage
├── __tests__/
└── index.ts # Public surface for the feature
The index.ts file is the feature's public API. Don't deep-import from another feature (@/features/widgets/components/Internal); always go through the feature's index.ts.
Routes mirror the URL hierarchy and use per-segment paths for tabs. Use React Router's nested routes with a wildcard parent and <Routes> + <Link> + useLocation for tab sections.
// app/router.tsx (excerpt)
<Route path="/compute/*" element={<ComputePage />} />
// features/compute/pages/ComputePage.tsx
import { Routes, Route, Link, useLocation } from 'react-router-dom';
export const ComputePage = () => {
const { pathname } = useLocation();
return (
<PageContainer
title="Compute"
actions={[<NewNodeButton key="new" />]}
>
<TabContainer
tabs={[
{ label: 'Nodes', href: '/compute/nodes', active: pathname.startsWith('/compute/nodes') },
{ label: 'Clusters', href: '/compute/clusters', active: pathname.startsWith('/compute/clusters') },
{ label: 'Templates', href: '/compute/templates', active: pathname.startsWith('/compute/templates') },
]}
>
<Routes>
<Route path="nodes/*" element={<NodesSection />} />
<Route path="clusters/*" element={<ClustersSection />} />
<Route path="templates/*" element={<TemplatesSection />} />
</Routes>
</TabContainer>
</PageContainer>
);
};Canonical exemplar: AdminSettingsPage.tsx.
flowchart TB
PC[PageContainer Top-level - one per page]
Header[Page Header: title, breadcrumbs, actions]
Desc[Page Description optional]
Content[Page Content]
TC[TabContainer when tabs needed]
Nav[Tab Navigation]
Panel[Tab Panels]
PC --> Header
PC --> Desc
PC --> Content
Content --> TC
TC --> Nav
TC --> Panel
PageContainer provides page-level structure: title, breadcrumbs, and consolidated actions. Every routable page uses it.
<PageContainer
title="Widgets"
description="Manage organization widgets"
breadcrumbs={[{ label: 'Home', href: '/' }, { label: 'Widgets' }]}
actions={[
<Button key="new" onClick={openCreate}>New Widget</Button>,
<Button key="export" variant="secondary" onClick={exportCSV}>Export</Button>,
]}
>
{/* Page content */}
</PageContainer>Hard rule: all page-level actions live in PageContainer.actions. Never put action buttons in page content (creates visual clutter and inconsistent placement).
TabContainer wraps tabbed sections. Tabs are URL-driven (per-segment paths) — never component-state-driven.
<TabContainer
tabs={[
{ label: 'Overview', href: '/widgets/overview' },
{ label: 'History', href: '/widgets/history' },
]}
>
<Routes>
<Route path="overview" element={<Overview />} />
<Route path="history" element={<History />} />
</Routes>
</TabContainer>Components compose props, slots, and render-prop children. No class components, no inheritance hierarchies.
- Components in PascalCase:
WidgetCard,WidgetDetailPanel - Hooks in camelCase with
useprefix:useWidget,useWidgetMutations - Files match their default export:
WidgetCard.tsxexportsWidgetCard
const { currentUser } = useCurrentUser();
const canEdit = currentUser?.permissions?.includes('widgets.update');
return (
<Card>
<CardBody>{widget.name}</CardBody>
{canEdit && <Button onClick={openEdit}>Edit</Button>}
</Card>
);Wrap repeated patterns in a <PermissionGate permission="widgets.update">...</PermissionGate> helper.
useForm provides values, errors, touched state, validation, submission handling, accessibility attributes, and integration with global notifications.
import { useForm } from '@/shared/hooks/useForm';
interface WidgetForm {
name: string;
description?: string;
status: 'active' | 'archived';
}
const CreateWidgetForm = () => {
const form = useForm<WidgetForm>({
initialValues: { name: '', description: '', status: 'active' },
validate: (values) => {
const errors: Partial<Record<keyof WidgetForm, string>> = {};
if (!values.name.trim()) errors.name = 'Name is required';
if (values.name.length > 200) errors.name = 'Name is too long';
return errors;
},
onSubmit: async (values) => {
await widgetsApiService.create(values);
},
successMessage: 'Widget created',
});
return (
<form onSubmit={form.handleSubmit}>
<TextInput {...form.fieldProps('name')} label="Name" required />
<TextArea {...form.fieldProps('description')} label="Description" />
<Select {...form.fieldProps('status')} label="Status"
options={[{ value: 'active', label: 'Active' }, { value: 'archived', label: 'Archived' }]} />
<Button type="submit" loading={form.isSubmitting} disabled={!form.isValid}>
Create
</Button>
</form>
);
};- Use
useFormfor every multi-field form. Don't roll your own state. - Use validation per-field via the
validatecallback — neveronChangevalidators on individual inputs. - Errors are surfaced inline AND via global notification on submit failure.
- Success messages are surfaced via global notification — don't render local success banners.
- ARIA attributes (
aria-invalid,aria-describedby) flow fromfieldPropsautomatically.
The shared form primitives (TextInput, TextArea, Select, Checkbox, RadioGroup, FileInput, DatePicker) all accept the props returned by form.fieldProps(name). Don't pass raw value/onChange separately — that breaks accessibility wiring.
Powernode uses a dual state model:
| State type | Tool | Examples |
|---|---|---|
| Global app state | Redux Toolkit | Auth (current user, token), UI prefs (theme, sidebar), feature flags |
| Server state | React Query (TanStack) | API data, mutations, optimistic updates, caching |
flowchart TB
UI[UI Component]
RTK[Redux Toolkit Slice]
RQ[React Query]
Service[Feature API Service]
Server[Rails API]
UI -->|dispatch| RTK
UI -->|useQuery/useMutation| RQ
RTK -.persisted.-> LocalStorage
RQ --> Service
Service --> Server
// features/auth/slice.ts
const authSlice = createSlice({
name: 'auth',
initialState: { user: null, accessToken: null, status: 'idle' },
reducers: {
setCredentials: (state, { payload }: PayloadAction<Credentials>) => {
state.user = payload.user;
state.accessToken = payload.accessToken;
},
logout: (state) => {
state.user = null;
state.accessToken = null;
},
},
});// features/widgets/hooks/useWidget.ts
export const useWidget = (id: string) =>
useQuery({
queryKey: ['widget', id],
queryFn: () => widgetsApiService.get(id),
staleTime: 60_000,
});
export const useUpdateWidget = (id: string) => {
const qc = useQueryClient();
return useMutation({
mutationFn: (patch: Partial<Widget>) => widgetsApiService.update(id, patch),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['widget', id] });
qc.invalidateQueries({ queryKey: ['widgets'] });
},
});
};Always select narrowly — never select an entire slice if you only need one field:
// GOOD
const userName = useSelector((s: RootState) => s.auth.user?.name);
// BAD — re-renders on every auth change
const auth = useSelector((s: RootState) => s.auth);For derived state, use createSelector from Reselect to memoize.
// shared/services/apiClient.ts
import axios from 'axios';
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE || '/api/v1',
withCredentials: false,
});
apiClient.interceptors.request.use((config) => {
const token = store.getState().auth.accessToken;
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
apiClient.interceptors.response.use(
(r) => r,
async (err) => {
if (err.response?.status === 401) await store.dispatch(refreshSession());
return Promise.reject(err);
},
);// features/widgets/services/widgetsApiService.ts
import { apiClient } from '@/shared/services/apiClient';
import type { Widget, WidgetCreatePayload, WidgetUpdatePayload } from '../types';
import type { ApiResponse, ApiPaginated } from '@/shared/types/api';
export const widgetsApiService = {
list: async (params?: { page?: number; per_page?: number }) => {
const { data } = await apiClient.get<ApiPaginated<Widget>>('/widgets', { params });
return data;
},
get: async (id: string) => {
const { data } = await apiClient.get<ApiResponse<Widget>>(`/widgets/${id}`);
return data.data;
},
create: async (payload: WidgetCreatePayload) => {
const { data } = await apiClient.post<ApiResponse<Widget>>('/widgets', { widget: payload });
return data.data;
},
update: async (id: string, payload: WidgetUpdatePayload) => {
const { data } = await apiClient.patch<ApiResponse<Widget>>(`/widgets/${id}`, { widget: payload });
return data.data;
},
destroy: (id: string) => apiClient.delete(`/widgets/${id}`),
};- The backend envelope is
{ success: boolean, data?: any, error?: string, details?: any, meta?: { pagination } }— see backend guide. - Services extract
.data(the inner data) before returning to hooks. - Errors from axios interceptors surface to React Query, which fires
onError— handle them through React Query's error boundary or auseMutationonErrorcallback. - Global notifications fire from the API client's response interceptor for unexpected errors. Per-field validation errors get returned to the form via
useForm.
const { items, fetchMore, isLoading, sentinelRef } = useInfiniteResourceList({
queryKey: ['widgets'],
fetchPage: (page) => widgetsApiService.list({ page, per_page: 30 }),
});
return (
<List>
{items.map((w) => <WidgetCard key={w.id} widget={w} />)}
<div ref={sentinelRef} />
{isLoading && <Spinner />}
</List>
);The frontend has a singleton WebSocketManager that wraps ActionCable. Domain hooks (useChatChannel, useFleetChannel, etc.) consume it with typed payloads.
sequenceDiagram
participant C as Component
participant H as useWidgetChannel
participant M as WebSocketManager
participant Cable as ActionCable
C->>H: useWidgetChannel(accountId, onUpdate)
H->>M: subscribe('WidgetChannel', { account_id })
M->>Cable: open WS if not connected, with JWT
Cable-->>M: confirm_subscription
Cable-->>M: message {event, widget}
M-->>H: forward typed payload
H-->>C: onUpdate(widget)
Note over C,H: on unmount
C->>H: cleanup
H->>M: unsubscribe
// features/widgets/hooks/useWidgetChannel.ts
export const useWidgetChannel = (
accountId: string,
onMessage: (msg: WidgetChannelMessage) => void,
) => {
useEffect(() => {
const sub = wsManager.subscribe<WidgetChannelMessage>('WidgetChannel', { account_id: accountId }, onMessage);
return () => sub.unsubscribe();
}, [accountId, onMessage]);
};- Singleton — opening N WebSockets per page is forbidden.
- Auto-reconnect with exponential backoff (max 30s).
- On 401, the manager calls
refreshSession()and re-opens. - Subscriptions are reference-counted — closing the last subscriber unsubs the channel; opening on an already-subscribed channel reuses.
useEffect runs twice in StrictMode dev mode. Always pair subscribe with unsubscribe in the cleanup function (the example above does this correctly). The manager's reference counting absorbs the double-subscribe so no duplicate WS frames hit the wire.
Wrap Chart.js in a typed React component so the rest of the codebase never imports chart.js directly:
// shared/components/charts/LineChart.tsx
import { Line } from 'react-chartjs-2';
import { Chart, CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend } from 'chart.js';
Chart.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend);
export const LineChart = ({ data, options }: LineChartProps) => (
<div className="bg-theme-surface p-4 rounded-lg">
<Line data={data} options={{ responsive: true, maintainAspectRatio: false, ...options }} />
</div>
);const RevenueDashboard = () => {
const { data, isLoading } = useRevenueMetrics({ period: '30d' });
if (isLoading) return <DashboardSkeleton />;
return (
<Grid cols={3} gap="md">
<KPICard title="MRR" value={formatMoney(data.mrr)} delta={data.mrr_delta} />
<KPICard title="ARR" value={formatMoney(data.arr)} delta={data.arr_delta} />
<KPICard title="Churn" value={`${data.churn_rate}%`} delta={-data.churn_delta} />
<Card className="col-span-3">
<CardHeader>Revenue Trend</CardHeader>
<LineChart data={data.trend_chart} options={{ scales: { y: { beginAtZero: true } } }} />
</Card>
</Grid>
);
};Subscribe to the relevant channel (useMetricsChannel) and invalidate the React Query cache on inbound message — the dashboard re-renders automatically.
- Server-side aggregate; never ship 10,000 raw points to the client.
- Use
react-windowfor long lists. - Memoize
chartDatawithuseMemokeyed on the metric snapshot.
Admin surfaces are permission-gated (admin.*), use complex tables with filters and bulk actions, and route through the same PageContainer pattern as the rest of the app.
Bulk operations affecting more than 5 items require an explicit confirmation modal stating the exact count. Show the first 3 and last 1 items for verification. Never silently auto-approve training decisions, permission grants, or financial operations.
const handleBulkArchive = async () => {
const confirmed = await confirm({
title: `Archive ${selectedIds.length} widgets?`,
description: `This will archive: ${samples.join(', ')}, ..., ${last}.`,
confirmLabel: 'Archive all',
danger: true,
});
if (!confirmed) return;
await bulkArchive.mutateAsync(selectedIds);
};const { filters, setFilter, clearFilters } = useFilterState({
status: 'active',
created_after: null,
owner_id: null,
});
const { data } = useWidgets({ ...filters });Filters live in URL query params so reloads preserve state and the filter URL is shareable.
The reliability surface exposes retry configuration, checkpoint history, circuit-breaker state, and recovery operations to operators.
| Component | Purpose |
|---|---|
RetryConfigurationPanel |
Configure exponential / linear / fixed / custom retry strategies with visual schedule preview, jitter toggle, retryable error types, and workflow-default override |
CheckpointHistoryViewer |
Browse workflow execution checkpoints with diff view and replay-from-here action |
CircuitBreakerDashboard |
Live status per breaker (closed/open/half-open) with manual reset, with metrics: failure rate, time-in-state, last error |
RecoveryActionPanel |
Trigger replay, skip, abort recovery for failed workflow runs |
WorkflowExecutionTimeline |
Per-node timeline with retry attempts visualized, latency bars, error overlay |
Retry configuration is exposed at two levels:
- Workflow default — set on the workflow itself, inherited by all nodes
- Node override — per-node retry config that overrides the workflow default
The panel highlights when a node is using the workflow default vs. an override, and a "Reset to workflow default" button clears the override.
The reliability surfaces consume useWorkflowExecutionChannel for live updates as nodes retry, recover, or trip breakers. Backend broadcasts state transitions; the UI reflects them within ~100ms.
The platform supports a Universal Plugin System for AI workflows, providers, and other extension points. The plugin management UI integrates seamlessly into the existing admin surface.
flowchart TB
PMP[PluginsManagementPage]
Browse[PluginMarketplaceBrowser]
Installed[InstalledPluginsList]
Detail[PluginDetailPanel]
Config[PluginConfigEditor]
Install[InstallPluginWizard]
PMP --> Browse
PMP --> Installed
Browse --> Detail
Installed --> Detail
Detail --> Config
Browse --> Install
A single "Plugins" entry in the admin nav exposes:
- Marketplace browser (search, filter by category, sort by popularity)
- Installed plugins list (with status: active, disabled, error, update-available)
- Plugin detail (description, version history, permissions requested, dependencies)
- Configuration editor (per-plugin settings, gated by
plugins.configurepermission)
- Discover and install — operator browses marketplace, reviews plugin permissions and dependencies, confirms install, plugin loads on next worker restart (or hot-reload if supported).
- Configure — operator opens plugin detail, edits config (JSON schema-driven form), saves, plugin re-initializes.
- Disable / uninstall — soft-disable via Flipper flag (no restart); hard-uninstall removes manifest and migrates data (only on confirm).
- Upgrade — marketplace surfaces available updates; operator reviews changelog and breaking-change notes before applying.
| Permission | Scope |
|---|---|
plugins.browse |
View marketplace |
plugins.install |
Install new plugins |
plugins.configure |
Modify plugin configuration |
plugins.toggle |
Enable/disable installed plugins |
plugins.uninstall |
Remove plugins |
- Unified experience — plugin management lives alongside AI providers and workflows in the admin nav, not as a separate "marketplace" silo.
- Permission-gated — all actions check explicit permissions; no role-based shortcuts.
- Theme-aware — full dark/light mode via theme classes.
- Mobile-first — marketplace browser is responsive; install wizard works on tablet.
All components MUST meet WCAG AA. The shared component library bakes in ARIA roles, keyboard navigation, focus management, and screen-reader labels. See docs/guides/accessibility.md for the full compliance standards.
Two themes ship (light, dark). Theme classes are the only allowed color references. Custom theme overrides are configured at the Account level — frontend reads the active theme from the auth context and applies the corresponding CSS variable set.
The platform is timezone-agnostic. The server stores all timestamps in UTC; the frontend formats them in the user's local timezone via the formatLocal() helper from @/shared/utils/datetime. Never reference timezones explicitly in code or copy.
- Backend — Rails API patterns this frontend consumes
- Testing — Jest, RTL, Storybook
- E2E Testing — Playwright
- Accessibility — WCAG AA standards
docs/concepts/chat-and-realtime.md— channel inventorydocs/reference/theme-system.md— theme tokensdocs/reference/plugin-system.md— Universal Plugin System reference
This guide consolidates content from these legacy paths (preserved in git history for one release cycle):
docs/frontend/REACT_ARCHITECT_SPECIALIST.mddocs/frontend/UI_COMPONENT_DEVELOPER_SPECIALIST.mddocs/frontend/DASHBOARD_SPECIALIST.mddocs/frontend/ADMIN_PANEL_DEVELOPER_SPECIALIST.mddocs/frontend/CONTAINER_PATTERNS.mddocs/frontend/FORM_PATTERNS.mddocs/frontend/STATE_MANAGEMENT_GUIDE.mddocs/frontend/WEBSOCKET_INTEGRATION.mddocs/frontend/API_INTEGRATION_PATTERNS.mddocs/frontend/FEATURE_DEVELOPMENT_GUIDE.mddocs/frontend/RELIABILITY_UI_COMPONENTS.mddocs/frontend/PLUGIN_MANAGEMENT_FRONTEND_DESIGN.md
Last verified: 2026-05-17