Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 83 additions & 6 deletions src/admin-ui/README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,87 @@
# admin-ui

This package provides the admin UI for Kloak.
This package provides the HTTP API server for Kloak deployments.

Responsibilities:
## Responsibilities

- show deployments, versions, drift, and reconciliation history
- guide provisioning and rollout flows
- surface repair failures and exceptional actions
- keep the workflow focused and opinionated
- Expose core deployment operations (create, list, status updates)
- Route HTTP requests to CoreService handlers
- Serialize/deserialize deployments to/from JSON
- Handle request validation and error responses

## Starting the Server

```bash
node --experimental-transform-types src/admin-ui/cli.ts
```

Set port and host via environment variables:
```bash
PORT=3000 HOST=localhost node --experimental-transform-types src/admin-ui/cli.ts
```

## HTTP API

### POST /deployments
Create a new deployment.

**Request:**
```json
{
"customerId": "acme",
"name": "production",
"target": {
"createDatabase": true,
"createIngress": true,
"createDns": true,
"createSecrets": true
}
Comment on lines +33 to +38
}
```

**Response:** `201 Created` with deployment object.

### GET /deployments
List all deployments.

**Response:** `200 OK` with array of deployments.

### GET /deployments/:id
Get a specific deployment.

**Response:** `200 OK` with deployment object, or `404 Not Found`.

### POST /deployments/:id/versions
Create a new deployment version.

**Request:**
```json
{
"createdBy": "alice@example.com",
"notes": "Update auth settings",
"snapshot": {
"keycloakConfig": {...},
"authProviders": [...]
}
Comment on lines +62 to +65
}
```

**Response:** `201 Created` with version object.

### PUT /deployments/:id/status
Update deployment status.

**Request:**
```json
{
"status": "healthy"
}
```

**Response:** `200 OK` with updated deployment object.

## Architecture

- `handler.ts`: HTTP request routing and CoreApi bridging
- `server.ts`: Node HTTP server wrapper with route matching
- `cli.ts`: Command-line entry point
10 changes: 10 additions & 0 deletions src/admin-ui/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env node
import {startServer} from './server.ts';

const port = parseInt(process.env.PORT || '3000', 10);
const host = process.env.HOST || 'localhost';
Comment on lines +4 to +5

startServer({port, host}).catch((error) => {
console.error('Failed to start server:', error);
process.exit(1);
});
115 changes: 115 additions & 0 deletions src/admin-ui/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type {Server} from 'node:http';
import {createServer} from 'node:http';

import {CoreService} from '../core/index.ts';
import {InMemoryDeploymentRepository} from '../core/index.ts';
import type {HttpMethod, HttpRequest, HttpResponse} from './handler.ts';
import {AdminHandler} from './handler.ts';

export interface ServerConfig {
readonly port: number;
readonly host?: string;
}

function matchRoute(path: string, pattern: string): Record<string, string>|null {
const patternSegments = pattern.split('/');
const pathSegments = path.split('/');
if (patternSegments.length !== pathSegments.length) return null;

Comment on lines +14 to +18
const params: Record<string, string> = {};
for (let i = 0; i < patternSegments.length; i++) {
const patternSeg = patternSegments[i];
if (patternSeg.startsWith(':')) {
params[patternSeg.slice(1)] = pathSegments[i];
} else if (patternSeg !== pathSegments[i]) {
return null;
}
}
return params;
}

function findRoutePattern(path: string): string|null {
const patterns = [
'/deployments/:id/versions',
'/deployments/:id/status',
'/deployments/:id',
'/deployments',
];

for (const pattern of patterns) {
if (matchRoute(path, pattern)) {
return pattern;
}
}
return null;
}

async function readBody(req: NodeJS.ReadableStream): Promise<unknown> {
return new Promise((resolve, reject) => {
let data = '';
req.on('data', (chunk) => {
data += chunk.toString();
});
req.on('end', () => {
if (!data) {
resolve(undefined);
return;
}
try {
resolve(JSON.parse(data));
} catch (e) {
reject(new Error('Invalid JSON'));
}
});
Comment on lines +49 to +63
req.on('error', reject);
});
}

export async function startServer(config: ServerConfig): Promise<Server> {
const repository = new InMemoryDeploymentRepository();
const core = new CoreService(repository);
const handler = new AdminHandler(core);

Comment on lines +68 to +72
const server = createServer(async (req, res) => {
try {
const method = (req.method || 'GET') as HttpMethod;
const pathname = new URL(req.url || '/', `http://${req.headers.host}`).pathname;

Comment on lines +75 to +77
const routePattern = findRoutePattern(pathname);
if (!routePattern) {
res.writeHead(404, {'Content-Type': 'application/json'});
res.end(JSON.stringify({error: 'Not found'}));
return;
}

const params = matchRoute(pathname, routePattern);
const body = method === 'GET' ? undefined : await readBody(req);

const httpRequest: HttpRequest = {
method,
path: routePattern,
params: params || {},
body,
};

const httpResponse = await handler.handle(httpRequest);

const headers: Record<string, string> = {'Content-Type': 'application/json'};
res.writeHead(httpResponse.status, headers);
res.end(JSON.stringify(httpResponse.body));
} catch (error) {
console.error('Handler error:', error);
res.writeHead(500, {'Content-Type': 'application/json'});
res.end(JSON.stringify({error: 'Internal server error'}));
}
Comment on lines +100 to +104
});

return new Promise((resolve, reject) => {
const host = config.host || 'localhost';
server.listen(config.port, host, () => {
console.log(`Server listening on http://${host}:${config.port}`);
Comment on lines +108 to +110
resolve(server);
});
server.on('error', reject);
});
}