ci(ui): setup GitHub Actions CI/CD pipeline

Closes #38
This commit is contained in:
khalil liloulah
2026-04-02 15:09:15 +02:00
committed by khalil-bot
parent ad911b7479
commit 781959240b
29 changed files with 1749 additions and 482 deletions

61
.github/workflows/README.md vendored Normal file
View File

@@ -0,0 +1,61 @@
# GitHub Actions Workflows
## Angular CI Pipeline
### Triggers
- **Push**: Runs on all branches when changes are made to `ui/` folder
- **Pull Request**: Runs when PR targets `main` branch
### Pipeline Stages
1. **Checkout** - Clone repository
2. **Setup Node.js** - Install Node 20.x with npm cache
3. **Install Dependencies** - Run `npm ci`
4. **Type Check** - Run `npx tsc --noEmit`
5. **Lint** - Run `npm run lint`
6. **Format Check** - Run `npm run format:check`
7. **Build Dev** - Build development configuration
8. **Build Prod** - Build production configuration
9. **Test** - Run unit tests with coverage
10. **Upload Artifacts** - Save build outputs and coverage reports
### Local Testing
Before pushing, run locally:
```bash
cd ui
npm ci
npx tsc --noEmit
npm run lint
npm run format:check
npm run build
```
### Troubleshooting
**Node version mismatch:**
```bash
# Use Node 20.x locally
nvm use 20
```
**Cache issues:**
```bash
# Clear npm cache
npm cache clean --force
rm -rf node_modules package-lock.json
npm install
```
**Lint errors:**
```bash
# Auto-fix
npm run lint -- --fix
```
**Format errors:**
```bash
# Auto-format
npm run format
```

100
.github/workflows/angular-ci.yml vendored Normal file
View File

@@ -0,0 +1,100 @@
name: Angular CI
on:
push:
branches:
- '**' # Run on all branches
paths:
- 'ui/**'
- '.github/workflows/angular-ci.yml'
pull_request:
branches:
- main
paths:
- 'ui/**'
- '.github/workflows/angular-ci.yml'
jobs:
build-and-test:
name: Build and Test Angular App
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./ui
strategy:
matrix:
node-version: [20.x]
steps:
# Checkout code
- name: Checkout repository
uses: actions/checkout@v4
# Setup Node.js
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
cache-dependency-path: './ui/package-lock.json'
# Install dependencies
- name: Install dependencies
run: npm ci
# Check TypeScript compilation
- name: TypeScript compilation check
run: npx tsc --noEmit
# Run linting
- name: Run ESLint
run: npm run lint
continue-on-error: true # Don't fail build on lint warnings
# Check code formatting
- name: Check Prettier formatting
run: npm run format:check
# Build development
- name: Build (Development)
run: npm run build -- --configuration=development
# Build production
- name: Build (Production)
run: npm run build -- --configuration=production
# Run unit tests (when available)
- name: Run unit tests
run: npm run test -- --watch=false --browsers=ChromeHeadless --code-coverage
continue-on-error: true # Don't fail yet (no tests implemented)
# Upload coverage (when tests exist)
- name: Upload coverage reports
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: ui/coverage/
retention-days: 7
continue-on-error: true
# Upload build artifacts
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: angular-build
path: ui/dist/
retention-days: 7
# Summary
- name: Build summary
if: success()
run: |
echo "Build successful!" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Build Details" >> $GITHUB_STEP_SUMMARY
echo "- Node version: ${{ matrix.node-version }}" >> $GITHUB_STEP_SUMMARY
echo "- Angular version: 18.x" >> $GITHUB_STEP_SUMMARY
echo "- Build time: $(date)" >> $GITHUB_STEP_SUMMARY

View File

@@ -1,13 +1,13 @@
{ {
"root": true, "root": true,
"ignorePatterns": ["projects/**/*"], "ignorePatterns": [
"projects/**/*"
],
"overrides": [ "overrides": [
{ {
"files": ["*.ts"], "files": [
"parserOptions": { "*.ts"
"project": ["tsconfig.json"], ],
"createDefaultProgram": true
},
"extends": [ "extends": [
"eslint:recommended", "eslint:recommended",
"plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended",
@@ -30,15 +30,16 @@
"prefix": "app", "prefix": "app",
"style": "kebab-case" "style": "kebab-case"
} }
], ]
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/explicit-function-return-type": "off"
} }
}, },
{ {
"files": ["*.html"], "files": [
"*.html"
],
"extends": [ "extends": [
"plugin:@angular-eslint/template/recommended" "plugin:@angular-eslint/template/recommended",
"plugin:@angular-eslint/template/accessibility"
], ],
"rules": {} "rules": {}
} }

View File

