added last downtime log (closes #55)

added uptime statistics (closes #56)
added german translations (closes #57)
This commit is contained in:
Samuel Philipp 2021-01-10 16:06:18 +01:00
parent acb39f6b2a
commit e9599373ec
27 changed files with 819 additions and 265 deletions

1
.gitignore vendored
View File

@ -49,3 +49,4 @@ Thumbs.db
# custom
config.json
cache.json
uptime.json

View File

@ -17,12 +17,14 @@
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"allowedCommonJsDependencies": ["dayjs/locale/de"],
"outputPath": "dist/universal-statuspage/browser",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.app.json",
"aot": true,
"outputHashing": "media",
"assets": [
"src/favicon.png",
"src/favicon-operational.ico",

View File

@ -2,22 +2,44 @@
"authToken": "test",
"title": "sp-status",
"description": "Services hosted by sp-codes",
"translations": {
"de": {
"title": "sp-status",
"description": "Services von sp-codes bereitgestellt"
}
},
"servicesPath": "$.alerts.*",
"idPath": "$.labels.status_service",
"statePath": "$.status",
"stateValues": {
"operational": ["ok", "resolved"],
"maintenance": ["paused"]
"maintenance": ["maintenance" ,"paused"]
},
"groups": [
{
"id": "test",
"name": "Test",
"id": "group",
"name": "My Group",
"url": "http://sp-codes.de",
"services": [
{
"id": "test",
"name": "test",
"name": "My Service",
"url": "http://sp-codes.de",
"statePath": "$.state"
}, {
"id": "test3",
"name": "Test3",
"statePath": "$.state"
}
]
}, {
"id": "group2",
"name": "Group2",
"services": [
{
"id": "test2",
"name": "Test2",
"url": "http://sp-codes.de",
"statePath": "$.state"
}
]

View File

@ -28,12 +28,20 @@
"@angular/router": "~11.0.2",
"@fortawesome/fontawesome-free": "^5.15.1",
"@nguniversal/express-engine": "^11.0.0",
"@ngx-translate/core": "^13.0.0",
"@ngx-translate/http-loader": "^6.0.0",
"@types/node-cron": "^2.0.3",
"bootstrap": "^4.5.3",
"cron": "^1.8.2",
"dayjs": "^1.10.2",
"express": "^4.17.1",
"flag-icon-css": "^3.5.0",
"jsonpath-plus": "^4.0.0",
"node-cron": "^2.0.3",
"roboto-fontface": "^0.10.0",
"rxjs": "~6.6.3",
"tslib": "^2.0.0",
"tz-offset": "0.0.2",
"zone.js": "~0.10.2"
},
"devDependencies": {

View File

@ -1,6 +1,6 @@
import 'zone.js/dist/zone-node';
import {ngExpressEngine} from '@nguniversal/express-engine';
import {ngExpressEngine, RenderOptions} from '@nguniversal/express-engine';
import * as express from 'express';
import {join} from 'path';
@ -17,14 +17,26 @@ export function app() {
const indexHtml = existsSync(join(distFolder, 'index.original.html')) ? 'index.original.html' : 'index';
// Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine)
server.engine('html', ngExpressEngine({
bootstrap: AppServerModule,
}));
// server.engine('html', ngExpressEngine({
// bootstrap: AppServerModule,
// }));
server.engine('html', (path: string, options: Readonly<RenderOptions>, callback) => {
const engine = ngExpressEngine({
bootstrap: AppServerModule,
providers: [
{provide: 'REQUEST', useFactory: () => options.req, deps: []}
]
});
engine(path, options, callback);
});
server.set('view engine', 'html');
server.set('views', distFolder);
server.use('/api', api);
server.get('/favicon.ico', (req, res) => {
return res.sendStatus(404);
});
// Serve static files from /browser
server.get('*.*', express.static(distFolder, {

View File

@ -18,9 +18,31 @@ export interface Service {
name: string;
url?: string;
state: State;
uptime: number;
}
export interface MetaInfo {
title: string;
description: string;
translations?: {
[lang: string]: {
title: string;
description: string;
}
}
}
export interface UptimeStatus {
hours24: number;
days7: number;
days30: number;
days90: number;
days: {
date: Date;
uptime: number;
}[];
events: {
state: State;
date: Date;
}[];
}

View File

@ -0,0 +1,8 @@
import { DayjsPipe } from './dayjs.pipe';
describe('DayjsPipe', () => {
it('create an instance', () => {
const pipe = new DayjsPipe();
expect(pipe).toBeTruthy();
});
});

View File

@ -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!');
}
}

View File

@ -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();
});
});

View File

@ -1,9 +1,9 @@
import {Inject, Injectable, PLATFORM_ID} from '@angular/core';
import {Observable} from "rxjs";
import {CurrentStatus, MetaInfo} from "../_data/data";
import {HttpClient} from "@angular/common/http";
import {environment} from "../../environments/environment";
import {isPlatformBrowser} from "@angular/common";
import {Observable, of} from 'rxjs';
import {CurrentStatus, MetaInfo, UptimeStatus} from '../_data/data';
import {HttpClient} from '@angular/common/http';
import {environment} from '../../environments/environment';
import {isPlatformBrowser} from '@angular/common';
@Injectable({
providedIn: 'root'
@ -16,10 +16,14 @@ export class ApiService {
}
public getServiceStates(): Observable<CurrentStatus> {
return this.http.get<CurrentStatus>(this.api+ '/status');
return this.http.get<CurrentStatus>(this.api + '/status');
}
public getServiceUptime(id: string): Observable<UptimeStatus> {
return this.http.get<UptimeStatus>(this.api + '/uptime', {params: {service: id}});
}
public getMetaInfo(): Observable<MetaInfo> {
return this.http.get<MetaInfo>(this.api+ '/info');
return this.http.get<MetaInfo>(this.api + '/info');
}
}

View File

@ -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) {
}
}
}
}

View File

@ -1,7 +1,22 @@
<div class="box">
<header class="container pt-4">
<h1 *ngIf="title && title.length">{{title}}</h1>
<h3 *ngIf="description && description.length">{{description}}</h3>
<div class="d-flex">
<div *ngIf="title && title.length">
<h1>{{translations[getLanguage()]?.title || title}}</h1>
</div>
<div class="flex-grow-1"></div>
<div class="language-selection">
<a href="#" class="mr-2" (click)="setLanguage('en'); $event.preventDefault();">
<span class="flag-icon flag-icon-us"></span>
</a>
<a href="#" (click)="setLanguage('de'); $event.preventDefault();">
<span class="flag-icon flag-icon-de"></span>
</a>
</div>
</div>
<div *ngIf="description && description.length">
<h3>{{translations[getLanguage()]?.description || description}}</h3>
</div>
</header>
<main class="container">

View File

@ -19,12 +19,6 @@ footer {
flex-shrink: 0;
}
a {
color: #cccccc;
text-decoration: none;
&:hover {
color: #ffffff;
text-decoration: underline;
}
.language-selection {
font-size: 1.3em;
}

View File

@ -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!');
});
});

