From e9599373ecdc49216ac9e8eea1cf989630603fd3 Mon Sep 17 00:00:00 2001 From: samuel-p Date: Sun, 10 Jan 2021 16:06:18 +0100 Subject: [PATCH] added last downtime log (closes #55) added uptime statistics (closes #56) added german translations (closes #57) --- .gitignore | 1 + angular.json | 2 + config.json | 30 ++- package.json | 8 + server.ts | 20 +- src/app/_data/data.ts | 22 +++ src/app/_pipe/dayjs.pipe.spec.ts | 8 + src/app/_pipe/dayjs.pipe.ts | 36 ++++ src/app/_service/api.service.spec.ts | 16 -- src/app/_service/api.service.ts | 18 +- src/app/_service/storage.service.ts | 30 +++ src/app/app.component.html | 19 +- src/app/app.component.scss | 10 +- src/app/app.component.spec.ts | 35 ---- src/app/app.component.ts | 42 ++++- src/app/app.module.ts | 42 ++++- src/app/status/status.component.html | 59 +++--- src/app/status/status.component.scss | 18 -- src/app/status/status.component.spec.ts | 25 --- src/app/status/status.component.ts | 32 ++-- src/app/uptime/uptime.component.html | 70 +++++++ src/app/uptime/uptime.component.scss | 0 src/app/uptime/uptime.component.ts | 52 ++++++ src/assets/i18n/de.json | 24 +++ src/assets/i18n/en.json | 24 +++ src/main.status.ts | 235 +++++++++++++++++++++--- src/styles.scss | 206 +++++++++++++-------- 27 files changed, 819 insertions(+), 265 deletions(-) create mode 100644 src/app/_pipe/dayjs.pipe.spec.ts create mode 100644 src/app/_pipe/dayjs.pipe.ts delete mode 100644 src/app/_service/api.service.spec.ts create mode 100644 src/app/_service/storage.service.ts delete mode 100644 src/app/app.component.spec.ts delete mode 100644 src/app/status/status.component.spec.ts create mode 100644 src/app/uptime/uptime.component.html create mode 100644 src/app/uptime/uptime.component.scss create mode 100644 src/app/uptime/uptime.component.ts create mode 100644 src/assets/i18n/de.json create mode 100644 src/assets/i18n/en.json diff --git a/.gitignore b/.gitignore index a3ed5d0..991d42f 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ Thumbs.db # custom config.json cache.json +uptime.json diff --git a/angular.json b/angular.json index a458a21..23b55b0 100644 --- a/angular.json +++ b/angular.json @@ -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", diff --git a/config.json b/config.json index d988f5c..d781b11 100644 --- a/config.json +++ b/config.json @@ -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" } ] diff --git a/package.json b/package.json index ce81dc9..f7f6d7f 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/server.ts b/server.ts index edd57b5..185923d 100644 --- a/server.ts +++ b/server.ts @@ -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, 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, { diff --git a/src/app/_data/data.ts b/src/app/_data/data.ts index 478dd1a..69aec05 100644 --- a/src/app/_data/data.ts +++ b/src/app/_data/data.ts @@ -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; + }[]; } diff --git a/src/app/_pipe/dayjs.pipe.spec.ts b/src/app/_pipe/dayjs.pipe.spec.ts new file mode 100644 index 0000000..e1d508f --- /dev/null +++ b/src/app/_pipe/dayjs.pipe.spec.ts @@ -0,0 +1,8 @@ +import { DayjsPipe } from './dayjs.pipe'; + +describe('DayjsPipe', () => { + it('create an instance', () => { + const pipe = new DayjsPipe(); + expect(pipe).toBeTruthy(); + }); +}); diff --git a/src/app/_pipe/dayjs.pipe.ts b/src/app/_pipe/dayjs.pipe.ts new file mode 100644 index 0000000..705b106 --- /dev/null +++ b/src/app/_pipe/dayjs.pipe.ts @@ -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!'); + } +} diff --git a/src/app/_service/api.service.spec.ts b/src/app/_service/api.service.spec.ts deleted file mode 100644 index c0310ae..0000000 --- a/src/app/_service/api.service.spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/src/app/_service/api.service.ts b/src/app/_service/api.service.ts index af2e0b3..83554e8 100644 --- a/src/app/_service/api.service.ts +++ b/src/app/_service/api.service.ts @@ -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 { - return this.http.get(this.api+ '/status'); + return this.http.get(this.api + '/status'); + } + + public getServiceUptime(id: string): Observable { + return this.http.get(this.api + '/uptime', {params: {service: id}}); } public getMetaInfo(): Observable { - return this.http.get(this.api+ '/info'); + return this.http.get(this.api + '/info'); } } diff --git a/src/app/_service/storage.service.ts b/src/app/_service/storage.service.ts new file mode 100644 index 0000000..cf42735 --- /dev/null +++ b/src/app/_service/storage.service.ts @@ -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) { + } + } + } +} diff --git a/src/app/app.component.html b/src/app/app.component.html index 9ad4c05..dad89c8 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,7 +1,22 @@
-

{{title}}

-

{{description}}

+
+
+

{{translations[getLanguage()]?.title || title}}

+
+
+ +
+
+

{{translations[getLanguage()]?.description || description}}

+
diff --git a/src/app/app.component.scss b/src/app/app.component.scss index 238bbd2..8d7031d 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -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; } diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts deleted file mode 100644 index 994ccc9..0000000 --- a/src/app/app.component.spec.ts +++ /dev/null @@ -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!'); - }); -}); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 83cacfb..32afafa 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -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( '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); } } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index db73aab..297ecdd 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -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 { + 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] diff --git a/src/app/status/status.component.html b/src/app/status/status.component.html index f0b5aa2..91ccd84 100644 --- a/src/app/status/status.component.html +++ b/src/app/status/status.component.html @@ -1,25 +1,38 @@ - - - - - - {{group.name}} - {{group.name}} - - +
+

