|
| 1 | +# Aurora Dashboard – Architecture Overview |
| 2 | + |
| 3 | + |
| 4 | + |
| 5 | +## Introduction |
| 6 | + |
| 7 | +Aurora Dashboard is an alternative OpenStack user dashboard, inspired by the [SAP Cloud Infrastructure Elektra dashboard](https://github.com/SAP-cloud-infrastructure/elektra). Its focus is on allowing fine-grained control and self-service for end users as well as integrating interfaces for related non-OpenStack services. It is designed to be usable with a vanilla OpenStack installation but offers extensibility and configurability for specific custom usecases. |
| 8 | + |
| 9 | +Aurora Dashboard is designed to be fast, modular, and developer-friendly, offering a clean and type-safe experience across the stack. By following a Backend-for-Frontend (BFF) architecture, we abstract away OpenStack complexity and deliver a UI that is decoupled, composable, and scalable. |
| 10 | + |
| 11 | +This document describes the architecture, conventions, technologies, and implementation strategies used to build and maintain the Aurora Dashboard. |
| 12 | + |
| 13 | +--- |
| 14 | + |
| 15 | +## Tech Stack and Tooling |
| 16 | + |
| 17 | +| Area | Tooling | |
| 18 | +| ----------------- | ----------------------------------- | |
| 19 | +| UI Framework | React 19, juno | |
| 20 | +| Styling | Tailwind CSS v4 | |
| 21 | +| Routing | TanStack Router (v1) | |
| 22 | +| API Communication | tRPC + Zod | |
| 23 | +| Server Runtime | Fastify | |
| 24 | +| i18n | Lingui | |
| 25 | +| Dev Tools | Vite, Vitest, pnpm, TurboRepo | |
| 26 | +| Code Quality | ESLint, Husky, Conventional Commits | |
| 27 | + |
| 28 | +--- |
| 29 | + |
| 30 | +## Monorepo Structure |
| 31 | + |
| 32 | +Aurora uses a pnpm-based monorepo managed by TurboRepo. Shared tooling is centralized, and apps and packages are clearly separated. |
| 33 | + |
| 34 | +``` |
| 35 | +/aurora |
| 36 | + /apps |
| 37 | + /aurora-portal # Main frontend application |
| 38 | + /packages |
| 39 | + /aurora-sdk # tRPC client wrapper |
| 40 | + /signal-openstack # OpenStack integration layer |
| 41 | + /config # Shared tsconfig, eslint, tailwind config |
| 42 | + /template # Internal package scaffold template |
| 43 | +``` |
| 44 | + |
| 45 | +--- |
| 46 | + |
| 47 | +## Frontend Routing and State – TanStack Router |
| 48 | + |
| 49 | +We use TanStack Router v1 with file-based routing to build our UI declaratively and with type safety at every level. |
| 50 | + |
| 51 | +### File-based Routing Conventions |
| 52 | + |
| 53 | +To maximize DX and enable type-safe navigation: |
| 54 | + |
| 55 | +- All route files for dynamic segments use a $ prefix, e.g. /$projectId.tsx |
| 56 | +- UI subfolders that shouldn’t become routes (e.g., components) are prefixed with -, e.g. `-components/` |
| 57 | +- Each route export `component`, `loader`, and optionally `beforeLoad` |
| 58 | +- Internal folders like `-components` are excluded from route generation |
| 59 | + |
| 60 | +Example route tree: |
| 61 | + |
| 62 | +``` |
| 63 | +routes/ |
| 64 | + _auth/ |
| 65 | + accounts/ |
| 66 | + $accountId/ |
| 67 | + projects/ |
| 68 | + $projectId/ |
| 69 | + compute/$ |
| 70 | + network.tsx |
| 71 | + -components/ |
| 72 | + ProjectSubNavigation.tsx |
| 73 | +``` |
| 74 | + |
| 75 | +### Shared Layout and Auth Guarding |
| 76 | + |
| 77 | +The `_auth` route is a shared layout for all authenticated views. It uses `beforeLoad` to check or hydrate session state: |
| 78 | + |
| 79 | +```ts |
| 80 | +export const Route = createFileRoute("/_auth")({ |
| 81 | + component: RouteComponent, |
| 82 | + beforeLoad: async ({ context, location }) => { |
| 83 | + if (!context.auth?.isAuthenticated) { |
| 84 | + const token = await context.trpcClient?.auth.getCurrentUserSession.query() |
| 85 | + if (!token) { |
| 86 | + throw redirect({ to: "/auth/login", search: { redirect: location.href } }) |
| 87 | + } |
| 88 | + context.auth?.login(token.user, token.expires_at) |
| 89 | + } |
| 90 | + }, |
| 91 | +}) |
| 92 | +``` |
| 93 | + |
| 94 | +This makes sure no child route loads without a scoped session. |
| 95 | + |
| 96 | +### Rescoping Tokens in Loaders |
| 97 | + |
| 98 | +OpenStack APIs require project- or domain-scoped tokens. These are set via tRPC mutations in route loaders: |
| 99 | + |
| 100 | +```ts |
| 101 | +const data = await context.trpcClient?.auth.setCurrentScope.mutate({ |
| 102 | + type: "project", |
| 103 | + projectId: params.projectId || "", |
| 104 | +}) |
| 105 | +``` |
| 106 | + |
| 107 | +Components do not perform data fetching. They receive fully scoped data via loader return values. |
| 108 | + |
| 109 | +### Declarative Subnavigation |
| 110 | + |
| 111 | +Subnavigation is constructed declaratively using `linkOptions` and `useParams`, avoiding manual URL construction: |
| 112 | + |
| 113 | +```tsx |
| 114 | +const options = [ |
| 115 | + linkOptions({ |
| 116 | + to: "/accounts/$accountId/projects/$projectId/compute/$", |
| 117 | + label: "Compute", |
| 118 | + params: { accountId: "accountId", projectId: "projectId" }, |
| 119 | + }), |
| 120 | +] |
| 121 | + |
| 122 | +export function ProjectSubNavigation() { |
| 123 | + const params = useParams({ from: "/_auth/accounts/$accountId/projects/$projectId" }) |
| 124 | + return <SubNavigationLayout options={options} params={params} /> |
| 125 | +} |
| 126 | +``` |
| 127 | + |
| 128 | +Benefits: |
| 129 | + |
| 130 | +- No manual path building |
| 131 | +- Automatic active states |
| 132 | +- Purely param-driven context |
| 133 | + |
| 134 | +Subnavigation is active-aware and context-dependent without additional logic. |
| 135 | + |
| 136 | +--- |
| 137 | + |
| 138 | +## Server-Side Architecture – BFF with Fastify and tRPC |
| 139 | + |
| 140 | +Aurora uses a Backend-for-Frontend (BFF) pattern implemented with Fastify. All API interactions go through tRPC routes, which internally communicate with OpenStack services using a unified abstraction layer (`signal-openstack`). |
| 141 | + |
| 142 | +### Benefits of BFF |
| 143 | + |
| 144 | +- UI remains clean and stateless |
| 145 | +- All API contracts are validated and typed |
| 146 | +- OpenStack logic is fully encapsulated |
| 147 | +- Enables token scoping, retry logic, and API stitching |
| 148 | + |
| 149 | +### Folder Structure |
| 150 | + |
| 151 | +Each domain is in a PascalCase folder (e.g., Compute, Network) and contains: |
| 152 | + |
| 153 | +``` |
| 154 | +Compute/ |
| 155 | + routers/ |
| 156 | + keypair.ts |
| 157 | + types/ |
| 158 | + keypair.ts |
| 159 | + tests/ |
| 160 | + keypair.test.ts |
| 161 | +``` |
| 162 | + |
| 163 | +### Example: Keypair Domain |
| 164 | + |
| 165 | +#### Zod Schema |
| 166 | + |
| 167 | +```ts |
| 168 | +export const keypairSchema = z.object({ |
| 169 | + name: z.string(), |
| 170 | + public_key: z.string().optional(), |
| 171 | + fingerprint: z.string().optional(), |
| 172 | + type: z.union([z.literal("ssh"), z.literal("x509"), z.string()]).optional(), |
| 173 | + user_id: z.string().optional().nullable(), |
| 174 | + created_at: z.string().optional(), |
| 175 | + deleted: z.boolean().optional(), |
| 176 | + deleted_at: z.string().optional().nullable(), |
| 177 | + updated_at: z.string().optional().nullable(), |
| 178 | + id: z.number().optional(), |
| 179 | + private_key: z.string().optional().nullable(), |
| 180 | +}) |
| 181 | +``` |
| 182 | + |
| 183 | +#### tRPC Router |
| 184 | + |
| 185 | +```ts |
| 186 | +getKeypairsByProjectId: protectedProcedure.input(z.object({ projectId: z.string() })).query(async ({ input, ctx }) => { |
| 187 | + const session = await ctx.rescopeSession({ projectId: input.projectId }) |
| 188 | + const compute = session?.service("compute") |
| 189 | + const response = await compute?.get("os-keypairs").then((r) => r.json()) |
| 190 | + const parsed = keypairsResponseSchema.safeParse(response) |
| 191 | + return parsed.success ? parsed.data.keypairs.map((k) => k.keypair) : undefined |
| 192 | +}) |
| 193 | +``` |
| 194 | + |
| 195 | +#### Vitest Tests |
| 196 | + |
| 197 | +```ts |
| 198 | +describe("OpenStack Keypair Schema", () => { |
| 199 | + it("validates correct keypair", () => { |
| 200 | + const result = keypairSchema.safeParse({ name: "my-key" }) |
| 201 | + expect(result.success).toBe(true) |
| 202 | + }) |
| 203 | + |
| 204 | + it("fails on missing name", () => { |
| 205 | + const result = keypairSchema.safeParse({}) |
| 206 | + expect(result.success).toBe(false) |
| 207 | + }) |
| 208 | +}) |
| 209 | +``` |
| 210 | + |
| 211 | +--- |
| 212 | + |
| 213 | +## Testing Strategy |
| 214 | + |
| 215 | +### Lingui + Vitest Setup |
| 216 | + |
| 217 | +All component tests use `I18nProvider` english locale is default if not language is provided and activate language context via `act()`. |
| 218 | + |
| 219 | +```tsx |
| 220 | +import { render, act } from "@testing-library/react" |
| 221 | +import { I18nProvider } from "@lingui/react" |
| 222 | +import { i18n } from "@lingui/core" |
| 223 | +import App from "./App" |
| 224 | + |
| 225 | +const TestingProvider = ({ children }) => <I18nProvider i18n={i18n}>{children}</I18nProvider> |
| 226 | + |
| 227 | +test("should translate to German", async () => { |
| 228 | + await act(() => i18n.activate("de")) |
| 229 | + const { findByText } = render(<App />, { wrapper: TestingProvider }) |
| 230 | + expect(await findByText("Willkommen beim Aurora-Dashboard")).toBeInTheDocument() |
| 231 | +}) |
| 232 | +``` |
| 233 | + |
| 234 | +--- |
| 235 | + |
| 236 | +## Infrastructure Context: Gardener |
| 237 | + |
| 238 | +Aurora primarily targets OpenStack APIs, but we also integrate with **Gardener**, a Kubernetes-native service management platform. Gardener provides a standardized Kubernetes API abstraction for managing clusters and infrastructure services, which fits well into our model for domain-scoped control and tRPC-driven orchestration. |
| 239 | + |
| 240 | +--- |
| 241 | + |
| 242 | +## Release Process – Semantic Release |
| 243 | + |
| 244 | +We use **Semantic Release** to automate our versioning, changelog creation, and release tagging based on commit history. |
| 245 | + |
| 246 | +### How It Works |
| 247 | + |
| 248 | +Our release process has two main steps: |
| 249 | + |
| 250 | +1. **Release Stage** |
| 251 | + Triggered by the `release` command. It runs Semantic Release, which: |
| 252 | + |
| 253 | + - Analyzes commit messages using the **Conventional Commits** format |
| 254 | + - Decides the next version (major, minor, or patch) |
| 255 | + - Generates a changelog automatically |
| 256 | + - Tags the release in Git |
| 257 | + |
| 258 | +2. **Promotion Stage** |
| 259 | + Triggered manually via a `promote` command. It: |
| 260 | + - Selects the Git tag created during the release |
| 261 | + - Deploys it to production |
| 262 | + |
| 263 | +### Why We Use It |
| 264 | + |
| 265 | +- No manual versioning – everything is based on commits |
| 266 | +- Automatic changelog generation |
| 267 | +- Clear and traceable releases |
| 268 | +- Safer, controlled production deployment |
| 269 | + |
| 270 | +This setup reduces manual work and keeps our releases consistent and predictable. |
| 271 | + |
| 272 | + |
| 273 | +## Summary |
| 274 | + |
| 275 | +Aurora Dashboard provides a robust, modern frontend and backend architecture with full type safety, modular structure, and OpenStack abstraction. Its key strengths include: |
| 276 | + |
| 277 | +- File-based, loader-driven frontend with TanStack Router |
| 278 | +- Stateless and clean UI powered by scoped route context |
| 279 | +- Fully decoupled BFF server using Fastify, tRPC, and Zod |
| 280 | +- Declarative subnavigation and scoped auth token logic |
| 281 | +- Testable domain modules and schema validation |
| 282 | +- Support for hybrid backends including OpenStack and Gardener |
| 283 | + |
| 284 | +Aurora is built to scale, to onboard developers quickly, and to support future cloud platforms with minimal coupling. |
0 commit comments