diff --git a/.yo-rc.json b/.yo-rc.json index bf55d8bdf..600c3d83a 100644 --- a/.yo-rc.json +++ b/.yo-rc.json @@ -61,6 +61,6 @@ "testFrameworks": [], "useSass": true, "websocket": false, - "withAdminUi": false + "withAdminUi": true } } diff --git a/src/main/resources/config/application.yml b/src/main/resources/config/application.yml index a37a3ed08..de5ee5845 100644 --- a/src/main/resources/config/application.yml +++ b/src/main/resources/config/application.yml @@ -38,8 +38,8 @@ eureka: enabled: true healthcheck: enabled: true - fetch-registry: true - register-with-eureka: true + fetch-registry: false + register-with-eureka: false instance-info-replication-interval-seconds: 10 registry-fetch-interval-seconds: 10 service-url: diff --git a/src/main/webapp/app/admin/admin-routing.module.ts b/src/main/webapp/app/admin/admin-routing.module.ts index 4722e566a..40a775d23 100644 --- a/src/main/webapp/app/admin/admin-routing.module.ts +++ b/src/main/webapp/app/admin/admin-routing.module.ts @@ -10,6 +10,22 @@ import { RouterModule } from '@angular/router'; path: 'docs', loadChildren: () => import('./docs/docs.module').then(m => m.DocsModule), }, + { + path: 'configuration', + loadChildren: () => import('./configuration/configuration.module').then(m => m.ConfigurationModule), + }, + { + path: 'health', + loadChildren: () => import('./health/health.module').then(m => m.HealthModule), + }, + { + path: 'logs', + loadChildren: () => import('./logs/logs.module').then(m => m.LogsModule), + }, + { + path: 'metrics', + loadChildren: () => import('./metrics/metrics.module').then(m => m.MetricsModule), + }, /* jhipster-needle-add-admin-route - JHipster will add admin routes here */ ]), ], diff --git a/src/main/webapp/app/admin/configuration/configuration.component.html b/src/main/webapp/app/admin/configuration/configuration.component.html new file mode 100644 index 000000000..1e35c674f --- /dev/null +++ b/src/main/webapp/app/admin/configuration/configuration.component.html @@ -0,0 +1,55 @@ +
+

Configuration

+ + Filter (by prefix) + + +

Spring configuration

+ + + + + + + + + + + + + + +
Prefix Properties
+ {{ bean.prefix }} + +
+
{{ property.key }}
+
+ {{ property.value | json }} +
+
+
+ +
+

+ {{ propertySource.name }} +

