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 }}
+
+
+
+
+
+ Property |
+ Value |
+
+
+
+
+ {{ 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 name |
+ Status |
+ Details |
+
+
+
+
+
+ {{ 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 @@
+
+
+
+
+
Properties
+
+
+
+
+
+ Name |
+ Value |
+
+
+
+
+ {{ healthDetail.key }} |
+ {{ readableValue(healthDetail.value) }} |
+
+
+
+
+
+
+
+
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 name |
+ Cache Hits |
+ Cache Misses |
+ Cache Gets |
+ Cache Puts |
+ Cache Removals |
+ Cache Evictions |
+ Cache 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 }})
+ |
+ Count |
+ Mean |
+ Min |
+ p50 |
+ p75 |
+ p95 |
+ p99 |
+ Max |
+
+
+
+
+ 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)
+
+
+
+
+
+ Method |
+ Endpoint url |
+ Count |
+ Mean |
+
+
+
+
+
+ {{ 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 }}
+
+
+
+
+
+
+
+ |
+ Count |
+ Mean |
+ Min |
+ p50 |
+ p75 |
+ p95 |
+ p99 |
+ Max |
+
+
+
+
+ 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 @@
+
+
+
+
+
+
+ All {{ threadDumpAll }}
+
+
+
+
+ Runnable {{ threadDumpRunnable }}
+
+
+
+
+ Waiting {{ threadDumpWaiting }}
+
+
+
+
+ Timed Waiting {{ threadDumpTimedWaiting }}
+
+
+
+
+ Blocked {{ threadDumpBlocked }}
+
+
+
+
+
+ {{ thread.threadState }}
+
+ {{ thread.threadName }} (ID {{ thread.threadId }})
+
+
+ Show Stacktrace
+ Hide Stacktrace
+
+
+
+
+
+
+ {{ st.className }}.{{ st.methodName }}({{ st.fileName }}:{{ st.lineNumber }}
)
+
+
+
+
+
+
+ Threads dump:
+ {{
+ thread.threadName
+ }}
+
+
+
+ Blocked Time |
+ Blocked Count |
+ Waited Time |
+ Waited Count |
+ Lock name |
+
+
+
+
+ {{ thread.blockedTime }} |
+ {{ thread.blockedCount }} |
+ {{ thread.waitedTime }} |
+ {{ thread.waitedCount }} |
+
+ {{ thread.lockName }}
+ |
+
+
+
+
+
+
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)
+
+
+
+
+ Code |
+ Count |
+ Mean |
+ Max |
+
+
+
+
+ {{ 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'));
+ }
+}
diff --git a/src/main/webapp/app/layouts/navbar/navbar.component.html b/src/main/webapp/app/layouts/navbar/navbar.component.html
index 196f4352b..7816c4f89 100644
--- a/src/main/webapp/app/layouts/navbar/navbar.component.html
+++ b/src/main/webapp/app/layouts/navbar/navbar.component.html
@@ -93,6 +93,30 @@