View File

@ -1,8 +1,9 @@
import {Component, OnInit} from '@angular/core';
import {ApiService} from "./_service/api.service";
import {Observable} from "rxjs";
import {MetaInfo} from "./_data/data";
import {Title} from "@angular/platform-browser";
import {Component, Inject, Injector, OnInit, PLATFORM_ID} from '@angular/core';
import {ApiService} from './_service/api.service';
import {Title} from '@angular/platform-browser';
import {TranslateService} from '@ngx-translate/core';
import {StorageService} from './_service/storage.service';
import {isPlatformServer} from '@angular/common';
@Component({
selector: 'app-root',
@ -12,15 +13,42 @@ import {Title} from "@angular/platform-browser";
export class AppComponent implements OnInit {
title: string;
description: string;
translations: { [lang: string]: { title: string; description: string } };
constructor(private api: ApiService, private htmlTitle: Title) {
private supportedLanguages = ['en', 'de'];
constructor(private translate: TranslateService, private api: ApiService,
private storage: StorageService, private htmlTitle: Title,
private injector: Injector, @Inject(PLATFORM_ID) private platformId: Object) {
this.translate.setDefaultLang('en');
if (isPlatformServer(platformId)) {
const request = this.injector.get<any>(<any> 'REQUEST');
const requestLanguage = request.acceptsLanguages(this.supportedLanguages) || 'en';
this.translate.use(requestLanguage);
return;
}
let language = this.storage.getValue('language') || this.translate.getBrowserLang();
if (language ! in this.supportedLanguages) {
language = 'en';
}
translate.use(language);
}
ngOnInit(): void {
this.api.getMetaInfo().subscribe(info => {
this.title = info.title;
this.description = info.description;
this.translations = info.translations;
this.htmlTitle.setTitle(this.title);
})
});
}
getLanguage(): string {
return this.translate.currentLang;
}
setLanguage(language: string): void {
this.translate.use(language);
this.storage.setValue('language', language);
}
}

View File

@ -1,26 +1,56 @@
import {BrowserModule} from '@angular/platform-browser';
import {NgModule} from '@angular/core';
import {NgModule, PLATFORM_ID} from '@angular/core';
import {AppRoutingModule} from './app-routing.module';
import {AppComponent} from './app.component';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {StatusComponent} from './status/status.component';
import {MatExpansionModule} from "@angular/material/expansion";
import {MatListModule} from "@angular/material/list";
import {HttpClientModule} from "@angular/common/http";
import {MatExpansionModule} from '@angular/material/expansion';
import {MatListModule} from '@angular/material/list';
import {HttpClient, HttpClientModule} from '@angular/common/http';
import {MatTooltipModule} from '@angular/material/tooltip';
import {UptimeComponent} from './uptime/uptime.component';
import {DayjsPipe} from './_pipe/dayjs.pipe';
import {TranslateLoader, TranslateModule} from '@ngx-translate/core';
import {TranslateHttpLoader} from '@ngx-translate/http-loader';
import {isPlatformServer} from '@angular/common';
import {from, Observable} from 'rxjs';
export class TranslateUniversalLoader extends TranslateLoader {
getTranslation(lang: string): Observable<any> {
return from(import(`../assets/i18n/${lang}.json`));
}
}
export function UniversalLoaderFactory(http: HttpClient, plattformId: Object) {
if (isPlatformServer(plattformId)) {
return new TranslateUniversalLoader();
}
return new TranslateHttpLoader(http);
}
@NgModule({
declarations: [
AppComponent,
StatusComponent
StatusComponent,
UptimeComponent,
DayjsPipe
],
imports: [
BrowserModule.withServerTransition({appId: 'serverApp'}),
AppRoutingModule,
BrowserAnimationsModule,
HttpClientModule,
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useFactory: UniversalLoaderFactory,
deps: [HttpClient, PLATFORM_ID]
}
}),
MatExpansionModule,
MatListModule
MatListModule,
MatTooltipModule
],
providers: [],
bootstrap: [AppComponent]

