Pagoda / Advanced Topics / Entity-Aware Routing

Entity-Aware Routing

Overview

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.

Key Benefits

  • 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

Current Scope (Phase 1)

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.

Architecture

System Overview

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&lt;string, Plugin&gt;]
    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

Data Flow

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

Component Relationships

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&lsqb;entityId&rsqb;]
        LOOKUP_PLUGIN[pluginMap.get&lpar;pluginId&rpar;]
        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

Configuration

Django Settings

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
        )
    ),
}

Configuration Format

{
  "entityId": {
    "plugin": "plugin-id",
    "pages": ["entry.list"]
  }
}
FieldTypeDescription
entityIdstringThe entity ID (as string) to apply custom routing
pluginstringThe plugin ID that provides the custom view
pagesstring[]List of page types to override (currently only "entry.list")

Example Configurations

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='{}'

Plugin Development

Creating an Entity View Plugin

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.
  };
}

Example Plugin Implementation

// 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;

Custom Component Example

// 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>
  );
};

Plugin Registration

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
  ],
};

Entity Schema Validation

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.

Why Use Entity Schema Validation?

  • 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

Defining an Entity Schema

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
};

Using Schema Helpers (from pagoda-core)

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"] },
]);

Validation Flow

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

Error Page

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:

  1. Update the entity to meet the plugin’s requirements
  2. Remove the plugin mapping for this entity

AttrType Reference

TypeValueDescription
OBJECT1Reference to another entry
STRING2Single-line text
TEXT4Multi-line text
BOOLEAN8True/false value
GROUP16Reference to a group
DATE32Date value
ROLE64Reference to a role
DATETIME128Date and time value
NUMBER256Numeric value
NAMED_OBJECT2049Named reference to entry
ARRAY_OBJECT1025Array of entry references
ARRAY_STRING1026Array of strings
ARRAY_NUMBER1280Array of numbers
ARRAY_NAMED_OBJECT3073Array of named references
ARRAY_GROUP1040Array of group references
ARRAY_ROLE1088Array of role references

Routing Flow

Decision Logic

flowchart TD
    START[User navigates to<br/>/ui/entities/:entityId/entries]

    CHECK_ID{entityId<br/>in URL?}

    LOOKUP_CONFIG[Lookup config&lsqb;entityId&rsqb;<br/>O&lpar;1&rpar;]

    CHECK_MAPPING{Mapping<br/>exists?}

    CHECK_PAGE{pageType in<br/>mapping.pages?}

    LOOKUP_PLUGIN[Lookup pluginMap.get&lpar;pluginId&rpar;<br/>O&lpar;1&rpar;]

    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

Performance Characteristics

All lookups are O(1):

OperationComplexityImplementation
Entity mapping lookupO(1)config[entityId] object property access
Plugin lookupO(1)pluginMap.get(pluginId) Map lookup
Page checkO(n)pages.includes(pageType) where n is typically 1-3

Total routing decision: O(1) with respect to number of entities and plugins.

Implementation Details

Key Files

FilePurpose
airone/settings_common.pyDjango configuration for ENTITY_PLUGIN_VIEWS
templates/frontend/index.htmlPasses config to frontend via django_context
frontend/src/plugins/index.tsType definitions for EntityViewPlugin
frontend/src/services/ServerContext.tsReads entityPluginViews from window
frontend/src/hooks/usePluginMappings.tsHook to access plugin mappings
frontend/src/routes/EntityAwareRoute.tsxCore routing logic component
frontend/src/routes/AppRouter.tsxIntegrates EntityAwareRoute
frontend/src/AppBase.tsxCreates pluginMap from plugins array

Type Definitions

// 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;
}

Verification

Testing the Configuration

  1. Set up the configuration:

    # In your Django settings or environment
    export ENTITY_PLUGIN_VIEWS='{"9681": {"plugin": "sample", "pages": ["entry.list"]}}'
    
  2. 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.js
    
  3. Start the server:

    python manage.py runserver
    
  4. Verify in browser:

    • Navigate to /ui/entities/9681/entries
    • You should see the plugin’s custom view instead of the default EntryListPage
  5. Check console for configuration:

    // In browser console
    console.log(window.django_context.entityPluginViews);
    // Output: {"9681": {"plugin": "sample", "pages": ["entry.list"]}}
    

Debugging

If the custom view is not displayed:

  1. Check configuration is loaded:

    window.django_context.entityPluginViews
    
  2. Verify plugin is registered:

    • Check pagoda-minimal-builder/plugins.config.js
    • Ensure plugin is in the configured plugins list
  3. Check console warnings:

    • EntityAwareRoute logs warnings for:
      • Plugin not found
      • Plugin doesn’t support entity pages
      • Page type not provided by plugin

Future Extensions

Planned Page Types

Page TypeURL PatternStatus
entry.list/ui/entities/:entityId/entriesImplemented
entry.detail/ui/entities/:entityId/entries/:entryIdPlanned
entry.edit/ui/entities/:entityId/entries/:entryId/editPlanned
entry.create/ui/entities/:entityId/entries/newPlanned

Adding New Page Types

To extend the system with new page types:

  1. Add the page type to EntityPageType:

    export type EntityPageType = "entry.list" | "entry.detail" | "entry.edit";
    
  2. Add routing in AppRouter.tsx:

    <Route
      path={entryDetailsPath(":entityId", ":entryId")}
      element={
        <EntityAwareRoute
          pageType="entry.detail"
          defaultComponent={EntryDetailsPage}
          pluginMap={pluginMap}
        />
      }
    />
    
  3. Implement the page in your plugin:

    entityPages: {
      "entry.list": CustomEntryList,
      "entry.detail": CustomEntryDetail,
    },