Layouts

Topology layouts store positions, zoom/pan, hidden nodes, and node scale. The canvas uses one shared library across networks: scope_type=workspace. The server supplies the fixed workspace ID 00000000-0000-0000-0000-000000000001; scope_id is optional and ignored for this scope. Existing area, protocol-instance, routing-domain and network layouts remain in the library with their original metadata. Users see shared layouts and their own private layouts. Admins can set a shared workspace default.

List Layouts

GET /api/v1/layouts?scope_type={scope_type}&scope_id={scope_id}
GET /api/v1/areas/{areaID}/layouts

List layouts for the requested library or legacy scope. Returns the authenticated user's own layouts plus any public layouts.

With scope_type=workspace, returns these layouts from all scopes. Deleted entries are excluded. The response includes owner_username and is_default; the latter identifies the workspace default. An empty list is []. Listing omits full layout_data.

Query parameters (first form):

Note: The second form (/areas/{areaID}/layouts) is backward-compatible and equivalent to ?scope_type=area&scope_id={areaID}.

Note: For parent scopes (network, routing_domain, protocol_instance), the response includes layouts from child scopes as well, so users can see layouts saved at lower levels.

Response: 200 OK

[
  {
    "id": "uuid",
    "scope_type": "area",
    "scope_id": "uuid",
    "owner_id": "uuid",
    "name": "My Layout",
    "description": "Custom layout for area 0",
    "is_public": false,
    "created_at": "2024-01-01T00:00:00Z",
    "updated_at": "2024-01-01T00:00:00Z"
  }
]

Note: layout_data is omitted from list responses for performance.

Error: 400 Bad Request — Missing or invalid scope_type/scope_id

Create Layout

POST /api/v1/layouts?scope_type={scope_type}&scope_id={scope_id}
POST /api/v1/areas/{areaID}/layouts

Create a new layout for a scope.

Request body:

{
  "name": "My Layout",
  "description": "Optional description",
  "is_public": false,
  "layout_data": {
    "positions": {"node-uuid": {"x": 100, "y": 200}},
    "zoom": 1.5,
    "pan": {"x": 0, "y": 0},
    "hidden_nodes": ["router-id"],
    "node_scale": 0.7
  }
}

layout_data is stored as opaque JSONB; the frontend writes positions, viewport zoom/pan, hidden_nodes (router-id keys) and node_scale (global node/label scale, absent on layouts saved before the field existed).

Response: 201 Created

{
  "id": "uuid",
  "scope_type": "area",
  "scope_id": "uuid",
  "owner_id": "uuid",
  "name": "My Layout",
  "description": "Optional description",
  "is_public": false,
  "layout_data": { ... },
  "created_at": "2024-01-01T00:00:00Z",
  "updated_at": "2024-01-01T00:00:00Z"
}

Error: 400 Bad Request — Missing required fields

Get Layout

GET /api/v1/layouts/{layoutID}
GET /api/v1/areas/{areaID}/layouts/{layoutID}

Get a single layout by ID (includes full layout_data).

Access control: Owner, public layouts, or admin.

Response: 200 OK (full layout object with layout_data)

Error responses:

Update Layout

PUT /api/v1/layouts/{layoutID}
PUT /api/v1/areas/{areaID}/layouts/{layoutID}

Update a layout. All fields are optional (partial update).

Access control: Owner or admin only.

Request body:

{
  "name": "Updated Name",
  "description": "Updated description",
  "is_public": true,
  "layout_data": { ... }
}

Response: 200 OK (updated layout object)

Error responses:

Delete Layout

DELETE /api/v1/layouts/{layoutID}
DELETE /api/v1/areas/{areaID}/layouts/{layoutID}

Move a layout to trash (deleted_at), preserving its data. Clears its shared and legacy default status. Repeated deletion is idempotent. Personal selections are retained but ignored while the layout is deleted. Layouts also survive deletion of their original hierarchy entity.

Access control: Owner or admin only.

Response: 204 No Content

Error responses:

Deleted Layouts and Restore

GET /api/v1/layouts/trash
PUT /api/v1/layouts/{layoutID}/restore

The trash list returns metadata including deleted_at for the owner's deleted layouts; admins see all deleted layouts. Restore requires ownership or the admin role and returns 204 No Content. It restores the same ID and data. If the original name is now in use within the same owner/scope, it appends a distinct restored suffix. It never replaces the workspace default. A retained personal selection may become active again after restoration.

Restore returns 403 for another user's layout, 404 for a missing ID, or 409 if the entry is already live or a concurrent name conflict prevents restoration. Deleted layouts cannot be updated or activated (409) and their contents can only be read by the owner or an admin. No automatic purge or permanent-delete endpoint is provided.

Set Shared Default

PUT /api/v1/layouts/{layoutID}/set-default?scope_type=workspace

Admin only (403 otherwise). The layout must be shared and outside the trash (409 otherwise). Atomically replaces the workspace default and returns 200 OK with {"message":"default layout set"}. Explicit personal workspace selections keep precedence. Legacy scope parameters, or no scope to use the layout's own scope, remain supported.

Get Active Layout

GET /api/v1/layouts/active?scope_type={scope_type}&scope_id={scope_id}
GET /api/v1/areas/{areaID}/active-layout

Get the currently active layout. For scope_type=workspace, resolution is: the user's explicit workspace selection, then the shared workspace default, then a retained personal selection from an older scope. When several legacy selections exist, choose the most recently updated layout, breaking ties by ID. Only accessible, non-deleted layouts qualify. Returns 204 if none qualifies.

The hierarchy walk below applies only to legacy scope requests.

Query parameters (first form):

Hierarchy walk order (closest match wins):

The server checks the following scopes in order, stopping at the first match. At each scope level, a user's explicit active layout takes priority over the scope default.

Starting scope Walk order
area area → protocol_instance → routing_domain → network
protocol_instance protocol_instance → routing_domain → network
routing_domain routing_domain → network
network network only

Resolution at each scope level:

  1. User's explicit active layout for the scope (set via Activate Layout)
  2. Scope default layout (set by admin via Set Default Layout)

If no layout is found at any level, returns 204 No Content.

Response: 200 OK (full layout object) or 204 No Content

Note: The returned layout may belong to a different (ancestor) scope than the one requested. The response scope_type and scope_id fields reflect the layout's own scope, not the requested scope.

Error: 400 Bad Request — Missing or invalid scope parameters

Activate Layout

PUT /api/v1/layouts/{layoutID}/activate
PUT /api/v1/layouts/{layoutID}/activate?scope_type={scope_type}&scope_id={scope_id}
PUT /api/v1/areas/{areaID}/layouts/{layoutID}/activate

Set a layout as the authenticated user's active layout. By default, the activation is stored under the layout's own scope. When optional scope_type and scope_id query parameters are provided, the activation is stored under the specified viewing scope instead. This enables cross-scope layout reuse -- for example, activating an area-scoped layout while viewing at the protocol-instance or network level.

The canvas always sends scope_type=workspace. This stores one selection for the user regardless of the layout's original scope, and it persists across network/area changes.

Query parameters (optional, first and second forms):

When scope_type/scope_id are omitted (or when using the backward-compatible /areas/{areaID}/... form), the activation is stored under the layout's own scope_id.

Access control: User must have access to the layout (owner, public, or admin).

Response: 200 OK

{"message": "layout activated"}

Error responses: