major refactoring

added universal
added api
This commit is contained in:
Samuel Philipp 2020-05-06 17:25:35 +02:00
parent 2bea201bb3
commit a4542f7abd
52 changed files with 2851 additions and 313 deletions

52
.gitignore vendored
View file

@ -1,3 +1,51 @@
.idea/
*.iml
# See http://help.github.com/ignore-files/ for more about ignoring files.
# compiled output
/dist
/tmp
/out-tsc
# Only exists if Bazel was run
/bazel-out
# dependencies
/node_modules
# profiling files
chrome-profiler-events*.json
speed-measure-plugin*.json
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# misc
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
yarn-error.log
testem.log
/typings
# System Files
.DS_Store
Thumbs.db
# custom
config.json
cache.json

View file

@ -1,4 +1,4 @@
# sp-status frontend
# GrafanaStatuspage
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 9.1.4.

View file

@ -3,7 +3,7 @@
"version": 1,
"newProjectRoot": "projects",
"projects": {
"frontend": {
"grafana-statuspage": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
@ -17,13 +17,14 @@
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/frontend",
"outputPath": "dist/grafana-statuspage/browser",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.app.json",
"aot": true,
"assets": [
"src/favicon.png",
"src/favicon-operational.ico",
"src/favicon-outage.ico",
"src/favicon-maintenance.ico",
@ -68,18 +69,18 @@
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "frontend:build"
"browserTarget": "grafana-statuspage:build"
},
"configurations": {
"production": {
"browserTarget": "frontend:build:production"
"browserTarget": "grafana-statuspage:build:production"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "frontend:build"
"browserTarget": "grafana-statuspage:build"
}
},
"test": {
@ -90,7 +91,10 @@
"tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js",
"assets": [
"src/favicon.ico",
"src/favicon.png",
"src/favicon-operational.ico",
"src/favicon-outage.ico",
"src/favicon-maintenance.ico",
"src/assets"
],
"styles": [
@ -116,15 +120,62 @@
"builder": "@angular-devkit/build-angular:protractor",
"options": {
"protractorConfig": "e2e/protractor.conf.js",
"devServerTarget": "frontend:serve"
"devServerTarget": "grafana-statuspage:serve"
},
"configurations": {
"production": {
"devServerTarget": "frontend:serve:production"
"devServerTarget": "grafana-statuspage:serve:production"
}
}
},
"server": {
"builder": "@angular-devkit/build-angular:server",
"options": {
"outputPath": "dist/grafana-statuspage/server",
"main": "server.ts",
"tsConfig": "tsconfig.server.json"
},
"configurations": {
"production": {
"outputHashing": "media",
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"sourceMap": false,
"optimization": true
}
}
},
"serve-ssr": {
"builder": "@nguniversal/builders:ssr-dev-server",
"options": {
"browserTarget": "grafana-statuspage:build",
"serverTarget": "grafana-statuspage:server"
},
"configurations": {
"production": {
"browserTarget": "grafana-statuspage:build:production",
"serverTarget": "grafana-statuspage:server:production"
}
}
},
"prerender": {
"builder": "@nguniversal/builders:prerender",
"options": {
"browserTarget": "grafana-statuspage:build:production",
"serverTarget": "grafana-statuspage:server:production",
"routes": [
"/"
]
},
"configurations": {
"production": {}
}
}
}
}},
"defaultProject": "frontend"
"defaultProject": "grafana-statuspage"
}

18
config.json Normal file
View file

@ -0,0 +1,18 @@
{
"authToken": "test",
"title": "sp-status",
"description": "Services hosted by sp-codes",
"groups": [
{
"id": "test",
"name": "Test",
"services": [
{
"id": "test",
"name": "Test",
"url": "http://test.de"
}
]
}
]
}

View file

