first commit

This commit is contained in:
Gal Podlipnik 2025-06-01 17:32:32 +02:00
commit a656d4ce07
52 changed files with 29044 additions and 0 deletions

0
README.md Normal file
View File

28
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,28 @@
# dev
.yarn/
!.yarn/releases
.vscode/*
!.vscode/launch.json
!.vscode/*.code-snippets
.idea/workspace.xml
.idea/usage.statistics.xml
.idea/shelf
# deps
node_modules/
# env
.env
.env.production
# logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# misc
.DS_Store

20
backend/Dockerfile Normal file
View File

@ -0,0 +1,20 @@
FROM node:22-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /usr/src/app
COPY package.json ./
COPY package-lock.json ./
RUN npm install --production
COPY src ./src
RUN chown -R appuser:appgroup /usr/src/app
USER appuser
EXPOSE 3000
CMD ["node", "src/index.js"]

8
backend/README.md Normal file
View File

@ -0,0 +1,8 @@
```
npm install
npm run dev
```
```
open http://localhost:3000
```

1369
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
backend/package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "backend",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@hono/node-server": "^1.14.3",
"@hono/node-ws": "^1.1.5",
"dockerode": "^4.0.6",
"hono": "^4.7.11"
},
"devDependencies": {
"@types/dockerode": "^3.3.39",
"@types/node": "^20.11.17",
"tsx": "^4.7.1",
"typescript": "^5.8.3"
}
}

View File

@ -0,0 +1,5 @@
import Docker from 'dockerode';
export function createDockerClient() {
return new Docker();
}

View File

@ -0,0 +1,144 @@
import { createDockerClient } from './client.js';
import type { DockerContainer, Networks } from './types.js';
const docker = createDockerClient();
export async function listContainers(): Promise<DockerContainer[]> {
try {
const containers = await docker.listContainers({ all: true });
return containers.map((c: any) => ({
id: c.Id,
names: c.Names,
image: c.Image,
state: c.State,
status: c.Status,
created: new Date(c.Created * 1000).toISOString(),
ports: c.Ports.map((p: any) => ({
privatePort: p.PrivatePort,
publicPort: p.PublicPort,
type: p.Type,
ip: p.IP,
})),
}));
} catch (error) {
console.error('Error listing containers:', error);
throw new Error('Failed to list Docker containers');
}
}
export async function getContainerStats(containerId: string) {
const container = docker.getContainer(containerId);
const stats = await container.stats({ stream: false });
const prevCpu = stats.precpu_stats.cpu_usage.total_usage;
const prevSys = stats.precpu_stats.system_cpu_usage;
const currCpu = stats.cpu_stats.cpu_usage.total_usage;
const currSys = stats.cpu_stats.system_cpu_usage;
const cpuDelta = currCpu - prevCpu;
const sysDelta = currSys - prevSys;
const cpuPercent =
sysDelta > 0
? (cpuDelta / sysDelta) * stats.cpu_stats.cpu_usage.percpu_usage.length * 100.0
: 0.0;
const memUsage = stats.memory_stats.usage;
const memLimit = stats.memory_stats.limit;
const memPercent = memLimit > 0 ? (memUsage / memLimit) * 100.0 : 0.0;
let netRx = 0,
netTx = 0;
if (stats.networks) {
const networks: Networks = stats.networks;
for (const iface of Object.values(networks)) {
netRx += iface.rx_bytes || 0;
netTx += iface.tx_bytes || 0;
}
}
let blkRead = 0,
blkWrite = 0;
if (
stats.blkio_stats &&
Array.isArray(stats.blkio_stats.io_service_bytes_recursive)
) {
for (const entry of stats.blkio_stats.io_service_bytes_recursive) {
if (entry.op === 'Read') blkRead += entry.value;
if (entry.op === 'Write') blkWrite += entry.value;
}
}
return {
id: containerId,
cpuPercent,
memUsage,
memLimit,
memPercent,
netRx,
netTx,
blkRead,
blkWrite,
timestamp: Date.now(),
};
}
export async function streamContainerStats(
containerId: string,
callback: (stats: any) => void
): Promise<() => void> {
const container = docker.getContainer(containerId);
try {
const stream = (await container.stats({ stream: true })) as import('stream').Readable;
stream.on('data', (chunk: Buffer) => {
const stats = JSON.parse(chunk.toString());
const prevCpu = stats.precpu_stats.cpu_usage.total_usage;
const prevSys = stats.precpu_stats.system_cpu_usage;
const currCpu = stats.cpu_stats.cpu_usage.total_usage;
const currSys = stats.cpu_stats.system_cpu_usage;
const cpuDelta = currCpu - prevCpu;
const sysDelta = currSys - prevSys;
const cpuPercent =
sysDelta > 0
? (cpuDelta / sysDelta) * stats.cpu_stats.cpu_usage.percpu_usage.length * 100.0
: 0.0;
const memUsage = stats.memory_stats.usage;
const memLimit = stats.memory_stats.limit;
const memPercent = memLimit > 0 ? (memUsage / memLimit) * 100.0 : 0.0;
let netRx = 0,
netTx = 0;
if (stats.networks) {
const networks: Networks = stats.networks;
for (const iface of Object.values(networks)) {
netRx += iface.rx_bytes || 0;
netTx += iface.tx_bytes || 0;
}
}
let blkRead = 0,
blkWrite = 0;
if (
stats.blkio_stats &&
Array.isArray(stats.blkio_stats.io_service_bytes_recursive)
) {
for (const entry of stats.blkio_stats.io_service_bytes_recursive) {
if (entry.op === 'Read') blkRead += entry.value;
if (entry.op === 'Write') blkWrite += entry.value;
}
}
callback({
id: containerId,
cpuPercent,
memUsage,
memLimit,
memPercent,
netRx,
netTx,
blkRead,
blkWrite,
timestamp: Date.now(),
});
});
stream.on('error', (err: Error) => {
console.error(`Error streaming stats for container ${containerId}:`, err);
});
return () => {
stream.destroy();
};
} catch (error) {
console.error(`Error getting stats stream for container ${containerId}:`, error);
throw new Error(`Failed to get stats stream for container ${containerId}`);
}
}

View File

@ -0,0 +1,33 @@
// Docker types and interfaces
export type DockerContainer = {
id: string;
names: string[];
image: string;
state: string;
status: string;
created: string;
ports: DockerContainerPort[];
}
export type DockerContainerPort = {
privatePort: number;
publicPort: number | null;
type: string;
ip: string;
}
export type NetworkInterface = {
rx_bytes: number;
rx_packets: number;
rx_errors: number;
rx_dropped: number;
tx_bytes: number;
tx_packets: number;
tx_errors: number;
tx_dropped: number;
}
export type Networks = {
[interfaceName: string]: NetworkInterface;
}

112
backend/src/index.ts Normal file
View File

@ -0,0 +1,112 @@
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { createNodeWebSocket } from '@hono/node-ws'
import {
registerContainerClient,
registerStatsClient,
registerCombinedClient,
registerSingleContainerClient,
removeContainerClient,
removeStatsClient,
removeCombinedClient,
removeSingleContainerClient,
stopAllStreams
} from './ws/manager.js';
const app = new Hono();
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
app.get('/', (c) => c.text('WS Docker Stats server (ESM) is running.'))
// Containers-only endpoint
app.get('/ws/containers', upgradeWebSocket(c => ({
onOpen(_, ws) {
console.log('WebSocket containers connection opened');
registerContainerClient(ws);
},
onClose(_, ws) {
console.log('❌ WS containers client disconnected');
removeContainerClient(ws);
},
onError(err) {
console.error('❌ WS containers error:', err);
}
})))
// Stats-only endpoint
app.get('/ws/stats', upgradeWebSocket(c => ({
onOpen(_, ws) {
console.log('WebSocket stats connection opened');
registerStatsClient(ws);
},
onClose(_, ws) {
console.log('❌ WS stats client disconnected');
removeStatsClient(ws);
},
onError(err) {
console.error('❌ WS stats error:', err);
}
})))
// Combined endpoint (explicit)
app.get('/ws/combined', upgradeWebSocket(c => ({
onOpen(_, ws) {
console.log('WebSocket combined connection opened');
registerCombinedClient(ws);
},
onClose(_, ws) {
console.log('❌ WS combined client disconnected');
removeCombinedClient(ws);
},
onError(err) {
console.error('❌ WS combined error:', err);
}
})))
// Single container endpoint
app.get('/ws/container', upgradeWebSocket(c => {
const containerId = c.req.query('id');
if (!containerId) {
throw new Error('Container ID is required');
}
return {
onOpen(_, ws) {
console.log(`WebSocket connection opened for container ${containerId}`);
registerSingleContainerClient(ws, containerId);
},
onClose(_, ws) {
console.log(`❌ WS client for container ${containerId} disconnected`);
removeSingleContainerClient(ws, containerId);
},
onError(err) {
console.error(`❌ WS error for container ${containerId}:`, err);
}
};
}))
const server = serve({
fetch: app.fetch,
port: 3000
}, (info) => {
console.log(`Server is running on http://localhost:${info.port}`)
})
injectWebSocket(server);
process.on('SIGTERM', () => cleanupAndExit());
process.on('SIGINT', () => cleanupAndExit());
function cleanupAndExit() {
console.log('Shutting down gracefully...');
stopAllStreams();
server.close(() => {
console.log('Server closed');
process.exit(0);
});
setTimeout(() => {
console.log('Forcing exit after timeout');
process.exit(1);
}, 5000);
}

55
backend/src/types.ts Normal file
View File

@ -0,0 +1,55 @@
export type DockerContainer = {
id: string;
names: string[];
image: string;
state: string;
status: string;
created: string;
ports: DockerContainerPort[];
}
export type DockerContainerPort = {
privatePort: number;
publicPort: number | null;
type: string;
ip: string;
}
export type NetworkInterface = {
rx_bytes: number;
rx_packets: number;
rx_errors: number;
rx_dropped: number;
tx_bytes: number;
tx_packets: number;
tx_errors: number;
tx_dropped: number;
}
export type Networks = {
[interfaceName: string]: NetworkInterface;
}
// New types for WebSocket responses
export type ContainerInfo = {
id: string;
name: string;
image: string;
state: string;
status: string;
}
export type ContainerStats = {
id: string;
cpuPercent: number;
memUsage: number;
memLimit: number;
memPercent: number;
netRx: number;
netTx: number;
blkRead: number;
blkWrite: number;
timestamp: number;
}
export type CombinedContainerData = ContainerInfo & ContainerStats;

View File

@ -0,0 +1,16 @@
import Docker from 'dockerode';
function createDockerClient() {
const useSocket = process.env.DOCKER_USE_SOCKET === 'true';
if (useSocket) {
const socketPath = process.env.DOCKER_SOCKET_PATH || '/var/run/docker.sock';
return new Docker({ socketPath });
} else {
const host = process.env.DOCKER_HOST || '127.0.0.1';
const port = parseInt(process.env.DOCKER_PORT || '2375', 10);
return new Docker({ host, port });
}
}
export default createDockerClient;

166
backend/src/ws/manager.ts Normal file
View File

@ -0,0 +1,166 @@
import type { WSContext } from 'hono/ws';
import { listContainers, streamContainerStats } from '../docker/containers.js';
import { sendContainerInfo, sendStats, sendCombined, sendToSingleContainer } from './sender.js';
// WebSocket client management and stream orchestration
const containerClients = new Set<WSContext<WebSocket>>();
const statsClients = new Set<WSContext<WebSocket>>();
const combinedClients = new Set<WSContext<WebSocket>>();
const singleContainerClients = new Map<string, Set<WSContext<WebSocket>>>();
const activeStreams = new Map<string, () => void>();
// Client registration functions
export function registerContainerClient(ws: WSContext<WebSocket>) {
const wasEmpty = allClientsEmpty();
containerClients.add(ws);
if (wasEmpty) setupStreams();
}
export function registerStatsClient(ws: WSContext<WebSocket>) {
const wasEmpty = allClientsEmpty();
statsClients.add(ws);
if (wasEmpty) setupStreams();
}
export function registerCombinedClient(ws: WSContext<WebSocket>) {
const wasEmpty = allClientsEmpty();
combinedClients.add(ws);
if (wasEmpty) setupStreams();
}
export function registerSingleContainerClient(ws: WSContext<WebSocket>, containerId: string) {
if (!singleContainerClients.has(containerId)) {
singleContainerClients.set(containerId, new Set());
}
const clients = singleContainerClients.get(containerId)!;
clients.add(ws);
if (clients.size === 1) {
setupStreamForContainer(containerId);
}
}
// Client removal functions
export function removeContainerClient(ws: WSContext<WebSocket>) {
containerClients.delete(ws);
checkAndCleanup();
}
export function removeStatsClient(ws: WSContext<WebSocket>) {
statsClients.delete(ws);
checkAndCleanup();
}
export function removeCombinedClient(ws: WSContext<WebSocket>) {
combinedClients.delete(ws);
checkAndCleanup();
}
export function removeSingleContainerClient(ws: WSContext<WebSocket>, containerId: string) {
const clients = singleContainerClients.get(containerId);
if (clients) {
clients.delete(ws);
if (clients.size === 0) {
singleContainerClients.delete(containerId);
stopStreamForContainer(containerId);
}
}
}
function allClientsEmpty() {
return containerClients.size === 0 &&
statsClients.size === 0 &&
combinedClients.size === 0 &&
singleContainerClients.size === 0;
}
function checkAndCleanup() {
if (allClientsEmpty()) {
stopAllStreams();
}
}
async function setupStreams() {
if (allClientsEmpty()) return;
try {
const containers = await listContainers();
for (const container of containers) {
if (activeStreams.has(container.id)) continue;
setupStreamForContainer(container.id, containers);
}
} catch (error) {
console.error('Error setting up streams:', error);
}
}
async function setupStreamForContainer(containerId: string, allContainers?: any[]) {
if (activeStreams.has(containerId)) return;
try {
let containerData;
if (!allContainers) {
const containers = await listContainers();
containerData = containers.find(c => c.id === containerId);
} else {
containerData = allContainers.find(c => c.id === containerId);
}
if (!containerData) {
console.warn(`Container ${containerId} not found`);
return;
}
const stopStream = await streamContainerStats(containerId, async (stats) => {
if (allClientsEmpty()) {
stopAllStreams();
return;
}
// Always fetch latest container info
let containerData;
if (!allContainers) {
const containers = await listContainers();
containerData = containers.find(c => c.id === containerId);
} else {
containerData = allContainers.find(c => c.id === containerId);
}
if (!containerData) {
console.warn(`Container ${containerId} not found`);
return;
}
const containerInfo = {
id: containerData.id,
name: containerData.names?.[0]?.replace('/', '') ?? containerData.id.slice(0, 12),
image: containerData.image,
state: containerData.state,
status: containerData.status,
};
if (containerClients.size > 0) {
sendContainerInfo(containerClients, containerInfo);
}
if (statsClients.size > 0) {
sendStats(statsClients, stats);
}
if (combinedClients.size > 0) {
sendCombined(combinedClients, containerInfo, stats);
}
const singleClients = singleContainerClients.get(containerId);
if (singleClients && singleClients.size > 0) {
sendToSingleContainer(singleContainerClients, containerId, containerInfo, stats);
}
});
activeStreams.set(containerId, stopStream);
} catch (error) {
console.error(`Failed to setup stream for container ${containerId}:`, error);
}
}
function stopStreamForContainer(containerId: string) {
const stopStream = activeStreams.get(containerId);
if (stopStream) {
stopStream();
activeStreams.delete(containerId);
}
}
export function stopAllStreams() {
for (const stopStream of activeStreams.values()) {
stopStream();
}
activeStreams.clear();
}

43
backend/src/ws/sender.ts Normal file
View File

@ -0,0 +1,43 @@
// Functions for sending data to WebSocket clients
import type { WSContext } from 'hono/ws';
export function sendContainerInfo(containerClients: Set<WSContext<WebSocket>>, containerInfo: any) {
const payload = JSON.stringify({ containers: [containerInfo] });
sendToClientSet(containerClients, payload);
}
export function sendStats(statsClients: Set<WSContext<WebSocket>>, stats: any) {
const payload = JSON.stringify({ stats: [stats] });
sendToClientSet(statsClients, payload);
}
export function sendCombined(combinedClients: Set<WSContext<WebSocket>>, containerInfo: any, stats: any) {
const payload = JSON.stringify({
containers: [containerInfo],
stats: [stats]
});
sendToClientSet(combinedClients, payload);
}
export function sendToSingleContainer(singleContainerClients: Map<string, Set<WSContext<WebSocket>>>, containerId: string, containerInfo: any, stats: any) {
const combinedData = {
...containerInfo,
...stats
};
const payload = JSON.stringify({ container: combinedData });
const clients = singleContainerClients.get(containerId);
if (clients) {
sendToClientSet(clients, payload);
}
}
export function sendToClientSet(clients: Set<WSContext<WebSocket>>, payload: string) {
for (const ws of clients) {
if (ws.readyState === 1) {
ws.send(payload);
} else {
clients.delete(ws);
}
}
}

24
backend/src/ws/types.ts Normal file
View File

@ -0,0 +1,24 @@
// WebSocket types and interfaces
export type ContainerInfo = {
id: string;
name: string;
image: string;
state: string;
status: string;
}
export type ContainerStats = {
id: string;
cpuPercent: number;
memUsage: number;
memLimit: number;
memPercent: number;
netRx: number;
netTx: number;
blkRead: number;
blkWrite: number;
timestamp: number;
}
export type CombinedContainerData = ContainerInfo & ContainerStats;

16
backend/tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"strict": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"types": [
"node"
],
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"outDir": "./dist"
},
"exclude": ["node_modules"]
}

13
frontend/.editorconfig Normal file
View File

@ -0,0 +1,13 @@
# Editor configuration, see http://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
max_line_length = off
trim_trailing_whitespace = false

46
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,46 @@
# See https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# compiled output
dist
tmp
out-tsc
# dependencies
node_modules
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# misc
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
yarn-error.log
testem.log
/typings
# System Files
.DS_Store
Thumbs.db
.nx/cache
.nx/workspace-data
.cursor/rules/nx-rules.mdc
.github/instructions/nx.instructions.md
.angular

6
frontend/.prettierignore Normal file
View File

@ -0,0 +1,6 @@
# Add files here to ignore them from prettier formatting
/dist
/coverage
/.nx/cache
/.nx/workspace-data
.angular

7
frontend/.prettierrc Normal file
View File

@ -0,0 +1,7 @@
{
"singleQuote": true,
"trailingComma": "es5",
"useTabs": true,
"tabWidth": 2,
"endOfLine": "crlf"
}

8
frontend/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,8 @@
{
"recommendations": [
"nrwl.angular-console",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"firsttris.vscode-jest-runner"
]
}

36
frontend/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,36 @@
{
"files.eol": "\n",
"editor.tabSize": 2,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
},
"editor.formatOnSave": true,
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"eslint.options": {
"extensions": [".js", ".ts", ".html"]
},
"eslint.validate": ["javascript", "typescript", "html"],
"angular.enable-strict-mode-prompt": false,
"search.exclude": {
"": true,
"**/node_modules": true
},
"search.useIgnoreFiles": false,
"nxConsole.generateAiAgentRules": true
}

