develop #59
30 changed files with 904 additions and 363 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -49,3 +49,4 @@ Thumbs.db
|
||||||
# custom
|
# custom
|
||||||
config.json
|
config.json
|
||||||
cache.json
|
cache.json
|
||||||
|
uptime.json
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
FROM node:14.15.1-alpine
|
FROM node:14.15.4-alpine
|
||||||
|
|
||||||
COPY dist/universal-statuspage /universal-statuspage
|
COPY dist/universal-statuspage /universal-statuspage
|
||||||
|
|
||||||
|
|
141
LICENSE
141
LICENSE
|
@ -1,5 +1,5 @@
|
||||||
GNU GENERAL PUBLIC LICENSE
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
Version 3, 29 June 2007
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
@ -7,17 +7,15 @@
|
||||||
|
|
||||||
Preamble
|
Preamble
|
||||||
|
|
||||||
The GNU General Public License is a free, copyleft license for
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
software and other kinds of works.
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
The licenses for most software and other practical works are designed
|
The licenses for most software and other practical works are designed
|
||||||
to take away your freedom to share and change the works. By contrast,
|
to take away your freedom to share and change the works. By contrast,
|
||||||
the GNU General Public License is intended to guarantee your freedom to
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
share and change all versions of a program--to make sure it remains free
|
share and change all versions of a program--to make sure it remains free
|
||||||
software for all its users. We, the Free Software Foundation, use the
|
software for all its users.
|
||||||
GNU General Public License for most of our software; it applies also to
|
|
||||||
any other work released this way by its authors. You can apply it to
|
|
||||||
your programs, too.
|
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not
|
When we speak of free software, we are referring to freedom, not
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
|
||||||
want it, that you can change the software or use pieces of it in new
|
want it, that you can change the software or use pieces of it in new
|
||||||
free programs, and that you know you can do these things.
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
To protect your rights, we need to prevent others from denying you
|
Developers that use our General Public Licenses protect your rights
|
||||||
these rights or asking you to surrender the rights. Therefore, you have
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
certain responsibilities if you distribute copies of the software, or if
|
you this License which gives you legal permission to copy, distribute
|
||||||
you modify it: responsibilities to respect the freedom of others.
|
and/or modify the software.
|
||||||
|
|
||||||
For example, if you distribute copies of such a program, whether
|
A secondary benefit of defending all users' freedom is that
|
||||||
gratis or for a fee, you must pass on to the recipients the same
|
improvements made in alternate versions of the program, if they
|
||||||
freedoms that you received. You must make sure that they, too, receive
|
receive widespread use, become available for other developers to
|
||||||
or can get the source code. And you must show them these terms so they
|
incorporate. Many developers of free software are heartened and
|
||||||
know their rights.
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
Developers that use the GNU GPL protect your rights with two steps:
|
The GNU Affero General Public License is designed specifically to
|
||||||
(1) assert copyright on the software, and (2) offer you this License
|
ensure that, in such cases, the modified source code becomes available
|
||||||
giving you legal permission to copy, distribute and/or modify it.
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
For the developers' and authors' protection, the GPL clearly explains
|
An older license, called the Affero General Public License and
|
||||||
that there is no warranty for this free software. For both users' and
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
authors' sake, the GPL requires that modified versions be marked as
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
changed, so that their problems will not be attributed erroneously to
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
authors of previous versions.
|
this license.
|
||||||
|
|
||||||
Some devices are designed to deny users access to install or run
|
|
||||||
modified versions of the software inside them, although the manufacturer
|
|
||||||
can do so. This is fundamentally incompatible with the aim of
|
|
||||||
protecting users' freedom to change the software. The systematic
|
|
||||||
pattern of such abuse occurs in the area of products for individuals to
|
|
||||||
use, which is precisely where it is most unacceptable. Therefore, we
|
|
||||||
have designed this version of the GPL to prohibit the practice for those
|
|
||||||
products. If such problems arise substantially in other domains, we
|
|
||||||
stand ready to extend this provision to those domains in future versions
|
|
||||||
of the GPL, as needed to protect the freedom of users.
|
|
||||||
|
|
||||||
Finally, every program is threatened constantly by software patents.
|
|
||||||
States should not allow patents to restrict development and use of
|
|
||||||
software on general-purpose computers, but in those that do, we wish to
|
|
||||||
avoid the special danger that patents applied to a free program could
|
|
||||||
make it effectively proprietary. To prevent this, the GPL assures that
|
|
||||||
patents cannot be used to render the program non-free.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
The precise terms and conditions for copying, distribution and
|
||||||
modification follow.
|
modification follow.
|
||||||
|
@ -72,7 +60,7 @@ modification follow.
|
||||||
|
|
||||||
0. Definitions.
|
0. Definitions.
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU General Public License.
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
works, such as semiconductor masks.
|
works, such as semiconductor masks.
|
||||||
|
@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
|
||||||
the Program, the only way you could satisfy both those terms and this
|
the Program, the only way you could satisfy both those terms and this
|
||||||
License would be to refrain entirely from conveying the Program.
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
13. Use with the GNU Affero General Public License.
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
Notwithstanding any other provision of this License, you have
|
||||||
permission to link or combine any covered work with a work licensed
|
permission to link or combine any covered work with a work licensed
|
||||||
under version 3 of the GNU Affero General Public License into a single
|
under version 3 of the GNU General Public License into a single
|
||||||
combined work, and to convey the resulting work. The terms of this
|
combined work, and to convey the resulting work. The terms of this
|
||||||
License will continue to apply to the part which is the covered work,
|
License will continue to apply to the part which is the covered work,
|
||||||
but the special requirements of the GNU Affero General Public License,
|
but the work with which it is combined will remain governed by version
|
||||||
section 13, concerning interaction through a network will apply to the
|
3 of the GNU General Public License.
|
||||||
combination as such.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
the GNU General Public License from time to time. Such new versions will
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
be similar in spirit to the present version, but may differ in detail to
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
address new problems or concerns.
|
address new problems or concerns.
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
Each version is given a distinguishing version number. If the
|
||||||
Program specifies that a certain numbered version of the GNU General
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
Public License "or any later version" applies to it, you have the
|
Public License "or any later version" applies to it, you have the
|
||||||
option of following the terms and conditions either of that numbered
|
option of following the terms and conditions either of that numbered
|
||||||
version or of any later version published by the Free Software
|
version or of any later version published by the Free Software
|
||||||
Foundation. If the Program does not specify a version number of the
|
Foundation. If the Program does not specify a version number of the
|
||||||
GNU General Public License, you may choose any version ever published
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
by the Free Software Foundation.
|
by the Free Software Foundation.
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
If the Program specifies that a proxy can decide which future
|
||||||
versions of the GNU General Public License can be used, that proxy's
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
public statement of acceptance of a version permanently authorizes you
|
public statement of acceptance of a version permanently authorizes you
|
||||||
to choose that version for the Program.
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
|
||||||
Copyright (C) <year> <name of author>
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
This program is free software: you can redistribute it and/or modify
|
||||||
it under the terms of the GNU General Public License as published by
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
(at your option) any later version.
|
(at your option) any later version.
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
This program is distributed in the hope that it will be useful,
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
GNU General Public License for more details.
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
You should have received a copy of the GNU Affero General Public License
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
If the program does terminal interaction, make it output a short
|
If your software can interact with users remotely through a computer
|
||||||
notice like this when it starts in an interactive mode:
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
<program> Copyright (C) <year> <name of author>
|
interface could display a "Source" link that leads users to an archive
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
of the code. There are many ways you could offer source, and different
|
||||||
This is free software, and you are welcome to redistribute it
|
solutions will be better for different programs; see section 13 for the
|
||||||
under certain conditions; type `show c' for details.
|
specific requirements.
|
||||||
|
|
||||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
|
||||||
parts of the General Public License. Of course, your program's commands
|
|
||||||
might be different; for a GUI interface, you would use an "about box".
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
For more information on this, and how to apply and follow the GNU GPL, see
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
<https://www.gnu.org/licenses/>.
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
The GNU General Public License does not permit incorporating your program
|
|
||||||
into proprietary programs. If your program is a subroutine library, you
|
|
||||||
may consider it more useful to permit linking proprietary applications with
|
|
||||||
the library. If this is what you want to do, use the GNU Lesser General
|
|
||||||
Public License instead of this License. But first, please read
|
|
||||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
# [universal-statuspage](https://git.sp-codes.de/samuel-p/universal-statuspage)
|
# [universal-statuspage](https://git.sp-codes.de/samuel-p/universal-statuspage)
|
||||||
|
|
||||||
[![Build Status](https://ci.sp-codes.de/api/badges/samuel-p/universal-statuspage/status.svg)](https://ci.sp-codes.de/samuel-p/universal-statuspage) [![License](https://img.shields.io/badge/license-GPL--3.0-orange)](#license) [![Docker Pulls](https://img.shields.io/docker/pulls/samuelph/universal-statuspage)](https://hub.docker.com/r/samuelph/universal-statuspage)
|
[![Build Status](https://ci.sp-codes.de/api/badges/samuel-p/universal-statuspage/status.svg)](https://ci.sp-codes.de/samuel-p/universal-statuspage) [![License](https://img.shields.io/badge/license-AGPL--3.0-purple)](#license) [![Docker Pulls](https://img.shields.io/docker/pulls/samuelph/universal-statuspage)](https://hub.docker.com/r/samuelph/universal-statuspage)
|
||||||
|
|
||||||
A simple, universal public Statuspage. [Demo](https://status.sp-codes.de)
|
A simple, universal public Statuspage. [Demo](https://status.sp-codes.de)
|
||||||
|
|
||||||
|
@ -10,6 +10,6 @@ _tbd_
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](https://www.gnu.org/licenses/gpl-3.0)
|
[![GNU AGPLv3 Image](https://www.gnu.org/graphics/agplv3-155x51.png)](https://www.gnu.org/licenses/agpl-3.0)
|
||||||
|
|
||||||
universal-statuspage is Free Software: It is licensed under GNU GPL v3 (See [LICENSE](LICENSE) for more information).
|
universal-statuspage is Free Software: It is licensed under GNU AGPL v3 (See [LICENSE](LICENSE) for more information).
|
||||||
|
|
|
@ -17,12 +17,14 @@
|
||||||
"build": {
|
"build": {
|
||||||
"builder": "@angular-devkit/build-angular:browser",
|
"builder": "@angular-devkit/build-angular:browser",
|
||||||
"options": {
|
"options": {
|
||||||
|
"allowedCommonJsDependencies": ["dayjs/locale/de"],
|
||||||
"outputPath": "dist/universal-statuspage/browser",
|
"outputPath": "dist/universal-statuspage/browser",
|
||||||
"index": "src/index.html",
|
"index": "src/index.html",
|
||||||
"main": "src/main.ts",
|
"main": "src/main.ts",
|
||||||
"polyfills": "src/polyfills.ts",
|
"polyfills": "src/polyfills.ts",
|
||||||
"tsConfig": "tsconfig.app.json",
|
"tsConfig": "tsconfig.app.json",
|
||||||
"aot": true,
|
"aot": true,
|
||||||
|
"outputHashing": "media",
|
||||||
"assets": [
|
"assets": [
|
||||||
"src/favicon.png",
|
"src/favicon.png",
|
||||||
"src/favicon-operational.ico",
|
"src/favicon-operational.ico",
|
||||||
|
|
30
config.json
30
config.json
|
@ -2,22 +2,44 @@
|
||||||
"authToken": "test",
|
"authToken": "test",
|
||||||
"title": "sp-status",
|
"title": "sp-status",
|
||||||
"description": "Services hosted by sp-codes",
|
"description": "Services hosted by sp-codes",
|
||||||
|
"translations": {
|
||||||
|
"de": {
|
||||||
|
"title": "sp-status",
|
||||||
|
"description": "Services von sp-codes bereitgestellt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"servicesPath": "$.alerts.*",
|
"servicesPath": "$.alerts.*",
|
||||||
"idPath": "$.labels.status_service",
|
"idPath": "$.labels.status_service",
|
||||||
"statePath": "$.status",
|
"statePath": "$.status",
|
||||||
"stateValues": {
|
"stateValues": {
|
||||||
"operational": ["ok", "resolved"],
|
"operational": ["ok", "resolved"],
|
||||||
"maintenance": ["paused"]
|
"maintenance": ["maintenance" ,"paused"]
|
||||||
},
|
},
|
||||||
"groups": [
|
"groups": [
|
||||||
{
|
{
|
||||||
"id": "test",
|
"id": "group",
|
||||||
"name": "Test",
|
"name": "My Group",
|
||||||
"url": "http://sp-codes.de",
|
"url": "http://sp-codes.de",
|
||||||
"services": [
|
"services": [
|
||||||
{
|
{
|
||||||
"id": "test",
|
"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"
|
"statePath": "$.state"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
42
package.json
42
package.json
|
@ -15,33 +15,41 @@
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "~11.0.2",
|
"@angular/animations": "~11.0.7",
|
||||||
"@angular/cdk": "^11.0.1",
|
"@angular/cdk": "^11.0.3",
|
||||||
"@angular/common": "~11.0.2",
|
"@angular/common": "~11.0.7",
|
||||||
"@angular/compiler": "~11.0.2",
|
"@angular/compiler": "~11.0.7",
|
||||||
"@angular/core": "~11.0.2",
|
"@angular/core": "~11.0.7",
|
||||||
"@angular/forms": "~11.0.2",
|
"@angular/forms": "~11.0.7",
|
||||||
"@angular/material": "^11.0.1",
|
"@angular/material": "^11.0.3",
|
||||||
"@angular/platform-browser": "~11.0.2",
|
"@angular/platform-browser": "~11.0.7",
|
||||||
"@angular/platform-browser-dynamic": "~11.0.2",
|
"@angular/platform-browser-dynamic": "~11.0.7",
|
||||||
"@angular/platform-server": "~11.0.2",
|
"@angular/platform-server": "~11.0.7",
|
||||||
"@angular/router": "~11.0.2",
|
"@angular/router": "~11.0.7",
|
||||||
"@fortawesome/fontawesome-free": "^5.15.1",
|
"@fortawesome/fontawesome-free": "^5.15.1",
|
||||||
"@nguniversal/express-engine": "^11.0.0",
|
"@nguniversal/express-engine": "^11.0.1",
|
||||||
|
"@ngx-translate/core": "^13.0.0",
|
||||||
|
"@ngx-translate/http-loader": "^6.0.0",
|
||||||
|
"@types/node-cron": "^2.0.3",
|
||||||
"bootstrap": "^4.5.3",
|
"bootstrap": "^4.5.3",
|
||||||
|
"cron": "^1.8.2",
|
||||||
|
"dayjs": "^1.10.2",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
|
"flag-icon-css": "^3.5.0",
|
||||||
"jsonpath-plus": "^4.0.0",
|
"jsonpath-plus": "^4.0.0",
|
||||||
|
"node-cron": "^2.0.3",
|
||||||
"roboto-fontface": "^0.10.0",
|
"roboto-fontface": "^0.10.0",
|
||||||
"rxjs": "~6.6.3",
|
"rxjs": "~6.6.3",
|
||||||
"tslib": "^2.0.0",
|
"tslib": "^2.0.0",
|
||||||
|
"tz-offset": "0.0.2",
|
||||||
"zone.js": "~0.10.2"
|
"zone.js": "~0.10.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "~0.1100.2",
|
"@angular-devkit/build-angular": "~0.1100.6",
|
||||||
"@angular/cli": "~11.0.2",
|
"@angular/cli": "~11.0.6",
|
||||||
"@angular/compiler-cli": "~11.0.2",
|
"@angular/compiler-cli": "~11.0.7",
|
||||||
"@angular/language-service": "~11.0.2",
|
"@angular/language-service": "~11.0.7",
|
||||||
"@nguniversal/builders": "^11.0.0",
|
"@nguniversal/builders": "^11.0.1",
|
||||||
"@types/express": "^4.17.9",
|
"@types/express": "^4.17.9",
|
||||||
"@types/node": "^14.0.23",
|
"@types/node": "^14.0.23",
|
||||||
"@types/jasmine": "~3.6.0",
|
"@types/jasmine": "~3.6.0",
|
||||||
|
|
20
server.ts
20
server.ts
|
@ -1,6 +1,6 @@
|
||||||
import 'zone.js/dist/zone-node';
|
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 * as express from 'express';
|
||||||
import {join} from 'path';
|
import {join} from 'path';
|
||||||
|
|
||||||
|
@ -17,14 +17,26 @@ export function app() {
|
||||||
const indexHtml = existsSync(join(distFolder, 'index.original.html')) ? 'index.original.html' : 'index';
|
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)
|
// Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine)
|
||||||
server.engine('html', ngExpressEngine({
|
// server.engine('html', ngExpressEngine({
|
||||||
bootstrap: AppServerModule,
|
// 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('view engine', 'html');
|
||||||
server.set('views', distFolder);
|
server.set('views', distFolder);
|
||||||
|
|
||||||
server.use('/api', api);
|
server.use('/api', api);
|
||||||
|
server.get('/favicon.ico', (req, res) => {
|
||||||
|
return res.sendStatus(404);
|
||||||
|
});
|
||||||
|
|
||||||
// Serve static files from /browser
|
// Serve static files from /browser
|
||||||
server.get('*.*', express.static(distFolder, {
|
server.get('*.*', express.static(distFolder, {
|
||||||
|
|
|
@ -18,9 +18,31 @@ export interface Service {
|
||||||
name: string;
|
name: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
state: State;
|
state: State;
|
||||||
|
uptime: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MetaInfo {
|
export interface MetaInfo {
|
||||||
title: string;
|
title: string;
|
||||||
description: 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;
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
8
src/app/_pipe/dayjs.pipe.spec.ts
Normal file
8
src/app/_pipe/dayjs.pipe.spec.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { DayjsPipe } from './dayjs.pipe';
|
||||||
|
|
||||||
|
describe('DayjsPipe', () => {
|
||||||
|
it('create an instance', () => {
|
||||||
|
const pipe = new DayjsPipe();
|
||||||
|
expect(pipe).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
36
src/app/_pipe/dayjs.pipe.ts
Normal file
36
src/app/_pipe/dayjs.pipe.ts
Normal 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!');
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,9 +1,9 @@
|
||||||
import {Inject, Injectable, PLATFORM_ID} from '@angular/core';
|
import {Inject, Injectable, PLATFORM_ID} from '@angular/core';
|
||||||
import {Observable} from "rxjs";
|
import {Observable, of} from 'rxjs';
|
||||||
import {CurrentStatus, MetaInfo} from "../_data/data";
|
import {CurrentStatus, MetaInfo, UptimeStatus} from '../_data/data';
|
||||||
import {HttpClient} from "@angular/common/http";
|
import {HttpClient} from '@angular/common/http';
|
||||||
import {environment} from "../../environments/environment";
|
import {environment} from '../../environments/environment';
|
||||||
import {isPlatformBrowser} from "@angular/common";
|
import {isPlatformBrowser} from '@angular/common';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
|
@ -16,10 +16,14 @@ export class ApiService {
|
||||||
}
|
}
|
||||||
|
|
||||||
public getServiceStates(): Observable<CurrentStatus> {
|
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> {
|
public getMetaInfo(): Observable<MetaInfo> {
|
||||||
return this.http.get<MetaInfo>(this.api+ '/info');
|
return this.http.get<MetaInfo>(this.api + '/info');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
30
src/app/_service/storage.service.ts
Normal file
30
src/app/_service/storage.service.ts
Normal 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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,7 +1,22 @@
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<header class="container pt-4">
|
<header class="container pt-4">
|
||||||
<h1 *ngIf="title && title.length">{{title}}</h1>
|
<div class="d-flex">
|
||||||
<h3 *ngIf="description && description.length">{{description}}</h3>
|
<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>
|
</header>
|
||||||
|
|
||||||
<main class="container">
|
<main class="container">
|
||||||
|
|
|
@ -19,12 +19,6 @@ footer {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
.language-selection {
|
||||||
color: #cccccc;
|
font-size: 1.3em;
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #ffffff;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,35 +0,0 @@
|
||||||
import { TestBed, waitForAsync } from '@angular/core/testing';
|
|
||||||
import { RouterTestingModule } from '@angular/router/testing';
|
|
||||||
import { AppComponent } from './app.component';
|
|
||||||
|
|
||||||
describe('AppComponent', () => {
|
|
||||||
beforeEach(waitForAsync(() => {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [
|
|
||||||
RouterTestingModule
|
|
||||||
],
|
|
||||||
declarations: [
|
|
||||||
AppComponent
|
|
||||||
],
|
|
||||||
}).compileComponents();
|
|
||||||
}));
|
|
||||||
|
|
||||||
it('should create the app', () => {
|
|
||||||
const fixture = TestBed.createComponent(AppComponent);
|
|
||||||
const app = fixture.componentInstance;
|
|
||||||
expect(app).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`should have as title 'universal-statuspage'`, () => {
|
|
||||||
const fixture = TestBed.createComponent(AppComponent);
|
|
||||||
const app = fixture.componentInstance;
|
|
||||||
expect(app.title).toEqual('universal-statuspage');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render title', () => {
|
|
||||||
const fixture = TestBed.createComponent(AppComponent);
|
|
||||||
fixture.detectChanges();
|
|
||||||
const compiled = fixture.nativeElement;
|
|
||||||
expect(compiled.querySelector('.content span').textContent).toContain('universal-statuspage app is running!');
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,8 +1,9 @@
|
||||||
import {Component, OnInit} from '@angular/core';
|
import {Component, Inject, Injector, OnInit, PLATFORM_ID} from '@angular/core';
|
||||||
import {ApiService} from "./_service/api.service";
|
import {ApiService} from './_service/api.service';
|
||||||
import {Observable} from "rxjs";
|
import {Title} from '@angular/platform-browser';
|
||||||
import {MetaInfo} from "./_data/data";
|
import {TranslateService} from '@ngx-translate/core';
|
||||||
import {Title} from "@angular/platform-browser";
|
import {StorageService} from './_service/storage.service';
|
||||||
|
import {isPlatformServer} from '@angular/common';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
|
@ -12,15 +13,42 @@ import {Title} from "@angular/platform-browser";
|
||||||
export class AppComponent implements OnInit {
|
export class AppComponent implements OnInit {
|
||||||
title: string;
|
title: string;
|
||||||
description: 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 {
|
ngOnInit(): void {
|
||||||
this.api.getMetaInfo().subscribe(info => {
|
this.api.getMetaInfo().subscribe(info => {
|
||||||
this.title = info.title;
|
this.title = info.title;
|
||||||
this.description = info.description;
|
this.description = info.description;
|
||||||
|
this.translations = info.translations;
|
||||||
this.htmlTitle.setTitle(this.title);
|
this.htmlTitle.setTitle(this.title);
|
||||||
})
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getLanguage(): string {
|
||||||
|
return this.translate.currentLang;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLanguage(language: string): void {
|
||||||
|
this.translate.use(language);
|
||||||
|
this.storage.setValue('language', language);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,26 +1,56 @@
|
||||||
import {BrowserModule} from '@angular/platform-browser';
|
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 {AppRoutingModule} from './app-routing.module';
|
||||||
import {AppComponent} from './app.component';
|
import {AppComponent} from './app.component';
|
||||||
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
|
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
|
||||||
import {StatusComponent} from './status/status.component';
|
import {StatusComponent} from './status/status.component';
|
||||||
import {MatExpansionModule} from "@angular/material/expansion";
|
import {MatExpansionModule} from '@angular/material/expansion';
|
||||||
import {MatListModule} from "@angular/material/list";
|
import {MatListModule} from '@angular/material/list';
|
||||||
import {HttpClientModule} from "@angular/common/http";
|
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({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [
|
||||||
AppComponent,
|
AppComponent,
|
||||||
StatusComponent
|
StatusComponent,
|
||||||
|
UptimeComponent,
|
||||||
|
DayjsPipe
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
BrowserModule.withServerTransition({appId: 'serverApp'}),
|
BrowserModule.withServerTransition({appId: 'serverApp'}),
|
||||||
AppRoutingModule,
|
AppRoutingModule,
|
||||||
BrowserAnimationsModule,
|
BrowserAnimationsModule,
|
||||||
HttpClientModule,
|
HttpClientModule,
|
||||||
|
TranslateModule.forRoot({
|
||||||
|
loader: {
|
||||||
|
provide: TranslateLoader,
|
||||||
|
useFactory: UniversalLoaderFactory,
|
||||||
|
deps: [HttpClient, PLATFORM_ID]
|
||||||
|
}
|
||||||
|
}),
|
||||||
MatExpansionModule,
|
MatExpansionModule,
|
||||||
MatListModule
|
MatListModule,
|
||||||
|
MatTooltipModule
|
||||||
],
|
],
|
||||||
providers: [],
|
providers: [],
|
||||||
bootstrap: [AppComponent]
|
bootstrap: [AppComponent]
|
||||||
|
|
|
@ -1,25 +1,38 @@
|
||||||
<mat-accordion [multi]="true">
|
<div class="py-3" *ngFor="let group of groups">
|
||||||
<mat-expansion-panel *ngFor="let group of groups" [expanded]="true">
|
<h2>
|
||||||
<mat-expansion-panel-header>
|
<i [class]="stateClasses[group.state]"></i>
|
||||||
<mat-panel-title>
|
<a *ngIf="group.url" [href]="group.url" target="_blank"
|
||||||
<i [class]="stateClasses[group.state]"></i>
|
(click)="$event.stopPropagation()">{{group.name}}</a>
|
||||||
<a *ngIf="group.url" class="name" [href]="group.url" target="_blank" (click)="$event.stopPropagation()">{{group.name}}</a>
|
<span *ngIf="!group.url">{{group.name}}</span>
|
||||||
<span *ngIf="!group.url">{{group.name}}</span>
|
</h2>
|
||||||
</mat-panel-title>
|
<mat-accordion [multi]="true">
|
||||||
</mat-expansion-panel-header>
|
<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>
|
<ng-template matExpansionPanelContent>
|
||||||
<a *ngFor="let service of group.services; last as last" mat-list-item [href]="service.url || group.url || '#'" target="_blank">
|
<app-uptime [id]="service.id"></app-uptime>
|
||||||
<div matLine class="d-flex">
|
</ng-template>
|
||||||
<i [class]="stateClasses[service.state]"></i>
|
</mat-expansion-panel>
|
||||||
<span class="text-truncate">{{service.name}}</span>
|
</mat-accordion>
|
||||||
<span class="flex-grow-1"></span>
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="text-right mt-3"><small>Last updated {{lastUpdated | date:'HH:mm:ss'}}</small></div>
|
<div class="text-right pb-3"><small matTooltip="{{lastUpdated | dayjs:'format':'LTS'}}" matTooltipPosition="above"
|
||||||
|
[matTooltipShowDelay]="0"
|
||||||
|
[matTooltipHideDelay]="0">{{'last-updated' | translate:{'time': lastUpdated | dayjs:'from'} }}</small>
|
||||||
|
</div>
|
||||||
|
|
|
@ -1,23 +1,5 @@
|
||||||
.operational {
|
|
||||||
color: #7ed321;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outage {
|
|
||||||
color: #ff6f6f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.maintenance {
|
|
||||||
color: #f7ca18;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
a {
|
||||||
text-decoration: none;
|
|
||||||
outline: none;
|
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
|
|
||||||
&:hover.name, &:hover .name {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mat-panel-title {
|
mat-panel-title {
|
||||||
|
|
|
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,11 +1,10 @@
|
||||||
import {Component, Inject, OnDestroy, OnInit, PLATFORM_ID} from '@angular/core';
|
import {Component, Inject, OnDestroy, OnInit, PLATFORM_ID} from '@angular/core';
|
||||||
import {ApiService} from "../_service/api.service";
|
import {ApiService} from '../_service/api.service';
|
||||||
import {Group} from "../_data/data";
|
import {Group} from '../_data/data';
|
||||||
import {interval, Subject} from "rxjs";
|
import {interval, Subject} from 'rxjs';
|
||||||
import {flatMap, startWith, takeUntil} from "rxjs/operators";
|
import {takeUntil} from 'rxjs/operators';
|
||||||
import {DOCUMENT, isPlatformBrowser} from "@angular/common";
|
import {DOCUMENT, isPlatformBrowser} from '@angular/common';
|
||||||
|
import {StorageService} from '../_service/storage.service';
|
||||||
// import {DOCUMENT} from "@angular/common";
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-status',
|
selector: 'app-status',
|
||||||
|
@ -14,17 +13,24 @@ import {DOCUMENT, isPlatformBrowser} from "@angular/common";
|
||||||
})
|
})
|
||||||
export class StatusComponent implements OnInit, OnDestroy {
|
export class StatusComponent implements OnInit, OnDestroy {
|
||||||
readonly stateClasses = {
|
readonly stateClasses = {
|
||||||
"operational": 'fas fa-fw fa-heart operational mr-2',
|
'operational': 'fas fa-fw fa-heart operational mr-2',
|
||||||
"outage": 'fas fa-fw fa-heart-broken outage mr-2',
|
'outage': 'fas fa-fw fa-heart-broken outage mr-2',
|
||||||
"maintenance": 'fas fa-fw fa-heartbeat maintenance mr-2'
|
'maintenance': 'fas fa-fw fa-heartbeat maintenance mr-2'
|
||||||
};
|
};
|
||||||
|
|
||||||
destroyed$ = new Subject();
|
destroyed$ = new Subject();
|
||||||
groups: Group[];
|
groups: Group[];
|
||||||
lastUpdated: Date;
|
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) {
|
@Inject(DOCUMENT) private document: Document) {
|
||||||
|
let cache = this.storage.getValue('expanded');
|
||||||
|
if (typeof cache !== 'object') {
|
||||||
|
cache = null;
|
||||||
|
}
|
||||||
|
this.expandedCache = cache || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
@ -49,4 +55,8 @@ export class StatusComponent implements OnInit, OnDestroy {
|
||||||
this.destroyed$.next();
|
this.destroyed$.next();
|
||||||
this.destroyed$.complete();
|
this.destroyed$.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
saveExpandedCache() {
|
||||||
|
this.storage.setValue('expanded', this.expandedCache);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
70
src/app/uptime/uptime.component.html
Normal file
70
src/app/uptime/uptime.component.html
Normal 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'}} {{day.uptime.toFixed(2)}}%" matTooltipPosition="above"
|
||||||
|
[matTooltipShowDelay]="0" [matTooltipHideDelay]="0" matTooltipClass="multiline-tooltip"></div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="uptime.events.length">
|
||||||
|
<h2 class="m-0">{{'recent-events.title' | translate}}</h2>
|
||||||
|
<mat-list>
|
||||||
|
<ng-container *ngFor="let event of uptime.events; index as index; last as last">
|
||||||
|
<mat-list-item *ngIf="index < 4 || expanded">
|
||||||
|
<i mat-list-icon [class]="stateClasses[event.state]"></i>
|
||||||
|
<p matLine>
|
||||||
|
<span *ngIf="event.state === 'operational'"
|
||||||
|
class="text-truncate">{{'recent-events.operational' | translate}}</span>
|
||||||
|
<span *ngIf="event.state === 'maintenance'"
|
||||||
|
class="text-truncate">{{'recent-events.maintenance' | translate: {'time': event.date | dayjs:'to':uptime.events[index - 1]?.date} }}</span>
|
||||||
|
<span *ngIf="event.state === 'outage'"
|
||||||
|
class="text-truncate">{{'recent-events.outage' | translate:{'time': event.date | dayjs:'to':uptime.events[index - 1]?.date} }}</span>
|
||||||
|
</p>
|
||||||
|
<div matLine><small matTooltip="{{event.date | dayjs:'format':'LLL'}}" matTooltipPosition="above"
|
||||||
|
[matTooltipShowDelay]="0" [matTooltipHideDelay]="0">{{event.date | dayjs:'from'}}</small>
|
||||||
|
</div>
|
||||||
|
<mat-divider [inset]="true"
|
||||||
|
*ngIf="!(last || uptime.events.length > 4 && !expanded && index >= 3)"></mat-divider>
|
||||||
|
</mat-list-item>
|
||||||
|
</ng-container>
|
||||||
|
</mat-list>
|
||||||
|
<a href="#" class="mt-3" *ngIf="uptime.events.length > 4"
|
||||||
|
(click)="toggleExpanded(); $event.preventDefault()">{{(expanded ? 'show-less' : 'show-all') | translate}}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ng-template #loadingOrError>
|
||||||
|
<div *ngIf="error">{{error}}</div>
|
||||||
|
<div *ngIf="!error">{{'loading' | translate}}</div>
|
||||||
|
</ng-template>
|
0
src/app/uptime/uptime.component.scss
Normal file
0
src/app/uptime/uptime.component.scss
Normal file
52
src/app/uptime/uptime.component.ts
Normal file
52
src/app/uptime/uptime.component.ts
Normal 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
24
src/assets/i18n/de.json
Normal 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
24
src/assets/i18n/en.json
Normal 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"
|
||||||
|
}
|
|
@ -1,8 +1,16 @@
|
||||||
import {json, Router} from 'express';
|
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 {existsSync, readFileSync, writeFileSync} from 'fs';
|
||||||
import {join} from 'path';
|
import {join} from 'path';
|
||||||
import {JSONPath} from 'jsonpath-plus';
|
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 {
|
interface Cache {
|
||||||
[id: string]: State;
|
[id: string]: State;
|
||||||
|
@ -12,6 +20,12 @@ interface Config {
|
||||||
authToken: string;
|
authToken: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
translations?: {
|
||||||
|
[lang: string]: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
},
|
||||||
servicesPath?: string;
|
servicesPath?: string;
|
||||||
idPath?: string;
|
idPath?: string;
|
||||||
statePath?: string;
|
statePath?: string;
|
||||||
|
@ -46,6 +60,7 @@ const serviceStatePaths: { [service: string]: string } = config.groups
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
let cache: CurrentStatus;
|
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();
|
updateCache();
|
||||||
|
|
||||||
api.post('/update/health', (req, res) => {
|
api.post('/update/health', (req, res) => {
|
||||||
|
@ -60,40 +75,48 @@ api.post('/update/health', (req, res) => {
|
||||||
} else if (config.servicesPath && config.idPath && config.statePath) {
|
} else if (config.servicesPath && config.idPath && config.statePath) {
|
||||||
services = JSONPath({path: config.servicesPath, json: req.body})
|
services = JSONPath({path: config.servicesPath, json: req.body})
|
||||||
.map(s => ({
|
.map(s => ({
|
||||||
id: JSONPath({path: config.idPath, json: s, wrap: false}),
|
id: JSONPath({path: config.idPath, json: s, wrap: false}),
|
||||||
state: JSONPath({path: config.statePath, json: s, wrap: false})
|
state: JSONPath({path: config.statePath, json: s, wrap: false})
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
services.forEach(s => {
|
services.forEach(s => {
|
||||||
if (config.stateValues.operational.includes(s.state)) {
|
if (config.stateValues.operational.includes(s.state)) {
|
||||||
serviceStates[s.id] = 'operational';
|
updateServiceState(s.id, 'operational');
|
||||||
} else if (config.stateValues.maintenance.includes(s.state)) {
|
} else if (config.stateValues.maintenance.includes(s.state)) {
|
||||||
serviceStates[s.id] = 'maintenance';
|
updateServiceState(s.id, 'maintenance');
|
||||||
} else {
|
} else {
|
||||||
serviceStates[s.id] = 'outage';
|
updateServiceState(s.id, 'outage');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
updateCache();
|
updateCache();
|
||||||
|
persistCache();
|
||||||
writeFileSync('cache.json', JSON.stringify(serviceStates), {encoding: 'utf-8'});
|
|
||||||
|
|
||||||
return res.send('OK');
|
return res.send('OK');
|
||||||
});
|
});
|
||||||
|
|
||||||
api.get('/status', (req, res) => {
|
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) => {
|
api.get('/badge', (req, res) => {
|
||||||
const serviceId = req.query.service as string;
|
const serviceId = req.query.service as string;
|
||||||
if (!serviceId) {
|
if (!serviceId) {
|
||||||
return res.json({
|
return res.json({
|
||||||
"schemaVersion": 1,
|
'schemaVersion': 1,
|
||||||
"label": "sp-status",
|
'label': 'sp-status',
|
||||||
"message": "service not provided",
|
'message': 'service not provided',
|
||||||
"isError": true
|
'isError': true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const service = cache.groups
|
const service = cache.groups
|
||||||
|
@ -101,10 +124,10 @@ api.get('/badge', (req, res) => {
|
||||||
.find(s => s.id === serviceId);
|
.find(s => s.id === serviceId);
|
||||||
if (!service) {
|
if (!service) {
|
||||||
return res.json({
|
return res.json({
|
||||||
"schemaVersion": 1,
|
'schemaVersion': 1,
|
||||||
"label": "sp-status",
|
'label': 'sp-status',
|
||||||
"message": "service not found",
|
'message': 'service not found',
|
||||||
"isError": true
|
'isError': true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const label = req.query.label || service.name;
|
const label = req.query.label || service.name;
|
||||||
|
@ -125,28 +148,51 @@ api.get('/badge', (req, res) => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
return res.json({
|
return res.json({
|
||||||
"schemaVersion": 1,
|
'schemaVersion': 1,
|
||||||
"label": label,
|
'label': label,
|
||||||
"message": message,
|
'message': message,
|
||||||
"color": color
|
'color': color
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
api.get('/info', (req, res) => {
|
api.get('/info', (req, res) => {
|
||||||
return res.json({
|
return res.json({
|
||||||
title: config.title,
|
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 {
|
function updateCache(): void {
|
||||||
|
updateUptime();
|
||||||
|
|
||||||
const groups = config.groups.map(group => {
|
const groups = config.groups.map(group => {
|
||||||
const services = group.services.map(service => {
|
const services = group.services.map(service => {
|
||||||
|
const uptime = uptimeStates[service.id];
|
||||||
return {
|
return {
|
||||||
id: service.id,
|
id: service.id,
|
||||||
name: service.name,
|
name: service.name,
|
||||||
url: service.url,
|
url: service.url,
|
||||||
state: serviceStates[service.id] || 'operational'
|
state: serviceStates[service.id] || 'operational',
|
||||||
|
uptime: uptime ? uptime.days30 : 100
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return {
|
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 {
|
function calculateOverallState(states: State[]): State {
|
||||||
return states.includes('outage') ? 'outage' : states.includes('maintenance') ? 'maintenance' : 'operational';
|
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};
|
export {api};
|
||||||
|
|
206
src/styles.scss
206
src/styles.scss
|
@ -3,8 +3,17 @@
|
||||||
@import "~bootstrap/scss/mixins";
|
@import "~bootstrap/scss/mixins";
|
||||||
@import "~bootstrap/scss/utilities";
|
@import "~bootstrap/scss/utilities";
|
||||||
@import "~bootstrap/scss/bootstrap-grid";
|
@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";
|
@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';
|
@import '~@angular/material/theming';
|
||||||
|
|
||||||
|
@ -35,21 +44,21 @@ $dark-dividers: rgba($dark-primary-text, 0.12);
|
||||||
$dark-focused: rgba($dark-primary-text, 0.12);
|
$dark-focused: rgba($dark-primary-text, 0.12);
|
||||||
|
|
||||||
$mat-light-theme-foreground: (
|
$mat-light-theme-foreground: (
|
||||||
base: black,
|
base: black,
|
||||||
divider: $dark-dividers,
|
divider: $dark-dividers,
|
||||||
dividers: $dark-dividers,
|
dividers: $dark-dividers,
|
||||||
disabled: $dark-disabled-text,
|
disabled: $dark-disabled-text,
|
||||||
disabled-button: rgba($dark-text, 0.26),
|
disabled-button: rgba($dark-text, 0.26),
|
||||||
disabled-text: $dark-disabled-text,
|
disabled-text: $dark-disabled-text,
|
||||||
elevation: black,
|
elevation: black,
|
||||||
secondary-text: $dark-accent-text,
|
secondary-text: $dark-accent-text,
|
||||||
hint-text: $dark-disabled-text,
|
hint-text: $dark-disabled-text,
|
||||||
accent-text: $dark-accent-text,
|
accent-text: $dark-accent-text,
|
||||||
icon: $dark-accent-text,
|
icon: $dark-accent-text,
|
||||||
icons: $dark-accent-text,
|
icons: $dark-accent-text,
|
||||||
text: $dark-primary-text,
|
text: $dark-primary-text,
|
||||||
slider-min: $dark-primary-text,
|
slider-min: $dark-primary-text,
|
||||||
slider-off: rgba($dark-text, 0.26),
|
slider-off: rgba($dark-text, 0.26),
|
||||||
slider-off-active: $dark-disabled-text,
|
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);
|
$light-focused: rgba($light-primary-text, 0.12);
|
||||||
|
|
||||||
$mat-dark-theme-foreground: (
|
$mat-dark-theme-foreground: (
|
||||||
base: $light-text,
|
base: $light-text,
|
||||||
divider: $light-dividers,
|
divider: $light-dividers,
|
||||||
dividers: $light-dividers,
|
dividers: $light-dividers,
|
||||||
disabled: $light-disabled-text,
|
disabled: $light-disabled-text,
|
||||||
disabled-button: rgba($light-text, 0.3),
|
disabled-button: rgba($light-text, 0.3),
|
||||||
disabled-text: $light-disabled-text,
|
disabled-text: $light-disabled-text,
|
||||||
elevation: black,
|
elevation: black,
|
||||||
hint-text: $light-disabled-text,
|
hint-text: $light-disabled-text,
|
||||||
secondary-text: $light-accent-text,
|
secondary-text: $light-accent-text,
|
||||||
accent-text: $light-accent-text,
|
accent-text: $light-accent-text,
|
||||||
icon: $light-text,
|
icon: $light-text,
|
||||||
icons: $light-text,
|
icons: $light-text,
|
||||||
text: $light-text,
|
text: $light-text,
|
||||||
slider-min: $light-text,
|
slider-min: $light-text,
|
||||||
slider-off: rgba($light-text, 0.3),
|
slider-off: rgba($light-text, 0.3),
|
||||||
slider-off-active: rgba($light-text, 0.3),
|
slider-off-active: rgba($light-text, 0.3),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Background config
|
// Background config
|
||||||
// Light bg
|
// Light bg
|
||||||
$light-background: #fafafa;
|
$light-background: #fafafa;
|
||||||
$light-bg-darker-5: darken($light-background, 5%);
|
$light-bg-darker-5: darken($light-background, 5%);
|
||||||
$light-bg-darker-10: darken($light-background, 10%);
|
$light-bg-darker-10: darken($light-background, 10%);
|
||||||
$light-bg-darker-20: darken($light-background, 20%);
|
$light-bg-darker-20: darken($light-background, 20%);
|
||||||
$light-bg-darker-30: darken($light-background, 30%);
|
$light-bg-darker-30: darken($light-background, 30%);
|
||||||
$light-bg-lighter-5: lighten($light-background, 5%);
|
$light-bg-lighter-5: lighten($light-background, 5%);
|
||||||
$dark-bg-tooltip: lighten(#2c2c2c, 20%);
|
$dark-bg-tooltip: lighten(#2c2c2c, 20%);
|
||||||
$dark-bg-alpha-4: rgba(#2c2c2c, 0.04);
|
$dark-bg-alpha-4: rgba(#2c2c2c, 0.04);
|
||||||
$dark-bg-alpha-12: rgba(#2c2c2c, 0.12);
|
$dark-bg-alpha-12: rgba(#2c2c2c, 0.12);
|
||||||
|
|
||||||
$mat-light-theme-background: (
|
$mat-light-theme-background: (
|
||||||
background: $light-background,
|
background: $light-background,
|
||||||
status-bar: $light-bg-darker-20,
|
status-bar: $light-bg-darker-20,
|
||||||
app-bar: $light-bg-darker-5,
|
app-bar: $light-bg-darker-5,
|
||||||
hover: $dark-bg-alpha-4,
|
hover: $dark-bg-alpha-4,
|
||||||
card: $light-bg-lighter-5,
|
card: $light-bg-lighter-5,
|
||||||
dialog: $light-bg-lighter-5,
|
dialog: $light-bg-lighter-5,
|
||||||
tooltip: $dark-bg-tooltip,
|
tooltip: $dark-bg-tooltip,
|
||||||
disabled-button: $dark-bg-alpha-12,
|
disabled-button: $dark-bg-alpha-12,
|
||||||
raised-button: $light-bg-lighter-5,
|
raised-button: $light-bg-lighter-5,
|
||||||
focused-button: $dark-focused,
|
focused-button: $dark-focused,
|
||||||
selected-button: $light-bg-darker-20,
|
selected-button: $light-bg-darker-20,
|
||||||
selected-disabled-button: $light-bg-darker-30,
|
selected-disabled-button: $light-bg-darker-30,
|
||||||
disabled-button-toggle: $light-bg-darker-10,
|
disabled-button-toggle: $light-bg-darker-10,
|
||||||
unselected-chip: $light-bg-darker-10,
|
unselected-chip: $light-bg-darker-10,
|
||||||
disabled-list-option: $light-bg-darker-10,
|
disabled-list-option: $light-bg-darker-10,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Dark bg
|
// Dark bg
|
||||||
$dark-background: #2c2c2c;
|
$dark-background: #2c2c2c;
|
||||||
$dark-bg-lighter-5: lighten($dark-background, 5%);
|
$dark-bg-lighter-5: lighten($dark-background, 5%);
|
||||||
$dark-bg-lighter-10: lighten($dark-background, 10%);
|
$dark-bg-lighter-10: lighten($dark-background, 10%);
|
||||||
$dark-bg-lighter-20: lighten($dark-background, 20%);
|
$dark-bg-lighter-20: lighten($dark-background, 20%);
|
||||||
$dark-bg-lighter-30: lighten($dark-background, 30%);
|
$dark-bg-lighter-30: lighten($dark-background, 30%);
|
||||||
$light-bg-alpha-4: rgba(#fafafa, 0.04);
|
$light-bg-alpha-4: rgba(#fafafa, 0.04);
|
||||||
$light-bg-alpha-12: rgba(#fafafa, 0.12);
|
$light-bg-alpha-12: rgba(#fafafa, 0.12);
|
||||||
|
|
||||||
// Background palette for dark themes.
|
// Background palette for dark themes.
|
||||||
$mat-dark-theme-background: (
|
$mat-dark-theme-background: (
|
||||||
background: $dark-background,
|
background: $dark-background,
|
||||||
status-bar: $dark-bg-lighter-20,
|
status-bar: $dark-bg-lighter-20,
|
||||||
app-bar: $dark-bg-lighter-5,
|
app-bar: $dark-bg-lighter-5,
|
||||||
hover: $light-bg-alpha-4,
|
hover: $light-bg-alpha-4,
|
||||||
card: $dark-bg-lighter-5,
|
card: $dark-bg-lighter-5,
|
||||||
dialog: $dark-bg-lighter-5,
|
dialog: $dark-bg-lighter-5,
|
||||||
tooltip: $dark-bg-lighter-20,
|
tooltip: $dark-bg-lighter-20,
|
||||||
disabled-button: $light-bg-alpha-12,
|
disabled-button: $light-bg-alpha-12,
|
||||||
raised-button: $dark-bg-lighter-5,
|
raised-button: $dark-bg-lighter-5,
|
||||||
focused-button: $light-focused,
|
focused-button: $light-focused,
|
||||||
selected-button: $dark-bg-lighter-20,
|
selected-button: $dark-bg-lighter-20,
|
||||||
selected-disabled-button: $dark-bg-lighter-30,
|
selected-disabled-button: $dark-bg-lighter-30,
|
||||||
disabled-button-toggle: $dark-bg-lighter-10,
|
disabled-button-toggle: $dark-bg-lighter-10,
|
||||||
unselected-chip: $dark-bg-lighter-20,
|
unselected-chip: $dark-bg-lighter-20,
|
||||||
disabled-list-option: $dark-bg-lighter-10,
|
disabled-list-option: $dark-bg-lighter-10,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Compute font config
|
// Compute font config
|
||||||
|
@ -207,7 +216,8 @@ $mat-warn: (
|
||||||
darker: $light-primary-text,
|
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);
|
$theme: mat-dark-theme($theme-primary, $theme-accent, $theme-warn);
|
||||||
$altTheme: mat-light-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;
|
background-color: #222222;
|
||||||
color: #ffffff;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Reference in a new issue