@@ -3,7 +3,10 @@
"version": 1, "version": 1,
"cli": { "cli": {
"packageManager": "npm", "packageManager": "npm",
"analytics": false "analytics": false,
"schematicCollections": [
"@angular-eslint/schematics"
]
}, },
"newProjectRoot": "projects", "newProjectRoot": "projects",
"projects": { "projects": {
@@ -119,6 +122,15 @@
], ],
"scripts": [] "scripts": []
} }
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
}
} }
} }
} }

1614
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,10 @@
"start": "ng serve", "start": "ng serve",
"build": "ng build", "build": "ng build",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test" "test": "ng test",
"lint": "ng lint",
"format": "prettier --write \"src/**/*.{ts,html,scss,json}\"",
"format:check": "prettier --check \"src/**/*.{ts,html,scss,json}\""
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
@@ -28,13 +31,18 @@
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "^18.0.2", "@angular-devkit/build-angular": "^18.0.2",
"@angular-eslint/builder": "21.0.1",
"@angular-eslint/eslint-plugin": "21.0.1",
"@angular-eslint/eslint-plugin-template": "21.0.1",
"@angular-eslint/schematics": "21.0.1",
"@angular-eslint/template-parser": "21.0.1",
"@angular/cli": "^18.0.2", "@angular/cli": "^18.0.2",
"@angular/compiler-cli": "^18.0.0", "@angular/compiler-cli": "^18.0.0",
"@types/chart.js": "^4.0.1", "@types/chart.js": "^4.0.1",
"@types/jasmine": "~5.1.0", "@types/jasmine": "~5.1.0",
"@typescript-eslint/eslint-plugin": "^8.57.2", "@typescript-eslint/eslint-plugin": "7.11.0",
"@typescript-eslint/parser": "^8.57.2", "@typescript-eslint/parser": "7.11.0",
"eslint": "^10.1.0", "eslint": "8.57.1",
"jasmine-core": "~5.1.0", "jasmine-core": "~5.1.0",
"karma": "~6.4.0", "karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0", "karma-chrome-launcher": "~3.2.0",

View File

@@ -1,4 +1,4 @@
<app-header></app-header> <app-header></app-header>
<main> <main>
<router-outlet></router-outlet> <router-outlet></router-outlet>
</main> </main>

View File

@@ -1,4 +1,4 @@
main { main {
min-height: calc(100vh - 64px); min-height: calc(100vh - 64px);
background: #fafafa; background: #fafafa;
} }

View File

@@ -1,10 +1,13 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component'; import { AppComponent } from './app.component';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
describe('AppComponent', () => { describe('AppComponent', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [AppComponent], imports: [AppComponent],
providers: [provideRouter(routes)],
}).compileComponents(); }).compileComponents();
}); });
@@ -14,16 +17,16 @@ describe('AppComponent', () => {
expect(app).toBeTruthy(); expect(app).toBeTruthy();
}); });
it(`should have the 'dashboard' title`, () => { it(`should have the 'PI_E2EEDA Dashboard' title`, () => {
const fixture = TestBed.createComponent(AppComponent); const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance; const app = fixture.componentInstance;
expect(app.title).toEqual('dashboard'); expect(app.title).toEqual('PI_E2EEDA Dashboard');
}); });
it('should render title', () => { it('should render header', () => {
const fixture = TestBed.createComponent(AppComponent); const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges(); fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement; const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, dashboard'); expect(compiled.querySelector('app-header')).toBeTruthy();
}); });
}); });

View File

@@ -7,9 +7,8 @@ import { HeaderComponent } from './components/header/header.component';
standalone: true, standalone: true,
imports: [RouterOutlet, HeaderComponent], imports: [RouterOutlet, HeaderComponent],
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrl: './app.component.scss' styleUrl: './app.component.scss',
}) })
export class AppComponent { export class AppComponent {
title = 'PI_E2EEDA Dashboard'; title = 'PI_E2EEDA Dashboard';
} }

View File

@@ -5,5 +5,9 @@ import { routes } from './app.routes';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), provideAnimationsAsync()] providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideAnimationsAsync(),
],
}; };

View File

@@ -4,22 +4,24 @@ export const routes: Routes = [
{ {
path: '', path: '',
redirectTo: '/dashboard', redirectTo: '/dashboard',
pathMatch: 'full' pathMatch: 'full',
}, },
{ {
path: 'dashboard', path: 'dashboard',
loadComponent: () => loadComponent: () =>
import('./components/room-map/room-map.component').then(m => m.RoomMapComponent), import('./components/room-map/room-map.component').then(m => m.RoomMapComponent),
title: 'Dashboard - PI_E2EEDA' title: 'Dashboard - PI_E2EEDA',
}, },
{ {
path: 'room/:id', path: 'room/:id',
loadComponent: () => loadComponent: () =>
import('./components/room-details-panel/room-details-panel.component').then(m => m.RoomDetailsPanelComponent), import('./components/room-details-panel/room-details-panel.component').then(
title: 'Room Details - PI_E2EEDA' m => m.RoomDetailsPanelComponent
),
title: 'Room Details - PI_E2EEDA',
}, },
{ {
path: '**', path: '**',
redirectTo: '/dashboard' redirectTo: '/dashboard',
} },
]; ];