82
frontend/README.md Normal file
View File

@ -0,0 +1,82 @@
# Frontend
<a alt="Nx logo" href="https://nx.dev" target="_blank" rel="noreferrer"><img src="https://raw.githubusercontent.com/nrwl/nx/master/images/nx-logo.png" width="45"></a>
✨ Your new, shiny [Nx workspace](https://nx.dev) is almost ready ✨.
[Learn more about this workspace setup and its capabilities](https://nx.dev/getting-started/tutorials/angular-standalone-tutorial?utm_source=nx_project&amp;utm_medium=readme&amp;utm_campaign=nx_projects) or run `npx nx graph` to visually explore what was created. Now, let's get you up to speed!
## Finish your remote caching setup
[Click here to finish setting up your workspace!](https://cloud.nx.app/connect/1cJ2WvnztZ)
## Run tasks
To run the dev server for your app, use:
```sh
npx nx serve frontend
```
To create a production bundle:
```sh
npx nx build frontend
```
To see all available targets to run for a project, run:
```sh
npx nx show project frontend
```
These targets are either [inferred automatically](https://nx.dev/concepts/inferred-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) or defined in the `project.json` or `package.json` files.
[More about running tasks in the docs &raquo;](https://nx.dev/features/run-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
## Add new projects
While you could add new projects to your workspace manually, you might want to leverage [Nx plugins](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) and their [code generation](https://nx.dev/features/generate-code?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) feature.
Use the plugin's generator to create new projects.
To generate a new application, use:
```sh
npx nx g @nx/angular:app demo
```
To generate a new library, use:
```sh
npx nx g @nx/angular:lib mylib
```
You can use `npx nx list` to get a list of installed plugins. Then, run `npx nx list <plugin-name>` to learn about more specific capabilities of a particular plugin. Alternatively, [install Nx Console](https://nx.dev/getting-started/editor-setup?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) to browse plugins and generators in your IDE.
[Learn more about Nx plugins &raquo;](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) | [Browse the plugin registry &raquo;](https://nx.dev/plugin-registry?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
[Learn more about Nx on CI](https://nx.dev/ci/intro/ci-with-nx#ready-get-started-with-your-provider?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
## Install Nx Console
Nx Console is an editor extension that enriches your developer experience. It lets you run tasks, generate code, and improves code autocompletion in your IDE. It is available for VSCode and IntelliJ.
[Install Nx Console &raquo;](https://nx.dev/getting-started/editor-setup?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
## Useful links
Learn more:
- [Learn more about this workspace setup](https://nx.dev/getting-started/tutorials/angular-standalone-tutorial?utm_source=nx_project&amp;utm_medium=readme&amp;utm_campaign=nx_projects)
- [Learn about Nx on CI](https://nx.dev/ci/intro/ci-with-nx?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
- [Releasing Packages with Nx release](https://nx.dev/features/manage-releases?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
- [What are Nx plugins?](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
And join the Nx community:
- [Discord](https://go.nx.dev/community)
- [Follow us on X](https://twitter.com/nxdevtools) or [LinkedIn](https://www.linkedin.com/company/nrwl)
- [Our Youtube channel](https://www.youtube.com/@nxdevtools)
- [Our blog](https://nx.dev/blog?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)

View File

@ -0,0 +1,42 @@
import nx from '@nx/eslint-plugin';
export default [
...nx.configs['flat/base'],
...nx.configs['flat/typescript'],
...nx.configs['flat/javascript'],
{
ignores: ['**/dist'],
},
{
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
rules: {
'@nx/enforce-module-boundaries': [
'error',
{
enforceBuildableLibDependency: true,
allow: ['^.*/eslint(\\.base)?\\.config\\.[cm]?js$'],
depConstraints: [
{
sourceTag: '*',
onlyDependOnLibsWithTags: ['*'],
},
],
},
],
},
},
{
files: [
'**/*.ts',
'**/*.tsx',
'**/*.cts',
'**/*.mts',
'**/*.js',
'**/*.jsx',
'**/*.cjs',
'**/*.mjs',
],
// Override or add rules here
rules: {},
},
];

View File

@ -0,0 +1,71 @@
import baseConfig from './eslint.base.config.mjs';
import { FlatCompat } from '@eslint/eslintrc';
import js from '@eslint/js';
import nxEslintPlugin from '@nx/eslint-plugin';
import { dirname } from 'path';
import { fileURLToPath } from 'url';
const compat = new FlatCompat({
baseDirectory: dirname(fileURLToPath(import.meta.url)),
recommendedConfig: js.configs.recommended,
});
export default [
...baseConfig,
{
ignores: ['**/dist'],
},
{
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
rules: {
'@angular-eslint/no-input-rename': 'off',
'@nx/enforce-module-boundaries': [
'error',
{
enforceBuildableLibDependency: true,
allow: [],
depConstraints: [
{
sourceTag: '*',
onlyDependOnLibsWithTags: ['*'],
},
],
},
],
},
},
...compat
.config({
extends: ['plugin:@nx/typescript'],
})
.map((config) => ({
...config,
files: ['**/*.ts', '**/*.tsx', '**/*.cts', '**/*.mts'],
rules: {
...config.rules,
},
})),
...compat
.config({
extends: ['plugin:@nx/javascript'],
})
.map((config) => ({
...config,
files: ['**/*.js', '**/*.jsx', '**/*.cjs', '**/*.mjs'],
rules: {
...config.rules,
},
})),
...compat
.config({
env: {
jest: true,
},
})
.map((config) => ({
...config,
files: ['**/*.spec.ts', '**/*.spec.tsx', '**/*.spec.js', '**/*.spec.jsx'],
rules: {
...config.rules,
},
})),
];

6
frontend/jest.config.ts Normal file
View File

@ -0,0 +1,6 @@
import type { Config } from 'jest';
import { getJestProjectsAsync } from '@nx/jest';
export default async (): Promise<Config> => ({
projects: await getJestProjectsAsync(),
});

3
frontend/jest.preset.js Normal file
View File

@ -0,0 +1,3 @@
const nxPreset = require('@nx/jest/preset').default;
module.exports = { ...nxPreset };

View File

@ -0,0 +1,7 @@
# stats-ws
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test stats-ws` 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: 'frontend',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'frontend',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@ -0,0 +1,21 @@
export default {
displayName: 'stats-ws',
preset: '../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../coverage/libs/shared/stats-ws',
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": "stats-ws",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/shared/stats-ws/src",
"prefix": "frontend",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/shared/stats-ws/jest.config.ts"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

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

View File

@ -0,0 +1,36 @@
import { computed, inject, Injectable, signal } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { ContainerInfo, PayloadContainers } from "./types";
@Injectable({
providedIn: "root",
})
export class StatsWsService {
private _containers = signal<ContainerInfo[]>([]);
private socket?: WebSocket;
constructor() {
this.initWebSocket();
}
readonly containers = computed(() => this._containers());
private initWebSocket() {
this.socket = new WebSocket("ws://localhost:3000/ws/containers")
this.socket.onmessage = (event) => {
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 = () => {
console.warn("WebSocket connection closed. Attempting to reconnect...");
setTimeout(() => this.initWebSocket(), 2000);
}
};
};

View File

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

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"
]
}

64
frontend/nx.json Normal file
View File

@ -0,0 +1,64 @@
{
"$schema": "./node_modules/nx/schemas/nx-schema.json",
"defaultBase": "master",
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"production": [
"default",
"!{projectRoot}/.eslintrc.json",
"!{projectRoot}/eslint.config.mjs",
"!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)",
"!{projectRoot}/tsconfig.spec.json",
"!{projectRoot}/jest.config.[jt]s",
"!{projectRoot}/src/test-setup.[jt]s",
"!{projectRoot}/test-setup.[jt]s"
],
"sharedGlobals": []
},
"nxCloudId": "683c630209e9a67b1f601ce8",
"targetDefaults": {
"@angular-devkit/build-angular:application": {
"cache": true,
"dependsOn": ["^build"],
"inputs": ["production", "^production"]
},
"@nx/eslint:lint": {
"cache": true,
"inputs": [
"default",
"{workspaceRoot}/.eslintrc.json",
"{workspaceRoot}/.eslintignore",
"{workspaceRoot}/eslint.config.mjs"
]
},
"@nx/jest:jest": {
"cache": true,
"inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"],
"options": {
"passWithNoTests": true
},
"configurations": {
"ci": {
"ci": true,
"codeCoverage": true
}
}
}
},
"generators": {
"@nx/angular:application": {
"e2eTestRunner": "none",
"linter": "eslint",
"style": "scss",
"unitTestRunner": "none"
},
"@nx/angular:library": {
"linter": "eslint",
"unitTestRunner": "jest"
},
"@nx/angular:component": {
"style": "css"
}
},
"defaultProject": "frontend"
}

26114
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

61
frontend/package.json Normal file
View File

@ -0,0 +1,61 @@
{
"name": "@frontend/source",
"version": "0.0.0",
"license": "MIT",
"scripts": {
"start": "nx serve",
"build": "nx build",
"test": "nx test"
},
"private": true,
"dependencies": {
"@angular/common": "~19.2.0",
"@angular/compiler": "~19.2.0",
"@angular/core": "~19.2.0",
"@angular/forms": "~19.2.0",
"@angular/platform-browser": "~19.2.0",
"@angular/platform-browser-dynamic": "~19.2.0",
"@angular/router": "~19.2.0",
"rxjs": "~7.8.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "~19.2.0",
"@angular-devkit/core": "~19.2.0",
"@angular-devkit/schematics": "~19.2.0",
"@angular/cli": "~19.2.0",
"@angular/compiler-cli": "~19.2.0",
"@angular/language-service": "~19.2.0",
"@eslint/js": "^9.8.0",
"@nx/angular": "21.1.2",
"@nx/eslint": "21.1.2",
"@nx/eslint-plugin": "^21.1.2",
"@nx/jest": "21.1.2",
"@nx/js": "21.1.2",
"@nx/web": "21.1.2",
"@nx/workspace": "21.1.2",
"@schematics/angular": "~19.2.0",
"@swc-node/register": "~1.9.1",
"@swc/core": "~1.5.7",
"@swc/helpers": "~0.5.11",
"@types/jest": "^29.5.12",
"@types/node": "18.16.9",
"@typescript-eslint/utils": "^8.19.0",
"angular-eslint": "^19.2.0",
"eslint": "^9.8.0",
"eslint-config-prettier": "^10.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-preset-angular": "~14.4.0",
"nx": "21.1.2",
"prettier": "^2.6.2",
"ts-jest": "^29.1.0",
"ts-node": "10.9.1",
"tslib": "^2.3.0",
"typescript": "~5.7.2",
"typescript-eslint": "^8.19.0"
},
"nx": {
"includedScripts": []
}
}

88
frontend/project.json Normal file
View File

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

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

View File

@ -0,0 +1,12 @@
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { appRoutes } from './app.routes';
import { provideHttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideHttpClient(),
provideRouter(appRoutes),
],
};

View File

@ -0,0 +1,3 @@
import { Route } from '@angular/router';
export const appRoutes: Route[] = [];

13
frontend/src/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>frontend</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>

7
frontend/src/main.ts Normal file
View File

@ -0,0 +1,7 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig).catch((err) =>
console.error(err)
);

1
frontend/src/styles.scss Normal file
View File

@ -0,0 +1 @@
/* You can add global styles to this file, and also import other style files */

View File

@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist/out-tsc",
"types": []
},
"files": ["src/main.ts"],
"include": ["src/**/*.d.ts"],
"exclude": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts"]
}

View File

@ -0,0 +1,22 @@
{
"compileOnSave": false,
"compilerOptions": {
"rootDir": ".",
"sourceMap": true,
"declaration": false,
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "es2022",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"baseUrl": ".",
"paths": {
"@frontend/shared/stats-ws": ["libs/shared/stats-ws/src/index.ts"]
}
},
"exclude": ["node_modules", "tmp"]
}

View File

@ -0,0 +1,6 @@
{
"extends": "./tsconfig.json",
"include": ["src/**/*.ts"],
"compilerOptions": {},
"exclude": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts"]
}

28
frontend/tsconfig.json Normal file
View File

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