fixes and api setup
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 2s

This commit is contained in:
Gal Podlipnik 2025-06-03 01:46:33 +02:00
parent 2381c51789
commit 459deb255d
25 changed files with 2821 additions and 1655 deletions

View File

@ -1,15 +1,24 @@
import type { WSContext } from 'hono/ws'; import type { WSContext } from 'hono/ws';
import { listContainers, streamContainerStats } from '../docker/containers.js'; import { listContainers, streamContainerStats } from '../docker/containers.js';
import { sendContainerInfo, sendStats, sendCombined, sendToSingleContainer } from './sender.js'; import { sendContainerInfo, sendStats, sendCombined, sendToSingleContainer } from './sender.js';
import type { ContainerInfo, ContainerStats } from './types.js';
import type { DockerContainer } from '../docker/types.js';
// WebSocket client management and stream orchestration
const containerClients = new Set<WSContext<WebSocket>>(); const containerClients = new Set<WSContext<WebSocket>>();
const statsClients = new Set<WSContext<WebSocket>>(); const statsClients = new Set<WSContext<WebSocket>>();
const combinedClients = new Set<WSContext<WebSocket>>(); const combinedClients = new Set<WSContext<WebSocket>>();
const singleContainerClients = new Map<string, Set<WSContext<WebSocket>>>(); const singleContainerClients = new Map<string, Set<WSContext<WebSocket>>>();
const activeStreams = new Map<string, () => void>(); const activeStreams = new Map<string, () => void>();
// Client registration functions
const containerCache = new Map<string, {
data: DockerContainer | null;
lastUpdated: number;
}>();
const CACHE_TTL = 2000;
export function registerContainerClient(ws: WSContext<WebSocket>) { export function registerContainerClient(ws: WSContext<WebSocket>) {
const wasEmpty = allClientsEmpty(); const wasEmpty = allClientsEmpty();
containerClients.add(ws); containerClients.add(ws);
@ -39,7 +48,7 @@ export function registerSingleContainerClient(ws: WSContext<WebSocket>, containe
} }
} }
// Client removal functions
export function removeContainerClient(ws: WSContext<WebSocket>) { export function removeContainerClient(ws: WSContext<WebSocket>) {
containerClients.delete(ws); containerClients.delete(ws);
checkAndCleanup(); checkAndCleanup();
@ -83,53 +92,89 @@ async function setupStreams() {
if (allClientsEmpty()) return; if (allClientsEmpty()) return;
try { try {
const containers = await listContainers(); const containers = await listContainers();
for (const container of containers) {
containerCache.set(container.id, {
data: container,
lastUpdated: Date.now()
});
}
for (const container of containers) { for (const container of containers) {
if (activeStreams.has(container.id)) continue; if (activeStreams.has(container.id)) continue;
setupStreamForContainer(container.id, containers); setupStreamForContainer(container.id);
} }
} catch (error) { } catch (error) {
console.error('Error setting up streams:', error); console.error('Error setting up streams:', error);
} }
} }
async function setupStreamForContainer(containerId: string, allContainers?: any[]) { async function getContainerData(containerId: string): Promise<DockerContainer | null> {
if (activeStreams.has(containerId)) return; const cached = containerCache.get(containerId);
const now = Date.now();
if (cached && (now - cached.lastUpdated < CACHE_TTL)) {
return cached.data;
}
try { try {
let containerData; const containers = await listContainers();
if (!allContainers) { const containerData = containers.find(c => c.id === containerId) || null;
const containers = await listContainers();
containerData = containers.find(c => c.id === containerId); for (const container of containers) {
} else { containerCache.set(container.id, {
containerData = allContainers.find(c => c.id === containerId); data: container,
lastUpdated: now
});
} }
return containerData;
} catch (error) {
console.error(`Error fetching container data for ${containerId}:`, error);
return cached?.data || null;
}
}
async function setupStreamForContainer(containerId: string) {
if (activeStreams.has(containerId)) return;
try {
const containerData = await getContainerData(containerId);
if (!containerData) { if (!containerData) {
console.warn(`Container ${containerId} not found`); console.warn(`Container ${containerId} not found`);
return; return;
} }
const stopStream = await streamContainerStats(containerId, async (stats) => {
const stopStream = await streamContainerStats(containerId, async (stats: ContainerStats) => {
if (allClientsEmpty()) { if (allClientsEmpty()) {
stopAllStreams(); stopAllStreams();
return; return;
} }
// Always fetch latest container info
let containerData;
if (!allContainers) { const freshContainerData = await getContainerData(containerId);
const containers = await listContainers();
containerData = containers.find(c => c.id === containerId); const containerInfo: ContainerInfo = freshContainerData ? {
} else { id: freshContainerData.id,
containerData = allContainers.find(c => c.id === containerId); name: freshContainerData.names?.[0]?.replace('/', '') ?? freshContainerData.id.slice(0, 12),
} image: freshContainerData.image,
if (!containerData) { state: freshContainerData.state,
console.warn(`Container ${containerId} not found`); status: freshContainerData.status,
return; } : {
}
const containerInfo = {
id: containerData.id, id: containerData.id,
name: containerData.names?.[0]?.replace('/', '') ?? containerData.id.slice(0, 12), name: containerData.names?.[0]?.replace('/', '') ?? containerData.id.slice(0, 12),
image: containerData.image, image: containerData.image,
state: containerData.state, state: 'exited',
status: containerData.status, status: 'Container stopped',
}; };
if (!freshContainerData && !singleContainerClients.has(containerId)) {
stopStreamForContainer(containerId);
return;
}
if (containerClients.size > 0) { if (containerClients.size > 0) {
sendContainerInfo(containerClients, containerInfo); sendContainerInfo(containerClients, containerInfo);
} }
@ -144,9 +189,12 @@ async function setupStreamForContainer(containerId: string, allContainers?: any[
sendToSingleContainer(singleContainerClients, containerId, containerInfo, stats); sendToSingleContainer(singleContainerClients, containerId, containerInfo, stats);
} }
}); });
activeStreams.set(containerId, stopStream); activeStreams.set(containerId, stopStream);
} catch (error) { } catch (error) {
console.error(`Failed to setup stream for container ${containerId}:`, error); console.error(`Failed to setup stream for container ${containerId}:`, error);
containerCache.delete(containerId);
} }
} }
@ -155,6 +203,8 @@ function stopStreamForContainer(containerId: string) {
if (stopStream) { if (stopStream) {
stopStream(); stopStream();
activeStreams.delete(containerId); activeStreams.delete(containerId);
containerCache.delete(containerId);
} }
} }
@ -163,4 +213,5 @@ export function stopAllStreams() {
stopStream(); stopStream();
} }
activeStreams.clear(); activeStreams.clear();
containerCache.clear();
} }

