Entity-Aware Routing
Entity-Aware Routing enables entity-specific UI customization through the frontend plugin system. This feature allows administrators to configure custom plugin views for specific entities, replacing default Pagoda pages with plugin-provided alternatives.
- Entity-Specific UX: Different entities can have completely different UI experiences
- Plugin-Based Customization: Leverage the plugin ecosystem for UI extensions
- Zero-Code Configuration: Switch views via Django settings, no code changes required
- Graceful Fallback: Missing plugins or pages automatically fall back to defaults
Phase 1 supports:
- Page Type:
entry.list(Entity entries listing page) - URL Pattern:
/ui/entities/:entityId/entries
Future phases may extend to additional page types such as entry.detail, entry.edit, etc.
graph TB
subgraph "Django Backend"
SETTINGS[settings_common.py<br/>ENTITY_PLUGIN_VIEWS]
TEMPLATE[index.html Template]
CONTEXT[django_context<br/>window.django_context]
end
subgraph "React Frontend"
SERVERCTX[ServerContext]
HOOK[usePluginMappings Hook]
ROUTER[EntityAwareRoute]
PLUGINMAP[pluginMap<br/>Map<string, Plugin>]
end
subgraph "Plugin Layer"
PLUGIN[Entity View Plugin]
PAGES[entityPages<br/>entry.list Component]
end
subgraph "UI Output"
DEFAULT[Default EntryListPage]
CUSTOM[Plugin Custom Page]
end
SETTINGS --> TEMPLATE
TEMPLATE --> CONTEXT
CONTEXT --> SERVERCTX
SERVERCTX --> HOOK
HOOK --> ROUTER
PLUGINMAP --> ROUTER
PLUGIN --> PAGES
PAGES --> ROUTER
ROUTER -->|No mapping| DEFAULT
ROUTER -->|Has mapping| CUSTOM
style SETTINGS fill:#e8f5e8
style ROUTER fill:#e1f5fe
style PLUGIN fill:#f3e5f5
sequenceDiagram
participant Admin as Administrator
participant Django as Django Settings
participant Template as HTML Template
participant React as React App
participant Router as EntityAwareRoute
participant Plugin as Plugin Component
Note over Admin,Django: Configuration Phase
Admin->>Django: Set ENTITY_PLUGIN_VIEWS<br/>{"9681": {"plugin": "sample", "pages": ["entry.list"]}}
Note over Template,React: Page Load Phase
Django->>Template: Render index.html
Template->>React: window.django_context.entityPluginViews
Note over React,Plugin: Routing Phase
React->>Router: Navigate to /ui/entities/9681/entries
Router->>Router: Extract entityId from URL
Router->>Router: Lookup config[entityId] (O(1))
Router->>Router: Check if "entry.list" in pages
Router->>Router: Lookup pluginMap.get("sample") (O(1))
Router->>Plugin: Render plugin.entityPages["entry.list"]
Plugin-->>React: Custom Entry List UI
graph LR
subgraph "App Initialization"
APP[AppBase]
PLUGINS[plugins: Plugin[]]
MAP[pluginMap: Map]
end
subgraph "Routing Layer"
APPROUTER[AppRouter]
ROUTE[EntityAwareRoute]
end
subgraph "Data Sources"
CTX[ServerContext]
HOOK[usePluginMappings]
CONFIG[EntityPluginViewsConfig]
end
subgraph "Resolution"
LOOKUP_ID[config[entityId]]
LOOKUP_PLUGIN[pluginMap.get(pluginId)]
COMPONENT[PluginComponent]
end
APP --> PLUGINS
PLUGINS -->|useMemo| MAP
APP --> APPROUTER
APPROUTER --> ROUTE
MAP --> ROUTE
CTX --> HOOK
HOOK --> CONFIG
CONFIG --> ROUTE
ROUTE --> LOOKUP_ID
LOOKUP_ID --> LOOKUP_PLUGIN
LOOKUP_PLUGIN --> COMPONENT
style APP fill:#e1f5fe
style ROUTE fill:#f3e5f5
style COMPONENT fill:#e8f5e8
Configure entity-plugin mappings in settings_common.py or via environment variable:
# airone/settings_common.py
AIRONE = {
# ... other settings ...
# Entity-specific plugin view routing configuration
# Format: { "entityId": { "plugin": "plugin-id", "pages": ["entry.list"] } }
"ENTITY_PLUGIN_VIEWS": json.loads(
env.str(
"ENTITY_PLUGIN_VIEWS",
json.dumps({}), # Default: no overrides
)
),
}
{
"entityId": {
"plugin": "plugin-id",
"pages": ["entry.list"]
}
}
| Field | Type | Description |
|---|---|---|
entityId | string | The entity ID (as string) to apply custom routing |
plugin | string | The plugin ID that provides the custom view |
pages | string[] | List of page types to override (currently only "entry.list") |
Single Entity Override:
export ENTITY_PLUGIN_VIEWS='{"42": {"plugin": "network-tools", "pages": ["entry.list"]}}'
Multiple Entity Overrides:
export ENTITY_PLUGIN_VIEWS='{
"42": {"plugin": "network-tools", "pages": ["entry.list"]},
"100": {"plugin": "asset-manager", "pages": ["entry.list"]},
"255": {"plugin": "custom-dashboard", "pages": ["entry.list"]}
}'
Disable All Overrides:
export ENTITY_PLUGIN_VIEWS='{}'
To provide custom entity pages, your plugin must implement the EntityViewPlugin interface:
import { FC } from "react";
// Plugin interface for entity-specific views
interface EntityViewPlugin {
id: string;
name: string;
// Entity page components by page type
entityPages?: {
"entry.list"?: FC;
// Future: "entry.detail"?, "entry.edit"?, etc.
};
}
// plugin-sample/src/index.ts
import { FC } from "react";
import { CustomEntryList } from "./components/CustomEntryList";
interface Plugin {
id: string;
name: string;
entityPages?: {
"entry.list"?: FC;
};
}
const plugin: Plugin = {
id: "sample",
name: "Sample Entity View Plugin",
entityPages: {
"entry.list": CustomEntryList,
},
};
export default plugin;
// plugin-sample/src/components/CustomEntryList.tsx
import { FC } from "react";
import { useParams } from "react-router";
import { Box, Typography } from "@mui/material";
export const CustomEntryList: FC = () => {
const { entityId } = useParams<{ entityId: string }>();
return (
<Box p={3}>
<Typography variant="h4">
Custom Entry List for Entity {entityId}
</Typography>
{/* Your custom implementation */}
</Box>
);
};
Register your plugin in pagoda-minimal-builder:
// frontend/plugins/pagoda-minimal-builder/plugins.config.js
module.exports = {
plugins: [
"pagoda-plugin-hello-world",
"pagoda-plugin-dashboard",
"pagoda-plugin-entity-sample", // Your entity view plugin
],
};
Plugins can define requirements for entity structure using Zod schemas. When a plugin specifies an entitySchema, the system validates that the target entity meets these requirements before rendering the plugin’s page.
- Prevent Runtime Errors: Ensure required attributes exist before your plugin code runs
- Clear Error Messages: Users see exactly what’s missing when an entity doesn’t match
- Type Safety: Schema validation provides TypeScript type inference
- Self-Documenting: The schema serves as documentation for entity requirements
Use the entitySchema property in your EntityViewPlugin to define requirements:
import { z } from "zod";
// AttrType constants (matching airone/lib/types.py)
const AttrType = {
OBJECT: 1,
STRING: 2,
TEXT: 4,
BOOLEAN: 8,
GROUP: 16,
NUMBER: 256,
ARRAY_STRING: 1026,
ARRAY_OBJECT: 1025,
// ... other types
} as const;
// Helper to check for required attributes
const hasAttr = (name: string, type: number | number[]) =>
(attrs: EntityAttrStructure[]) => {
const types = Array.isArray(type) ? type : [type];
return attrs.some(a => a.name === name && types.includes(a.type));
};
// Define the entity schema
const networkDeviceSchema = z.object({
id: z.number(),
name: z.string(),
attrs: z.array(z.object({
id: z.number(),
name: z.string(),
type: z.number(),
isMandatory: z.boolean(),
referral: z.array(z.object({ id: z.number(), name: z.string() })),
}))
})
.refine(
(entity) => hasAttr("hostname", AttrType.STRING)(entity.attrs),
{ message: "hostname attribute (STRING type) is required" }
)
.refine(
(entity) => hasAttr("ip_address", AttrType.STRING)(entity.attrs),
{ message: "ip_address attribute (STRING type) is required" }
);
// Use in plugin
const plugin: EntityViewPlugin = {
id: "network-tools",
name: "Network Tools Plugin",
version: "1.0.0",
routes: [],
entityPages: {
"entry.list": NetworkDeviceList,
},
entitySchema: networkDeviceSchema, // Enable validation
};
The frontend provides helper functions to simplify schema creation:
import {
AttrType,
baseEntitySchema,
requireAttr,
requireReferral,
createEntitySchema,
} from "plugins/schema";
// Method 1: Using baseEntitySchema with refine
const schema1 = baseEntitySchema
.refine(
(entity) => requireAttr("hostname", AttrType.STRING)(entity.attrs),
{ message: "hostname attribute is required" }
)
.refine(
(entity) => requireReferral("location", ["Datacenter"])(entity.attrs),
{ message: "location must reference Datacenter entity" }
);
// Method 2: Using createEntitySchema helper
const schema2 = createEntitySchema([
{ name: "hostname", type: AttrType.STRING },
{ name: "ip_address", type: AttrType.STRING },
{ name: "location", type: AttrType.OBJECT, referrals: ["Datacenter"] },
]);
When schema validation is enabled:
sequenceDiagram
participant User
participant Router as EntityAwareRoute
participant API as Airone API
participant Validator as Schema Validator
participant Plugin as Plugin Page
User->>Router: Navigate to entity page
Router->>Router: Find plugin with entitySchema
Router->>API: Fetch entity details
API-->>Router: EntityDetail response
Router->>Validator: Validate against schema
alt Validation passes
Validator-->>Router: success: true
Router->>Plugin: Render plugin page
else Validation fails
Validator-->>Router: success: false, errors: [...]
Router->>User: Show SchemaValidationErrorPage
end
When validation fails, users see a clear error page showing:
- Which entity failed validation
- Which plugin defined the requirements
- Specific validation errors with helpful messages
This allows administrators to either:
- Update the entity to meet the plugin’s requirements
- Remove the plugin mapping for this entity
| Type | Value | Description |
|---|---|---|
OBJECT | 1 | Reference to another entry |
STRING | 2 | Single-line text |
TEXT | 4 | Multi-line text |
BOOLEAN | 8 | True/false value |
GROUP | 16 | Reference to a group |
DATE | 32 | Date value |
ROLE | 64 | Reference to a role |
DATETIME | 128 | Date and time value |
NUMBER | 256 | Numeric value |
NAMED_OBJECT | 2049 | Named reference to entry |
ARRAY_OBJECT | 1025 | Array of entry references |
ARRAY_STRING | 1026 | Array of strings |
ARRAY_NUMBER | 1280 | Array of numbers |
ARRAY_NAMED_OBJECT | 3073 | Array of named references |
ARRAY_GROUP | 1040 | Array of group references |
ARRAY_ROLE | 1088 | Array of role references |
flowchart TD
START[User navigates to<br/>/ui/entities/:entityId/entries]
CHECK_ID{entityId<br/>in URL?}
LOOKUP_CONFIG[Lookup config[entityId]<br/>O(1)]
CHECK_MAPPING{Mapping<br/>exists?}
CHECK_PAGE{pageType in<br/>mapping.pages?}
LOOKUP_PLUGIN[Lookup pluginMap.get(pluginId)<br/>O(1)]
CHECK_PLUGIN{Plugin<br/>found?}
CHECK_ENTITY_VIEW{Plugin has<br/>entityPages?}
CHECK_COMPONENT{Page component<br/>exists?}
RENDER_PLUGIN[Render Plugin Component]
RENDER_DEFAULT[Render Default EntryListPage]
START --> CHECK_ID
CHECK_ID -->|No| RENDER_DEFAULT
CHECK_ID -->|Yes| LOOKUP_CONFIG
LOOKUP_CONFIG --> CHECK_MAPPING
CHECK_MAPPING -->|No| RENDER_DEFAULT
CHECK_MAPPING -->|Yes| CHECK_PAGE
CHECK_PAGE -->|No| RENDER_DEFAULT
CHECK_PAGE -->|Yes| LOOKUP_PLUGIN
LOOKUP_PLUGIN --> CHECK_PLUGIN
CHECK_PLUGIN -->|No| RENDER_DEFAULT
CHECK_PLUGIN -->|Yes| CHECK_ENTITY_VIEW
CHECK_ENTITY_VIEW -->|No| RENDER_DEFAULT
CHECK_ENTITY_VIEW -->|Yes| CHECK_COMPONENT
CHECK_COMPONENT -->|No| RENDER_DEFAULT
CHECK_COMPONENT -->|Yes| RENDER_PLUGIN
style START fill:#e1f5fe
style RENDER_PLUGIN fill:#e8f5e8
style RENDER_DEFAULT fill:#fff3e0
All lookups are O(1):
| Operation | Complexity | Implementation |
|---|---|---|
| Entity mapping lookup | O(1) | config[entityId] object property access |
| Plugin lookup | O(1) | pluginMap.get(pluginId) Map lookup |
| Page check | O(n) | pages.includes(pageType) where n is typically 1-3 |
Total routing decision: O(1) with respect to number of entities and plugins.
| File | Purpose |
|---|---|
airone/settings_common.py | Django configuration for ENTITY_PLUGIN_VIEWS |
templates/frontend/index.html | Passes config to frontend via django_context |
frontend/src/plugins/index.ts | Type definitions for EntityViewPlugin |
frontend/src/services/ServerContext.ts | Reads entityPluginViews from window |
frontend/src/hooks/usePluginMappings.ts | Hook to access plugin mappings |
frontend/src/routes/EntityAwareRoute.tsx | Core routing logic component |
frontend/src/routes/AppRouter.tsx | Integrates EntityAwareRoute |
frontend/src/AppBase.tsx | Creates pluginMap from plugins array |
// frontend/src/plugins/index.ts
// Supported page types for entity views
export type EntityPageType = "entry.list";
// Configuration for a single entity mapping
export interface EntityPluginMapping {
plugin: string; // Plugin ID
pages: EntityPageType[]; // Page types to override
}
// Full configuration: entityId -> mapping
export type EntityPluginViewsConfig = Record<string, EntityPluginMapping>;
// Plugin interface with entity pages
export interface EntityViewPlugin extends Plugin {
entityPages?: Partial<Record<EntityPageType, FC>>;
entitySchema?: z.ZodType<EntityStructure>; // Optional schema validation
}
// Type guard function
export function isEntityViewPlugin(plugin: Plugin): plugin is EntityViewPlugin {
return "entityPages" in plugin && plugin.entityPages !== undefined;
}
Set up the configuration:
# In your Django settings or environment export ENTITY_PLUGIN_VIEWS='{"9681": {"plugin": "sample", "pages": ["entry.list"]}}'Build the frontend with plugins:
# Build the core library npm run build:lib # Build with plugins cd frontend/plugins/pagoda-minimal-builder npm run build cp dist/ui.js ../../../static/js/ui.jsStart the server:
python manage.py runserverVerify in browser:
- Navigate to
/ui/entities/9681/entries - You should see the plugin’s custom view instead of the default EntryListPage
- Navigate to
Check console for configuration:
// In browser console console.log(window.django_context.entityPluginViews); // Output: {"9681": {"plugin": "sample", "pages": ["entry.list"]}}
If the custom view is not displayed:
Check configuration is loaded:
window.django_context.entityPluginViewsVerify plugin is registered:
- Check
pagoda-minimal-builder/plugins.config.js - Ensure plugin is in the configured plugins list
- Check
Check console warnings:
- EntityAwareRoute logs warnings for:
- Plugin not found
- Plugin doesn’t support entity pages
- Page type not provided by plugin
- EntityAwareRoute logs warnings for:
| Page Type | URL Pattern | Status |
|---|---|---|
entry.list | /ui/entities/:entityId/entries | Implemented |
entry.detail | /ui/entities/:entityId/entries/:entryId | Planned |
entry.edit | /ui/entities/:entityId/entries/:entryId/edit | Planned |
entry.create | /ui/entities/:entityId/entries/new | Planned |
To extend the system with new page types:
Add the page type to
EntityPageType:export type EntityPageType = "entry.list" | "entry.detail" | "entry.edit";Add routing in
AppRouter.tsx:<Route path={entryDetailsPath(":entityId", ":entryId")} element={ <EntityAwareRoute pageType="entry.detail" defaultComponent={EntryDetailsPage} pluginMap={pluginMap} /> } />Implement the page in your plugin:
entityPages: { "entry.list": CustomEntryList, "entry.detail": CustomEntryDetail, },