View File

@@ -1,8 +1,8 @@
<header> <header>
<div class="header-content"> <div class="header-content">
<h1 routerLink="/dashboard">🌡️ PI_E2EEDA</h1> <h1 routerLink="/dashboard">🌡️ PI_E2EEDA</h1>
<nav> <nav>
<a routerLink="/dashboard" routerLinkActive="active">Dashboard</a> <a routerLink="/dashboard" routerLinkActive="active">Dashboard</a>
</nav> </nav>
</div> </div>
</header> </header>

View File

@@ -1,41 +1,41 @@
header { header {
background: #00bcd4; background: #00bcd4;
color: white; color: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
} }
.header-content { .header-content {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 16px 24px; padding: 16px 24px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
h1 { h1 {
margin: 0; margin: 0;
font-size: 24px; font-size: 24px;
cursor: pointer; cursor: pointer;
&:hover { &:hover {
opacity: 0.9; opacity: 0.9;
} }
} }
nav a { nav a {
color: white; color: white;
text-decoration: none; text-decoration: none;
margin-left: 24px; margin-left: 24px;
padding: 8px 16px; padding: 8px 16px;
border-radius: 4px; border-radius: 4px;
transition: background 0.3s; transition: background 0.3s;
&:hover { &:hover {
background: rgba(255,255,255,0.1); background: rgba(255, 255, 255, 0.1);
} }
&.active { &.active {
background: rgba(255,255,255,0.2); background: rgba(255, 255, 255, 0.2);
} }
} }

View File

@@ -8,8 +8,6 @@ import { RouterModule } from '@angular/router';
imports: [CommonModule, RouterModule], imports: [CommonModule, RouterModule],
templateUrl: './header.component.html', templateUrl: './header.component.html',
styleUrl: './header.component.scss', styleUrl: './header.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class HeaderComponent { export class HeaderComponent {}
}

View File

@@ -1,10 +1,12 @@
<div class="legend"> <div class="legend">
<h3>Air Quality (CO2)</h3> <h3>Air Quality (CO2)</h3>
<div class="legend-items"> <div class="legend-items">
<div class="legend-item" *ngFor="let level of levels"> @for (level of levels; track level.label) {
<div class="color-box" [style.background-color]="level.color"></div> <div class="legend-item">
<span class="label">{{ level.label }}</span> <div class="color-box" [style.background-color]="level.color"></div>
<span class="range">{{ level.range }}</span> <span class="label">{{ level.label }}</span>
</div> <span class="range">{{ level.range }}</span>
</div> </div>
</div>> }
</div>
</div>

View File

@@ -1,42 +1,42 @@
.legend { .legend {
background: white; background: white;
border-radius: 8px; border-radius: 8px;
padding: 16px; padding: 16px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
} }
h3 { h3 {
margin: 0 0 12px 0; margin: 0 0 12px 0;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: rgba(0,0,0,0.87); color: rgba(0, 0, 0, 0.87);
} }
.legend-items { .legend-items {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
} }
.legend-item { .legend-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
font-size: 13px; font-size: 13px;
} }
.color-box { .color-box {
width: 24px; width: 24px;
height: 24px; height: 24px;
border-radius: 4px; border-radius: 4px;
flex-shrink: 0; flex-shrink: 0;
} }
.label { .label {
font-weight: 500; font-weight: 500;
min-width: 100px; min-width: 100px;
} }
.range { .range {
color: rgba(0,0,0,0.6); color: rgba(0, 0, 0, 0.6);
} }

View File

