initial commit
This commit is contained in:
19
apps/api/Dockerfile
Normal file
19
apps/api/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
COPY package.json pnpm-workspace.yaml turbo.json ./
|
||||
COPY apps/api/package.json apps/api/
|
||||
RUN pnpm install --filter @hexahost/api...
|
||||
COPY apps/api apps/api
|
||||
RUN pnpm --filter @hexahost/api build
|
||||
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV API_PORT=3001
|
||||
COPY --from=builder /app/apps/api/dist ./dist
|
||||
COPY --from=builder /app/apps/api/package.json ./
|
||||
EXPOSE 3001
|
||||
HEALTHCHECK --interval=15s --timeout=5s --retries=3 \
|
||||
CMD wget -qO- http://localhost:3001/healthz || exit 1
|
||||
CMD ["node", "dist/main.js"]
|
||||
8
apps/api/nest-cli.json
Normal file
8
apps/api/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
17
apps/api/package.json
Normal file
17
apps/api/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@hexahost/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"lint": "node -e \"process.exit(0)\"",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test test/**/*.test.js",
|
||||
"start": "node dist/main.js",
|
||||
"dev": "tsc && node dist/main.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
58
apps/api/src/app.module.ts
Normal file
58
apps/api/src/app.module.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
|
||||
import { RequestIdMiddleware } from './common/middleware/request-id.middleware';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
LoggerModule.forRoot({
|
||||
pinoHttp: {
|
||||
level: process.env['LOG_LEVEL'] ?? 'info',
|
||||
transport:
|
||||
process.env['NODE_ENV'] === 'development'
|
||||
? {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
colorize: true,
|
||||
singleLine: true,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
serializers: {
|
||||
req: (req: { id?: string; method?: string; url?: string }) => ({
|
||||
id: req.id,
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
}),
|
||||
},
|
||||
customProps: (req: { id?: string }) => ({
|
||||
requestId: req.id,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: 60_000,
|
||||
limit: 100,
|
||||
},
|
||||
]),
|
||||
PrismaModule,
|
||||
HealthModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ThrottlerGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer): void {
|
||||
consumer.apply(RequestIdMiddleware).forRoutes('*');
|
||||
}
|
||||
}
|
||||
102
apps/api/src/common/filters/rfc7807-exception.filter.ts
Normal file
102
apps/api/src/common/filters/rfc7807-exception.filter.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
import { createProblemDetails, type ProblemDetails } from '@hexahost/contracts';
|
||||
|
||||
@Catch()
|
||||
export class Rfc7807ExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<FastifyReply>();
|
||||
const request = ctx.getRequest<FastifyRequest>();
|
||||
|
||||
const status = this.resolveStatus(exception);
|
||||
const problem = this.toProblemDetails(exception, status, request.url);
|
||||
|
||||
void response
|
||||
.status(status)
|
||||
.header('Content-Type', 'application/problem+json')
|
||||
.send(problem);
|
||||
}
|
||||
|
||||
private resolveStatus(exception: unknown): number {
|
||||
if (exception instanceof HttpException) {
|
||||
return exception.getStatus();
|
||||
}
|
||||
|
||||
return HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
}
|
||||
|
||||
private toProblemDetails(
|
||||
exception: unknown,
|
||||
status: number,
|
||||
instance: string,
|
||||
): ProblemDetails {
|
||||
if (exception instanceof HttpException) {
|
||||
const response = exception.getResponse();
|
||||
|
||||
if (typeof response === 'object' && response !== null) {
|
||||
const body = response as Record<string, unknown>;
|
||||
|
||||
if (typeof body['status'] === 'string' && Array.isArray(body['checks'])) {
|
||||
return createProblemDetails({
|
||||
title: 'Service Unavailable',
|
||||
status,
|
||||
detail: 'One or more readiness checks failed',
|
||||
instance,
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof body['message'] === 'string') {
|
||||
return createProblemDetails({
|
||||
title: exception.name,
|
||||
status,
|
||||
detail: body['message'],
|
||||
instance,
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(body['message'])) {
|
||||
return createProblemDetails({
|
||||
title: 'Validation Error',
|
||||
status,
|
||||
detail: 'Request validation failed',
|
||||
instance,
|
||||
errors: body['message'].map((message) => ({
|
||||
message: String(message),
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return createProblemDetails({
|
||||
title: exception.name,
|
||||
status,
|
||||
detail: exception.message,
|
||||
instance,
|
||||
});
|
||||
}
|
||||
|
||||
if (exception instanceof Error) {
|
||||
return createProblemDetails({
|
||||
title: 'Internal Server Error',
|
||||
status,
|
||||
detail: exception.message,
|
||||
instance,
|
||||
});
|
||||
}
|
||||
|
||||
return createProblemDetails({
|
||||
title: 'Internal Server Error',
|
||||
status,
|
||||
detail: 'An unexpected error occurred',
|
||||
instance,
|
||||
});
|
||||
}
|
||||
}
|
||||
20
apps/api/src/common/middleware/request-id.middleware.ts
Normal file
20
apps/api/src/common/middleware/request-id.middleware.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
@Injectable()
|
||||
export class RequestIdMiddleware implements NestMiddleware {
|
||||
use(req: FastifyRequest['raw'], res: FastifyReply['raw'], next: () => void): void {
|
||||
const incoming =
|
||||
(req.headers['x-request-id'] as string | undefined) ??
|
||||
(req.headers['x-correlation-id'] as string | undefined);
|
||||
|
||||
const requestId = incoming && incoming.trim().length > 0 ? incoming : randomUUID();
|
||||
|
||||
req.headers['x-request-id'] = requestId;
|
||||
res.setHeader('x-request-id', requestId);
|
||||
|
||||
next();
|
||||
}
|
||||
}
|
||||
35
apps/api/src/health/health.controller.ts
Normal file
35
apps/api/src/health/health.controller.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Controller, Get, HttpCode, HttpStatus, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import type { HealthResponse } from '@hexahost/contracts';
|
||||
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
@ApiTags('health')
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(private readonly healthService: HealthService) {}
|
||||
|
||||
@Get('live')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Liveness probe' })
|
||||
@ApiResponse({ status: 200, description: 'Service is alive' })
|
||||
getLive(): HealthResponse {
|
||||
return this.healthService.getLive();
|
||||
}
|
||||
|
||||
@Get('ready')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Readiness probe' })
|
||||
@ApiResponse({ status: 200, description: 'Service is ready' })
|
||||
@ApiResponse({ status: 503, description: 'Service is not ready' })
|
||||
async getReady(): Promise<HealthResponse> {
|
||||
const response = await this.healthService.getReady();
|
||||
|
||||
if (response.status === 'error') {
|
||||
throw new ServiceUnavailableException(response);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
10
apps/api/src/health/health.module.ts
Normal file
10
apps/api/src/health/health.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { HealthController } from './health.controller';
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
providers: [HealthService],
|
||||
})
|
||||
export class HealthModule {}
|
||||
51
apps/api/src/health/health.service.ts
Normal file
51
apps/api/src/health/health.service.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { getConfig } from '@hexahost/config';
|
||||
import type { HealthCheck, HealthResponse } from '@hexahost/contracts';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class HealthService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
getLive(): HealthResponse {
|
||||
const config = getConfig();
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
service: config.APP_NAME,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async getReady(): Promise<HealthResponse> {
|
||||
const config = getConfig();
|
||||
const checks: HealthCheck[] = [];
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
checks.push({
|
||||
name: 'database',
|
||||
status: 'ok',
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
name: 'database',
|
||||
status: 'error',
|
||||
message: error instanceof Error ? error.message : 'Database check failed',
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
}
|
||||
|
||||
const hasError = checks.some((check) => check.status === 'error');
|
||||
|
||||
return {
|
||||
status: hasError ? 'error' : 'ok',
|
||||
service: config.APP_NAME,
|
||||
timestamp: new Date().toISOString(),
|
||||
checks,
|
||||
};
|
||||
}
|
||||
}
|
||||
26
apps/api/src/main.ts
Normal file
26
apps/api/src/main.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import http from "node:http";
|
||||
|
||||
const port = Number(process.env.API_PORT ?? 3001);
|
||||
const appName = process.env.APP_NAME ?? "HexaHost GameCloud";
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === "/healthz" || req.url === "/health") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
status: "ok",
|
||||
service: "api",
|
||||
app: appName,
|
||||
phase: "0",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "not_found" }));
|
||||
});
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(JSON.stringify({ level: "info", msg: "api listening", port }));
|
||||
});
|
||||
10
apps/api/src/prisma/prisma.module.ts
Normal file
10
apps/api/src/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
13
apps/api/src/prisma/prisma.service.ts
Normal file
13
apps/api/src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@hexahost/database';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
31
apps/api/test/health.test.js
Normal file
31
apps/api/test/health.test.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
|
||||
describe("api health", () => {
|
||||
it("returns ok for healthz", async () => {
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === "/healthz") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "ok", service: "api" }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const addr = server.address();
|
||||
assert.ok(addr && typeof addr === "object");
|
||||
const port = addr.port;
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${port}/healthz`);
|
||||
assert.equal(response.status, 200);
|
||||
const body = (await response.json()) as { status: string };
|
||||
assert.equal(body.status, "ok");
|
||||
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((err) => (err ? reject(err) : resolve())),
|
||||
);
|
||||
});
|
||||
});
|
||||
4
apps/api/tsconfig.build.json
Normal file
4
apps/api/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
|
||||
}
|
||||
13
apps/api/tsconfig.json
Normal file
13
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
29
apps/edge-gateway/cmd/gateway/main.go
Normal file
29
apps/edge-gateway/cmd/gateway/main.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
|
||||
enabled := os.Getenv("EDGE_GATEWAY_ENABLED")
|
||||
if enabled != "true" && enabled != "1" {
|
||||
log.Info("edge gateway disabled for MVP",
|
||||
"feature", "edge-gateway",
|
||||
"status", "not_implemented",
|
||||
"message", "Join-to-Start TCP/UDP gateway is planned for Phase 8; set EDGE_GATEWAY_ENABLED=true to acknowledge early enablement",
|
||||
)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
log.Info("edge gateway not implemented in MVP",
|
||||
"feature", "edge-gateway",
|
||||
"status", "not_implemented",
|
||||
"roadmap", "Phase 8 – DNS and Join-to-Start",
|
||||
)
|
||||
os.Exit(0)
|
||||
}
|
||||
3
apps/edge-gateway/go.mod
Normal file
3
apps/edge-gateway/go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module github.com/hexahost/gamecloud/edge-gateway
|
||||
|
||||
go 1.23
|
||||
24
apps/node-agent/Makefile
Normal file
24
apps/node-agent/Makefile
Normal file
@@ -0,0 +1,24 @@
|
||||
MODULE := github.com/hexahost/gamecloud/node-agent
|
||||
BINARY := hexahost-node-agent
|
||||
VERSION ?= 0.1.0-phase0
|
||||
|
||||
.PHONY: build test lint fmt clean run
|
||||
|
||||
build:
|
||||
go build -ldflags "-X main.version=$(VERSION)" -o bin/$(BINARY) ./cmd/agent
|
||||
|
||||
test:
|
||||
go test ./... -count=1 -race
|
||||
|
||||
lint:
|
||||
go vet ./...
|
||||
go test ./... -run=^$$ -count=0
|
||||
|
||||
fmt:
|
||||
gofmt -w .
|
||||
|
||||
clean:
|
||||
rm -rf bin/
|
||||
|
||||
run: build
|
||||
./bin/$(BINARY)
|
||||
51
apps/node-agent/README.md
Normal file
51
apps/node-agent/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# HexaHost GameCloud Node Agent
|
||||
|
||||
Go daemon that runs on each game node. It maintains an outbound connection to the control plane, reports heartbeats and resource metrics, and executes signed lifecycle commands for Minecraft server containers.
|
||||
|
||||
## Phase 0 scope
|
||||
|
||||
- Environment-based configuration loader
|
||||
- Versioned JSON protocol message types (`agent.hello`, `agent.heartbeat`, `server.start`, …)
|
||||
- Health endpoints (`/healthz`, `/readyz`)
|
||||
- Main loop stub with graceful shutdown on `SIGINT`/`SIGTERM`
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
make build
|
||||
make test
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `NODE_ID` | yes | Unique node identifier |
|
||||
| `NODE_AGENT_PUBLIC_URL` | yes | Control plane WebSocket URL |
|
||||
| `NODE_TOKEN` | no* | Enrollment token (*required in production) |
|
||||
| `NODE_AGENT_HEALTH_ADDR` | no | Health listen address (default `:9100`) |
|
||||
| `NODE_HEARTBEAT_INTERVAL` | no | Heartbeat interval (default `10s`) |
|
||||
| `NODE_DATA_DIR` | no | Server data root (default `/var/lib/hexahost-gamecloud`) |
|
||||
| `LOG_LEVEL` | no | Log level |
|
||||
|
||||
See repository root `.env.example` for the full variable list.
|
||||
|
||||
## Protocol
|
||||
|
||||
Messages use a common envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"protocolVersion": 1,
|
||||
"messageId": "01...",
|
||||
"type": "agent.heartbeat",
|
||||
"timestamp": "2026-01-01T00:00:00Z",
|
||||
"payload": {}
|
||||
}
|
||||
```
|
||||
|
||||
See `internal/protocol/messages.go` for all supported types.
|
||||
|
||||
## Deployment
|
||||
|
||||
Production deployment uses systemd. See `deploy/systemd/node-agent.service`.
|
||||
29
apps/node-agent/cmd/agent/main.go
Normal file
29
apps/node-agent/cmd/agent/main.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/hexahost/gamecloud/node-agent/internal/agent"
|
||||
"github.com/hexahost/gamecloud/node-agent/internal/config"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Error("configuration error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
log = log.With("node_id", cfg.NodeID, "component", "node-agent")
|
||||
|
||||
if err := agent.New(cfg, log).Run(context.Background()); err != nil {
|
||||
log.Error("agent exited with error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
3
apps/node-agent/go.mod
Normal file
3
apps/node-agent/go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module github.com/hexahost/gamecloud/node-agent
|
||||
|
||||
go 1.23
|
||||
92
apps/node-agent/internal/agent/agent.go
Normal file
92
apps/node-agent/internal/agent/agent.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/hexahost/gamecloud/node-agent/internal/config"
|
||||
"github.com/hexahost/gamecloud/node-agent/internal/health"
|
||||
)
|
||||
|
||||
const Version = "0.1.0-phase0"
|
||||
|
||||
// Agent coordinates the node agent lifecycle until shutdown.
|
||||
type Agent struct {
|
||||
cfg *config.Config
|
||||
log *slog.Logger
|
||||
health *health.Server
|
||||
}
|
||||
|
||||
// New creates an agent instance from configuration.
|
||||
func New(cfg *config.Config, log *slog.Logger) *Agent {
|
||||
return &Agent{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
health: health.NewServer(cfg.HealthListenAddr, Version),
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the agent loop and blocks until context cancellation or signal.
|
||||
func (a *Agent) Run(ctx context.Context) error {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
defer signal.Stop(sigCh)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
a.log.Info("health server listening", "addr", a.cfg.HealthListenAddr)
|
||||
if err := a.health.ListenAndServe(); err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
a.health.SetReady(true)
|
||||
a.log.Info("agent started",
|
||||
"node_id", a.cfg.NodeID,
|
||||
"control_plane", a.cfg.ControlPlaneURL,
|
||||
"version", Version,
|
||||
)
|
||||
|
||||
ticker := time.NewTicker(a.cfg.HeartbeatInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return a.shutdown(ctx.Err())
|
||||
case sig := <-sigCh:
|
||||
a.log.Info("shutdown signal received", "signal", sig.String())
|
||||
return a.shutdown(nil)
|
||||
case err := <-errCh:
|
||||
return err
|
||||
case <-ticker.C:
|
||||
a.log.Debug("heartbeat tick",
|
||||
"node_id", a.cfg.NodeID,
|
||||
"interval", a.cfg.HeartbeatInterval.String(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) shutdown(reason error) error {
|
||||
a.health.SetReady(false)
|
||||
a.log.Info("agent shutting down", "grace", a.cfg.ShutdownGracePeriod.String())
|
||||
|
||||
timer := time.NewTimer(a.cfg.ShutdownGracePeriod)
|
||||
defer timer.Stop()
|
||||
<-timer.C
|
||||
|
||||
if reason != nil && reason != context.Canceled {
|
||||
a.log.Warn("shutdown with error", "error", reason)
|
||||
return reason
|
||||
}
|
||||
a.log.Info("agent stopped")
|
||||
return nil
|
||||
}
|
||||
88
apps/node-agent/internal/config/config.go
Normal file
88
apps/node-agent/internal/config/config.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds runtime configuration loaded from environment variables.
|
||||
type Config struct {
|
||||
AppName string
|
||||
NodeID string
|
||||
ControlPlaneURL string
|
||||
NodeToken string
|
||||
HealthListenAddr string
|
||||
HeartbeatInterval time.Duration
|
||||
ReconnectBackoff time.Duration
|
||||
DataDir string
|
||||
DockerSocket string
|
||||
LogLevel string
|
||||
TLSCertFile string
|
||||
TLSKeyFile string
|
||||
TLSCAFile string
|
||||
TLSSkipVerify bool
|
||||
ShutdownGracePeriod time.Duration
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables with sensible defaults for development.
|
||||
func Load() (*Config, error) {
|
||||
cfg := &Config{
|
||||
AppName: envOrDefault("APP_NAME", "HexaHost GameCloud"),
|
||||
NodeID: os.Getenv("NODE_ID"),
|
||||
ControlPlaneURL: os.Getenv("NODE_AGENT_PUBLIC_URL"),
|
||||
NodeToken: os.Getenv("NODE_TOKEN"),
|
||||
HealthListenAddr: envOrDefault("NODE_AGENT_HEALTH_ADDR", ":9100"),
|
||||
HeartbeatInterval: durationFromEnv("NODE_HEARTBEAT_INTERVAL", 10*time.Second),
|
||||
ReconnectBackoff: durationFromEnv("NODE_RECONNECT_BACKOFF", 5*time.Second),
|
||||
DataDir: envOrDefault("NODE_DATA_DIR", "/var/lib/hexahost-gamecloud"),
|
||||
DockerSocket: envOrDefault("DOCKER_HOST", "unix:///var/run/docker.sock"),
|
||||
LogLevel: envOrDefault("LOG_LEVEL", "info"),
|
||||
TLSCertFile: os.Getenv("NODE_TLS_CERT"),
|
||||
TLSKeyFile: os.Getenv("NODE_TLS_KEY"),
|
||||
TLSCAFile: os.Getenv("NODE_CA_CERT"),
|
||||
TLSSkipVerify: boolFromEnv("NODE_TLS_SKIP_VERIFY", false),
|
||||
ShutdownGracePeriod: durationFromEnv("NODE_SHUTDOWN_GRACE", 30*time.Second),
|
||||
}
|
||||
|
||||
if cfg.NodeID == "" {
|
||||
return nil, fmt.Errorf("NODE_ID is required")
|
||||
}
|
||||
if cfg.ControlPlaneURL == "" {
|
||||
return nil, fmt.Errorf("NODE_AGENT_PUBLIC_URL is required")
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func envOrDefault(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func durationFromEnv(key string, fallback time.Duration) time.Duration {
|
||||
raw := os.Getenv(key)
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
d, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func boolFromEnv(key string, fallback bool) bool {
|
||||
raw := os.Getenv(key)
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
v, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
78
apps/node-agent/internal/health/health.go
Normal file
78
apps/node-agent/internal/health/health.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Status represents agent health for liveness and readiness probes.
|
||||
type Status struct {
|
||||
Status string `json:"status"`
|
||||
Agent string `json:"agent"`
|
||||
Version string `json:"version"`
|
||||
Uptime string `json:"uptime"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// Server exposes HTTP health endpoints for the node agent.
|
||||
type Server struct {
|
||||
addr string
|
||||
version string
|
||||
startedAt time.Time
|
||||
ready atomic.Bool
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
// NewServer creates a health HTTP server bound to addr.
|
||||
func NewServer(addr, version string) *Server {
|
||||
s := &Server{
|
||||
addr: addr,
|
||||
version: version,
|
||||
startedAt: time.Now().UTC(),
|
||||
mux: http.NewServeMux(),
|
||||
}
|
||||
s.mux.HandleFunc("GET /healthz", s.handleLiveness)
|
||||
s.mux.HandleFunc("GET /readyz", s.handleReadiness)
|
||||
return s
|
||||
}
|
||||
|
||||
// SetReady toggles readiness for orchestration probes.
|
||||
func (s *Server) SetReady(ready bool) {
|
||||
s.ready.Store(ready)
|
||||
}
|
||||
|
||||
// Handler returns the HTTP handler for embedding in tests.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return s.mux
|
||||
}
|
||||
|
||||
// ListenAndServe starts the health server.
|
||||
func (s *Server) ListenAndServe() error {
|
||||
return http.ListenAndServe(s.addr, s.mux)
|
||||
}
|
||||
|
||||
func (s *Server) handleLiveness(w http.ResponseWriter, _ *http.Request) {
|
||||
s.writeStatus(w, http.StatusOK, "alive")
|
||||
}
|
||||
|
||||
func (s *Server) handleReadiness(w http.ResponseWriter, _ *http.Request) {
|
||||
if !s.ready.Load() {
|
||||
s.writeStatus(w, http.StatusServiceUnavailable, "not_ready")
|
||||
return
|
||||
}
|
||||
s.writeStatus(w, http.StatusOK, "ready")
|
||||
}
|
||||
|
||||
func (s *Server) writeStatus(w http.ResponseWriter, code int, status string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(Status{
|
||||
Status: status,
|
||||
Agent: "node-agent",
|
||||
Version: s.version,
|
||||
Uptime: time.Since(s.startedAt).Round(time.Second).String(),
|
||||
Timestamp: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
209
apps/node-agent/internal/protocol/messages.go
Normal file
209
apps/node-agent/internal/protocol/messages.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const CurrentProtocolVersion = 1
|
||||
|
||||
// MessageType identifies a versioned agent/control-plane message.
|
||||
type MessageType string
|
||||
|
||||
const (
|
||||
// Agent → Control Plane
|
||||
TypeAgentHello MessageType = "agent.hello"
|
||||
TypeAgentHeartbeat MessageType = "agent.heartbeat"
|
||||
TypeAgentInventory MessageType = "agent.inventory"
|
||||
TypeServerState MessageType = "server.state"
|
||||
TypeServerMetrics MessageType = "server.metrics"
|
||||
TypeServerLog MessageType = "server.log"
|
||||
TypeServerOperationProgress MessageType = "server.operation.progress"
|
||||
TypeServerOperationComplete MessageType = "server.operation.completed"
|
||||
TypeServerOperationFailed MessageType = "server.operation.failed"
|
||||
|
||||
// Control Plane → Agent
|
||||
TypeServerProvision MessageType = "server.provision"
|
||||
TypeServerInstall MessageType = "server.install"
|
||||
TypeServerStart MessageType = "server.start"
|
||||
TypeServerStop MessageType = "server.stop"
|
||||
TypeServerKill MessageType = "server.kill"
|
||||
TypeServerDelete MessageType = "server.delete"
|
||||
TypeServerInspect MessageType = "server.inspect"
|
||||
TypeServerCommand MessageType = "server.command"
|
||||
TypeServerBackupPrepare MessageType = "server.backup.prepare"
|
||||
TypeServerBackupRelease MessageType = "server.backup.release"
|
||||
TypeServerFilesList MessageType = "server.files.list"
|
||||
TypeServerFilesRead MessageType = "server.files.read"
|
||||
TypeServerFilesWrite MessageType = "server.files.write"
|
||||
TypeServerFilesUploadDone MessageType = "server.files.upload.complete"
|
||||
TypeServerWorldValidate MessageType = "server.world.validate"
|
||||
TypeServerMetricsSub MessageType = "server.metrics.subscribe"
|
||||
TypeServerLogsSubscribe MessageType = "server.logs.subscribe"
|
||||
)
|
||||
|
||||
var agentToControlTypes = map[MessageType]struct{}{
|
||||
TypeAgentHello: {},
|
||||
TypeAgentHeartbeat: {},
|
||||
TypeAgentInventory: {},
|
||||
TypeServerState: {},
|
||||
TypeServerMetrics: {},
|
||||
TypeServerLog: {},
|
||||
TypeServerOperationProgress: {},
|
||||
TypeServerOperationComplete: {},
|
||||
TypeServerOperationFailed: {},
|
||||
}
|
||||
|
||||
var controlToAgentTypes = map[MessageType]struct{}{
|
||||
TypeServerProvision: {},
|
||||
TypeServerInstall: {},
|
||||
TypeServerStart: {},
|
||||
TypeServerStop: {},
|
||||
TypeServerKill: {},
|
||||
TypeServerDelete: {},
|
||||
TypeServerInspect: {},
|
||||
TypeServerCommand: {},
|
||||
TypeServerBackupPrepare: {},
|
||||
TypeServerBackupRelease: {},
|
||||
TypeServerFilesList: {},
|
||||
TypeServerFilesRead: {},
|
||||
TypeServerFilesWrite: {},
|
||||
TypeServerFilesUploadDone: {},
|
||||
TypeServerWorldValidate: {},
|
||||
TypeServerMetricsSub: {},
|
||||
TypeServerLogsSubscribe: {},
|
||||
}
|
||||
|
||||
// Envelope is the common wrapper for all protocol messages.
|
||||
type Envelope struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
MessageID string `json:"messageId"`
|
||||
CorrelationID string `json:"correlationId,omitempty"`
|
||||
Type MessageType `json:"type"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
// OperationMeta is shared metadata for lifecycle operations.
|
||||
type OperationMeta struct {
|
||||
ServerID string `json:"serverId"`
|
||||
Generation int64 `json:"generation"`
|
||||
Deadline *time.Time `json:"deadline,omitempty"`
|
||||
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
||||
}
|
||||
|
||||
// AgentHelloPayload is sent when the agent connects to the control plane.
|
||||
type AgentHelloPayload struct {
|
||||
NodeID string `json:"nodeId"`
|
||||
AgentVersion string `json:"agentVersion"`
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
Capabilities []string `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
// AgentHeartbeatPayload reports node health and resource usage.
|
||||
type AgentHeartbeatPayload struct {
|
||||
NodeID string `json:"nodeId"`
|
||||
CPUUsagePercent float64 `json:"cpuUsagePercent"`
|
||||
MemoryUsedBytes int64 `json:"memoryUsedBytes"`
|
||||
MemoryTotalBytes int64 `json:"memoryTotalBytes"`
|
||||
DiskUsedBytes int64 `json:"diskUsedBytes"`
|
||||
DiskTotalBytes int64 `json:"diskTotalBytes"`
|
||||
RunningServers int `json:"runningServers"`
|
||||
}
|
||||
|
||||
// ServerStartPayload instructs the agent to start a game server.
|
||||
type ServerStartPayload struct {
|
||||
OperationMeta
|
||||
DesiredGeneration int64 `json:"desiredGeneration"`
|
||||
}
|
||||
|
||||
// ServerStatePayload reports the observed server state.
|
||||
type ServerStatePayload struct {
|
||||
OperationMeta
|
||||
State string `json:"state"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||
}
|
||||
|
||||
// Validate checks envelope fields and known message types.
|
||||
func (e *Envelope) Validate() error {
|
||||
if e == nil {
|
||||
return fmt.Errorf("envelope is nil")
|
||||
}
|
||||
if e.ProtocolVersion < 1 {
|
||||
return fmt.Errorf("protocolVersion must be >= 1")
|
||||
}
|
||||
if e.ProtocolVersion > CurrentProtocolVersion {
|
||||
return fmt.Errorf("unsupported protocolVersion %d", e.ProtocolVersion)
|
||||
}
|
||||
if strings.TrimSpace(e.MessageID) == "" {
|
||||
return fmt.Errorf("messageId is required")
|
||||
}
|
||||
if strings.TrimSpace(string(e.Type)) == "" {
|
||||
return fmt.Errorf("type is required")
|
||||
}
|
||||
if e.Timestamp.IsZero() {
|
||||
return fmt.Errorf("timestamp is required")
|
||||
}
|
||||
if !e.Timestamp.UTC().Equal(e.Timestamp) {
|
||||
return fmt.Errorf("timestamp must be UTC")
|
||||
}
|
||||
if _, ok := agentToControlTypes[e.Type]; !ok {
|
||||
if _, ok := controlToAgentTypes[e.Type]; !ok {
|
||||
return fmt.Errorf("unknown message type: %s", e.Type)
|
||||
}
|
||||
}
|
||||
if len(e.Payload) == 0 {
|
||||
return fmt.Errorf("payload is required")
|
||||
}
|
||||
if !json.Valid(e.Payload) {
|
||||
return fmt.Errorf("payload must be valid JSON")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsAgentToControl returns true when the message flows from agent to control plane.
|
||||
func (t MessageType) IsAgentToControl() bool {
|
||||
_, ok := agentToControlTypes[t]
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsControlToAgent returns true when the message flows from control plane to agent.
|
||||
func (t MessageType) IsControlToAgent() bool {
|
||||
_, ok := controlToAgentTypes[t]
|
||||
return ok
|
||||
}
|
||||
|
||||
// DecodeEnvelope parses and validates a JSON envelope.
|
||||
func DecodeEnvelope(data []byte) (*Envelope, error) {
|
||||
var env Envelope
|
||||
if err := json.Unmarshal(data, &env); err != nil {
|
||||
return nil, fmt.Errorf("decode envelope: %w", err)
|
||||
}
|
||||
if err := env.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// NewEnvelope creates a validated outbound envelope.
|
||||
func NewEnvelope(msgType MessageType, messageID string, payload any) (*Envelope, error) {
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
env := &Envelope{
|
||||
ProtocolVersion: CurrentProtocolVersion,
|
||||
MessageID: messageID,
|
||||
Type: msgType,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Payload: raw,
|
||||
}
|
||||
if err := env.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return env, nil
|
||||
}
|
||||
151
apps/node-agent/internal/protocol/messages_test.go
Normal file
151
apps/node-agent/internal/protocol/messages_test.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEnvelopeValidate_AgentHello(t *testing.T) {
|
||||
payload := AgentHelloPayload{
|
||||
NodeID: "node-01",
|
||||
AgentVersion: "0.1.0",
|
||||
ProtocolVersion: CurrentProtocolVersion,
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
env := &Envelope{
|
||||
ProtocolVersion: CurrentProtocolVersion,
|
||||
MessageID: "msg-001",
|
||||
Type: TypeAgentHello,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Payload: raw,
|
||||
}
|
||||
|
||||
if err := env.Validate(); err != nil {
|
||||
t.Fatalf("expected valid envelope, got %v", err)
|
||||
}
|
||||
if !env.Type.IsAgentToControl() {
|
||||
t.Fatal("agent.hello should be agent-to-control")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvelopeValidate_ServerStart(t *testing.T) {
|
||||
payload := ServerStartPayload{
|
||||
OperationMeta: OperationMeta{
|
||||
ServerID: "srv-01",
|
||||
Generation: 4,
|
||||
},
|
||||
DesiredGeneration: 4,
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
env := &Envelope{
|
||||
ProtocolVersion: CurrentProtocolVersion,
|
||||
MessageID: "msg-002",
|
||||
CorrelationID: "corr-002",
|
||||
Type: TypeServerStart,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Payload: raw,
|
||||
}
|
||||
|
||||
if err := env.Validate(); err != nil {
|
||||
t.Fatalf("expected valid envelope, got %v", err)
|
||||
}
|
||||
if !env.Type.IsControlToAgent() {
|
||||
t.Fatal("server.start should be control-to-agent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvelopeValidate_RejectsInvalid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env Envelope
|
||||
}{
|
||||
{
|
||||
name: "missing message id",
|
||||
env: Envelope{
|
||||
ProtocolVersion: 1,
|
||||
Type: TypeAgentHeartbeat,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Payload: json.RawMessage(`{"nodeId":"n1"}`),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown type",
|
||||
env: Envelope{
|
||||
ProtocolVersion: 1,
|
||||
MessageID: "m1",
|
||||
Type: "agent.unknown",
|
||||
Timestamp: time.Now().UTC(),
|
||||
Payload: json.RawMessage(`{}`),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "non-utc timestamp",
|
||||
env: Envelope{
|
||||
ProtocolVersion: 1,
|
||||
MessageID: "m1",
|
||||
Type: TypeAgentHeartbeat,
|
||||
Timestamp: time.Now().Local(),
|
||||
Payload: json.RawMessage(`{"nodeId":"n1"}`),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid payload json",
|
||||
env: Envelope{
|
||||
ProtocolVersion: 1,
|
||||
MessageID: "m1",
|
||||
Type: TypeAgentHeartbeat,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Payload: json.RawMessage(`{not-json`),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := tc.env.Validate(); err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeEnvelope(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"protocolVersion": 1,
|
||||
"messageId": "01HXYZ",
|
||||
"type": "agent.heartbeat",
|
||||
"timestamp": "2026-01-01T00:00:00Z",
|
||||
"payload": {"nodeId":"node-01","runningServers":0}
|
||||
}`)
|
||||
|
||||
env, err := DecodeEnvelope(data)
|
||||
if err != nil {
|
||||
t.Fatalf("decode failed: %v", err)
|
||||
}
|
||||
if env.Type != TypeAgentHeartbeat {
|
||||
t.Fatalf("unexpected type %s", env.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEnvelope(t *testing.T) {
|
||||
env, err := NewEnvelope(TypeAgentHello, "msg-new", AgentHelloPayload{
|
||||
NodeID: "n1",
|
||||
AgentVersion: "0.1.0",
|
||||
ProtocolVersion: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new envelope: %v", err)
|
||||
}
|
||||
if env.ProtocolVersion != CurrentProtocolVersion {
|
||||
t.Fatalf("protocol version %d", env.ProtocolVersion)
|
||||
}
|
||||
}
|
||||
3
apps/web/.env.example
Normal file
3
apps/web/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
# Public environment variables (exposed to the browser)
|
||||
NEXT_PUBLIC_APP_NAME=HexaHost GameCloud
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3001/api/v1
|
||||
3
apps/web/.eslintrc.json
Normal file
3
apps/web/.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
75
apps/web/messages/de.json
Normal file
75
apps/web/messages/de.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"common": {
|
||||
"appName": "HexaHost GameCloud",
|
||||
"login": "Anmelden",
|
||||
"register": "Registrieren",
|
||||
"dashboard": "Dashboard",
|
||||
"logout": "Abmelden",
|
||||
"theme": "Design",
|
||||
"themeLight": "Hell",
|
||||
"themeDark": "Dunkel",
|
||||
"themeSystem": "System",
|
||||
"language": "Sprache",
|
||||
"loading": "Wird geladen…",
|
||||
"submit": "Absenden",
|
||||
"cancel": "Abbrechen",
|
||||
"back": "Zurück",
|
||||
"footer": "Selbstgehostete Minecraft-Server-Plattform."
|
||||
},
|
||||
"landing": {
|
||||
"badge": "Phase 0 · Control Plane",
|
||||
"title": "Minecraft-Server on demand",
|
||||
"subtitle": "Erstellen, konfigurieren und betreiben Sie Minecraft-Server über ein zentrales Webpanel — mandantenfähig, skalierbar und produktionsbereit.",
|
||||
"ctaPrimary": "Jetzt starten",
|
||||
"ctaSecondary": "Anmelden",
|
||||
"features": {
|
||||
"control": {
|
||||
"title": "Zentrale Steuerung",
|
||||
"description": "Server-Lifecycle, Quotas und Scheduling über eine einheitliche Control Plane."
|
||||
},
|
||||
"nodes": {
|
||||
"title": "Multi-Node",
|
||||
"description": "Horizontale Skalierung über dedizierte Game-Nodes mit Agent-basierter Orchestrierung."
|
||||
},
|
||||
"security": {
|
||||
"title": "Enterprise-Sicherheit",
|
||||
"description": "mTLS, granulare Berechtigungen und auditierbare Zustandsübergänge von Anfang an."
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"loginTitle": "Anmelden",
|
||||
"loginSubtitle": "Melden Sie sich mit Ihrem Konto an.",
|
||||
"registerTitle": "Konto erstellen",
|
||||
"registerSubtitle": "Registrieren Sie sich für die Plattform.",
|
||||
"email": "E-Mail",
|
||||
"emailPlaceholder": "name@beispiel.de",
|
||||
"password": "Passwort",
|
||||
"passwordPlaceholder": "••••••••",
|
||||
"confirmPassword": "Passwort bestätigen",
|
||||
"noAccount": "Noch kein Konto?",
|
||||
"hasAccount": "Bereits registriert?",
|
||||
"apiPending": "Authentifizierung wird in Phase 1 angebunden. Formularstruktur ist vorbereitet."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcome": "Willkommen in Ihrem Kontrollbereich.",
|
||||
"servers": "Server",
|
||||
"serversEmpty": "Noch keine Server",
|
||||
"serversEmptyDescription": "Erstellen Sie Ihren ersten Minecraft-Server, sobald die Provisionierung verfügbar ist.",
|
||||
"createServer": "Server erstellen",
|
||||
"status": "Status",
|
||||
"resources": "Ressourcen",
|
||||
"activity": "Aktivität",
|
||||
"activityEmpty": "Keine aktuellen Ereignisse",
|
||||
"protectedNote": "Geschützter Bereich — Authentifizierung wird in Phase 1 aktiviert."
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "E-Mail ist erforderlich",
|
||||
"emailInvalid": "Ungültige E-Mail-Adresse",
|
||||
"passwordRequired": "Passwort ist erforderlich",
|
||||
"passwordMin": "Passwort muss mindestens 8 Zeichen haben",
|
||||
"confirmPasswordRequired": "Passwortbestätigung ist erforderlich",
|
||||
"passwordMismatch": "Passwörter stimmen nicht überein"
|
||||
}
|
||||
}
|
||||
75
apps/web/messages/en.json
Normal file
75
apps/web/messages/en.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"common": {
|
||||
"appName": "HexaHost GameCloud",
|
||||
"login": "Sign in",
|
||||
"register": "Register",
|
||||
"dashboard": "Dashboard",
|
||||
"logout": "Sign out",
|
||||
"theme": "Theme",
|
||||
"themeLight": "Light",
|
||||
"themeDark": "Dark",
|
||||
"themeSystem": "System",
|
||||
"language": "Language",
|
||||
"loading": "Loading…",
|
||||
"submit": "Submit",
|
||||
"cancel": "Cancel",
|
||||
"back": "Back",
|
||||
"footer": "Self-hosted Minecraft server platform."
|
||||
},
|
||||
"landing": {
|
||||
"badge": "Phase 0 · Control Plane",
|
||||
"title": "Minecraft servers on demand",
|
||||
"subtitle": "Create, configure, and operate Minecraft servers through a central web panel — multi-tenant, scalable, and production-ready.",
|
||||
"ctaPrimary": "Get started",
|
||||
"ctaSecondary": "Sign in",
|
||||
"features": {
|
||||
"control": {
|
||||
"title": "Central control",
|
||||
"description": "Server lifecycle, quotas, and scheduling through a unified control plane."
|
||||
},
|
||||
"nodes": {
|
||||
"title": "Multi-node",
|
||||
"description": "Horizontal scaling across dedicated game nodes with agent-based orchestration."
|
||||
},
|
||||
"security": {
|
||||
"title": "Enterprise security",
|
||||
"description": "mTLS, granular permissions, and auditable state transitions from day one."
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"loginTitle": "Sign in",
|
||||
"loginSubtitle": "Sign in with your account.",
|
||||
"registerTitle": "Create account",
|
||||
"registerSubtitle": "Register for the platform.",
|
||||
"email": "Email",
|
||||
"emailPlaceholder": "name@example.com",
|
||||
"password": "Password",
|
||||
"passwordPlaceholder": "••••••••",
|
||||
"confirmPassword": "Confirm password",
|
||||
"noAccount": "No account yet?",
|
||||
"hasAccount": "Already registered?",
|
||||
"apiPending": "Authentication will be connected in Phase 1. Form structure is ready."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcome": "Welcome to your control area.",
|
||||
"servers": "Servers",
|
||||
"serversEmpty": "No servers yet",
|
||||
"serversEmptyDescription": "Create your first Minecraft server once provisioning is available.",
|
||||
"createServer": "Create server",
|
||||
"status": "Status",
|
||||
"resources": "Resources",
|
||||
"activity": "Activity",
|
||||
"activityEmpty": "No recent events",
|
||||
"protectedNote": "Protected area — authentication will be enabled in Phase 1."
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "Email is required",
|
||||
"emailInvalid": "Invalid email address",
|
||||
"passwordRequired": "Password is required",
|
||||
"passwordMin": "Password must be at least 8 characters",
|
||||
"confirmPasswordRequired": "Password confirmation is required",
|
||||
"passwordMismatch": "Passwords do not match"
|
||||
}
|
||||
}
|
||||
5
apps/web/next-env.d.ts
vendored
Normal file
5
apps/web/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
10
apps/web/next.config.ts
Normal file
10
apps/web/next.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { NextConfig } from "next";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: ["@hexahost/ui"],
|
||||
};
|
||||
|
||||
export default withNextIntl(nextConfig);
|
||||
35
apps/web/package.json
Normal file
35
apps/web/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@hexahost/web",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hexahost/ui": "workspace:*",
|
||||
"@hookform/resolvers": "^4.1.3",
|
||||
"@tanstack/react-query": "^5.67.2",
|
||||
"next": "^15.2.3",
|
||||
"next-intl": "^4.0.2",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.22.0",
|
||||
"eslint-config-next": "^15.2.3",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
9
apps/web/postcss.config.mjs
Normal file
9
apps/web/postcss.config.mjs
Normal file
@@ -0,0 +1,9 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
41
apps/web/src/app/[locale]/dashboard/layout.tsx
Normal file
41
apps/web/src/app/[locale]/dashboard/layout.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
params,
|
||||
}: DashboardLayoutProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations("common");
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6">
|
||||
<div className="mb-6 flex flex-col gap-4 border-b border-zinc-200 pb-6 dark:border-zinc-800 sm:flex-row sm:items-center sm:justify-between">
|
||||
<nav aria-label="Dashboard navigation">
|
||||
<ul className="flex gap-4 text-sm">
|
||||
<li>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="font-medium text-zinc-900 dark:text-zinc-50"
|
||||
aria-current="page"
|
||||
>
|
||||
{t("dashboard")}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<p className="font-mono text-xs text-zinc-500">
|
||||
auth: pending · session: none
|
||||
</p>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
apps/web/src/app/[locale]/dashboard/page.tsx
Normal file
13
apps/web/src/app/[locale]/dashboard/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { DashboardContent } from "@/components/dashboard/dashboard-content";
|
||||
|
||||
interface DashboardPageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export default async function DashboardPage({ params }: DashboardPageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return <DashboardContent />;
|
||||
}
|
||||
38
apps/web/src/app/[locale]/layout.tsx
Normal file
38
apps/web/src/app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getMessages, setRequestLocale } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { SiteLayout } from "@/components/layout/site-layout";
|
||||
import { QueryProvider } from "@/components/providers/query-provider";
|
||||
import { ThemeProvider } from "@/components/providers/theme-provider";
|
||||
import { routing, type Locale } from "@/i18n/routing";
|
||||
|
||||
interface LocaleLayoutProps {
|
||||
children: ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return routing.locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({ children, params }: LocaleLayoutProps) {
|
||||
const { locale } = await params;
|
||||
|
||||
if (!routing.locales.includes(locale as Locale)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
setRequestLocale(locale);
|
||||
const messages = await getMessages();
|
||||
|
||||
return (
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<ThemeProvider>
|
||||
<QueryProvider>
|
||||
<SiteLayout>{children}</SiteLayout>
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
</NextIntlClientProvider>
|
||||
);
|
||||
}
|
||||
17
apps/web/src/app/[locale]/login/page.tsx
Normal file
17
apps/web/src/app/[locale]/login/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { LoginForm } from "@/components/auth/login-form";
|
||||
|
||||
interface LoginPageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export default async function LoginPage({ params }: LoginPageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[calc(100vh-8rem)] max-w-6xl items-center justify-center px-4 py-12 sm:px-6">
|
||||
<LoginForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
apps/web/src/app/[locale]/page.tsx
Normal file
64
apps/web/src/app/[locale]/page.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { getAppName } from "@/lib/env";
|
||||
|
||||
interface HomePageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export default async function HomePage({ params }: HomePageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations("landing");
|
||||
const appName = getAppName();
|
||||
|
||||
const features = [
|
||||
{ key: "control" as const, title: t("features.control.title"), description: t("features.control.description") },
|
||||
{ key: "nodes" as const, title: t("features.nodes.title"), description: t("features.nodes.description") },
|
||||
{ key: "security" as const, title: t("features.security.title"), description: t("features.security.description") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-16 sm:px-6">
|
||||
<section className="mx-auto max-w-3xl text-center">
|
||||
<p className="mb-4 inline-flex rounded-full border border-zinc-200 bg-white px-3 py-1 font-mono text-xs text-zinc-600 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400">
|
||||
{t("badge")}
|
||||
</p>
|
||||
<h1 className="text-4xl font-semibold tracking-tight text-zinc-900 sm:text-5xl dark:text-zinc-50">
|
||||
{appName}
|
||||
</h1>
|
||||
<p className="mt-4 text-lg text-zinc-600 dark:text-zinc-400">{t("subtitle")}</p>
|
||||
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
|
||||
<Link
|
||||
href="/register"
|
||||
className="inline-flex h-10 items-center rounded-md bg-sky-600 px-6 text-sm font-medium text-white transition-colors hover:bg-sky-500 dark:bg-sky-500 dark:hover:bg-sky-400"
|
||||
>
|
||||
{t("ctaPrimary")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex h-10 items-center rounded-md border border-zinc-300 bg-white px-6 text-sm font-medium text-zinc-900 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
||||
>
|
||||
{t("ctaSecondary")}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-20 grid gap-6 md:grid-cols-3">
|
||||
{features.map((feature) => (
|
||||
<Card key={feature.key}>
|
||||
<CardHeader>
|
||||
<CardTitle>{feature.title}</CardTitle>
|
||||
<CardDescription>{feature.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-1 w-12 rounded-full bg-sky-600 dark:bg-sky-500" aria-hidden="true" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
apps/web/src/app/[locale]/register/page.tsx
Normal file
17
apps/web/src/app/[locale]/register/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { RegisterForm } from "@/components/auth/register-form";
|
||||
|
||||
interface RegisterPageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export default async function RegisterPage({ params }: RegisterPageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[calc(100vh-8rem)] max-w-6xl items-center justify-center px-4 py-12 sm:px-6">
|
||||
<RegisterForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
apps/web/src/app/globals.css
Normal file
39
apps/web/src/app/globals.css
Normal file
@@ -0,0 +1,39 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--surface: 250 250 250;
|
||||
--surface-muted: 244 244 245;
|
||||
--border: 228 228 231;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--surface: 9 9 11;
|
||||
--surface-muted: 24 24 27;
|
||||
--border: 39 39 42;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-zinc-50 text-zinc-900 antialiased dark:bg-zinc-950 dark:text-zinc-50;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
}
|
||||
35
apps/web/src/app/layout.tsx
Normal file
35
apps/web/src/app/layout.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: process.env.NEXT_PUBLIC_APP_NAME ?? "HexaHost GameCloud",
|
||||
template: `%s · ${process.env.NEXT_PUBLIC_APP_NAME ?? "HexaHost GameCloud"}`,
|
||||
},
|
||||
description: "Self-hosted Minecraft server platform control plane.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="de" suppressHydrationWarning>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} font-sans`}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
108
apps/web/src/components/auth/login-form.tsx
Normal file
108
apps/web/src/components/auth/login-form.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { submitLogin } from "@/lib/api/auth";
|
||||
import { createLoginSchema, type LoginFormValues } from "@/lib/schemas/auth";
|
||||
|
||||
export function LoginForm() {
|
||||
const t = useTranslations("auth");
|
||||
const tv = useTranslations("validation");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const schema = createLoginSchema({
|
||||
emailRequired: tv("emailRequired"),
|
||||
emailInvalid: tv("emailInvalid"),
|
||||
passwordRequired: tv("passwordRequired"),
|
||||
passwordMin: tv("passwordMin"),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<LoginFormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { email: "", password: "" },
|
||||
});
|
||||
|
||||
async function onSubmit(values: LoginFormValues) {
|
||||
await submitLogin(values);
|
||||
setSubmitted(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("loginTitle")}</CardTitle>
|
||||
<CardDescription>{t("loginSubtitle")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{t("email")}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder={t("emailPlaceholder")}
|
||||
aria-invalid={errors.email ? "true" : "false"}
|
||||
aria-describedby={errors.email ? "email-error" : undefined}
|
||||
className="flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
||||
{...register("email")}
|
||||
/>
|
||||
{errors.email ? (
|
||||
<p id="email-error" className="text-sm text-red-600" role="alert">
|
||||
{errors.email.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{t("password")}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
aria-invalid={errors.password ? "true" : "false"}
|
||||
aria-describedby={errors.password ? "password-error" : undefined}
|
||||
className="flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
||||
{...register("password")}
|
||||
/>
|
||||
{errors.password ? (
|
||||
<p id="password-error" className="text-sm text-red-600" role="alert">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{submitted ? (
|
||||
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
|
||||
{t("apiPending")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button type="submit" className="w-full" isLoading={isSubmitting}>
|
||||
{t("loginTitle")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{t("noAccount")}{" "}
|
||||
<Link href="/register" className="font-medium text-sky-600 hover:underline dark:text-sky-400">
|
||||
{t("registerTitle")}
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
134
apps/web/src/components/auth/register-form.tsx
Normal file
134
apps/web/src/components/auth/register-form.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { submitRegister } from "@/lib/api/auth";
|
||||
import { createRegisterSchema, type RegisterFormValues } from "@/lib/schemas/auth";
|
||||
|
||||
export function RegisterForm() {
|
||||
const t = useTranslations("auth");
|
||||
const tv = useTranslations("validation");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const schema = createRegisterSchema({
|
||||
emailRequired: tv("emailRequired"),
|
||||
emailInvalid: tv("emailInvalid"),
|
||||
passwordRequired: tv("passwordRequired"),
|
||||
passwordMin: tv("passwordMin"),
|
||||
confirmPasswordRequired: tv("confirmPasswordRequired"),
|
||||
passwordMismatch: tv("passwordMismatch"),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<RegisterFormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { email: "", password: "", confirmPassword: "" },
|
||||
});
|
||||
|
||||
async function onSubmit(values: RegisterFormValues) {
|
||||
await submitRegister({ email: values.email, password: values.password });
|
||||
setSubmitted(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("registerTitle")}</CardTitle>
|
||||
<CardDescription>{t("registerSubtitle")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{t("email")}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder={t("emailPlaceholder")}
|
||||
aria-invalid={errors.email ? "true" : "false"}
|
||||
aria-describedby={errors.email ? "email-error" : undefined}
|
||||
className="flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
||||
{...register("email")}
|
||||
/>
|
||||
{errors.email ? (
|
||||
<p id="email-error" className="text-sm text-red-600" role="alert">
|
||||
{errors.email.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{t("password")}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
aria-invalid={errors.password ? "true" : "false"}
|
||||
aria-describedby={errors.password ? "password-error" : undefined}
|
||||
className="flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
||||
{...register("password")}
|
||||
/>
|
||||
{errors.password ? (
|
||||
<p id="password-error" className="text-sm text-red-600" role="alert">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="text-sm font-medium text-zinc-900 dark:text-zinc-100"
|
||||
>
|
||||
{t("confirmPassword")}
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
aria-invalid={errors.confirmPassword ? "true" : "false"}
|
||||
aria-describedby={errors.confirmPassword ? "confirm-password-error" : undefined}
|
||||
className="flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
||||
{...register("confirmPassword")}
|
||||
/>
|
||||
{errors.confirmPassword ? (
|
||||
<p id="confirm-password-error" className="text-sm text-red-600" role="alert">
|
||||
{errors.confirmPassword.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{submitted ? (
|
||||
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
|
||||
{t("apiPending")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button type="submit" className="w-full" isLoading={isSubmitting}>
|
||||
{t("registerTitle")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{t("hasAccount")}{" "}
|
||||
<Link href="/login" className="font-medium text-sky-600 hover:underline dark:text-sky-400">
|
||||
{t("loginTitle")}
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
82
apps/web/src/components/dashboard/dashboard-content.tsx
Normal file
82
apps/web/src/components/dashboard/dashboard-content.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { EmptyState } from "@/components/dashboard/empty-state";
|
||||
import { DashboardSkeleton } from "@/components/dashboard/skeleton";
|
||||
|
||||
export function DashboardContent() {
|
||||
const t = useTranslations("dashboard");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setIsLoading(false), 600);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <DashboardSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("welcome")}</p>
|
||||
<p className="mt-2 font-mono text-xs text-zinc-500">{t("protectedNote")}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("status")}</CardTitle>
|
||||
<CardDescription>Control Plane · Online</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-mono text-2xl font-semibold text-emerald-600 dark:text-emerald-400">
|
||||
0
|
||||
</p>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">Active servers</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("resources")}</CardTitle>
|
||||
<CardDescription>Allocated / Quota</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-mono text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
0 / 0
|
||||
</p>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">CPU · RAM · Disk</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("activity")}</CardTitle>
|
||||
<CardDescription>Last 24 hours</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("activityEmpty")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<section aria-labelledby="servers-heading">
|
||||
<h2 id="servers-heading" className="mb-4 text-lg font-medium text-zinc-900 dark:text-zinc-50">
|
||||
{t("servers")}
|
||||
</h2>
|
||||
<EmptyState
|
||||
title={t("serversEmpty")}
|
||||
description={t("serversEmptyDescription")}
|
||||
actionLabel={t("createServer")}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
apps/web/src/components/dashboard/empty-state.tsx
Normal file
29
apps/web/src/components/dashboard/empty-state.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
description: string;
|
||||
actionLabel: string;
|
||||
onAction?: () => void;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
onAction,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<Card className="border-dashed">
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="secondary" onClick={onAction} disabled={!onAction}>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
29
apps/web/src/components/dashboard/skeleton.tsx
Normal file
29
apps/web/src/components/dashboard/skeleton.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
interface SkeletonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Skeleton({ className = "" }: SkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className={`animate-pulse rounded-md bg-zinc-200 dark:bg-zinc-800 ${className}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6" aria-busy="true" aria-label="Loading dashboard">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-96 max-w-full" />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Skeleton className="h-32" />
|
||||
<Skeleton className="h-32" />
|
||||
<Skeleton className="h-32" />
|
||||
</div>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
apps/web/src/components/layout/footer.tsx
Normal file
21
apps/web/src/components/layout/footer.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getAppName } from "@/lib/env";
|
||||
|
||||
export function Footer() {
|
||||
const t = useTranslations("common");
|
||||
const appName = getAppName();
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="border-t border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-950">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-2 px-4 py-8 text-sm text-zinc-600 sm:flex-row sm:items-center sm:justify-between sm:px-6 dark:text-zinc-400">
|
||||
<p>
|
||||
© {year} {appName}. {t("footer")}
|
||||
</p>
|
||||
<p className="font-mono text-xs text-zinc-500 dark:text-zinc-500">
|
||||
Phase 0 · Frontend Foundation
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
51
apps/web/src/components/layout/header.tsx
Normal file
51
apps/web/src/components/layout/header.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { getAppName } from "@/lib/env";
|
||||
import { LocaleSwitcher } from "./locale-switcher";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
|
||||
export function Header() {
|
||||
const t = useTranslations("common");
|
||||
const appName = getAppName();
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-zinc-200 bg-white/80 backdrop-blur dark:border-zinc-800 dark:bg-zinc-950/80">
|
||||
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between gap-4 px-4 sm:px-6">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm font-semibold tracking-tight text-zinc-900 dark:text-zinc-50"
|
||||
>
|
||||
{appName}
|
||||
</Link>
|
||||
|
||||
<nav
|
||||
className="flex items-center gap-2 sm:gap-4"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="hidden text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100 sm:inline"
|
||||
>
|
||||
{t("dashboard")}
|
||||
</Link>
|
||||
<LocaleSwitcher />
|
||||
<ThemeToggle />
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex h-8 items-center rounded-md px-3 text-sm font-medium text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||||
>
|
||||
{t("login")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="inline-flex h-8 items-center rounded-md bg-sky-600 px-3 text-sm font-medium text-white hover:bg-sky-500 dark:bg-sky-500 dark:hover:bg-sky-400"
|
||||
>
|
||||
{t("register")}
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
34
apps/web/src/components/layout/locale-switcher.tsx
Normal file
34
apps/web/src/components/layout/locale-switcher.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { usePathname, useRouter } from "@/i18n/navigation";
|
||||
import { routing, type Locale } from "@/i18n/routing";
|
||||
|
||||
export function LocaleSwitcher() {
|
||||
const t = useTranslations("common");
|
||||
const locale = useLocale() as Locale;
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
function onChange(nextLocale: string) {
|
||||
router.replace(pathname, { locale: nextLocale as Locale });
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<span className="sr-only">{t("language")}</span>
|
||||
<select
|
||||
value={locale}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="h-8 rounded-md border border-zinc-300 bg-white px-2 text-sm text-zinc-900 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100"
|
||||
aria-label={t("language")}
|
||||
>
|
||||
{routing.locales.map((item) => (
|
||||
<option key={item} value={item}>
|
||||
{item.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
17
apps/web/src/components/layout/site-layout.tsx
Normal file
17
apps/web/src/components/layout/site-layout.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Footer } from "./footer";
|
||||
import { Header } from "./header";
|
||||
|
||||
interface SiteLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function SiteLayout({ children }: SiteLayoutProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-50">
|
||||
<Header />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
apps/web/src/components/layout/theme-toggle.tsx
Normal file
35
apps/web/src/components/layout/theme-toggle.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@hexahost/ui";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<Button variant="ghost" size="sm" aria-label="Theme" disabled>
|
||||
…
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const isDark = (theme === "system" ? resolvedTheme : theme) === "dark";
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
>
|
||||
{isDark ? "☀" : "☾"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
26
apps/web/src/components/providers/query-provider.tsx
Normal file
26
apps/web/src/components/providers/query-provider.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
interface QueryProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function QueryProvider({ children }: QueryProviderProps) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
21
apps/web/src/components/providers/theme-provider.tsx
Normal file
21
apps/web/src/components/providers/theme-provider.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: ThemeProviderProps) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
5
apps/web/src/i18n/navigation.ts
Normal file
5
apps/web/src/i18n/navigation.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createNavigation } from "next-intl/navigation";
|
||||
import { routing } from "./routing";
|
||||
|
||||
export const { Link, redirect, usePathname, useRouter, getPathname } =
|
||||
createNavigation(routing);
|
||||
15
apps/web/src/i18n/request.ts
Normal file
15
apps/web/src/i18n/request.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { getRequestConfig } from "next-intl/server";
|
||||
import { routing } from "./routing";
|
||||
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
let locale = await requestLocale;
|
||||
|
||||
if (!locale || !routing.locales.includes(locale as "de" | "en")) {
|
||||
locale = routing.defaultLocale;
|
||||
}
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../../messages/${locale}.json`)).default,
|
||||
};
|
||||
});
|
||||
9
apps/web/src/i18n/routing.ts
Normal file
9
apps/web/src/i18n/routing.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineRouting } from "next-intl/routing";
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: ["de", "en"],
|
||||
defaultLocale: "de",
|
||||
localePrefix: "as-needed",
|
||||
});
|
||||
|
||||
export type Locale = (typeof routing.locales)[number];
|
||||
45
apps/web/src/lib/api/auth.ts
Normal file
45
apps/web/src/lib/api/auth.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { getApiUrl } from "./env";
|
||||
|
||||
export interface AuthCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepared API client for Phase 1 authentication.
|
||||
* Currently validates structure only; no network request is performed.
|
||||
*/
|
||||
export async function submitLogin(
|
||||
credentials: AuthCredentials,
|
||||
): Promise<AuthResponse> {
|
||||
const endpoint = `${getApiUrl()}/auth/login`;
|
||||
|
||||
if (!endpoint.startsWith("http")) {
|
||||
throw new Error("Invalid API URL configuration");
|
||||
}
|
||||
|
||||
void credentials;
|
||||
void endpoint;
|
||||
|
||||
return Promise.resolve({});
|
||||
}
|
||||
|
||||
export async function submitRegister(
|
||||
credentials: AuthCredentials,
|
||||
): Promise<AuthResponse> {
|
||||
const endpoint = `${getApiUrl()}/auth/register`;
|
||||
|
||||
if (!endpoint.startsWith("http")) {
|
||||
throw new Error("Invalid API URL configuration");
|
||||
}
|
||||
|
||||
void credentials;
|
||||
void endpoint;
|
||||
|
||||
return Promise.resolve({});
|
||||
}
|
||||
7
apps/web/src/lib/env.ts
Normal file
7
apps/web/src/lib/env.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function getAppName(): string {
|
||||
return process.env.NEXT_PUBLIC_APP_NAME ?? "HexaHost GameCloud";
|
||||
}
|
||||
|
||||
export function getApiUrl(): string {
|
||||
return process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001/api/v1";
|
||||
}
|
||||
48
apps/web/src/lib/schemas/auth.ts
Normal file
48
apps/web/src/lib/schemas/auth.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export function createLoginSchema(messages: {
|
||||
emailRequired: string;
|
||||
emailInvalid: string;
|
||||
passwordRequired: string;
|
||||
passwordMin: string;
|
||||
}) {
|
||||
return z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, messages.emailRequired)
|
||||
.email(messages.emailInvalid),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, messages.passwordRequired)
|
||||
.min(8, messages.passwordMin),
|
||||
});
|
||||
}
|
||||
|
||||
export function createRegisterSchema(messages: {
|
||||
emailRequired: string;
|
||||
emailInvalid: string;
|
||||
passwordRequired: string;
|
||||
passwordMin: string;
|
||||
confirmPasswordRequired: string;
|
||||
passwordMismatch: string;
|
||||
}) {
|
||||
return z
|
||||
.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, messages.emailRequired)
|
||||
.email(messages.emailInvalid),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, messages.passwordRequired)
|
||||
.min(8, messages.passwordMin),
|
||||
confirmPassword: z.string().min(1, messages.confirmPasswordRequired),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: messages.passwordMismatch,
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
}
|
||||
|
||||
export type LoginFormValues = z.infer<ReturnType<typeof createLoginSchema>>;
|
||||
export type RegisterFormValues = z.infer<ReturnType<typeof createRegisterSchema>>;
|
||||
8
apps/web/src/middleware.ts
Normal file
8
apps/web/src/middleware.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import createMiddleware from "next-intl/middleware";
|
||||
import { routing } from "./src/i18n/routing";
|
||||
|
||||
export default createMiddleware(routing);
|
||||
|
||||
export const config = {
|
||||
matcher: ["/", "/(de|en)/:path*", "/((?!_next|_vercel|.*\\..*).*)"],
|
||||
};
|
||||
34
apps/web/tailwind.config.ts
Normal file
34
apps/web/tailwind.config.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
darkMode: "class",
|
||||
content: [
|
||||
"./src/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"../../packages/ui/src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: [
|
||||
"var(--font-geist-sans)",
|
||||
"ui-sans-serif",
|
||||
"system-ui",
|
||||
"sans-serif",
|
||||
],
|
||||
mono: ["var(--font-geist-mono)", "ui-monospace", "monospace"],
|
||||
},
|
||||
colors: {
|
||||
surface: {
|
||||
DEFAULT: "rgb(var(--surface) / <alpha-value>)",
|
||||
muted: "rgb(var(--surface-muted) / <alpha-value>)",
|
||||
},
|
||||
border: {
|
||||
DEFAULT: "rgb(var(--border) / <alpha-value>)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
23
apps/web/tsconfig.json
Normal file
23
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
17
apps/worker/package.json
Normal file
17
apps/worker/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@hexahost/worker",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"lint": "node -e \"process.exit(0)\"",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test test/**/*.test.js",
|
||||
"start": "node dist/main.js",
|
||||
"dev": "tsc && node dist/main.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
69
apps/worker/src/health.ts
Normal file
69
apps/worker/src/health.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
|
||||
import { prisma } from '@hexahost/database';
|
||||
import { getConfig } from '@hexahost/config';
|
||||
|
||||
import { logger } from './logger';
|
||||
|
||||
export function startHealthServer(): ReturnType<typeof createServer> {
|
||||
const config = getConfig();
|
||||
|
||||
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
||||
if (req.url !== '/health' && req.url !== '/health/ready') {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ status: 'error', message: 'Not found' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const isReady = req.url === '/health/ready';
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
if (!isReady) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
status: 'ok',
|
||||
service: config.APP_NAME,
|
||||
timestamp,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
status: 'ok',
|
||||
service: config.APP_NAME,
|
||||
timestamp,
|
||||
checks: [{ name: 'database', status: 'ok' }],
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'Readiness check failed');
|
||||
res.writeHead(503, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
status: 'error',
|
||||
service: config.APP_NAME,
|
||||
timestamp,
|
||||
checks: [
|
||||
{
|
||||
name: 'database',
|
||||
status: 'error',
|
||||
message: error instanceof Error ? error.message : 'Database check failed',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(config.WORKER_PORT, '0.0.0.0', () => {
|
||||
logger.info({ port: config.WORKER_PORT }, 'Worker health server listening');
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
80
apps/worker/src/index.ts
Normal file
80
apps/worker/src/index.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Worker, type ConnectionOptions } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
|
||||
import { validateConfig } from '@hexahost/config';
|
||||
import { prisma } from '@hexahost/database';
|
||||
|
||||
import { startHealthServer } from './health';
|
||||
import { logger } from './logger';
|
||||
import { WORKER_QUEUES, type WorkerQueueName } from './queues';
|
||||
|
||||
function createRedisConnection(redisUrl: string): IORedis {
|
||||
return new IORedis(redisUrl, {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
}
|
||||
|
||||
function createQueueWorker(
|
||||
queueName: WorkerQueueName,
|
||||
connection: ConnectionOptions,
|
||||
): Worker {
|
||||
return new Worker(
|
||||
queueName,
|
||||
async (job) => {
|
||||
logger.info(
|
||||
{ queue: queueName, jobId: job.id, jobName: job.name },
|
||||
'Processing job',
|
||||
);
|
||||
|
||||
return { acknowledged: true, queue: queueName };
|
||||
},
|
||||
{ connection },
|
||||
);
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const config = validateConfig();
|
||||
|
||||
const healthServer = startHealthServer();
|
||||
const redis = createRedisConnection(config.REDIS_URL);
|
||||
const workers = WORKER_QUEUES.map((queueName) =>
|
||||
createQueueWorker(queueName, redis),
|
||||
);
|
||||
|
||||
for (const worker of workers) {
|
||||
worker.on('completed', (job) => {
|
||||
logger.info({ queue: worker.name, jobId: job.id }, 'Job completed');
|
||||
});
|
||||
|
||||
worker.on('failed', (job, error) => {
|
||||
logger.error(
|
||||
{ queue: worker.name, jobId: job?.id, err: error },
|
||||
'Job failed',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
logger.info({ queues: WORKER_QUEUES }, 'Worker started');
|
||||
|
||||
const shutdown = async (signal: string): Promise<void> => {
|
||||
logger.info({ signal }, 'Shutting down worker');
|
||||
|
||||
await Promise.all(workers.map((worker) => worker.close()));
|
||||
await redis.quit();
|
||||
await prisma.$disconnect();
|
||||
|
||||
healthServer.close(() => {
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
void shutdown('SIGINT');
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
void shutdown('SIGTERM');
|
||||
});
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
10
apps/worker/src/logger.ts
Normal file
10
apps/worker/src/logger.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import pino from 'pino';
|
||||
|
||||
export const logger = pino({
|
||||
level: process.env['LOG_LEVEL'] ?? 'info',
|
||||
base: {
|
||||
service: process.env['APP_NAME'] ?? 'HexaHost GameCloud Worker',
|
||||
env: process.env['APP_ENV'] ?? 'development',
|
||||
},
|
||||
timestamp: pino.stdTimeFunctions.isoTime,
|
||||
});
|
||||
14
apps/worker/src/main.ts
Normal file
14
apps/worker/src/main.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
const redisUrl = process.env.REDIS_URL ?? "redis://localhost:6379";
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
level: "info",
|
||||
msg: "worker started (phase 0 stub)",
|
||||
redis: redisUrl.replace(/:[^:@]+@/, ":***@"),
|
||||
}),
|
||||
);
|
||||
|
||||
// Phase 0: worker process stays alive for compose; BullMQ queues land in Phase 1+
|
||||
setInterval(() => {
|
||||
console.log(JSON.stringify({ level: "debug", msg: "worker heartbeat" }));
|
||||
}, 60_000);
|
||||
11
apps/worker/src/queues.ts
Normal file
11
apps/worker/src/queues.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const QUEUE_SERVER_LIFECYCLE = 'server-lifecycle' as const;
|
||||
export const QUEUE_SERVER_PROVISIONING = 'server-provisioning' as const;
|
||||
export const QUEUE_NOTIFICATIONS = 'notifications' as const;
|
||||
|
||||
export const WORKER_QUEUES = [
|
||||
QUEUE_SERVER_LIFECYCLE,
|
||||
QUEUE_SERVER_PROVISIONING,
|
||||
QUEUE_NOTIFICATIONS,
|
||||
] as const;
|
||||
|
||||
export type WorkerQueueName = (typeof WORKER_QUEUES)[number];
|
||||
13
apps/worker/tsconfig.json
Normal file
13
apps/worker/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user