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

View File

@@ -3,7 +3,10 @@
"version": 1,
"cli": {
"packageManager": "npm",
"analytics": false
"analytics": false,
"schematicCollections": [
"@angular-eslint/schematics"
]
},
"newProjectRoot": "projects",
"projects": {
@@ -119,6 +122,15 @@
],
"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",
"build": "ng build",
"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,
"dependencies": {
@@ -28,13 +31,18 @@
},
"devDependencies": {
"@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/compiler-cli": "^18.0.0",
"@types/chart.js": "^4.0.1",
"@types/jasmine": "~5.1.0",
"@typescript-eslint/eslint-plugin": "^8.57.2",
"@typescript-eslint/parser": "^8.57.2",
"eslint": "^10.1.0",
"@typescript-eslint/eslint-plugin": "7.11.0",
"@typescript-eslint/parser": "7.11.0",
"eslint": "8.57.1",
"jasmine-core": "~5.1.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",

View File

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

View File

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

View File

@@ -1,10 +1,13 @@
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent],
providers: [provideRouter(routes)],
}).compileComponents();
});
@@ -14,16 +17,16 @@ describe('AppComponent', () => {
expect(app).toBeTruthy();
});
it(`should have the 'dashboard' title`, () => {
it(`should have the 'PI_E2EEDA Dashboard' title`, () => {
const fixture = TestBed.createComponent(AppComponent);
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);
fixture.detectChanges();
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,
imports: [RouterOutlet, HeaderComponent],
templateUrl: './app.component.html',
styleUrl: './app.component.scss'
styleUrl: './app.component.scss',
})
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';
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: '',
redirectTo: '/dashboard',
pathMatch: 'full'
pathMatch: 'full',
},
{
path: 'dashboard',
loadComponent: () =>
import('./components/room-map/room-map.component').then(m => m.RoomMapComponent),
title: 'Dashboard - PI_E2EEDA'
title: 'Dashboard - PI_E2EEDA',
},
{
path: 'room/:id',
loadComponent: () =>
import('./components/room-details-panel/room-details-panel.component').then(m => m.RoomDetailsPanelComponent),
title: 'Room Details - PI_E2EEDA'
import('./components/room-details-panel/room-details-panel.component').then(
m => m.RoomDetailsPanelComponent
),
title: 'Room Details - PI_E2EEDA',
},
{
path: '**',
redirectTo: '/dashboard'
}
redirectTo: '/dashboard',
},
];

View File

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

View File

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

View File

@@ -8,8 +8,6 @@ import { RouterModule } from '@angular/router';
imports: [CommonModule, RouterModule],
templateUrl: './header.component.html',
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">
<h3>Air Quality (CO2)</h3>
<div class="legend-items">
<div class="legend-item" *ngFor="let level of levels">
<div class="color-box" [style.background-color]="level.color"></div>
<span class="label">{{ level.label }}</span>
<span class="range">{{ level.range }}</span>
</div>
<h3>Air Quality (CO2)</h3>
<div class="legend-items">
@for (level of levels; track level.label) {
<div class="legend-item">
<div class="color-box" [style.background-color]="level.color"></div>
<span class="label">{{ level.label }}</span>
<span class="range">{{ level.range }}</span>
</div>
</div>>
}
</div>
</div>

View File

@@ -1,42 +1,42 @@
.legend {
background: white;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.legend {
background: white;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
h3 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: rgba(0,0,0,0.87);
}
h3 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.87);
}
.legend-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.legend-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.legend-item {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
}
.legend-item {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
}
.color-box {
width: 24px;
height: 24px;
border-radius: 4px;
flex-shrink: 0;
}
.color-box {
width: 24px;
height: 24px;
border-radius: 4px;
flex-shrink: 0;
}
.label {
font-weight: 500;
min-width: 100px;
}
.label {
font-weight: 500;
min-width: 100px;
}
.range {
color: rgba(0,0,0,0.6);
}
.range {
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 { ChangeDetectionStrategy, Component } from '@angular/core';
interface CO2Level {
label: string;
@@ -10,19 +10,18 @@ interface CO2Level {
@Component({
selector: 'app-legend',
standalone: true,
imports: [],
imports: [CommonModule],
templateUrl: './legend.component.html',
styleUrl: './legend.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LegendComponent {
levels: CO2Level[] = [
levels: CO2Level[] = [
{ label: 'Excellent', range: '< 800 ppm', color: '#4caf50' },
{ label: 'Good', range: '800-1000 ppm', color: '#8bc34a' },
{ label: 'Moderate', range: '1000-1200 ppm', color: '#ffc107' },
{ label: 'Poor', range: '1200-1500 ppm', color: '#ff9800' },
{ 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">
<h1>Room Details - {{ roomId }}</h1>
<p>Detailed room information coming soon.</p>
<a routerLink="/dashboard">← Back to Dashboard</a>
</div>
<div class="room-details-container">
<h1>Room Details - {{ roomId }}</h1>
<p>Detailed room information coming soon.</p>
<a routerLink="/dashboard">← Back to Dashboard</a>
</div>

View File

@@ -1,16 +1,16 @@
.room-details-container {
padding: 24px;
}
.room-details-container {
padding: 24px;
}
h1 {
margin-bottom: 16px;
}
h1 {
margin-bottom: 16px;
}
a {
color: #00bcd4;
text-decoration: none;
a {
color: #00bcd4;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
&:hover {
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 { ActivatedRoute, RouterModule } from '@angular/router';
@Component({
@@ -7,13 +7,9 @@ import { ActivatedRoute, RouterModule } from '@angular/router';
imports: [CommonModule, RouterModule],
templateUrl: './room-details-panel.component.html',
styleUrl: './room-details-panel.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RoomDetailsPanelComponent {
roomId: string | null = null;
constructor(private route: ActivatedRoute) {
this.roomId = this.route.snapshot.paramMap.get('id');
}
private route = inject(ActivatedRoute);
roomId: string | null = this.route.snapshot.paramMap.get('id');
}

View File

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

View File

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

View File

@@ -7,8 +7,6 @@ import { ChangeDetectionStrategy, Component } from '@angular/core';
imports: [CommonModule],
templateUrl: './room-map.component.html',
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',
wsUrl: 'wss://YOUR_DOMAIN/ws',
influxUrl: '',
logLevel: 'error'
logLevel: 'error',
};

View File

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

View File

@@ -1,15 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Dashboard</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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 href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body class="mat-typography">
<app-root></app-root>
</body>
<head>
<meta charset="utf-8" />
<title>Dashboard</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<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 href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
</head>
<body class="mat-typography">
<app-root></app-root>
</body>
</html>

View File

@@ -2,5 +2,4 @@ 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));
bootstrapApplication(AppComponent, appConfig).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 */
html, body { height: 100%; }
body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }
html,
body {
height: 100%;
}
body {
margin: 0;
font-family: Roboto, 'Helvetica Neue', sans-serif;
}