feat: implement federation management
What is this MR?
It adds a Federation page to the portal. An operator can now:
- Register partner operators, the other telecom platforms we want to share edge resources with.
- Create or delete a federation with a partner (a trust relationship between two platforms).
- Subscribe to or unsubscribe from the partner's zones, the edge locations it offers us.
It also contains supporting work that the page needed: a shared data table, one shared confirm dialog, role-based API catalogue filtering, and a cleanup of dead code.
Terms
| Term | Meaning |
|---|---|
| Partner | Another operator's platform, registered in our Federation Manager (FM). |
| Federation | An agreement between us and an active partner. It is identified by a federationContextId. |
| Zone | An edge location the partner offers. Subscribing makes the partner reserve resources for us. |
| FM | Federation Manager, the backend service that talks to partners. |
How the pieces fit together
flowchart TD
A["pages/federation/index.tsx<br/>(route entry)"] --> B["features/federation/federation.tsx<br/>(FederationPage: owns dialog state)"]
B --> C["components/federation/*<br/>(columns, dialogs, badges, zone list)"]
B --> D["hooks/usePartners, usePartnerFederation"]
C --> D
D --> E["services/federation/*<br/>(partners.ts, federations.ts)"]
E --> F["federation-request.ts<br/>(fetch + error handling)"]
F --> G["lib/api-fetch (existing)"]The GUI never talks to the FM directly. Every call goes through the portal backend, which forwards /api/oeg/* to the OEG with the user's token. The only new config is federation.baseUrl in api.ts.
Code layers (top to bottom)
Each layer only calls the one below it. That is why the components contain no fetch calls.
| Layer | Files | Job |
|---|---|---|
| Types | types/federation.ts | Shapes of the API data |
| Service | services/federation/* |
One function per endpoint |
| Hooks | usePartners, usePartnerFederation, useSingleFlight |
Loading state, caching, actions |
| Validation | features/federation/*-schema.ts |
Zod schemas for the forms |
| UI | components/federation/*, features/federation/federation.tsx |
What the user sees |
Service layer: errors you can react to
federation-request.ts wraps apiFetch and does three things:
- It sets the JSON headers and parses the body safely.
- On a non-2xx response it throws a
FederationRequestError. The error message is built from the API'sProblemDetails:detail, thentitle, plus anyinvalidParams. - It exposes
isProblem(err, 'some-code'). The code is the last:-segment ofproblem.type.
isProblem turns "expected" errors into normal answers. In federations.ts:
findFederationContextIdreturnsnullonfederation-context-unknown. That means "not federated", not a failure.findZoneSubscriptionreturnsnullonzone-not-subscribed. That means "not subscribed".
URL builders (federations(), federation(), zones(), zone()) call encodeURIComponent on every id, so a weird id can't break the path.
partners.ts has listPartners, getPartner, registerPartner (POST) and updatePartner (PATCH).
usePartners: the table's data, loaded in two stages
Listing partners is one call. Finding out whether each is federated costs one call per partner, so it is lazy:
sequenceDiagram
participant T as DataTable
participant H as usePartners
participant FM as API
H->>FM: GET /partners
FM-->>H: partners
H-->>T: rows (federation = "loading")
T->>H: onVisibleRows(ids on this page)
loop each id not requested yet
H->>FM: GET /partners/{id}/federations
FM-->>H: contextId or "federation-context-unknown"
H-->>T: badge becomes federated / not-federated / unknown
endDetails:
- Only the rows visible on the current page are checked. A
requestedSet avoids asking twice. - A
generationcounter is bumped on every refresh. A late answer from an older generation is ignored, so stale data can't overwrite fresh data. - A failed check shows
unknown. It does not break the page. refresh()re-runs the load without blanking the table (refreshingis separate fromloading).
usePartnerFederation(partnerId, { zones, partner }): one partner's federation
It loads the federation context and its details. With zones: true it also checks the subscription status of each offered zone. It returns create, remove, subscribe and unsubscribe, and each action reloads afterwards, so the UI always shows server truth rather than guesses.
The cancelled flag in each useEffect stops a closed dialog from setting state.
useSingleFlight: no double-click bugs
useSingleFlight.ts wraps an async action. If it is already running, a second call is ignored. It is used on every submit and confirm button, so a double-click can't create two federations.
The UI
FederationPage is the only stateful orchestrator. It holds which partner is selected for each dialog and renders:
flowchart TD
PAGE[FederationPage] --> TABLE[DataTable of partners]
TABLE -->|row menu: Details| D1[PartnerDetailsDialog]
TABLE -->|Federation| D2[PartnerFederationDialog]
TABLE -->|Zones<br/>only if federated| D3[PartnerZonesDialog]
TABLE -->|Edit| D4[PartnerFormDialog edit]
PAGE -->|Register partner button| D5[PartnerFormDialog create]
D2 --> D2a[CreateFederationDialog]
D2 --> D2b[ConfirmActionDialog: delete]
D3 --> ZL[ZoneList]
ZL --> D3a[ConfirmActionDialog: unsubscribe]| Component | What it does |
|---|---|
partner-columns.tsx |
Table columns: Name (click opens details, searchable by name or MCC-MNC), Registration badge, Federation badge, EWBI endpoint, actions menu. The Zones action only appears when federation === 'federated'. |
PartnerFormDialog |
Register and edit form. On edit it sends only changed fields (PATCH). |
PartnerFederationDialog |
Shows the federation details. It offers Create federation only when the partner is active, otherwise it explains why not. It also offers Delete federation. |
CreateFederationDialog |
Asks for initialDate (defaults to now) and partnerStatusLink. |
PartnerZonesDialog / ZoneList |
Lists the offered zones as Offered or Subscribed, with the reserved CPU, memory and disk, and Subscribe/Unsubscribe buttons. |
*-badge.tsx, detail-fields.tsx |
Small display helpers. |
The Federation page reloads the table (refresh) when the federation dialog closes after a change.
Access control: operators only
- The sidebar item (
Handshakeicon, in navigation.ts) hasroles: ['operator']. The newcanSeeNavItem()hides it from other users. - The route in router.tsx is wrapped in the existing
RoleGuard, so typing the URL by hand sends non-operators to Forbidden. - API catalogue: the federation endpoints are in the OEG OpenAPI spec.
getApis(includeFederation)andgetApi(id, includeFederation)in apis.ts now hide them from non-operators.useAPIspassesisOperatorfromuseAuth().getApinow returnsnullinstead of throwing when an API is not found.
The OEG base URL also changed from vwip to v0.1alpha1, in api.ts and in tests/unit/test_oeg_proxy.py. This matches the docs rename in the architecture repo.
Shared components added or refactored
ConfirmActionDialog (confirm-action-dialog.tsx)
- It replaces the three near-identical dialogs
confirm-delete-app/deployment/instance-dialog, which are deleted. - Its props are
title,description,confirmLabel,pendingLabel,failureMessageandonConfirm. - It shows a pending state, shows the error inside the dialog, and can't be closed while pending.
- It is used for delete federation, unsubscribe zone, and the existing app/deployment/instance deletes.
DataTable (data-table.tsx)
- It replaces the per-feature table components (
app-*-table.tsxandapp-*-toolbar.tsx), about 1,000 lines of near-copies. - It is built on TanStack Table and gives you:
- name search
- sorting
- pagination (10 per page)
- column show/hide
- drag-and-drop row reordering (
@dnd-kit) - a refresh button
- an optional
filtersslot, which Federation uses for the Registration filter - an
onVisibleRowscallback, whichusePartnersuses to load federation states lazily
- Applications (profiles, instances, deployments) and Federation all use it now, so they look and behave alike.
actionsColumnandRowActionsMenugive every table the same "⋯" menu. Items can befalse, which hides them, and can addseparatorBefore.
return-focus.ts: when a dialog closes, keyboard focus returns to the element that opened it (or to a fallback). It also works with nested dialogs.
Cleanup
- Deleted dead code:
api-columns.tsx,api-search.tsx,api-details.css,catalogue/column.tsx,table-columns.tsx,useAppInstances.ts, unused types and utils, and debug logs. - Removed the unused deps
next-themes,eslint-config-prettierandeslint-plugin-react. Renamed the package tooop-portal. - Normalised file names:
app-profile-columns .tsx(it had a space) becameapp-profile-columns.tsx,dragable-rowbecamedraggable-row, anduse-mobileanduse-sidebarbecameuseIsMobileanduseSidebar. useApplicationsanduseDeploymentswere reworked to match the loading/error/refresh pattern used byusePartners.
Suggested review order
types/federation.tsto learn the data.services/federation/*to see what the API offers.hooks/usePartners.tsandusePartnerFederation.ts, the core logic.features/federation/federation.tsx, then the dialogs.data-table/*andconfirm-action-dialog.tsx, the shared refactors.- Skim the cleanup.
How to try it
- Log in as a user with the
operatorrole. The Federation item appears in the sidebar. A non-operator doesn't see it, and/federationshows Forbidden. - Click Register partner. For
activestatus, fill in all three outbound credentials. - Open Federation from the row menu and click Create federation.
- When the badge turns Federated, open Zones and subscribe or unsubscribe.
- Check Applications, Instances and Deployments still list, filter and delete as before.