Skip to content
Open
Show file tree
Hide file tree
Changes from 11 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
1 change: 1 addition & 0 deletions client/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ module.exports = [
'.github/',
'build/',
'coverage/',
'dist/',
'node/',
'node_modules/',
'src/app/core/modules/openapi/',
Expand Down
3 changes: 3 additions & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
"@tanstack/angular-query-experimental": "5.101.0",
"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
38 changes: 38 additions & 0 deletions client/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions client/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,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 @@ -136,6 +140,13 @@ export const routes: Routes = [
},
],
},
{
// Admin org-wide overview only. Repo-scoped stats/alerts have no meaning here so are not
// exposed at the top level — those live under /repo/:repositoryId/ci-cd/queue/*.
path: 'queue',
canActivate: [adminGuard],
loadComponent: () => import('./pages/queue/queue-overview.component').then(m => m.QueueOverviewComponent),
},
{
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);
});
});
83 changes: 83 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,83 @@
import { ChangeDetectionStrategy, Component, computed, effect, inject, input } from '@angular/core';
import { ChartModule } from 'primeng/chart';
import { ThemeService } from '@app/core/services/theme.service';
// Required side-effect import: registers the date adapter used by `type: 'time'` scales below.
// Without it Chart.js throws at render time ("complete date adapter is provided").
import 'chartjs-adapter-date-fns';

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,18 @@ import { PermissionService } from '@app/core/services/permission.service';
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';

@Component({
selector: 'app-navigation-bar',
Expand All @@ -27,6 +38,7 @@ import { IconAdjustmentsAlt, IconArrowGuide, IconChevronLeft, IconChevronRight,
IconChevronRight,
IconEyeOff,
IconEye,
IconListNumbers,
}),
],
templateUrl: './navigation-bar.component.html',
Expand Down Expand Up @@ -68,6 +80,20 @@ export class NavigationBarComponent {
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
Loading
Loading