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.name }} + +
+
+ {{ 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 + } +}