+ + + + + + + + + + + + + + +
PropertyValue
{{ property.key }} + {{ property.value.value }} +
+
+
diff --git a/src/main/webapp/app/admin/configuration/configuration.component.spec.ts b/src/main/webapp/app/admin/configuration/configuration.component.spec.ts new file mode 100644 index 000000000..12df2b285 --- /dev/null +++ b/src/main/webapp/app/admin/configuration/configuration.component.spec.ts @@ -0,0 +1,67 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { of } from 'rxjs'; + +import { ConfigurationComponent } from './configuration.component'; +import { ConfigurationService } from './configuration.service'; +import { Bean, PropertySource } from './configuration.model'; + +describe('ConfigurationComponent', () => { + let comp: ConfigurationComponent; + let fixture: ComponentFixture; + let service: ConfigurationService; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + declarations: [ConfigurationComponent], + providers: [ConfigurationService], + }) + .overrideTemplate(ConfigurationComponent, '') + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ConfigurationComponent); + comp = fixture.componentInstance; + service = TestBed.inject(ConfigurationService); + }); + + describe('OnInit', () => { + it('Should call load all on init', () => { + // GIVEN + const beans: Bean[] = [ + { + prefix: 'jhipster', + properties: { + clientApp: { + name: 'jhipsterApp', + }, + }, + }, + ]; + const propertySources: PropertySource[] = [ + { + name: 'server.ports', + properties: { + 'local.server.port': { + value: '8080', + }, + }, + }, + ]; + jest.spyOn(service, 'getBeans').mockReturnValue(of(beans)); + jest.spyOn(service, 'getPropertySources').mockReturnValue(of(propertySources)); + + // WHEN + comp.ngOnInit(); + + // THEN + expect(service.getBeans).toHaveBeenCalled(); + expect(service.getPropertySources).toHaveBeenCalled(); + expect(comp.allBeans).toEqual(beans); + expect(comp.beans).toEqual(beans); + expect(comp.propertySources).toEqual(propertySources); + }); + }); +}); diff --git a/src/main/webapp/app/admin/configuration/configuration.component.ts b/src/main/webapp/app/admin/configuration/configuration.component.ts new file mode 100644 index 000000000..939d1aec8 --- /dev/null +++ b/src/main/webapp/app/admin/configuration/configuration.component.ts @@ -0,0 +1,35 @@ +import { Component, OnInit } from '@angular/core'; + +import { ConfigurationService } from './configuration.service'; +import { Bean, PropertySource } from './configuration.model'; + +@Component({ + selector: 'jhi-configuration', + templateUrl: './configuration.component.html', +}) +export class ConfigurationComponent implements OnInit { + allBeans!: Bean[]; + beans: Bean[] = []; + beansFilter = ''; + beansAscending = true; + propertySources: PropertySource[] = []; + + constructor(private configurationService: ConfigurationService) {} + + ngOnInit(): void { + this.configurationService.getBeans().subscribe(beans => { + this.allBeans = beans; + this.filterAndSortBeans(); + }); + + this.configurationService.getPropertySources().subscribe(propertySources => (this.propertySources = propertySources)); + } + + filterAndSortBeans(): void { + const beansAscendingValue = this.beansAscending ? -1 : 1; + const beansAscendingValueReverse = this.beansAscending ? 1 : -1; + this.beans = this.allBeans + .filter(bean => !this.beansFilter || bean.prefix.toLowerCase().includes(this.beansFilter.toLowerCase())) + .sort((a, b) => (a.prefix < b.prefix ? beansAscendingValue : beansAscendingValueReverse)); + } +} diff --git a/src/main/webapp/app/admin/configuration/configuration.model.ts b/src/main/webapp/app/admin/configuration/configuration.model.ts new file mode 100644 index 000000000..6a671e0a9 --- /dev/null +++ b/src/main/webapp/app/admin/configuration/configuration.model.ts @@ -0,0 +1,40 @@ +export interface ConfigProps { + contexts: Contexts; +} + +export interface Contexts { + [key: string]: Context; +} + +export interface Context { + beans: Beans; + parentId?: any; +} + +export interface Beans { + [key: string]: Bean; +} + +export interface Bean { + prefix: string; + properties: any; +} + +export interface Env { + activeProfiles?: string[]; + propertySources: PropertySource[]; +} + +export interface PropertySource { + name: string; + properties: Properties; +} + +export interface Properties { + [key: string]: Property; +} + +export interface Property { + value: string; + origin?: string; +} diff --git a/src/main/webapp/app/admin/configuration/configuration.module.ts b/src/main/webapp/app/admin/configuration/configuration.module.ts new file mode 100644 index 000000000..43ade13c1 --- /dev/null +++ b/src/main/webapp/app/admin/configuration/configuration.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { SharedModule } from 'app/shared/shared.module'; + +import { ConfigurationComponent } from './configuration.component'; +import { configurationRoute } from './configuration.route'; + +@NgModule({ + imports: [SharedModule, RouterModule.forChild([configurationRoute])], + declarations: [ConfigurationComponent], +}) +export class ConfigurationModule {} diff --git a/src/main/webapp/app/admin/configuration/configuration.route.ts b/src/main/webapp/app/admin/configuration/configuration.route.ts new file mode 100644 index 000000000..f3090ec49 --- /dev/null +++ b/src/main/webapp/app/admin/configuration/configuration.route.ts @@ -0,0 +1,11 @@ +import { Route } from '@angular/router'; + +import { ConfigurationComponent } from './configuration.component'; + +export const configurationRoute: Route = { + path: '', + component: ConfigurationComponent, + data: { + pageTitle: 'Configuration', + }, +}; diff --git a/src/main/webapp/app/admin/configuration/configuration.service.spec.ts b/src/main/webapp/app/admin/configuration/configuration.service.spec.ts new file mode 100644 index 000000000..6e6ff7f49 --- /dev/null +++ b/src/main/webapp/app/admin/configuration/configuration.service.spec.ts @@ -0,0 +1,71 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + +import { ConfigurationService } from './configuration.service'; +import { Bean, ConfigProps, Env, PropertySource } from './configuration.model'; + +describe('Logs Service', () => { + let service: ConfigurationService; + let httpMock: HttpTestingController; + let expectedResult: Bean[] | PropertySource[] | null; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + }); + + expectedResult = null; + service = TestBed.inject(ConfigurationService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + describe('Service methods', () => { + it('should get the config', () => { + const bean: Bean = { + prefix: 'jhipster', + properties: { + clientApp: { + name: 'jhipsterApp', + }, + }, + }; + const configProps: ConfigProps = { + contexts: { + jhipster: { + beans: { + 'tech.jhipster.config.JHipsterProperties': bean, + }, + }, + }, + }; + service.getBeans().subscribe(received => (expectedResult = received)); + + const req = httpMock.expectOne({ method: 'GET' }); + req.flush(configProps); + expect(expectedResult).toEqual([bean]); + }); + + it('should get the env', () => { + const propertySources: PropertySource[] = [ + { + name: 'server.ports', + properties: { + 'local.server.port': { + value: '8080', + }, + }, + }, + ]; + const env: Env = { propertySources }; + service.getPropertySources().subscribe(received => (expectedResult = received)); + + const req = httpMock.expectOne({ method: 'GET' }); + req.flush(env); + expect(expectedResult).toEqual(propertySources); + }); + }); +}); diff --git a/src/main/webapp/app/admin/configuration/configuration.service.ts b/src/main/webapp/app/admin/configuration/configuration.service.ts new file mode 100644 index 000000000..d8d30518d --- /dev/null +++ b/src/main/webapp/app/admin/configuration/configuration.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { ApplicationConfigService } from 'app/core/config/application-config.service'; +import { Bean, Beans, ConfigProps, Env, PropertySource } from './configuration.model'; + +@Injectable({ providedIn: 'root' }) +export class ConfigurationService { + constructor(private http: HttpClient, private applicationConfigService: ApplicationConfigService) {} + + getBeans(): Observable { + return this.http.get(this.applicationConfigService.getEndpointFor('management/configprops')).pipe( + map(configProps => + Object.values( + Object.values(configProps.contexts) + .map(context => context.beans) + .reduce((allBeans: Beans, contextBeans: Beans) => ({ ...allBeans, ...contextBeans })) + ) + ) + ); + } + + getPropertySources(): Observable { + return this.http.get(this.applicationConfigService.getEndpointFor('management/env')).pipe(map(env => env.propertySources)); + } +} diff --git a/src/main/webapp/app/admin/health/health.component.html b/src/main/webapp/app/admin/health/health.component.html new file mode 100644 index 000000000..6844d9b62 --- /dev/null +++ b/src/main/webapp/app/admin/health/health.component.html @@ -0,0 +1,42 @@ +
+

+ Health Checks + + +

+ +
+ + + + + + + + + + + + + + + +
Service nameStatusDetails
+ {{ componentHealth.key }} + + + {{ + { UNKNOWN: 'UNKNOWN', UP: 'UP', OUT_OF_SERVICE: 'OUT_OF_SERVICE', DOWN: 'DOWN' }[componentHealth.value!.status || 'UNKNOWN'] + }} + + + + + +
+
+
diff --git a/src/main/webapp/app/admin/health/health.component.spec.ts b/src/main/webapp/app/admin/health/health.component.spec.ts new file mode 100644 index 000000000..3cfdf9f80 --- /dev/null +++ b/src/main/webapp/app/admin/health/health.component.spec.ts @@ -0,0 +1,66 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { HttpErrorResponse } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { of, throwError } from 'rxjs'; + +import { HealthComponent } from './health.component'; +import { HealthService } from './health.service'; +import { Health } from './health.model'; + +describe('HealthComponent', () => { + let comp: HealthComponent; + let fixture: ComponentFixture; + let service: HealthService; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + declarations: [HealthComponent], + }) + .overrideTemplate(HealthComponent, '') + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(HealthComponent); + comp = fixture.componentInstance; + service = TestBed.inject(HealthService); + }); + + describe('getBadgeClass', () => { + it('should get badge class', () => { + const upBadgeClass = comp.getBadgeClass('UP'); + const downBadgeClass = comp.getBadgeClass('DOWN'); + expect(upBadgeClass).toEqual('bg-success'); + expect(downBadgeClass).toEqual('bg-danger'); + }); + }); + + describe('refresh', () => { + it('should call refresh on init', () => { + // GIVEN + const health: Health = { status: 'UP', components: { mail: { status: 'UP', details: { mailDetail: 'mail' } } } }; + jest.spyOn(service, 'checkHealth').mockReturnValue(of(health)); + + // WHEN + comp.ngOnInit(); + + // THEN + expect(service.checkHealth).toHaveBeenCalled(); + expect(comp.health).toEqual(health); + }); + + it('should handle a 503 on refreshing health data', () => { + // GIVEN + const health: Health = { status: 'DOWN', components: { mail: { status: 'DOWN' } } }; + jest.spyOn(service, 'checkHealth').mockReturnValue(throwError(new HttpErrorResponse({ status: 503, error: health }))); + + // WHEN + comp.refresh(); + + // THEN + expect(service.checkHealth).toHaveBeenCalled(); + expect(comp.health).toEqual(health); + }); + }); +}); diff --git a/src/main/webapp/app/admin/health/health.component.ts b/src/main/webapp/app/admin/health/health.component.ts new file mode 100644 index 000000000..2d9bc2d01 --- /dev/null +++ b/src/main/webapp/app/admin/health/health.component.ts @@ -0,0 +1,44 @@ +import { Component, OnInit } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; + +import { HealthService } from './health.service'; +import { Health, HealthDetails, HealthStatus } from './health.model'; +import { HealthModalComponent } from './modal/health-modal.component'; + +@Component({ + selector: 'jhi-health', + templateUrl: './health.component.html', +}) +export class HealthComponent implements OnInit { + health?: Health; + + constructor(private modalService: NgbModal, private healthService: HealthService) {} + + ngOnInit(): void { + this.refresh(); + } + + getBadgeClass(statusState: HealthStatus): string { + if (statusState === 'UP') { + return 'bg-success'; + } + return 'bg-danger'; + } + + refresh(): void { + this.healthService.checkHealth().subscribe({ + next: health => (this.health = health), + error: (error: HttpErrorResponse) => { + if (error.status === 503) { + this.health = error.error; + } + }, + }); + } + + showHealth(health: { key: string; value: HealthDetails }): void { + const modalRef = this.modalService.open(HealthModalComponent); + modalRef.componentInstance.health = health; + } +} diff --git a/src/main/webapp/app/admin/health/health.model.ts b/src/main/webapp/app/admin/health/health.model.ts new file mode 100644 index 000000000..8f2e5cf82 --- /dev/null +++ b/src/main/webapp/app/admin/health/health.model.ts @@ -0,0 +1,24 @@ +export type HealthStatus = 'UP' | 'DOWN' | 'UNKNOWN' | 'OUT_OF_SERVICE'; + +export type HealthKey = + | 'discoveryComposite' + | 'refreshScope' + | 'clientConfigServer' + | 'hystrix' + | 'diskSpace' + | 'mail' + | 'ping' + | 'livenessState' + | 'readinessState'; + +export interface Health { + status: HealthStatus; + components: { + [key in HealthKey]?: HealthDetails; + }; +} + +export interface HealthDetails { + status: HealthStatus; + details?: { [key: string]: unknown }; +} diff --git a/src/main/webapp/app/admin/health/health.module.ts b/src/main/webapp/app/admin/health/health.module.ts new file mode 100644 index 000000000..554d1b8e0 --- /dev/null +++ b/src/main/webapp/app/admin/health/health.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { SharedModule } from 'app/shared/shared.module'; + +import { HealthComponent } from './health.component'; +import { HealthModalComponent } from './modal/health-modal.component'; +import { healthRoute } from './health.route'; + +@NgModule({ + imports: [SharedModule, RouterModule.forChild([healthRoute])], + declarations: [HealthComponent, HealthModalComponent], +}) +export class HealthModule {} diff --git a/src/main/webapp/app/admin/health/health.route.ts b/src/main/webapp/app/admin/health/health.route.ts new file mode 100644 index 000000000..3a8fe5705 --- /dev/null +++ b/src/main/webapp/app/admin/health/health.route.ts @@ -0,0 +1,11 @@ +import { Route } from '@angular/router'; + +import { HealthComponent } from './health.component'; + +export const healthRoute: Route = { + path: '', + component: HealthComponent, + data: { + pageTitle: 'Health Checks', + }, +}; diff --git a/src/main/webapp/app/admin/health/health.service.spec.ts b/src/main/webapp/app/admin/health/health.service.spec.ts new file mode 100644 index 000000000..850c531f7 --- /dev/null +++ b/src/main/webapp/app/admin/health/health.service.spec.ts @@ -0,0 +1,48 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + +import { HealthService } from './health.service'; +import { ApplicationConfigService } from 'app/core/config/application-config.service'; + +describe('HealthService Service', () => { + let service: HealthService; + let httpMock: HttpTestingController; + let applicationConfigService: ApplicationConfigService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + }); + + service = TestBed.inject(HealthService); + applicationConfigService = TestBed.inject(ApplicationConfigService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + describe('Service methods', () => { + it('should call management/health endpoint with correct values', () => { + // GIVEN + let expectedResult; + const checkHealth = { + components: [], + }; + + // WHEN + service.checkHealth().subscribe(received => { + expectedResult = received; + }); + const testRequest = httpMock.expectOne({ + method: 'GET', + url: applicationConfigService.getEndpointFor('management/health'), + }); + testRequest.flush(checkHealth); + + // THEN + expect(expectedResult).toEqual(checkHealth); + }); + }); +}); diff --git a/src/main/webapp/app/admin/health/health.service.ts b/src/main/webapp/app/admin/health/health.service.ts new file mode 100644 index 000000000..4712a9787 --- /dev/null +++ b/src/main/webapp/app/admin/health/health.service.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { ApplicationConfigService } from 'app/core/config/application-config.service'; +import { Health } from './health.model'; + +@Injectable({ providedIn: 'root' }) +export class HealthService { + constructor(private http: HttpClient, private applicationConfigService: ApplicationConfigService) {} + + checkHealth(): Observable { + return this.http.get(this.applicationConfigService.getEndpointFor('management/health')); + } +} diff --git a/src/main/webapp/app/admin/health/modal/health-modal.component.html b/src/main/webapp/app/admin/health/modal/health-modal.component.html new file mode 100644 index 000000000..95d6687d0 --- /dev/null +++ b/src/main/webapp/app/admin/health/modal/health-modal.component.html @@ -0,0 +1,36 @@ + + + + + diff --git a/src/main/webapp/app/admin/health/modal/health-modal.component.spec.ts b/src/main/webapp/app/admin/health/modal/health-modal.component.spec.ts new file mode 100644 index 000000000..9eb92d0e8 --- /dev/null +++ b/src/main/webapp/app/admin/health/modal/health-modal.component.spec.ts @@ -0,0 +1,112 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +import { HealthModalComponent } from './health-modal.component'; + +describe('HealthModalComponent', () => { + let comp: HealthModalComponent; + let fixture: ComponentFixture; + let mockActiveModal: NgbActiveModal; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + declarations: [HealthModalComponent], + providers: [NgbActiveModal], + }) + .overrideTemplate(HealthModalComponent, '') + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(HealthModalComponent); + comp = fixture.componentInstance; + mockActiveModal = TestBed.inject(NgbActiveModal); + }); + + describe('readableValue', () => { + it('should return stringify value', () => { + // GIVEN + comp.health = undefined; + + // WHEN + const result = comp.readableValue({ name: 'jhipster' }); + + // THEN + expect(result).toEqual('{"name":"jhipster"}'); + }); + + it('should return string value', () => { + // GIVEN + comp.health = undefined; + + // WHEN + const result = comp.readableValue('jhipster'); + + // THEN + expect(result).toEqual('jhipster'); + }); + + it('should return storage space in an human readable unit (GB)', () => { + // GIVEN + comp.health = { + key: 'diskSpace', + value: { + status: 'UP', + }, + }; + + // WHEN + const result = comp.readableValue(1073741825); + + // THEN + expect(result).toEqual('1.00 GB'); + }); + + it('should return storage space in an human readable unit (MB)', () => { + // GIVEN + comp.health = { + key: 'diskSpace', + value: { + status: 'UP', + }, + }; + + // WHEN + const result = comp.readableValue(1073741824); + + // THEN + expect(result).toEqual('1024.00 MB'); + }); + + it('should return string value', () => { + // GIVEN + comp.health = { + key: 'mail', + value: { + status: 'UP', + }, + }; + + // WHEN + const result = comp.readableValue(1234); + + // THEN + expect(result).toEqual('1234'); + }); + }); + + describe('dismiss', () => { + it('should call dismiss when dismiss modal is called', () => { + // GIVEN + const spy = jest.spyOn(mockActiveModal, 'dismiss'); + + // WHEN + comp.dismiss(); + + // THEN + expect(spy).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/main/webapp/app/admin/health/modal/health-modal.component.ts b/src/main/webapp/app/admin/health/modal/health-modal.component.ts new file mode 100644 index 000000000..02f7cc94f --- /dev/null +++ b/src/main/webapp/app/admin/health/modal/health-modal.component.ts @@ -0,0 +1,34 @@ +import { Component } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +import { HealthKey, HealthDetails } from '../health.model'; + +@Component({ + selector: 'jhi-health-modal', + templateUrl: './health-modal.component.html', +}) +export class HealthModalComponent { + health?: { key: HealthKey; value: HealthDetails }; + + constructor(private activeModal: NgbActiveModal) {} + + readableValue(value: any): string { + if (this.health?.key === 'diskSpace') { + // Should display storage space in an human readable unit + const val = value / 1073741824; + if (val > 1) { + return `${val.toFixed(2)} GB`; + } + return `${(value / 1048576).toFixed(2)} MB`; + } + + if (typeof value === 'object') { + return JSON.stringify(value); + } + return String(value); + } + + dismiss(): void { + this.activeModal.dismiss(); + } +} diff --git a/src/main/webapp/app/admin/logs/log.model.ts b/src/main/webapp/app/admin/logs/log.model.ts new file mode 100644 index 000000000..83f2e154a --- /dev/null +++ b/src/main/webapp/app/admin/logs/log.model.ts @@ -0,0 +1,15 @@ +export type Level = 'TRACE' | 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' | 'OFF'; + +export interface Logger { + configuredLevel: Level | null; + effectiveLevel: Level; +} + +export interface LoggersResponse { + levels: Level[]; + loggers: { [key: string]: Logger }; +} + +export class Log { + constructor(public name: string, public level: Level) {} +} diff --git a/src/main/webapp/app/admin/logs/logs.component.html b/src/main/webapp/app/admin/logs/logs.component.html new file mode 100644 index 000000000..414d9deef --- /dev/null +++ b/src/main/webapp/app/admin/logs/logs.component.html @@ -0,0 +1,74 @@ +
+

Logs

+ +

There are {{ loggers.length }} loggers.

+ + Filter + + + + + + + + + + + + + + + + +
Name Level
+ {{ logger.name | slice: 0:140 }} + + + + + + + + + + + + +
+
diff --git a/src/main/webapp/app/admin/logs/logs.component.spec.ts b/src/main/webapp/app/admin/logs/logs.component.spec.ts new file mode 100644 index 000000000..774e01884 --- /dev/null +++ b/src/main/webapp/app/admin/logs/logs.component.spec.ts @@ -0,0 +1,83 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { of } from 'rxjs'; + +import { LogsComponent } from './logs.component'; +import { LogsService } from './logs.service'; +import { Log, LoggersResponse } from './log.model'; + +describe('LogsComponent', () => { + let comp: LogsComponent; + let fixture: ComponentFixture; + let service: LogsService; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + declarations: [LogsComponent], + providers: [LogsService], + }) + .overrideTemplate(LogsComponent, '') + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(LogsComponent); + comp = fixture.componentInstance; + service = TestBed.inject(LogsService); + }); + + describe('OnInit', () => { + it('should set all default values correctly', () => { + expect(comp.filter).toBe(''); + expect(comp.orderProp).toBe('name'); + expect(comp.ascending).toBe(true); + }); + + it('Should call load all on init', () => { + // GIVEN + const log = new Log('main', 'WARN'); + jest.spyOn(service, 'findAll').mockReturnValue( + of({ + loggers: { + main: { + effectiveLevel: 'WARN', + }, + }, + } as unknown as LoggersResponse) + ); + + // WHEN + comp.ngOnInit(); + + // THEN + expect(service.findAll).toHaveBeenCalled(); + expect(comp.loggers?.[0]).toEqual(expect.objectContaining(log)); + }); + }); + + describe('change log level', () => { + it('should change log level correctly', () => { + // GIVEN + const log = new Log('main', 'ERROR'); + jest.spyOn(service, 'changeLevel').mockReturnValue(of({})); + jest.spyOn(service, 'findAll').mockReturnValue( + of({ + loggers: { + main: { + effectiveLevel: 'ERROR', + }, + }, + } as unknown as LoggersResponse) + ); + + // WHEN + comp.changeLevel('main', 'ERROR'); + + // THEN + expect(service.changeLevel).toHaveBeenCalled(); + expect(service.findAll).toHaveBeenCalled(); + expect(comp.loggers?.[0]).toEqual(expect.objectContaining(log)); + }); + }); +}); diff --git a/src/main/webapp/app/admin/logs/logs.component.ts b/src/main/webapp/app/admin/logs/logs.component.ts new file mode 100644 index 000000000..6320accb7 --- /dev/null +++ b/src/main/webapp/app/admin/logs/logs.component.ts @@ -0,0 +1,48 @@ +import { Component, OnInit } from '@angular/core'; + +import { Log, LoggersResponse, Level } from './log.model'; +import { LogsService } from './logs.service'; + +@Component({ + selector: 'jhi-logs', + templateUrl: './logs.component.html', +}) +export class LogsComponent implements OnInit { + loggers?: Log[]; + filteredAndOrderedLoggers?: Log[]; + filter = ''; + orderProp: keyof Log = 'name'; + ascending = true; + + constructor(private logsService: LogsService) {} + + ngOnInit(): void { + this.findAndExtractLoggers(); + } + + changeLevel(name: string, level: Level): void { + this.logsService.changeLevel(name, level).subscribe(() => this.findAndExtractLoggers()); + } + + filterAndSort(): void { + this.filteredAndOrderedLoggers = this.loggers!.filter( + logger => !this.filter || logger.name.toLowerCase().includes(this.filter.toLowerCase()) + ).sort((a, b) => { + if (a[this.orderProp] < b[this.orderProp]) { + return this.ascending ? -1 : 1; + } else if (a[this.orderProp] > b[this.orderProp]) { + return this.ascending ? 1 : -1; + } else if (this.orderProp === 'level') { + return a.name < b.name ? -1 : 1; + } + return 0; + }); + } + + private findAndExtractLoggers(): void { + this.logsService.findAll().subscribe((response: LoggersResponse) => { + this.loggers = Object.entries(response.loggers).map(([key, logger]) => new Log(key, logger.effectiveLevel)); + this.filterAndSort(); + }); + } +} diff --git a/src/main/webapp/app/admin/logs/logs.module.ts b/src/main/webapp/app/admin/logs/logs.module.ts new file mode 100644 index 000000000..720af2794 --- /dev/null +++ b/src/main/webapp/app/admin/logs/logs.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { SharedModule } from 'app/shared/shared.module'; + +import { LogsComponent } from './logs.component'; +import { logsRoute } from './logs.route'; + +@NgModule({ + imports: [SharedModule, RouterModule.forChild([logsRoute])], + declarations: [LogsComponent], +}) +export class LogsModule {} diff --git a/src/main/webapp/app/admin/logs/logs.route.ts b/src/main/webapp/app/admin/logs/logs.route.ts new file mode 100644 index 000000000..5a160eb8f --- /dev/null +++ b/src/main/webapp/app/admin/logs/logs.route.ts @@ -0,0 +1,11 @@ +import { Route } from '@angular/router'; + +import { LogsComponent } from './logs.component'; + +export const logsRoute: Route = { + path: '', + component: LogsComponent, + data: { + pageTitle: 'Logs', + }, +}; diff --git a/src/main/webapp/app/admin/logs/logs.service.spec.ts b/src/main/webapp/app/admin/logs/logs.service.spec.ts new file mode 100644 index 000000000..cebee2ccd --- /dev/null +++ b/src/main/webapp/app/admin/logs/logs.service.spec.ts @@ -0,0 +1,31 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + +import { LogsService } from './logs.service'; + +describe('Logs Service', () => { + let service: LogsService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + }); + + service = TestBed.inject(LogsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + describe('Service methods', () => { + it('should change log level', () => { + service.changeLevel('main', 'ERROR').subscribe(); + + const req = httpMock.expectOne({ method: 'POST' }); + expect(req.request.body).toEqual({ configuredLevel: 'ERROR' }); + }); + }); +}); diff --git a/src/main/webapp/app/admin/logs/logs.service.ts b/src/main/webapp/app/admin/logs/logs.service.ts new file mode 100644 index 000000000..625868a8b --- /dev/null +++ b/src/main/webapp/app/admin/logs/logs.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { ApplicationConfigService } from 'app/core/config/application-config.service'; +import { LoggersResponse, Level } from './log.model'; + +@Injectable({ providedIn: 'root' }) +export class LogsService { + constructor(private http: HttpClient, private applicationConfigService: ApplicationConfigService) {} + + changeLevel(name: string, configuredLevel: Level): Observable<{}> { + return this.http.post(this.applicationConfigService.getEndpointFor(`management/loggers/${name}`), { configuredLevel }); + } + + findAll(): Observable { + return this.http.get(this.applicationConfigService.getEndpointFor('management/loggers')); + } +} diff --git a/src/main/webapp/app/admin/metrics/blocks/jvm-memory/jvm-memory.component.html b/src/main/webapp/app/admin/metrics/blocks/jvm-memory/jvm-memory.component.html new file mode 100644 index 000000000..9009456db --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/jvm-memory/jvm-memory.component.html @@ -0,0 +1,28 @@ +

Memory

+ +
+
+ + {{ entry.key }} + ({{ entry.value.used / 1048576 | number: '1.0-0' }}M / {{ entry.value.max / 1048576 | number: '1.0-0' }}M) + + +
Committed : {{ entry.value.committed / 1048576 | number: '1.0-0' }}M
+ + {{ entry.key }} {{ entry.value.used / 1048576 | number: '1.0-0' }}M + + + {{ (entry.value.used * 100) / entry.value.max | number: '1.0-0' }}% + +
+
diff --git a/src/main/webapp/app/admin/metrics/blocks/jvm-memory/jvm-memory.component.ts b/src/main/webapp/app/admin/metrics/blocks/jvm-memory/jvm-memory.component.ts new file mode 100644 index 000000000..d434b8aec --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/jvm-memory/jvm-memory.component.ts @@ -0,0 +1,19 @@ +import { Component, Input } from '@angular/core'; + +import { JvmMetrics } from 'app/admin/metrics/metrics.model'; + +@Component({ + selector: 'jhi-jvm-memory', + templateUrl: './jvm-memory.component.html', +}) +export class JvmMemoryComponent { + /** + * object containing all jvm memory metrics + */ + @Input() jvmMemoryMetrics?: { [key: string]: JvmMetrics }; + + /** + * boolean field saying if the metrics are in the process of being updated + */ + @Input() updating?: boolean; +} diff --git a/src/main/webapp/app/admin/metrics/blocks/jvm-threads/jvm-threads.component.html b/src/main/webapp/app/admin/metrics/blocks/jvm-threads/jvm-threads.component.html new file mode 100644 index 000000000..df0a5df2f --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/jvm-threads/jvm-threads.component.html @@ -0,0 +1,55 @@ +

Threads

+ +Runnable {{ threadStats.threadDumpRunnable }} + + + {{ (threadStats.threadDumpRunnable * 100) / threadStats.threadDumpAll | number: '1.0-0' }}% + + +Timed waiting ({{ threadStats.threadDumpTimedWaiting }}) + + + {{ (threadStats.threadDumpTimedWaiting * 100) / threadStats.threadDumpAll | number: '1.0-0' }}% + + +Waiting ({{ threadStats.threadDumpWaiting }}) + + + {{ (threadStats.threadDumpWaiting * 100) / threadStats.threadDumpAll | number: '1.0-0' }}% + + +Blocked ({{ threadStats.threadDumpBlocked }}) + + + {{ (threadStats.threadDumpBlocked * 100) / threadStats.threadDumpAll | number: '1.0-0' }}% + + +
Total: {{ threadStats.threadDumpAll }}
+ + diff --git a/src/main/webapp/app/admin/metrics/blocks/jvm-threads/jvm-threads.component.ts b/src/main/webapp/app/admin/metrics/blocks/jvm-threads/jvm-threads.component.ts new file mode 100644 index 000000000..de98fb26f --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/jvm-threads/jvm-threads.component.ts @@ -0,0 +1,55 @@ +import { Component, Input } from '@angular/core'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; + +import { Thread, ThreadState } from 'app/admin/metrics/metrics.model'; +import { MetricsModalThreadsComponent } from '../metrics-modal-threads/metrics-modal-threads.component'; + +@Component({ + selector: 'jhi-jvm-threads', + templateUrl: './jvm-threads.component.html', +}) +export class JvmThreadsComponent { + threadStats = { + threadDumpAll: 0, + threadDumpRunnable: 0, + threadDumpTimedWaiting: 0, + threadDumpWaiting: 0, + threadDumpBlocked: 0, + }; + + @Input() + set threads(threads: Thread[] | undefined) { + this._threads = threads; + + threads?.forEach(thread => { + if (thread.threadState === ThreadState.Runnable) { + this.threadStats.threadDumpRunnable += 1; + } else if (thread.threadState === ThreadState.Waiting) { + this.threadStats.threadDumpWaiting += 1; + } else if (thread.threadState === ThreadState.TimedWaiting) { + this.threadStats.threadDumpTimedWaiting += 1; + } else if (thread.threadState === ThreadState.Blocked) { + this.threadStats.threadDumpBlocked += 1; + } + }); + + this.threadStats.threadDumpAll = + this.threadStats.threadDumpRunnable + + this.threadStats.threadDumpWaiting + + this.threadStats.threadDumpTimedWaiting + + this.threadStats.threadDumpBlocked; + } + + get threads(): Thread[] | undefined { + return this._threads; + } + + private _threads: Thread[] | undefined; + + constructor(private modalService: NgbModal) {} + + open(): void { + const modalRef = this.modalService.open(MetricsModalThreadsComponent); + modalRef.componentInstance.threads = this.threads; + } +} diff --git a/src/main/webapp/app/admin/metrics/blocks/metrics-cache/metrics-cache.component.html b/src/main/webapp/app/admin/metrics/blocks/metrics-cache/metrics-cache.component.html new file mode 100644 index 000000000..2cfd03113 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/metrics-cache/metrics-cache.component.html @@ -0,0 +1,42 @@ +

Cache statistics

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Cache nameCache HitsCache MissesCache GetsCache PutsCache RemovalsCache EvictionsCache Hit %Cache Miss %
{{ entry.key }}{{ entry.value['cache.gets.hit'] }}{{ entry.value['cache.gets.miss'] }}{{ entry.value['cache.gets.hit'] + entry.value['cache.gets.miss'] }}{{ entry.value['cache.puts'] }}{{ entry.value['cache.removals'] }}{{ entry.value['cache.evictions'] }} + {{ + filterNaN((100 * entry.value['cache.gets.hit']) / (entry.value['cache.gets.hit'] + entry.value['cache.gets.miss'])) + | number: '1.0-4' + }} + + {{ + filterNaN((100 * entry.value['cache.gets.miss']) / (entry.value['cache.gets.hit'] + entry.value['cache.gets.miss'])) + | number: '1.0-4' + }} +
+
diff --git a/src/main/webapp/app/admin/metrics/blocks/metrics-cache/metrics-cache.component.ts b/src/main/webapp/app/admin/metrics/blocks/metrics-cache/metrics-cache.component.ts new file mode 100644 index 000000000..6d721463c --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/metrics-cache/metrics-cache.component.ts @@ -0,0 +1,23 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; + +import { CacheMetrics } from 'app/admin/metrics/metrics.model'; +import { filterNaN } from 'app/core/util/operators'; + +@Component({ + selector: 'jhi-metrics-cache', + templateUrl: './metrics-cache.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MetricsCacheComponent { + /** + * object containing all cache related metrics + */ + @Input() cacheMetrics?: { [key: string]: CacheMetrics }; + + /** + * boolean field saying if the metrics are in the process of being updated + */ + @Input() updating?: boolean; + + filterNaN = (input: number): number => filterNaN(input); +} diff --git a/src/main/webapp/app/admin/metrics/blocks/metrics-datasource/metrics-datasource.component.html b/src/main/webapp/app/admin/metrics/blocks/metrics-datasource/metrics-datasource.component.html new file mode 100644 index 000000000..99c61203b --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/metrics-datasource/metrics-datasource.component.html @@ -0,0 +1,57 @@ +

DataSource statistics (time in millisecond)

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Connection Pool Usage (active: {{ datasourceMetrics.active.value }}, min: {{ datasourceMetrics.min.value }}, max: + {{ datasourceMetrics.max.value }}, idle: {{ datasourceMetrics.idle.value }}) + CountMeanMinp50p75p95p99Max
Acquire{{ datasourceMetrics.acquire.count }}{{ filterNaN(datasourceMetrics.acquire.mean) | number: '1.0-2' }}{{ datasourceMetrics.acquire['0.0'] | number: '1.0-3' }}{{ datasourceMetrics.acquire['0.5'] | number: '1.0-3' }}{{ datasourceMetrics.acquire['0.75'] | number: '1.0-3' }}{{ datasourceMetrics.acquire['0.95'] | number: '1.0-3' }}{{ datasourceMetrics.acquire['0.99'] | number: '1.0-3' }}{{ filterNaN(datasourceMetrics.acquire.max) | number: '1.0-2' }}
Creation{{ datasourceMetrics.creation.count }}{{ filterNaN(datasourceMetrics.creation.mean) | number: '1.0-2' }}{{ datasourceMetrics.creation['0.0'] | number: '1.0-3' }}{{ datasourceMetrics.creation['0.5'] | number: '1.0-3' }}{{ datasourceMetrics.creation['0.75'] | number: '1.0-3' }}{{ datasourceMetrics.creation['0.95'] | number: '1.0-3' }}{{ datasourceMetrics.creation['0.99'] | number: '1.0-3' }}{{ filterNaN(datasourceMetrics.creation.max) | number: '1.0-2' }}
Usage{{ datasourceMetrics.usage.count }}{{ filterNaN(datasourceMetrics.usage.mean) | number: '1.0-2' }}{{ datasourceMetrics.usage['0.0'] | number: '1.0-3' }}{{ datasourceMetrics.usage['0.5'] | number: '1.0-3' }}{{ datasourceMetrics.usage['0.75'] | number: '1.0-3' }}{{ datasourceMetrics.usage['0.95'] | number: '1.0-3' }}{{ datasourceMetrics.usage['0.99'] | number: '1.0-3' }}{{ filterNaN(datasourceMetrics.usage.max) | number: '1.0-2' }}
+
diff --git a/src/main/webapp/app/admin/metrics/blocks/metrics-datasource/metrics-datasource.component.ts b/src/main/webapp/app/admin/metrics/blocks/metrics-datasource/metrics-datasource.component.ts new file mode 100644 index 000000000..eefcf585b --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/metrics-datasource/metrics-datasource.component.ts @@ -0,0 +1,23 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; + +import { Databases } from 'app/admin/metrics/metrics.model'; +import { filterNaN } from 'app/core/util/operators'; + +@Component({ + selector: 'jhi-metrics-datasource', + templateUrl: './metrics-datasource.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MetricsDatasourceComponent { + /** + * object containing all datasource related metrics + */ + @Input() datasourceMetrics?: Databases; + + /** + * boolean field saying if the metrics are in the process of being updated + */ + @Input() updating?: boolean; + + filterNaN = (input: number): number => filterNaN(input); +} diff --git a/src/main/webapp/app/admin/metrics/blocks/metrics-endpoints-requests/metrics-endpoints-requests.component.html b/src/main/webapp/app/admin/metrics/blocks/metrics-endpoints-requests/metrics-endpoints-requests.component.html new file mode 100644 index 000000000..ee47ec7ea --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/metrics-endpoints-requests/metrics-endpoints-requests.component.html @@ -0,0 +1,24 @@ +

Endpoints requests (time in millisecond)

+ +
+ + + + + + + + + + + + + + + + + + + +
MethodEndpoint urlCountMean
{{ method.key }}{{ entry.key }}{{ method.value!.count }}{{ method.value!.mean | number: '1.0-3' }}
+
diff --git a/src/main/webapp/app/admin/metrics/blocks/metrics-endpoints-requests/metrics-endpoints-requests.component.ts b/src/main/webapp/app/admin/metrics/blocks/metrics-endpoints-requests/metrics-endpoints-requests.component.ts new file mode 100644 index 000000000..aa4ceb4b4 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/metrics-endpoints-requests/metrics-endpoints-requests.component.ts @@ -0,0 +1,19 @@ +import { Component, Input } from '@angular/core'; + +import { Services } from 'app/admin/metrics/metrics.model'; + +@Component({ + selector: 'jhi-metrics-endpoints-requests', + templateUrl: './metrics-endpoints-requests.component.html', +}) +export class MetricsEndpointsRequestsComponent { + /** + * object containing service related metrics + */ + @Input() endpointsRequestsMetrics?: Services; + + /** + * boolean field saying if the metrics are in the process of being updated + */ + @Input() updating?: boolean; +} diff --git a/src/main/webapp/app/admin/metrics/blocks/metrics-garbagecollector/metrics-garbagecollector.component.html b/src/main/webapp/app/admin/metrics/blocks/metrics-garbagecollector/metrics-garbagecollector.component.html new file mode 100644 index 000000000..219acf946 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/metrics-garbagecollector/metrics-garbagecollector.component.html @@ -0,0 +1,92 @@ +

Garbage collections

+ +
+
+
+ + GC Live Data Size/GC Max Data Size ({{ garbageCollectorMetrics['jvm.gc.live.data.size'] / 1048576 | number: '1.0-0' }}M / + {{ garbageCollectorMetrics['jvm.gc.max.data.size'] / 1048576 | number: '1.0-0' }}M) + + + + + {{ + (100 * garbageCollectorMetrics['jvm.gc.live.data.size']) / garbageCollectorMetrics['jvm.gc.max.data.size'] | number: '1.0-2' + }}% + + +
+
+ +
+
+ + GC Memory Promoted/GC Memory Allocated ({{ garbageCollectorMetrics['jvm.gc.memory.promoted'] / 1048576 | number: '1.0-0' }}M / + {{ garbageCollectorMetrics['jvm.gc.memory.allocated'] / 1048576 | number: '1.0-0' }}M) + + + + + {{ + (100 * garbageCollectorMetrics['jvm.gc.memory.promoted']) / garbageCollectorMetrics['jvm.gc.memory.allocated'] + | number: '1.0-2' + }}% + + +
+
+ +
+
+
Classes loaded
+
{{ garbageCollectorMetrics.classesLoaded }}
+
+
+
Classes unloaded
+
{{ garbageCollectorMetrics.classesUnloaded }}
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
CountMeanMinp50p75p95p99Max
jvm.gc.pause{{ garbageCollectorMetrics['jvm.gc.pause'].count }}{{ garbageCollectorMetrics['jvm.gc.pause'].mean | number: '1.0-3' }}{{ garbageCollectorMetrics['jvm.gc.pause']['0.0'] | number: '1.0-3' }}{{ garbageCollectorMetrics['jvm.gc.pause']['0.5'] | number: '1.0-3' }}{{ garbageCollectorMetrics['jvm.gc.pause']['0.75'] | number: '1.0-3' }}{{ garbageCollectorMetrics['jvm.gc.pause']['0.95'] | number: '1.0-3' }}{{ garbageCollectorMetrics['jvm.gc.pause']['0.99'] | number: '1.0-3' }}{{ garbageCollectorMetrics['jvm.gc.pause'].max | number: '1.0-3' }}
+
+
diff --git a/src/main/webapp/app/admin/metrics/blocks/metrics-garbagecollector/metrics-garbagecollector.component.ts b/src/main/webapp/app/admin/metrics/blocks/metrics-garbagecollector/metrics-garbagecollector.component.ts new file mode 100644 index 000000000..bbf11e00c --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/metrics-garbagecollector/metrics-garbagecollector.component.ts @@ -0,0 +1,19 @@ +import { Component, Input } from '@angular/core'; + +import { GarbageCollector } from 'app/admin/metrics/metrics.model'; + +@Component({ + selector: 'jhi-metrics-garbagecollector', + templateUrl: './metrics-garbagecollector.component.html', +}) +export class MetricsGarbageCollectorComponent { + /** + * object containing garbage collector related metrics + */ + @Input() garbageCollectorMetrics?: GarbageCollector; + + /** + * boolean field saying if the metrics are in the process of being updated + */ + @Input() updating?: boolean; +} diff --git a/src/main/webapp/app/admin/metrics/blocks/metrics-modal-threads/metrics-modal-threads.component.html b/src/main/webapp/app/admin/metrics/blocks/metrics-modal-threads/metrics-modal-threads.component.html new file mode 100644 index 000000000..e725a60e8 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/metrics-modal-threads/metrics-modal-threads.component.html @@ -0,0 +1,90 @@ + + + + diff --git a/src/main/webapp/app/admin/metrics/blocks/metrics-modal-threads/metrics-modal-threads.component.ts b/src/main/webapp/app/admin/metrics/blocks/metrics-modal-threads/metrics-modal-threads.component.ts new file mode 100644 index 000000000..f6dd2816d --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/metrics-modal-threads/metrics-modal-threads.component.ts @@ -0,0 +1,59 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +import { Thread, ThreadState } from 'app/admin/metrics/metrics.model'; + +@Component({ + selector: 'jhi-thread-modal', + templateUrl: './metrics-modal-threads.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MetricsModalThreadsComponent implements OnInit { + ThreadState = ThreadState; + threadStateFilter?: ThreadState; + threads?: Thread[]; + threadDumpAll = 0; + threadDumpBlocked = 0; + threadDumpRunnable = 0; + threadDumpTimedWaiting = 0; + threadDumpWaiting = 0; + + constructor(private activeModal: NgbActiveModal) {} + + ngOnInit(): void { + this.threads?.forEach(thread => { + if (thread.threadState === ThreadState.Runnable) { + this.threadDumpRunnable += 1; + } else if (thread.threadState === ThreadState.Waiting) { + this.threadDumpWaiting += 1; + } else if (thread.threadState === ThreadState.TimedWaiting) { + this.threadDumpTimedWaiting += 1; + } else if (thread.threadState === ThreadState.Blocked) { + this.threadDumpBlocked += 1; + } + }); + + this.threadDumpAll = this.threadDumpRunnable + this.threadDumpWaiting + this.threadDumpTimedWaiting + this.threadDumpBlocked; + } + + getBadgeClass(threadState: ThreadState): string { + if (threadState === ThreadState.Runnable) { + return 'bg-success'; + } else if (threadState === ThreadState.Waiting) { + return 'bg-info'; + } else if (threadState === ThreadState.TimedWaiting) { + return 'bg-warning'; + } else if (threadState === ThreadState.Blocked) { + return 'bg-danger'; + } + return ''; + } + + getThreads(): Thread[] { + return this.threads?.filter(thread => !this.threadStateFilter || thread.threadState === this.threadStateFilter) ?? []; + } + + dismiss(): void { + this.activeModal.dismiss(); + } +} diff --git a/src/main/webapp/app/admin/metrics/blocks/metrics-request/metrics-request.component.html b/src/main/webapp/app/admin/metrics/blocks/metrics-request/metrics-request.component.html new file mode 100644 index 000000000..9be2f80a0 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/metrics-request/metrics-request.component.html @@ -0,0 +1,26 @@ +

HTTP requests (time in millisecond)

+ + + + + + + + + + + + + + + + + + +
CodeCountMeanMax
{{ entry.key }} + + {{ entry.value.count }} + + + {{ filterNaN(entry.value.mean) | number: '1.0-2' }} + {{ entry.value.max | number: '1.0-2' }}
diff --git a/src/main/webapp/app/admin/metrics/blocks/metrics-request/metrics-request.component.ts b/src/main/webapp/app/admin/metrics/blocks/metrics-request/metrics-request.component.ts new file mode 100644 index 000000000..6a7cbc7dd --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/metrics-request/metrics-request.component.ts @@ -0,0 +1,23 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; + +import { HttpServerRequests } from 'app/admin/metrics/metrics.model'; +import { filterNaN } from 'app/core/util/operators'; + +@Component({ + selector: 'jhi-metrics-request', + templateUrl: './metrics-request.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MetricsRequestComponent { + /** + * object containing http request related metrics + */ + @Input() requestMetrics?: HttpServerRequests; + + /** + * boolean field saying if the metrics are in the process of being updated + */ + @Input() updating?: boolean; + + filterNaN = (input: number): number => filterNaN(input); +} diff --git a/src/main/webapp/app/admin/metrics/blocks/metrics-system/metrics-system.component.html b/src/main/webapp/app/admin/metrics/blocks/metrics-system/metrics-system.component.html new file mode 100644 index 000000000..d35be0cf0 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/metrics-system/metrics-system.component.html @@ -0,0 +1,51 @@ +

System

+ + +
+
Uptime
+
{{ convertMillisecondsToDuration(systemMetrics['process.uptime']) }}
+
+ +
+
Start time
+
{{ systemMetrics['process.start.time'] | date: 'full' }}
+
+ +
+
Process CPU usage
+
{{ 100 * systemMetrics['process.cpu.usage'] | number: '1.0-2' }} %
+
+ + + {{ 100 * systemMetrics['process.cpu.usage'] | number: '1.0-2' }} % + + +
+
System CPU usage
+
{{ 100 * systemMetrics['system.cpu.usage'] | number: '1.0-2' }} %
+
+ + + {{ 100 * systemMetrics['system.cpu.usage'] | number: '1.0-2' }} % + + +
+
System CPU count
+
{{ systemMetrics['system.cpu.count'] }}
+
+ +
+
System 1m Load average
+
{{ systemMetrics['system.load.average.1m'] | number: '1.0-2' }}
+
+ +
+
Process files max
+
{{ systemMetrics['process.files.max'] | number: '1.0-0' }}
+
+ +
+
Process files open
+
{{ systemMetrics['process.files.open'] | number: '1.0-0' }}
+
+
diff --git a/src/main/webapp/app/admin/metrics/blocks/metrics-system/metrics-system.component.ts b/src/main/webapp/app/admin/metrics/blocks/metrics-system/metrics-system.component.ts new file mode 100644 index 000000000..f078ada40 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/blocks/metrics-system/metrics-system.component.ts @@ -0,0 +1,43 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; + +import { ProcessMetrics } from 'app/admin/metrics/metrics.model'; + +@Component({ + selector: 'jhi-metrics-system', + templateUrl: './metrics-system.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MetricsSystemComponent { + /** + * object containing thread related metrics + */ + @Input() systemMetrics?: ProcessMetrics; + + /** + * boolean field saying if the metrics are in the process of being updated + */ + @Input() updating?: boolean; + + convertMillisecondsToDuration(ms: number): string { + const times = { + year: 31557600000, + month: 2629746000, + day: 86400000, + hour: 3600000, + minute: 60000, + second: 1000, + }; + let timeString = ''; + for (const [key, value] of Object.entries(times)) { + if (Math.floor(ms / value) > 0) { + let plural = ''; + if (Math.floor(ms / value) > 1) { + plural = 's'; + } + timeString += `${Math.floor(ms / value).toString()} ${key.toString()}${plural} `; + ms = ms - value * Math.floor(ms / value); + } + } + return timeString; + } +} diff --git a/src/main/webapp/app/admin/metrics/metrics.component.html b/src/main/webapp/app/admin/metrics/metrics.component.html new file mode 100644 index 000000000..4445a9f5c --- /dev/null +++ b/src/main/webapp/app/admin/metrics/metrics.component.html @@ -0,0 +1,49 @@ +
+

+ Application Metrics + + +

+ +

JVM Metrics

+ +
+ + + + + +
+ + + +
Updating...
+ + + + + + + + +
diff --git a/src/main/webapp/app/admin/metrics/metrics.component.spec.ts b/src/main/webapp/app/admin/metrics/metrics.component.spec.ts new file mode 100644 index 000000000..4bd325861 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/metrics.component.spec.ts @@ -0,0 +1,41 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { of } from 'rxjs'; + +import { MetricsComponent } from './metrics.component'; +import { MetricsService } from './metrics.service'; +import { Metrics } from './metrics.model'; + +describe('MetricsComponent', () => { + let comp: MetricsComponent; + let fixture: ComponentFixture; + let service: MetricsService; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + declarations: [MetricsComponent], + }) + .overrideTemplate(MetricsComponent, '') + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MetricsComponent); + comp = fixture.componentInstance; + service = TestBed.inject(MetricsService); + }); + + describe('refresh', () => { + it('should call refresh on init', () => { + // GIVEN + jest.spyOn(service, 'getMetrics').mockReturnValue(of({} as Metrics)); + + // WHEN + comp.ngOnInit(); + + // THEN + expect(service.getMetrics).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/main/webapp/app/admin/metrics/metrics.component.ts b/src/main/webapp/app/admin/metrics/metrics.component.ts new file mode 100644 index 000000000..f633ee1fa --- /dev/null +++ b/src/main/webapp/app/admin/metrics/metrics.component.ts @@ -0,0 +1,40 @@ +import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'; +import { combineLatest } from 'rxjs'; + +import { MetricsService } from './metrics.service'; +import { Metrics, Thread } from './metrics.model'; + +@Component({ + selector: 'jhi-metrics', + templateUrl: './metrics.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MetricsComponent implements OnInit { + metrics?: Metrics; + threads?: Thread[]; + updatingMetrics = true; + + constructor(private metricsService: MetricsService, private changeDetector: ChangeDetectorRef) {} + + ngOnInit(): void { + this.refresh(); + } + + refresh(): void { + this.updatingMetrics = true; + combineLatest([this.metricsService.getMetrics(), this.metricsService.threadDump()]).subscribe(([metrics, threadDump]) => { + this.metrics = metrics; + this.threads = threadDump.threads; + this.updatingMetrics = false; + this.changeDetector.markForCheck(); + }); + } + + metricsKeyExists(key: keyof Metrics): boolean { + return Boolean(this.metrics?.[key]); + } + + metricsKeyExistsAndObjectNotEmpty(key: keyof Metrics): boolean { + return Boolean(this.metrics?.[key] && JSON.stringify(this.metrics[key]) !== '{}'); + } +} diff --git a/src/main/webapp/app/admin/metrics/metrics.model.ts b/src/main/webapp/app/admin/metrics/metrics.model.ts new file mode 100644 index 000000000..d9576a903 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/metrics.model.ts @@ -0,0 +1,159 @@ +export interface Metrics { + jvm: { [key: string]: JvmMetrics }; + databases: Databases; + 'http.server.requests': HttpServerRequests; + cache: { [key: string]: CacheMetrics }; + garbageCollector: GarbageCollector; + services: Services; + processMetrics: ProcessMetrics; +} + +export interface JvmMetrics { + committed: number; + max: number; + used: number; +} + +export interface Databases { + min: Value; + idle: Value; + max: Value; + usage: MetricsWithPercentile; + pending: Value; + active: Value; + acquire: MetricsWithPercentile; + creation: MetricsWithPercentile; + connections: Value; +} + +export interface Value { + value: number; +} + +export interface MetricsWithPercentile { + '0.0': number; + '1.0': number; + max: number; + totalTime: number; + mean: number; + '0.5': number; + count: number; + '0.99': number; + '0.75': number; + '0.95': number; +} + +export interface HttpServerRequests { + all: { + count: number; + }; + percode: { [key: string]: MaxMeanCount }; +} + +export interface MaxMeanCount { + max: number; + mean: number; + count: number; +} + +export interface CacheMetrics { + 'cache.gets.miss': number; + 'cache.puts': number; + 'cache.gets.hit': number; + 'cache.removals': number; + 'cache.evictions': number; +} + +export interface GarbageCollector { + 'jvm.gc.max.data.size': number; + 'jvm.gc.pause': MetricsWithPercentile; + 'jvm.gc.memory.promoted': number; + 'jvm.gc.memory.allocated': number; + classesLoaded: number; + 'jvm.gc.live.data.size': number; + classesUnloaded: number; +} + +export interface Services { + [key: string]: { + [key in HttpMethod]?: MaxMeanCount; + }; +} + +export enum HttpMethod { + Post = 'POST', + Get = 'GET', + Put = 'PUT', + Patch = 'PATCH', + Delete = 'DELETE', +} + +export interface ProcessMetrics { + 'system.cpu.usage': number; + 'system.cpu.count': number; + 'system.load.average.1m'?: number; + 'process.cpu.usage': number; + 'process.files.max'?: number; + 'process.files.open'?: number; + 'process.start.time': number; + 'process.uptime': number; +} + +export interface ThreadDump { + threads: Thread[]; +} + +export interface Thread { + threadName: string; + threadId: number; + blockedTime: number; + blockedCount: number; + waitedTime: number; + waitedCount: number; + lockName: string | null; + lockOwnerId: number; + lockOwnerName: string | null; + daemon: boolean; + inNative: boolean; + suspended: boolean; + threadState: ThreadState; + priority: number; + stackTrace: StackTrace[]; + lockedMonitors: LockedMonitor[]; + lockedSynchronizers: string[]; + lockInfo: LockInfo | null; + // custom field for showing-hiding thread dump + showThreadDump?: boolean; +} + +export interface LockInfo { + className: string; + identityHashCode: number; +} + +export interface LockedMonitor { + className: string; + identityHashCode: number; + lockedStackDepth: number; + lockedStackFrame: StackTrace; +} + +export interface StackTrace { + classLoaderName: string | null; + moduleName: string | null; + moduleVersion: string | null; + methodName: string; + fileName: string; + lineNumber: number; + className: string; + nativeMethod: boolean; +} + +export enum ThreadState { + Runnable = 'RUNNABLE', + TimedWaiting = 'TIMED_WAITING', + Waiting = 'WAITING', + Blocked = 'BLOCKED', + New = 'NEW', + Terminated = 'TERMINATED', +} diff --git a/src/main/webapp/app/admin/metrics/metrics.module.ts b/src/main/webapp/app/admin/metrics/metrics.module.ts new file mode 100644 index 000000000..e96c87b89 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/metrics.module.ts @@ -0,0 +1,32 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +import { SharedModule } from 'app/shared/shared.module'; +import { MetricsComponent } from './metrics.component'; +import { metricsRoute } from './metrics.route'; +import { JvmMemoryComponent } from './blocks/jvm-memory/jvm-memory.component'; +import { JvmThreadsComponent } from './blocks/jvm-threads/jvm-threads.component'; +import { MetricsCacheComponent } from './blocks/metrics-cache/metrics-cache.component'; +import { MetricsDatasourceComponent } from './blocks/metrics-datasource/metrics-datasource.component'; +import { MetricsEndpointsRequestsComponent } from './blocks/metrics-endpoints-requests/metrics-endpoints-requests.component'; +import { MetricsGarbageCollectorComponent } from './blocks/metrics-garbagecollector/metrics-garbagecollector.component'; +import { MetricsModalThreadsComponent } from './blocks/metrics-modal-threads/metrics-modal-threads.component'; +import { MetricsRequestComponent } from './blocks/metrics-request/metrics-request.component'; +import { MetricsSystemComponent } from './blocks/metrics-system/metrics-system.component'; + +@NgModule({ + imports: [SharedModule, RouterModule.forChild([metricsRoute])], + declarations: [ + MetricsComponent, + JvmMemoryComponent, + JvmThreadsComponent, + MetricsCacheComponent, + MetricsDatasourceComponent, + MetricsEndpointsRequestsComponent, + MetricsGarbageCollectorComponent, + MetricsModalThreadsComponent, + MetricsRequestComponent, + MetricsSystemComponent, + ], +}) +export class MetricsModule {} diff --git a/src/main/webapp/app/admin/metrics/metrics.route.ts b/src/main/webapp/app/admin/metrics/metrics.route.ts new file mode 100644 index 000000000..deee47bb0 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/metrics.route.ts @@ -0,0 +1,11 @@ +import { Route } from '@angular/router'; + +import { MetricsComponent } from './metrics.component'; + +export const metricsRoute: Route = { + path: '', + component: MetricsComponent, + data: { + pageTitle: 'Application Metrics', + }, +}; diff --git a/src/main/webapp/app/admin/metrics/metrics.service.spec.ts b/src/main/webapp/app/admin/metrics/metrics.service.spec.ts new file mode 100644 index 000000000..468ebd52d --- /dev/null +++ b/src/main/webapp/app/admin/metrics/metrics.service.spec.ts @@ -0,0 +1,81 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + +import { MetricsService } from './metrics.service'; +import { ThreadDump, ThreadState } from './metrics.model'; + +describe('Logs Service', () => { + let service: MetricsService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + }); + service = TestBed.inject(MetricsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + describe('Service methods', () => { + it('should return Metrics', () => { + let expectedResult; + const metrics = { + jvm: {}, + 'http.server.requests': {}, + cache: {}, + services: {}, + databases: {}, + garbageCollector: {}, + processMetrics: {}, + }; + + service.getMetrics().subscribe(received => { + expectedResult = received; + }); + + const req = httpMock.expectOne({ method: 'GET' }); + req.flush(metrics); + expect(expectedResult).toEqual(metrics); + }); + + it('should return Thread Dump', () => { + let expectedResult: ThreadDump | null = null; + const dump: ThreadDump = { + threads: [ + { + threadName: 'Reference Handler', + threadId: 2, + blockedTime: -1, + blockedCount: 7, + waitedTime: -1, + waitedCount: 0, + lockName: null, + lockOwnerId: -1, + lockOwnerName: null, + daemon: true, + inNative: false, + suspended: false, + threadState: ThreadState.Runnable, + priority: 10, + stackTrace: [], + lockedMonitors: [], + lockedSynchronizers: [], + lockInfo: null, + }, + ], + }; + + service.threadDump().subscribe(received => { + expectedResult = received; + }); + + const req = httpMock.expectOne({ method: 'GET' }); + req.flush(dump); + expect(expectedResult).toEqual(dump); + }); + }); +}); diff --git a/src/main/webapp/app/admin/metrics/metrics.service.ts b/src/main/webapp/app/admin/metrics/metrics.service.ts new file mode 100644 index 000000000..1d27f2b13 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/metrics.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { ApplicationConfigService } from 'app/core/config/application-config.service'; +import { Metrics, ThreadDump } from './metrics.model'; + +@Injectable({ providedIn: 'root' }) +export class MetricsService { + constructor(private http: HttpClient, private applicationConfigService: ApplicationConfigService) {} + + getMetrics(): Observable { + return this.http.get(this.applicationConfigService.getEndpointFor('management/jhimetrics')); + } + + threadDump(): Observable { + return this.http.get(this.applicationConfigService.getEndpointFor('management/threaddump')); + } +}