Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@
"@tanstack/angular-query-experimental": "5.100.9",
"angular-tabler-icons": "3.26.0",
"canvas-confetti": "1.9.4",
"chart.js": "4.4.4",
"chartjs-adapter-date-fns": "3.0.0",
"date-fns": "3.6.0",
Comment on lines +39 to +41
"eslint-config-prettier": "10.1.8",
"keycloak-js": "26.2.4",
"marked": "16.4.2",
Expand Down
9 changes: 9 additions & 0 deletions client/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ export const routes: Routes = [
{ path: ':branchName', loadComponent: () => import('./pages/branch-details/branch-details.component').then(m => m.BranchDetailsComponent) },
],
},
{
path: 'queue',
loadChildren: () => import('./pages/queue/queue.routes').then(m => m.queueRoutes),
},
],
},
{
Expand All @@ -129,6 +133,11 @@ export const routes: Routes = [
},
],
},
{
path: 'queue',
canActivate: [adminGuard],
loadChildren: () => import('./pages/queue/queue.routes').then(m => m.queueRoutes),
},
{
path: 'unauthorized',
loadComponent: () => import('./pages/unauthorized-page/unauthorized-page.component').then(m => m.UnauthorizedPageComponent),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { provideNoopAnimations } from '@angular/platform-browser/animations';
import { HeliosLineChartComponent, type ChartSeries } from './helios-line-chart.component';
import { ThemeService } from '@app/core/services/theme.service';

describe('HeliosLineChartComponent', () => {
let fixture: ComponentFixture<HeliosLineChartComponent>;
let themeService: ThemeService;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HeliosLineChartComponent],
providers: [provideZonelessChangeDetection(), provideNoopAnimations()],
}).compileComponents();
fixture = TestBed.createComponent(HeliosLineChartComponent);
themeService = TestBed.inject(ThemeService);
});

function setSeries(series: ChartSeries[]) {
fixture.componentRef.setInput('series', series);
fixture.detectChanges();
}

it('builds one Chart.js dataset per series with the requested label', () => {
setSeries([
{ label: 'queue p50', data: [{ x: '2026-05-18T10:00:00Z', y: 30 }] },
{ label: 'queue p95', data: [{ x: '2026-05-18T10:00:00Z', y: 120 }] },
]);
const data = fixture.componentInstance.chartData();
expect(data.datasets).toHaveLength(2);
expect(data.datasets[0].label).toBe('queue p50');
expect(data.datasets[1].label).toBe('queue p95');
});

it('rebuilds options when dark mode toggles', () => {
setSeries([{ label: 'foo', data: [] }]);
themeService.isDarkMode.set(false);
const lightOptions = JSON.stringify(fixture.componentInstance.chartOptions());

themeService.isDarkMode.set(true);
fixture.detectChanges();
const darkOptions = JSON.stringify(fixture.componentInstance.chartOptions());

expect(darkOptions).not.toBe(lightOptions);
});

it('uses different palette colours across datasets', () => {
setSeries([
{ label: 'a', data: [] },
{ label: 'b', data: [] },
{ label: 'c', data: [] },
]);
const datasets = fixture.componentInstance.chartData().datasets;
const colours = new Set(datasets.map(d => d.borderColor));
expect(colours.size).toBeGreaterThan(1);
});
});
80 changes: 80 additions & 0 deletions client/src/app/components/charts/helios-line-chart.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { ChangeDetectionStrategy, Component, computed, effect, inject, input } from '@angular/core';
import { ChartModule } from 'primeng/chart';
import { ThemeService } from '@app/core/services/theme.service';

export interface ChartSeries {
label: string;
data: { x: string | number | Date; y: number }[];
}