@ -10,7 +10,7 @@ describe('workspace-project App', () => {
it('should display welcome message', () => {
page.navigateTo();
expect(page.getTitleText()).toEqual('frontend app is running!');
expect(page.getTitleText()).toEqual('grafana-statuspage app is running!');
});
afterEach(async () => {

46
frontend/.gitignore vendored
View file

@ -1,46 +0,0 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
# compiled output
/dist
/tmp
/out-tsc
# Only exists if Bazel was run
/bazel-out
# dependencies
/node_modules
# profiling files
chrome-profiler-events*.json
speed-measure-plugin*.json
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# misc
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
yarn-error.log
testem.log
/typings
# System Files
.DS_Store
Thumbs.db

View file

@ -1,53 +0,0 @@
import {Injectable} from '@angular/core';
import {Observable, of} from "rxjs";
import {ApiResponse} from "../_data/data";
@Injectable({
providedIn: 'root'
})
export class ApiService {
constructor() {
}
public getServiceStates(): Observable<ApiResponse> {
return of({
state: "maintenance",
groups: [{
id: 'default',
name: 'Some Group',
state: "outage",
services: [{
id: 'nextcloud',
name: 'Nextcloud',
url: "https://sp-codes.de",
state: "operational"
}, {
id: 'synapse',
name: 'Synapse',
url: "https://sp-codes.de",
state: "outage"
}, {
id: 'searx',
name: 'Searx',
url: "https://sp-codes.de",
state: "maintenance"
}]
}, {
id: 'test',
name: 'Test',
state: "operational",
services: [{
id: 'nextcloud',
name: 'Nextcloud',
url: "https://sp-codes.de",
state: "operational"
}, {
id: 'synapse',
name: 'Synapse',
url: "https://sp-codes.de",
state: "operational"
}]
}]
});
}
}

View file

@ -1,9 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent {
}

View file

@ -1,26 +0,0 @@
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } 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";
@NgModule({
declarations: [
AppComponent,
StatusComponent
],
imports: [
BrowserModule,
AppRoutingModule,
BrowserAnimationsModule,
MatExpansionModule,
MatListModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }

View file

@ -1,3 +0,0 @@
export const environment = {
production: true
};

View file

@ -1,15 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>sp-status</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link id="favicon" rel="icon" type="image/x-icon" href="favicon-operational.ico">
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body class="mat-typography">
<app-root></app-root>
</body>
</html>

View file

@ -16,7 +16,7 @@ module.exports = function (config) {
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
coverageIstanbulReporter: {
dir: require('path').join(__dirname, './coverage/frontend'),
dir: require('path').join(__dirname, './coverage/grafana-statuspage'),
reports: ['html', 'lcovonly', 'text-summary'],
fixWebpackSourcePaths: true
},

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,17 @@
{
"name": "sp-status",
"version": "0.0.1",
"name": "grafana-statuspage",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e"
"e2e": "ng e2e",
"dev:ssr": "ng run grafana-statuspage:serve-ssr",
"serve:ssr": "node dist/grafana-statuspage/server/main.js",
"build:ssr": "ng build --prod && ng run grafana-statuspage:server:production",
"prerender": "ng run grafana-statuspage:prerender"
},
"private": true,
"dependencies": {
@ -20,9 +24,12 @@
"@angular/material": "^9.2.2",
"@angular/platform-browser": "~9.1.4",
"@angular/platform-browser-dynamic": "~9.1.4",
"@angular/platform-server": "~9.1.4",
"@angular/router": "~9.1.4",
"@fortawesome/fontawesome-free": "^5.13.0",
"@nguniversal/express-engine": "^9.1.0",
"bootstrap": "^4.4.1",
"express": "^4.15.2",
"rxjs": "~6.5.4",
"tslib": "^1.10.0",
"zone.js": "~0.10.2"
@ -32,6 +39,8 @@
"@angular/cli": "~9.1.4",
"@angular/compiler-cli": "~9.1.4",
"@angular/language-service": "~9.1.4",
"@nguniversal/builders": "^9.1.0",
"@types/express": "^4.17.0",
"@types/node": "^12.11.1",
"@types/jasmine": "~3.5.0",
"@types/jasminewd2": "~2.0.3",

61
server.ts Normal file
View file

@ -0,0 +1,61 @@
import 'zone.js/dist/zone-node';
import {ngExpressEngine} from '@nguniversal/express-engine';
import * as express from 'express';
import {join} from 'path';
import {AppServerModule} from './src/main.server';
import {APP_BASE_HREF} from '@angular/common';
import {existsSync} from 'fs';
import {api} from './src/main.status';
// The Express app is exported so that it can be used by serverless Functions.
export function app() {
const server = express();
const distFolder = join(process.cwd(), 'dist/grafana-statuspage/browser');
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.set('view engine', 'html');
server.set('views', distFolder);
server.use('/api', api);
// Serve static files from /browser
server.get('*.*', express.static(distFolder, {
maxAge: '1y'
}));
// All regular routes use the Universal engine
server.get('*', (req, res) => {
res.render(indexHtml, {req, providers: [{provide: APP_BASE_HREF, useValue: req.baseUrl}]});
});
return server;
}
function run() {
const port = process.env.PORT || 4000;
// Start up the Node server
const server = app();
server.listen(port, () => {
console.log(`Node Express server listening on http://localhost:${port}`);
});
}
// Webpack will replace 'require' with '__webpack_require__'
// '__non_webpack_require__' is a proxy to Node 'require'
// The below code is to ensure that the server is run only when not requiring the bundle.
declare const __non_webpack_require__: NodeRequire;
const mainModule = __non_webpack_require__.main;
const moduleFilename = mainModule && mainModule.filename || '';
if (moduleFilename === __filename || moduleFilename.includes('iisnode')) {
run();
}
export * from './src/main.server';

View file

@ -1,6 +1,6 @@
export type State = 'operational' | 'outage' | 'maintenance'; // ok, alerting, paused
export type State = 'operational' | 'outage' | 'maintenance';
export interface ApiResponse {
export interface CurrentStatus {
state: State;
groups: Group[];
}
@ -18,3 +18,8 @@ export interface Service {
url: string;
state: State;
}
export interface MetaInfo {
title: string;
description: string;
}

View file

@ -0,0 +1,25 @@
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";
@Injectable({
providedIn: 'root'
})
export class ApiService {
private readonly api;
constructor(private http: HttpClient, @Inject(PLATFORM_ID) platformId: Object) {
this.api = isPlatformBrowser(platformId) ? '/api' : environment.serverUrl + '/api';
}
public getServiceStates(): Observable<CurrentStatus> {
return this.http.get<CurrentStatus>(this.api+ '/status');
}
public getMetaInfo(): Observable<MetaInfo> {
return this.http.get<MetaInfo>(this.api+ '/info');
}
}

View file

@ -2,14 +2,15 @@ import {NgModule} from '@angular/core';
import {RouterModule, Routes} from '@angular/router';
import {StatusComponent} from "./status/status.component";
const routes: Routes = [{
path: '',
component: StatusComponent
}];
@NgModule({
imports: [RouterModule.forRoot(routes)],
imports: [RouterModule.forRoot(routes, {
initialNavigation: 'enabled'
})],
exports: [RouterModule]
})
export class AppRoutingModule {

View file

@ -1,7 +1,7 @@
<div class="box">
<header class="container pt-4">
<h1>sp-status</h1>
<h3>Services hosted by sp-codes</h3>
<h1 *ngIf="title && title.length">{{title}}</h1>
<h3 *ngIf="description && description.length">{{description}}</h3>
</header>
<main class="container">

View file

@ -20,16 +20,16 @@ describe('AppComponent', () => {
expect(app).toBeTruthy();
});
it(`should have as title 'frontend'`, () => {
it(`should have as title 'grafana-statuspage'`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('frontend');
expect(app.title).toEqual('grafana-statuspage');
});
it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.content span').textContent).toContain('frontend app is running!');
expect(compiled.querySelector('.content span').textContent).toContain('grafana-statuspage app is running!');
});
});

26
src/app/app.component.ts Normal file
View file

@ -0,0 +1,26 @@
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";
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
title: string;
description: string;
constructor(private api: ApiService, private htmlTitle: Title) {
}
ngOnInit(): void {
this.api.getMetaInfo().subscribe(info => {
this.title = info.title;
this.description = info.description;
this.htmlTitle.setTitle(this.title);
})
}
}

29
src/app/app.module.ts Normal file
View file

@ -0,0 +1,29 @@
import {BrowserModule} from '@angular/platform-browser';
import {NgModule} 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";
@NgModule({
declarations: [
AppComponent,
StatusComponent
],
imports: [
BrowserModule.withServerTransition({appId: 'serverApp'}),
AppRoutingModule,
BrowserAnimationsModule,
HttpClientModule,
MatExpansionModule,
MatListModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule {
}

View file

@ -0,0 +1,14 @@
import { NgModule } from '@angular/core';
import { ServerModule } from '@angular/platform-server';
import { AppModule } from './app.module';
import { AppComponent } from './app.component';
@NgModule({
imports: [
AppModule,
ServerModule,
],
bootstrap: [AppComponent],
})
export class AppServerModule {}

View file

@ -1,10 +1,11 @@
import {Component, Inject, OnDestroy, OnInit} from '@angular/core';
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 {Meta} from "@angular/platform-browser";
import {DOCUMENT} from "@angular/common";
import {DOCUMENT, isPlatformBrowser} from "@angular/common";
// import {DOCUMENT} from "@angular/common";
@Component({
selector: 'app-status',
@ -22,17 +23,23 @@ export class StatusComponent implements OnInit, OnDestroy {
groups: Group[];
lastUpdated: Date;
constructor(private api: ApiService, @Inject(DOCUMENT) private document: Document) {
constructor(private api: ApiService, @Inject(PLATFORM_ID) private platformId: Object,
@Inject(DOCUMENT) private document: Document) {
}
ngOnInit(): void {
interval(30000).pipe(
startWith(0),
takeUntil(this.destroyed$),
flatMap(() => this.api.getServiceStates())
).subscribe(response => {
const favicon: HTMLLinkElement = document.getElementById('favicon') as HTMLLinkElement;
favicon.href = `favicon-${response.state}.ico`;
this.update();
if (isPlatformBrowser(this.platformId)) {
interval(30000).pipe(takeUntil(this.destroyed$)).subscribe(() => this.update());
}
}
private update() {
this.api.getServiceStates().subscribe(response => {
if (isPlatformBrowser(this.platformId)) {
const favicon: HTMLLinkElement = document.getElementById('favicon') as HTMLLinkElement;
favicon.href = `favicon-${response.state}.ico`;
}
this.groups = response.groups;
this.lastUpdated = new Date();
});

View file

@ -0,0 +1,4 @@
export const environment = {
production: true,
serverUrl: 'http://localhost:4000'
};

View file

@ -3,7 +3,8 @@
// The list of file replacements can be found in `angular.json`.
export const environment = {
production: false
production: false,
serverUrl: 'http://localhost:4200'
};
/*

View file

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View file

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View file

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

13
src/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>grafana-statuspage</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link id="favicon" rel="icon" type="image/x-icon" href="">
</head>
<body class="mat-typography">
<app-root></app-root>
</body>
</html>

10
src/main.server.ts Normal file
View file

@ -0,0 +1,10 @@
import { enableProdMode } from '@angular/core';
import { environment } from './environments/environment';
if (environment.production) {
enableProdMode();
}
export { AppServerModule } from './app/app.server.module';
export { renderModule, renderModuleFactory } from '@angular/platform-server';

117
src/main.status.ts Normal file
View file

@ -0,0 +1,117 @@
import {json, Router} from 'express';
import {CurrentStatus, State} from "./app/_data/data";
import {existsSync, readFileSync, writeFileSync} from "fs";
interface Cache {
[id: string]: State
}
interface Config {
authToken: string;
title: string;
description: string;
groups: {
id: string;
name: string;
services: {
id: string;
name: string;
url: string;
}[];
}[];
}
interface GrafanaWebhookBody {
dashboardId: number;
evalMatches: {
value: number,
metric: string,
tags: any
}[];
imageUrl: string,
message: string,
orgId: number,
panelId: number,
ruleId: number,
ruleName: string,
ruleUrl: string,
state: "ok" | "paused" | "alerting" | "pending" | "no_data";
tags: { [key: string]: string },
title: string
}
const api = Router();
api.use(json());
const config = JSON.parse(readFileSync('config.json', {encoding: 'UTF-8'})) as Config;
const serviceStates = existsSync('cache.json') ? JSON.parse(readFileSync('cache.json', {encoding: 'UTF-8'})) : {} as Cache;
let cache: CurrentStatus;
updateCache();
api.post('/update/health', (req, res) => {
const token = req.query.token;
if (token !== config.authToken) {
return res.status(401).send('invalid token');
}
const serviceId = req.query.service as string;
const message = req.body as GrafanaWebhookBody;
switch (message.state) {
case "no_data":
case "alerting":
serviceStates[serviceId] = "outage";
break;
case "paused":
serviceStates[serviceId] = "maintenance";
break;
default:
serviceStates[serviceId] = "operational"
}
updateCache();
writeFileSync('cache.json', JSON.stringify(serviceStates), {encoding: 'UTF-8'});
return res.send('OK');
});
api.get('/status', (req, res) => {
return res.json(cache);
});
api.get('/info', (req, res) => {
return res.json({
title: config.title,
description: config.description
});
});
function updateCache(): void {
const groups = config.groups.map(group => {
const services = group.services.map(service => {
return {
id: service.id,
name: service.name,
url: service.url,
state: serviceStates[service.id] || "operational"
}
});
return {
id: group.id,
name: group.name,
state: calculateOverallState(services.map(s => s.state)),
services: services
}
});
cache = {
state: calculateOverallState(groups.map(g => g.state)),
groups: groups
};
}
function calculateOverallState(states: State[]): State {
return states.includes("outage") ? "outage" : states.includes("maintenance") ? "maintenance" : "operational"
}
export {api};

View file

@ -8,5 +8,7 @@ if (environment.production) {
enableProdMode();
}
platformBrowserDynamic().bootstrapModule(AppModule)
document.addEventListener('DOMContentLoaded', () => {
platformBrowserDynamic().bootstrapModule(AppModule)
.catch(err => console.error(err));
});

17
tsconfig.server.json Normal file
View file

@ -0,0 +1,17 @@
{
"extends": "./tsconfig.app.json",
"compilerOptions": {
"outDir": "./out-tsc/app-server",
"module": "commonjs",
"types": [
"node"
]
},
"files": [
"src/main.server.ts",
"server.ts"
],
"angularCompilerOptions": {
"entryModule": "./src/app/app.server.module#AppServerModule"
}
}