View File

@ -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>

View File

@ -1,23 +1,5 @@
.operational {
color: #7ed321;
}
.outage {
color: #ff6f6f;
}
.maintenance {
color: #f7ca18;
}
a {
text-decoration: none;
outline: none;
color: #ffffff;
&:hover.name, &:hover .name {
text-decoration: underline;
}
}
mat-panel-title {

View File

@ -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();
});
});

View File

@ -1,11 +1,10 @@
import {Component, Inject, OnDestroy, OnInit, PLATFORM_ID} from '@angular/core';
import {ApiService} from "../_service/api.service";
import {Group} from "../_data/data";
import {interval, Subject} from "rxjs";
import {flatMap, startWith, takeUntil} from "rxjs/operators";
import {DOCUMENT, isPlatformBrowser} from "@angular/common";
// import {DOCUMENT} from "@angular/common";
import {ApiService} from '../_service/api.service';
import {Group} from '../_data/data';
import {interval, Subject} from 'rxjs';
import {takeUntil} from 'rxjs/operators';
import {DOCUMENT, isPlatformBrowser} from '@angular/common';
import {StorageService} from '../_service/storage.service';
@Component({
selector: 'app-status',
@ -14,17 +13,24 @@ import {DOCUMENT, isPlatformBrowser} from "@angular/common";
})
export class StatusComponent implements OnInit, OnDestroy {
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'
'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'
};
destroyed$ = new Subject();
groups: Group[];
lastUpdated: Date;
expandedCache: { [id: string]: boolean };
constructor(private api: ApiService, @Inject(PLATFORM_ID) private platformId: Object,
constructor(private api: ApiService, private storage: StorageService,
@Inject(PLATFORM_ID) private platformId: Object,
@Inject(DOCUMENT) private document: Document) {
let cache = this.storage.getValue('expanded');
if (typeof cache !== 'object') {
cache = null;
}
this.expandedCache = cache || {};
}
ngOnInit(): void {
@ -49,4 +55,8 @@ export class StatusComponent implements OnInit, OnDestroy {
this.destroyed$.next();
this.destroyed$.complete();
}
saveExpandedCache() {
this.storage.setValue('expanded', this.expandedCache);
}
}

View File

@ -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'}}&#13;{{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>

View File

View File

@ -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);
}
}

