Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions gateway/frontend/src/app/core/models/docker-action.model.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export type DockerActionKind = 'temporary' | 'always';
export type DockerActionKind = 'temporary' | 'always' | 'mount';

export type DockerActionGroup = 'containers' | 'images' | 'volumes' | 'networks' | 'system';
export type DockerActionGroup = 'containers' | 'images' | 'volumes' | 'networks' | 'system' | 'mounts';

export interface DockerActionDef {
action: string;
Expand Down
107 changes: 107 additions & 0 deletions gateway/frontend/src/app/core/services/foreground-sync.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { Injectable, inject, DestroyRef, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

/**
* Browser-lifecycle laag voor de state-sync: luistert naar focus/visibility en
* draait een vangnet-poll. Bewust losgetrokken van StateService zodat die zich
* enkel met state + API/WebSocket-sync bezighoudt (single responsibility).
*
* De WebSocket is de primaire push. Deze poll is puur het vangnet en draait
* daarom alléén als de realtime-push niet levert — zie {@link setRealtimeLive}.
* Zo verdwijnt in het normale geval (WS open) de vaste 5s-polling volledig,
* terwijl nieuwe firewall-requests bij een verbroken/afwezige socket (bijv.
* dev-proxy zonder /ws) alsnog binnen enkele seconden verschijnen.
*/
@Injectable({ providedIn: 'root' })
export class ForegroundSyncService {
private platformId = inject(PLATFORM_ID);
private destroyRef = inject(DestroyRef);

// Handle van de zichtbaarheidsgebonden voorgrond-poll.
private pollHandle: ReturnType<typeof setInterval> | null = null;
private started = false;
// Levert de primaire push (WebSocket) op dit moment? Zolang dit waar is blijft
// de vangnet-poll uit.
private realtimeLive = false;
// Callback die een resync aanvraagt; de eigenaar (StateService) bepaalt zelf of
// er daadwerkelijk gefetcht wordt (throttle op basis van laatste load).
private requestRefresh: () => void = () => {};

// Poll-interval terwijl het tabblad zichtbaar is én de WS niet levert.
private static readonly VISIBLE_POLL_MS = 5_000;

/**
* Begin te reageren op focus/visibility en draai zo nodig de vangnet-poll.
* @param requestRefresh wordt aangeroepen zodra een resync gewenst is
* (focus terug, tab weer zichtbaar, of een poll-tick).
*/
start(requestRefresh: () => void): void {
if (!isPlatformBrowser(this.platformId) || this.started) return;
this.started = true;
this.requestRefresh = requestRefresh;
// Refetch zodra het tabblad/venster terug in focus/zicht komt — het
// Angular-equivalent van TanStack Query's refetchOnWindowFocus. `focus`
// dekt alt-tab tussen apps; `visibility` dekt tab-wissels binnen de browser.
window.addEventListener('focus', this.onWindowFocus);
document.addEventListener('visibilitychange', this.onVisibilityChange);
this.evaluatePolling();
this.destroyRef.onDestroy(() => this.stop());
}

/**
* Meld of de realtime-push (WebSocket) op dit moment levert. Bij `true` stopt
* de vangnet-poll; bij `false` (verbroken/afwezige socket) start hij weer,
* mits het tabblad zichtbaar is.
*/
setRealtimeLive(live: boolean): void {
if (this.realtimeLive === live) return;
this.realtimeLive = live;
this.evaluatePolling();
}

// Arrow-properties zodat `this` klopt als event-listener en add/remove
// dezelfde referentie delen.
private onWindowFocus = (): void => {
if (document.visibilityState === 'hidden') return;
this.requestRefresh();
};

private onVisibilityChange = (): void => {
if (document.visibilityState === 'hidden') {
// Verborgen tab: browsers throttlen timers zwaar en de WS kan sluimeren —
// stop de poll en synchroniseer weer bij terugkeer.
this.stopPolling();
return;
}
this.requestRefresh();
this.evaluatePolling();
};

// De poll draait alleen als het tabblad zichtbaar is én de WS niet levert.
private evaluatePolling(): void {
const shouldPoll = !this.realtimeLive && document.visibilityState !== 'hidden';
if (shouldPoll) this.startPolling();
else this.stopPolling();
}

private startPolling(): void {
if (this.pollHandle !== null) return;
this.pollHandle = setInterval(() => {
if (!this.realtimeLive && document.visibilityState !== 'hidden') this.requestRefresh();
}, ForegroundSyncService.VISIBLE_POLL_MS);
}

private stopPolling(): void {
if (this.pollHandle !== null) {
clearInterval(this.pollHandle);
this.pollHandle = null;
}
}

private stop(): void {
window.removeEventListener('focus', this.onWindowFocus);
document.removeEventListener('visibilitychange', this.onVisibilityChange);
this.stopPolling();
this.started = false;
}
}
36 changes: 27 additions & 9 deletions gateway/frontend/src/app/core/services/state.service.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { Injectable, inject, DestroyRef, PLATFORM_ID } from '@angular/core';
import { Injectable, inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { BehaviorSubject, forkJoin, timer } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { BehaviorSubject, forkJoin } from 'rxjs';
import { ApiService } from './api.service';
import { ForegroundSyncService } from './foreground-sync.service';
import { Container } from '../models/container.model';
import { Rule } from '../models/rule.model';
import { GrantMap } from '../models/grant.model';

@Injectable({ providedIn: 'root' })
export class StateService {
private api = inject(ApiService);
private destroyRef = inject(DestroyRef);
private platformId = inject(PLATFORM_ID);
// Browser-lifecycle (focus/visibility) + vangnet-poll leven hier; StateService
// zelf gaat enkel over state + API/WebSocket-sync.
private foregroundSync = inject(ForegroundSyncService);

containers$ = new BehaviorSubject<Container[]>([]);
rules$ = new BehaviorSubject<Rule[]>([]);
Expand All @@ -21,16 +23,25 @@ export class StateService {
private ws: WebSocket | null = null;
// Debounce rapid consecutive triggers (e.g. WS message + timer race, or reconnect overlap)
private loadDebounce: ReturnType<typeof setTimeout> | null = null;
// Timestamp of the last loadAll — basis voor de refetch-throttle.
private lastLoadAt = 0;
// Kort venster waarin een focus/visibility/poll-event géén extra fetch triggert:
// de data is dan nog vers (WS/poll of een refetch van net).
private static readonly REFETCH_STALE_MS = 2_000;

constructor() {
this.loadAll();
if (isPlatformBrowser(this.platformId)) {
this.connectWs();
// De vangnet-laag vraagt een resync aan zodra dat zinvol is (tab weer in
// focus/zicht, of een poll-tick terwijl de WS niet levert).
this.foregroundSync.start(() => this.refetchIfStale());
}
// Fallback poll every 30s in case WS drops
timer(30_000, 30_000)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.triggerLoad());
}

private refetchIfStale(): void {
if (Date.now() - this.lastLoadAt < StateService.REFETCH_STALE_MS) return;
this.triggerLoad();
}

private connectWs(): void {
Expand All @@ -41,9 +52,15 @@ export class StateService {
}
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
this.ws = new WebSocket(`${proto}//${location.host}/ws`);
// Open socket = primaire push levert → vangnet-poll uit. Sluiten/fout →
// poll weer aan zodat updates blijven binnenkomen tot de WS terug is.
this.ws.onopen = () => this.foregroundSync.setRealtimeLive(true);
this.ws.onmessage = () => this.triggerLoad();
this.ws.onerror = () => this.ws?.close();
this.ws.onclose = () => setTimeout(() => this.connectWs(), 3000);
this.ws.onclose = () => {
this.foregroundSync.setRealtimeLive(false);
setTimeout(() => this.connectWs(), 3000);
};
}

private triggerLoad(): void {
Expand All @@ -52,6 +69,7 @@ export class StateService {
}

loadAll(): void {
this.lastLoadAt = Date.now();
forkJoin([
this.api.getContainers(),
this.api.getRules(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -264,11 +264,30 @@
flex-shrink: 0;
}

.req-row__icon--path { background: var(--accent-soft); color: var(--accent); }

.req-row__info {
flex: 1;
min-width: 0;
}

.req-row__host { color: var(--text-muted); }

.req-row__tag {
font-family: 'Space Grotesk', monospace;
font-size: 11px; color: var(--text-muted);
background: var(--surface-hover);
padding: 2px 8px; border-radius: 6px;
white-space: nowrap; max-width: 200px;
overflow: hidden; text-overflow: ellipsis;
}
.req-row__tag--path { background: var(--accent-soft); color: var(--accent); }
.req-row__sample {
font-family: 'Space Grotesk', monospace;
font-size: 11.5px; color: var(--text-dim);
margin-top: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

.req-row__domain {
font-family: 'Space Grotesk', monospace;
font-size: 14px;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,10 @@ <h1>{{ shortName }}</h1>

<!-- ── Segmented tabs ── -->
<div class="cd-tabs">
@let pendingCount = requested.length + pathRequests(d.rules, d.globalRules).length;
<button [class.on]="activeTab === 'firewall'" (click)="setTab('firewall')">
Firewall
@if (requested.length > 0) { <i>{{ requested.length }}</i> }
@if (pendingCount > 0) { <i>{{ pendingCount }}</i> }
</button>
<button [class.on]="activeTab === 'docker'" (click)="setTab('docker')">Docker</button>
<button [class.on]="activeTab === 'noot'" (click)="setTab('noot')">Noot</button>
Expand All @@ -109,6 +110,7 @@ <h1>{{ shortName }}</h1>

<!-- ════════════════════════════════════════════ TAB: FIREWALL -->
@if (activeTab === 'firewall') {
@let pathReq = pathRequests(d.rules, d.globalRules);

<!-- Pending requests -->
<section class="cd-sec">
Expand All @@ -117,7 +119,7 @@ <h2 class="cd-sec__title">Pending <span class="em">requests</span></h2>
<p class="cd-sec__sub">— for this container</p>
</div>
<div class="card req-card">
@if (requested.length === 0) {
@if (requested.length === 0 && pathReq.length === 0) {
<div class="fw-empty">
<app-icon name="circle-check" [size]="24" />
<span>No pending requests.</span>
Expand All @@ -137,6 +139,27 @@ <h2 class="cd-sec__title">Pending <span class="em">requests</span></h2>
<app-pie-menu [config]="pieConfig" (action)="onPieAction($event, r)" />
</div>
}
<!-- Path sub-requests from path-mode domains -->
@for (row of pathReq; track row.rule.id) {
<div class="req-row">
<div class="req-row__icon req-row__icon--path"><app-icon name="file-text" [size]="17" /></div>
<div class="req-row__info">
<div class="req-row__domain">
<span class="req-row__host">{{ row.domain }}</span>{{ row.path_pattern }}
</div>
<div class="req-row__meta">
<span class="req-row__tag req-row__tag--path">path</span>
<span><b>{{ row.rule.request_count }}</b> request{{ row.rule.request_count !== 1 ? 's' : '' }}</span>
<span class="sep">·</span>
<span>{{ row.rule.last_seen | relTime }}</span>
</div>
@if (row.last_path) {
<div class="req-row__sample">{{ row.last_path }}</div>
}
</div>
<app-pie-menu [config]="pieConfigPath" (action)="onPathPieAction($event, row)" />
</div>
}
}
</div>
</section>
Expand All @@ -152,9 +175,9 @@ <h2 class="card__title">Container rules</h2>
<button [class.on]="rulesTab === 'deny'" (click)="rulesTab = 'deny'">
Blocked <i>{{ deny.length }}</i>
</button>
@if (cPathDomains.length > 0) {
@if (cPathDomains.length + gPathDomains.length > 0) {
<button [class.on]="rulesTab === 'path'" (click)="rulesTab = 'path'">
Path mode <i>{{ cPathDomains.length }}</i>
Path mode <i>{{ cPathDomains.length + gPathDomains.length }}</i>
</button>
}
</div>
Expand Down Expand Up @@ -189,6 +212,19 @@ <h2 class="card__title">Container rules</h2>
<div class="fw-empty"><span>No allowed domains.</span></div>
}
</div>
@if (gAllow.length > 0) {
<div class="cd-global-head"><app-icon name="globe" [size]="13" /> Global rules</div>
<div class="rule-list">
@for (r of gAllow; track r.id) {
<div class="rule-row rule-row--global">
<span class="rule-row__status rule-row__status--allow"><app-icon name="check" [size]="14" /></span>
<span class="rule-row__domain">{{ r.domain }}</span>
<span class="scope-chip scope-chip--global">Global</span>
<span class="rule-row__last">{{ r.last_seen | relTime }}</span>
</div>
}
</div>
}
}

@if (rulesTab === 'deny') {
Expand All @@ -207,38 +243,32 @@ <h2 class="card__title">Container rules</h2>
<div class="fw-empty"><span>No blocked domains.</span></div>
}
</div>
@if (gDeny.length > 0) {
<div class="cd-global-head"><app-icon name="globe" [size]="13" /> Global rules</div>
<div class="rule-list">
@for (r of gDeny; track r.id) {
<div class="rule-row rule-row--global">
<span class="rule-row__status rule-row__status--deny"><app-icon name="deny" [size]="14" /></span>
<span class="rule-row__domain">{{ r.domain }}</span>
<span class="scope-chip scope-chip--global">Global</span>
<span class="rule-row__last">{{ r.last_seen | relTime }}</span>
</div>
}
</div>
}
}

@if (rulesTab === 'path') {
<app-path-allowlist [domains]="cPathDomains" [now]="nowTs" (changed)="reload()" />
}

<!-- Global rules divider -->
@if (gAllow.length > 0 || gDeny.length > 0 || gPathDomains.length > 0) {
<div class="cd-global-head">
<app-icon name="globe" [size]="13" /> Global rules
</div>
<div class="rule-list">
@for (r of gAllow; track r.id) {
<div class="rule-row rule-row--global">
<span class="rule-row__status rule-row__status--allow"><app-icon name="check" [size]="14" /></span>
<span class="rule-row__domain">{{ r.domain }}</span>
<span class="scope-chip scope-chip--global">Global</span>
<span class="rule-row__last">{{ r.last_seen | relTime }}</span>
</div>
}
@for (r of gDeny; track r.id) {
<div class="rule-row rule-row--global">
<span class="rule-row__status rule-row__status--deny"><app-icon name="deny" [size]="14" /></span>
<span class="rule-row__domain">{{ r.domain }}</span>
<span class="scope-chip scope-chip--global">Global</span>
<span class="rule-row__last">{{ r.last_seen | relTime }}</span>
</div>
}
</div>
@if (cPathDomains.length > 0) {
<app-path-allowlist [domains]="cPathDomains" [now]="nowTs" (changed)="reload()" />
}
@if (gPathDomains.length > 0) {
<div class="cd-global-head"><app-icon name="globe" [size]="13" /> Global rules</div>
<app-path-allowlist [domains]="gPathDomains" [now]="nowTs" (changed)="reload()" />
}
@if (cPathDomains.length === 0 && gPathDomains.length === 0) {
<div class="fw-empty"><span>No path-mode domains.</span></div>
}
}
</div>
}
Expand Down
Loading
Loading