added last downtime log (closes #55)
added uptime statistics (closes #56) added german translations (closes #57)pull/59/head
parent
acb39f6b2a
commit
e9599373ec
@ -0,0 +1,8 @@
|
||||
import { DayjsPipe } from './dayjs.pipe';
|
||||
|
||||
describe('DayjsPipe', () => {
|
||||
it('create an instance', () => {
|
||||
const pipe = new DayjsPipe();
|
||||
expect(pipe).toBeTruthy();
|
||||
});
|
||||
});
|
@ -0,0 +1,36 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import * as dayjs from 'dayjs';
|
||||
import * as utc from 'dayjs/plugin/utc';
|
||||
import * as relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import * as localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||
import {TranslateService} from '@ngx-translate/core';
|
||||
import 'dayjs/locale/de';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(localizedFormat);
|
||||
|
||||
@Pipe({
|
||||
name: 'dayjs',
|
||||
pure: false
|
||||
})
|
||||
export class DayjsPipe implements PipeTransform {
|
||||
constructor(private translate: TranslateService) {
|
||||
}
|
||||
|
||||
transform(value: string | Date, method: string, ...args: any[]): string {
|
||||
const date = dayjs.utc(value);
|
||||
switch (method) {
|
||||
case 'to':
|
||||
const to = args[0] ? dayjs.utc(args[0]) : dayjs.utc();
|
||||
const suffix = args.length > 1 && args[1] === true;
|
||||
return date.locale(this.translate.currentLang).to(to, !suffix);
|
||||
case 'from':
|
||||
const from = args[0] ? dayjs.utc(args[0]) : dayjs.utc();
|
||||
return date.locale(this.translate.currentLang).from(from);
|
||||
case 'format':
|
||||
return date.local().locale(this.translate.currentLang).format(args[0]);
|
||||
}
|
||||
throw new Error('please pass a method to use!');
|
||||
}
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ApiService } from './api.service';
|
||||
|
||||
describe('ApiService', () => {
|
||||
let service: ApiService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(ApiService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
@ -0,0 +1,30 @@
|
||||
import {Inject, Injectable, PLATFORM_ID} from '@angular/core';
|
||||
import {isPlatformBrowser} from '@angular/common';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class StorageService {
|
||||
constructor(@Inject(PLATFORM_ID) private platformId: Object) {
|
||||
}
|
||||
|
||||
getValue(key: string): any {
|
||||
if (!isPlatformBrowser(this.platformId)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(key));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
setValue(key: string, value: any): void {
|
||||
if (isPlatformBrowser(this.platformId)) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,35 +0,0 @@
|
||||
import { TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
RouterTestingModule
|
||||
],
|
||||
declarations: [
|
||||
AppComponent
|
||||
],
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it(`should have as title 'universal-statuspage'`, () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app.title).toEqual('universal-statuspage');
|
||||
});
|
||||
|
||||
it('should render title', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.content span').textContent).toContain('universal-statuspage app is running!');
|
||||
});
|
||||
});
|
@ -1,25 +1,38 @@
|
||||
<mat-accordion [multi]="true">
|
||||
<mat-expansion-panel *ngFor="let group of groups" [expanded]="true">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<i [class]="stateClasses[group.state]"></i>
|
||||
<a *ngIf="group.url" class="name" [href]="group.url" target="_blank" (click)="$event.stopPropagation()">{{group.name}}</a>
|
||||
<span *ngIf="!group.url">{{group.name}}</span>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<div class="py-3" *ngFor="let group of groups">
|
||||
<h2>
|
||||
<i [class]="stateClasses[group.state]"></i>
|
||||
<a *ngIf="group.url" [href]="group.url" target="_blank"
|
||||
(click)="$event.stopPropagation()">{{group.name}}</a>
|
||||
<span *ngIf="!group.url">{{group.name}}</span>
|
||||
</h2>
|
||||
<mat-accordion [multi]="true">
|
||||
<mat-expansion-panel *ngFor="let service of group.services" [hideToggle]="true"
|
||||
[(expanded)]="expandedCache[group.id + '-' + service.id]"
|
||||
(afterExpand)="saveExpandedCache()" (afterCollapse)="saveExpandedCache()">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<div matLine class="w-100 d-flex">
|
||||
<i [class]="stateClasses[service.state]"></i>
|
||||
<a *ngIf="service.url" class="text-truncate" [href]="service.url" target="_blank"
|
||||
(click)="$event.stopPropagation()">{{service.name}}</a>
|
||||
<span *ngIf="!service.url" class="text-truncate">{{service.name}}</span>
|
||||
<span class="ml-2 font-weight-normal d-none d-sm-block" [class.operational]="service.uptime >= 99"
|
||||
[class.maintenance]="service.uptime < 99 && service.uptime >= 95"
|
||||
[class.outage]="service.uptime < 95">{{service.uptime?.toFixed(2)}}%</span>
|
||||
<span class="flex-grow-1"></span>
|
||||
<span class="{{service.state}}">{{'state.' + service.state | translate}}</span>
|
||||
</div>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
|
||||
<mat-list>
|
||||
<a *ngFor="let service of group.services; last as last" mat-list-item [href]="service.url || group.url || '#'" target="_blank">
|
||||
<div matLine class="d-flex">
|
||||
<i [class]="stateClasses[service.state]"></i>
|
||||
<span class="text-truncate">{{service.name}}</span>
|
||||
<span class="flex-grow-1"></span>
|
||||
<span class="text-capitalize {{service.state}}">{{service.state}}</span>
|
||||
</div>
|
||||
<mat-divider [inset]="true" *ngIf="!last"></mat-divider>
|
||||
</a>
|
||||
</mat-list>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
<ng-template matExpansionPanelContent>
|
||||
<app-uptime [id]="service.id"></app-uptime>
|
||||
</ng-template>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
</div>
|
||||
|
||||
<div class="text-right mt-3"><small>Last updated {{lastUpdated | date:'HH:mm:ss'}}</small></div>
|
||||
<div class="text-right pb-3"><small matTooltip="{{lastUpdated | dayjs:'format':'LTS'}}" matTooltipPosition="above"
|
||||
[matTooltipShowDelay]="0"
|
||||
[matTooltipHideDelay]="0">{{'last-updated' | translate:{'time': lastUpdated | dayjs:'from'} }}</small>
|
||||
</div>
|
||||
|
@ -1,25 +0,0 @@
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
|
||||
import { StatusComponent } from './status.component';
|
||||
|
||||
describe('StatusComponent', () => {
|
||||
let component: StatusComponent;
|
||||
let fixture: ComponentFixture<StatusComponent>;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ StatusComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(StatusComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
@ -0,0 +1,70 @@
|
||||
<div *ngIf="uptime$ | async as uptime; else loadingOrError">
|
||||
<h2 class="m-0">{{'uptime.title' | translate}}</h2>
|
||||
|
||||
<div class="row m-0">
|
||||
<div class="col-6 col-md-3 p-0">
|
||||
<div class="my-4 px-4 border-right">
|
||||
<h1 class="m-0">{{uptime.hours24?.toFixed(2)}}%</h1>
|
||||
{{'uptime.last24hours' | translate}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3 p-0">
|
||||
<div class="my-4 px-4 border-md-right">
|
||||
<h1 class="m-0">{{uptime.days7?.toFixed(2)}}%</h1>
|
||||
{{'uptime.last7days' | translate}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3 p-0">
|
||||
<div class="my-4 px-4 border-right">
|
||||
<h1 class="m-0">{{uptime.days30?.toFixed(2)}}%</h1>
|
||||
{{'uptime.last30days' | translate}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3 p-0">
|
||||
<div class="my-4 px-4">
|
||||
<h1 class="m-0">{{uptime.days90?.toFixed(2)}}%</h1>
|
||||
{{'uptime.last90days' | translate}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex mb-4" style="height: 2rem">
|
||||
<ng-container *ngFor="let day of uptime.days; index as index">
|
||||
<div class="flex-grow-1" style="margin: 1px"
|
||||
[class.d-none]="index < 60" [class.d-lg-block]="index < 30" [class.d-sm-block]="index >= 30 && index < 60"
|
||||
[class.bg-operational]="day.uptime >= 99" [class.bg-maintenance]="day.uptime < 99 && day.uptime >= 95"
|
||||
[class.bg-outage]="day.uptime < 95"
|
||||
matTooltip="{{day.date | dayjs:'format':'l'}} {{day.uptime.toFixed(2)}}%" matTooltipPosition="above"
|
||||
[matTooltipShowDelay]="0" [matTooltipHideDelay]="0" matTooltipClass="multiline-tooltip"></div>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div *ngIf="uptime.events.length">
|
||||
<h2 class="m-0">{{'recent-events.title' | translate}}</h2>
|
||||
<mat-list>
|
||||
<ng-container *ngFor="let event of uptime.events; index as index; last as last">
|
||||
<mat-list-item *ngIf="index < 4 || expanded">
|
||||
<i mat-list-icon [class]="stateClasses[event.state]"></i>
|
||||
<p matLine>
|
||||
<span *ngIf="event.state === 'operational'"
|
||||
class="text-truncate">{{'recent-events.operational' | translate}}</span>
|
||||
<span *ngIf="event.state === 'maintenance'"
|
||||
class="text-truncate">{{'recent-events.maintenance' | translate: {'time': event.date | dayjs:'to':uptime.events[index - 1]?.date} }}</span>
|
||||
<span *ngIf="event.state === 'outage'"
|
||||
class="text-truncate">{{'recent-events.outage' | translate:{'time': event.date | dayjs:'to':uptime.events[index - 1]?.date} }}</span>
|
||||
</p>
|
||||
<div matLine><small matTooltip="{{event.date | dayjs:'format':'LLL'}}" matTooltipPosition="above"
|
||||
[matTooltipShowDelay]="0" [matTooltipHideDelay]="0">{{event.date | dayjs:'from'}}</small>
|
||||
</div>
|
||||
<mat-divider [inset]="true"
|
||||
*ngIf="!(last || uptime.events.length > 4 && !expanded && index >= 3)"></mat-divider>
|
||||
</mat-list-item>
|
||||
</ng-container>
|
||||
</mat-list>
|
||||
<a href="#" class="mt-3" *ngIf="uptime.events.length > 4"
|
||||
(click)="toggleExpanded(); $event.preventDefault()">{{(expanded ? 'show-less' : 'show-all') | translate}}</a>
|
||||
</div>
|
||||
</div>
|
||||
<ng-template #loadingOrError>
|
||||
<div *ngIf="error">{{error}}</div>
|
||||
<div *ngIf="!error">{{'loading' | translate}}</div>
|
||||
</ng-template>
|
@ -0,0 +1,52 @@
|
||||
import {Component, Input, OnInit} from '@angular/core';
|
||||
import {Observable, of} from 'rxjs';
|
||||
import {UptimeStatus} from '../_data/data';
|
||||
import {ApiService} from '../_service/api.service';
|
||||
import {catchError} from 'rxjs/operators';
|
||||
import {StorageService} from '../_service/storage.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-uptime',
|
||||
templateUrl: './uptime.component.html',
|
||||
styleUrls: ['./uptime.component.scss']
|
||||
})
|
||||
export class UptimeComponent implements OnInit {
|
||||
@Input() id: string;
|
||||
readonly stateClasses = {
|
||||
'operational': 'fas fa-fw fa-heart operational mr-2',
|
||||
'outage': 'fas fa-fw fa-heart-broken outage mr-2',
|
||||
'maintenance': 'fas fa-fw fa-heartbeat maintenance mr-2'
|
||||
};
|
||||
uptime$: Observable<UptimeStatus>;
|
||||
error: string;
|
||||
expanded: boolean;
|
||||
|
||||
constructor(private api: ApiService, private storage: StorageService) {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (!this.id) {
|
||||
throw new Error('please pass a service id!');
|
||||
}
|
||||
let value = this.storage.getValue('show-events-' + this.id);
|
||||
console.log(value, typeof value);
|
||||
if (typeof value !== 'boolean') {
|
||||
value = false;
|
||||
}
|
||||
this.expanded = value;
|
||||
this.uptime$ = this.api.getServiceUptime(this.id)
|
||||
.pipe(catchError(err => {
|
||||
if (err.status === 404) {
|
||||
this.error = 'No uptime information available.';
|
||||
} else {
|
||||
this.error = 'An unexpected error occurred: ' + err.error;
|
||||
}
|
||||
return of(null);
|
||||
}));
|
||||
}
|
||||
|
||||
toggleExpanded(): void {
|
||||
this.expanded = !this.expanded;
|
||||
this.storage.setValue('show-events-' + this.id, this.expanded);
|
||||
}
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
{
|
||||
"state": {
|
||||
"operational": "Funktionsfähig",
|
||||
"maintenance": "Wartung",
|
||||
"outage": "Ausfall"
|
||||
},
|
||||
"uptime": {
|
||||
"title": "Verfügbarkeit",
|
||||
"last24hours": "Letzte 24 Stunden",
|
||||
"last7days": "Letzte 7 Tage",
|
||||
"last30days": "Letzte 30 Tage",
|
||||
"last90days": "Letzte 90 Tage"
|
||||
},
|
||||
"recent-events": {
|
||||
"title": "Letzte Ereignisse",
|
||||
"operational": "Wieder funktionsfähig",
|
||||
"maintenance": "Wartung für {{time}}",
|
||||
"outage": "Ausfall für {{time}}"
|
||||
},
|
||||
"last-updated": "Aktualisiert {{time}}",
|
||||
"loading": "Lade...",
|
||||
"show-all": "Alle anzeigen",
|
||||
"show-less": "Weniger anzeigen"
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
{
|
||||
"state": {
|
||||
"operational": "Operational",
|
||||
"maintenance": "Maintenance",
|
||||
"outage": "Outage"
|
||||
},
|
||||
"uptime": {
|
||||
"title": "Uptime",
|
||||
"last24hours": "Last 24 hours",
|
||||
"last7days": "Last 7 days",
|
||||
"last30days": "Last 30 days",
|
||||
"last90days": "Last 90 days"
|
||||
},
|
||||
"recent-events": {
|
||||
"title": "Recent events",
|
||||
"operational": "Operational again",
|
||||
"maintenance": "Maintenance for {{time}}",
|
||||
"outage": "Outage for {{time}}"
|
||||
},
|
||||
"last-updated": "Last updated {{time}}",
|
||||
"loading": "Loading...",
|
||||
"show-all": "Show all",
|
||||
"show-less": "Show less"
|
||||
}
|