Frontend Architecture
This document provides a comprehensive deep-dive into Jet Admin's frontend architecture, explaining the design decisions, folder structure, key components, and how data flows through the application.
📋 Table of Contents
- Technology Stack
- Project Structure
- Application Architecture
- Routing System
- State Management
- API Layer
- Component Architecture
- Shared Packages
- Real-Time Communication
- Authentication Flow
- Styling System
- Build & Deployment
Technology Stack
Jet Admin's frontend is built on a modern, production-ready technology stack:
| Technology | Version | Purpose |
|---|---|---|
| Framework | React 18 | UI library |
| Build Tool | Vite 5.x | Fast build & HMR |
| Language | JavaScript/JSX | Application code |
| Routing | React Router v6 | Client-side routing |
| State | React Query + Context | Server & client state |
| UI Library | Material-UI (MUI) | Component library |
| Styling | TailwindCSS + Emotion | CSS framework |
| Charts | Chart.js + react-chartjs-2 | Data visualization |
| Workflow Editor | React Flow | Node-based editor |
| Forms | JSON Forms | Dynamic form rendering |
| Code Editor | Monaco Editor | Code input |
| HTTP Client | Axios | API requests |
| Real-time | Socket.IO Client | WebSocket communication |
| Auth | Firebase SDK | Authentication |
Project Structure
The frontend follows a feature-based, layered architecture:
apps/frontend/
├── public/ # Static assets
│ ├── config.js # Runtime configuration
│ ├── index.html # HTML template
│ └── favicon.ico
│
├── src/
│ ├── data/ # Data layer
│ │ ├── api/ # API clients
│ │ │ ├── apiClient.js # Axios instance
│ │ │ ├── auth.api.js # Auth endpoints
│ │ │ ├── tenant.api.js # Tenant endpoints
│ │ │ ├── datasource.api.js
│ │ │ ├── workflow.api.js
│ │ │ ├── dashboard.api.js
│ │ │ └── widget.api.js
│ │ │
│ │ ├── models/ # Data models
│ │ │ ├── tenant.model.js
│ │ │ ├── datasource.model.js
│ │ │ ├── workflow.model.js
│ │ │ └── widget.model.js
│ │ │
│ │ └── sockets/ # Socket.IO clients
│ │ ├── socketClient.js # Socket connection
│ │ ├── workflow.socket.js
│ │ └── widget.socket.js
│ │
│ ├── logic/ # Business logic layer
│ │ ├── contexts/ # React contexts
│ │ │ ├── AuthContext.jsx # Authentication state
│ │ │ ├── TenantContext.jsx # Tenant state
│ │ │ ├── ThemeContext.jsx # Theme state
│ │ │ └── SocketContext.jsx # Socket state
│ │ │
│ │ ├── hooks/ # Custom hooks
│ │ │ ├── useAuth.js # Auth hook
│ │ │ ├── useTenant.js # Tenant hook
│ │ │ ├── useWorkflow.js # Workflow hook
│ │ │ ├── useWidget.js # Widget hook
│ │ │ └── useSocket.js # Socket hook
│ │ │
│ │ └── utils/ # Utility functions
│ │ ├── formatters.js # Data formatters
│ │ ├── validators.js # Input validators
│ │ └── constants.js # App constants
│ │
│ ├── presentation/ # UI layer
│ │ ├── components/ # Reusable components
│ │ │ ├── common/ # Generic components
│ │ │ │ ├── Button.jsx
│ │ │ │ ├── Modal.jsx
│ │ │ │ ├── Table.jsx
│ │ │ │ ├── Loading.jsx
│ │ │ │ └── ErrorBoundary.jsx
│ │ │ │
│ │ │ ├── layout/ # Layout components
│ │ │ │ ├── AppShell.jsx # Main app container
│ │ │ │ ├── Sidebar.jsx # Navigation sidebar
│ │ │ │ ├── Header.jsx # Top header
│ │ │ │ ├── Breadcrumbs.jsx # Navigation breadcrumbs
│ │ │ │ └── Footer.jsx
│ │ │ │
│ │ │ ├── auth/ # Authentication components
│ │ │ │ ├── LoginForm.jsx
│ │ │ │ ├── ProtectedRoute.jsx
│ │ │ │ └── AuthCallback.jsx
│ │ │ │
│ │ │ ├── datasource/ # Datasource components
│ │ │ │ ├── DatasourceList.jsx
│ │ │ │ ├── DatasourceForm.jsx
│ │ │ │ ├── DatasourceCatalog.jsx
│ │ │ │ └── TestConnection.jsx
│ │ │ │
│ │ │ ├── workflow/ # Workflow components
│ │ │ │ ├── WorkflowEditor.jsx # Main editor
│ │ │ │ ├── WorkflowCanvas.jsx # React Flow canvas
│ │ │ │ ├── WorkflowNode.jsx # Node wrapper
│ │ │ │ ├── WorkflowEdge.jsx # Custom edge
│ │ │ │ ├── NodeInspector.jsx # Node config panel
│ │ │ │ ├── WorkflowToolbar.jsx # Editor toolbar
│ │ │ │ └── WorkflowRunner.jsx # Execution controls
│ │ │ │
│ │ │ ├── widget/ # Widget components
│ │ │ │ ├── WidgetCatalog.jsx # Widget selector
│ │ │ │ ├── WidgetConfigurator.jsx # Config panel
│ │ │ │ ├── ChartWidget.jsx # Chart renderer
│ │ │ │ ├── TableWidget.jsx # Table renderer
│ │ │ │ └── TextWidget.jsx # Text renderer
│ │ │ │
│ │ │ ├── dashboard/ # Dashboard components
│ │ │ │ ├── DashboardBuilder.jsx # Grid layout editor
│ │ │ │ ├── DashboardList.jsx
│ │ │ │ ├── DashboardView.jsx
│ │ │ │ └── WidgetGrid.jsx # Draggable grid
│ │ │ │
│ │ │ ├── query/ # Query builder components
│ │ │ │ ├── QueryEditor.jsx # SQL/code editor
│ │ │ │ ├── QueryTester.jsx # Test panel
│ │ │ │ ├── QueryParams.jsx # Parameter inputs
│ │ │ │ └── QueryResults.jsx # Results display
│ │ │ │
│ │ │ └── admin/ # Admin components
│ │ │ ├── UserManagement.jsx
│ │ │ ├── RoleEditor.jsx
│ │ │ └── TenantSettings.jsx
│ │ │
│ │ └── pages/ # Route pages
│ │ ├── Login.jsx
│ │ ├── Dashboard.jsx
│ │ ├── Datasources.jsx
│ │ ├── Workflows.jsx
│ │ ├── WorkflowEditor.jsx
│ │ ├── Dashboards.jsx
│ │ ├── DashboardBuilder.jsx
│ │ ├── Queries.jsx
│ │ ├── Users.jsx
│ │ └── Settings.jsx
│ │
│ ├── App.jsx # Root component
│ ├── App.test.jsx # App tests
│ ├── index.jsx # Entry point
│ └── reportWebVitals.js # Performance metrics
│
├── .env # Environment variables
├── .env.sample # Environment template
├── vite.config.js # Vite configuration
├── tailwind.config.js # Tailwind configuration
├── package.json # Dependencies
└── public/
└── config.js # Runtime config (Docker)
Application Architecture
Layered Architecture
Component Hierarchy
App
├── AuthProvider
│ └── TenantProvider
│ └─ ─ SocketProvider
│ └── ThemeProvider
│ └── AppShell
│ ├── Sidebar (Navigation)
│ ├── Header (User info, tenant switcher)
│ └── Main Content Area
│ └── Route Pages
│ ├── Dashboard
│ ├── Datasources
│ ├── Workflows
│ └── ...
Routing System
React Router v6 manages client-side routing:
Route Configuration
// App.jsx
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from './logic/hooks/useAuth';
// Pages
import Login from './presentation/pages/Login';
import Dashboard from './presentation/pages/Dashboard';
import Datasources from './presentation/pages/Datasources';
import Workflows from './presentation/pages/Workflows';
import WorkflowEditor from './presentation/pages/WorkflowEditor';
import Dashboards from './presentation/pages/Dashboards';
import DashboardBuilder from './presentation/pages/DashboardBuilder';
import ProtectedRoute from './presentation/components/auth/ProtectedRoute';
function App() {
return (
<BrowserRouter>
<Routes>
{/* Public routes */}
<Route path="/login" element={<Login />} />
{/* Protected routes */}
<Route element={<ProtectedRoute />}>
<Route path="/" element={<Dashboard />} />
<Route path="/datasources" element={<Datasources />} />
<Route path="/workflows" element={<Workflows />} />
<Route path="/workflows/:workflowId/edit" element={<WorkflowEditor />} />
<Route path="/dashboards" element={<Dashboards />} />
<Route path="/dashboards/:dashboardId/build" element={<DashboardBuilder />} />
<Route path="/queries" element={<Queries />} />
<Route path="/users" element={<Users />} />
<Route path="/settings" element={<Settings />} />
</Route>
{/* Catch all */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
);
}
Protected Route Component
// presentation/components/auth/ProtectedRoute.jsx
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../../logic/hooks/useAuth';
import Loading from '../common/Loading';
function ProtectedRoute({ children }) {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) {
return <Loading fullScreen message="Loading..." />;
}
if (!user) {
// Redirect to login with return URL
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
export default ProtectedRoute;
State Management
Jet Admin uses a hybrid approach: React Query for server state + Context for global state.
React Query (Server State)
// data/api/queryClient.js
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes
},
},
});
Custom Hooks with React Query
// logic/hooks/useWorkflow.js
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { workflowApi } from '../../data/api/workflow.api';
export function useWorkflows(tenantID) {
return useQuery({
queryKey: ['workflows', tenantID],
queryFn: () => workflowApi.list(tenantID),
enabled: !!tenantID,
});
}
export function useWorkflow(workflowID, tenantID) {
return useQuery({
queryKey: ['workflow', workflowID],
queryFn: () => workflowApi.get(workflowID, tenantID),
enabled: !!workflowID && !!tenantID,
});
}
export function useCreateWorkflow() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data) => workflowApi.create(data.tenantID, data.workflow),
onSuccess: (data, variables) => {
// Invalidate and refetch
queryClient.invalidateQueries(['workflows', variables.tenantID]);
},
});
}
export function useExecuteWorkflow() {
return useMutation({
mutationFn: ({ workflowID, tenantID, input }) =>
workflowApi.execute(workflowID, tenantID, input),
});
}
Auth Context
// logic/contexts/AuthContext.jsx
import { createContext, useContext, useState, useEffect } from 'react';
import { auth } from 'firebase/app';
import { onAuthStateChanged, signInWithPopup, GoogleAuthProvider } from 'firebase/auth';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (firebaseUser) => {
setUser(firebaseUser);
setLoading(false);
});
return unsubscribe;
}, []);
const signInWithGoogle = async () => {
const provider = new GoogleAuthProvider();
await signInWithPopup(auth, provider);
};
const signOut = async () => {
await auth.signOut();
};
const value = {
user,
loading,
signInWithGoogle,
signOut,
isAuthenticated: !!user,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
Tenant Context
// logic/contexts/TenantContext.jsx
import { createContext, useContext, useState, useEffect } from 'react';
import { useAuth } from '../hooks/useAuth';
import { tenantApi } from '../../data/api/tenant.api';
const TenantContext = createContext(null);
export function TenantProvider({ children }) {
const { user } = useAuth();
const [tenants, setTenants] = useState([]);
const [currentTenant, setCurrentTenant] = useState(null);
const [loading, setLoading] = useState(false);
// Load user's tenants
useEffect(() => {
async function loadTenants() {
if (!user) {
setTenants([]);
return;
}
setLoading(true);
try {
const userTenants = await tenantApi.list();
setTenants(userTenants);
// Set first tenant as default
if (userTenants.length > 0 && !currentTenant) {
setCurrentTenant(userTenants[0]);
}
} catch (error) {
console.error('Failed to load tenants:', error);
} finally {
setLoading(false);
}
}
loadTenants();
}, [user]);
const selectTenant = (tenant) => {
setCurrentTenant(tenant);
localStorage.setItem('selectedTenant', JSON.stringify(tenant));
};
const value = {
tenants,
currentTenant,
selectTenant,
loading,
hasTenants: tenants.length > 0,
};
return (
<TenantContext.Provider value={value}>
{children}
</TenantContext.Provider>
);
}
export function useTenant() {
const context = useContext(TenantContext);
if (!context) {
throw new Error('useTenant must be used within TenantProvider');
}
return context;
}
API Layer
Axios Instance
// data/api/apiClient.js
import axios from 'axios';
import { auth } from 'firebase/app';
// Get runtime config
const getConfig = () => {
// Check for Docker runtime config
if (window.JET_ADMIN_CONFIG) {
return {
baseURL: window.JET_ADMIN_CONFIG.SERVER_HOST,
socketURL: window.JET_ADMIN_CONFIG.SOCKET_HOST,
};
}
// Fallback to Vite env vars
return {
baseURL: import.meta.env.VITE_SERVER_HOST || 'http://localhost:8090',
socketURL: import.meta.env.VITE_SOCKET_HOST || 'http://localhost:8090',
};
};
const config = getConfig();
// Create axios instance
const apiClient = axios.create({
baseURL: config.baseURL,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor - add auth token
apiClient.interceptors.request.use(
async (request) => {
const user = auth.currentUser;
if (user) {
const token = await user.getIdToken();
request.headers.Authorization = `Bearer ${token}`;
}
// Add tenant ID if available
const tenant = JSON.parse(localStorage.getItem('selectedTenant'));
if (tenant) {
request.headers['x-tenant-id'] = tenant.tenantID;
}
return request;
},
(error) => Promise.reject(error)
);
// Response interceptor - handle errors
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Token expired - redirect to login
auth.signOut();
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export { apiClient, config };
API Module Example
// data/api/workflow.api.js
import { apiClient } from './apiClient';
export const workflowApi = {
// List all workflows
async list(tenantID) {
const response = await apiClient.get(`/api/v1/tenants/${tenantID}/workflows`);
return response.data.data;
},
// Get single workflow
async get(workflowID, tenantID) {
const response = await apiClient.get(
`/api/v1/tenants/${tenantID}/workflows/${workflowID}`
);
return response.data.data;
},
// Create workflow
async create(tenantID, workflowData) {
const response = await apiClient.post(
`/api/v1/tenants/${tenantID}/workflows`,
workflowData
);
return response.data.data;
},
// Update workflow
async update(workflowID, tenantID, workflowData) {
const response = await apiClient.patch(
`/api/v1/tenants/${tenantID}/workflows/${workflowID}`,
workflowData
);
return response.data.data;
},
// Delete workflow
async delete(workflowID, tenantID) {
await apiClient.delete(
`/api/v1/tenants/${tenantID}/workflows/${workflowID}`
);
},
// Execute workflow
async execute(workflowID, tenantID, input = {}) {
const response = await apiClient.post(
`/api/v1/tenants/${tenantID}/workflows/${workflowID}/execute`,
{ input }
);
return response.data.data;
},
// Test workflow (unsaved)
async test(tenantID, nodes, edges, input = {}) {
const response = await apiClient.post(
`/api/v1/tenants/${tenantID}/workflows/test`,
{ nodes, edges, input }
);
return response.data.data;
},
// Get workflow instance status
async getInstance(instanceID, tenantID) {
const response = await apiClient.get(
`/api/v1/tenants/${tenantID}/workflows/instances/${instanceID}`
);
return response.data.data;
},
// Stop workflow instance
async stop(instanceID, tenantID) {
await apiClient.delete(
`/api/v1/tenants/${tenantID}/workflows/instances/${instanceID}/stop`
);
},
};
Component Architecture
Component Patterns
Container/Presentational Pattern
// Container component (logic)
// presentation/components/workflow/WorkflowEditorContainer.jsx
import { useState, useCallback } from 'react';
import { useWorkflow, useExecuteWorkflow } from '../../../logic/hooks/useWorkflow';
import { useSocket } from '../../../logic/hooks/useSocket';
import WorkflowEditor from './WorkflowEditor';
export function WorkflowEditorContainer({ workflowId }) {
const { data: workflow, isLoading } = useWorkflow(workflowId);
const executeMutation = useExecuteWorkflow();
const { subscribeToWorkflow } = useSocket();
const [nodes, setNodes] = useState(workflow?.nodes || []);
const [edges, setEdges] = useState(workflow?.edges || []);
const [isRunning, setIsRunning] = useState(false);
const handleRun = useCallback(async (input) => {
setIsRunning(true);
// Subscribe to real-time updates
const instance = await executeMutation.mutateAsync({ workflowId, input });
subscribeToWorkflow(instance.instanceID, {
onNodeUpdate: (nodeData) => {
// Update UI with node progress
},
onComplete: () => {
setIsRunning(false);
},
});
}, [workflowId]);
if (isLoading) {
return <div>Loading workflow...</div>;
}
return (
<WorkflowEditor
nodes={nodes}
edges={edges}
onNodesChange={setNodes}
onEdgesChange={setEdges}
onRun={handleRun}
isRunning={isRunning}
/>
);
}
// Presentational component (UI)
// presentation/components/workflow/WorkflowEditor.jsx
import ReactFlow from 'reactflow';
import 'reactflow/dist/style.css';
function WorkflowEditor({ nodes, edges, onNodesChange, onEdgesChange, onRun, isRunning }) {
return (
<div className="workflow-editor">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
fitView
>
{/* Custom controls */}
</ReactFlow>
<button onClick={() => onRun({})} disabled={isRunning}>
{isRunning ? 'Running...' : 'Run Workflow'}
</button>
</div>
);
}
export default WorkflowEditor;
Shared Component Example
// presentation/components/common/Modal.jsx
import { Dialog, Transition } from '@headlessui/react';
import { Fragment } from 'react';
export function Modal({ isOpen, onClose, title, children, size = 'md' }) {
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
};
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel
className={`w-full ${sizeClasses[size]} transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all`}
>
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-gray-900 mb-4"
>
{title}
</Dialog.Title>
{children}
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
);
}
Shared Packages
Jet Admin uses a monorepo structure with shared packages:
Package Structure
packages/
├── datasource-types/ # Shared datasource schemas
│ ├── src/
│ │ ├── index.js # Exports all types
│ │ ├── postgresql.js # PostgreSQL schema
│ │ ├── restapi.js # REST API schema
│ │ └── ...
│ └── package.json
│
├── datasources-logic/ # Backend datasource drivers
│ ├── src/
│ │ ├── index.js
│ │ ├── postgresql/
│ │ │ ├── testConnection.js
│ │ │ └── executeQuery.js
│ │ └── ...
│ └── package.json
│
├── datasources-ui/ # Frontend datasource forms
│ ├── src/
│ │ ├── index.js
│ │ ├── PostgreSQLForm.jsx
│ │ ├── RestApiForm.jsx
│ │ └── ...
│ └── package.json
│
├── widgets-ui/ # Widget configuration UI
│ ├── src/
│ │ ├── index.js
│ │ ├── ChartConfig.jsx
│ │ ├── TableConfig.jsx
│ │ └── ...
│ └── package.json
│
├── widgets-logic/ # Widget data transformers
│ ├── src/
│ │ ├── index.js
│ │ ├── chartTransformer.js
│ │ └── ...
│ └── package.json
│
├── workflow-nodes/ # Workflow node components
│ ├── src/
│ │ ├── index.js
│ │ ├── StartNode.jsx
│ │ ├── QueryNode.jsx
│ │ ├── ScriptNode.jsx
│ │ └── ...
│ └── package.json
│
├── workflow-edges/ # Custom edge components
│ ├── src/
│ │ ├── index.js
│ │ └── ConditionalEdge.jsx
│ └── package.json
│
├── ui/ # Shared UI components
│ ├── src/
│ │ ├── index.js
│ │ ├── Button.jsx
│ │ ├── Input.jsx
│ │ └── ...
│ └── package.json
│
└── json-forms-renderers/ # JSON Forms custom renderers
├── src/
│ └── ...
└── package.json
Using Shared Packages
// In apps/frontend/src/presentation/components/datasource/DatasourceForm.jsx
import { DATASOURCE_UI_COMPONENTS } from '@jet-admin/datasources-ui';
import { DATASOURCE_TYPES } from '@jet-admin/datasource-types';
function DatasourceForm({ datasourceType }) {
const FormComponent = DATASOURCE_UI_COMPONENTS[datasourceType];
return (
<div>
<h3>Configure {datasourceType}</h3>
<FormComponent />
</div>
);
}