Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
414ea0d
fix(deps): update all non-major client dependencies (#851)
renovate[bot] May 5, 2026
40ead93
chore(deps): update actions/upload-artifact action to v7 (#1023)
renovate[bot] May 5, 2026
f232003
chore(deps): update actions/stale action to v10 (#1022)
renovate[bot] May 5, 2026
7dd6537
chore(deps): upgrade to Spring Boot 4.0.6 in application-server, noti…
meryemefe May 7, 2026
20685b7
refactor: simplify label rendering and improve color handling in pull…
mertilginoglu May 8, 2026
b3cb8dd
fix: add database index for deployment workflow run ID (#1031)
mertilginoglu May 8, 2026
a3df999
refactor: introduce statusUpdatedAt to HeliosDeployment and enforce d…
mertilginoglu May 8, 2026
7861047
fix: remove inactive developers from heliosDevelopers list in environ…
meryemefe May 9, 2026
5f412f8
fix(client): broadcast cross-tab logout to sync auth state and hide w…
meryemefe May 9, 2026
556e01e
feat: add advanced pull request filtering options (#1033)
meryemefe May 9, 2026
ef1c396
feat(ai): add AI-powered test failure analysis with persistence, rate…
meryemefe May 9, 2026
4fba51b
fix(deploy): update OPENAI_BASE_URL and OPENAI_MODEL to use secrets i…
meryemefe May 9, 2026
53ea950
fix(deploy): add AI-related environment variables for Helios configur…
meryemefe May 10, 2026
f2e34a0
fix(deploy): add AI-related environment variables for compose.prod.yaml
meryemefe May 10, 2026
573c89a
chore: add 30-second delays to deployment workflow steps (#1035)
mertilginoglu May 10, 2026
9afbc46
feat: implement WebSocket-based real-time updates for workflow runs w…
mertilginoglu May 10, 2026
7f9253e
Revert "feat: implement WebSocket-based real-time updates for workflo…
mertilginoglu May 10, 2026
db92475
refactor: optimize workflow job timing persistence by introducing Hel…
mertilginoglu May 11, 2026
163b4d3
feat: add environment deployment websocket updates
mertilginoglu May 14, 2026
c57ae73
Merge remote-tracking branch 'origin/staging' into codex/environment-…
krusche May 27, 2026
a916dc5
Merge branch 'staging' into codex/environment-websocket-only
krusche May 28, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
unlockEnvironmentMutation,
cancelDeploymentMutation,
} from '@app/core/modules/openapi/@tanstack/angular-query-experimental.gen';
import { EnvironmentDeploymentWebSocketService } from '@app/core/services/environment-deployment-websocket/environment-deployment-websocket.service';
import { KeycloakService } from '@app/core/services/keycloak/keycloak.service';
import { PermissionService } from '@app/core/services/permission.service';
import { injectMutation, injectQuery, QueryClient } from '@tanstack/angular-query-experimental';
Expand Down Expand Up @@ -73,8 +74,10 @@ export class EnvironmentListViewComponent implements OnDestroy {
private confirmationService = inject(ConfirmationService);
private keycloakService = inject(KeycloakService);
private permissionService = inject(PermissionService);
private environmentDeploymentWebSocketService = inject(EnvironmentDeploymentWebSocketService);
private currentTime = signal(Date.now());
private intervalId: number | undefined;
private webSocketCleanup: (() => void) | undefined;
private messageService = inject(MessageService);

deployDialogVisible = signal(false);
Expand Down Expand Up @@ -157,7 +160,10 @@ export class EnvironmentListViewComponent implements OnDestroy {
canViewAllEnvironments = computed(() => this.isLoggedIn() && this.editable() && this.hasEditEnvironmentPermissions());
queryFunction = computed(() => {
const options = this.canViewAllEnvironments() ? getAllEnvironmentsOptions() : getAllEnabledEnvironmentsOptions();
return { ...options, refetchInterval: 3000 };
return {
...options,
refetchInterval: () => (this.environmentDeploymentWebSocketService.isConnected() ? false : 60000),
};
});
queryKey = computed(() => (this.canViewAllEnvironments() ? getAllEnvironmentsQueryKey() : getAllEnabledEnvironmentsQueryKey()));

Expand Down Expand Up @@ -199,12 +205,14 @@ export class EnvironmentListViewComponent implements OnDestroy {
}

constructor() {
this.webSocketCleanup = this.environmentDeploymentWebSocketService.activate();
this.intervalId = window.setInterval(() => {
this.currentTime.set(Date.now());
}, 1000);
}

ngOnDestroy(): void {
this.webSocketCleanup?.();
if (this.intervalId !== undefined) {
clearInterval(this.intervalId);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Component, computed, inject, input, signal } from '@angular/core';
import { Component, OnDestroy, computed, inject, input, signal } from '@angular/core';
import { EnvironmentDto, getReleaseInfoByName, ReleaseInfoDetailsDto } from '@app/core/modules/openapi';
import {
deployToEnvironmentMutation,
getAllEnabledEnvironmentsOptions,
getAllEnabledEnvironmentsQueryKey,
getEnvironmentByIdQueryKey,
} from '@app/core/modules/openapi/@tanstack/angular-query-experimental.gen';
import { EnvironmentDeploymentWebSocketService } from '@app/core/services/environment-deployment-websocket/environment-deployment-websocket.service';
import { KeycloakService } from '@app/core/services/keycloak/keycloak.service';
import { PermissionService } from '@app/core/services/permission.service';
import { injectMutation, injectQuery, QueryClient } from '@tanstack/angular-query-experimental';
Expand Down Expand Up @@ -46,7 +47,7 @@ import { DeployConfirmationComponent } from '@app/components/dialogs/deploy-conf
],
templateUrl: './release-candidate-deployment-table.component.html',
})
export class ReleaseCandidateDeploymentTableComponent {
export class ReleaseCandidateDeploymentTableComponent implements OnDestroy {
releaseCandidate = input.required<ReleaseInfoDetailsDto>();
queryClient = inject(QueryClient);
selectedEnvironment = signal<EnvironmentDto | undefined>(undefined);
Expand All @@ -55,11 +56,16 @@ export class ReleaseCandidateDeploymentTableComponent {
messageService = inject(MessageService);
keycloakService = inject(KeycloakService);
permissions = inject(PermissionService);
private environmentDeploymentWebSocketService = inject(EnvironmentDeploymentWebSocketService);
private webSocketCleanup = this.environmentDeploymentWebSocketService.activate();
isLoggedIn = computed(() => this.keycloakService.isLoggedIn());

userCanDeploy = computed(() => !!(this.isLoggedIn() && this.permissions.isAdmin()));

environmentQuery = injectQuery(() => ({ ...getAllEnabledEnvironmentsOptions(), refetchInterval: 3000 }));
environmentQuery = injectQuery(() => ({
...getAllEnabledEnvironmentsOptions(),
refetchInterval: () => (this.environmentDeploymentWebSocketService.isConnected() ? false : 60000),
}));

groupedEnvironments = computed(() => {
const environments = this.environmentQuery.data() || [];
Expand Down Expand Up @@ -156,4 +162,8 @@ export class ReleaseCandidateDeploymentTableComponent {
}
return url;
}

ngOnDestroy(): void {
this.webSocketCleanup();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection, signal } from '@angular/core';
import { Subject } from 'rxjs';
import { QueryClient } from '@tanstack/angular-query-experimental';
import { KeycloakService } from '@app/core/services/keycloak/keycloak.service';
import { RepositoryService } from '@app/core/services/repository.service';
import { EnvironmentDeploymentWebSocketService } from './environment-deployment-websocket.service';
import { EnvironmentDeploymentWsServerMessage } from './environment-deployment-websocket.types';
import { webSocket } from 'rxjs/webSocket';

vi.mock('rxjs/webSocket', () => ({
webSocket: vi.fn(),
}));

describe('EnvironmentDeploymentWebSocketService', () => {
let socketSubject: Subject<EnvironmentDeploymentWsServerMessage>;
let queryClient: { invalidateQueries: ReturnType<typeof vi.fn> };
let repositoryService: { currentRepositoryId: ReturnType<typeof signal<number | string | null>> };

beforeEach(() => {
vi.useFakeTimers();
socketSubject = new Subject<EnvironmentDeploymentWsServerMessage>();
queryClient = { invalidateQueries: vi.fn() };
repositoryService = { currentRepositoryId: signal<number | string | null>(null) };
vi.mocked(webSocket).mockReturnValue(socketSubject as never);

TestBed.configureTestingModule({
providers: [
provideZonelessChangeDetection(),
EnvironmentDeploymentWebSocketService,
{
provide: QueryClient,
useValue: queryClient,
},
{
provide: KeycloakService,
useValue: {
keycloak: {
token: 'access-token',
},
},
},
{
provide: RepositoryService,
useValue: repositoryService,
},
],
});
});

afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});

it('opens the repository-scoped WebSocket with token and repository subprotocols', () => {
const service = TestBed.inject(EnvironmentDeploymentWebSocketService);

service.activate();
repositoryService.currentRepositoryId.set(42);
TestBed.flushEffects();

expect(webSocket).toHaveBeenCalledOnce();
const config = vi.mocked(webSocket).mock.calls[0][0];
expect(config.url).toMatch(/\/ws\/environments$/);
expect(config.protocol).toEqual(['helios.v1', 'helios.token.access-token', 'helios.repo.42']);

config.openObserver?.next(undefined);
expect(service.isConnected()).toBe(true);
});

it('batches environment deployment invalidations', () => {
TestBed.inject(EnvironmentDeploymentWebSocketService).activate();
repositoryService.currentRepositoryId.set(42);
TestBed.flushEffects();

socketSubject.next({
type: 'environment-deployment-invalidated',
repositoryId: 42,
environmentId: 7,
});
socketSubject.next({
type: 'environment-deployment-invalidated',
repositoryId: 42,
environmentId: 8,
});

expect(queryClient.invalidateQueries).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);

expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(5);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { Injectable, computed, effect, inject, signal } from '@angular/core';
import { QueryClient } from '@tanstack/angular-query-experimental';
import {
getAllEnabledEnvironmentsQueryKey,
getAllEnvironmentsQueryKey,
getEnvironmentByIdQueryKey,
getEnvironmentsByUserLockingQueryKey,
} from '@app/core/modules/openapi/@tanstack/angular-query-experimental.gen';
import { KeycloakService } from '@app/core/services/keycloak/keycloak.service';
import { RepositoryService } from '@app/core/services/repository.service';
import { environment } from 'environments/environment';
import { Subscription, timer } from 'rxjs';
import { retry } from 'rxjs/operators';
import { WebSocketSubject, webSocket } from 'rxjs/webSocket';
import { EnvironmentDeploymentWsServerMessage } from './environment-deployment-websocket.types';

const SUBPROTOCOL = 'helios.v1';
const TOKEN_PREFIX = 'helios.token.';
const REPO_PREFIX = 'helios.repo.';
const INVALIDATION_DEBOUNCE_MS = 100;

@Injectable({ providedIn: 'root' })
export class EnvironmentDeploymentWebSocketService {
private readonly keycloak = inject(KeycloakService);
private readonly queryClient = inject(QueryClient);
private readonly repositoryService = inject(RepositoryService);

private socket$: WebSocketSubject<EnvironmentDeploymentWsServerMessage> | null = null;
private socketSub: Subscription | null = null;
private activeConsumers = signal(0);
private activeRepositoryId: number | null = null;
private connected = signal(false);
private pendingEnvironmentIds = new Set<number>();
private invalidationTimer: ReturnType<typeof setTimeout> | null = null;

readonly isConnected = computed(() => this.connected());

constructor() {
effect(() => {
const repositoryId = this.repositoryService.currentRepositoryId();
const activeConsumers = this.activeConsumers();
this.syncConnection(repositoryId, activeConsumers);
});
}

activate(): () => void {
this.activeConsumers.update(count => count + 1);
return () => {
this.activeConsumers.update(count => Math.max(0, count - 1));
};
}

private syncConnection(repositoryIdValue: number | string | null, activeConsumers: number): void {
const repositoryId = this.parseRepositoryId(repositoryIdValue);
const shouldConnect = activeConsumers > 0 && repositoryId !== null;

if (!shouldConnect) {
this.disconnect();
return;
}

if (this.socket$ && this.activeRepositoryId === repositoryId) {
return;
}

this.disconnect();
this.activeRepositoryId = repositoryId;
this.socket$ = this.openSocket(repositoryId);
this.socketSub = this.socket$
.pipe(
retry({
delay: (_, attempt) => timer(Math.min(30_000, 500 * 2 ** Math.min(attempt, 6))),
})
)
.subscribe({
next: msg => this.handleMessage(msg),
error: () => {
this.connected.set(false);
this.socket$ = null;
},
});
}

private openSocket(repositoryId: number): WebSocketSubject<EnvironmentDeploymentWsServerMessage> {
const token = this.keycloak.keycloak.token ?? '';
const protocols = [SUBPROTOCOL, `${TOKEN_PREFIX}${token}`, `${REPO_PREFIX}${repositoryId}`];
return webSocket<EnvironmentDeploymentWsServerMessage>({
url: this.buildUrl(),
protocol: protocols,
openObserver: {
next: () => this.connected.set(true),
},
closeObserver: {
next: () => this.connected.set(false),
},
});
}

private handleMessage(msg: EnvironmentDeploymentWsServerMessage): void {
if (msg.type !== 'environment-deployment-invalidated') {
return;
}

if (msg.repositoryId !== this.activeRepositoryId) {
return;
}

this.pendingEnvironmentIds.add(msg.environmentId);
if (this.invalidationTimer) {
return;
}

this.invalidationTimer = setTimeout(() => this.flushInvalidations(), INVALIDATION_DEBOUNCE_MS);
}

private flushInvalidations(): void {
const environmentIds = Array.from(this.pendingEnvironmentIds);
this.pendingEnvironmentIds.clear();
this.invalidationTimer = null;

for (const environmentId of environmentIds) {
this.queryClient.invalidateQueries({
queryKey: getEnvironmentByIdQueryKey({ path: { id: environmentId } }),
});
}

if (environmentIds.length === 0) {
return;
}

this.queryClient.invalidateQueries({ queryKey: getAllEnabledEnvironmentsQueryKey() });
this.queryClient.invalidateQueries({ queryKey: getAllEnvironmentsQueryKey() });
this.queryClient.invalidateQueries({ queryKey: getEnvironmentsByUserLockingQueryKey() });
}

private disconnect(): void {
if (this.invalidationTimer) {
clearTimeout(this.invalidationTimer);
this.invalidationTimer = null;
}
this.pendingEnvironmentIds.clear();
this.socketSub?.unsubscribe();
this.socketSub = null;
this.socket$?.complete();
this.socket$ = null;
this.activeRepositoryId = null;
this.connected.set(false);
}

private buildUrl(): string {
const base = environment.serverUrl;
const wsBase = base.startsWith('https://') ? 'wss://' + base.slice('https://'.length) : base.startsWith('http://') ? 'ws://' + base.slice('http://'.length) : base;
return wsBase.replace(/\/+$/, '') + '/ws/environments';
}

private parseRepositoryId(repositoryId: number | string | null): number | null {
if (repositoryId === null) {
return null;
}
const parsed = Number(repositoryId);
return Number.isFinite(parsed) ? parsed : null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export type EnvironmentDeploymentWsClientMessage = { type: 'ping' };

export type EnvironmentDeploymentWsServerMessage =
| {
type: 'environment-deployment-invalidated';
repositoryId: number;
environmentId: number;
}
| { type: 'error'; code: string; message: string }
| { type: 'pong' };
1 change: 1 addition & 0 deletions server/application-server/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation 'org.springframework.boot:spring-boot-starter-restclient'
implementation 'org.springframework.boot:spring-boot-starter-security-oauth2-resource-server'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'org.springframework.boot:spring-boot-starter-flyway'
implementation 'org.springframework.boot:spring-boot-jackson2'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
/* shared‑secret filter handles these requests */
.requestMatchers(HttpMethod.POST, "/api/environments/status/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/tests/flakiness-scores").permitAll()
/* WebSocket handshake auth happens in WebSocketJwtHandshakeInterceptor */
.requestMatchers("/ws/**").permitAll()
/* other public endpoints (e.g. Swagger) */
.requestMatchers(
"/auth/**",
Expand Down
Loading
Loading