View File

@ -1,18 +1,21 @@
// Functions for sending data to WebSocket clients
import type { WSContext } from 'hono/ws'; import type { WSContext } from 'hono/ws';
import type { ContainerInfo, ContainerStats } from './types.js';
export function sendContainerInfo(containerClients: Set<WSContext<WebSocket>>, containerInfo: any) { export function sendContainerInfo(containerClients: Set<WSContext<WebSocket>>, containerInfo: ContainerInfo) {
const payload = JSON.stringify({ containers: [containerInfo] }); const payload = JSON.stringify({ containers: [containerInfo] });
sendToClientSet(containerClients, payload); sendToClientSet(containerClients, payload);
} }
export function sendStats(statsClients: Set<WSContext<WebSocket>>, stats: any) { export function sendStats(statsClients: Set<WSContext<WebSocket>>, stats: ContainerStats) {
const payload = JSON.stringify({ stats: [stats] }); const payload = JSON.stringify({ stats: [stats] });
sendToClientSet(statsClients, payload); sendToClientSet(statsClients, payload);
} }
export function sendCombined(combinedClients: Set<WSContext<WebSocket>>, containerInfo: any, stats: any) { export function sendCombined(
combinedClients: Set<WSContext<WebSocket>>,
containerInfo: ContainerInfo,
stats: ContainerStats
) {
const payload = JSON.stringify({ const payload = JSON.stringify({
containers: [containerInfo], containers: [containerInfo],
stats: [stats] stats: [stats]
@ -20,7 +23,12 @@ export function sendCombined(combinedClients: Set<WSContext<WebSocket>>, contain
sendToClientSet(combinedClients, payload); sendToClientSet(combinedClients, payload);
} }
export function sendToSingleContainer(singleContainerClients: Map<string, Set<WSContext<WebSocket>>>, containerId: string, containerInfo: any, stats: any) { export function sendToSingleContainer(
singleContainerClients: Map<string, Set<WSContext<WebSocket>>>,
containerId: string,
containerInfo: ContainerInfo,
stats: ContainerStats
) {
const combinedData = { const combinedData = {
...containerInfo, ...containerInfo,
...stats ...stats
@ -35,7 +43,12 @@ export function sendToSingleContainer(singleContainerClients: Map<string, Set<WS
export function sendToClientSet(clients: Set<WSContext<WebSocket>>, payload: string) { export function sendToClientSet(clients: Set<WSContext<WebSocket>>, payload: string) {
for (const ws of clients) { for (const ws of clients) {
if (ws.readyState === 1) { if (ws.readyState === 1) {
ws.send(payload); try {
ws.send(payload);
} catch (err) {
console.error('Error sending to client:', err);
clients.delete(ws);
}
} else { } else {
clients.delete(ws); clients.delete(ws);
} }

View File

@ -0,0 +1,7 @@
# environment
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test environment` to execute the unit tests.

View File

@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../../eslint.base.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'lib',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'lib',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@ -0,0 +1,21 @@
export default {
displayName: 'environment',
preset: '../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../coverage/libs/shared/environment',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};

View File

@ -0,0 +1,20 @@
{
"name": "environment",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/shared/environment/src",
"prefix": "lib",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/shared/environment/jest.config.ts"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@ -0,0 +1,2 @@
export * from './lib/types';
export * from './lib/token';

View File

@ -0,0 +1,4 @@
import { InjectionToken } from '@angular/core';
import { Environment } from './types';
export const ENVIRONMENT = new InjectionToken<Environment>('environment');

View File

@ -0,0 +1,16 @@
export type Environment = {
wsBaseUrl: string;
production: boolean;
apis: APIS;
}
export const API_KEYS = [
'containers',
'container',
'stats',
'combined'
] as const;
export type ApiKey = (typeof API_KEYS)[number];
export type APIS = Record<ApiKey, string>;

View File

@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});

View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "es2022",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../../tsconfig.base.json",
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts"
],
"include": ["src/**/*.ts"]
}

View File

@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"]
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

View File

@ -1,36 +1,161 @@
import { computed, inject, Injectable, signal } from "@angular/core"; import { effect, inject, Injectable, signal } from '@angular/core';
import { HttpClient } from "@angular/common/http"; import { ApiKey, ENVIRONMENT } from '@frontend/shared/environment';
import { ContainerInfo, PayloadContainers } from "./types"; import {
ContainerInfo,
ContainerStats,
PayloadContainerDetail,
PayloadContainers,
PayloadStats,
} from './types';
@Injectable({ @Injectable({
providedIn: "root", providedIn: 'root',
}) })
export class StatsWsService { export class StatsWsService {
private _containers = signal<ContainerInfo[]>([]); readonly containers = signal<ContainerInfo[]>([]);
readonly containerStats = signal<Record<string, ContainerStats>>({});
readonly containerDetail = signal<PayloadContainerDetail | null>(null);
private environment = inject(ENVIRONMENT);
private connections: Partial<Record<ApiKey, WebSocket>> = {};
private socket?: WebSocket; constructor() {
this.connectToEndpoint('containers');
constructor() { effect(() => {
this.initWebSocket(); return () => this.closeAllConnections();
} });
}
readonly containers = computed(() => this._containers()); initContainers(): void {
this.connectToEndpoint('containers');
}
private initWebSocket() { initStats(): void {
this.socket = new WebSocket("ws://localhost:3000/ws/containers") this.connectToEndpoint('stats');
this.socket.onmessage = (event) => { this.connectToEndpoint('containers');
try { }
const parsed = JSON.parse(event.data) as PayloadContainers;
this._containers.set(parsed.containers);
} catch (error) {
console.error("Error parsing WebSocket message:", error);
}
}
this.socket.onclose = () => { initContainerDetail(containerId: string): void {
console.warn("WebSocket connection closed. Attempting to reconnect..."); this.connectToEndpoint('container', { id: containerId });
setTimeout(() => this.initWebSocket(), 2000); }
}
}; initCombined(): void {
}; this.connectToEndpoint('combined');
}
connectToEndpoint(
endpoint: ApiKey,
queryParams?: Record<string, string>
): WebSocket | undefined {
if (this.connections[endpoint]) {
return this.connections[endpoint];
}
try {
let wsUrl = `${this.environment.wsBaseUrl}${this.environment.apis[endpoint]}`;
if (queryParams) {
const params = new URLSearchParams();
Object.entries(queryParams).forEach(([key, value]) => {
params.append(key, value);
});
wsUrl += `?${params.toString()}`;
}
const socket = new WebSocket(wsUrl);
socket.onmessage = (event) => this.handleMessage(event, endpoint);
socket.onclose = () => {
console.warn(
`WebSocket connection to ${endpoint} closed. Attempting to reconnect...`
);
delete this.connections[endpoint];
setTimeout(() => this.connectToEndpoint(endpoint), 2000);
};
socket.onerror = (error) => {
console.error(`WebSocket error for ${endpoint}:`, error);
};
this.connections[endpoint] = socket;
return socket;
} catch (error) {
console.error(`Failed to connect to ${endpoint}:`, error);
return undefined;
}
}
closeConnection(endpoint: ApiKey): void {
if (this.connections[endpoint]) {
this.connections[endpoint]?.close();
delete this.connections[endpoint];
}
}
closeAllConnections(): void {
Object.keys(this.connections).forEach((key) => {
this.closeConnection(key as ApiKey);
});
}
private handleMessage(event: MessageEvent, endpoint: ApiKey): void {
try {
const data = JSON.parse(event.data);
switch (endpoint) {
case 'containers':
this.handleContainersMessage(data as PayloadContainers);
break;
case 'stats':
this.handleStatsMessage(data as PayloadStats);
break;
case 'container':
this.handleContainerDetailMessage(data as PayloadContainerDetail);
break;
case 'combined':
this.handleCombinedMessage(data);
break;
default:
console.warn(`Unhandled endpoint type: ${endpoint}`);
}
} catch (error) {
console.error(`Error parsing WebSocket message from ${endpoint}:`, error);
}
}
private handleContainersMessage(data: PayloadContainers): void {
this.containers.set(data.containers);
}
private handleStatsMessage(data: PayloadStats): void {
if (data.stats && Array.isArray(data.stats)) {
data.stats.forEach((stat) => {
this.containerStats.update((currentStats) => ({
...currentStats,
[stat.id]: stat,
}));
});
}
}
private handleContainerDetailMessage(data: PayloadContainerDetail): void {
this.containerDetail.set(data);
}
private handleCombinedMessage(data: any): void {
if (data.containers) {
this.containers.set(data.containers);
}
if (data.stats) {
Object.entries(data.stats).forEach(([containerId, stats]) => {
this.containerStats.update((current) => ({
...current,
[containerId]: stats as ContainerStats,
}));
});
}
}
}

View File

@ -1,24 +1,33 @@
export type ContainerInfo = { export type ContainerInfo = {
id: string; id: string;
name: string; name: string;
image: string; image: string;
state: string; state: string;
status: string; status: string;
} };
export type ContainerStats = { export type ContainerStats = {
id: string id: string;
cpuPercent: number cpuPercent: number;
memUsage: number memUsage: number;
memLimit: number memLimit: number;
memPercent: number memPercent: number;
netRx: number netRx: number;
netTx: number netTx: number;
blkRead: number blkRead: number;
blkWrite: number blkWrite: number;
timestamp: number timestamp: number;
} };
export type PayloadContainers = { export type PayloadContainers = {
containers: ContainerInfo[]; containers: ContainerInfo[];
} };
export type PayloadStats = {
stats: ContainerStats[];
};
export type PayloadContainerDetail = {
container: ContainerInfo;
stats: ContainerStats;
};

File diff suppressed because it is too large Load Diff

View File

@ -9,51 +9,56 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/common": "~19.2.0", "@angular/cdk": "^20.0.1",
"@angular/compiler": "~19.2.0", "@angular/common": "20.0.0",
"@angular/core": "~19.2.0", "@angular/compiler": "20.0.0",
"@angular/forms": "~19.2.0", "@angular/core": "20.0.0",
"@angular/platform-browser": "~19.2.0", "@angular/forms": "20.0.0",
"@angular/platform-browser-dynamic": "~19.2.0", "@angular/material": "^20.0.1",
"@angular/router": "~19.2.0", "@angular/platform-browser": "20.0.0",
"rxjs": "~7.8.0", "@angular/platform-browser-dynamic": "20.0.0",
"zone.js": "~0.15.0" "@angular/router": "20.0.0",
"@mmstack/form-material": "^19.2.0",
"@mmstack/primitives": "^19.2.3",
"@mmstack/resource": "^19.2.0",
"rxjs": "7.8.2",
"zone.js": "0.15.1"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "~19.2.0", "@angular-devkit/build-angular": "20.0.0",
"@angular-devkit/core": "~19.2.0", "@angular-devkit/core": "20.0.0",
"@angular-devkit/schematics": "~19.2.0", "@angular-devkit/schematics": "20.0.0",
"@angular/cli": "~19.2.0", "@angular/cli": "20.0.0",
"@angular/compiler-cli": "~19.2.0", "@angular/compiler-cli": "20.0.0",
"@angular/language-service": "~19.2.0", "@angular/language-service": "20.0.0",
"@eslint/js": "^9.8.0", "@eslint/js": "9.28.0",
"@nx/angular": "21.1.2", "@nx/angular": "21.1.2",
"@nx/eslint": "21.1.2", "@nx/eslint": "21.1.2",
"@nx/eslint-plugin": "^21.1.2", "@nx/eslint-plugin": "21.1.2",
"@nx/jest": "21.1.2", "@nx/jest": "21.1.2",
"@nx/js": "21.1.2", "@nx/js": "21.1.2",
"@nx/web": "21.1.2", "@nx/web": "21.1.2",
"@nx/workspace": "21.1.2", "@nx/workspace": "21.1.2",
"@schematics/angular": "~19.2.0", "@schematics/angular": "20.0.0",
"@swc-node/register": "~1.9.1", "@swc-node/register": "1.10.10",
"@swc/core": "~1.5.7", "@swc/core": "1.11.29",
"@swc/helpers": "~0.5.11", "@swc/helpers": "0.5.17",
"@types/jest": "^29.5.12", "@types/jest": "29.5.14",
"@types/node": "18.16.9", "@types/node": "22.15.29",
"@typescript-eslint/utils": "^8.19.0", "@typescript-eslint/utils": "8.33.1",
"angular-eslint": "^19.2.0", "angular-eslint": "19.7.0",
"eslint": "^9.8.0", "eslint": "9.28.0",
"eslint-config-prettier": "^10.0.0", "eslint-config-prettier": "10.1.5",
"jest": "^29.7.0", "jest": "29.7.0",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "29.7.0",
"jest-preset-angular": "~14.4.0", "jest-preset-angular": "14.6.0",
"nx": "21.1.2", "nx": "21.1.2",
"prettier": "^2.6.2", "prettier": "3.5.3",
"ts-jest": "^29.1.0", "ts-jest": "29.3.4",
"ts-node": "10.9.1", "ts-node": "10.9.2",
"tslib": "^2.3.0", "tslib": "2.8.1",
"typescript": "~5.7.2", "typescript": "5.8.3",
"typescript-eslint": "^8.19.0" "typescript-eslint": "8.33.1"
}, },
"nx": { "nx": {
"includedScripts": [] "includedScripts": []

View File

@ -1,88 +1,89 @@
{ {
"name": "frontend", "name": "frontend",
"$schema": "node_modules/nx/schemas/project-schema.json", "$schema": "node_modules/nx/schemas/project-schema.json",
"includedScripts": [], "includedScripts": [],
"projectType": "application", "projectType": "application",
"prefix": "app", "prefix": "app",
"sourceRoot": "./src", "sourceRoot": "./src",
"tags": [], "tags": [],
"targets": { "targets": {
"build": { "build": {
"executor": "@angular-devkit/build-angular:application", "executor": "@angular-devkit/build-angular:application",
"outputs": ["{options.outputPath}"], "outputs": ["{options.outputPath}"],
"options": { "options": {
"outputPath": "dist/frontend", "outputPath": "dist/frontend",
"index": "./src/index.html", "index": "./src/index.html",
"browser": "./src/main.ts", "browser": "./src/main.ts",
"polyfills": ["zone.js"], "polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json", "tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss", "inlineStyleLanguage": "scss",
"assets": [ "assets": [
{ {
"glob": "**/*", "glob": "**/*",
"input": "public" "input": "public"
} }
], ],
"styles": ["./src/styles.scss"], "styles": ["./src/styles.scss", "./src/theme.scss"],
"scripts": [] "scripts": []
}, },
"configurations": { "configurations": {
"production": { "production": {
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
"maximumWarning": "500kb", "maximumWarning": "500kb",
"maximumError": "1mb" "maximumError": "1mb"
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
"maximumWarning": "4kb", "maximumWarning": "4kb",
"maximumError": "8kb" "maximumError": "8kb"
} }
], ],
"outputHashing": "all" "outputHashing": "all"
}, },
"development": { "development": {
"optimization": false, "optimization": false,
"extractLicenses": false, "extractLicenses": false,
"sourceMap": true "sourceMap": true
} }
}, },
"defaultConfiguration": "production" "defaultConfiguration": "production"
}, },
"serve": { "serve": {
"continuous": true, "continuous": true,
"executor": "@angular-devkit/build-angular:dev-server", "executor": "@angular-devkit/build-angular:dev-server",
"configurations": { "configurations": {
"production": { "production": {
"buildTarget": "frontend:build:production" "buildTarget": "frontend:build:production"
}, },
"development": { "development": {
"buildTarget": "frontend:build:development" "buildTarget": "frontend:build:development"
} }
}, },
"defaultConfiguration": "development" "defaultConfiguration": "development"
}, },
"extract-i18n": { "extract-i18n": {
"executor": "@angular-devkit/build-angular:extract-i18n", "executor": "@angular-devkit/build-angular:extract-i18n",
"options": { "options": {
"buildTarget": "frontend:build" "buildTarget": "frontend:build"
} }
}, },
"lint": { "lint": {
"executor": "@nx/eslint:lint", "executor": "@nx/eslint:lint",
"options": { "options": {
"lintFilePatterns": ["./src"] "lintFilePatterns": ["./src"]
} }
}, },
"serve-static": { "serve-static": {
"continuous": true, "continuous": true,
"executor": "@nx/web:file-server", "executor": "@nx/web:file-server",
"options": { "options": {
"buildTarget": "frontend:build", "buildTarget": "frontend:build",
"staticFilePath": "dist/frontend/browser", "staticFilePath": "dist/frontend/browser",
"spa": true "spa": true
} }
} }
} }
} }

View File

@ -1,30 +1,35 @@
import { Component, inject } from '@angular/core'; import { CommonModule } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { StatsWsService } from '@frontend/shared/stats-ws'; import { StatsWsService } from '@frontend/shared/stats-ws';
import { CommonModule } from '@angular/common';
@Component({ @Component({
standalone: true, standalone: true,
imports: [RouterModule, CommonModule], imports: [RouterModule, CommonModule],
selector: 'app-root', selector: 'app-root',
template: ` template: `
<div> <div>
<h1>Docker Containers</h1> <h1>Docker Containers</h1>
@for (container of containers(); track container.id) { @for (container of containers(); track container.id) {
<div class="container"> <div class="container">
<h2>{{ container.name }}</h2> <h2>{{ container.name }}</h2>
<p>ID: {{ container.id }}</p> <p>ID: {{ container.id }}</p>
<p>Image: {{ container.image }}</p> <p>Image: {{ container.image }}</p>
<p>Status: {{ container.status }}</p> <p>Status: {{ container.status }}</p>
<p>State: {{ container.state }}</p> <p>State: {{ container.state }}</p>
</div> </div>
} }
</div> </div>
<router-outlet></router-outlet> <router-outlet></router-outlet>
` `,
}) })
export class AppComponent { export class AppComponent implements OnInit {
private readonly service = inject(StatsWsService); private readonly service = inject(StatsWsService);
protected containers = this.service.containers; protected containers = this.service.containers;
ngOnInit() {
this.service.initContainers();
}
} }

View File

@ -2,11 +2,14 @@ import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { appRoutes } from './app.routes'; import { appRoutes } from './app.routes';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
import { ENVIRONMENT } from '@frontend/shared/environment';
import { environment } from '../environments/environment';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideZoneChangeDetection({ eventCoalescing: true }), provideZoneChangeDetection({ eventCoalescing: true }),
provideHttpClient(), provideHttpClient(),
provideRouter(appRoutes), provideRouter(appRoutes),
{ provide: ENVIRONMENT, useValue: environment }
], ],
}; };

View File

@ -0,0 +1,13 @@
import { Environment } from '@frontend/shared/environment';
export const environment: Environment = {
// Change this to your production WebSocket server URL
wsBaseUrl: 'wss://your-production-server.com',
production: true,
apis: {
containers: '/ws/containers',
container: '/ws/container',
stats: '/ws/stats',
combined: '/ws/combined'
}
};

View File

@ -0,0 +1,12 @@
import { Environment } from '@frontend/shared/environment';
export const environment: Environment = {
wsBaseUrl: 'ws://localhost:3000',
production: false,
apis: {
containers: '/ws/containers',
container: '/ws/container',
stats: '/ws/stats',
combined: '/ws/combined'
}
};

16
frontend/src/theme.scss Normal file
View File

@ -0,0 +1,16 @@
@use '@angular/material' as mat;
:root {
color-scheme: light dark;
@include mat.theme(
(
color: (
primary: mat.$azure-palette,
tertiary: mat.$blue-palette,
theme-type: color-scheme,
),
typography: Roboto,
density: -3,
)
);
}

View File

@ -15,6 +15,7 @@
"skipDefaultLibCheck": true, "skipDefaultLibCheck": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@frontend/shared/environment": ["libs/shared/environment/src/index.ts"],
"@frontend/shared/stats-ws": ["libs/shared/stats-ws/src/index.ts"] "@frontend/shared/stats-ws": ["libs/shared/stats-ws/src/index.ts"]
} }
}, },