@@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component } from '@angular/core';
interface CO2Level { interface CO2Level {
label: string; label: string;
@@ -10,19 +10,18 @@ interface CO2Level {
@Component({ @Component({
selector: 'app-legend', selector: 'app-legend',
standalone: true, standalone: true,
imports: [], imports: [CommonModule],
templateUrl: './legend.component.html', templateUrl: './legend.component.html',
styleUrl: './legend.component.scss', styleUrl: './legend.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class LegendComponent { export class LegendComponent {
levels: CO2Level[] = [ levels: CO2Level[] = [
{ label: 'Excellent', range: '< 800 ppm', color: '#4caf50' }, { label: 'Excellent', range: '< 800 ppm', color: '#4caf50' },
{ label: 'Good', range: '800-1000 ppm', color: '#8bc34a' }, { label: 'Good', range: '800-1000 ppm', color: '#8bc34a' },
{ label: 'Moderate', range: '1000-1200 ppm', color: '#ffc107' }, { label: 'Moderate', range: '1000-1200 ppm', color: '#ffc107' },
{ label: 'Poor', range: '1200-1500 ppm', color: '#ff9800' }, { label: 'Poor', range: '1200-1500 ppm', color: '#ff9800' },
{ label: 'Very Poor', range: '1500-2000 ppm', color: '#ff5722' }, { label: 'Very Poor', range: '1500-2000 ppm', color: '#ff5722' },
{ label: 'Critical', range: '> 2000 ppm', color: '#f44336' } { label: 'Critical', range: '> 2000 ppm', color: '#f44336' },
]; ];
} }

View File

@@ -1,5 +1,5 @@
<div class="room-details-container"> <div class="room-details-container">
<h1>Room Details - {{ roomId }}</h1> <h1>Room Details - {{ roomId }}</h1>
<p>Detailed room information coming soon.</p> <p>Detailed room information coming soon.</p>
<a routerLink="/dashboard">← Back to Dashboard</a> <a routerLink="/dashboard">← Back to Dashboard</a>
</div> </div>

View File

@@ -1,16 +1,16 @@
.room-details-container { .room-details-container {
padding: 24px; padding: 24px;
} }
h1 { h1 {
margin-bottom: 16px; margin-bottom: 16px;
} }
a { a {
color: #00bcd4; color: #00bcd4;
text-decoration: none; text-decoration: none;
&:hover { &:hover {
text-decoration: underline; text-decoration: underline;
} }
} }

View File

@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterModule } from '@angular/router'; import { ActivatedRoute, RouterModule } from '@angular/router';
@Component({ @Component({
@@ -7,13 +7,9 @@ import { ActivatedRoute, RouterModule } from '@angular/router';
imports: [CommonModule, RouterModule], imports: [CommonModule, RouterModule],
templateUrl: './room-details-panel.component.html', templateUrl: './room-details-panel.component.html',
styleUrl: './room-details-panel.component.scss', styleUrl: './room-details-panel.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class RoomDetailsPanelComponent { export class RoomDetailsPanelComponent {
roomId: string | null = null; private route = inject(ActivatedRoute);
roomId: string | null = this.route.snapshot.paramMap.get('id');
constructor(private route: ActivatedRoute) {
this.roomId = this.route.snapshot.paramMap.get('id');
}
} }

View File

@@ -1,4 +1,4 @@
<div class="room-map-container"> <div class="room-map-container">
<h1>Room Map</h1> <h1>Room Map</h1>
<p>Interactive 2D SVG map will be displayed here.</p> <p>Interactive 2D SVG map will be displayed here.</p>
</div> </div>

View File

@@ -1,9 +1,9 @@
.room-map-container { .room-map-container {
padding: 24px; padding: 24px;
text-align: center; text-align: center;
} }
h1 { h1 {
color: #00bcd4; color: #00bcd4;
margin-bottom: 16px; margin-bottom: 16px;
} }

View File

@@ -7,8 +7,6 @@ import { ChangeDetectionStrategy, Component } from '@angular/core';
imports: [CommonModule], imports: [CommonModule],
templateUrl: './room-map.component.html', templateUrl: './room-map.component.html',
styleUrl: './room-map.component.scss', styleUrl: './room-map.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class RoomMapComponent { export class RoomMapComponent {}
}

View File

@@ -7,5 +7,5 @@ export const environment = {
apiUrl: 'https://YOUR_DOMAIN/api', apiUrl: 'https://YOUR_DOMAIN/api',
wsUrl: 'wss://YOUR_DOMAIN/ws', wsUrl: 'wss://YOUR_DOMAIN/ws',
influxUrl: '', influxUrl: '',
logLevel: 'error' logLevel: 'error',
}; };

View File

@@ -3,5 +3,5 @@ export const environment = {
apiUrl: 'http://localhost:8080', apiUrl: 'http://localhost:8080',
wsUrl: 'ws://localhost:8080', wsUrl: 'ws://localhost:8080',
influxUrl: '', influxUrl: '',
logLevel: 'debug' logLevel: 'debug',
}; };

View File

@@ -1,15 +1,18 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<title>Dashboard</title> <title>Dashboard</title>
<base href="/"> <base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico"> <link rel="icon" type="image/x-icon" href="favicon.ico" />
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet"> <link
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap"
</head> rel="stylesheet"
<body class="mat-typography"> />
<app-root></app-root> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
</body> </head>
<body class="mat-typography">
<app-root></app-root>
</body>
</html> </html>

View File

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

View File

@@ -1,4 +1,10 @@
/* You can add global styles to this file, and also import other style files */ /* You can add global styles to this file, and also import other style files */
html, body { height: 100%; } html,
body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; } body {
height: 100%;
}
body {
margin: 0;
font-family: Roboto, 'Helvetica Neue', sans-serif;
}