24
src/assets/i18n/de.json Normal file
View File

@ -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"
}

24
src/assets/i18n/en.json Normal file
View File

@ -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"
}

View File

@ -1,8 +1,16 @@
import {json, Router} from 'express';
import {CurrentStatus, Service, State} from './app/_data/data';
import {CurrentStatus, State, UptimeStatus} from './app/_data/data';
import {existsSync, readFileSync, writeFileSync} from 'fs';
import {join} from 'path';
import {JSONPath} from 'jsonpath-plus';
import * as dayjs from 'dayjs';
import {Dayjs} from 'dayjs';
import * as utc from 'dayjs/plugin/utc';
import * as isBetween from 'dayjs/plugin/isBetween';
import {CronJob} from 'cron';
dayjs.extend(utc);
dayjs.extend(isBetween);
interface Cache {
[id: string]: State;
@ -12,6 +20,12 @@ interface Config {
authToken: string;
title: string;
description: string;
translations?: {
[lang: string]: {
title: string;
description: string;
}
},
servicesPath?: string;
idPath?: string;
statePath?: string;
@ -46,6 +60,7 @@ const serviceStatePaths: { [service: string]: string } = config.groups
}, {});
let cache: CurrentStatus;
let uptimeStates = existsSync(join(process.cwd(), 'uptime.json')) ? JSON.parse(readFileSync(join(process.cwd(), 'uptime.json'), {encoding: 'utf-8'})) : {} as { [id: string]: UptimeStatus; };
updateCache();
api.post('/update/health', (req, res) => {
@ -60,40 +75,48 @@ api.post('/update/health', (req, res) => {
} else if (config.servicesPath && config.idPath && config.statePath) {
services = JSONPath({path: config.servicesPath, json: req.body})
.map(s => ({
id: JSONPath({path: config.idPath, json: s, wrap: false}),
state: JSONPath({path: config.statePath, json: s, wrap: false})
id: JSONPath({path: config.idPath, json: s, wrap: false}),
state: JSONPath({path: config.statePath, json: s, wrap: false})
}));
}
services.forEach(s => {
if (config.stateValues.operational.includes(s.state)) {
serviceStates[s.id] = 'operational';
updateServiceState(s.id, 'operational');
} else if (config.stateValues.maintenance.includes(s.state)) {
serviceStates[s.id] = 'maintenance';
updateServiceState(s.id, 'maintenance');
} else {
serviceStates[s.id] = 'outage';
updateServiceState(s.id, 'outage');
}
});
updateCache();
writeFileSync('cache.json', JSON.stringify(serviceStates), {encoding: 'utf-8'});
persistCache();
return res.send('OK');
});
api.get('/status', (req, res) => {
return res.json(cache);
return res.json(cache);
});
api.get('/uptime', (req, res) => {
const serviceId = req.query.service as string;
const uptime = uptimeStates[serviceId];
if (uptime) {
return res.json(uptime);
}
return res.sendStatus(404);
});
api.get('/badge', (req, res) => {
const serviceId = req.query.service as string;
if (!serviceId) {
return res.json({
"schemaVersion": 1,
"label": "sp-status",
"message": "service not provided",
"isError": true
'schemaVersion': 1,
'label': 'sp-status',
'message': 'service not provided',
'isError': true
});
}
const service = cache.groups
@ -101,10 +124,10 @@ api.get('/badge', (req, res) => {
.find(s => s.id === serviceId);
if (!service) {
return res.json({
"schemaVersion": 1,
"label": "sp-status",
"message": "service not found",
"isError": true
'schemaVersion': 1,
'label': 'sp-status',
'message': 'service not found',
'isError': true
});
}
const label = req.query.label || service.name;
@ -125,28 +148,51 @@ api.get('/badge', (req, res) => {
break;
}
return res.json({
"schemaVersion": 1,
"label": label,
"message": message,
"color": color
'schemaVersion': 1,
'label': label,
'message': message,
'color': color
});
});
api.get('/info', (req, res) => {
return res.json({
title: config.title,
description: config.description
description: config.description,
translations: config.translations
});
});
function updateServiceState(id: string, state: string) {
if (serviceStates[id] === state) {
return;
}
serviceStates[id] = state;
if (!uptimeStates[id]) {
uptimeStates[id] = {
days: [],
events: []
};
}
if (uptimeStates[id].events.length === 0 && state !== 'operational' ||
uptimeStates[id].events.length > 0 && uptimeStates[id].events[0].state !== state) {
uptimeStates[id].events.unshift({state: state, date: new Date()});
console.log(`${id} changed to ${state}`);
}
}
function updateCache(): void {
updateUptime();
const groups = config.groups.map(group => {
const services = group.services.map(service => {
const uptime = uptimeStates[service.id];
return {
id: service.id,
name: service.name,
url: service.url,
state: serviceStates[service.id] || 'operational'
state: serviceStates[service.id] || 'operational',
uptime: uptime ? uptime.days30 : 100
};
});
return {
@ -163,8 +209,151 @@ function updateCache(): void {
};
}
function updateUptime() {
const now = dayjs.utc();
const today = now.startOf('d');
const eventLimit = now.subtract(7, 'd');
for (const id in uptimeStates) {
if (uptimeStates.hasOwnProperty(id)) {
const uptime = uptimeStates[id] as UptimeStatus;
if (uptime.days.length < 90) {
for (let i = 0; i < 90; i++) {
uptime.days.push({date: today.subtract(90 - i, 'd').toDate(), uptime: 100})
}
}
if (today.diff(dayjs.utc(uptime.days[uptime.days.length - 1].date), 'd') >= 1) {
uptime.days.push({date: today.toDate(), uptime: 0});
}
if (uptime.days.length > 90) {
uptime.days.splice(0, uptime.days.length - 90);
}
for (let i = uptime.days.length - 3; i < uptime.days.length; i++) {
const start = dayjs.utc(uptime.days[i].date);
let end = start.add(1, 'd');
if (end.isAfter(now)) {
end = now;
}
uptime.days[i].uptime = calculateUptime(start, end, uptime.events);
}
uptime.hours24 = calculateUptime(now.subtract(24, 'h'), now, uptime.events);
uptime.days7 = uptime.days.slice(uptime.days.length - 7, uptime.days.length).map(e => e.uptime).reduce((a, b) => a + b) / 7;
uptime.days30 = uptime.days.slice(uptime.days.length - 30, uptime.days.length).map(e => e.uptime).reduce((a, b) => a + b) / 30;
uptime.days90 = uptime.days.slice(uptime.days.length - 90, uptime.days.length).map(e => e.uptime).reduce((a, b) => a + b) / 90;
uptime.events = uptime.events.filter(e => dayjs.utc(e.date).isAfter(eventLimit));
if (uptime.events.length > 0 && uptime.events[uptime.events.length - 1].state === 'operational') {
uptime.events.pop();
}
}
}
}
function calculateUptime(start: Dayjs, end: Dayjs, events: { state: State; date: Date; }[]): number {
if (events.filter(event => dayjs.utc(event.date).isBetween(start, end)).length == 0) {
const lastEvent = events.filter(event => dayjs.utc(event.date).isBefore(start))[0];
if (lastEvent && lastEvent.state !== 'operational') {
return 0;
}
return 100;
}
let uptimeMillis = 0;
let newestEventDate;
for (let i = events.length - 1; i >= 0; i--) {
const event = events[i];
const eventDate = dayjs.utc(event.date);
const lastEvent = events[i + 1];
let lastEventDate = lastEvent ? dayjs.utc(lastEvent.date) : start;
if (lastEventDate.isBefore(start)) {
lastEventDate = start;
}
if (eventDate.isBetween(start, end)) {
if (event.state === 'operational') {
newestEventDate = eventDate;
} else if (!lastEvent || lastEvent.state === 'operational') {
newestEventDate = null;
uptimeMillis += eventDate.diff(lastEventDate, 'ms');
}
}
}
if (newestEventDate) {
uptimeMillis += end.diff(newestEventDate, 'ms');
}
return uptimeMillis / end.diff(start, 'ms') * 100;
}
function calculateOverallState(states: State[]): State {
return states.includes('outage') ? 'outage' : states.includes('maintenance') ? 'maintenance' : 'operational';
}
function persistCache() {
writeFileSync('cache.json', JSON.stringify(serviceStates), {encoding: 'utf-8'});
writeFileSync('uptime.json', JSON.stringify(uptimeStates), {encoding: 'utf-8'});
}
new CronJob('0 * * * * *', () => updateCache(), null, true, 'UTC').start();
new CronJob('0 0 * * * *', () => persistCache(), null, true, 'UTC').start();
api.get('/test', (req, res) => {
return res.json({
'50_5': calculateUptime(dayjs.utc('2020-01-02'), dayjs.utc('2020-01-03'), [{
state: 'outage',
date: new Date('2020-01-03T12:00:00.000Z')
}, {
state: 'operational',
date: new Date('2020-01-02T18:00:00.000Z')
}, {
state: 'outage',
date: new Date('2020-01-02T06:00:00.000Z')
}, {
state: 'operational',
date: new Date('2020-01-01T12:00:00.000Z')
}]),
'50_4': calculateUptime(dayjs.utc('2020-01-02'), dayjs.utc('2020-01-03'), [{
state: 'operational',
date: new Date('2020-01-02T18:00:00.000Z')
}, {
state: 'outage',
date: new Date('2020-01-02T06:00:00.000Z')
}, {
state: 'operational',
date: new Date('2020-01-01T12:00:00.000Z')
}]),
'50_3': calculateUptime(dayjs.utc('2020-01-02'), dayjs.utc('2020-01-03'), [{
state: 'outage',
date: new Date('2020-01-02T12:00:00.000Z')
}, {
state: 'operational',
date: new Date('2020-01-01T12:00:00.000Z')
}]),
'50_2': calculateUptime(dayjs.utc('2020-01-02'), dayjs.utc('2020-01-03'), [{
state: 'outage',
date: new Date('2020-01-02T18:00:00.000Z')
}, {
state: 'operational',
date: new Date('2020-01-02T06:00:00.000Z')
}]),
'50_1': calculateUptime(dayjs.utc('2020-01-02'), dayjs.utc('2020-01-03'), [{
state: 'operational',
date: new Date('2020-01-02T12:00:00.000Z')
}, {
state: 'outage',
date: new Date('2020-01-01T12:00:00.000Z')
}]),
'50_0': calculateUptime(dayjs.utc('2020-01-01'), dayjs.utc('2020-01-02'), [{
state: 'operational',
date: new Date('2020-01-01T12:00:00.000Z')
}]),
'75': calculateUptime(dayjs.utc('2020-01-01'), dayjs.utc('2020-01-02'), [{
state: 'operational',
date: new Date('2020-01-01T06:00:00.000Z')
}]),
'100': calculateUptime(dayjs.utc('2020-01-01'), dayjs.utc('2020-01-02'), []),
'0': calculateUptime(dayjs.utc('2020-01-02'), dayjs.utc('2020-01-03'), [{
state: 'outage',
date: new Date('2020-01-01T12:00:00.000Z')
}]),
'test': calculateUptime(dayjs.utc('2020-01-07'), dayjs.utc(), [{state: 'outage', date: new Date('2021-01-07T13:54:32.705Z')}])
});
});
export {api};

View File

@ -3,8 +3,17 @@
@import "~bootstrap/scss/mixins";
@import "~bootstrap/scss/utilities";
@import "~bootstrap/scss/bootstrap-grid";
@import "~@fortawesome/fontawesome-free/css/all.css";
@import "~bootstrap/scss/functions";
@import "~bootstrap/scss/variables";
@import "~bootstrap/scss/mixins/_breakpoints";
@import "~roboto-fontface/css/roboto/roboto-fontface.css";
$fa-font-path: "~@fortawesome/fontawesome-free/webfonts";
@import "~@fortawesome/fontawesome-free/scss/fontawesome";
@import "~@fortawesome/fontawesome-free/scss/solid";
@import "~@fortawesome/fontawesome-free/scss/brands";
@import "~@fortawesome/fontawesome-free/scss/regular";
$flag-icon-css-path: '~flag-icon-css/flags' !default;
@import "~flag-icon-css/sass/flag-icon";
@import '~@angular/material/theming';
@ -35,21 +44,21 @@ $dark-dividers: rgba($dark-primary-text, 0.12);
$dark-focused: rgba($dark-primary-text, 0.12);
$mat-light-theme-foreground: (
base: black,
divider: $dark-dividers,
dividers: $dark-dividers,
disabled: $dark-disabled-text,
disabled-button: rgba($dark-text, 0.26),
disabled-text: $dark-disabled-text,
elevation: black,
secondary-text: $dark-accent-text,
hint-text: $dark-disabled-text,
accent-text: $dark-accent-text,
icon: $dark-accent-text,
icons: $dark-accent-text,
text: $dark-primary-text,
slider-min: $dark-primary-text,
slider-off: rgba($dark-text, 0.26),
base: black,
divider: $dark-dividers,
dividers: $dark-dividers,
disabled: $dark-disabled-text,
disabled-button: rgba($dark-text, 0.26),
disabled-text: $dark-disabled-text,
elevation: black,
secondary-text: $dark-accent-text,
hint-text: $dark-disabled-text,
accent-text: $dark-accent-text,
icon: $dark-accent-text,
icons: $dark-accent-text,
text: $dark-primary-text,
slider-min: $dark-primary-text,
slider-off: rgba($dark-text, 0.26),
slider-off-active: $dark-disabled-text,
);
@ -62,80 +71,80 @@ $light-dividers: rgba($light-primary-text, 0.12);
$light-focused: rgba($light-primary-text, 0.12);
$mat-dark-theme-foreground: (
base: $light-text,
divider: $light-dividers,
dividers: $light-dividers,
disabled: $light-disabled-text,
disabled-button: rgba($light-text, 0.3),
disabled-text: $light-disabled-text,
elevation: black,
hint-text: $light-disabled-text,
secondary-text: $light-accent-text,
accent-text: $light-accent-text,
icon: $light-text,
icons: $light-text,
text: $light-text,
slider-min: $light-text,
slider-off: rgba($light-text, 0.3),
base: $light-text,
divider: $light-dividers,
dividers: $light-dividers,
disabled: $light-disabled-text,
disabled-button: rgba($light-text, 0.3),
disabled-text: $light-disabled-text,
elevation: black,
hint-text: $light-disabled-text,
secondary-text: $light-accent-text,
accent-text: $light-accent-text,
icon: $light-text,
icons: $light-text,
text: $light-text,
slider-min: $light-text,
slider-off: rgba($light-text, 0.3),
slider-off-active: rgba($light-text, 0.3),
);
// Background config
// Light bg
$light-background: #fafafa;
$light-bg-darker-5: darken($light-background, 5%);
$light-bg-darker-10: darken($light-background, 10%);
$light-bg-darker-20: darken($light-background, 20%);
$light-bg-darker-30: darken($light-background, 30%);
$light-bg-lighter-5: lighten($light-background, 5%);
$dark-bg-tooltip: lighten(#2c2c2c, 20%);
$dark-bg-alpha-4: rgba(#2c2c2c, 0.04);
$dark-bg-alpha-12: rgba(#2c2c2c, 0.12);
$light-background: #fafafa;
$light-bg-darker-5: darken($light-background, 5%);
$light-bg-darker-10: darken($light-background, 10%);
$light-bg-darker-20: darken($light-background, 20%);
$light-bg-darker-30: darken($light-background, 30%);
$light-bg-lighter-5: lighten($light-background, 5%);
$dark-bg-tooltip: lighten(#2c2c2c, 20%);
$dark-bg-alpha-4: rgba(#2c2c2c, 0.04);
$dark-bg-alpha-12: rgba(#2c2c2c, 0.12);
$mat-light-theme-background: (
background: $light-background,
status-bar: $light-bg-darker-20,
app-bar: $light-bg-darker-5,
hover: $dark-bg-alpha-4,
card: $light-bg-lighter-5,
dialog: $light-bg-lighter-5,
tooltip: $dark-bg-tooltip,
disabled-button: $dark-bg-alpha-12,
raised-button: $light-bg-lighter-5,
focused-button: $dark-focused,
selected-button: $light-bg-darker-20,
background: $light-background,
status-bar: $light-bg-darker-20,
app-bar: $light-bg-darker-5,
hover: $dark-bg-alpha-4,
card: $light-bg-lighter-5,
dialog: $light-bg-lighter-5,
tooltip: $dark-bg-tooltip,
disabled-button: $dark-bg-alpha-12,
raised-button: $light-bg-lighter-5,
focused-button: $dark-focused,
selected-button: $light-bg-darker-20,
selected-disabled-button: $light-bg-darker-30,
disabled-button-toggle: $light-bg-darker-10,
unselected-chip: $light-bg-darker-10,
disabled-list-option: $light-bg-darker-10,
disabled-button-toggle: $light-bg-darker-10,
unselected-chip: $light-bg-darker-10,
disabled-list-option: $light-bg-darker-10,
);
// Dark bg
$dark-background: #2c2c2c;
$dark-bg-lighter-5: lighten($dark-background, 5%);
$dark-bg-lighter-10: lighten($dark-background, 10%);
$dark-bg-lighter-20: lighten($dark-background, 20%);
$dark-bg-lighter-30: lighten($dark-background, 30%);
$light-bg-alpha-4: rgba(#fafafa, 0.04);
$light-bg-alpha-12: rgba(#fafafa, 0.12);
$dark-background: #2c2c2c;
$dark-bg-lighter-5: lighten($dark-background, 5%);
$dark-bg-lighter-10: lighten($dark-background, 10%);
$dark-bg-lighter-20: lighten($dark-background, 20%);
$dark-bg-lighter-30: lighten($dark-background, 30%);
$light-bg-alpha-4: rgba(#fafafa, 0.04);
$light-bg-alpha-12: rgba(#fafafa, 0.12);
// Background palette for dark themes.
$mat-dark-theme-background: (
background: $dark-background,
status-bar: $dark-bg-lighter-20,
app-bar: $dark-bg-lighter-5,
hover: $light-bg-alpha-4,
card: $dark-bg-lighter-5,
dialog: $dark-bg-lighter-5,
tooltip: $dark-bg-lighter-20,
disabled-button: $light-bg-alpha-12,
raised-button: $dark-bg-lighter-5,
focused-button: $light-focused,
selected-button: $dark-bg-lighter-20,
background: $dark-background,
status-bar: $dark-bg-lighter-20,
app-bar: $dark-bg-lighter-5,
hover: $light-bg-alpha-4,
card: $dark-bg-lighter-5,
dialog: $dark-bg-lighter-5,
tooltip: $dark-bg-lighter-20,
disabled-button: $light-bg-alpha-12,
raised-button: $dark-bg-lighter-5,
focused-button: $light-focused,
selected-button: $dark-bg-lighter-20,
selected-disabled-button: $dark-bg-lighter-30,
disabled-button-toggle: $dark-bg-lighter-10,
unselected-chip: $dark-bg-lighter-20,
disabled-list-option: $dark-bg-lighter-10,
disabled-button-toggle: $dark-bg-lighter-10,
unselected-chip: $dark-bg-lighter-20,
disabled-list-option: $dark-bg-lighter-10,
);
// Compute font config
@ -207,7 +216,8 @@ $mat-warn: (
darker: $light-primary-text,
)
);
$theme-warn: mat-palette($mat-warn, main, lighter, darker);;
$theme-warn: mat-palette($mat-warn, main, lighter, darker);
;
$theme: mat-dark-theme($theme-primary, $theme-accent, $theme-warn);
$altTheme: mat-light-theme($theme-primary, $theme-accent, $theme-warn);
@ -244,3 +254,47 @@ body {
background-color: #222222;
color: #ffffff;
}
.multiline-tooltip {
white-space: pre-line;
}
.operational {
color: #7ed321;
}
.outage {
color: #ff6f6f;
}
.maintenance {
color: #f7ca18;
}
.bg-operational {
background-color: #7ed321;
}
.bg-outage {
background-color: #ff6f6f;
}
.bg-maintenance {
background-color: #f7ca18;
}
a {
color: #cccccc;
text-decoration: none;
&:hover {
color: #ffffff;
text-decoration: underline;
}
}
@include media-breakpoint-up(md) {
.border-md-right {
border-right: $border-width solid $border-color !important;
}
}