/**
* Thin PrimeNG `<p-chart>` wrapper that rebuilds its Chart.js options from the current PrimeNG
* theme tokens. Reacts to dark-mode toggles via `ThemeService.isDarkMode`.
*/
@Component({
selector: 'app-helios-line-chart',
standalone: true,
imports: [ChartModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p-chart type="line" [data]="chartData()" [options]="chartOptions()" height="320"></p-chart>`,
})
export class HeliosLineChartComponent {
series = input.required<ChartSeries[]>();
yAxisLabel = input<string>('');

private themeService = inject(ThemeService);

constructor() {
effect(() => {
// Touch the signal so the computed re-fires when dark mode flips.
this.themeService.isDarkMode();
});
}

chartData = computed(() => {
const palette = this.palette();
return {
datasets: this.series().map((s, i) => ({
label: s.label,
data: s.data,
borderColor: palette[i % palette.length],
backgroundColor: palette[i % palette.length] + '20',
tension: 0.2,
fill: false,
})),
};
});

chartOptions = computed(() => {
const isDark = this.themeService.isDarkMode();
const textColor = isDark ? '#e5e7eb' : '#111827';
const gridColor = isDark ? '#374151' : '#e5e7eb';
return {
maintainAspectRatio: false,
responsive: true,
plugins: {
legend: {
labels: { color: textColor },
},
},
scales: {
x: {
type: 'time',
time: { unit: 'hour' },
Comment on lines +63 to +66
ticks: { color: textColor },
grid: { color: gridColor },
},
y: {
title: { display: !!this.yAxisLabel(), text: this.yAxisLabel(), color: textColor },
ticks: { color: textColor },
grid: { color: gridColor },
beginAtZero: true,
},
},
};
});

private palette(): string[] {
return ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import { injectQuery } from '@tanstack/angular-query-experimental';
import { getRepositoryByIdOptions } from '@app/core/modules/openapi/@tanstack/angular-query-experimental.gen';
import { ButtonModule } from 'primeng/button';
import { IconAdjustmentsAlt, IconArrowGuide, IconChevronLeft, IconChevronRight, IconRocket, IconServerCog, IconEyeOff, IconEye, IconBug } from 'angular-tabler-icons/icons';
import { IconAdjustmentsAlt, IconArrowGuide, IconChevronLeft, IconChevronRight, IconRocket, IconServerCog, IconEyeOff, IconEye, IconBug, IconListNumbers } from 'angular-tabler-icons/icons';

Check failure on line 14 in client/src/app/components/navigation-bar/navigation-bar.component.ts

View workflow job for this annotation

GitHub Actions / Linting Client (Angular)

Replace `·IconAdjustmentsAlt,·IconArrowGuide,·IconChevronLeft,·IconChevronRight,·IconRocket,·IconServerCog,·IconEyeOff,·IconEye,·IconBug,·IconListNumbers·` with `⏎··IconAdjustmentsAlt,⏎··IconArrowGuide,⏎··IconChevronLeft,⏎··IconChevronRight,⏎··IconRocket,⏎··IconServerCog,⏎··IconEyeOff,⏎··IconEye,⏎··IconBug,⏎··IconListNumbers,⏎`

@Component({
selector: 'app-navigation-bar',
Expand All @@ -27,6 +27,7 @@
IconChevronRight,
IconEyeOff,
IconEye,
IconListNumbers,
}),
],
templateUrl: './navigation-bar.component.html',
Expand Down Expand Up @@ -68,6 +69,20 @@
icon: 'server-cog',
path: ['repo', this.repositoryId(), 'environment'],
},
{
label: 'Queue',
icon: 'list-numbers',
path: ['repo', this.repositoryId(), 'ci-cd', 'queue'],
},
...(this.permissionService.isAdmin()
? [
{
label: 'Org Queue',
icon: 'list-numbers',
path: ['/queue'],
},
]
: []),
...(this.keycloakService.profile && this.permissionService.isAtLeastMaintainer()
? [
{
Expand Down
84 changes: 84 additions & 0 deletions client/src/app/core/services/theme.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { ThemeService } from './theme.service';

describe('ThemeService', () => {
let store: Record<string, string>;
let originalLocalStorage: Storage;
let originalMatchMedia: typeof window.matchMedia;

beforeEach(() => {
store = {};
originalLocalStorage = window.localStorage;
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: {
getItem: (k: string) => store[k] ?? null,
setItem: (k: string, v: string) => {
store[k] = v;
},
removeItem: (k: string) => {
delete store[k];
},
clear: () => {
store = {};
},
key: () => null,
length: 0,
} as Storage,
});
originalMatchMedia = window.matchMedia;
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: () => ({ matches: false } as MediaQueryList),

Check failure on line 33 in client/src/app/core/services/theme.service.spec.ts

View workflow job for this annotation

GitHub Actions / Linting Client (Angular)

Replace `·as·MediaQueryList)` with `)·as·MediaQueryList`
});
});

afterEach(() => {
Object.defineProperty(window, 'localStorage', { configurable: true, value: originalLocalStorage });
Object.defineProperty(window, 'matchMedia', { configurable: true, value: originalMatchMedia });
});

function getService(): ThemeService {
TestBed.configureTestingModule({ providers: [provideZonelessChangeDetection()] });
return TestBed.inject(ThemeService);
}

it('initializes from localStorage when "dark" is saved', () => {
store.theme = 'dark';
const service = getService();
expect(service.isDarkMode()).toBe(true);
});

it('initializes from localStorage when "light" is saved', () => {
store.theme = 'light';
const service = getService();
expect(service.isDarkMode()).toBe(false);
});

it('toggle flips the signal and writes to localStorage', () => {
const service = getService();
const initial = service.isDarkMode();
service.toggle();
expect(service.isDarkMode()).toBe(!initial);
expect(store.theme).toBe(!initial ? 'dark' : 'light');
});

it('applies the dark-mode-enabled class on <html> when dark mode is on', () => {
store.theme = 'dark';
document.querySelector('html')?.classList.remove('dark-mode-enabled');
getService();
TestBed.tick(); // flush the constructor effect
expect(document.querySelector('html')?.classList.contains('dark-mode-enabled')).toBe(true);
});

it('removes the dark-mode-enabled class when toggled off', () => {
store.theme = 'dark';
const service = getService();
TestBed.tick();
expect(document.querySelector('html')?.classList.contains('dark-mode-enabled')).toBe(true);
service.toggle();
TestBed.tick();
expect(document.querySelector('html')?.classList.contains('dark-mode-enabled')).toBe(false);
});
});
32 changes: 32 additions & 0 deletions client/src/app/core/services/theme.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { effect, Injectable, signal } from '@angular/core';

/**
* Shared theme state. Owns the dark-mode signal and the DOM class toggle so any component
* (e.g. chart wrappers) can react to theme changes via `effect()`.
*/
@Injectable({ providedIn: 'root' })
export class ThemeService {
private readonly STORAGE_KEY = 'theme';

readonly isDarkMode = signal<boolean>(this.initialIsDark());

constructor() {
effect(() => {
document.querySelector('html')?.classList.toggle('dark-mode-enabled', this.isDarkMode());
});
}

toggle(): void {
const next = !this.isDarkMode();
this.isDarkMode.set(next);
localStorage.setItem(this.STORAGE_KEY, next ? 'dark' : 'light');
}

private initialIsDark(): boolean {
const saved = localStorage.getItem(this.STORAGE_KEY);
if (saved === 'light' || saved === 'dark') {
return saved === 'dark';
}
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
}
38 changes: 5 additions & 33 deletions client/src/app/pages/main-layout/main-layout.component.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Component, computed, inject, OnInit, signal, effect } from '@angular/core';
import { Component, computed, inject, OnInit, signal } from '@angular/core';
import { ActivatedRoute, NavigationEnd, Router, RouterLink, RouterOutlet } from '@angular/router';
import { KeycloakService } from '@app/core/services/keycloak/keycloak.service';
import { ThemeService } from '@app/core/services/theme.service';
import { AvatarModule } from 'primeng/avatar';
import { ButtonModule } from 'primeng/button';
import { CardModule } from 'primeng/card';
Expand Down Expand Up @@ -45,21 +46,15 @@ import { IconMoon, IconSun } from 'angular-tabler-icons/icons';
templateUrl: './main-layout.component.html',
})
export class MainLayoutComponent implements OnInit {
private STORAGE_KEY = 'theme';
private keycloakService = inject(KeycloakService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private themeService = inject(ThemeService);

repositoryId = signal<number | undefined>(undefined);
isDarkModeEnabled = signal(this.isThemeDark());
isDarkModeEnabled = this.themeService.isDarkMode;
isLoggedIn = computed(() => this.keycloakService.isLoggedIn());

constructor() {
effect(() => {
document.querySelector('html')?.classList.toggle('dark-mode-enabled', this.isDarkModeEnabled());
});
}

ngOnInit(): void {
// Initialize on first load (Refresh)
this.updateRepositoryId();
Expand Down Expand Up @@ -128,30 +123,7 @@ export class MainLayoutComponent implements OnInit {
this.keycloakService.login();
}

/**
* Checks if the current theme is dark.
*
* This method retrieves the saved theme from localStorage and checks if it is set to 'dark'.
* If not found, it falls back to the user's OS preference using `window.matchMedia`.
*
* @returns {boolean} - Returns true if the theme is dark, false otherwise.
*/
private isThemeDark(): boolean {
// Get the saved theme from localStorage
const saved = localStorage.getItem('theme');

// Check if the saved theme is either 'light' or 'dark'
if (saved === 'light' || saved === 'dark') {
return saved === 'dark';
}

// fall back to OS preference
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}

toggleDarkMode() {
const next = !this.isDarkModeEnabled();
this.isDarkModeEnabled.set(next);
localStorage.setItem(this.STORAGE_KEY, next ? 'dark' : 'light');
this.themeService.toggle();
}
}
Loading
Loading