diff --git a/src/AmtocBots.Web/Dockerfile b/src/AmtocBots.Web/Dockerfile
new file mode 100644
index 0000000..5360673
--- /dev/null
+++ b/src/AmtocBots.Web/Dockerfile
@@ -0,0 +1,13 @@
+# ── Build stage ───────────────────────────────────────────────────────────────
+FROM node:22-alpine AS build
+WORKDIR /app
+COPY package*.json ./
+RUN npm ci
+COPY . .
+RUN npm run build:prod
+
+# ── Serve stage ───────────────────────────────────────────────────────────────
+FROM nginx:1.27-alpine AS runtime
+COPY --from=build /app/dist/amtocbots-web/browser /usr/share/nginx/html
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+EXPOSE 80
diff --git a/src/AmtocBots.Web/angular.json b/src/AmtocBots.Web/angular.json
new file mode 100644
index 0000000..76f489a
--- /dev/null
+++ b/src/AmtocBots.Web/angular.json
@@ -0,0 +1,71 @@
+{
+ "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
+ "version": 1,
+ "newProjectRoot": "projects",
+ "projects": {
+ "amtocbots-web": {
+ "projectType": "application",
+ "root": "",
+ "sourceRoot": "src",
+ "prefix": "app",
+ "architect": {
+ "build": {
+ "builder": "@angular-devkit/build-angular:application",
+ "options": {
+ "outputPath": "dist/amtocbots-web",
+ "index": "src/index.html",
+ "browser": "src/main.ts",
+ "polyfills": ["zone.js"],
+ "tsConfig": "tsconfig.app.json",
+ "assets": [
+ "src/favicon.ico",
+ "src/assets",
+ "src/manifest.webmanifest",
+ { "glob": "ngsw-worker.js", "input": "./node_modules/@angular/service-worker", "output": "." }
+ ],
+ "styles": ["src/styles.scss"],
+ "scripts": [],
+ "serviceWorker": "ngsw-config.json"
+ },
+ "configurations": {
+ "production": {
+ "budgets": [
+ { "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" },
+ { "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" }
+ ],
+ "outputHashing": "all",
+ "optimization": true
+ },
+ "development": {
+ "optimization": false,
+ "extractLicenses": false,
+ "sourceMap": true
+ }
+ },
+ "defaultConfiguration": "production"
+ },
+ "serve": {
+ "builder": "@angular-devkit/build-angular:dev-server",
+ "configurations": {
+ "production": { "buildTarget": "amtocbots-web:build:production" },
+ "development": { "buildTarget": "amtocbots-web:build:development" }
+ },
+ "defaultConfiguration": "development",
+ "options": {
+ "proxyConfig": "proxy.conf.json"
+ }
+ },
+ "test": {
+ "builder": "@angular-devkit/build-angular:karma",
+ "options": {
+ "polyfills": ["zone.js", "zone.js/testing"],
+ "tsConfig": "tsconfig.spec.json",
+ "assets": ["src/favicon.ico", "src/assets"],
+ "styles": ["src/styles.scss"],
+ "scripts": []
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/AmtocBots.Web/nginx.conf b/src/AmtocBots.Web/nginx.conf
new file mode 100644
index 0000000..504dcf8
--- /dev/null
+++ b/src/AmtocBots.Web/nginx.conf
@@ -0,0 +1,27 @@
+server {
+ listen 80;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # PWA — all routes to index.html
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ # Cache static assets aggressively
+ location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ }
+
+ # Never cache index.html or service worker
+ location = /index.html {
+ add_header Cache-Control "no-cache";
+ }
+ location = /ngsw-worker.js {
+ add_header Cache-Control "no-cache";
+ }
+
+ gzip on;
+ gzip_types text/plain text/css application/javascript application/json;
+}
diff --git a/src/AmtocBots.Web/ngsw-config.json b/src/AmtocBots.Web/ngsw-config.json
new file mode 100644
index 0000000..3ff0609
--- /dev/null
+++ b/src/AmtocBots.Web/ngsw-config.json
@@ -0,0 +1,33 @@
+{
+ "$schema": "./node_modules/@angular/service-worker/config/schema.json",
+ "index": "/index.html",
+ "assetGroups": [
+ {
+ "name": "app-shell",
+ "installMode": "prefetch",
+ "resources": {
+ "files": ["/favicon.ico", "/index.html", "/manifest.webmanifest", "/*.css", "/*.js"]
+ }
+ },
+ {
+ "name": "assets",
+ "installMode": "lazy",
+ "updateMode": "prefetch",
+ "resources": {
+ "files": ["/assets/**"]
+ }
+ }
+ ],
+ "dataGroups": [
+ {
+ "name": "api",
+ "urls": ["/api/**"],
+ "cacheConfig": {
+ "strategy": "freshness",
+ "maxSize": 100,
+ "maxAge": "1m",
+ "timeout": "10s"
+ }
+ }
+ ]
+}
diff --git a/src/AmtocBots.Web/package.json b/src/AmtocBots.Web/package.json
new file mode 100644
index 0000000..c6bc2dd
--- /dev/null
+++ b/src/AmtocBots.Web/package.json
@@ -0,0 +1,45 @@
+{
+ "name": "amtocbots-web",
+ "version": "0.0.1",
+ "scripts": {
+ "ng": "ng",
+ "start": "ng serve",
+ "build": "ng build",
+ "build:prod": "ng build --configuration production",
+ "test": "ng test",
+ "lint": "ng lint"
+ },
+ "private": true,
+ "dependencies": {
+ "@angular/animations": "^21.0.0",
+ "@angular/cdk": "^21.0.0",
+ "@angular/common": "^21.0.0",
+ "@angular/compiler": "^21.0.0",
+ "@angular/core": "^21.0.0",
+ "@angular/forms": "^21.0.0",
+ "@angular/material": "^21.0.0",
+ "@angular/platform-browser": "^21.0.0",
+ "@angular/platform-browser-dynamic": "^21.0.0",
+ "@angular/router": "^21.0.0",
+ "@angular/service-worker": "^21.0.0",
+ "@microsoft/signalr": "^8.0.7",
+ "angular-auth-oidc-client": "^19.0.0",
+ "ngx-monaco-editor-v2": "^21.0.0",
+ "rxjs": "~7.8.0",
+ "tslib": "^2.6.0",
+ "zone.js": "~0.15.0"
+ },
+ "devDependencies": {
+ "@angular-devkit/build-angular": "^21.0.0",
+ "@angular/cli": "^21.0.0",
+ "@angular/compiler-cli": "^21.0.0",
+ "@types/jasmine": "~5.1.0",
+ "jasmine-core": "~5.3.0",
+ "karma": "~6.4.0",
+ "karma-chrome-launcher": "~3.2.0",
+ "karma-coverage": "~2.2.0",
+ "karma-jasmine": "~5.1.0",
+ "karma-jasmine-html-reporter": "~2.1.0",
+ "typescript": "~5.6.0"
+ }
+}
diff --git a/src/AmtocBots.Web/proxy.conf.json b/src/AmtocBots.Web/proxy.conf.json
new file mode 100644
index 0000000..cba7fd3
--- /dev/null
+++ b/src/AmtocBots.Web/proxy.conf.json
@@ -0,0 +1,13 @@
+{
+ "/api": {
+ "target": "http://localhost:8080",
+ "secure": false,
+ "changeOrigin": true
+ },
+ "/hubs": {
+ "target": "http://localhost:8080",
+ "secure": false,
+ "changeOrigin": true,
+ "ws": true
+ }
+}
diff --git a/src/AmtocBots.Web/src/app/app.component.ts b/src/AmtocBots.Web/src/app/app.component.ts
new file mode 100644
index 0000000..83d3238
--- /dev/null
+++ b/src/AmtocBots.Web/src/app/app.component.ts
@@ -0,0 +1,10 @@
+import { Component } from '@angular/core';
+import { RouterOutlet } from '@angular/router';
+
+@Component({
+ selector: 'app-root',
+ standalone: true,
+ imports: [RouterOutlet],
+ template: ``,
+})
+export class AppComponent {}
diff --git a/src/AmtocBots.Web/src/app/app.config.ts b/src/AmtocBots.Web/src/app/app.config.ts
new file mode 100644
index 0000000..b041312
--- /dev/null
+++ b/src/AmtocBots.Web/src/app/app.config.ts
@@ -0,0 +1,36 @@
+import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
+import { provideRouter, withComponentInputBinding, withViewTransitions } from '@angular/router';
+import { provideHttpClient, withInterceptors } from '@angular/common/http';
+import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
+import { provideServiceWorker } from '@angular/service-worker';
+import { provideAuth, LogLevel } from 'angular-auth-oidc-client';
+import { appRoutes } from './app.routes';
+import { authTokenInterceptor } from './core/auth/auth-token.interceptor';
+import { environment } from '../environments/environment';
+
+export const appConfig: ApplicationConfig = {
+ providers: [
+ provideZoneChangeDetection({ eventCoalescing: true }),
+ provideRouter(appRoutes, withComponentInputBinding(), withViewTransitions()),
+ provideHttpClient(withInterceptors([authTokenInterceptor])),
+ provideAnimationsAsync(),
+ provideServiceWorker('ngsw-worker.js', {
+ enabled: environment.production,
+ registrationStrategy: 'registerWhenStable:30000',
+ }),
+ provideAuth({
+ config: {
+ authority: environment.keycloak.authority,
+ redirectUrl: environment.keycloak.redirectUri,
+ postLogoutRedirectUri: environment.keycloak.postLogoutRedirectUri,
+ clientId: environment.keycloak.clientId,
+ scope: environment.keycloak.scope,
+ responseType: 'code',
+ silentRenew: true,
+ useRefreshToken: true,
+ logLevel: environment.production ? LogLevel.Warn : LogLevel.Debug,
+ secureRoutes: [environment.apiBase, environment.hubBase],
+ },
+ }),
+ ],
+};
diff --git a/src/AmtocBots.Web/src/app/app.routes.ts b/src/AmtocBots.Web/src/app/app.routes.ts
new file mode 100644
index 0000000..5c9b0cd
--- /dev/null
+++ b/src/AmtocBots.Web/src/app/app.routes.ts
@@ -0,0 +1,39 @@
+import { Routes } from '@angular/router';
+import { authGuard } from './core/auth/auth.guard';
+import { ShellComponent } from './core/layout/shell.component';
+
+export const appRoutes: Routes = [
+ {
+ path: 'callback',
+ loadComponent: () => import('./core/auth/auth-callback.component').then(m => m.AuthCallbackComponent),
+ },
+ {
+ path: '',
+ component: ShellComponent,
+ canActivate: [authGuard],
+ children: [
+ { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
+ {
+ path: 'dashboard',
+ loadChildren: () => import('./features/dashboard/dashboard.routes').then(m => m.dashboardRoutes),
+ },
+ {
+ path: 'instances',
+ loadChildren: () => import('./features/instances/instances.routes').then(m => m.instanceRoutes),
+ },
+ {
+ path: 'models',
+ loadChildren: () => import('./features/models/models.routes').then(m => m.modelRoutes),
+ },
+ {
+ path: 'kanban',
+ loadChildren: () => import('./features/kanban/kanban.routes').then(m => m.kanbanRoutes),
+ },
+ {
+ path: 'chat',
+ loadChildren: () => import('./features/chat/chat.routes').then(m => m.chatRoutes),
+ },
+ ],
+ },
+ { path: '**', redirectTo: '' },
+];
diff --git a/src/AmtocBots.Web/src/app/core/auth/auth-callback.component.ts b/src/AmtocBots.Web/src/app/core/auth/auth-callback.component.ts
new file mode 100644
index 0000000..4b049b1
--- /dev/null
+++ b/src/AmtocBots.Web/src/app/core/auth/auth-callback.component.ts
@@ -0,0 +1,19 @@
+import { Component, OnInit, inject } from '@angular/core';
+import { Router } from '@angular/router';
+import { OidcSecurityService } from 'angular-auth-oidc-client';
+
+@Component({
+ selector: 'app-auth-callback',
+ standalone: true,
+ template: `
Authenticating…
`,
+})
+export class AuthCallbackComponent implements OnInit {
+ private readonly oidc = inject(OidcSecurityService);
+ private readonly router = inject(Router);
+
+ ngOnInit(): void {
+ this.oidc.checkAuth().subscribe(({ isAuthenticated }) => {
+ this.router.navigateByUrl(isAuthenticated ? '/' : '/');
+ });
+ }
+}
diff --git a/src/AmtocBots.Web/src/app/core/auth/auth-token.interceptor.ts b/src/AmtocBots.Web/src/app/core/auth/auth-token.interceptor.ts
new file mode 100644
index 0000000..29725e5
--- /dev/null
+++ b/src/AmtocBots.Web/src/app/core/auth/auth-token.interceptor.ts
@@ -0,0 +1,18 @@
+import { HttpInterceptorFn } from '@angular/common/http';
+import { inject } from '@angular/core';
+import { OidcSecurityService } from 'angular-auth-oidc-client';
+import { switchMap, take } from 'rxjs';
+
+export const authTokenInterceptor: HttpInterceptorFn = (req, next) => {
+ const oidc = inject(OidcSecurityService);
+
+ return oidc.getAccessToken().pipe(
+ take(1),
+ switchMap(token => {
+ if (token && (req.url.startsWith('/api') || req.url.startsWith('/hubs'))) {
+ req = req.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
+ }
+ return next(req);
+ })
+ );
+};
diff --git a/src/AmtocBots.Web/src/app/core/auth/auth.guard.ts b/src/AmtocBots.Web/src/app/core/auth/auth.guard.ts
new file mode 100644
index 0000000..af199c5
--- /dev/null
+++ b/src/AmtocBots.Web/src/app/core/auth/auth.guard.ts
@@ -0,0 +1,17 @@
+import { inject } from '@angular/core';
+import { CanActivateFn } from '@angular/router';
+import { OidcSecurityService } from 'angular-auth-oidc-client';
+import { map, take } from 'rxjs';
+
+export const authGuard: CanActivateFn = () => {
+ const oidc = inject(OidcSecurityService);
+
+ return oidc.isAuthenticated$.pipe(
+ take(1),
+ map(({ isAuthenticated }) => {
+ if (isAuthenticated) return true;
+ oidc.authorize();
+ return false;
+ })
+ );
+};
diff --git a/src/AmtocBots.Web/src/app/core/auth/auth.service.ts b/src/AmtocBots.Web/src/app/core/auth/auth.service.ts
new file mode 100644
index 0000000..fae02ec
--- /dev/null
+++ b/src/AmtocBots.Web/src/app/core/auth/auth.service.ts
@@ -0,0 +1,38 @@
+import { Injectable, computed, inject } from '@angular/core';
+import { toSignal } from '@angular/core/rxjs-interop';
+import { OidcSecurityService } from 'angular-auth-oidc-client';
+
+@Injectable({ providedIn: 'root' })
+export class AuthService {
+ private readonly oidc = inject(OidcSecurityService);
+
+ readonly isAuthenticated = toSignal(
+ this.oidc.isAuthenticated$.pipe(map(({ isAuthenticated }) => isAuthenticated)),
+ { initialValue: false }
+ );
+
+ readonly userData = toSignal(
+ this.oidc.userData$.pipe(map(({ userData }) => userData)),
+ { initialValue: null }
+ );
+
+ readonly accessToken = toSignal(
+ this.oidc.getAccessToken(),
+ { initialValue: '' }
+ );
+
+ readonly roles = computed(() => {
+ const ud = this.userData();
+ return ud?.realm_access?.roles ?? [];
+ });
+
+ readonly isAdmin = computed(() => this.roles().includes('admin'));
+ readonly isOperator = computed(() => this.roles().includes('admin') || this.roles().includes('operator'));
+
+ login() { this.oidc.authorize(); }
+ logout() { this.oidc.logoff().subscribe(); }
+
+ getToken(): string { return this.oidc.getAccessToken()() ?? ''; }
+}
+
+import { map } from 'rxjs';
diff --git a/src/AmtocBots.Web/src/app/core/auth/role.guard.ts b/src/AmtocBots.Web/src/app/core/auth/role.guard.ts
new file mode 100644
index 0000000..49e26db
--- /dev/null
+++ b/src/AmtocBots.Web/src/app/core/auth/role.guard.ts
@@ -0,0 +1,6 @@
+import { inject } from '@angular/core';
+import { CanActivateFn } from '@angular/router';
+import { AuthService } from './auth.service';
+
+export const adminGuard: CanActivateFn = () => inject(AuthService).isAdmin();
+export const operatorGuard: CanActivateFn = () => inject(AuthService).isOperator();
diff --git a/src/AmtocBots.Web/src/app/core/layout/shell.component.ts b/src/AmtocBots.Web/src/app/core/layout/shell.component.ts
new file mode 100644
index 0000000..9b1ed88
--- /dev/null
+++ b/src/AmtocBots.Web/src/app/core/layout/shell.component.ts
@@ -0,0 +1,44 @@
+import { Component, inject, signal } from '@angular/core';
+import { RouterOutlet } from '@angular/router';
+import { MatSidenavModule } from '@angular/material/sidenav';
+import { MatToolbarModule } from '@angular/material/toolbar';
+import { SidebarComponent } from './sidebar.component';
+import { TopbarComponent } from './topbar.component';
+import { SignalrService } from '../signalr/signalr.service';
+import { CommonModule } from '@angular/common';
+
+@Component({
+ selector: 'app-shell',
+ standalone: true,
+ imports: [RouterOutlet, MatSidenavModule, MatToolbarModule, SidebarComponent, TopbarComponent, CommonModule],
+ template: `
+
+
+
+
+ @if (signalr.reconnecting()) {
+ Reconnecting to server…
+ }
+
+
+
+
+
+ `,
+ styles: [`
+ .shell { height: 100vh; }
+ .shell__sidebar { width: 240px; background: var(--bg-surface); border-right: 1px solid var(--border); }
+ .shell__content { display: flex; flex-direction: column; }
+ .shell__main { flex: 1; overflow: auto; padding: 24px; }
+ .reconnect-banner {
+ background: var(--accent-amber); color: #000; text-align: center;
+ padding: 6px; font-size: 12px; font-weight: 600;
+ }
+ `],
+})
+export class ShellComponent {
+ readonly signalr = inject(SignalrService);
+ readonly sidebarOpen = signal(true);
+}
diff --git a/src/AmtocBots.Web/src/app/core/layout/sidebar.component.ts b/src/AmtocBots.Web/src/app/core/layout/sidebar.component.ts
new file mode 100644
index 0000000..31fd9a0
--- /dev/null
+++ b/src/AmtocBots.Web/src/app/core/layout/sidebar.component.ts
@@ -0,0 +1,39 @@
+import { Component } from '@angular/core';
+import { RouterLink, RouterLinkActive } from '@angular/router';
+import { MatListModule } from '@angular/material/list';
+import { MatIconModule } from '@angular/material/icon';
+
+interface NavItem { label: string; icon: string; path: string; }
+
+@Component({
+ selector: 'app-sidebar',
+ standalone: true,
+ imports: [RouterLink, RouterLinkActive, MatListModule, MatIconModule],
+ template: `
+
+
+ @for (item of navItems; track item.path) {
+
+ {{ item.icon }}
+ {{ item.label }}
+
+ }
+
+ `,
+ styles: [`
+ .sidebar-header { padding: 20px 16px 12px; border-bottom: 1px solid var(--border); }
+ .sidebar-logo { font-size: 16px; font-weight: 700; color: var(--accent-blue); }
+ .active { background: rgba(59,130,246,0.12) !important; color: var(--accent-blue) !important; }
+ `],
+})
+export class SidebarComponent {
+ readonly navItems: NavItem[] = [
+ { label: 'Dashboard', icon: 'dashboard', path: '/dashboard' },
+ { label: 'Instances', icon: 'smart_toy', path: '/instances' },
+ { label: 'Models', icon: 'auto_awesome', path: '/models' },
+ { label: 'Kanban', icon: 'view_kanban', path: '/kanban' },
+ { label: 'Chat', icon: 'chat', path: '/chat' },
+ ];
+}
diff --git a/src/AmtocBots.Web/src/app/core/layout/topbar.component.ts b/src/AmtocBots.Web/src/app/core/layout/topbar.component.ts
new file mode 100644
index 0000000..39456ad
--- /dev/null
+++ b/src/AmtocBots.Web/src/app/core/layout/topbar.component.ts
@@ -0,0 +1,38 @@
+import { Component, EventEmitter, Output, inject } from '@angular/core';
+import { MatToolbarModule } from '@angular/material/toolbar';
+import { MatIconButton } from '@angular/material/button';
+import { MatIconModule } from '@angular/material/icon';
+import { MatMenuModule } from '@angular/material/menu';
+import { AuthService } from '../auth/auth.service';
+
+@Component({
+ selector: 'app-topbar',
+ standalone: true,
+ imports: [MatToolbarModule, MatIconButton, MatIconModule, MatMenuModule],
+ template: `
+
+
+
+ {{ auth.userData()?.preferred_username }}
+
+
+
+
+
+ `,
+ styles: [`
+ .topbar { background: var(--bg-surface); border-bottom: 1px solid var(--border); height: 56px; }
+ .spacer { flex: 1; }
+ .topbar__user { font-size: 13px; color: var(--text-secondary); margin-right: 8px; }
+ `],
+})
+export class TopbarComponent {
+ @Output() menuToggle = new EventEmitter();
+ readonly auth = inject(AuthService);
+}
diff --git a/src/AmtocBots.Web/src/app/core/signalr/signalr.service.ts b/src/AmtocBots.Web/src/app/core/signalr/signalr.service.ts
new file mode 100644
index 0000000..9f207a9
--- /dev/null
+++ b/src/AmtocBots.Web/src/app/core/signalr/signalr.service.ts
@@ -0,0 +1,65 @@
+import { Injectable, inject, signal } from '@angular/core';
+import * as signalR from '@microsoft/signalr';
+import { Observable, Subject } from 'rxjs';
+import { OidcSecurityService } from 'angular-auth-oidc-client';
+import { environment } from '../../../environments/environment';
+
+export type HubName = 'instances' | 'kanban' | 'chat';
+
+@Injectable({ providedIn: 'root' })
+export class SignalrService {
+ private readonly oidc = inject(OidcSecurityService);
+ private readonly connections = new Map();
+ private readonly subjects = new Map>();
+
+ readonly reconnecting = signal(false);
+
+ /** Get or build a hub connection. Call from feature stores. */
+ getConnection(hub: HubName): signalR.HubConnection {
+ if (this.connections.has(hub)) return this.connections.get(hub)!;
+
+ const connection = new signalR.HubConnectionBuilder()
+ .withUrl(`${environment.hubBase}/${hub}`, {
+ accessTokenFactory: () => this.oidc.getAccessToken()() ?? '',
+ })
+ .withAutomaticReconnect([0, 2000, 10000, 30000])
+ .configureLogging(signalR.LogLevel.Warning)
+ .build();
+
+ connection.onreconnecting(() => this.reconnecting.set(true));
+ connection.onreconnected(() => this.reconnecting.set(false));
+
+ this.connections.set(hub, connection);
+ return connection;
+ }
+
+ /** Start a hub connection if not already started. */
+ async start(hub: HubName): Promise {
+ const conn = this.getConnection(hub);
+ if (conn.state === signalR.HubConnectionState.Disconnected) {
+ await conn.start();
+ }
+ }
+
+ /** Typed event listener — returns an Observable that emits on each server push. */
+ on(hub: HubName, event: string): Observable {
+ const key = `${hub}:${event}`;
+ if (!this.subjects.has(key)) {
+ const subject = new Subject();
+ this.subjects.set(key, subject);
+ this.getConnection(hub).on(event, (data: unknown) => subject.next(data));
+ }
+ return this.subjects.get(key)! as Observable;
+ }
+
+ /** Invoke a hub method. */
+ invoke(hub: HubName, method: string, ...args: unknown[]): Promise {
+ return this.getConnection(hub).invoke(method, ...args);
+ }
+
+ async stopAll(): Promise {
+ for (const conn of this.connections.values()) {
+ await conn.stop();
+ }
+ }
+}
diff --git a/src/AmtocBots.Web/src/app/features/chat/chat.routes.ts b/src/AmtocBots.Web/src/app/features/chat/chat.routes.ts
new file mode 100644
index 0000000..c1f28df
--- /dev/null
+++ b/src/AmtocBots.Web/src/app/features/chat/chat.routes.ts
@@ -0,0 +1,5 @@
+import { Routes } from '@angular/router';
+
+export const chatRoutes: Routes = [
+ { path: '', loadComponent: () => import('./chat-shell/chat-shell.component').then(m => m.ChatShellComponent) },
+];
diff --git a/src/AmtocBots.Web/src/app/features/dashboard/dashboard.component.ts b/src/AmtocBots.Web/src/app/features/dashboard/dashboard.component.ts
new file mode 100644
index 0000000..553ec4a
--- /dev/null
+++ b/src/AmtocBots.Web/src/app/features/dashboard/dashboard.component.ts
@@ -0,0 +1,87 @@
+import { Component, inject, OnInit } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { toSignal } from '@angular/core/rxjs-interop';
+import { MatCardModule } from '@angular/material/card';
+import { MatIconModule } from '@angular/material/icon';
+import { StatusBadgeComponent } from '../../shared/components/status-badge/status-badge.component';
+import { environment } from '../../../environments/environment';
+
+@Component({
+ selector: 'app-dashboard',
+ standalone: true,
+ imports: [MatCardModule, MatIconModule, StatusBadgeComponent],
+ template: `
+ Dashboard
+
+
+
+ Total Instances
+ {{ instances()?.length ?? '—' }}
+
+
+
+
+ Running
+
+ {{ running() }}
+
+
+
+
+
+ Stopped
+ {{ stopped() }}
+
+
+
+
+ Instances
+
+ @for (inst of instances(); track inst.id) {
+
+
+
+
+ {{ inst.currentModel }}
+ :{{ inst.hostPort }}
+
+
+
+ } @empty {
+
No instances yet. Create one →
+ }
+
+ `,
+ styles: [`
+ .page-title { font-size: 22px; font-weight: 700; margin: 0 0 24px; }
+ .section-title { font-size: 16px; font-weight: 600; margin: 32px 0 12px; color: var(--text-secondary); }
+ .stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 16px; }
+ .stat-card { background: var(--bg-surface); border: 1px solid var(--border); }
+ .stat-label { font-size: 12px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; }
+ .stat-value { font-size: 36px; font-weight: 700; margin-top: 4px; }
+ .stat-value.running { color: var(--accent-green); }
+ .instance-list { display: flex; flex-direction: column; gap: 8px; }
+ .instance-row { background: var(--bg-surface); border: 1px solid var(--border); }
+ .instance-row__main { display: flex; align-items: center; gap: 12px; }
+ .instance-row__meta { margin-top: 6px; display: flex; gap: 8px; }
+ .instance-name { font-weight: 600; }
+ .model-chip { font-size: 11px; background: rgba(59,130,246,.12); color: var(--accent-blue); padding: 2px 8px; border-radius: 999px; }
+ .port-chip { font-size: 11px; color: var(--text-secondary); }
+ .empty-state { color: var(--text-secondary); }
+ .empty-state a { color: var(--accent-blue); }
+ `],
+})
+export class DashboardComponent {
+ private readonly http = inject(HttpClient);
+
+ readonly instances = toSignal(
+ this.http.get(`${environment.apiBase}/instances`),
+ { initialValue: [] }
+ );
+
+ readonly running = () => this.instances()?.filter(i => i.status === 'running').length ?? 0;
+ readonly stopped = () => this.instances()?.filter(i => i.status === 'stopped').length ?? 0;
+}
diff --git a/src/AmtocBots.Web/src/app/features/dashboard/dashboard.routes.ts b/src/AmtocBots.Web/src/app/features/dashboard/dashboard.routes.ts
new file mode 100644
index 0000000..1aaeaea
--- /dev/null
+++ b/src/AmtocBots.Web/src/app/features/dashboard/dashboard.routes.ts
@@ -0,0 +1,8 @@
+import { Routes } from '@angular/router';
+
+export const dashboardRoutes: Routes = [
+ {
+ path: '',
+ loadComponent: () => import('./dashboard.component').then(m => m.DashboardComponent),
+ },
+];
diff --git a/src/AmtocBots.Web/src/app/features/instances/instances.routes.ts b/src/AmtocBots.Web/src/app/features/instances/instances.routes.ts
new file mode 100644
index 0000000..65f66cc
--- /dev/null
+++ b/src/AmtocBots.Web/src/app/features/instances/instances.routes.ts
@@ -0,0 +1,7 @@
+import { Routes } from '@angular/router';
+
+export const instanceRoutes: Routes = [
+ { path: '', loadComponent: () => import('./instance-list/instance-list.component').then(m => m.InstanceListComponent) },
+ { path: ':id', loadComponent: () => import('./instance-detail/instance-detail.component').then(m => m.InstanceDetailComponent) },
+ { path: 'new', loadComponent: () => import('./instance-form/instance-form.component').then(m => m.InstanceFormComponent) },
+];
diff --git a/src/AmtocBots.Web/src/app/features/kanban/kanban.routes.ts b/src/AmtocBots.Web/src/app/features/kanban/kanban.routes.ts
new file mode 100644
index 0000000..e58cda0
--- /dev/null
+++ b/src/AmtocBots.Web/src/app/features/kanban/kanban.routes.ts
@@ -0,0 +1,6 @@
+import { Routes } from '@angular/router';
+
+export const kanbanRoutes: Routes = [
+ { path: '', loadComponent: () => import('./board-list/board-list.component').then(m => m.BoardListComponent) },
+ { path: ':id', loadComponent: () => import('./board/kanban-board.component').then(m => m.KanbanBoardComponent) },
+];
diff --git a/src/AmtocBots.Web/src/app/features/models/models.routes.ts b/src/AmtocBots.Web/src/app/features/models/models.routes.ts
new file mode 100644
index 0000000..f56b93d
--- /dev/null
+++ b/src/AmtocBots.Web/src/app/features/models/models.routes.ts
@@ -0,0 +1,5 @@
+import { Routes } from '@angular/router';
+
+export const modelRoutes: Routes = [
+ { path: '', loadComponent: () => import('./token-dashboard/token-dashboard.component').then(m => m.TokenDashboardComponent) },
+];
diff --git a/src/AmtocBots.Web/src/app/shared/components/confirm-dialog/confirm-dialog.component.ts b/src/AmtocBots.Web/src/app/shared/components/confirm-dialog/confirm-dialog.component.ts
new file mode 100644
index 0000000..65a6193
--- /dev/null
+++ b/src/AmtocBots.Web/src/app/shared/components/confirm-dialog/confirm-dialog.component.ts
@@ -0,0 +1,25 @@
+import { Component, inject } from '@angular/core';
+import { MatButtonModule } from '@angular/material/button';
+import { MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
+
+export interface ConfirmDialogData { title: string; message: string; confirmLabel?: string; }
+
+@Component({
+ selector: 'app-confirm-dialog',
+ standalone: true,
+ imports: [MatDialogModule, MatButtonModule],
+ template: `
+ {{ data.title }}
+ {{ data.message }}
+
+
+
+
+ `,
+})
+export class ConfirmDialogComponent {
+ readonly data = inject(MAT_DIALOG_DATA);
+ readonly dialogRef = inject(MatDialogRef);
+}
diff --git a/src/AmtocBots.Web/src/app/shared/components/status-badge/status-badge.component.ts b/src/AmtocBots.Web/src/app/shared/components/status-badge/status-badge.component.ts
new file mode 100644
index 0000000..a86bfc2
--- /dev/null
+++ b/src/AmtocBots.Web/src/app/shared/components/status-badge/status-badge.component.ts
@@ -0,0 +1,20 @@
+import { Component, input } from '@angular/core';
+
+@Component({
+ selector: 'app-status-badge',
+ standalone: true,
+ template: `{{ status() }}`,
+ styles: [`
+ .badge {
+ display: inline-block; padding: 2px 8px; border-radius: 999px;
+ font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;
+ }
+ .badge--running { background: rgba(16,185,129,.15); color: #10b981; }
+ .badge--stopped { background: rgba(148,163,184,.12); color: #94a3b8; }
+ .badge--starting { background: rgba(245,158,11,.15); color: #f59e0b; }
+ .badge--error { background: rgba(239,68,68,.15); color: #ef4444; }
+ `],
+})
+export class StatusBadgeComponent {
+ readonly status = input.required();
+}
diff --git a/src/AmtocBots.Web/src/environments/environment.production.ts b/src/AmtocBots.Web/src/environments/environment.production.ts
new file mode 100644
index 0000000..523fc60
--- /dev/null
+++ b/src/AmtocBots.Web/src/environments/environment.production.ts
@@ -0,0 +1,12 @@
+export const environment = {
+ production: true,
+ apiBase: '/api',
+ hubBase: '/hubs',
+ keycloak: {
+ authority: 'https://auth.amtocbot.com/realms/amtocbots',
+ clientId: 'amtocbots-web',
+ redirectUri: 'https://manager.amtocbot.com/callback',
+ postLogoutRedirectUri: 'https://manager.amtocbot.com',
+ scope: 'openid profile email roles',
+ },
+};
diff --git a/src/AmtocBots.Web/src/environments/environment.ts b/src/AmtocBots.Web/src/environments/environment.ts
new file mode 100644
index 0000000..2b4650d
--- /dev/null
+++ b/src/AmtocBots.Web/src/environments/environment.ts
@@ -0,0 +1,12 @@
+export const environment = {
+ production: false,
+ apiBase: '/api',
+ hubBase: '/hubs',
+ keycloak: {
+ authority: 'http://localhost:8180/realms/amtocbots',
+ clientId: 'amtocbots-web',
+ redirectUri: 'http://localhost:4200/callback',
+ postLogoutRedirectUri: 'http://localhost:4200',
+ scope: 'openid profile email roles',
+ },
+};
diff --git a/src/AmtocBots.Web/src/index.html b/src/AmtocBots.Web/src/index.html
new file mode 100644
index 0000000..002cc85
--- /dev/null
+++ b/src/AmtocBots.Web/src/index.html
@@ -0,0 +1,19 @@
+
+
+
+
+ AmtocBots Manager
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AmtocBots.Web/src/main.ts b/src/AmtocBots.Web/src/main.ts
new file mode 100644
index 0000000..2896ea7
--- /dev/null
+++ b/src/AmtocBots.Web/src/main.ts
@@ -0,0 +1,5 @@
+import { bootstrapApplication } from '@angular/platform-browser';
+import { AppComponent } from './app/app.component';
+import { appConfig } from './app/app.config';
+
+bootstrapApplication(AppComponent, appConfig).catch(err => console.error(err));
diff --git a/src/AmtocBots.Web/src/manifest.webmanifest b/src/AmtocBots.Web/src/manifest.webmanifest
new file mode 100644
index 0000000..d0abc76
--- /dev/null
+++ b/src/AmtocBots.Web/src/manifest.webmanifest
@@ -0,0 +1,19 @@
+{
+ "name": "AmtocBots Manager",
+ "short_name": "AmtocBots",
+ "theme_color": "#1e293b",
+ "background_color": "#0f172a",
+ "display": "standalone",
+ "scope": "/",
+ "start_url": "/",
+ "icons": [
+ { "src": "assets/icons/icon-72x72.png", "sizes": "72x72", "type": "image/png" },
+ { "src": "assets/icons/icon-96x96.png", "sizes": "96x96", "type": "image/png" },
+ { "src": "assets/icons/icon-128x128.png", "sizes": "128x128", "type": "image/png" },
+ { "src": "assets/icons/icon-144x144.png", "sizes": "144x144", "type": "image/png" },
+ { "src": "assets/icons/icon-152x152.png", "sizes": "152x152", "type": "image/png" },
+ { "src": "assets/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png" },
+ { "src": "assets/icons/icon-384x384.png", "sizes": "384x384", "type": "image/png" },
+ { "src": "assets/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png" }
+ ]
+}
diff --git a/src/AmtocBots.Web/src/styles.scss b/src/AmtocBots.Web/src/styles.scss
new file mode 100644
index 0000000..d04edc0
--- /dev/null
+++ b/src/AmtocBots.Web/src/styles.scss
@@ -0,0 +1,53 @@
+@use '@angular/material' as mat;
+
+// ── Theme ─────────────────────────────────────────────────────────────────────
+$primary: mat.define-palette(mat.$blue-palette, 600);
+$accent: mat.define-palette(mat.$cyan-palette, 400);
+$warn: mat.define-palette(mat.$red-palette);
+
+$theme: mat.define-dark-theme((
+ color: (primary: $primary, accent: $accent, warn: $warn),
+ typography: mat.define-typography-config($font-family: 'Inter, sans-serif'),
+ density: -1,
+));
+
+@include mat.all-component-themes($theme);
+
+// ── CSS Custom Properties ─────────────────────────────────────────────────────
+:root {
+ --bg-base: #0f172a;
+ --bg-surface: #1e293b;
+ --bg-elevated: #293548;
+ --border: #334155;
+ --text-primary: #f1f5f9;
+ --text-secondary: #94a3b8;
+ --accent-blue: #3b82f6;
+ --accent-green: #10b981;
+ --accent-amber: #f59e0b;
+ --accent-red: #ef4444;
+ --radius-sm: 6px;
+ --radius-md: 10px;
+ --radius-lg: 16px;
+}
+
+// ── Global Reset ──────────────────────────────────────────────────────────────
+*, *::before, *::after { box-sizing: border-box; }
+
+html, body {
+ height: 100%;
+ margin: 0;
+ background: var(--bg-base);
+ color: var(--text-primary);
+ font-family: 'Inter', sans-serif;
+ font-size: 14px;
+ line-height: 1.5;
+}
+
+// ── Scrollbar ─────────────────────────────────────────────────────────────────
+::-webkit-scrollbar { width: 6px; height: 6px; }
+::-webkit-scrollbar-track { background: var(--bg-base); }
+::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
+
+// ── Utilities ─────────────────────────────────────────────────────────────────
+.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }
diff --git a/src/AmtocBots.Web/tsconfig.app.json b/src/AmtocBots.Web/tsconfig.app.json
new file mode 100644
index 0000000..5b9d3c5
--- /dev/null
+++ b/src/AmtocBots.Web/tsconfig.app.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./out-tsc/app",
+ "types": []
+ },
+ "files": ["src/main.ts"],
+ "include": ["src/**/*.d.ts"]
+}
diff --git a/src/AmtocBots.Web/tsconfig.json b/src/AmtocBots.Web/tsconfig.json
new file mode 100644
index 0000000..d7877ac
--- /dev/null
+++ b/src/AmtocBots.Web/tsconfig.json
@@ -0,0 +1,31 @@
+{
+ "compileOnSave": false,
+ "compilerOptions": {
+ "outDir": "./dist/out-tsc",
+ "strict": true,
+ "noImplicitOverride": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "noImplicitReturns": true,
+ "forceConsistentCasingInFileNames": true,
+ "newLine": "lf",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "lib": ["ES2022", "dom"],
+ "target": "ES2022",
+ "module": "ES2022",
+ "useDefineForClassFields": false,
+ "experimentalDecorators": true,
+ "paths": {
+ "@core/*": ["src/app/core/*"],
+ "@shared/*": ["src/app/shared/*"],
+ "@features/*": ["src/app/features/*"],
+ "@env/*": ["src/environments/*"]
+ }
+ },
+ "angularCompilerOptions": {
+ "enableI18nLegacyMessageIdFormat": false,
+ "strictInjectionParameters": true,
+ "strictInputAccessModifiers": true,
+ "strictTemplates": true
+ }
+}