+ + {{group.name}} + {{group.name}} +

+ + + + +
+ + {{service.name}} + {{service.name}} + {{service.uptime?.toFixed(2)}}% + + {{'state.' + service.state | translate}} +
+
+
- - -
- - {{service.name}} - - {{service.state}} -
- -
-
-
-
+ + + + + +
-
Last updated {{lastUpdated | date:'HH:mm:ss'}}
+
{{'last-updated' | translate:{'time': lastUpdated | dayjs:'from'} }} +
diff --git a/src/app/status/status.component.scss b/src/app/status/status.component.scss index 310e4f4..19c51a6 100644 --- a/src/app/status/status.component.scss +++ b/src/app/status/status.component.scss @@ -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 { diff --git a/src/app/status/status.component.spec.ts b/src/app/status/status.component.spec.ts deleted file mode 100644 index 0fed327..0000000 --- a/src/app/status/status.component.spec.ts +++ /dev/null @@ -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; - - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - declarations: [ StatusComponent ] - }) - .compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(StatusComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/status/status.component.ts b/src/app/status/status.component.ts index 44212ec..8c64f50 100644 --- a/src/app/status/status.component.ts +++ b/src/app/status/status.component.ts @@ -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); + } } diff --git a/src/app/uptime/uptime.component.html b/src/app/uptime/uptime.component.html new file mode 100644 index 0000000..9266374 --- /dev/null +++ b/src/app/uptime/uptime.component.html @@ -0,0 +1,70 @@ +
+

{{'uptime.title' | translate}}

+ +
+
+
+

{{uptime.hours24?.toFixed(2)}}%

+ {{'uptime.last24hours' | translate}} +
+
+
+
+

{{uptime.days7?.toFixed(2)}}%

+ {{'uptime.last7days' | translate}} +
+
+
+
+

{{uptime.days30?.toFixed(2)}}%

+ {{'uptime.last30days' | translate}} +
+
+
+
+

{{uptime.days90?.toFixed(2)}}%

+ {{'uptime.last90days' | translate}} +
+
+
+
+ +
+
+
+ +
+

{{'recent-events.title' | translate}}

+ + + + +

+ {{'recent-events.operational' | translate}} + {{'recent-events.maintenance' | translate: {'time': event.date | dayjs:'to':uptime.events[index - 1]?.date} }} + {{'recent-events.outage' | translate:{'time': event.date | dayjs:'to':uptime.events[index - 1]?.date} }} +

+
{{event.date | dayjs:'from'}} +
+ +
+
+
+ {{(expanded ? 'show-less' : 'show-all') | translate}} +
+
+ +
{{error}}
+
{{'loading' | translate}}
+
diff --git a/src/app/uptime/uptime.component.scss b/src/app/uptime/uptime.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/uptime/uptime.component.ts b/src/app/uptime/uptime.component.ts new file mode 100644 index 0000000..30cd58f --- /dev/null +++ b/src/app/uptime/uptime.component.ts @@ -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; + 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); + } +} diff --git a/src/assets/i18n/de.json b/src/assets/i18n/de.json new file mode 100644 index 0000000..5ae699f --- /dev/null +++ b/src/assets/i18n/de.json @@ -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" +} diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json new file mode 100644 index 0000000..9e6d0cf --- /dev/null +++ b/src/assets/i18n/en.json @@ -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" +} diff --git a/src/main.status.ts b/src/main.status.ts index db41deb..6f6de4c 100644 --- a/src/main.status.ts +++ b/src/main.status.ts @@ -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}; diff --git a/src/styles.scss b/src/styles.scss index 066912a..539e68a 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -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; + } +}