From f944635dcf5035e4bc50f5d1c537594169d8afcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Thu, 18 Jun 2026 00:02:04 -0600 Subject: [PATCH 01/36] feat[frontend](federation_server): added federation server front handling --- frontend/src/app/app.module.ts | 14 ++ .../federation-instance.interceptor.ts | 53 +++++++ frontend/src/app/core/auth/account.service.ts | 45 +++++- .../instance-form-modal.component.html | 44 ++++++ .../instance-form-modal.component.scss | 3 + .../instance-form-modal.component.ts | 91 +++++++++++ .../instance-switcher.component.html | 60 ++++++++ .../instance-switcher.component.scss | 57 +++++++ .../instance-switcher.component.ts | 142 ++++++++++++++++++ .../domain/federation-instance.model.ts | 8 + .../domain/federation-mode.model.ts | 4 + .../federation/federation-routing.module.ts | 20 +++ .../src/app/federation/federation.module.ts | 31 ++++ .../federation/hooks/federation-bootstrap.ts | 10 ++ .../pages/welcome/welcome.component.html | 16 ++ .../pages/welcome/welcome.component.scss | 1 + .../pages/welcome/welcome.component.ts | 46 ++++++ .../federation-instance-state.service.ts | 85 +++++++++++ .../services/federation-instances.service.ts | 39 +++++ .../services/federation-mode.service.ts | 38 +++++ .../layout/header/header.component.html | 12 +- .../layout/header/header.component.ts | 8 +- .../mobile-header.component.html | 11 +- .../mobile-header/mobile-header.component.ts | 7 +- frontend/src/app/shared/utm-shared.module.ts | 4 +- frontend/src/environments/environment.ts | 10 ++ 26 files changed, 845 insertions(+), 14 deletions(-) create mode 100644 frontend/src/app/blocks/interceptor/federation-instance.interceptor.ts create mode 100644 frontend/src/app/federation/components/instance-form-modal/instance-form-modal.component.html create mode 100644 frontend/src/app/federation/components/instance-form-modal/instance-form-modal.component.scss create mode 100644 frontend/src/app/federation/components/instance-form-modal/instance-form-modal.component.ts create mode 100644 frontend/src/app/federation/components/instance-switcher/instance-switcher.component.html create mode 100644 frontend/src/app/federation/components/instance-switcher/instance-switcher.component.scss create mode 100644 frontend/src/app/federation/components/instance-switcher/instance-switcher.component.ts create mode 100644 frontend/src/app/federation/domain/federation-instance.model.ts create mode 100644 frontend/src/app/federation/domain/federation-mode.model.ts create mode 100644 frontend/src/app/federation/federation-routing.module.ts create mode 100644 frontend/src/app/federation/federation.module.ts create mode 100644 frontend/src/app/federation/hooks/federation-bootstrap.ts create mode 100644 frontend/src/app/federation/pages/welcome/welcome.component.html create mode 100644 frontend/src/app/federation/pages/welcome/welcome.component.scss create mode 100644 frontend/src/app/federation/pages/welcome/welcome.component.ts create mode 100644 frontend/src/app/federation/services/federation-instance-state.service.ts create mode 100644 frontend/src/app/federation/services/federation-instances.service.ts create mode 100644 frontend/src/app/federation/services/federation-mode.service.ts diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index b2020ec51..e58b7036a 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -23,6 +23,7 @@ import {AppComponent} from './app.component'; import {AuthExpiredInterceptor} from './blocks/interceptor/auth-expired.interceptor'; import {AuthInterceptor} from './blocks/interceptor/auth.interceptor'; import {ErrorHandlerInterceptor} from './blocks/interceptor/errorhandler.interceptor'; +import {FederationInstanceInterceptor} from './blocks/interceptor/federation-instance.interceptor'; import {ManageHttpInterceptor} from './blocks/interceptor/managehttp.interceptor'; import {NotificationInterceptor} from './blocks/interceptor/notification.interceptor'; import {HttpCancelService} from './blocks/service/httpcancel.service'; @@ -36,6 +37,8 @@ import {AlertIncidentStatusChangeBehavior} from './shared/behaviors/alert-incide import {GettingStartedBehavior} from './shared/behaviors/getting-started.behavior'; import {NavBehavior} from './shared/behaviors/nav.behavior'; import {NewAlertBehavior} from './shared/behaviors/new-alert.behavior'; +import {initFederationMode} from './federation/hooks/federation-bootstrap'; +import {FederationModeService} from './federation/services/federation-mode.service'; import {TimezoneFormatService} from './shared/services/utm-timezone.service'; import {AppVersionService} from './shared/services/version/app-version.service'; import {UtmSharedModule} from './shared/utm-shared.module'; @@ -109,6 +112,11 @@ export function initTimezoneFormat(apiChecker: ApiServiceCheckerService, deps: [SessionStorageService, LocalStorageService], multi: true }, + { + provide: HTTP_INTERCEPTORS, + useClass: FederationInstanceInterceptor, + multi: true + }, { provide: HTTP_INTERCEPTORS, useClass: AuthExpiredInterceptor, @@ -132,6 +140,12 @@ export function initTimezoneFormat(apiChecker: ApiServiceCheckerService, useClass: ManageHttpInterceptor, multi: true, }, + { + provide: APP_INITIALIZER, + useFactory: initFederationMode, + deps: [FederationModeService], + multi: true + }, { provide: APP_INITIALIZER, useFactory: initTimezoneFormat, diff --git a/frontend/src/app/blocks/interceptor/federation-instance.interceptor.ts b/frontend/src/app/blocks/interceptor/federation-instance.interceptor.ts new file mode 100644 index 000000000..ccefa58c1 --- /dev/null +++ b/frontend/src/app/blocks/interceptor/federation-instance.interceptor.ts @@ -0,0 +1,53 @@ +import {HttpEvent, HttpHandler, HttpInterceptor, HttpRequest} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {Observable} from 'rxjs'; +import {SERVER_API_URL} from '../../app.constants'; +import {FederationInstanceStateService} from '../../federation/services/federation-instance-state.service'; +import {FederationModeService} from '../../federation/services/federation-mode.service'; + +const AUTH_PATH_PREFIXES = [ + 'api/authenticate', + 'api/v1/auth/', + 'api/tfa/', + 'auth/' +]; + +@Injectable() +export class FederationInstanceInterceptor implements HttpInterceptor { + constructor(private modeService: FederationModeService, + private instanceState: FederationInstanceStateService) {} + + intercept(request: HttpRequest, next: HttpHandler): Observable> { + if (!this.modeService.isActive) { + return next.handle(request); + } + if (!this.isApiRequest(request.url)) { + return next.handle(request); + } + if (this.isAuthEndpoint(request.url)) { + return next.handle(request); + } + const instance = this.instanceState.current; + if (!instance) { + return next.handle(request); + } + const cloned = request.clone({ + setHeaders: {'X-UTM-Instance': String(instance.id)} + }); + return next.handle(cloned); + } + + private isApiRequest(url: string): boolean { + if (!url) { + return false; + } + if (/^https?:/i.test(url)) { + return !!SERVER_API_URL && url.startsWith(SERVER_API_URL); + } + return true; + } + + private isAuthEndpoint(url: string): boolean { + return AUTH_PATH_PREFIXES.some(prefix => url.includes(prefix)); + } +} diff --git a/frontend/src/app/core/auth/account.service.ts b/frontend/src/app/core/auth/account.service.ts index e117bf6f0..7674d5f03 100644 --- a/frontend/src/app/core/auth/account.service.ts +++ b/frontend/src/app/core/auth/account.service.ts @@ -4,6 +4,9 @@ import {Observable, Subject} from 'rxjs'; import {SERVER_API_URL} from '../../app.constants'; import {HttpCancelService} from '../../blocks/service/httpcancel.service'; +import {FederationInstancesService} from '../../federation/services/federation-instances.service'; +import {FederationInstanceStateService} from '../../federation/services/federation-instance-state.service'; +import {FederationModeService} from '../../federation/services/federation-mode.service'; import {Account} from '../user/account.model'; import {AuthServerProvider} from './auth-jwt.service'; import {extractQueryParamsForNavigation} from "../../shared/util/query-params-to-filter.util"; @@ -18,6 +21,7 @@ export class AccountService { private userIdentity: Account; private authenticated = false; private authenticationState = new Subject(); + private instancesFetchInFlight = false; constructor(private http: HttpClient, private authServerProvider: AuthServerProvider, @@ -25,7 +29,10 @@ export class AccountService { private stateStorageService: StateStorageService, private router: Router, private spinner: NgxSpinnerService, - private utmToast: UtmToastService) { + private utmToast: UtmToastService, + private federationModeService: FederationModeService, + private federationInstancesService: FederationInstancesService, + private federationInstanceState: FederationInstanceStateService) { } fetch(): Observable> { @@ -100,10 +107,13 @@ export class AccountService { if (account) { this.userIdentity = account; this.authenticated = true; - } else { - this.userIdentity = null; - this.authenticated = false; + return this.ensureFederationInstancesLoaded().then(() => { + this.authenticationState.next(this.userIdentity); + return this.userIdentity; + }); } + this.userIdentity = null; + this.authenticated = false; this.authenticationState.next(this.userIdentity); return this.userIdentity; }) @@ -141,9 +151,36 @@ export class AccountService { } } + private ensureFederationInstancesLoaded(): Promise { + if (!this.federationModeService.isActive) { + return Promise.resolve(); + } + if (this.federationInstanceState.instances.length > 0) { + return Promise.resolve(); + } + if (this.instancesFetchInFlight) { + return Promise.resolve(); + } + this.instancesFetchInFlight = true; + return this.federationInstancesService.list() + .toPromise() + .then(instances => { + this.instancesFetchInFlight = false; + this.federationInstanceState.setInstances(instances || []); + }) + .catch(() => { + this.instancesFetchInFlight = false; + }); + } + startNavigation() { this.identity(true).then(account => { if (account) { + if (this.federationModeService.isActive && this.federationInstanceState.instances.length === 0) { + this.router.navigate(['/federation/welcome']) + .then(() => this.spinner.hide()); + return; + } const { path, queryParams } = extractQueryParamsForNavigation(this.stateStorageService.getUrl() ? this.stateStorageService.getUrl() : '' ); if (path) { diff --git a/frontend/src/app/federation/components/instance-form-modal/instance-form-modal.component.html b/frontend/src/app/federation/components/instance-form-modal/instance-form-modal.component.html new file mode 100644 index 000000000..9e7e3ecfa --- /dev/null +++ b/frontend/src/app/federation/components/instance-form-modal/instance-form-modal.component.html @@ -0,0 +1,44 @@ +
+
{{ title }}
+ +
+
+
+
{{ errorMessage }}
+ +
+ + +
+ +
+ + +
+ +
+ + + {{ apiKeyHint }} +
+ +
+ + +
+
+
+ + +
+
diff --git a/frontend/src/app/federation/components/instance-form-modal/instance-form-modal.component.scss b/frontend/src/app/federation/components/instance-form-modal/instance-form-modal.component.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/frontend/src/app/federation/components/instance-form-modal/instance-form-modal.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/frontend/src/app/federation/components/instance-form-modal/instance-form-modal.component.ts b/frontend/src/app/federation/components/instance-form-modal/instance-form-modal.component.ts new file mode 100644 index 000000000..38e9c4d82 --- /dev/null +++ b/frontend/src/app/federation/components/instance-form-modal/instance-form-modal.component.ts @@ -0,0 +1,91 @@ +import {Component, EventEmitter, Input, Output} from '@angular/core'; +import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; +import {FederationInstance} from '../../domain/federation-instance.model'; +import {FederationInstanceInput, FederationInstancesService} from '../../services/federation-instances.service'; + +interface InstanceFormValue { + name: string; + baseUrl: string; + apiKey: string; + tlsSkipVerify: boolean; +} + +@Component({ + selector: 'app-federation-instance-form-modal', + templateUrl: './instance-form-modal.component.html', + styleUrls: ['./instance-form-modal.component.scss'] +}) +export class InstanceFormModalComponent { + @Input() instance: FederationInstance | null = null; + @Output() saved = new EventEmitter(); + submitting = false; + errorMessage: string | null = null; + model: InstanceFormValue = { + name: '', + baseUrl: '', + apiKey: '', + tlsSkipVerify: false + }; + + constructor(public activeModal: NgbActiveModal, + private instancesService: FederationInstancesService) {} + + ngOnInit(): void { + if (this.instance) { + this.model = { + name: this.instance.name, + baseUrl: this.instance.baseUrl, + apiKey: '', + tlsSkipVerify: this.instance.tlsSkipVerify + }; + } + } + + get isEditMode(): boolean { + return this.instance !== null; + } + + get title(): string { + return this.isEditMode ? 'Edit instance' : 'Connect instance'; + } + + get apiKeyHint(): string { + return this.isEditMode + ? 'Leave empty to keep the existing API key.' + : ''; + } + + submit(): void { + if (this.submitting) { + return; + } + this.submitting = true; + this.errorMessage = null; + const payload: FederationInstanceInput = { + name: this.model.name, + baseUrl: this.model.baseUrl, + tlsSkipVerify: this.model.tlsSkipVerify + }; + if (this.model.apiKey) { + payload.apiKey = this.model.apiKey; + } + const action$ = this.isEditMode && this.instance + ? this.instancesService.update(this.instance.id, payload) + : this.instancesService.create(payload); + action$.subscribe({ + next: instance => { + this.submitting = false; + this.saved.emit(instance); + }, + error: err => { + this.submitting = false; + this.errorMessage = (err && err.error && err.error.message) + || 'Failed to save instance.'; + } + }); + } + + cancel(): void { + this.activeModal.dismiss(); + } +} diff --git a/frontend/src/app/federation/components/instance-switcher/instance-switcher.component.html b/frontend/src/app/federation/components/instance-switcher/instance-switcher.component.html new file mode 100644 index 000000000..d7c912aa4 --- /dev/null +++ b/frontend/src/app/federation/components/instance-switcher/instance-switcher.component.html @@ -0,0 +1,60 @@ +
+
+ + + {{ active ? active.name : 'No instance' }} + +
+
+
Instances
+ +
{{ errorMessage }}
+ +
+ No instances connected. +
+ +
+ +
+ + +
+
+ +
+ +
+
diff --git a/frontend/src/app/federation/components/instance-switcher/instance-switcher.component.scss b/frontend/src/app/federation/components/instance-switcher/instance-switcher.component.scss new file mode 100644 index 000000000..05320182c --- /dev/null +++ b/frontend/src/app/federation/components/instance-switcher/instance-switcher.component.scss @@ -0,0 +1,57 @@ +.federation-instance-switcher { + min-width: 120px; + max-width: 220px; + + .federation-instance-name { + max-width: 160px; + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.85rem; + } + + .federation-instance-menu { + min-width: 260px; + } + + .federation-instance-row { + cursor: default; + padding-top: 0.35rem; + padding-bottom: 0.35rem; + + &.active { + background-color: rgba(35, 47, 62, 0.08); + color: inherit; + } + } + + .federation-instance-name-btn { + color: inherit; + text-decoration: none; + cursor: pointer; + + &:hover { + text-decoration: none; + } + } + + .federation-instance-actions { + .btn-link { + color: inherit; + opacity: 0.7; + + &:hover:not(:disabled) { + opacity: 1; + } + + &.text-danger { + color: #dc3545 !important; + } + } + } + + .federation-instance-add { + cursor: pointer; + } +} diff --git a/frontend/src/app/federation/components/instance-switcher/instance-switcher.component.ts b/frontend/src/app/federation/components/instance-switcher/instance-switcher.component.ts new file mode 100644 index 000000000..348a0e7d5 --- /dev/null +++ b/frontend/src/app/federation/components/instance-switcher/instance-switcher.component.ts @@ -0,0 +1,142 @@ +import {Component, OnDestroy, OnInit} from '@angular/core'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {Subject} from 'rxjs'; +import {takeUntil} from 'rxjs/operators'; +import {FederationInstance} from '../../domain/federation-instance.model'; +import {FederationInstanceStateService} from '../../services/federation-instance-state.service'; +import {FederationInstancesService} from '../../services/federation-instances.service'; +import {InstanceFormModalComponent} from '../instance-form-modal/instance-form-modal.component'; + +@Component({ + selector: 'app-federation-instance-switcher', + templateUrl: './instance-switcher.component.html', + styleUrls: ['./instance-switcher.component.scss'] +}) +export class InstanceSwitcherComponent implements OnInit, OnDestroy { + instances: FederationInstance[] = []; + active: FederationInstance | null = null; + pendingDeleteId: number | null = null; + errorMessage: string | null = null; + private destroy$ = new Subject(); + + constructor(private instanceState: FederationInstanceStateService, + private instancesService: FederationInstancesService, + private modalService: NgbModal) {} + + ngOnInit(): void { + this.instanceState.instances$ + .pipe(takeUntil(this.destroy$)) + .subscribe(items => this.instances = items); + this.instanceState.active$ + .pipe(takeUntil(this.destroy$)) + .subscribe(item => this.active = item); + } + + select(instance: FederationInstance, event?: Event): void { + if (event) { + event.stopPropagation(); + } + if (this.active && this.active.id === instance.id) { + return; + } + this.instanceState.setActive(instance); + window.location.reload(); + } + + openCreate(event?: Event): void { + if (event) { + event.stopPropagation(); + } + this.errorMessage = null; + const ref = this.modalService.open(InstanceFormModalComponent, { + centered: true, + backdrop: 'static' + }); + ref.componentInstance.saved.subscribe((instance: FederationInstance) => { + ref.close(); + this.reload(instance.id, true); + }); + } + + openEdit(instance: FederationInstance, event?: Event): void { + if (event) { + event.stopPropagation(); + } + this.errorMessage = null; + const ref = this.modalService.open(InstanceFormModalComponent, { + centered: true, + backdrop: 'static' + }); + ref.componentInstance.instance = instance; + ref.componentInstance.saved.subscribe((updated: FederationInstance) => { + ref.close(); + this.reload(updated.id, false); + }); + } + + remove(instance: FederationInstance, event?: Event): void { + if (event) { + event.stopPropagation(); + } + if (this.pendingDeleteId !== null) { + return; + } + const confirmed = window.confirm(`Remove instance "${instance.name}"?`); + if (!confirmed) { + return; + } + this.pendingDeleteId = instance.id; + this.errorMessage = null; + this.instancesService.delete(instance.id).subscribe({ + next: () => { + this.pendingDeleteId = null; + const wasActive = this.active && this.active.id === instance.id; + this.reload(null, wasActive); + }, + error: err => { + this.pendingDeleteId = null; + this.errorMessage = (err && err.error && err.error.message) + || 'Failed to remove instance.'; + } + }); + } + + trackById(_index: number, item: FederationInstance): number { + return item.id; + } + + private reload(preferredId: number | null, forceSwitch: boolean): void { + this.instancesService.list().subscribe({ + next: items => { + const list = items || []; + const previousActiveId = this.active ? this.active.id : null; + this.instanceState.setInstances(list); + if (list.length === 0) { + window.location.reload(); + return; + } + if (preferredId !== null) { + const target = list.find(i => i.id === preferredId); + if (target) { + this.instanceState.setActive(target, false); + if (forceSwitch || target.id !== previousActiveId) { + window.location.reload(); + } + return; + } + } + if (forceSwitch) { + window.location.reload(); + } + }, + error: () => { + this.errorMessage = 'Failed to refresh instances.'; + } + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/frontend/src/app/federation/domain/federation-instance.model.ts b/frontend/src/app/federation/domain/federation-instance.model.ts new file mode 100644 index 000000000..4f5ab9ef2 --- /dev/null +++ b/frontend/src/app/federation/domain/federation-instance.model.ts @@ -0,0 +1,8 @@ +export interface FederationInstance { + id: number; + name: string; + baseUrl: string; + tlsSkipVerify: boolean; + createdAt: string; + updatedAt: string; +} diff --git a/frontend/src/app/federation/domain/federation-mode.model.ts b/frontend/src/app/federation/domain/federation-mode.model.ts new file mode 100644 index 000000000..317f2050e --- /dev/null +++ b/frontend/src/app/federation/domain/federation-mode.model.ts @@ -0,0 +1,4 @@ +export interface FederationMode { + federation: boolean; + version: string; +} diff --git a/frontend/src/app/federation/federation-routing.module.ts b/frontend/src/app/federation/federation-routing.module.ts new file mode 100644 index 000000000..c24292263 --- /dev/null +++ b/frontend/src/app/federation/federation-routing.module.ts @@ -0,0 +1,20 @@ +import {NgModule} from '@angular/core'; +import {RouterModule, Routes} from '@angular/router'; +import {WelcomeComponent} from './pages/welcome/welcome.component'; + +const routes: Routes = [ + { + path: 'federation', + children: [ + {path: '', redirectTo: 'welcome', pathMatch: 'full'}, + {path: 'welcome', component: WelcomeComponent}, + {path: 'instances', component: WelcomeComponent} + ] + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class FederationRoutingModule {} diff --git a/frontend/src/app/federation/federation.module.ts b/frontend/src/app/federation/federation.module.ts new file mode 100644 index 000000000..7c44de8c9 --- /dev/null +++ b/frontend/src/app/federation/federation.module.ts @@ -0,0 +1,31 @@ +import {CommonModule} from '@angular/common'; +import {NgModule} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; +import {InlineSVGModule} from 'ng-inline-svg'; +import {InstanceFormModalComponent} from './components/instance-form-modal/instance-form-modal.component'; +import {InstanceSwitcherComponent} from './components/instance-switcher/instance-switcher.component'; +import {FederationRoutingModule} from './federation-routing.module'; +import {WelcomeComponent} from './pages/welcome/welcome.component'; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + NgbModule, + InlineSVGModule, + FederationRoutingModule + ], + declarations: [ + InstanceSwitcherComponent, + InstanceFormModalComponent, + WelcomeComponent + ], + exports: [ + InstanceSwitcherComponent + ], + entryComponents: [ + InstanceFormModalComponent + ] +}) +export class FederationModule {} diff --git a/frontend/src/app/federation/hooks/federation-bootstrap.ts b/frontend/src/app/federation/hooks/federation-bootstrap.ts new file mode 100644 index 000000000..ca16c3942 --- /dev/null +++ b/frontend/src/app/federation/hooks/federation-bootstrap.ts @@ -0,0 +1,10 @@ +import {FederationModeService} from '../services/federation-mode.service'; + +export function initFederationMode(modeService: FederationModeService): () => Promise { + return () => new Promise(resolve => { + modeService.detect().subscribe({ + next: () => resolve(), + error: () => resolve() + }); + }); +} diff --git a/frontend/src/app/federation/pages/welcome/welcome.component.html b/frontend/src/app/federation/pages/welcome/welcome.component.html new file mode 100644 index 000000000..f007c5674 --- /dev/null +++ b/frontend/src/app/federation/pages/welcome/welcome.component.html @@ -0,0 +1,16 @@ +
+
+
+ +
+

Welcome to UTMStack Federation

+

+ No instances are connected yet. Connect your first instance to start managing it from this console. +

+ +
+
diff --git a/frontend/src/app/federation/pages/welcome/welcome.component.scss b/frontend/src/app/federation/pages/welcome/welcome.component.scss new file mode 100644 index 000000000..5804c2c02 --- /dev/null +++ b/frontend/src/app/federation/pages/welcome/welcome.component.scss @@ -0,0 +1 @@ +:host { display: block; } diff --git a/frontend/src/app/federation/pages/welcome/welcome.component.ts b/frontend/src/app/federation/pages/welcome/welcome.component.ts new file mode 100644 index 000000000..0758e29d5 --- /dev/null +++ b/frontend/src/app/federation/pages/welcome/welcome.component.ts @@ -0,0 +1,46 @@ +import {Component, OnInit} from '@angular/core'; +import {Router} from '@angular/router'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {InstanceFormModalComponent} from '../../components/instance-form-modal/instance-form-modal.component'; +import {FederationInstance} from '../../domain/federation-instance.model'; +import {FederationInstanceStateService} from '../../services/federation-instance-state.service'; +import {FederationInstancesService} from '../../services/federation-instances.service'; + +@Component({ + selector: 'app-federation-welcome', + templateUrl: './welcome.component.html', + styleUrls: ['./welcome.component.scss'] +}) +export class WelcomeComponent implements OnInit { + + constructor( + private modalService: NgbModal, + private router: Router, + private instanceState: FederationInstanceStateService, + private instancesService: FederationInstancesService + ) {} + + ngOnInit(): void { + this.openCreateModal(); + } + + openCreateModal(): void { + const ref = this.modalService.open(InstanceFormModalComponent, { + centered: true, + backdrop: 'static', + keyboard: false + }); + ref.componentInstance.saved.subscribe((created: FederationInstance) => { + ref.close(); + this.instancesService.list().subscribe(instances => { + const list = instances || []; + this.instanceState.setInstances(list); + const next = list.find(i => i.id === created.id) || list[0]; + if (next) { + this.instanceState.setActive(next, false); + } + this.router.navigate(['/dashboard/overview']); + }); + }); + } +} diff --git a/frontend/src/app/federation/services/federation-instance-state.service.ts b/frontend/src/app/federation/services/federation-instance-state.service.ts new file mode 100644 index 000000000..26aab4455 --- /dev/null +++ b/frontend/src/app/federation/services/federation-instance-state.service.ts @@ -0,0 +1,85 @@ +import {Injectable} from '@angular/core'; +import {BehaviorSubject, Observable} from 'rxjs'; +import {FederationInstance} from '../domain/federation-instance.model'; + +const STORAGE_KEY = 'utm.federation.activeInstanceId'; + +@Injectable({providedIn: 'root'}) +export class FederationInstanceStateService { + private instancesSubject = new BehaviorSubject([]); + private activeSubject = new BehaviorSubject(null); + private switchSubject = new BehaviorSubject(0); + + readonly instances$: Observable = this.instancesSubject.asObservable(); + readonly active$: Observable = this.activeSubject.asObservable(); + readonly switch$: Observable = this.switchSubject.asObservable(); + + setInstances(instances: FederationInstance[]): void { + this.instancesSubject.next(instances); + if (instances.length === 0) { + this.activeSubject.next(null); + return; + } + const persistedId = this.readPersistedId(); + const fromStorage = persistedId !== null ? instances.find(i => i.id === persistedId) : undefined; + const current = this.activeSubject.getValue(); + const stillThere = current ? instances.find(i => i.id === current.id) : undefined; + const next = stillThere || fromStorage || instances[0]; + this.setActive(next, false); + } + + setActive(instance: FederationInstance, broadcastSwitch: boolean = true): void { + const current = this.activeSubject.getValue(); + if (current && current.id === instance.id) { + return; + } + this.activeSubject.next(instance); + this.persistId(instance.id); + if (broadcastSwitch) { + this.switchSubject.next(this.switchSubject.getValue() + 1); + } + } + + clear(): void { + this.instancesSubject.next([]); + this.activeSubject.next(null); + this.clearPersistedId(); + } + + get current(): FederationInstance | null { + return this.activeSubject.getValue(); + } + + get instances(): FederationInstance[] { + return this.instancesSubject.getValue(); + } + + private readPersistedId(): number | null { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) { + return null; + } + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : null; + } catch { + return null; + } + } + + private persistId(id: number): void { + try { + window.localStorage.setItem(STORAGE_KEY, String(id)); + } catch { + // ignore + } + } + + private clearPersistedId(): void { + try { + window.localStorage.removeItem(STORAGE_KEY); + } catch { + // ignore + } + } +} diff --git a/frontend/src/app/federation/services/federation-instances.service.ts b/frontend/src/app/federation/services/federation-instances.service.ts new file mode 100644 index 000000000..4507ce028 --- /dev/null +++ b/frontend/src/app/federation/services/federation-instances.service.ts @@ -0,0 +1,39 @@ +import {HttpClient} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {Observable} from 'rxjs'; +import {SERVER_API_URL} from '../../app.constants'; +import {FederationInstance} from '../domain/federation-instance.model'; + +export interface FederationInstanceInput { + name: string; + baseUrl: string; + apiKey?: string; + tlsSkipVerify: boolean; +} + +@Injectable({providedIn: 'root'}) +export class FederationInstancesService { + private readonly endpoint = SERVER_API_URL + 'api/v1/instances'; + + constructor(private http: HttpClient) {} + + list(): Observable { + return this.http.get(this.endpoint); + } + + get(id: number): Observable { + return this.http.get(`${this.endpoint}/${id}`); + } + + create(payload: FederationInstanceInput): Observable { + return this.http.post(this.endpoint, payload); + } + + update(id: number, payload: FederationInstanceInput): Observable { + return this.http.put(`${this.endpoint}/${id}`, payload); + } + + delete(id: number): Observable { + return this.http.delete(`${this.endpoint}/${id}`); + } +} diff --git a/frontend/src/app/federation/services/federation-mode.service.ts b/frontend/src/app/federation/services/federation-mode.service.ts new file mode 100644 index 000000000..117e4f508 --- /dev/null +++ b/frontend/src/app/federation/services/federation-mode.service.ts @@ -0,0 +1,38 @@ +import {HttpClient} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {BehaviorSubject, Observable, of} from 'rxjs'; +import {catchError, map, shareReplay, tap} from 'rxjs/operators'; +import {SERVER_API_URL} from '../../app.constants'; +import {FederationMode} from '../domain/federation-mode.model'; + +@Injectable({providedIn: 'root'}) +export class FederationModeService { + private readonly endpoint = SERVER_API_URL + 'api/v1/mode'; + private modeSubject = new BehaviorSubject(null); + private detect$: Observable | null = null; + + readonly mode$ = this.modeSubject.asObservable(); + readonly active$ = this.modeSubject.pipe(map(m => !!m && m.federation === true)); + + constructor(private http: HttpClient) {} + + detect(): Observable { + if (!this.detect$) { + this.detect$ = this.http.get(this.endpoint).pipe( + catchError(() => of({federation: false, version: ''})), + tap(mode => this.modeSubject.next(mode)), + shareReplay(1) + ); + } + return this.detect$; + } + + get current(): FederationMode | null { + return this.modeSubject.getValue(); + } + + get isActive(): boolean { + const current = this.modeSubject.getValue(); + return !!current && current.federation === true; + } +} diff --git a/frontend/src/app/shared/components/layout/header/header.component.html b/frontend/src/app/shared/components/layout/header/header.component.html index 7688cc08f..be292f6ce 100644 --- a/frontend/src/app/shared/components/layout/header/header.component.html +++ b/frontend/src/app/shared/components/layout/header/header.component.html @@ -24,12 +24,18 @@ -
  • +
  • -
  • - + +
  • + +
  • + + +
  • +
  • diff --git a/frontend/src/app/shared/components/layout/header/header.component.ts b/frontend/src/app/shared/components/layout/header/header.component.ts index b6ae1c83d..8c0c95074 100644 --- a/frontend/src/app/shared/components/layout/header/header.component.ts +++ b/frontend/src/app/shared/components/layout/header/header.component.ts @@ -1,9 +1,10 @@ import {Component, OnDestroy, OnInit} from '@angular/core'; import {DomSanitizer} from '@angular/platform-browser'; -import {Subject} from 'rxjs'; +import {Observable, Subject} from 'rxjs'; import {filter, takeUntil} from 'rxjs/operators'; import {AccountService} from '../../../../core/auth/account.service'; import {User} from '../../../../core/user/user.model'; +import {FederationModeService} from '../../../../federation/services/federation-mode.service'; import {ThemeChangeBehavior} from '../../../behaviors/theme-change.behavior'; import {ADMIN_ROLE} from '../../../constants/global.constant'; import {AppThemeLocationEnum} from '../../../enums/app-theme-location.enum'; @@ -23,12 +24,15 @@ export class HeaderComponent implements OnInit, OnDestroy { logoImage: string; altImage: string; versionInfo: AppVersionInfo; + federationActive$: Observable; destroy$: Subject = new Subject(); constructor(private accountService: AccountService, public sanitizer: DomSanitizer, private themeChangeBehavior: ThemeChangeBehavior, - private versionTypeService: VersionInfoService) { + private versionTypeService: VersionInfoService, + private federationModeService: FederationModeService) { + this.federationActive$ = this.federationModeService.active$; } ngOnInit() { diff --git a/frontend/src/app/shared/components/layout/header/mobile-header/mobile-header.component.html b/frontend/src/app/shared/components/layout/header/mobile-header/mobile-header.component.html index 9ab49ebb5..efc45d5e4 100644 --- a/frontend/src/app/shared/components/layout/header/mobile-header/mobile-header.component.html +++ b/frontend/src/app/shared/components/layout/header/mobile-header/mobile-header.component.html @@ -20,11 +20,16 @@
    S
    -
  • +
  • -
  • - + +
  • + +
  • + +
  • +
  • diff --git a/frontend/src/app/shared/components/layout/header/mobile-header/mobile-header.component.ts b/frontend/src/app/shared/components/layout/header/mobile-header/mobile-header.component.ts index 0d9bba9b9..99dcc01a4 100644 --- a/frontend/src/app/shared/components/layout/header/mobile-header/mobile-header.component.ts +++ b/frontend/src/app/shared/components/layout/header/mobile-header/mobile-header.component.ts @@ -1,7 +1,9 @@ import {Component, OnInit} from '@angular/core'; +import {Observable} from 'rxjs'; import {AccountService} from '../../../../../core/auth/account.service'; import {LoginService} from '../../../../../core/login/login.service'; import {User} from '../../../../../core/user/user.model'; +import {FederationModeService} from '../../../../../federation/services/federation-mode.service'; import {ADMIN_ROLE} from '../../../../constants/global.constant'; import {ActiveAdModuleActiveService} from '../../../../services/active-modules/active-ad-module.service'; @@ -16,11 +18,14 @@ export class MobileHeaderComponent implements OnInit { menuActive = false; isAdActive: boolean; roleAdmin = ADMIN_ROLE; + federationActive$: Observable; constructor(private loginService: LoginService, private accountService: AccountService, - private adModuleActiveService: ActiveAdModuleActiveService) { + private adModuleActiveService: ActiveAdModuleActiveService, + private federationModeService: FederationModeService) { + this.federationActive$ = this.federationModeService.active$; } ngOnInit() { diff --git a/frontend/src/app/shared/utm-shared.module.ts b/frontend/src/app/shared/utm-shared.module.ts index d24177924..457e4de95 100644 --- a/frontend/src/app/shared/utm-shared.module.ts +++ b/frontend/src/app/shared/utm-shared.module.ts @@ -18,6 +18,7 @@ import {AssetsGroupAddModule} from '../assets-discover/shared/components/asset-g import {AssetsApplyNoteModule} from '../assets-discover/shared/components/assets-apply-note/assets-apply-note.module'; import {AssetsApplyTypeModule} from '../assets-discover/shared/components/assets-apply-type/assets-apply-type.module'; import {AuthServerProvider} from '../core/auth/auth-jwt.service'; +import {FederationModule} from '../federation/federation.module'; import {AlertEchoesTimelineComponent} from '../data-management/alert-management/shared/components/alert-echoes-timeline/alert-echoes-timeline.component'; import {UtmToastService} from './alert/utm-toast.service'; import {DashboardBehavior} from './behaviors/dashboard.behavior'; @@ -269,7 +270,8 @@ import {ScheduleConfigComponent} from './components/schedule-config/schedule-con InfiniteScrollModule, NgxEchartsModule, ResizableModule, - MonacoEditorModule.forRoot() + MonacoEditorModule.forRoot(), + FederationModule ], declarations: [ ElasticFilterComponent, diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts index e69de29bb..976a14684 100644 --- a/frontend/src/environments/environment.ts +++ b/frontend/src/environments/environment.ts @@ -0,0 +1,10 @@ +export const environment = { + production: false, + SERVER_API_URL: `http://localhost:8080`, + WEBSOCKET_URL: ``, + SESSION_AUTH_TOKEN: window.location.host.split(':')[0].toLocaleUpperCase(), + SERVER_API_CONTEXT: '/', + BUILD_TIMESTAMP: new Date().getTime(), + DEBUG_INFO_ENABLED: true, + VERSION: '0.0.1' +}; From 085daf995342ee4e8a8d3424c998cfc8f78c016c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Thu, 18 Jun 2026 07:45:25 -0600 Subject: [PATCH 02/36] fix[frontend](federation_service): added instance selector and actions --- .../app/core/auth/user-route-access-service.ts | 16 +++++++++++++++- .../instance-switcher.component.html | 1 + .../instance-switcher.component.scss | 5 ++++- .../app/federation/federation-routing.module.ts | 15 +++++++++++++-- 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/core/auth/user-route-access-service.ts b/frontend/src/app/core/auth/user-route-access-service.ts index 90fc38195..cb5116578 100644 --- a/frontend/src/app/core/auth/user-route-access-service.ts +++ b/frontend/src/app/core/auth/user-route-access-service.ts @@ -2,15 +2,21 @@ import {Injectable, isDevMode} from '@angular/core'; import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot} from '@angular/router'; import {ModalService} from '../modal/modal.service'; +import {FederationInstanceStateService} from '../../federation/services/federation-instance-state.service'; +import {FederationModeService} from '../../federation/services/federation-mode.service'; import {AccountService} from './account.service'; import {StateStorageService} from './state-storage.service'; +const FEDERATION_WELCOME_URL = '/federation/welcome'; + @Injectable({providedIn: 'root'}) export class UserRouteAccessService implements CanActivate { constructor( private router: Router, private accountService: AccountService, - private stateStorageService: StateStorageService + private stateStorageService: StateStorageService, + private federationModeService: FederationModeService, + private federationInstanceState: FederationInstanceStateService ) { } @@ -28,6 +34,14 @@ export class UserRouteAccessService implements CanActivate { return false; } if (account) { + if ( + this.federationModeService.isActive && + this.federationInstanceState.instances.length === 0 && + !url.startsWith(FEDERATION_WELCOME_URL) + ) { + this.router.navigate([FEDERATION_WELCOME_URL]); + return false; + } const hasAnyAuthority = this.accountService.hasAnyAuthority(authorities); if (hasAnyAuthority) { return true; diff --git a/frontend/src/app/federation/components/instance-switcher/instance-switcher.component.html b/frontend/src/app/federation/components/instance-switcher/instance-switcher.component.html index d7c912aa4..cc4de5c9f 100644 --- a/frontend/src/app/federation/components/instance-switcher/instance-switcher.component.html +++ b/frontend/src/app/federation/components/instance-switcher/instance-switcher.component.html @@ -27,6 +27,7 @@
    Instances
    class="federation-instance-row dropdown-item d-flex align-items-center justify-content-between" [class.active]="active && instance.id === active.id"> + + +
    +
    +
    + +
    + +
    + +
    {{ errorMessage }}
    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameLoginEmailStatus2FAActions
    Loading...
    No team members found.
    {{ displayName(user) }}{{ user.login }}{{ user.email || '—' }} + Pending + Active + Inactive + + On + — + + + + + +
    +
    + +
    + + Page {{ pageInfo.page }} of {{ pageInfo.total_pages }} + ({{ pageInfo.total_items }} total) + +
    + + +
    +
    +
    + +
    + +
    diff --git a/frontend/src/app/federation/components/team-management-modal/team-management-modal.component.scss b/frontend/src/app/federation/components/team-management-modal/team-management-modal.component.scss new file mode 100644 index 000000000..67d74f00c --- /dev/null +++ b/frontend/src/app/federation/components/team-management-modal/team-management-modal.component.scss @@ -0,0 +1,30 @@ +.federation-team-modal { + .federation-team-toolbar { + gap: 1rem; + } + + .federation-team-table { + th { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.03em; + color: #6c757d; + } + + td { + vertical-align: middle; + } + + .federation-team-actions { + white-space: nowrap; + + .btn { + margin-left: 0.15rem; + } + } + } + + .badge { + font-weight: 500; + } +} diff --git a/frontend/src/app/federation/components/team-management-modal/team-management-modal.component.ts b/frontend/src/app/federation/components/team-management-modal/team-management-modal.component.ts new file mode 100644 index 000000000..2c84e8c76 --- /dev/null +++ b/frontend/src/app/federation/components/team-management-modal/team-management-modal.component.ts @@ -0,0 +1,249 @@ +import {Component, OnDestroy, OnInit} from '@angular/core'; +import {NgbActiveModal, NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {Subject} from 'rxjs'; +import {debounceTime, distinctUntilChanged, takeUntil} from 'rxjs/operators'; +import {UtmToastService} from '../../../shared/alert/utm-toast.service'; +import { + ModalConfirmationComponent +} from '../../../shared/components/utm/util/modal-confirmation/modal-confirmation.component'; +import {TeamUser, TeamUserPageInfo} from '../../domain/team-user.model'; +import {FederationTeamService} from '../../services/federation-team.service'; +import {TeamUserFormModalComponent} from '../team-user-form-modal/team-user-form-modal.component'; + +const DEFAULT_PAGE_SIZE = 20; + +@Component({ + selector: 'app-federation-team-management-modal', + templateUrl: './team-management-modal.component.html', + styleUrls: ['./team-management-modal.component.scss'] +}) +export class TeamManagementModalComponent implements OnInit, OnDestroy { + users: TeamUser[] = []; + pageInfo: TeamUserPageInfo | null = null; + loading = false; + errorMessage: string | null = null; + searchTerm = ''; + pendingActionId: number | null = null; + + private currentPage = 1; + private readonly pageSize = DEFAULT_PAGE_SIZE; + private searchSubject = new Subject(); + private destroy$ = new Subject(); + + constructor(public activeModal: NgbActiveModal, + private teamService: FederationTeamService, + private modalService: NgbModal, + private toast: UtmToastService) {} + + ngOnInit(): void { + this.searchSubject + .pipe(debounceTime(300), distinctUntilChanged(), takeUntil(this.destroy$)) + .subscribe(() => { + this.currentPage = 1; + this.load(); + }); + this.load(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + onSearchChange(value: string): void { + this.searchTerm = value; + this.searchSubject.next(value); + } + + prevPage(): void { + if (!this.pageInfo || !this.pageInfo.has_prev || this.loading) { + return; + } + this.currentPage = this.pageInfo.page - 1; + this.load(); + } + + nextPage(): void { + if (!this.pageInfo || !this.pageInfo.has_next || this.loading) { + return; + } + this.currentPage = this.pageInfo.page + 1; + this.load(); + } + + openInvite(): void { + const ref = this.modalService.open(TeamUserFormModalComponent, { + centered: true, + backdrop: 'static' + }); + ref.componentInstance.saved.subscribe(() => { + ref.close(); + this.toast.showSuccessBottom('Invitation sent.'); + this.currentPage = 1; + this.load(); + }); + } + + openEdit(user: TeamUser): void { + const ref = this.modalService.open(TeamUserFormModalComponent, { + centered: true, + backdrop: 'static' + }); + ref.componentInstance.user = user; + ref.componentInstance.saved.subscribe(() => { + ref.close(); + this.toast.showSuccessBottom('Team member updated.'); + this.load(); + }); + } + + resendInvite(user: TeamUser): void { + if (this.pendingActionId !== null) { + return; + } + this.pendingActionId = user.id; + this.teamService.resendInvite(user.id).subscribe({ + next: () => { + this.pendingActionId = null; + this.toast.showSuccessBottom('Invitation re-sent.'); + }, + error: err => { + this.pendingActionId = null; + this.toast.showError('Resend invite failed', this.extractError(err)); + } + }); + } + + disableTfa(user: TeamUser): void { + if (this.pendingActionId !== null) { + return; + } + const ref = this.modalService.open(ModalConfirmationComponent, { + backdrop: 'static', + centered: true + }); + ref.componentInstance.header = 'Disable 2FA'; + ref.componentInstance.message = `Disable 2FA for ${this.displayName(user)}?`; + ref.componentInstance.confirmBtnText = 'Disable'; + ref.componentInstance.confirmBtnIcon = 'icon-shield-cross'; + ref.componentInstance.confirmBtnType = 'delete'; + ref.componentInstance.textDisplay = + 'The user will be able to sign in without a 2FA code until they re-enroll.'; + ref.componentInstance.textType = 'warning'; + ref.result.then(() => this.runDisableTfa(user), () => undefined); + } + + toggleActivation(user: TeamUser): void { + if (this.pendingActionId !== null) { + return; + } + if (user.activated) { + this.confirmAndDeactivate(user); + } else { + this.activate(user); + } + } + + trackById(_index: number, item: TeamUser): number { + return item.id; + } + + displayName(user: TeamUser): string { + const composed = [user.first_name, user.last_name].filter(part => !!part).join(' ').trim(); + return composed || user.login; + } + + private confirmAndDeactivate(user: TeamUser): void { + const ref = this.modalService.open(ModalConfirmationComponent, { + backdrop: 'static', + centered: true + }); + ref.componentInstance.header = 'Deactivate team member'; + ref.componentInstance.message = `Deactivate ${this.displayName(user)}?`; + ref.componentInstance.confirmBtnText = 'Deactivate'; + ref.componentInstance.confirmBtnIcon = 'icon-cancel-circle2'; + ref.componentInstance.confirmBtnType = 'delete'; + ref.componentInstance.textDisplay = 'The user will lose access to this federation server.'; + ref.componentInstance.textType = 'warning'; + ref.result.then(() => this.runDeactivate(user), () => undefined); + } + + private runDeactivate(user: TeamUser): void { + this.pendingActionId = user.id; + this.teamService.deactivate(user.id).subscribe({ + next: () => { + this.pendingActionId = null; + this.toast.showSuccessBottom('Team member deactivated.'); + this.load(); + }, + error: err => { + this.pendingActionId = null; + this.toast.showError('Deactivation failed', this.extractError(err)); + } + }); + } + + private activate(user: TeamUser): void { + this.pendingActionId = user.id; + this.teamService.update(user.id, { + email: user.email || '', + first_name: user.first_name || '', + last_name: user.last_name || '', + activated: true + }).subscribe({ + next: () => { + this.pendingActionId = null; + this.toast.showSuccessBottom('Team member activated.'); + this.load(); + }, + error: err => { + this.pendingActionId = null; + this.toast.showError('Activation failed', this.extractError(err)); + } + }); + } + + private runDisableTfa(user: TeamUser): void { + this.pendingActionId = user.id; + this.teamService.disableTfa(user.id).subscribe({ + next: () => { + this.pendingActionId = null; + this.toast.showSuccessBottom('2FA disabled.'); + this.load(); + }, + error: err => { + this.pendingActionId = null; + this.toast.showError('Disable 2FA failed', this.extractError(err)); + } + }); + } + + private load(): void { + this.loading = true; + this.errorMessage = null; + this.teamService.list({ + page: this.currentPage, + page_size: this.pageSize, + search: this.searchTerm ? this.searchTerm.trim() : undefined + }).subscribe({ + next: response => { + this.loading = false; + this.users = response.data || []; + this.pageInfo = response.page_info; + }, + error: err => { + this.loading = false; + this.users = []; + this.pageInfo = null; + this.errorMessage = this.extractError(err); + } + }); + } + + private extractError(err: {error?: {message?: string}}): string { + if (err && err.error && err.error.message) { + return err.error.message; + } + return 'Operation failed. Please try again.'; + } +} diff --git a/frontend/src/app/federation/components/team-user-form-modal/team-user-form-modal.component.html b/frontend/src/app/federation/components/team-user-form-modal/team-user-form-modal.component.html new file mode 100644 index 000000000..df7dd0163 --- /dev/null +++ b/frontend/src/app/federation/components/team-user-form-modal/team-user-form-modal.component.html @@ -0,0 +1,52 @@ +
    +
    {{ title }}
    + +
    +
    +
    +
    {{ errorMessage }}
    + +
    + + + + Unique identifier used to sign in. + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +

    + An invitation email with a link to set a password will be sent. +

    +
    +
    + + +
    +
    diff --git a/frontend/src/app/federation/components/team-user-form-modal/team-user-form-modal.component.scss b/frontend/src/app/federation/components/team-user-form-modal/team-user-form-modal.component.scss new file mode 100644 index 000000000..bf7567209 --- /dev/null +++ b/frontend/src/app/federation/components/team-user-form-modal/team-user-form-modal.component.scss @@ -0,0 +1,5 @@ +.modal-body { + .alert { + margin-bottom: 1rem; + } +} diff --git a/frontend/src/app/federation/components/team-user-form-modal/team-user-form-modal.component.ts b/frontend/src/app/federation/components/team-user-form-modal/team-user-form-modal.component.ts new file mode 100644 index 000000000..98208391b --- /dev/null +++ b/frontend/src/app/federation/components/team-user-form-modal/team-user-form-modal.component.ts @@ -0,0 +1,111 @@ +import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core'; +import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; +import { + TeamUser, + TeamUserCreatePayload, + TeamUserUpdatePayload +} from '../../domain/team-user.model'; +import {FederationTeamService} from '../../services/federation-team.service'; + +interface TeamUserFormValue { + login: string; + email: string; + first_name: string; + last_name: string; +} + +@Component({ + selector: 'app-federation-team-user-form-modal', + templateUrl: './team-user-form-modal.component.html', + styleUrls: ['./team-user-form-modal.component.scss'] +}) +export class TeamUserFormModalComponent implements OnInit { + @Input() user: TeamUser | null = null; + @Output() saved = new EventEmitter(); + + submitting = false; + errorMessage: string | null = null; + model: TeamUserFormValue = { + login: '', + email: '', + first_name: '', + last_name: '' + }; + + constructor(public activeModal: NgbActiveModal, + private teamService: FederationTeamService) {} + + ngOnInit(): void { + if (this.user) { + this.model = { + login: this.user.login, + email: this.user.email || '', + first_name: this.user.first_name || '', + last_name: this.user.last_name || '' + }; + } + } + + get isEditMode(): boolean { + return this.user !== null; + } + + get title(): string { + return this.isEditMode ? 'Edit team member' : 'Invite team member'; + } + + submit(): void { + if (this.submitting) { + return; + } + this.submitting = true; + this.errorMessage = null; + + const action$ = this.isEditMode && this.user + ? this.teamService.update(this.user.id, this.buildUpdatePayload()) + : this.teamService.create(this.buildCreatePayload()); + + action$.subscribe({ + next: result => { + this.submitting = false; + this.saved.emit(result); + }, + error: err => { + this.submitting = false; + this.errorMessage = this.extractError(err); + } + }); + } + + cancel(): void { + this.activeModal.dismiss(); + } + + private buildCreatePayload(): TeamUserCreatePayload { + return { + login: this.model.login.trim(), + email: this.model.email.trim(), + first_name: this.model.first_name.trim(), + last_name: this.model.last_name.trim(), + lang_key: 'en' + }; + } + + private buildUpdatePayload(): TeamUserUpdatePayload { + return { + email: this.model.email.trim(), + first_name: this.model.first_name.trim(), + last_name: this.model.last_name.trim() + }; + } + + private extractError(err: {status?: number; error?: {message?: string}}): string { + if (err && err.status === 409) { + return 'Login or email is already in use.'; + } + if (err && err.error && err.error.message) { + return err.error.message; + } + return 'Failed to save team member.'; + } +} diff --git a/frontend/src/app/federation/domain/team-user.model.ts b/frontend/src/app/federation/domain/team-user.model.ts new file mode 100644 index 000000000..c43e1950e --- /dev/null +++ b/frontend/src/app/federation/domain/team-user.model.ts @@ -0,0 +1,48 @@ +export interface TeamUser { + id: number; + login: string; + email?: string; + first_name?: string; + last_name?: string; + image_url?: string; + activated: boolean; + pending: boolean; + tfa_enabled: boolean; + created_by?: string; + created_at: string; +} + +export interface TeamUserPageInfo { + page: number; + page_size: number; + total_items: number; + total_pages: number; + has_next: boolean; + has_prev: boolean; +} + +export interface TeamUserListResponse { + data: TeamUser[]; + page_info: TeamUserPageInfo; +} + +export interface TeamUserCreatePayload { + login: string; + email: string; + first_name: string; + last_name: string; + lang_key: string; +} + +export interface TeamUserUpdatePayload { + email: string; + first_name: string; + last_name: string; + activated?: boolean; +} + +export interface TeamUserListQuery { + page?: number; + page_size?: number; + search?: string; +} diff --git a/frontend/src/app/federation/federation.module.ts b/frontend/src/app/federation/federation.module.ts index c3132ccb0..630db40a9 100644 --- a/frontend/src/app/federation/federation.module.ts +++ b/frontend/src/app/federation/federation.module.ts @@ -5,6 +5,8 @@ import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; import {InlineSVGModule} from 'ng-inline-svg'; import {InstanceFormModalComponent} from './components/instance-form-modal/instance-form-modal.component'; import {InstanceSwitcherComponent} from './components/instance-switcher/instance-switcher.component'; +import {TeamManagementModalComponent} from './components/team-management-modal/team-management-modal.component'; +import {TeamUserFormModalComponent} from './components/team-user-form-modal/team-user-form-modal.component'; import {WelcomeComponent} from './pages/welcome/welcome.component'; @NgModule({ @@ -17,13 +19,18 @@ import {WelcomeComponent} from './pages/welcome/welcome.component'; declarations: [ InstanceSwitcherComponent, InstanceFormModalComponent, + TeamManagementModalComponent, + TeamUserFormModalComponent, WelcomeComponent ], exports: [ - InstanceSwitcherComponent + InstanceSwitcherComponent, + TeamManagementModalComponent ], entryComponents: [ - InstanceFormModalComponent + InstanceFormModalComponent, + TeamManagementModalComponent, + TeamUserFormModalComponent ] }) export class FederationModule {} diff --git a/frontend/src/app/federation/services/federation-team.service.ts b/frontend/src/app/federation/services/federation-team.service.ts new file mode 100644 index 000000000..ef7fc4c4f --- /dev/null +++ b/frontend/src/app/federation/services/federation-team.service.ts @@ -0,0 +1,56 @@ +import {HttpClient, HttpParams} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {Observable} from 'rxjs'; +import {SERVER_API_URL} from '../../app.constants'; +import { + TeamUser, + TeamUserCreatePayload, + TeamUserListQuery, + TeamUserListResponse, + TeamUserUpdatePayload +} from '../domain/team-user.model'; + +@Injectable({providedIn: 'root'}) +export class FederationTeamService { + private readonly endpoint = SERVER_API_URL + 'api/v1/users'; + + constructor(private http: HttpClient) {} + + list(query: TeamUserListQuery = {}): Observable { + let params = new HttpParams(); + if (query.page !== undefined) { + params = params.set('page', String(query.page)); + } + if (query.page_size !== undefined) { + params = params.set('page_size', String(query.page_size)); + } + if (query.search) { + params = params.set('search', query.search); + } + return this.http.get(this.endpoint, {params}); + } + + get(id: number): Observable { + return this.http.get(`${this.endpoint}/${id}`); + } + + create(payload: TeamUserCreatePayload): Observable { + return this.http.post(this.endpoint, payload); + } + + update(id: number, payload: TeamUserUpdatePayload): Observable { + return this.http.put(`${this.endpoint}/${id}`, payload); + } + + deactivate(id: number): Observable { + return this.http.delete(`${this.endpoint}/${id}`); + } + + resendInvite(id: number): Observable { + return this.http.post(`${this.endpoint}/${id}/resend-invite`, {}); + } + + disableTfa(id: number): Observable { + return this.http.post(`${this.endpoint}/${id}/tfa/disable`, {}); + } +} diff --git a/frontend/src/app/shared/components/layout/header/header-menu-navigation/header-menu-navigation.component.ts b/frontend/src/app/shared/components/layout/header/header-menu-navigation/header-menu-navigation.component.ts index e428fb9ec..3099379c5 100644 --- a/frontend/src/app/shared/components/layout/header/header-menu-navigation/header-menu-navigation.component.ts +++ b/frontend/src/app/shared/components/layout/header/header-menu-navigation/header-menu-navigation.component.ts @@ -42,7 +42,6 @@ export class HeaderMenuNavigationComponent implements OnInit, OnDestroy { this.navBehavior.$nav .pipe(takeUntil(this.destroy$)) .subscribe( value => { -console.log(value) if (value) { this.loadMenus(); } diff --git a/frontend/src/app/shared/components/layout/header/shared/notification/utm-notification-user-setting/utm-notification-user-setting.component.html b/frontend/src/app/shared/components/layout/header/shared/notification/utm-notification-user-setting/utm-notification-user-setting.component.html index a27b1c6d9..ecc2fbfa4 100644 --- a/frontend/src/app/shared/components/layout/header/shared/notification/utm-notification-user-setting/utm-notification-user-setting.component.html +++ b/frontend/src/app/shared/components/layout/header/shared/notification/utm-notification-user-setting/utm-notification-user-setting.component.html @@ -10,6 +10,12 @@ inlineSVG="assets/icons/header/user-icon.svg">
    + + + Team management + Profile Settings diff --git a/frontend/src/app/shared/components/layout/header/shared/notification/utm-notification-user-setting/utm-notification-user-setting.component.ts b/frontend/src/app/shared/components/layout/header/shared/notification/utm-notification-user-setting/utm-notification-user-setting.component.ts index 30a4a56d5..33a56d883 100644 --- a/frontend/src/app/shared/components/layout/header/shared/notification/utm-notification-user-setting/utm-notification-user-setting.component.ts +++ b/frontend/src/app/shared/components/layout/header/shared/notification/utm-notification-user-setting/utm-notification-user-setting.component.ts @@ -1,7 +1,12 @@ import {Component, OnInit} from '@angular/core'; import {Router} from '@angular/router'; import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {Observable} from 'rxjs'; import {LoginService} from '../../../../../../../core/login/login.service'; +import { + TeamManagementModalComponent +} from '../../../../../../../federation/components/team-management-modal/team-management-modal.component'; +import {FederationModeService} from '../../../../../../../federation/services/federation-mode.service'; import {PasswordComponent} from './password/password.component'; import {SettingsComponent} from './settings/settings.component'; @@ -11,10 +16,13 @@ import {SettingsComponent} from './settings/settings.component'; styleUrls: ['./utm-notification-user-setting.component.css'] }) export class UtmNotificationUserSettingComponent implements OnInit { + federationActive$: Observable; constructor(public router: Router, private modalService: NgbModal, - private loginService: LoginService) { + private loginService: LoginService, + private federationModeService: FederationModeService) { + this.federationActive$ = this.federationModeService.active$; } ngOnInit() { @@ -32,4 +40,12 @@ export class UtmNotificationUserSettingComponent implements OnInit { changePassword() { this.modalService.open(PasswordComponent, {centered: true}); } + + openTeamManagement() { + this.modalService.open(TeamManagementModalComponent, { + centered: true, + size: 'lg', + backdrop: 'static' + }); + } } From f9606880e1aa757398f8e1cef616cdf50f83ba68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Mon, 22 Jun 2026 12:41:16 -0600 Subject: [PATCH 06/36] fix[federation](white_label): reload images after federation instance reload --- frontend/src/app/app.component.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index 10dca801f..488113a6f 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -1,9 +1,10 @@ import {Component, HostListener, OnInit, Renderer2} from '@angular/core'; import {Router} from '@angular/router'; import {TranslateService} from '@ngx-translate/core'; -import {catchError, delay, distinctUntilChanged, filter, map, takeUntil, tap} from 'rxjs/operators'; +import {catchError, delay, distinctUntilChanged, filter, map, skip, takeUntil, tap} from 'rxjs/operators'; import {AccountService} from './core/auth/account.service'; import {ApiServiceCheckerService} from './core/auth/api-checker-service'; +import {FederationInstanceStateService} from './federation/services/federation-instance-state.service'; import {MenuBehavior} from './shared/behaviors/menu.behavior'; import {ThemeChangeBehavior} from './shared/behaviors/theme-change.behavior'; import {ADMIN_ROLE, USER_ROLE} from './shared/constants/global.constant'; @@ -42,7 +43,8 @@ export class AppComponent implements OnInit { private apiServiceCheckerService: ApiServiceCheckerService, private accountService: AccountService, private checkForUpdatesService: AppVersionService, - private versionTypeService: VersionInfoService) { + private versionTypeService: VersionInfoService, + private federationInstanceState: FederationInstanceStateService) { this.translate.setDefaultLang('en'); @@ -82,6 +84,10 @@ export class AppComponent implements OnInit { } }); + this.federationInstanceState.switch$ + .pipe(skip(1)) + .subscribe(() => this.getReportLogo()); + this.accountService.getAuthenticationState() .pipe(distinctUntilChanged((prev, next) => prev && next && prev.id === next.id)) .subscribe(identity => { From b29af62aef639c45c9795a4a9577f3848dbe4bb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Mon, 22 Jun 2026 15:20:24 -0600 Subject: [PATCH 07/36] feat[instances](overview): added instances stadistics overview --- .../federation-overview-grid.component.html | 27 +++++ .../federation-overview-grid.component.scss | 49 ++++++++ .../federation-overview-grid.component.ts | 70 +++++++++++ .../instance-overview-card.component.html | 17 +++ .../instance-overview-card.component.scss | 88 ++++++++++++++ .../instance-overview-card.component.ts | 113 ++++++++++++++++++ .../domain/count-alerts-by-status.model.ts | 12 ++ .../src/app/federation/federation.module.ts | 13 +- .../pages/welcome/welcome.component.html | 15 ++- .../pages/welcome/welcome.component.ts | 8 +- .../services/federation-overview.service.ts | 17 +++ 11 files changed, 412 insertions(+), 17 deletions(-) create mode 100644 frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.html create mode 100644 frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.scss create mode 100644 frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.ts create mode 100644 frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.html create mode 100644 frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.scss create mode 100644 frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.ts create mode 100644 frontend/src/app/federation/domain/count-alerts-by-status.model.ts create mode 100644 frontend/src/app/federation/services/federation-overview.service.ts diff --git a/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.html b/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.html new file mode 100644 index 000000000..c912092c7 --- /dev/null +++ b/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.html @@ -0,0 +1,27 @@ +
    +
    +
    +
    Loading federation overview…
    +
    + +
    + +
    {{ errorMessage }}
    + +
    + +
    + +
    No active instances are reporting data.
    +
    + +
    + + +
    +
    diff --git a/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.scss b/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.scss new file mode 100644 index 000000000..f1c175dd4 --- /dev/null +++ b/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.scss @@ -0,0 +1,49 @@ +:host { display: block; width: 100%; } + +.overview-grid { + width: 100%; + padding: 16px; + + &__items { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 16px; + } + + &__state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + min-height: 280px; + color: #6b7280; + } + + &__state-label { + font-size: 14px; + color: #475569; + text-align: center; + max-width: 360px; + } + + &__state-icon { + font-size: 48px; + + &--error { color: #dc3545; font-size: 36px; } + &--muted { color: #232f3e; opacity: 0.4; } + } + + &__spinner { + width: 36px; + height: 36px; + border-radius: 50%; + border: 3px solid #e5e7eb; + border-top-color: #232f3e; + animation: federation-overview-spin 0.9s linear infinite; + } +} + +@keyframes federation-overview-spin { + to { transform: rotate(360deg); } +} diff --git a/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.ts b/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.ts new file mode 100644 index 000000000..5c34bc898 --- /dev/null +++ b/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.ts @@ -0,0 +1,70 @@ +import {Component, OnDestroy, OnInit} from '@angular/core'; +import {Router} from '@angular/router'; +import {Subject} from 'rxjs'; +import {takeUntil} from 'rxjs/operators'; +import {resolveRangeByTime} from '../../../shared/util/resolve-date'; +import {CountAlertsByStatusEntry} from '../../domain/count-alerts-by-status.model'; +import {FederationInstanceStateService} from '../../services/federation-instance-state.service'; +import {FederationOverviewService} from '../../services/federation-overview.service'; + +@Component({ + selector: 'app-federation-overview-grid', + templateUrl: './federation-overview-grid.component.html', + styleUrls: ['./federation-overview-grid.component.scss'] +}) +export class FederationOverviewGridComponent implements OnInit, OnDestroy { + entries: CountAlertsByStatusEntry[] = []; + loading = false; + errorMessage: string | null = null; + + private destroy$ = new Subject(); + + constructor( + private overviewService: FederationOverviewService, + private instanceState: FederationInstanceStateService, + private router: Router + ) {} + + ngOnInit(): void { + this.loadData(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + loadData(): void { + const range = resolveRangeByTime('week'); + if (!range.timeFrom || !range.timeTo) { + return; + } + this.loading = true; + this.errorMessage = null; + this.overviewService.countAlertsByStatus(range.timeFrom, range.timeTo) + .pipe(takeUntil(this.destroy$)) + .subscribe( + entries => { + this.entries = entries || []; + this.loading = false; + }, + () => { + this.entries = []; + this.errorMessage = 'Could not load federation overview.'; + this.loading = false; + } + ); + } + + onSelect(entry: CountAlertsByStatusEntry, route: string): void { + const target = this.instanceState.instances.find(i => i.id === entry.instanceId); + if (target) { + this.instanceState.setActive(target, true); + } + this.router.navigate([route]); + } + + trackByInstance(_index: number, entry: CountAlertsByStatusEntry): number { + return entry.instanceId; + } +} diff --git a/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.html b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.html new file mode 100644 index 000000000..8ee600c55 --- /dev/null +++ b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.html @@ -0,0 +1,17 @@ +
    +
    +
    +
    {{ entry.instanceName }}
    +
    No alerts in window
    +
    +
    + +
    +
    diff --git a/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.scss b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.scss new file mode 100644 index 000000000..07473927e --- /dev/null +++ b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.scss @@ -0,0 +1,88 @@ +:host { display: block; height: 100%; } + +.overview-card { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 12px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + height: 100%; + min-height: 320px; + + &__chart { + flex: 1 1 auto; + min-height: 220px; + height: 220px; + } + + &__empty { + flex: 1 1 auto; + min-height: 220px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #6b7280; + text-align: center; + padding: 16px; + } + + &__empty-title { + font-weight: 600; + color: #232f3e; + font-size: 14px; + margin-bottom: 6px; + } + + &__empty-msg { + font-size: 12px; + color: #9ca3af; + } + + &__nav { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 6px; + border-top: 1px solid #f1f3f5; + padding-top: 10px; + } +} + +.nav-icon { + appearance: none; + background: none; + border: 1px solid transparent; + border-radius: 6px; + padding: 8px 4px; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + color: #475569; + cursor: pointer; + font-size: 10px; + transition: background 0.15s, color 0.15s, border-color 0.15s; + min-width: 0; + + i { + font-size: 18px; + } + + &:hover { + background: #f1f5f9; + color: #232f3e; + border-color: #e2e8f0; + } +} + +.nav-icon__label { + line-height: 1.1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + text-align: center; +} diff --git a/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.ts b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.ts new file mode 100644 index 000000000..280c60af2 --- /dev/null +++ b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.ts @@ -0,0 +1,113 @@ +import {Component, EventEmitter, Input, OnChanges, Output, SimpleChanges} from '@angular/core'; +import {AlertStatusSerie, CountAlertsByStatusEntry} from '../../domain/count-alerts-by-status.model'; + +interface NavAction { + readonly label: string; + readonly iconClass: string; + readonly route: string; +} + +interface PieDatum { + name: string; + value: number; + itemStyle: { color: string }; +} + +interface PieChartOption { + tooltip: { trigger: string; formatter: string }; + legend: { + orient: 'horizontal'; + bottom: number; + data: string[]; + textStyle: { fontSize: number }; + }; + title: { + text: string; + left: string; + top: number; + textStyle: { fontSize: number; fontWeight: string }; + }; + series: Array<{ + name: string; + type: 'pie'; + radius: [string, string]; + center: [string, string]; + data: PieDatum[]; + label: { show: boolean }; + labelLine: { show: boolean }; + }>; +} + +const STATUS_COLORS: Record = { + 'Open': '#dc3545', + 'In review': '#ffc107', + 'Completed': '#28a745' +}; + +const ACTIONS: ReadonlyArray = [ + {label: 'Log Explorer', iconClass: 'icon-search4', route: '/discover/log-analyzer'}, + {label: 'Alert Management', iconClass: 'icon-bell2', route: '/data/alert/view'}, + {label: 'SOAR Flows', iconClass: 'icon-stack-play', route: '/soar/flows'}, + {label: 'Data Sources', iconClass: 'icon-database', route: '/data-sources'} +]; + +@Component({ + selector: 'app-instance-overview-card', + templateUrl: './instance-overview-card.component.html', + styleUrls: ['./instance-overview-card.component.scss'] +}) +export class InstanceOverviewCardComponent implements OnChanges { + @Input() entry!: CountAlertsByStatusEntry; + @Output() select = new EventEmitter(); + + readonly actions: ReadonlyArray = ACTIONS; + pieOption: PieChartOption | null = null; + totalAlerts = 0; + + ngOnChanges(changes: SimpleChanges): void { + if (changes.entry && this.entry) { + this.totalAlerts = this.entry.data.reduce((sum, bucket) => sum + bucket.value, 0); + this.pieOption = this.totalAlerts > 0 ? this.buildOption(this.entry) : null; + } + } + + get hasData(): boolean { + return this.totalAlerts > 0; + } + + onSelect(route: string): void { + this.select.emit(route); + } + + private buildOption(entry: CountAlertsByStatusEntry): PieChartOption { + const data: PieDatum[] = entry.data.map(bucket => ({ + name: bucket.serie, + value: bucket.value, + itemStyle: {color: STATUS_COLORS[bucket.serie] || '#6c757d'} + })); + return { + tooltip: {trigger: 'item', formatter: '{b}: {c} ({d}%)'}, + legend: { + orient: 'horizontal', + bottom: 0, + data: entry.data.map(bucket => bucket.serie), + textStyle: {fontSize: 11} + }, + title: { + text: entry.instanceName, + left: 'center', + top: 8, + textStyle: {fontSize: 14, fontWeight: '600'} + }, + series: [{ + name: 'Alerts', + type: 'pie', + radius: ['45%', '70%'], + center: ['50%', '52%'], + data, + label: {show: false}, + labelLine: {show: false} + }] + }; + } +} diff --git a/frontend/src/app/federation/domain/count-alerts-by-status.model.ts b/frontend/src/app/federation/domain/count-alerts-by-status.model.ts new file mode 100644 index 000000000..5293522a1 --- /dev/null +++ b/frontend/src/app/federation/domain/count-alerts-by-status.model.ts @@ -0,0 +1,12 @@ +export type AlertStatusSerie = 'Open' | 'In review' | 'Completed'; + +export interface CountAlertsByStatusBucket { + serie: AlertStatusSerie; + value: number; +} + +export interface CountAlertsByStatusEntry { + instanceId: number; + instanceName: string; + data: CountAlertsByStatusBucket[]; +} diff --git a/frontend/src/app/federation/federation.module.ts b/frontend/src/app/federation/federation.module.ts index 630db40a9..acb50f493 100644 --- a/frontend/src/app/federation/federation.module.ts +++ b/frontend/src/app/federation/federation.module.ts @@ -3,7 +3,10 @@ import {NgModule} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; import {InlineSVGModule} from 'ng-inline-svg'; +import {NgxEchartsModule} from 'ngx-echarts'; +import {FederationOverviewGridComponent} from './components/federation-overview-grid/federation-overview-grid.component'; import {InstanceFormModalComponent} from './components/instance-form-modal/instance-form-modal.component'; +import {InstanceOverviewCardComponent} from './components/instance-overview-card/instance-overview-card.component'; import {InstanceSwitcherComponent} from './components/instance-switcher/instance-switcher.component'; import {TeamManagementModalComponent} from './components/team-management-modal/team-management-modal.component'; import {TeamUserFormModalComponent} from './components/team-user-form-modal/team-user-form-modal.component'; @@ -14,18 +17,22 @@ import {WelcomeComponent} from './pages/welcome/welcome.component'; CommonModule, FormsModule, NgbModule, - InlineSVGModule + InlineSVGModule, + NgxEchartsModule ], declarations: [ InstanceSwitcherComponent, InstanceFormModalComponent, TeamManagementModalComponent, TeamUserFormModalComponent, - WelcomeComponent + WelcomeComponent, + FederationOverviewGridComponent, + InstanceOverviewCardComponent ], exports: [ InstanceSwitcherComponent, - TeamManagementModalComponent + TeamManagementModalComponent, + FederationOverviewGridComponent ], entryComponents: [ InstanceFormModalComponent, diff --git a/frontend/src/app/federation/pages/welcome/welcome.component.html b/frontend/src/app/federation/pages/welcome/welcome.component.html index f007c5674..3127bf1fc 100644 --- a/frontend/src/app/federation/pages/welcome/welcome.component.html +++ b/frontend/src/app/federation/pages/welcome/welcome.component.html @@ -1,16 +1,15 @@ -
    -
    -
    - +
    +
    +
    +

    Federation Overview

    +

    Active instances and alert distribution.

    -

    Welcome to UTMStack Federation

    -

    - No instances are connected yet. Connect your first instance to start managing it from this console. -

    + +
    diff --git a/frontend/src/app/federation/pages/welcome/welcome.component.ts b/frontend/src/app/federation/pages/welcome/welcome.component.ts index 0758e29d5..02c8bebd8 100644 --- a/frontend/src/app/federation/pages/welcome/welcome.component.ts +++ b/frontend/src/app/federation/pages/welcome/welcome.component.ts @@ -1,4 +1,4 @@ -import {Component, OnInit} from '@angular/core'; +import {Component} from '@angular/core'; import {Router} from '@angular/router'; import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; import {InstanceFormModalComponent} from '../../components/instance-form-modal/instance-form-modal.component'; @@ -11,7 +11,7 @@ import {FederationInstancesService} from '../../services/federation-instances.se templateUrl: './welcome.component.html', styleUrls: ['./welcome.component.scss'] }) -export class WelcomeComponent implements OnInit { +export class WelcomeComponent { constructor( private modalService: NgbModal, @@ -20,10 +20,6 @@ export class WelcomeComponent implements OnInit { private instancesService: FederationInstancesService ) {} - ngOnInit(): void { - this.openCreateModal(); - } - openCreateModal(): void { const ref = this.modalService.open(InstanceFormModalComponent, { centered: true, diff --git a/frontend/src/app/federation/services/federation-overview.service.ts b/frontend/src/app/federation/services/federation-overview.service.ts new file mode 100644 index 000000000..77bd66341 --- /dev/null +++ b/frontend/src/app/federation/services/federation-overview.service.ts @@ -0,0 +1,17 @@ +import {HttpClient, HttpParams} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {Observable} from 'rxjs'; +import {SERVER_API_URL} from '../../app.constants'; +import {CountAlertsByStatusEntry} from '../domain/count-alerts-by-status.model'; + +@Injectable({providedIn: 'root'}) +export class FederationOverviewService { + private readonly endpoint = SERVER_API_URL + 'api/v1/overview/count-alerts-by-status'; + + constructor(private http: HttpClient) {} + + countAlertsByStatus(from: string, to: string): Observable { + const params = new HttpParams().set('from', from).set('to', to); + return this.http.get(this.endpoint, {params}); + } +} From b293531e226c7b33c6929ff3e50cf1686d739d59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Mon, 22 Jun 2026 17:12:38 -0600 Subject: [PATCH 08/36] feat[frontend](federation): added federation left sidebar --- frontend/src/app/app-routing.module.ts | 4 +- frontend/src/app/app.component.html | 20 +++- frontend/src/app/app.component.scss | 15 +++ frontend/src/app/app.component.ts | 8 +- .../federation-overview-grid.component.html | 1 + .../federation-overview-grid.component.ts | 25 +++- ...deration-sidebar-instances.component.html} | 30 +++-- ...deration-sidebar-instances.component.scss} | 43 ++++--- ...federation-sidebar-instances.component.ts} | 18 +-- .../federation-sidebar.component.html | 66 +++++++++++ .../federation-sidebar.component.scss | 73 ++++++++++++ .../federation-sidebar.component.ts | 41 +++++++ .../instance-overview-card.component.html | 10 +- .../instance-overview-card.component.scss | 21 +++- .../instance-overview-card.component.ts | 74 ++++++++---- .../team-management-modal.component.html | 107 ------------------ .../team-management-modal.component.scss | 30 ----- .../domain/count-alerts-by-severity.model.ts | 12 ++ .../domain/federation-nav-actions.ts | 12 ++ .../src/app/federation/federation.module.ts | 28 +++-- .../team-members-pagination.component.html | 17 +++ .../team-members-pagination.component.ts | 13 +++ .../team-members-search.component.html | 5 + .../team-members-search.component.ts | 32 ++++++ .../team-members-table.component.html | 62 ++++++++++ .../team-members-table.component.scss | 24 ++++ .../team-members-table.component.ts | 27 +++++ .../team-members.page.component.html | 38 +++++++ .../team-members.page.component.scss | 3 + .../team-members.page.component.ts} | 30 ++--- .../services/federation-overview.service.ts | 11 +- .../layout/header/header.component.html | 4 - .../mobile-header.component.html | 4 - ...m-notification-user-setting.component.html | 6 - ...utm-notification-user-setting.component.ts | 18 +-- frontend/src/app/shared/utm-shared.module.ts | 3 +- 36 files changed, 653 insertions(+), 282 deletions(-) rename frontend/src/app/federation/components/{instance-switcher/instance-switcher.component.html => federation-sidebar-instances/federation-sidebar-instances.component.html} (72%) rename frontend/src/app/federation/components/{instance-switcher/instance-switcher.component.scss => federation-sidebar-instances/federation-sidebar-instances.component.scss} (58%) rename frontend/src/app/federation/components/{instance-switcher/instance-switcher.component.ts => federation-sidebar-instances/federation-sidebar-instances.component.ts} (93%) create mode 100644 frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.html create mode 100644 frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.scss create mode 100644 frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.ts delete mode 100644 frontend/src/app/federation/components/team-management-modal/team-management-modal.component.html delete mode 100644 frontend/src/app/federation/components/team-management-modal/team-management-modal.component.scss create mode 100644 frontend/src/app/federation/domain/count-alerts-by-severity.model.ts create mode 100644 frontend/src/app/federation/domain/federation-nav-actions.ts create mode 100644 frontend/src/app/federation/pages/team-members/team-members-pagination.component.html create mode 100644 frontend/src/app/federation/pages/team-members/team-members-pagination.component.ts create mode 100644 frontend/src/app/federation/pages/team-members/team-members-search.component.html create mode 100644 frontend/src/app/federation/pages/team-members/team-members-search.component.ts create mode 100644 frontend/src/app/federation/pages/team-members/team-members-table.component.html create mode 100644 frontend/src/app/federation/pages/team-members/team-members-table.component.scss create mode 100644 frontend/src/app/federation/pages/team-members/team-members-table.component.ts create mode 100644 frontend/src/app/federation/pages/team-members/team-members.page.component.html create mode 100644 frontend/src/app/federation/pages/team-members/team-members.page.component.scss rename frontend/src/app/federation/{components/team-management-modal/team-management-modal.component.ts => pages/team-members/team-members.page.component.ts} (87%) diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index a8c6f2dbe..332a4f5bd 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -1,6 +1,7 @@ import {NgModule} from '@angular/core'; import {RouterModule, Routes} from '@angular/router'; import {UserRouteAccessService} from './core/auth/user-route-access-service'; +import {TeamMembersPageComponent} from './federation/pages/team-members/team-members.page.component'; import {WelcomeComponent as FederationWelcomeComponent} from './federation/pages/welcome/welcome.component'; import {ConfirmIdentityComponent} from './shared/components/auth/confirm-identity/confirm-identity.component'; import {LoginComponent} from './shared/components/auth/login/login.component'; @@ -160,7 +161,8 @@ const routes: Routes = [ children: [ {path: '', redirectTo: 'welcome', pathMatch: 'full'}, {path: 'welcome', component: FederationWelcomeComponent}, - {path: 'instances', component: FederationWelcomeComponent} + {path: 'instances', component: FederationWelcomeComponent}, + {path: 'team-members', component: TeamMembersPageComponent} ] }, {path: '', component: LoginComponent}, diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index fe002398c..1b4522376 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -1,16 +1,24 @@
    -
    +
    -
    -
    - + +
    +
    + +
    -
    + +
    + +
    +
    +
    ; constructor( private translate: TranslateService, @@ -44,7 +46,9 @@ export class AppComponent implements OnInit { private accountService: AccountService, private checkForUpdatesService: AppVersionService, private versionTypeService: VersionInfoService, - private federationInstanceState: FederationInstanceStateService) { + private federationInstanceState: FederationInstanceStateService, + private federationModeService: FederationModeService) { + this.federationActive$ = this.federationModeService.active$; this.translate.setDefaultLang('en'); diff --git a/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.html b/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.html index c912092c7..9ca5df46f 100644 --- a/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.html +++ b/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.html @@ -21,6 +21,7 @@
    diff --git a/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.ts b/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.ts index 5c34bc898..2976f6e15 100644 --- a/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.ts +++ b/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.ts @@ -1,8 +1,9 @@ import {Component, OnDestroy, OnInit} from '@angular/core'; import {Router} from '@angular/router'; -import {Subject} from 'rxjs'; -import {takeUntil} from 'rxjs/operators'; +import {forkJoin, of, Subject} from 'rxjs'; +import {catchError, takeUntil} from 'rxjs/operators'; import {resolveRangeByTime} from '../../../shared/util/resolve-date'; +import {CountAlertsBySeverityEntry} from '../../domain/count-alerts-by-severity.model'; import {CountAlertsByStatusEntry} from '../../domain/count-alerts-by-status.model'; import {FederationInstanceStateService} from '../../services/federation-instance-state.service'; import {FederationOverviewService} from '../../services/federation-overview.service'; @@ -14,6 +15,7 @@ import {FederationOverviewService} from '../../services/federation-overview.serv }) export class FederationOverviewGridComponent implements OnInit, OnDestroy { entries: CountAlertsByStatusEntry[] = []; + severityByInstance: Record = {}; loading = false; errorMessage: string | null = null; @@ -41,21 +43,34 @@ export class FederationOverviewGridComponent implements OnInit, OnDestroy { } this.loading = true; this.errorMessage = null; - this.overviewService.countAlertsByStatus(range.timeFrom, range.timeTo) + forkJoin([ + this.overviewService.countAlertsByStatus(range.timeFrom, range.timeTo), + this.overviewService.countAlertsBySeverity(range.timeFrom, range.timeTo) + .pipe(catchError(() => of([] as CountAlertsBySeverityEntry[]))) + ]) .pipe(takeUntil(this.destroy$)) .subscribe( - entries => { - this.entries = entries || []; + ([status, severity]: [CountAlertsByStatusEntry[], CountAlertsBySeverityEntry[]]) => { + this.entries = status || []; + this.severityByInstance = {}; + (severity || []).forEach(entry => { + this.severityByInstance[entry.instanceId] = entry; + }); this.loading = false; }, () => { this.entries = []; + this.severityByInstance = {}; this.errorMessage = 'Could not load federation overview.'; this.loading = false; } ); } + severityFor(entry: CountAlertsByStatusEntry): CountAlertsBySeverityEntry | null { + return this.severityByInstance[entry.instanceId] || null; + } + onSelect(entry: CountAlertsByStatusEntry, route: string): void { const target = this.instanceState.instances.find(i => i.id === entry.instanceId); if (target) { diff --git a/frontend/src/app/federation/components/instance-switcher/instance-switcher.component.html b/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.html similarity index 72% rename from frontend/src/app/federation/components/instance-switcher/instance-switcher.component.html rename to frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.html index cc4de5c9f..13bd2e6fe 100644 --- a/frontend/src/app/federation/components/instance-switcher/instance-switcher.component.html +++ b/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.html @@ -1,20 +1,18 @@ -
    -
    - - - {{ active ? active.name : 'No instance' }} - -
    -
    + class="federation-sidebar-instances"> + + + + +
    Instances
    {{ errorMessage }}
    @@ -27,8 +25,8 @@
    Instances
    class="federation-instance-row dropdown-item d-flex align-items-center justify-content-between" [class.active]="active && instance.id === active.id"> -
    - -
    -
    -
    - -
    - -
    - -
    {{ errorMessage }}
    - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameLoginEmailStatus2FAActions
    Loading...
    No team members found.
    {{ displayName(user) }}{{ user.login }}{{ user.email || '—' }} - Pending - Active - Inactive - - On - — - - - - - -
    -
    - -
    - - Page {{ pageInfo.page }} of {{ pageInfo.total_pages }} - ({{ pageInfo.total_items }} total) - -
    - - -
    -
    -
    - -
    - -
    diff --git a/frontend/src/app/federation/components/team-management-modal/team-management-modal.component.scss b/frontend/src/app/federation/components/team-management-modal/team-management-modal.component.scss deleted file mode 100644 index 67d74f00c..000000000 --- a/frontend/src/app/federation/components/team-management-modal/team-management-modal.component.scss +++ /dev/null @@ -1,30 +0,0 @@ -.federation-team-modal { - .federation-team-toolbar { - gap: 1rem; - } - - .federation-team-table { - th { - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.03em; - color: #6c757d; - } - - td { - vertical-align: middle; - } - - .federation-team-actions { - white-space: nowrap; - - .btn { - margin-left: 0.15rem; - } - } - } - - .badge { - font-weight: 500; - } -} diff --git a/frontend/src/app/federation/domain/count-alerts-by-severity.model.ts b/frontend/src/app/federation/domain/count-alerts-by-severity.model.ts new file mode 100644 index 000000000..1e78680e5 --- /dev/null +++ b/frontend/src/app/federation/domain/count-alerts-by-severity.model.ts @@ -0,0 +1,12 @@ +export type AlertSeveritySerie = 'Low' | 'Medium' | 'High'; + +export interface CountAlertsBySeverityBucket { + serie: AlertSeveritySerie; + value: number; +} + +export interface CountAlertsBySeverityEntry { + instanceId: number; + instanceName: string; + data: CountAlertsBySeverityBucket[]; +} diff --git a/frontend/src/app/federation/domain/federation-nav-actions.ts b/frontend/src/app/federation/domain/federation-nav-actions.ts new file mode 100644 index 000000000..e3913686a --- /dev/null +++ b/frontend/src/app/federation/domain/federation-nav-actions.ts @@ -0,0 +1,12 @@ +export interface NavAction { + readonly label: string; + readonly iconClass: string; + readonly route: string; +} + +export const FEDERATION_NAV_ACTIONS: ReadonlyArray = [ + {label: 'Log Explorer', iconClass: 'icon-search4', route: '/discover/log-analyzer'}, + {label: 'Alert Management', iconClass: 'icon-bell2', route: '/data/alert/view'}, + {label: 'SOAR Flows', iconClass: 'icon-stack-play', route: '/soar/flows'}, + {label: 'Data Sources', iconClass: 'icon-database', route: '/data-sources'} +]; diff --git a/frontend/src/app/federation/federation.module.ts b/frontend/src/app/federation/federation.module.ts index acb50f493..b9957e311 100644 --- a/frontend/src/app/federation/federation.module.ts +++ b/frontend/src/app/federation/federation.module.ts @@ -1,42 +1,54 @@ import {CommonModule} from '@angular/common'; import {NgModule} from '@angular/core'; import {FormsModule} from '@angular/forms'; +import {RouterModule} from '@angular/router'; import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; import {InlineSVGModule} from 'ng-inline-svg'; import {NgxEchartsModule} from 'ngx-echarts'; import {FederationOverviewGridComponent} from './components/federation-overview-grid/federation-overview-grid.component'; +import {FederationSidebarComponent} from './components/federation-sidebar/federation-sidebar.component'; +import { + FederationSidebarInstancesComponent +} from './components/federation-sidebar-instances/federation-sidebar-instances.component'; import {InstanceFormModalComponent} from './components/instance-form-modal/instance-form-modal.component'; import {InstanceOverviewCardComponent} from './components/instance-overview-card/instance-overview-card.component'; -import {InstanceSwitcherComponent} from './components/instance-switcher/instance-switcher.component'; -import {TeamManagementModalComponent} from './components/team-management-modal/team-management-modal.component'; import {TeamUserFormModalComponent} from './components/team-user-form-modal/team-user-form-modal.component'; +import { + TeamMembersPaginationComponent +} from './pages/team-members/team-members-pagination.component'; +import {TeamMembersSearchComponent} from './pages/team-members/team-members-search.component'; +import {TeamMembersTableComponent} from './pages/team-members/team-members-table.component'; +import {TeamMembersPageComponent} from './pages/team-members/team-members.page.component'; import {WelcomeComponent} from './pages/welcome/welcome.component'; @NgModule({ imports: [ CommonModule, FormsModule, + RouterModule, NgbModule, InlineSVGModule, NgxEchartsModule ], declarations: [ - InstanceSwitcherComponent, + FederationSidebarComponent, + FederationSidebarInstancesComponent, InstanceFormModalComponent, - TeamManagementModalComponent, TeamUserFormModalComponent, WelcomeComponent, FederationOverviewGridComponent, - InstanceOverviewCardComponent + InstanceOverviewCardComponent, + TeamMembersPageComponent, + TeamMembersTableComponent, + TeamMembersSearchComponent, + TeamMembersPaginationComponent ], exports: [ - InstanceSwitcherComponent, - TeamManagementModalComponent, + FederationSidebarComponent, FederationOverviewGridComponent ], entryComponents: [ InstanceFormModalComponent, - TeamManagementModalComponent, TeamUserFormModalComponent ] }) diff --git a/frontend/src/app/federation/pages/team-members/team-members-pagination.component.html b/frontend/src/app/federation/pages/team-members/team-members-pagination.component.html new file mode 100644 index 000000000..a47a06e96 --- /dev/null +++ b/frontend/src/app/federation/pages/team-members/team-members-pagination.component.html @@ -0,0 +1,17 @@ +
    + + Page {{ pageInfo.page }} of {{ pageInfo.total_pages }} + ({{ pageInfo.total_items }} total) + +
    + + +
    +
    diff --git a/frontend/src/app/federation/pages/team-members/team-members-pagination.component.ts b/frontend/src/app/federation/pages/team-members/team-members-pagination.component.ts new file mode 100644 index 000000000..d6218a5f8 --- /dev/null +++ b/frontend/src/app/federation/pages/team-members/team-members-pagination.component.ts @@ -0,0 +1,13 @@ +import {Component, EventEmitter, Input, Output} from '@angular/core'; +import {TeamUserPageInfo} from '../../domain/team-user.model'; + +@Component({ + selector: 'app-team-members-pagination', + templateUrl: './team-members-pagination.component.html' +}) +export class TeamMembersPaginationComponent { + @Input() pageInfo: TeamUserPageInfo | null = null; + @Input() loading = false; + @Output() prev = new EventEmitter(); + @Output() next = new EventEmitter(); +} diff --git a/frontend/src/app/federation/pages/team-members/team-members-search.component.html b/frontend/src/app/federation/pages/team-members/team-members-search.component.html new file mode 100644 index 000000000..9f040c552 --- /dev/null +++ b/frontend/src/app/federation/pages/team-members/team-members-search.component.html @@ -0,0 +1,5 @@ + diff --git a/frontend/src/app/federation/pages/team-members/team-members-search.component.ts b/frontend/src/app/federation/pages/team-members/team-members-search.component.ts new file mode 100644 index 000000000..da53a86e0 --- /dev/null +++ b/frontend/src/app/federation/pages/team-members/team-members-search.component.ts @@ -0,0 +1,32 @@ +import {Component, EventEmitter, Input, OnDestroy, OnInit, Output} from '@angular/core'; +import {Subject} from 'rxjs'; +import {debounceTime, distinctUntilChanged, takeUntil} from 'rxjs/operators'; + +@Component({ + selector: 'app-team-members-search', + templateUrl: './team-members-search.component.html' +}) +export class TeamMembersSearchComponent implements OnInit, OnDestroy { + @Input() placeholder = 'Search by name, login or email'; + @Output() searchChange = new EventEmitter(); + + value = ''; + private input$ = new Subject(); + private destroy$ = new Subject(); + + ngOnInit(): void { + this.input$ + .pipe(debounceTime(300), distinctUntilChanged(), takeUntil(this.destroy$)) + .subscribe(term => this.searchChange.emit(term)); + } + + onInput(value: string): void { + this.value = value; + this.input$.next(value); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/frontend/src/app/federation/pages/team-members/team-members-table.component.html b/frontend/src/app/federation/pages/team-members/team-members-table.component.html new file mode 100644 index 000000000..877c140ac --- /dev/null +++ b/frontend/src/app/federation/pages/team-members/team-members-table.component.html @@ -0,0 +1,62 @@ +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameLoginEmailStatus2FAActions
    Loading...
    No team members found.
    {{ displayName(user) }}{{ user.login }}{{ user.email || '—' }} + Pending + Active + Inactive + + On + — + + + + + +
    +
    diff --git a/frontend/src/app/federation/pages/team-members/team-members-table.component.scss b/frontend/src/app/federation/pages/team-members/team-members-table.component.scss new file mode 100644 index 000000000..3c2b45cbb --- /dev/null +++ b/frontend/src/app/federation/pages/team-members/team-members-table.component.scss @@ -0,0 +1,24 @@ +.federation-team-table { + th { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.03em; + color: #6c757d; + } + + td { + vertical-align: middle; + } + + .federation-team-actions { + white-space: nowrap; + + .btn { + margin-left: 0.15rem; + } + } +} + +.badge { + font-weight: 500; +} diff --git a/frontend/src/app/federation/pages/team-members/team-members-table.component.ts b/frontend/src/app/federation/pages/team-members/team-members-table.component.ts new file mode 100644 index 000000000..68fe34389 --- /dev/null +++ b/frontend/src/app/federation/pages/team-members/team-members-table.component.ts @@ -0,0 +1,27 @@ +import {Component, EventEmitter, Input, Output} from '@angular/core'; +import {TeamUser} from '../../domain/team-user.model'; + +@Component({ + selector: 'app-team-members-table', + templateUrl: './team-members-table.component.html', + styleUrls: ['./team-members-table.component.scss'] +}) +export class TeamMembersTableComponent { + @Input() users: TeamUser[] = []; + @Input() loading = false; + @Input() pendingActionId: number | null = null; + + @Output() editUser = new EventEmitter(); + @Output() resendInvite = new EventEmitter(); + @Output() disableTfa = new EventEmitter(); + @Output() toggleActivation = new EventEmitter(); + + trackById(_index: number, item: TeamUser): number { + return item.id; + } + + displayName(user: TeamUser): string { + const composed = [user.first_name, user.last_name].filter(part => !!part).join(' ').trim(); + return composed || user.login; + } +} diff --git a/frontend/src/app/federation/pages/team-members/team-members.page.component.html b/frontend/src/app/federation/pages/team-members/team-members.page.component.html new file mode 100644 index 000000000..b7351d2a4 --- /dev/null +++ b/frontend/src/app/federation/pages/team-members/team-members.page.component.html @@ -0,0 +1,38 @@ +
    +
    +
    +

    Team Members

    +

    Manage federation team members across instances.

    +
    + +
    + +
    +
    + +
    +
    + +
    {{ errorMessage }}
    + + + + + + +
    diff --git a/frontend/src/app/federation/pages/team-members/team-members.page.component.scss b/frontend/src/app/federation/pages/team-members/team-members.page.component.scss new file mode 100644 index 000000000..e818051ee --- /dev/null +++ b/frontend/src/app/federation/pages/team-members/team-members.page.component.scss @@ -0,0 +1,3 @@ +.team-members-toolbar { + gap: 1rem; +} diff --git a/frontend/src/app/federation/components/team-management-modal/team-management-modal.component.ts b/frontend/src/app/federation/pages/team-members/team-members.page.component.ts similarity index 87% rename from frontend/src/app/federation/components/team-management-modal/team-management-modal.component.ts rename to frontend/src/app/federation/pages/team-members/team-members.page.component.ts index 2c84e8c76..69c40bc94 100644 --- a/frontend/src/app/federation/components/team-management-modal/team-management-modal.component.ts +++ b/frontend/src/app/federation/pages/team-members/team-members.page.component.ts @@ -1,23 +1,22 @@ import {Component, OnDestroy, OnInit} from '@angular/core'; -import {NgbActiveModal, NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; import {Subject} from 'rxjs'; -import {debounceTime, distinctUntilChanged, takeUntil} from 'rxjs/operators'; import {UtmToastService} from '../../../shared/alert/utm-toast.service'; import { ModalConfirmationComponent } from '../../../shared/components/utm/util/modal-confirmation/modal-confirmation.component'; import {TeamUser, TeamUserPageInfo} from '../../domain/team-user.model'; import {FederationTeamService} from '../../services/federation-team.service'; -import {TeamUserFormModalComponent} from '../team-user-form-modal/team-user-form-modal.component'; +import {TeamUserFormModalComponent} from '../../components/team-user-form-modal/team-user-form-modal.component'; const DEFAULT_PAGE_SIZE = 20; @Component({ - selector: 'app-federation-team-management-modal', - templateUrl: './team-management-modal.component.html', - styleUrls: ['./team-management-modal.component.scss'] + selector: 'app-team-members-page', + templateUrl: './team-members.page.component.html', + styleUrls: ['./team-members.page.component.scss'] }) -export class TeamManagementModalComponent implements OnInit, OnDestroy { +export class TeamMembersPageComponent implements OnInit, OnDestroy { users: TeamUser[] = []; pageInfo: TeamUserPageInfo | null = null; loading = false; @@ -27,21 +26,13 @@ export class TeamManagementModalComponent implements OnInit, OnDestroy { private currentPage = 1; private readonly pageSize = DEFAULT_PAGE_SIZE; - private searchSubject = new Subject(); private destroy$ = new Subject(); - constructor(public activeModal: NgbActiveModal, - private teamService: FederationTeamService, + constructor(private teamService: FederationTeamService, private modalService: NgbModal, private toast: UtmToastService) {} ngOnInit(): void { - this.searchSubject - .pipe(debounceTime(300), distinctUntilChanged(), takeUntil(this.destroy$)) - .subscribe(() => { - this.currentPage = 1; - this.load(); - }); this.load(); } @@ -52,7 +43,8 @@ export class TeamManagementModalComponent implements OnInit, OnDestroy { onSearchChange(value: string): void { this.searchTerm = value; - this.searchSubject.next(value); + this.currentPage = 1; + this.load(); } prevPage(): void { @@ -144,10 +136,6 @@ export class TeamManagementModalComponent implements OnInit, OnDestroy { } } - trackById(_index: number, item: TeamUser): number { - return item.id; - } - displayName(user: TeamUser): string { const composed = [user.first_name, user.last_name].filter(part => !!part).join(' ').trim(); return composed || user.login; diff --git a/frontend/src/app/federation/services/federation-overview.service.ts b/frontend/src/app/federation/services/federation-overview.service.ts index 77bd66341..8afeb109a 100644 --- a/frontend/src/app/federation/services/federation-overview.service.ts +++ b/frontend/src/app/federation/services/federation-overview.service.ts @@ -2,16 +2,23 @@ import {HttpClient, HttpParams} from '@angular/common/http'; import {Injectable} from '@angular/core'; import {Observable} from 'rxjs'; import {SERVER_API_URL} from '../../app.constants'; +import {CountAlertsBySeverityEntry} from '../domain/count-alerts-by-severity.model'; import {CountAlertsByStatusEntry} from '../domain/count-alerts-by-status.model'; @Injectable({providedIn: 'root'}) export class FederationOverviewService { - private readonly endpoint = SERVER_API_URL + 'api/v1/overview/count-alerts-by-status'; + private readonly statusEndpoint = SERVER_API_URL + 'api/v1/overview/count-alerts-by-status'; + private readonly severityEndpoint = SERVER_API_URL + 'api/v1/overview/count-alerts-by-severity'; constructor(private http: HttpClient) {} countAlertsByStatus(from: string, to: string): Observable { const params = new HttpParams().set('from', from).set('to', to); - return this.http.get(this.endpoint, {params}); + return this.http.get(this.statusEndpoint, {params}); + } + + countAlertsBySeverity(from: string, to: string): Observable { + const params = new HttpParams().set('from', from).set('to', to); + return this.http.get(this.severityEndpoint, {params}); } } diff --git a/frontend/src/app/shared/components/layout/header/header.component.html b/frontend/src/app/shared/components/layout/header/header.component.html index 52b34575a..31f4e5382 100644 --- a/frontend/src/app/shared/components/layout/header/header.component.html +++ b/frontend/src/app/shared/components/layout/header/header.component.html @@ -24,10 +24,6 @@ -
  • - -
  • -
  • diff --git a/frontend/src/app/shared/components/layout/header/mobile-header/mobile-header.component.html b/frontend/src/app/shared/components/layout/header/mobile-header/mobile-header.component.html index 79653d49e..2c424f2a9 100644 --- a/frontend/src/app/shared/components/layout/header/mobile-header/mobile-header.component.html +++ b/frontend/src/app/shared/components/layout/header/mobile-header/mobile-header.component.html @@ -20,10 +20,6 @@
    S
    -
  • - -
  • -
  • diff --git a/frontend/src/app/shared/components/layout/header/shared/notification/utm-notification-user-setting/utm-notification-user-setting.component.html b/frontend/src/app/shared/components/layout/header/shared/notification/utm-notification-user-setting/utm-notification-user-setting.component.html index ecc2fbfa4..a27b1c6d9 100644 --- a/frontend/src/app/shared/components/layout/header/shared/notification/utm-notification-user-setting/utm-notification-user-setting.component.html +++ b/frontend/src/app/shared/components/layout/header/shared/notification/utm-notification-user-setting/utm-notification-user-setting.component.html @@ -10,12 +10,6 @@ inlineSVG="assets/icons/header/user-icon.svg">
    - - - Team management - Profile Settings diff --git a/frontend/src/app/shared/components/layout/header/shared/notification/utm-notification-user-setting/utm-notification-user-setting.component.ts b/frontend/src/app/shared/components/layout/header/shared/notification/utm-notification-user-setting/utm-notification-user-setting.component.ts index 33a56d883..30a4a56d5 100644 --- a/frontend/src/app/shared/components/layout/header/shared/notification/utm-notification-user-setting/utm-notification-user-setting.component.ts +++ b/frontend/src/app/shared/components/layout/header/shared/notification/utm-notification-user-setting/utm-notification-user-setting.component.ts @@ -1,12 +1,7 @@ import {Component, OnInit} from '@angular/core'; import {Router} from '@angular/router'; import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; -import {Observable} from 'rxjs'; import {LoginService} from '../../../../../../../core/login/login.service'; -import { - TeamManagementModalComponent -} from '../../../../../../../federation/components/team-management-modal/team-management-modal.component'; -import {FederationModeService} from '../../../../../../../federation/services/federation-mode.service'; import {PasswordComponent} from './password/password.component'; import {SettingsComponent} from './settings/settings.component'; @@ -16,13 +11,10 @@ import {SettingsComponent} from './settings/settings.component'; styleUrls: ['./utm-notification-user-setting.component.css'] }) export class UtmNotificationUserSettingComponent implements OnInit { - federationActive$: Observable; constructor(public router: Router, private modalService: NgbModal, - private loginService: LoginService, - private federationModeService: FederationModeService) { - this.federationActive$ = this.federationModeService.active$; + private loginService: LoginService) { } ngOnInit() { @@ -40,12 +32,4 @@ export class UtmNotificationUserSettingComponent implements OnInit { changePassword() { this.modalService.open(PasswordComponent, {centered: true}); } - - openTeamManagement() { - this.modalService.open(TeamManagementModalComponent, { - centered: true, - size: 'lg', - backdrop: 'static' - }); - } } diff --git a/frontend/src/app/shared/utm-shared.module.ts b/frontend/src/app/shared/utm-shared.module.ts index 457e4de95..fccdd5561 100644 --- a/frontend/src/app/shared/utm-shared.module.ts +++ b/frontend/src/app/shared/utm-shared.module.ts @@ -529,7 +529,8 @@ import {ScheduleConfigComponent} from './components/schedule-config/schedule-con ResizableFilterContainerComponent, CodeEditorComponent, IsEnterpriseModuleDirective, - ScheduleConfigComponent + ScheduleConfigComponent, + FederationModule ], entryComponents: [ LoginComponent, From 5c86fddbaf310efdfa1f99c1764f404121de33db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Mon, 22 Jun 2026 17:41:22 -0600 Subject: [PATCH 09/36] feat[frontend](federation): fixed overview charts --- .../instance-overview-card.component.html | 39 +++++- .../instance-overview-card.component.scss | 42 ++++++- .../instance-overview-card.component.ts | 116 +++++------------- .../domain/count-alerts-by-severity.model.ts | 4 +- .../src/app/federation/federation.module.ts | 4 +- .../pages/welcome/welcome.component.html | 35 ++++-- .../pages/welcome/welcome.component.scss | 4 + .../pages/welcome/welcome.component.ts | 4 + .../services/federation-overview.service.ts | 2 +- 9 files changed, 138 insertions(+), 112 deletions(-) diff --git a/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.html b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.html index 242f24d1b..484233149 100644 --- a/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.html +++ b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.html @@ -1,9 +1,42 @@
    {{ entry.instanceName }}
    -
    -
    -
    +
    +
    +
    Severity
    +
    + +
    +
    + +
    +
    Status
    +
    + +
    +
    diff --git a/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.scss b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.scss index 88f7121ef..b37d0e048 100644 --- a/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.scss +++ b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.scss @@ -19,18 +19,48 @@ text-align: center; } - &__charts { + &__rows { flex: 1 1 auto; + display: flex; + flex-direction: column; + gap: 10px; + } + + &__row { + display: flex; + flex-direction: column; + gap: 4px; + } + + &__row-label { + font-size: 11px; + font-weight: 600; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + &__row-meters { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(3, 1fr); gap: 8px; - min-height: 200px; + min-width: 0; + justify-items: center; } - &__chart { + &__gauge { + display: block; min-width: 0; - min-height: 200px; - height: 200px; + + ::ng-deep .reading-block { + font-size: 14px !important; + font-weight: 600; + } + + ::ng-deep .reading-label { + font-size: 10px !important; + color: #475569; + } } &__empty { diff --git a/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.ts b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.ts index 3acadb9c4..d00ae9810 100644 --- a/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.ts +++ b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.ts @@ -1,41 +1,15 @@ import {Component, EventEmitter, Input, OnChanges, Output, SimpleChanges} from '@angular/core'; import { AlertSeveritySerie, - CountAlertsBySeverityBucket, CountAlertsBySeverityEntry } from '../../domain/count-alerts-by-severity.model'; import {AlertStatusSerie, CountAlertsByStatusEntry} from '../../domain/count-alerts-by-status.model'; import {FEDERATION_NAV_ACTIONS, NavAction} from '../../domain/federation-nav-actions'; -interface PieDatum { - name: string; +interface MeterDatum { + label: string; value: number; - itemStyle: { color: string }; -} - -interface PieChartOption { - tooltip: { trigger: string; formatter: string }; - legend: { - orient: 'horizontal'; - bottom: number; - data: string[]; - textStyle: { fontSize: number }; - }; - title: { - text: string; - left: string; - top: number; - textStyle: { fontSize: number; fontWeight: string }; - }; - series: Array<{ - name: string; - type: 'pie'; - radius: [string, string]; - center: [string, string]; - data: PieDatum[]; - label: { show: boolean }; - labelLine: { show: boolean }; - }>; + color: string; } const STATUS_COLORS: Record = { @@ -51,6 +25,7 @@ const SEVERITY_COLORS: Record = { }; const SEVERITY_ORDER: ReadonlyArray = ['High', 'Medium', 'Low']; +const STATUS_ORDER: ReadonlyArray = ['Open', 'In review', 'Completed']; @Component({ selector: 'app-instance-overview-card', @@ -63,23 +38,40 @@ export class InstanceOverviewCardComponent implements OnChanges { @Output() select = new EventEmitter(); readonly actions: ReadonlyArray = FEDERATION_NAV_ACTIONS; - statusOption: PieChartOption | null = null; - severityOption: PieChartOption | null = null; + severityMeters: MeterDatum[] = []; + statusMeters: MeterDatum[] = []; + severityMax = 0; + statusMax = 0; totalAlerts = 0; totalSeverity = 0; ngOnChanges(changes: SimpleChanges): void { if (changes.entry && this.entry) { - console.log(this.entry) - this.totalAlerts = this.entry.data.reduce((sum, bucket) => sum + bucket.value, 0); - this.statusOption = this.totalAlerts > 0 ? this.buildStatusOption(this.entry) : null; + const buckets = this.entry.data || []; + this.totalAlerts = buckets.reduce((sum, bucket) => sum + bucket.value, 0); + this.statusMeters = STATUS_ORDER.map(serie => { + const bucket = buckets.find(b => b.serie === serie); + return { + label: serie, + value: bucket ? bucket.value : 0, + color: STATUS_COLORS[serie] + }; + }); + this.statusMax = this.statusMeters.reduce((m, d) => m+d.value, 0); } if (changes.severityEntry) { - const buckets = this.severityEntry ? this.severityEntry.data : []; + const buckets = this.severityEntry ? this.severityEntry.data.value : []; this.totalSeverity = buckets.reduce((sum, bucket) => sum + bucket.value, 0); - this.severityOption = this.totalSeverity > 0 - ? this.buildSeverityOption(buckets) - : null; + this.severityMeters = SEVERITY_ORDER.map(serie => { + const bucket = buckets.find(b => b.name === serie); + console.log(bucket,name) + return { + label: serie, + value: bucket ? bucket.value : 0, + color: SEVERITY_COLORS[serie] + }; + }); + this.severityMax = this.severityMeters.reduce((m, d) => Math.max(m, d.value), 0); } } @@ -90,52 +82,4 @@ export class InstanceOverviewCardComponent implements OnChanges { onSelect(route: string): void { this.select.emit(route); } - - private buildStatusOption(entry: CountAlertsByStatusEntry): PieChartOption { - const data: PieDatum[] = entry.data.map(bucket => ({ - name: bucket.serie, - value: bucket.value, - itemStyle: {color: STATUS_COLORS[bucket.serie] || '#6c757d'} - })); - return this.buildPieOption('Status', data, entry.data.map(bucket => bucket.serie)); - } - - private buildSeverityOption(buckets: CountAlertsBySeverityBucket[]): PieChartOption { - const ordered = SEVERITY_ORDER - .map(serie => buckets.find(bucket => bucket.serie === serie)) - .filter((bucket): bucket is CountAlertsBySeverityBucket => !!bucket && bucket.value > 0); - const data: PieDatum[] = ordered.map(bucket => ({ - name: bucket.serie, - value: bucket.value, - itemStyle: {color: SEVERITY_COLORS[bucket.serie] || '#6c757d'} - })); - return this.buildPieOption('Severity', data, ordered.map(bucket => bucket.serie)); - } - - private buildPieOption(title: string, data: PieDatum[], legend: string[]): PieChartOption { - return { - tooltip: {trigger: 'item', formatter: '{b}: {c} ({d}%)'}, - legend: { - orient: 'horizontal', - bottom: 0, - data: legend, - textStyle: {fontSize: 11} - }, - title: { - text: title, - left: 'center', - top: 4, - textStyle: {fontSize: 12, fontWeight: '600'} - }, - series: [{ - name: title, - type: 'pie', - radius: ['45%', '70%'], - center: ['50%', '55%'], - data, - label: {show: false}, - labelLine: {show: false} - }] - }; - } } diff --git a/frontend/src/app/federation/domain/count-alerts-by-severity.model.ts b/frontend/src/app/federation/domain/count-alerts-by-severity.model.ts index 1e78680e5..35ed68c5c 100644 --- a/frontend/src/app/federation/domain/count-alerts-by-severity.model.ts +++ b/frontend/src/app/federation/domain/count-alerts-by-severity.model.ts @@ -1,12 +1,12 @@ export type AlertSeveritySerie = 'Low' | 'Medium' | 'High'; export interface CountAlertsBySeverityBucket { - serie: AlertSeveritySerie; + name: AlertSeveritySerie; value: number; } export interface CountAlertsBySeverityEntry { instanceId: number; instanceName: string; - data: CountAlertsBySeverityBucket[]; + data: {value:CountAlertsBySeverityBucket[]}; } diff --git a/frontend/src/app/federation/federation.module.ts b/frontend/src/app/federation/federation.module.ts index b9957e311..01c04aab0 100644 --- a/frontend/src/app/federation/federation.module.ts +++ b/frontend/src/app/federation/federation.module.ts @@ -5,6 +5,7 @@ import {RouterModule} from '@angular/router'; import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; import {InlineSVGModule} from 'ng-inline-svg'; import {NgxEchartsModule} from 'ngx-echarts'; +import {NgxGaugeModule} from 'ngx-gauge'; import {FederationOverviewGridComponent} from './components/federation-overview-grid/federation-overview-grid.component'; import {FederationSidebarComponent} from './components/federation-sidebar/federation-sidebar.component'; import { @@ -28,7 +29,8 @@ import {WelcomeComponent} from './pages/welcome/welcome.component'; RouterModule, NgbModule, InlineSVGModule, - NgxEchartsModule + NgxEchartsModule, + NgxGaugeModule ], declarations: [ FederationSidebarComponent, diff --git a/frontend/src/app/federation/pages/welcome/welcome.component.html b/frontend/src/app/federation/pages/welcome/welcome.component.html index 3127bf1fc..aef12215c 100644 --- a/frontend/src/app/federation/pages/welcome/welcome.component.html +++ b/frontend/src/app/federation/pages/welcome/welcome.component.html @@ -1,15 +1,24 @@ -
    -
    -
    -

    Federation Overview

    -

    Active instances and alert distribution.

    -
    - + +
    +
    +
    - -
    + +
    +
    +
    + +
    +

    Welcome to UTMStack Federation

    +

    + No instances are connected yet. Connect your first instance to start managing it from this console. +

    + +
    +
    +
    diff --git a/frontend/src/app/federation/pages/welcome/welcome.component.scss b/frontend/src/app/federation/pages/welcome/welcome.component.scss index 5804c2c02..9acbfda40 100644 --- a/frontend/src/app/federation/pages/welcome/welcome.component.scss +++ b/frontend/src/app/federation/pages/welcome/welcome.component.scss @@ -1 +1,5 @@ :host { display: block; } + +.welcome-overview { + padding: 16px; +} diff --git a/frontend/src/app/federation/pages/welcome/welcome.component.ts b/frontend/src/app/federation/pages/welcome/welcome.component.ts index 02c8bebd8..7cc036a34 100644 --- a/frontend/src/app/federation/pages/welcome/welcome.component.ts +++ b/frontend/src/app/federation/pages/welcome/welcome.component.ts @@ -1,6 +1,8 @@ import {Component} from '@angular/core'; import {Router} from '@angular/router'; import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {Observable} from 'rxjs'; +import {map} from 'rxjs/operators'; import {InstanceFormModalComponent} from '../../components/instance-form-modal/instance-form-modal.component'; import {FederationInstance} from '../../domain/federation-instance.model'; import {FederationInstanceStateService} from '../../services/federation-instance-state.service'; @@ -12,6 +14,8 @@ import {FederationInstancesService} from '../../services/federation-instances.se styleUrls: ['./welcome.component.scss'] }) export class WelcomeComponent { + readonly hasInstances$: Observable = this.instanceState.instances$ + .pipe(map(instances => instances.length > 0)); constructor( private modalService: NgbModal, diff --git a/frontend/src/app/federation/services/federation-overview.service.ts b/frontend/src/app/federation/services/federation-overview.service.ts index 8afeb109a..1ebef3a74 100644 --- a/frontend/src/app/federation/services/federation-overview.service.ts +++ b/frontend/src/app/federation/services/federation-overview.service.ts @@ -18,7 +18,7 @@ export class FederationOverviewService { } countAlertsBySeverity(from: string, to: string): Observable { - const params = new HttpParams().set('from', from).set('to', to); + const params = new HttpParams().set('from', from).set('to', to).set('top','100000'); return this.http.get(this.severityEndpoint, {params}); } } From 645fb239af42ac2f24790476eaf65b456cd17507 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Mon, 22 Jun 2026 17:56:28 -0600 Subject: [PATCH 10/36] feat[frontend](federation): fixed overview icons --- .../instance-overview-card.component.html | 3 ++- .../instance-overview-card.component.ts | 2 ++ .../src/app/federation/domain/federation-nav-actions.ts | 8 ++++---- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.html b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.html index 484233149..8e4d48674 100644 --- a/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.html +++ b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.html @@ -49,7 +49,8 @@ *ngFor="let action of actions" (click)="onSelect(action.route)" [title]="action.label"> - + {{ action.label }}
    diff --git a/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.ts b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.ts index d00ae9810..1f0f95e8c 100644 --- a/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.ts +++ b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.ts @@ -5,6 +5,7 @@ import { } from '../../domain/count-alerts-by-severity.model'; import {AlertStatusSerie, CountAlertsByStatusEntry} from '../../domain/count-alerts-by-status.model'; import {FEDERATION_NAV_ACTIONS, NavAction} from '../../domain/federation-nav-actions'; +import {SYSTEM_MENU_ICONS_PATH} from '../../../shared/constants/menu_icons.constants'; interface MeterDatum { label: string; @@ -36,6 +37,7 @@ export class InstanceOverviewCardComponent implements OnChanges { @Input() entry!: CountAlertsByStatusEntry; @Input() severityEntry: CountAlertsBySeverityEntry | null = null; @Output() select = new EventEmitter(); + iconPath=SYSTEM_MENU_ICONS_PATH readonly actions: ReadonlyArray = FEDERATION_NAV_ACTIONS; severityMeters: MeterDatum[] = []; diff --git a/frontend/src/app/federation/domain/federation-nav-actions.ts b/frontend/src/app/federation/domain/federation-nav-actions.ts index e3913686a..14a80b169 100644 --- a/frontend/src/app/federation/domain/federation-nav-actions.ts +++ b/frontend/src/app/federation/domain/federation-nav-actions.ts @@ -5,8 +5,8 @@ export interface NavAction { } export const FEDERATION_NAV_ACTIONS: ReadonlyArray = [ - {label: 'Log Explorer', iconClass: 'icon-search4', route: '/discover/log-analyzer'}, - {label: 'Alert Management', iconClass: 'icon-bell2', route: '/data/alert/view'}, - {label: 'SOAR Flows', iconClass: 'icon-stack-play', route: '/soar/flows'}, - {label: 'Data Sources', iconClass: 'icon-database', route: '/data-sources'} + {label: 'Log Explorer', iconClass: 'ANALYTICS.svg', route: '/discover/log-analyzer'}, + {label: 'Alert Management', iconClass: 'PROMOTION.svg', route: '/data/alert/view'}, + {label: 'SOAR Flows', iconClass: 'VISION.svg', route: '/soar/flows'}, + {label: 'Data Sources', iconClass: 'NETWORK.svg', route: '/data-sources'} ]; From c31a0f5c9f450e645dbef3933510b688eca19809 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Mon, 22 Jun 2026 17:59:55 -0600 Subject: [PATCH 11/36] feat[frontend](federation): setted user options only on federation sidebar --- .../layout/header/header.component.html | 16 ++++++++-------- .../mobile-header/mobile-header.component.html | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/src/app/shared/components/layout/header/header.component.html b/frontend/src/app/shared/components/layout/header/header.component.html index 31f4e5382..79f4c308f 100644 --- a/frontend/src/app/shared/components/layout/header/header.component.html +++ b/frontend/src/app/shared/components/layout/header/header.component.html @@ -24,15 +24,15 @@ -
  • - -
  • +
  • + +
  • - -
  • - -
  • -
    + +
  • + +
  • +
    diff --git a/frontend/src/app/shared/components/layout/header/mobile-header/mobile-header.component.html b/frontend/src/app/shared/components/layout/header/mobile-header/mobile-header.component.html index 2c424f2a9..a6e75ea47 100644 --- a/frontend/src/app/shared/components/layout/header/mobile-header/mobile-header.component.html +++ b/frontend/src/app/shared/components/layout/header/mobile-header/mobile-header.component.html @@ -20,11 +20,11 @@
    S
    -
  • +
  • - +
  • From 43f6794f20f529dba4dd5b6ff95fc6f58d900caf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Mon, 22 Jun 2026 18:11:04 -0600 Subject: [PATCH 12/36] feat[frontend](federation): moved second header to fit federation sidebar --- frontend/src/app/app.component.scss | 4 ++-- .../app/shared/components/layout/header/header.component.html | 3 ++- .../app/shared/components/layout/header/header.component.scss | 4 ++++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/app.component.scss b/frontend/src/app/app.component.scss index 3c649601f..68c36ddc7 100644 --- a/frontend/src/app/app.component.scss +++ b/frontend/src/app/app.component.scss @@ -100,11 +100,11 @@ .side-menu-federation { position: fixed; - top: $navbar-secondary-height+$navbar-primary-height -10px; + top: $navbar-primary-height; left: 0; bottom: 0; width: 60px; - z-index: 999; + z-index: 1031; background-color: $utm-header-color; border-right: 1px solid rgba(255, 255, 255, 0.08); } diff --git a/frontend/src/app/shared/components/layout/header/header.component.html b/frontend/src/app/shared/components/layout/header/header.component.html index 79f4c308f..176c5652e 100644 --- a/frontend/src/app/shared/components/layout/header/header.component.html +++ b/frontend/src/app/shared/components/layout/header/header.component.html @@ -42,7 +42,8 @@ -
    +
    diff --git a/frontend/src/app/shared/components/layout/header/header.component.scss b/frontend/src/app/shared/components/layout/header/header.component.scss index 6bc068532..1dc9a2b99 100644 --- a/frontend/src/app/shared/components/layout/header/header.component.scss +++ b/frontend/src/app/shared/components/layout/header/header.component.scss @@ -181,6 +181,10 @@ overflow-scrolling: auto; } +.utm-navbar-secondary--federation { + padding-left: 60px !important; +} + .dropdown-menu-right { z-index: 999; From 98a7d96770baebeb83c235051bb3c7d5c3ddaf42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Mon, 22 Jun 2026 18:46:09 -0600 Subject: [PATCH 13/36] fix[frontend](federation): removed misplaced letter on federation sidebar --- .../federation-sidebar/federation-sidebar.component.html | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.html b/frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.html index 73a85c0cc..9cc460813 100644 --- a/frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.html +++ b/frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.html @@ -1,5 +1,4 @@
    - asas
    • From b66694c0d35731890fbc27024d40e283ea5ee841 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Tue, 23 Jun 2026 07:11:04 -0600 Subject: [PATCH 14/36] fix[frontend](federation): passed by actions on overview cards instead of instances list --- .../federation-overview-grid.component.html | 21 ++++ .../federation-overview-grid.component.scss | 108 ++++++++++++++++ .../federation-overview-grid.component.ts | 115 +++++++++++++++++- ...ederation-sidebar-instances.component.html | 24 ---- .../federation-sidebar-instances.component.ts | 110 +---------------- .../instance-overview-card.component.html | 4 + .../instance-overview-card.component.scss | 7 ++ .../instance-overview-card.component.ts | 1 - 8 files changed, 256 insertions(+), 134 deletions(-) diff --git a/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.html b/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.html index 9ca5df46f..1152abbcf 100644 --- a/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.html +++ b/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.html @@ -23,6 +23,27 @@ [entry]="entry" [severityEntry]="severityFor(entry)" (select)="onSelect(entry, $event)"> +
      + + +
      + +
      + +
      + +
    diff --git a/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.scss b/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.scss index f1c175dd4..cd12eeac6 100644 --- a/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.scss +++ b/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.scss @@ -47,3 +47,111 @@ @keyframes federation-overview-spin { to { transform: rotate(360deg); } } + + + +.overview-create-card { + cursor:pointer; + position:relative; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 12px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + height: 100%; + min-height: 320px; + + &__icon { + font-size: 48px; + color: #6b7280; + opacity: 0.6; + } + + &__title { + font-weight: 600; + color: #232f3e; + font-size: 14px; + text-align: center; + } + + &__rows { + flex: 1 1 auto; + display: flex; + flex-direction: column; + gap: 10px; + } + + &__row { + display: flex; + flex-direction: column; + gap: 4px; + } + + &__row-label { + font-size: 11px; + font-weight: 600; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + &__row-meters { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + min-width: 0; + justify-items: center; + } + + &__gauge { + display: block; + min-width: 0; + + ::ng-deep .reading-block { + font-size: 14px !important; + font-weight: 600; + } + + ::ng-deep .reading-label { + font-size: 10px !important; + color: #475569; + } + } + + &__empty { + flex: 1 1 auto; + min-height: 220px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #6b7280; + text-align: center; + padding: 16px; + } + + &__empty-title { + font-weight: 600; + color: #232f3e; + font-size: 14px; + margin-bottom: 6px; + } + + &__empty-msg { + font-size: 12px; + color: #9ca3af; + } + + &__nav { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 6px; + border-top: 1px solid #f1f3f5; + padding-top: 10px; + } +} diff --git a/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.ts b/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.ts index 2976f6e15..73855d9bd 100644 --- a/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.ts +++ b/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.ts @@ -7,6 +7,13 @@ import {CountAlertsBySeverityEntry} from '../../domain/count-alerts-by-severity. import {CountAlertsByStatusEntry} from '../../domain/count-alerts-by-status.model'; import {FederationInstanceStateService} from '../../services/federation-instance-state.service'; import {FederationOverviewService} from '../../services/federation-overview.service'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {FederationInstance} from '../../domain/federation-instance.model'; +import {FederationInstancesService} from '../../services/federation-instances.service'; +import {InstanceFormModalComponent} from '../instance-form-modal/instance-form-modal.component'; +import { + ModalConfirmationComponent +} from '../../../shared/components/utm/util/modal-confirmation/modal-confirmation.component'; @Component({ selector: 'app-federation-overview-grid', @@ -18,13 +25,16 @@ export class FederationOverviewGridComponent implements OnInit, OnDestroy { severityByInstance: Record = {}; loading = false; errorMessage: string | null = null; + pendingDeleteId: number | null = null; private destroy$ = new Subject(); constructor( private overviewService: FederationOverviewService, private instanceState: FederationInstanceStateService, - private router: Router + private router: Router, + private instancesService: FederationInstancesService, + private modalService: NgbModal ) {} ngOnInit(): void { @@ -82,4 +92,107 @@ export class FederationOverviewGridComponent implements OnInit, OnDestroy { trackByInstance(_index: number, entry: CountAlertsByStatusEntry): number { return entry.instanceId; } + + + + openCreate(event?: Event): void { + if (event) { + event.stopPropagation(); + } + this.errorMessage = null; + const ref = this.modalService.open(InstanceFormModalComponent, { + centered: true, + backdrop: 'static' + }); + ref.componentInstance.saved.subscribe((instance: FederationInstance) => { + ref.close(); + this.reload(instance.id, true); + }); + } + + openEdit(instance: CountAlertsByStatusEntry, event?: Event): void { + if (event) { + event.stopPropagation(); + } + this.errorMessage = null; + const ref = this.modalService.open(InstanceFormModalComponent, { + centered: true, + backdrop: 'static' + }); + const target = this.instanceState.instances.find(i => i.id === instance.instanceId); + ref.componentInstance.instance = target; + ref.componentInstance.saved.subscribe((updated: FederationInstance) => { + ref.close(); + this.reload(updated.id, false); + }); + } + + remove(instance: CountAlertsByStatusEntry, event?: Event): void { + if (event) { + event.stopPropagation(); + } + if (this.pendingDeleteId !== null) { + return; + } + + const target = this.instanceState.instances.find(i => i.id === instance.instanceId); + if(!target)return + const modalRef = this.modalService.open(ModalConfirmationComponent, {backdrop: 'static', centered: true}); + modalRef.componentInstance.header = 'Remove federation instance'; + modalRef.componentInstance.message = `Are you sure that you want to remove the instance: \n${target.name}?`; + modalRef.componentInstance.confirmBtnText = 'Remove'; + modalRef.componentInstance.confirmBtnIcon = 'icon-cancel-circle2'; + modalRef.componentInstance.confirmBtnType = 'delete'; + modalRef.componentInstance.textDisplay = 'Requests routed to this instance will stop working' + + ' and its locally stored selection will be cleared.'; + modalRef.componentInstance.textType = 'warning'; + modalRef.result.then(() => this.confirmRemove(target), () => undefined); + } + + trackById(_index: number, item: FederationInstance): number { + return item.id; + } + + private confirmRemove(instance: FederationInstance): void { + this.pendingDeleteId = instance.id; + this.errorMessage = null; + this.instancesService.delete(instance.id).subscribe({ + next: () => { + this.pendingDeleteId = null; + this.reload(null, false); + }, + error: err => { + this.pendingDeleteId = null; + this.errorMessage = (err && err.error && err.error.message) + || 'Failed to remove instance.'; + } + }); + } + + private reload(preferredId: number | null, forceSwitch: boolean): void { + this.instancesService.list().subscribe({ + next: items => { + const list = items || []; + this.instanceState.setInstances(list); + if (list.length === 0) { + window.location.reload(); + return; + } + if (preferredId !== null) { + const target = list.find(i => i.id === preferredId); + if (target) { + this.instanceState.setActive(target, false); + return; + } + } + if (forceSwitch) { + window.location.reload(); + } + }, + error: () => { + this.errorMessage = 'Failed to refresh instances.'; + } + }); + } + } diff --git a/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.html b/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.html index 13bd2e6fe..ad9bda263 100644 --- a/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.html +++ b/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.html @@ -31,29 +31,5 @@
    Instances
    {{ instance.name }} -
    - - -
    - -
    - -
    diff --git a/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.ts b/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.ts index 37d609ac2..e7be25f5f 100644 --- a/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.ts +++ b/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.ts @@ -1,15 +1,9 @@ import {Component, OnDestroy, OnInit} from '@angular/core'; -import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; import {Subject} from 'rxjs'; import {takeUntil} from 'rxjs/operators'; -import { - ModalConfirmationComponent -} from '../../../shared/components/utm/util/modal-confirmation/modal-confirmation.component'; import {SYSTEM_MENU_ICONS_PATH} from '../../../shared/constants/menu_icons.constants'; import {FederationInstance} from '../../domain/federation-instance.model'; import {FederationInstanceStateService} from '../../services/federation-instance-state.service'; -import {FederationInstancesService} from '../../services/federation-instances.service'; -import {InstanceFormModalComponent} from '../instance-form-modal/instance-form-modal.component'; @Component({ selector: 'app-federation-sidebar-instances', @@ -25,8 +19,8 @@ export class FederationSidebarInstancesComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); constructor(private instanceState: FederationInstanceStateService, - private instancesService: FederationInstancesService, - private modalService: NgbModal) {} +) {} + ngOnInit(): void { this.instanceState.instances$ @@ -48,106 +42,6 @@ export class FederationSidebarInstancesComponent implements OnInit, OnDestroy { window.location.reload(); } - openCreate(event?: Event): void { - if (event) { - event.stopPropagation(); - } - this.errorMessage = null; - const ref = this.modalService.open(InstanceFormModalComponent, { - centered: true, - backdrop: 'static' - }); - ref.componentInstance.saved.subscribe((instance: FederationInstance) => { - ref.close(); - this.reload(instance.id, true); - }); - } - - openEdit(instance: FederationInstance, event?: Event): void { - if (event) { - event.stopPropagation(); - } - this.errorMessage = null; - const ref = this.modalService.open(InstanceFormModalComponent, { - centered: true, - backdrop: 'static' - }); - ref.componentInstance.instance = instance; - ref.componentInstance.saved.subscribe((updated: FederationInstance) => { - ref.close(); - this.reload(updated.id, false); - }); - } - - remove(instance: FederationInstance, event?: Event): void { - if (event) { - event.stopPropagation(); - } - if (this.pendingDeleteId !== null) { - return; - } - const modalRef = this.modalService.open(ModalConfirmationComponent, {backdrop: 'static', centered: true}); - modalRef.componentInstance.header = 'Remove federation instance'; - modalRef.componentInstance.message = `Are you sure that you want to remove the instance: \n${instance.name}?`; - modalRef.componentInstance.confirmBtnText = 'Remove'; - modalRef.componentInstance.confirmBtnIcon = 'icon-cancel-circle2'; - modalRef.componentInstance.confirmBtnType = 'delete'; - modalRef.componentInstance.textDisplay = 'Requests routed to this instance will stop working' + - ' and its locally stored selection will be cleared.'; - modalRef.componentInstance.textType = 'warning'; - modalRef.result.then(() => this.confirmRemove(instance), () => undefined); - } - - trackById(_index: number, item: FederationInstance): number { - return item.id; - } - - private confirmRemove(instance: FederationInstance): void { - this.pendingDeleteId = instance.id; - this.errorMessage = null; - this.instancesService.delete(instance.id).subscribe({ - next: () => { - this.pendingDeleteId = null; - const wasActive = this.active && this.active.id === instance.id; - this.reload(null, wasActive); - }, - error: err => { - this.pendingDeleteId = null; - this.errorMessage = (err && err.error && err.error.message) - || 'Failed to remove instance.'; - } - }); - } - - private reload(preferredId: number | null, forceSwitch: boolean): void { - this.instancesService.list().subscribe({ - next: items => { - const list = items || []; - const previousActiveId = this.active ? this.active.id : null; - this.instanceState.setInstances(list); - if (list.length === 0) { - window.location.reload(); - return; - } - if (preferredId !== null) { - const target = list.find(i => i.id === preferredId); - if (target) { - this.instanceState.setActive(target, false); - if (forceSwitch || target.id !== previousActiveId) { - window.location.reload(); - } - return; - } - } - if (forceSwitch) { - window.location.reload(); - } - }, - error: () => { - this.errorMessage = 'Failed to refresh instances.'; - } - }); - } ngOnDestroy(): void { this.destroy$.next(); diff --git a/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.html b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.html index 8e4d48674..1a487c7e2 100644 --- a/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.html +++ b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.html @@ -1,6 +1,10 @@
    {{ entry.instanceName }}
    +
    + +
    +
    Severity
    diff --git a/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.scss b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.scss index b37d0e048..67d9ecfe6 100644 --- a/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.scss +++ b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.scss @@ -1,6 +1,13 @@ :host { display: block; height: 100%; } +.overview-card__actions{ + position:absolute; + right:2%; + top:2%; +} + .overview-card { + position:relative; background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; diff --git a/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.ts b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.ts index 1f0f95e8c..d3f5af262 100644 --- a/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.ts +++ b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.ts @@ -66,7 +66,6 @@ export class InstanceOverviewCardComponent implements OnChanges { this.totalSeverity = buckets.reduce((sum, bucket) => sum + bucket.value, 0); this.severityMeters = SEVERITY_ORDER.map(serie => { const bucket = buckets.find(b => b.name === serie); - console.log(bucket,name) return { label: serie, value: bucket ? bucket.value : 0, From 4e85c0acc0d387846e05ce71fdde4ceb59ec67d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Tue, 23 Jun 2026 07:15:56 -0600 Subject: [PATCH 15/36] fix[frontend](federation): removed non related headers on federation mode --- .../components/layout/header/header.component.html | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/shared/components/layout/header/header.component.html b/frontend/src/app/shared/components/layout/header/header.component.html index 176c5652e..d51aa3b0e 100644 --- a/frontend/src/app/shared/components/layout/header/header.component.html +++ b/frontend/src/app/shared/components/layout/header/header.component.html @@ -11,9 +11,21 @@ > + + + + + + + + + + + +
      @@ -28,7 +40,7 @@ - +
    • From f2759305288cd773ad5b049386eaf355c98a91c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Tue, 23 Jun 2026 07:22:35 -0600 Subject: [PATCH 16/36] fix[frontend](federation): removed federation sidebar --- frontend/src/app/app.component.html | 7 +----- frontend/src/app/app.component.scss | 13 +---------- ...ederation-sidebar-instances.component.html | 4 ++-- .../federation-sidebar.component.html | 14 ++++++------ .../federation-sidebar.component.scss | 22 +++++++++++-------- .../layout/header/header.component.html | 5 +++++ .../layout/header/header.component.scss | 10 ++++++++- 7 files changed, 38 insertions(+), 37 deletions(-) diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index 1b4522376..11ad044b3 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -7,17 +7,12 @@
    -
    - -
    - -
    -
    diff --git a/frontend/src/app/app.component.scss b/frontend/src/app/app.component.scss index 68c36ddc7..32a7854aa 100644 --- a/frontend/src/app/app.component.scss +++ b/frontend/src/app/app.component.scss @@ -95,18 +95,7 @@ } .federation-mode { - margin-left: 60px; -} - -.side-menu-federation { - position: fixed; - top: $navbar-primary-height; - left: 0; - bottom: 0; - width: 60px; - z-index: 1031; - background-color: $utm-header-color; - border-right: 1px solid rgba(255, 255, 255, 0.08); + margin-left: 0; } // diff --git a/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.html b/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.html index ad9bda263..b6d7c7741 100644 --- a/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.html +++ b/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.html @@ -1,5 +1,5 @@
    @@ -7,7 +7,7 @@ class="federation-sidebar-link" container="body" [ngbTooltip]="active ? active.name : 'Instances'" - placement="right" + placement="bottom" tooltipClass="utm-tooltip"> diff --git a/frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.html b/frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.html index 9cc460813..8b63b0a79 100644 --- a/frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.html +++ b/frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.html @@ -1,4 +1,4 @@ -
    +
    • @@ -10,7 +10,7 @@ @@ -22,7 +22,7 @@ @@ -36,7 +36,7 @@ @@ -46,7 +46,7 @@ @@ -56,10 +56,10 @@
    -
    + diff --git a/frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.scss b/frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.scss index 62702c764..7bfaafce4 100644 --- a/frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.scss +++ b/frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.scss @@ -4,24 +4,28 @@ .federation-sidebar { width: 100%; height: 100%; - padding: 0.25rem 0; - overflow-y: auto; - overflow-x: hidden; + padding: 0; + overflow: visible; } .federation-sidebar-list { list-style: none; padding: 0; margin: 0; + display: flex; + flex-direction: row; + align-items: center; } .federation-sidebar-footer { - margin-top: auto; + margin-left: auto; } .federation-sidebar-item { - padding: 4px 0; + padding: 0 4px; cursor: pointer; + display: flex; + align-items: center; &:hover { background-color: rgba(255, 255, 255, 0.08); @@ -44,9 +48,8 @@ display: flex; justify-content: center; align-items: center; - width: 100%; height: 100%; - padding: 8px 0; + padding: 8px 10px; &:hover, &:focus { @@ -66,8 +69,9 @@ } .federation-sidebar-divider { - height: 1px; - margin: 6px 12px; + width: 1px; + height: 24px; + margin: 0 8px; background-color: rgba(255, 255, 255, 0.12); list-style: none; } diff --git a/frontend/src/app/shared/components/layout/header/header.component.html b/frontend/src/app/shared/components/layout/header/header.component.html index d51aa3b0e..fb8024260 100644 --- a/frontend/src/app/shared/components/layout/header/header.component.html +++ b/frontend/src/app/shared/components/layout/header/header.component.html @@ -27,6 +27,11 @@
    + + + + +
      -
      diff --git a/frontend/src/app/shared/components/layout/header/header.component.ts b/frontend/src/app/shared/components/layout/header/header.component.ts index 8c0c95074..a599cc399 100644 --- a/frontend/src/app/shared/components/layout/header/header.component.ts +++ b/frontend/src/app/shared/components/layout/header/header.component.ts @@ -1,7 +1,7 @@ import {Component, OnDestroy, OnInit} from '@angular/core'; import {DomSanitizer} from '@angular/platform-browser'; -import {Observable, Subject} from 'rxjs'; -import {filter, takeUntil} from 'rxjs/operators'; +import {Observable, of, Subject} from 'rxjs'; +import {filter, map, takeUntil} from 'rxjs/operators'; import {AccountService} from '../../../../core/auth/account.service'; import {User} from '../../../../core/user/user.model'; import {FederationModeService} from '../../../../federation/services/federation-mode.service'; @@ -10,6 +10,7 @@ import {ADMIN_ROLE} from '../../../constants/global.constant'; import {AppThemeLocationEnum} from '../../../enums/app-theme-location.enum'; import {VersionInfoService} from '../../../services/version/version-info.service'; import {AppVersionInfo} from '../../../types/updates/updates.type'; +import { ActivatedRoute, UrlSegment } from '@angular/router'; @Component({ selector: 'app-header', @@ -26,13 +27,19 @@ export class HeaderComponent implements OnInit, OnDestroy { versionInfo: AppVersionInfo; federationActive$: Observable; destroy$: Subject = new Subject(); + federationRoute=of(false) constructor(private accountService: AccountService, public sanitizer: DomSanitizer, + private route:ActivatedRoute, private themeChangeBehavior: ThemeChangeBehavior, private versionTypeService: VersionInfoService, private federationModeService: FederationModeService) { this.federationActive$ = this.federationModeService.active$; + this.federationRoute = this.route.url.pipe(map(paths=>paths.some(segment=>{ + console.log(segment) + return false + }))) } ngOnInit() { From a7d61bc4c8d884174b4038cf4d1a605294ab3fc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Tue, 23 Jun 2026 07:42:29 -0600 Subject: [PATCH 18/36] fix[frontend](federation): fixed federation instance selection menu --- ...ederation-sidebar-instances.component.html | 48 ++++--- ...ederation-sidebar-instances.component.scss | 130 ++++++++++++------ .../federation-sidebar-instances.component.ts | 3 + .../federation-sidebar.component.html | 7 +- .../domain/federation-instance.model.ts | 1 + .../layout/header/header.component.html | 2 +- .../layout/header/header.component.ts | 25 ++-- 7 files changed, 139 insertions(+), 77 deletions(-) diff --git a/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.html b/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.html index b6d7c7741..f1eaf9c53 100644 --- a/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.html +++ b/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.html @@ -1,16 +1,18 @@
      - - - + class="federation-instance-selector"> +
      Instances
      @@ -21,15 +23,19 @@
      Instances
      No instances connected.
      -
      - -
      + + + {{ instance.version || 'n/a' }} + + +
    diff --git a/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.scss b/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.scss index 7241af072..161fe0c66 100644 --- a/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.scss +++ b/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.scss @@ -1,71 +1,115 @@ -.federation-sidebar-link { - display: flex; - justify-content: center; +@import "src/assets/styles/var"; + +.federation-instance-selector { + display: inline-flex; align-items: center; - width: 100%; height: 100%; - padding: 8px 0; - cursor: pointer; - &:hover, - &:focus { - text-decoration: none; - outline: none; + &__toggle { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 180px; + max-width: 260px; + padding: 6px 10px; + background-color: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 4px; + color: #ffffff; + font-size: 13px; + line-height: 1.2; + cursor: pointer; + transition: background-color 0.15s ease, border-color 0.15s ease; + + &:hover, + &:focus { + background-color: rgba(255, 255, 255, 0.14); + border-color: rgba(255, 255, 255, 0.28); + outline: none; + } + + &::after { + display: none; + } + } + + &__lead { + font-size: 16px; + color: #ffffff; + flex: 0 0 auto; } - ::ng-deep svg { - fill: #ffffff !important; + &__label { + flex: 1 1 auto; + text-align: left; + overflow: hidden; + white-space: nowrap; } - &::after { - display: none; + &__caret { + font-size: 12px; + color: rgba(255, 255, 255, 0.75); + flex: 0 0 auto; + margin-left: auto; } } ::ng-deep .federation-instance-menu { min-width: 260px; + max-width: 320px; + padding: 6px 0; + + .dropdown-header { + font-size: 11px; + letter-spacing: 0.04em; + color: #6b7280; + padding: 6px 12px; + } .federation-instance-row { - cursor: default; - padding-top: 0.35rem; - padding-bottom: 0.35rem; + cursor: pointer; + padding: 8px 12px; + background: transparent; + border: 0; + width: 100%; + text-align: left; + color: #232f3e; + gap: 8px; + + &:hover, + &:focus { + background-color: rgba(35, 47, 62, 0.06); + outline: none; + } &.active { background-color: rgba(35, 47, 62, 0.08); + color: #0a6ebd; + font-weight: 500; } } - .federation-instance-name-btn { - color: inherit; - text-decoration: none; - cursor: pointer; - - &:hover { - text-decoration: none; - } + .federation-instance-row__check { + width: 14px; + font-size: 12px; + flex: 0 0 auto; } - .textActive { - color: #0a6ebd !important; - font-weight: 500; + .federation-instance-row__name { + font-size: 13px; } - .federation-instance-actions { - .btn-link { - color: inherit; - opacity: 0.7; - - &:hover:not(:disabled) { - opacity: 1; - } - - &.text-danger { - color: #dc3545 !important; - } - } + .federation-instance-row__version { + font-size: 11px; + color: #6b7280; + background-color: rgba(35, 47, 62, 0.06); + border-radius: 10px; + padding: 2px 8px; + flex: 0 0 auto; + margin-left: 8px; } - .federation-instance-add { - cursor: pointer; + .invisible { + visibility: hidden; } } diff --git a/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.ts b/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.ts index e7be25f5f..c6ed667ff 100644 --- a/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.ts +++ b/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.ts @@ -42,6 +42,9 @@ export class FederationSidebarInstancesComponent implements OnInit, OnDestroy { window.location.reload(); } + trackById(_index: number, instance: FederationInstance): number { + return instance.id; + } ngOnDestroy(): void { this.destroy$.next(); diff --git a/frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.html b/frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.html index 677dc6972..fc6903267 100644 --- a/frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.html +++ b/frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.html @@ -1,8 +1,5 @@
      -
    • - -
    • + +
    • + +
    diff --git a/frontend/src/app/federation/domain/federation-instance.model.ts b/frontend/src/app/federation/domain/federation-instance.model.ts index 4f5ab9ef2..cdc65e3cf 100644 --- a/frontend/src/app/federation/domain/federation-instance.model.ts +++ b/frontend/src/app/federation/domain/federation-instance.model.ts @@ -5,4 +5,5 @@ export interface FederationInstance { tlsSkipVerify: boolean; createdAt: string; updatedAt: string; + version?: string; } diff --git a/frontend/src/app/shared/components/layout/header/header.component.html b/frontend/src/app/shared/components/layout/header/header.component.html index 728873dde..88813dd73 100644 --- a/frontend/src/app/shared/components/layout/header/header.component.html +++ b/frontend/src/app/shared/components/layout/header/header.component.html @@ -63,7 +63,7 @@ -
    diff --git a/frontend/src/app/shared/components/layout/header/header.component.ts b/frontend/src/app/shared/components/layout/header/header.component.ts index a599cc399..c9c113584 100644 --- a/frontend/src/app/shared/components/layout/header/header.component.ts +++ b/frontend/src/app/shared/components/layout/header/header.component.ts @@ -1,7 +1,8 @@ import {Component, OnDestroy, OnInit} from '@angular/core'; import {DomSanitizer} from '@angular/platform-browser'; -import {Observable, of, Subject} from 'rxjs'; -import {filter, map, takeUntil} from 'rxjs/operators'; +import {NavigationEnd, Router} from '@angular/router'; +import {Observable, Subject} from 'rxjs'; +import {filter, map, startWith, takeUntil} from 'rxjs/operators'; import {AccountService} from '../../../../core/auth/account.service'; import {User} from '../../../../core/user/user.model'; import {FederationModeService} from '../../../../federation/services/federation-mode.service'; @@ -10,7 +11,8 @@ import {ADMIN_ROLE} from '../../../constants/global.constant'; import {AppThemeLocationEnum} from '../../../enums/app-theme-location.enum'; import {VersionInfoService} from '../../../services/version/version-info.service'; import {AppVersionInfo} from '../../../types/updates/updates.type'; -import { ActivatedRoute, UrlSegment } from '@angular/router'; + +const FEDERATION_WELCOME_ROUTE = '/federation/welcome'; @Component({ selector: 'app-header', @@ -26,20 +28,22 @@ export class HeaderComponent implements OnInit, OnDestroy { altImage: string; versionInfo: AppVersionInfo; federationActive$: Observable; + federationWelcomeRoute$: Observable; destroy$: Subject = new Subject(); - federationRoute=of(false) constructor(private accountService: AccountService, public sanitizer: DomSanitizer, - private route:ActivatedRoute, + private router: Router, private themeChangeBehavior: ThemeChangeBehavior, private versionTypeService: VersionInfoService, private federationModeService: FederationModeService) { this.federationActive$ = this.federationModeService.active$; - this.federationRoute = this.route.url.pipe(map(paths=>paths.some(segment=>{ - console.log(segment) - return false - }))) + + this.federationWelcomeRoute$ = this.router.events.pipe( + filter(event => event instanceof NavigationEnd), + map((event: NavigationEnd) => this.isFederationWelcome(event.urlAfterRedirects)), + startWith(this.isFederationWelcome(this.router.url)) + ); } ngOnInit() { @@ -63,4 +67,7 @@ export class HeaderComponent implements OnInit, OnDestroy { this.destroy$.complete(); } + private isFederationWelcome(url: string): boolean { + return url.startsWith(FEDERATION_WELCOME_ROUTE); + } } From 556d07794321a9c24a5899c1cd74897949edf1d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Tue, 23 Jun 2026 07:50:47 -0600 Subject: [PATCH 19/36] fix[frontend](federation): added federation management option on federation instances --- .../federation-overview-grid.component.html | 1 + ...ederation-sidebar-instances.component.html | 9 ++++++ ...ederation-sidebar-instances.component.scss | 32 +++++++++++++++++++ .../federation-sidebar-instances.component.ts | 10 +++++- 4 files changed, 51 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.html b/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.html index 1152abbcf..94a220440 100644 --- a/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.html +++ b/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.html @@ -42,6 +42,7 @@
    + add a new instance
    diff --git a/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.html b/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.html index f1eaf9c53..05ceb7322 100644 --- a/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.html +++ b/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.html @@ -37,5 +37,14 @@
    Instances
    {{ instance.version || 'n/a' }} + +
    + +
    diff --git a/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.scss b/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.scss index 161fe0c66..1708006d8 100644 --- a/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.scss +++ b/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.scss @@ -112,4 +112,36 @@ .invisible { visibility: hidden; } + + .dropdown-divider { + margin: 6px 0; + } + + .federation-instance-manage { + cursor: pointer; + padding: 8px 12px; + background: transparent; + border: 0; + width: 100%; + text-align: left; + color: #232f3e; + gap: 8px; + font-weight: 500; + + &:hover, + &:focus { + background-color: rgba(35, 47, 62, 0.06); + outline: none; + } + } + + .federation-instance-manage__icon { + font-size: 14px; + flex: 0 0 auto; + color: #475569; + } + + .federation-instance-manage__label { + font-size: 13px; + } } diff --git a/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.ts b/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.ts index c6ed667ff..c1ae4130f 100644 --- a/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.ts +++ b/frontend/src/app/federation/components/federation-sidebar-instances/federation-sidebar-instances.component.ts @@ -1,4 +1,5 @@ import {Component, OnDestroy, OnInit} from '@angular/core'; +import {Router} from '@angular/router'; import {Subject} from 'rxjs'; import {takeUntil} from 'rxjs/operators'; import {SYSTEM_MENU_ICONS_PATH} from '../../../shared/constants/menu_icons.constants'; @@ -19,7 +20,7 @@ export class FederationSidebarInstancesComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); constructor(private instanceState: FederationInstanceStateService, -) {} + private router: Router) {} ngOnInit(): void { @@ -46,6 +47,13 @@ export class FederationSidebarInstancesComponent implements OnInit, OnDestroy { return instance.id; } + manageInstances(event?: Event): void { + if (event) { + event.stopPropagation(); + } + this.router.navigate(['/federation/welcome']); + } + ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); From 9de2d98daecd9466943ae955286c53a15ea8b441 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Tue, 23 Jun 2026 07:51:39 -0600 Subject: [PATCH 20/36] fix[frontend](federation): removed federation welcome navigation button --- .../federation-sidebar.component.html | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.html b/frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.html index fc6903267..89bd498cb 100644 --- a/frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.html +++ b/frontend/src/app/federation/components/federation-sidebar/federation-sidebar.component.html @@ -1,18 +1,6 @@
      -
    • - - - -
    • -
    • From 951719afb6154f788c08e23b42a9fec8a0c22c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Tue, 23 Jun 2026 07:53:38 -0600 Subject: [PATCH 21/36] fix[frontend](federation): removed secondary sidebar if is not in federation welcome page --- .../shared/components/layout/header/header.component.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/shared/components/layout/header/header.component.ts b/frontend/src/app/shared/components/layout/header/header.component.ts index c9c113584..4d4310148 100644 --- a/frontend/src/app/shared/components/layout/header/header.component.ts +++ b/frontend/src/app/shared/components/layout/header/header.component.ts @@ -39,14 +39,17 @@ export class HeaderComponent implements OnInit, OnDestroy { private federationModeService: FederationModeService) { this.federationActive$ = this.federationModeService.active$; + } + + ngOnInit() { + this.federationWelcomeRoute$ = this.router.events.pipe( filter(event => event instanceof NavigationEnd), map((event: NavigationEnd) => this.isFederationWelcome(event.urlAfterRedirects)), startWith(this.isFederationWelcome(this.router.url)) ); - } - ngOnInit() { + this.themeChangeBehavior.$themeNavbarIcon .pipe( takeUntil(this.destroy$), From 1c7b58ac938b941aeb52b77bad904a8ccbca4264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Tue, 23 Jun 2026 07:54:47 -0600 Subject: [PATCH 22/36] fix[frontend](federation):separated notifications and user actions button --- .../app/shared/components/layout/header/header.component.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/shared/components/layout/header/header.component.html b/frontend/src/app/shared/components/layout/header/header.component.html index 88813dd73..6aa33aa24 100644 --- a/frontend/src/app/shared/components/layout/header/header.component.html +++ b/frontend/src/app/shared/components/layout/header/header.component.html @@ -37,7 +37,8 @@ -
    • + +
    • From 6a2e609caa34f56856bbb68d1d0c4e00c3a73c72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Tue, 23 Jun 2026 08:07:28 -0600 Subject: [PATCH 23/36] fix[frontend](federation): fixed overview card components --- .../instance-overview-card.component.html | 16 +++++++---- .../instance-overview-card.component.scss | 27 +++++++++++++++++++ .../instance-overview-card.component.ts | 4 +-- .../domain/federation-nav-actions.ts | 9 ++++--- 4 files changed, 45 insertions(+), 11 deletions(-) diff --git a/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.html b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.html index 1a487c7e2..f90286ea8 100644 --- a/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.html +++ b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.html @@ -16,10 +16,10 @@ [label]="meter.label" [max]="severityMax || 1" [min]="0" - [thick]="6" + [thick]="3" + [size]="80" type="full" [value]="meter.value" - [size]="70" class="overview-card__gauge">
    @@ -29,13 +29,13 @@
    @@ -53,9 +53,15 @@ *ngFor="let action of actions" (click)="onSelect(action.route)" [title]="action.label"> + - {{ action.label }} + + {{ totalAlerts }} + +
    diff --git a/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.scss b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.scss index 67d9ecfe6..c52a4818d 100644 --- a/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.scss +++ b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.scss @@ -118,6 +118,7 @@ font-size: 10px; transition: background 0.15s, color 0.15s, border-color 0.15s; min-width: 0; + position: relative; i { font-size: 18px; @@ -130,6 +131,32 @@ } } +.nav-icon__graphic { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.nav-icon__badge { + position: absolute; + top: -6px; + right: -10px; + min-width: 16px; + height: 16px; + padding: 0 4px; + background-color: #dc3545; + color: #ffffff; + font-size: 4px; + font-weight: 600; + line-height: 16px; + text-align: center; + transform: translateX(50%); + border-radius: 8px; + box-shadow: 0 0 0 2px #ffffff; + white-space: nowrap; +} + .nav-icon__label { line-height: 1.1; white-space: nowrap; diff --git a/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.ts b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.ts index d3f5af262..ab38540c8 100644 --- a/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.ts +++ b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.ts @@ -14,9 +14,9 @@ interface MeterDatum { } const STATUS_COLORS: Record = { - 'Open': '#dc3545', + 'Open': '#28a745', 'In review': '#ffc107', - 'Completed': '#28a745' + 'Completed': '#17a2b8' }; const SEVERITY_COLORS: Record = { diff --git a/frontend/src/app/federation/domain/federation-nav-actions.ts b/frontend/src/app/federation/domain/federation-nav-actions.ts index 14a80b169..650a1de8d 100644 --- a/frontend/src/app/federation/domain/federation-nav-actions.ts +++ b/frontend/src/app/federation/domain/federation-nav-actions.ts @@ -2,11 +2,12 @@ export interface NavAction { readonly label: string; readonly iconClass: string; readonly route: string; + readonly counter: boolean } export const FEDERATION_NAV_ACTIONS: ReadonlyArray = [ - {label: 'Log Explorer', iconClass: 'ANALYTICS.svg', route: '/discover/log-analyzer'}, - {label: 'Alert Management', iconClass: 'PROMOTION.svg', route: '/data/alert/view'}, - {label: 'SOAR Flows', iconClass: 'VISION.svg', route: '/soar/flows'}, - {label: 'Data Sources', iconClass: 'NETWORK.svg', route: '/data-sources'} + {label: 'Alert Management', iconClass: 'PROMOTION.svg', route: '/data/alert/view', counter:true}, + {label: 'Log Explorer', iconClass: 'ANALYTICS.svg', route: '/discover/log-analyzer',counter:false}, + {label: 'SOAR Flows', iconClass: 'VISION.svg', route: '/soar/flows',counter:false}, + {label: 'Data Sources', iconClass: 'NETWORK.svg', route: '/data-sources',counter:false} ]; From c06f33ac3f987f2e8de54a2819fe7bf2c20bb000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Tue, 23 Jun 2026 08:23:07 -0600 Subject: [PATCH 24/36] fix[frontend](federation): added password validation to have 8 characters min --- .../password/password.component.html | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/shared/components/layout/header/shared/notification/utm-notification-user-setting/password/password.component.html b/frontend/src/app/shared/components/layout/header/shared/notification/utm-notification-user-setting/password/password.component.html index 71605d676..e4b1afd44 100644 --- a/frontend/src/app/shared/components/layout/header/shared/notification/utm-notification-user-setting/password/password.component.html +++ b/frontend/src/app/shared/components/layout/header/shared/notification/utm-notification-user-setting/password/password.component.html @@ -26,7 +26,7 @@
    - Your password is required to be at least 4 characters. + Your password is required to be at least 8 characters. @@ -48,7 +48,8 @@
    - Your confirmation password is required to be at least 4 characters. + Your confirmation password is required to be at least 8 characters. From 2287c957382224deba137dce5409708938c5614d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Tue, 23 Jun 2026 10:14:30 -0600 Subject: [PATCH 25/36] fix[frontend](federation): added needed query param on alert overview navigation --- frontend/src/app/federation/domain/federation-nav-actions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/federation/domain/federation-nav-actions.ts b/frontend/src/app/federation/domain/federation-nav-actions.ts index 650a1de8d..0c3bc57b5 100644 --- a/frontend/src/app/federation/domain/federation-nav-actions.ts +++ b/frontend/src/app/federation/domain/federation-nav-actions.ts @@ -6,7 +6,7 @@ export interface NavAction { } export const FEDERATION_NAV_ACTIONS: ReadonlyArray = [ - {label: 'Alert Management', iconClass: 'PROMOTION.svg', route: '/data/alert/view', counter:true}, + {label: 'Alert Management', iconClass: 'PROMOTION.svg', route: '/data/alert/view?alertType=ALERT', counter:true}, {label: 'Log Explorer', iconClass: 'ANALYTICS.svg', route: '/discover/log-analyzer',counter:false}, {label: 'SOAR Flows', iconClass: 'VISION.svg', route: '/soar/flows',counter:false}, {label: 'Data Sources', iconClass: 'NETWORK.svg', route: '/data-sources',counter:false} From e45e43ead2719f3d4ee15b40aec5fd53799f3d42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Tue, 23 Jun 2026 10:44:44 -0600 Subject: [PATCH 26/36] fix[frontend](federation): fixed overview redirection --- .../federation-overview-grid.component.ts | 4 +++- .../instance-overview-card.component.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.ts b/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.ts index 73855d9bd..4335a0eb1 100644 --- a/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.ts +++ b/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.ts @@ -86,7 +86,8 @@ export class FederationOverviewGridComponent implements OnInit, OnDestroy { if (target) { this.instanceState.setActive(target, true); } - this.router.navigate([route]); + + this.router.navigateByUrl(route); } trackByInstance(_index: number, entry: CountAlertsByStatusEntry): number { @@ -188,6 +189,7 @@ export class FederationOverviewGridComponent implements OnInit, OnDestroy { if (forceSwitch) { window.location.reload(); } + this.loadData(); }, error: () => { this.errorMessage = 'Failed to refresh instances.'; diff --git a/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.ts b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.ts index ab38540c8..6228b3459 100644 --- a/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.ts +++ b/frontend/src/app/federation/components/instance-overview-card/instance-overview-card.component.ts @@ -62,7 +62,7 @@ export class InstanceOverviewCardComponent implements OnChanges { this.statusMax = this.statusMeters.reduce((m, d) => m+d.value, 0); } if (changes.severityEntry) { - const buckets = this.severityEntry ? this.severityEntry.data.value : []; + const buckets = this.severityEntry && this.severityEntry.data ? this.severityEntry.data.value : []; this.totalSeverity = buckets.reduce((sum, bucket) => sum + bucket.value, 0); this.severityMeters = SEVERITY_ORDER.map(serie => { const bucket = buckets.find(b => b.name === serie); From d1af47ac1a500b2e5c4fd91013ec466a038ad7bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Tue, 23 Jun 2026 10:54:08 -0600 Subject: [PATCH 27/36] fix[frontend](federation): fixed new instance reload on create --- .../federation-overview-grid.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.ts b/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.ts index 4335a0eb1..18acd9aa8 100644 --- a/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.ts +++ b/frontend/src/app/federation/components/federation-overview-grid/federation-overview-grid.component.ts @@ -179,6 +179,7 @@ export class FederationOverviewGridComponent implements OnInit, OnDestroy { window.location.reload(); return; } + this.loadData(); if (preferredId !== null) { const target = list.find(i => i.id === preferredId); if (target) { @@ -189,7 +190,6 @@ export class FederationOverviewGridComponent implements OnInit, OnDestroy { if (forceSwitch) { window.location.reload(); } - this.loadData(); }, error: () => { this.errorMessage = 'Failed to refresh instances.'; From 932f6dc3aa2097fc399cb2c6a75b5a93b0289180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Tue, 23 Jun 2026 12:07:04 -0600 Subject: [PATCH 28/36] fix[frontend](federation): added email configuration for federation use --- frontend/src/app/app-routing.module.ts | 11 +- .../federation-user-menu.component.html | 4 + .../federation-user-menu.component.ts | 4 + .../federation-email-config-params.const.ts | 129 ++++++++++++++++++ ...ederation-email-config.page.component.html | 7 + ...ederation-email-config.page.component.scss | 3 + .../federation-email-config.page.component.ts | 15 ++ .../app-config-sections.component.ts | 40 +++--- frontend/src/app/shared/utm-shared.module.ts | 4 + 9 files changed, 200 insertions(+), 17 deletions(-) create mode 100644 frontend/src/app/federation/pages/email-config/federation-email-config-params.const.ts create mode 100644 frontend/src/app/federation/pages/email-config/federation-email-config.page.component.html create mode 100644 frontend/src/app/federation/pages/email-config/federation-email-config.page.component.scss create mode 100644 frontend/src/app/federation/pages/email-config/federation-email-config.page.component.ts diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 332a4f5bd..a6d60c3b1 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -1,6 +1,9 @@ import {NgModule} from '@angular/core'; import {RouterModule, Routes} from '@angular/router'; import {UserRouteAccessService} from './core/auth/user-route-access-service'; +import { + FederationEmailConfigPageComponent +} from './federation/pages/email-config/federation-email-config.page.component'; import {TeamMembersPageComponent} from './federation/pages/team-members/team-members.page.component'; import {WelcomeComponent as FederationWelcomeComponent} from './federation/pages/welcome/welcome.component'; import {ConfirmIdentityComponent} from './shared/components/auth/confirm-identity/confirm-identity.component'; @@ -162,7 +165,13 @@ const routes: Routes = [ {path: '', redirectTo: 'welcome', pathMatch: 'full'}, {path: 'welcome', component: FederationWelcomeComponent}, {path: 'instances', component: FederationWelcomeComponent}, - {path: 'team-members', component: TeamMembersPageComponent} + {path: 'team-members', component: TeamMembersPageComponent}, + { + path: 'email-config', + component: FederationEmailConfigPageComponent, + canActivate: [UserRouteAccessService], + data: {authorities: [ADMIN_ROLE]} + } ] }, {path: '', component: LoginComponent}, diff --git a/frontend/src/app/federation/components/federation-user-menu/federation-user-menu.component.html b/frontend/src/app/federation/components/federation-user-menu/federation-user-menu.component.html index 0c3e6dbb0..129948c51 100644 --- a/frontend/src/app/federation/components/federation-user-menu/federation-user-menu.component.html +++ b/frontend/src/app/federation/components/federation-user-menu/federation-user-menu.component.html @@ -20,6 +20,10 @@ Team Members + + + Email Configuration + Change Password diff --git a/frontend/src/app/federation/components/federation-user-menu/federation-user-menu.component.ts b/frontend/src/app/federation/components/federation-user-menu/federation-user-menu.component.ts index de3121c7f..e1d6dbecc 100644 --- a/frontend/src/app/federation/components/federation-user-menu/federation-user-menu.component.ts +++ b/frontend/src/app/federation/components/federation-user-menu/federation-user-menu.component.ts @@ -23,6 +23,10 @@ export class FederationUserMenuComponent { this.router.navigate(['/federation/team-members']); } + goToEmailConfig(): void { + this.router.navigate(['/federation/email-config']); + } + openProfileSettings(): void { this.modalService.open(SettingsComponent, {centered: true}); } diff --git a/frontend/src/app/federation/pages/email-config/federation-email-config-params.const.ts b/frontend/src/app/federation/pages/email-config/federation-email-config-params.const.ts new file mode 100644 index 000000000..78d67db73 --- /dev/null +++ b/frontend/src/app/federation/pages/email-config/federation-email-config-params.const.ts @@ -0,0 +1,129 @@ +import { + ConfigDataTypeEnum, + SectionConfigParamType +} from '../../../shared/types/configuration/section-config-param.type'; +import {SectionConfigType} from '../../../shared/types/configuration/section-config.type'; + +const EMAIL_SECTION = ({ + id: 2, + section: 'email', + shortName: 'EMAIL', + description: 'Here you can configure all necessary parameters for email notifications' +} as unknown) as SectionConfigType; + +export const FEDERATION_EMAIL_CONFIG_SECTION: SectionConfigType = EMAIL_SECTION; + +export const FEDERATION_EMAIL_CONFIG_PARAMS: SectionConfigParamType[] = ([ + { + id: 4, + sectionId: 2, + confParamShort: 'utmstack.mail.password', + confParamLarge: 'Mail Server Password', + confParamDescription: 'Login password of the SMTP server', + confParamValue: null, + confParamRegexp: null, + confParamRequired: true, + confParamDatatype: ConfigDataTypeEnum.Password, + confParamRestartRequired: false, + confParamOption: null, + section: EMAIL_SECTION + }, + { + id: 5, + sectionId: 2, + confParamShort: 'utmstack.mail.from', + confParamLarge: 'Utmstack email address', + confParamDescription: 'Address from which emails are sent', + confParamValue: null, + confParamRegexp: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$', + confParamRequired: true, + confParamDatatype: ConfigDataTypeEnum.Email, + confParamRestartRequired: false, + confParamOption: null, + section: EMAIL_SECTION + }, + { + id: 6, + sectionId: 2, + confParamShort: 'utmstack.mail.baseUrl', + confParamLarge: 'Utmstack base url', + confParamDescription: 'Base url of Utmstack', + confParamValue: 'https://v11dev2.utmstack.com', + confParamRegexp: '^(?:https?:\\/\\/)?(?:\\d{1,3}(?:\\.\\d{1,3}){3}|(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,})(?:\\/[^\\s]*)?$', + confParamRequired: true, + confParamDatatype: ConfigDataTypeEnum.Text, + confParamRestartRequired: false, + confParamOption: null, + section: EMAIL_SECTION + }, + { + id: 7, + sectionId: 2, + confParamShort: 'utmstack.mail.host', + confParamLarge: 'Mail Server Host', + confParamDescription: 'SMTP server host. For instance, `smtp.example.com`.', + confParamValue: 'example.hostmail.com', + confParamRegexp: '^(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,}|(?:[0-9]{1,3}\\.){3}[0-9]{1,3}$', + confParamRequired: true, + confParamDatatype: ConfigDataTypeEnum.Text, + confParamRestartRequired: false, + confParamOption: null, + section: EMAIL_SECTION + }, + { + id: 8, + sectionId: 2, + confParamShort: 'utmstack.mail.port', + confParamLarge: 'Mail Server Port', + confParamDescription: 'SMTP server port', + confParamValue: '587', + confParamRegexp: null, + confParamRequired: true, + confParamDatatype: ConfigDataTypeEnum.Number, + confParamRestartRequired: false, + confParamOption: null, + section: EMAIL_SECTION + }, + { + id: 9, + sectionId: 2, + confParamShort: 'utmstack.mail.username', + confParamLarge: 'Mail Server Username', + confParamDescription: 'Login user of the SMTP server', + confParamValue: null, + confParamRegexp: null, + confParamRequired: true, + confParamDatatype: ConfigDataTypeEnum.Text, + confParamRestartRequired: false, + confParamOption: null, + section: EMAIL_SECTION + }, + { + id: 10, + sectionId: 2, + confParamShort: 'utmstack.mail.organization', + confParamLarge: 'Organization Name', + confParamDescription: 'This field helps identify the organization name in incident and alert notification emails.', + confParamValue: '', + confParamRegexp: null, + confParamRequired: false, + confParamDatatype: ConfigDataTypeEnum.Text, + confParamRestartRequired: false, + confParamOption: null, + section: EMAIL_SECTION + }, + { + id: 13, + sectionId: 2, + confParamShort: 'utmstack.mail.properties.mail.smtp.auth', + confParamLarge: 'Encryption type', + confParamDescription: 'Select the encryption type used by the SMTP server', + confParamValue: 'STARTTLS', + confParamRegexp: null, + confParamRequired: true, + confParamDatatype: ConfigDataTypeEnum.Radio, + confParamRestartRequired: false, + confParamOption: 'STARTTLS,SSL/TLS,NONE', + section: EMAIL_SECTION + } +] as unknown) as SectionConfigParamType[]; diff --git a/frontend/src/app/federation/pages/email-config/federation-email-config.page.component.html b/frontend/src/app/federation/pages/email-config/federation-email-config.page.component.html new file mode 100644 index 000000000..94c85d0fa --- /dev/null +++ b/frontend/src/app/federation/pages/email-config/federation-email-config.page.component.html @@ -0,0 +1,7 @@ +
    +
    +

    Email Configuration

    +

    Configure SMTP parameters used for federation notifications.

    +
    + +
    diff --git a/frontend/src/app/federation/pages/email-config/federation-email-config.page.component.scss b/frontend/src/app/federation/pages/email-config/federation-email-config.page.component.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/frontend/src/app/federation/pages/email-config/federation-email-config.page.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/frontend/src/app/federation/pages/email-config/federation-email-config.page.component.ts b/frontend/src/app/federation/pages/email-config/federation-email-config.page.component.ts new file mode 100644 index 000000000..c2058d139 --- /dev/null +++ b/frontend/src/app/federation/pages/email-config/federation-email-config.page.component.ts @@ -0,0 +1,15 @@ +import {Component} from '@angular/core'; +import { + FEDERATION_EMAIL_CONFIG_PARAMS, + FEDERATION_EMAIL_CONFIG_SECTION +} from './federation-email-config-params.const'; + +@Component({ + selector: 'app-federation-email-config-page', + templateUrl: './federation-email-config.page.component.html', + styleUrls: ['./federation-email-config.page.component.scss'] +}) +export class FederationEmailConfigPageComponent { + readonly section = FEDERATION_EMAIL_CONFIG_SECTION; + readonly params = FEDERATION_EMAIL_CONFIG_PARAMS; +} diff --git a/frontend/src/app/shared/components/utm/config/app-config-sections/app-config-sections.component.ts b/frontend/src/app/shared/components/utm/config/app-config-sections/app-config-sections.component.ts index f9e6fda22..3ea823229 100644 --- a/frontend/src/app/shared/components/utm/config/app-config-sections/app-config-sections.component.ts +++ b/frontend/src/app/shared/components/utm/config/app-config-sections/app-config-sections.component.ts @@ -29,6 +29,7 @@ import {AppConfigDeleteConfirmComponent} from '../app-config-delete-confirm/app- export class AppConfigSectionsComponent implements OnInit, OnDestroy { @Input() section: SectionConfigType; + @Input() params?: SectionConfigParamType[]; @Output() validConfigSection = new EventEmitter(); @Input() allowDeleteSection = false; @Output() changesApplied = new EventEmitter(); @@ -66,7 +67,12 @@ export class AppConfigSectionsComponent implements OnInit, OnDestroy { ngOnInit() { - this.getConfigurations(); + if (this.params) { + this.applyConfigs(this.params); + this.loading = false; + } else { + this.getConfigurations(); + } this.changesApplied.emit(true); this.networkService.isOnline$ @@ -89,26 +95,28 @@ export class AppConfigSectionsComponent implements OnInit, OnDestroy { }) .subscribe(response => { this.loading = false; - this.configs = response.body; - - this.configs = this.configs.map(conf => { - if(conf.confParamDatatype === ConfigDataTypeEnum.Cron) { - conf.confParamValue = JSON.parse(conf.confParamValue); - } - return conf; - }); - - const countryList = this.configs.find(conf => conf.confParamDatatype === ConfigDataTypeEnum.CountryList); - if (countryList) { - this.loadSelectOptions(this.getName(countryList.confParamShort)); - } - this.configToSave=this.configs - this.validConfigSection.emit(this.checkConfigValid()); + this.applyConfigs(response.body); }, error => { this.toastService.showError('Error', 'Error getting application configurations'); }); } + private applyConfigs(params: SectionConfigParamType[]) { + this.configs = params.map(conf => { + if (conf.confParamDatatype === ConfigDataTypeEnum.Cron) { + conf.confParamValue = JSON.parse(conf.confParamValue); + } + return conf; + }); + + const countryList = this.configs.find(conf => conf.confParamDatatype === ConfigDataTypeEnum.CountryList); + if (countryList) { + this.loadSelectOptions(this.getName(countryList.confParamShort)); + } + this.configToSave = this.configs; + this.validConfigSection.emit(this.checkConfigValid()); + } + saveConfig() { this.checkedEmailConfig(false); this.saving = true; diff --git a/frontend/src/app/shared/utm-shared.module.ts b/frontend/src/app/shared/utm-shared.module.ts index fccdd5561..fe499fc14 100644 --- a/frontend/src/app/shared/utm-shared.module.ts +++ b/frontend/src/app/shared/utm-shared.module.ts @@ -118,6 +118,9 @@ import { } from './components/utm/config/app-config-delete-confirm/app-config-delete-confirm.component'; import {AppConfigParamsComponent} from './components/utm/config/app-config-params/app-config-params.component'; import {AppConfigSectionsComponent} from './components/utm/config/app-config-sections/app-config-sections.component'; +import { + FederationEmailConfigPageComponent +} from '../federation/pages/email-config/federation-email-config.page.component'; import { AppModuleDisabledWarningComponent } from './components/utm/config/app-module-disabled-warning/app-module-disabled-warning.component'; @@ -348,6 +351,7 @@ import {ScheduleConfigComponent} from './components/schedule-config/schedule-con GenericFilerSortComponent, AppConfigParamsComponent, AppConfigSectionsComponent, + FederationEmailConfigPageComponent, AppModuleDisabledWarningComponent, AppConfigDeleteConfirmComponent, UtmScrollTopComponent, From 0806bf2ccefcd8a8baa14e50cfbc5738b6ce8c82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Tue, 23 Jun 2026 12:07:04 -0600 Subject: [PATCH 29/36] fix[frontend](federation): added email configuration for federation use From 49245f845c3ebd9ef70a7b3f5523312865146831 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Tue, 23 Jun 2026 13:41:31 -0600 Subject: [PATCH 30/36] fix[frontend](federation): added email configuration initial parameters load --- .../federation-email-config.page.component.ts | 47 +++++++++++++++++-- .../app-config-sections.component.html | 1 + .../section-config-param.type.ts | 3 +- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/federation/pages/email-config/federation-email-config.page.component.ts b/frontend/src/app/federation/pages/email-config/federation-email-config.page.component.ts index c2058d139..e56b735e7 100644 --- a/frontend/src/app/federation/pages/email-config/federation-email-config.page.component.ts +++ b/frontend/src/app/federation/pages/email-config/federation-email-config.page.component.ts @@ -1,4 +1,6 @@ -import {Component} from '@angular/core'; +import {Component, OnInit} from '@angular/core'; +import {UtmConfigParamsService} from '../../../shared/services/config/utm-config-params.service'; +import {SectionConfigParamType} from '../../../shared/types/configuration/section-config-param.type'; import { FEDERATION_EMAIL_CONFIG_PARAMS, FEDERATION_EMAIL_CONFIG_SECTION @@ -9,7 +11,46 @@ import { templateUrl: './federation-email-config.page.component.html', styleUrls: ['./federation-email-config.page.component.scss'] }) -export class FederationEmailConfigPageComponent { +export class FederationEmailConfigPageComponent implements OnInit { readonly section = FEDERATION_EMAIL_CONFIG_SECTION; - readonly params = FEDERATION_EMAIL_CONFIG_PARAMS; + readonly params: SectionConfigParamType[] = FEDERATION_EMAIL_CONFIG_PARAMS.map(p => ({...p})); + + constructor(private paramsService: UtmConfigParamsService) {} + + ngOnInit(): void { + this.paramsService.query({ + page: 0, + size: 10000, + 'sectionId.equals': this.section.id, + sort: 'id,asc' + }).subscribe(response => { + const byShort = new Map(); + (response.body || []).forEach(p => byShort.set(p.confParamShort, p)); + this.params.forEach(p => { + const server = byShort.get(p.confParamShort); + if (server) { + p.id = server.id; + p.confParamValue = server.confParamValue; + } + }); + this.maskPasswordIfPreviouslySet(); + }); + } + + private maskPasswordIfPreviouslySet(): void { + const password = this.params.find(p => p.confParamShort === 'utmstack.mail.password'); + if (!password || !this.isEmpty(password.confParamValue)) { + return; + } + const others = this.params.filter(p => p !== password); + const allOthersFilled = others.length > 0 && others.every(p => !this.isEmpty(p.confParamValue)); + if (allOthersFilled) { + password.confParamPlaceholder = '••••••••'; + password.confParamRequired = false; + } + } + + private isEmpty(value: unknown): boolean { + return value === null || value === undefined || value === ''; + } } diff --git a/frontend/src/app/shared/components/utm/config/app-config-sections/app-config-sections.component.html b/frontend/src/app/shared/components/utm/config/app-config-sections/app-config-sections.component.html index d48caf357..240320ed9 100644 --- a/frontend/src/app/shared/components/utm/config/app-config-sections/app-config-sections.component.html +++ b/frontend/src/app/shared/components/utm/config/app-config-sections/app-config-sections.component.html @@ -82,6 +82,7 @@
    {{section.section | titlecase}}
    [id]="'sectionParam'+conf.id" [required]="conf.confParamRequired" [disabled]="checkDisable(conf)" + [placeholder]="conf.confParamPlaceholder || ''" [type]="conf.confParamDatatype" class="form-control pr-5"> Date: Wed, 24 Jun 2026 07:02:42 -0600 Subject: [PATCH 31/36] fix[frontend](federation/email_conf): removed route guard on email configuration --- frontend/src/app/app-routing.module.ts | 1 - frontend/src/app/core/auth/account.service.ts | 1 - .../header-menu-navigation.component.ts | 7 ++++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index a6d60c3b1..ad1b2e603 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -169,7 +169,6 @@ const routes: Routes = [ { path: 'email-config', component: FederationEmailConfigPageComponent, - canActivate: [UserRouteAccessService], data: {authorities: [ADMIN_ROLE]} } ] diff --git a/frontend/src/app/core/auth/account.service.ts b/frontend/src/app/core/auth/account.service.ts index 7674d5f03..0771dafd6 100644 --- a/frontend/src/app/core/auth/account.service.ts +++ b/frontend/src/app/core/auth/account.service.ts @@ -188,7 +188,6 @@ export class AccountService { } const redirectTo = (account.authorities.includes(ADMIN_ROLE) && account.email === ADMIN_DEFAULT_EMAIL) ? '/getting-started' : !!path ? path : '/dashboard/overview'; - console.log(redirectTo); this.router.navigate([redirectTo], {queryParams}) .then(() => this.spinner.hide()); } else { diff --git a/frontend/src/app/shared/components/layout/header/header-menu-navigation/header-menu-navigation.component.ts b/frontend/src/app/shared/components/layout/header/header-menu-navigation/header-menu-navigation.component.ts index 3099379c5..d171e15e0 100644 --- a/frontend/src/app/shared/components/layout/header/header-menu-navigation/header-menu-navigation.component.ts +++ b/frontend/src/app/shared/components/layout/header/header-menu-navigation/header-menu-navigation.component.ts @@ -11,6 +11,7 @@ import {stringParamToQueryParams} from '../../../../util/query-params-to-filter. import {filter, takeUntil, tap} from 'rxjs/operators'; import {OpenAlertsService} from '../../../../../data-management/alert-management/shared/services/open-alerts.service'; import {UtmToastService} from '../../../../alert/utm-toast.service'; +import {FederationModeService} from '../../../../../federation/services/federation-mode.service'; @Component({ selector: 'app-header-menu-navigation', @@ -32,7 +33,8 @@ export class HeaderMenuNavigationComponent implements OnInit, OnDestroy { private menuBehavior: MenuBehavior, private openAlertsService: OpenAlertsService, private menuService: MenuService, - private toast: UtmToastService) { + private toast: UtmToastService, + private federationModeService: FederationModeService) { } ngOnInit() { @@ -72,6 +74,9 @@ export class HeaderMenuNavigationComponent implements OnInit, OnDestroy { } loadMenus() { + if (this.federationModeService.isActive) { + return; + } this.menuService.getMenuStructure(true).subscribe(reponse => { this.menus = reponse.body; this.defaultStructureMenu = reponse.body; From c9ea833a0707d26fedbf088a2ec78166631738cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Wed, 24 Jun 2026 07:12:12 -0600 Subject: [PATCH 32/36] fix[frontend](federation/settings_nav): added route guard to avoid incorrect navigation on federation mode --- frontend/src/app/app-routing.module.ts | 4 +++- .../guards/federation-disabled.guard.ts | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/federation/guards/federation-disabled.guard.ts diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index ad1b2e603..a6c2564e3 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -1,6 +1,7 @@ import {NgModule} from '@angular/core'; import {RouterModule, Routes} from '@angular/router'; import {UserRouteAccessService} from './core/auth/user-route-access-service'; +import {FederationDisabledGuard} from './federation/guards/federation-disabled.guard'; import { FederationEmailConfigPageComponent } from './federation/pages/email-config/federation-email-config.page.component'; @@ -74,7 +75,8 @@ const routes: Routes = [ { path: 'app-management', loadChildren: './app-management/app-management.module#AppManagementModule', - canActivate: [UserRouteAccessService], + canActivate: [UserRouteAccessService, FederationDisabledGuard], + canActivateChild: [FederationDisabledGuard], data: {authorities: [USER_ROLE, ADMIN_ROLE]} }, { diff --git a/frontend/src/app/federation/guards/federation-disabled.guard.ts b/frontend/src/app/federation/guards/federation-disabled.guard.ts new file mode 100644 index 000000000..0b2515530 --- /dev/null +++ b/frontend/src/app/federation/guards/federation-disabled.guard.ts @@ -0,0 +1,21 @@ +import {Injectable} from '@angular/core'; +import {CanActivate, CanActivateChild, Router} from '@angular/router'; +import {FederationModeService} from '../services/federation-mode.service'; + +@Injectable({providedIn: 'root'}) +export class FederationDisabledGuard implements CanActivate, CanActivateChild { + constructor(private federationModeService: FederationModeService, + private router: Router) {} + + canActivate(): boolean { + if (this.federationModeService.isActive) { + this.router.navigate(['/federation/welcome']); + return false; + } + return true; + } + + canActivateChild(): boolean { + return this.canActivate(); + } +} From ea021e04df614225aa6a3740990b644665b88994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Wed, 24 Jun 2026 08:30:30 -0600 Subject: [PATCH 33/36] fix[frontend](federation/settings_nav):rolled back menu header changes --- .../header-menu-navigation.component.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/frontend/src/app/shared/components/layout/header/header-menu-navigation/header-menu-navigation.component.ts b/frontend/src/app/shared/components/layout/header/header-menu-navigation/header-menu-navigation.component.ts index d171e15e0..3099379c5 100644 --- a/frontend/src/app/shared/components/layout/header/header-menu-navigation/header-menu-navigation.component.ts +++ b/frontend/src/app/shared/components/layout/header/header-menu-navigation/header-menu-navigation.component.ts @@ -11,7 +11,6 @@ import {stringParamToQueryParams} from '../../../../util/query-params-to-filter. import {filter, takeUntil, tap} from 'rxjs/operators'; import {OpenAlertsService} from '../../../../../data-management/alert-management/shared/services/open-alerts.service'; import {UtmToastService} from '../../../../alert/utm-toast.service'; -import {FederationModeService} from '../../../../../federation/services/federation-mode.service'; @Component({ selector: 'app-header-menu-navigation', @@ -33,8 +32,7 @@ export class HeaderMenuNavigationComponent implements OnInit, OnDestroy { private menuBehavior: MenuBehavior, private openAlertsService: OpenAlertsService, private menuService: MenuService, - private toast: UtmToastService, - private federationModeService: FederationModeService) { + private toast: UtmToastService) { } ngOnInit() { @@ -74,9 +72,6 @@ export class HeaderMenuNavigationComponent implements OnInit, OnDestroy { } loadMenus() { - if (this.federationModeService.isActive) { - return; - } this.menuService.getMenuStructure(true).subscribe(reponse => { this.menus = reponse.body; this.defaultStructureMenu = reponse.body; From 06f15ec5639082299cabf2acdceed7cf15451cf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Thu, 24 Sep 2026 12:30:35 -0600 Subject: [PATCH 34/36] fix[frontend](team): setted 0-indexed user list and header pagination handling --- .../src/app/federation/domain/team-user.model.ts | 7 ++----- .../team-members/team-members.page.component.ts | 16 ++++++++++------ .../services/federation-team.service.ts | 7 +++---- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/frontend/src/app/federation/domain/team-user.model.ts b/frontend/src/app/federation/domain/team-user.model.ts index c43e1950e..4366842f5 100644 --- a/frontend/src/app/federation/domain/team-user.model.ts +++ b/frontend/src/app/federation/domain/team-user.model.ts @@ -9,6 +9,8 @@ export interface TeamUser { pending: boolean; tfa_enabled: boolean; created_by?: string; + last_modified_by?: string; + last_modified_date?: string; created_at: string; } @@ -21,11 +23,6 @@ export interface TeamUserPageInfo { has_prev: boolean; } -export interface TeamUserListResponse { - data: TeamUser[]; - page_info: TeamUserPageInfo; -} - export interface TeamUserCreatePayload { login: string; email: string; diff --git a/frontend/src/app/federation/pages/team-members/team-members.page.component.ts b/frontend/src/app/federation/pages/team-members/team-members.page.component.ts index 69c40bc94..bb2f0b9d6 100644 --- a/frontend/src/app/federation/pages/team-members/team-members.page.component.ts +++ b/frontend/src/app/federation/pages/team-members/team-members.page.component.ts @@ -1,5 +1,6 @@ import {Component, OnDestroy, OnInit} from '@angular/core'; import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {HttpResponse} from '@angular/common/http'; import {Subject} from 'rxjs'; import {UtmToastService} from '../../../shared/alert/utm-toast.service'; import { @@ -24,7 +25,7 @@ export class TeamMembersPageComponent implements OnInit, OnDestroy { searchTerm = ''; pendingActionId: number | null = null; - private currentPage = 1; + private currentPage = 0; private readonly pageSize = DEFAULT_PAGE_SIZE; private destroy$ = new Subject(); @@ -43,7 +44,7 @@ export class TeamMembersPageComponent implements OnInit, OnDestroy { onSearchChange(value: string): void { this.searchTerm = value; - this.currentPage = 1; + this.currentPage = 0; this.load(); } @@ -71,7 +72,7 @@ export class TeamMembersPageComponent implements OnInit, OnDestroy { ref.componentInstance.saved.subscribe(() => { ref.close(); this.toast.showSuccessBottom('Invitation sent.'); - this.currentPage = 1; + this.currentPage = 0; this.load(); }); } @@ -214,10 +215,13 @@ export class TeamMembersPageComponent implements OnInit, OnDestroy { page_size: this.pageSize, search: this.searchTerm ? this.searchTerm.trim() : undefined }).subscribe({ - next: response => { + next: (res: HttpResponse) => { this.loading = false; - this.users = response.data || []; - this.pageInfo = response.page_info; + this.users = res.body || []; + const totalItems = Number(res.headers.get('X-Total-Count') || 0); + const totalPages = Math.ceil(totalItems / this.pageSize); + const page = Math.max(this.currentPage, 0); + this.pageInfo = {page, page_size: this.pageSize, total_items: totalItems, total_pages: totalPages, has_next: page < totalPages, has_prev: page > 1}; }, error: err => { this.loading = false; diff --git a/frontend/src/app/federation/services/federation-team.service.ts b/frontend/src/app/federation/services/federation-team.service.ts index ef7fc4c4f..3fad9d0ab 100644 --- a/frontend/src/app/federation/services/federation-team.service.ts +++ b/frontend/src/app/federation/services/federation-team.service.ts @@ -1,4 +1,4 @@ -import {HttpClient, HttpParams} from '@angular/common/http'; +import {HttpClient, HttpResponse, HttpParams} from '@angular/common/http'; import {Injectable} from '@angular/core'; import {Observable} from 'rxjs'; import {SERVER_API_URL} from '../../app.constants'; @@ -6,7 +6,6 @@ import { TeamUser, TeamUserCreatePayload, TeamUserListQuery, - TeamUserListResponse, TeamUserUpdatePayload } from '../domain/team-user.model'; @@ -16,7 +15,7 @@ export class FederationTeamService { constructor(private http: HttpClient) {} - list(query: TeamUserListQuery = {}): Observable { + list(query: TeamUserListQuery = {}): Observable> { let params = new HttpParams(); if (query.page !== undefined) { params = params.set('page', String(query.page)); @@ -27,7 +26,7 @@ export class FederationTeamService { if (query.search) { params = params.set('search', query.search); } - return this.http.get(this.endpoint, {params}); + return this.http.get(this.endpoint, {params, observe: 'response'}); } get(id: number): Observable { From a503f21cdf5de3ed7ce54fa1b2905f99821ccb74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Thu, 24 Sep 2026 13:57:08 -0600 Subject: [PATCH 35/36] fix[frontend](config): reset email/TFA checked flags on field change and add tooltip on disabled save --- .../app-config-sections.component.html | 4 +++- .../app-config-sections/app-config-sections.component.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/shared/components/utm/config/app-config-sections/app-config-sections.component.html b/frontend/src/app/shared/components/utm/config/app-config-sections/app-config-sections.component.html index 240320ed9..08131d102 100644 --- a/frontend/src/app/shared/components/utm/config/app-config-sections/app-config-sections.component.html +++ b/frontend/src/app/shared/components/utm/config/app-config-sections/app-config-sections.component.html @@ -36,7 +36,9 @@
    {{section.section | titlecase}}
    *ngIf="section.shortName === sectionType[sectionType.TFA]">