develop #3
15 changed files with 261 additions and 106 deletions
82
.drone.yml
82
.drone.yml
|
@ -1,18 +1,92 @@
|
||||||
kind: pipeline
|
kind: pipeline
|
||||||
name: default
|
type: docker
|
||||||
|
name: linux-amd64
|
||||||
|
|
||||||
|
platform:
|
||||||
|
os: linux
|
||||||
|
arch: amd64
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: build
|
- name: build
|
||||||
image: node
|
image: node:alpine
|
||||||
commands:
|
commands:
|
||||||
- npm install
|
- npm install
|
||||||
- npm run build:ssr
|
- npm run build:ssr
|
||||||
|
|
||||||
- name: docker
|
- name: docker
|
||||||
image: plugins/docker
|
image: plugins/docker
|
||||||
settings:
|
settings:
|
||||||
repo: samuelph/grafana-statuspage
|
auto_tag: true
|
||||||
|
auto_tag_suffix: linux-amd64
|
||||||
|
repo: samuelph/universal-statuspage
|
||||||
username:
|
username:
|
||||||
from_secret: USERNAME
|
from_secret: USERNAME
|
||||||
password:
|
password:
|
||||||
from_secret: PASSWORD
|
from_secret: PASSWORD
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
branch:
|
||||||
|
- master
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: linux-arm
|
||||||
|
|
||||||
|
platform:
|
||||||
|
os: linux
|
||||||
|
arch: arm
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: build
|
||||||
|
image: node:alpine
|
||||||
|
commands:
|
||||||
|
- npm install
|
||||||
|
- npm run build:ssr
|
||||||
|
- name: docker
|
||||||
|
image: plugins/docker
|
||||||
|
settings:
|
||||||
|
auto_tag: true
|
||||||
|
auto_tag_suffix: linux-arm
|
||||||
|
repo: samuelph/universal-statuspage
|
||||||
|
username:
|
||||||
|
from_secret: USERNAME
|
||||||
|
password:
|
||||||
|
from_secret: PASSWORD
|
||||||
|
when:
|
||||||
|
branch:
|
||||||
|
- master
|
||||||
|
event:
|
||||||
|
- push
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: manifest
|
||||||
|
|
||||||
|
platform:
|
||||||
|
os: linux
|
||||||
|
arch: arm
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: publish
|
||||||
|
image: plugins/manifest
|
||||||
|
settings:
|
||||||
|
auto_tag: true
|
||||||
|
ignore_missing: true
|
||||||
|
target: samuelph/universal-statuspage
|
||||||
|
template: samuelph/universal-statuspage:OS-ARCH
|
||||||
|
platforms:
|
||||||
|
- linux/amd64
|
||||||
|
- linux/arm
|
||||||
|
username:
|
||||||
|
from_secret: USERNAME
|
||||||
|
password:
|
||||||
|
from_secret: PASSWORD
|
||||||
|
|
||||||
|
depends_on:
|
||||||
|
- linux-amd64
|
||||||
|
- linux-arm
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
branch:
|
||||||
|
- master
|
||||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -47,6 +47,5 @@ testem.log
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# custom
|
# custom
|
||||||
|
|
||||||
config.json
|
config.json
|
||||||
cache.json
|
cache.json
|
||||||
|
|
14
config.json
14
config.json
|
@ -2,6 +2,11 @@
|
||||||
"authToken": "test",
|
"authToken": "test",
|
||||||
"title": "sp-status",
|
"title": "sp-status",
|
||||||
"description": "Services hosted by sp-codes",
|
"description": "Services hosted by sp-codes",
|
||||||
|
"statePath": "$.status",
|
||||||
|
"stateValues": {
|
||||||
|
"operational": ["OK"],
|
||||||
|
"maintenance": ["PAUSED"]
|
||||||
|
},
|
||||||
"groups": [
|
"groups": [
|
||||||
{
|
{
|
||||||
"id": "test",
|
"id": "test",
|
||||||
|
@ -9,8 +14,13 @@
|
||||||
"services": [
|
"services": [
|
||||||
{
|
{
|
||||||
"id": "test",
|
"id": "test",
|
||||||
"name": "Test",
|
"name": "test",
|
||||||
"url": "http://test.de"
|
"url": "http://sp-codes.de",
|
||||||
|
"statePath": "$.state",
|
||||||
|
"stateValues": {
|
||||||
|
"operational": ["ok"],
|
||||||
|
"maintenance": ["paused"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
{
|
{
|
||||||
"extends": "../tsconfig.json",
|
"extends": "../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "../out-tsc/e2e",
|
"outDir": "../out-tsc/e2e",
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"target": "es5",
|
"target": "es2018",
|
||||||
"types": [
|
"types": [
|
||||||
"jasmine",
|
"jasmine",
|
||||||
"jasminewd2",
|
"jasminewd2",
|
||||||
|
|
63
package.json
63
package.json
|
@ -15,47 +15,48 @@
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "~9.1.4",
|
"@angular/animations": "~10.0.2",
|
||||||
"@angular/cdk": "^9.2.2",
|
"@angular/cdk": "^10.0.1",
|
||||||
"@angular/common": "~9.1.4",
|
"@angular/common": "~10.0.2",
|
||||||
"@angular/compiler": "~9.1.4",
|
"@angular/compiler": "~10.0.2",
|
||||||
"@angular/core": "~9.1.4",
|
"@angular/core": "~10.0.2",
|
||||||
"@angular/forms": "~9.1.4",
|
"@angular/forms": "~10.0.2",
|
||||||
"@angular/material": "^9.2.2",
|
"@angular/material": "^10.0.1",
|
||||||
"@angular/platform-browser": "~9.1.4",
|
"@angular/platform-browser": "~10.0.2",
|
||||||
"@angular/platform-browser-dynamic": "~9.1.4",
|
"@angular/platform-browser-dynamic": "~10.0.2",
|
||||||
"@angular/platform-server": "~9.1.4",
|
"@angular/platform-server": "~10.0.2",
|
||||||
"@angular/router": "~9.1.4",
|
"@angular/router": "~10.0.2",
|
||||||
"@fortawesome/fontawesome-free": "^5.13.0",
|
"@fortawesome/fontawesome-free": "^5.13.1",
|
||||||
"@nguniversal/express-engine": "^9.1.0",
|
"@nguniversal/express-engine": "^10.0.1",
|
||||||
"bootstrap": "^4.4.1",
|
"bootstrap": "^4.5.0",
|
||||||
"express": "^4.15.2",
|
"express": "^4.15.2",
|
||||||
|
"jsonpath": "^1.0.2",
|
||||||
"roboto-fontface": "^0.10.0",
|
"roboto-fontface": "^0.10.0",
|
||||||
"rxjs": "~6.5.4",
|
"rxjs": "~6.6.0",
|
||||||
"tslib": "^1.10.0",
|
"tslib": "^2.0.0",
|
||||||
"zone.js": "~0.10.2"
|
"zone.js": "~0.10.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "~0.901.4",
|
"@angular-devkit/build-angular": "~0.1000.1",
|
||||||
"@angular/cli": "~9.1.4",
|
"@angular/cli": "~10.0.1",
|
||||||
"@angular/compiler-cli": "~9.1.4",
|
"@angular/compiler-cli": "~10.0.2",
|
||||||
"@angular/language-service": "~9.1.4",
|
"@angular/language-service": "~10.0.2",
|
||||||
"@nguniversal/builders": "^9.1.0",
|
"@nguniversal/builders": "^10.0.1",
|
||||||
"@types/express": "^4.17.0",
|
"@types/express": "^4.17.0",
|
||||||
"@types/node": "^12.11.1",
|
"@types/node": "^14.0.14",
|
||||||
"@types/jasmine": "~3.5.0",
|
"@types/jasmine": "~3.5.11",
|
||||||
"@types/jasminewd2": "~2.0.3",
|
"@types/jasminewd2": "~2.0.3",
|
||||||
"codelyzer": "^5.1.2",
|
"codelyzer": "^6.0.0",
|
||||||
"jasmine-core": "~3.5.0",
|
"jasmine-core": "~3.5.0",
|
||||||
"jasmine-spec-reporter": "~4.2.1",
|
"jasmine-spec-reporter": "~5.0.0",
|
||||||
"karma": "~5.0.0",
|
"karma": "~5.0.0",
|
||||||
"karma-chrome-launcher": "~3.1.0",
|
"karma-chrome-launcher": "~3.1.0",
|
||||||
"karma-coverage-istanbul-reporter": "~2.1.0",
|
"karma-coverage-istanbul-reporter": "~3.0.2",
|
||||||
"karma-jasmine": "~3.0.1",
|
"karma-jasmine": "~3.3.0",
|
||||||
"karma-jasmine-html-reporter": "^1.4.2",
|
"karma-jasmine-html-reporter": "^1.5.0",
|
||||||
"protractor": "~5.4.3",
|
"protractor": "~7.0.0",
|
||||||
"ts-node": "~8.3.0",
|
"ts-node": "~8.10.2",
|
||||||
"tslint": "~6.1.0",
|
"tslint": "~6.1.0",
|
||||||
"typescript": "~3.8.3"
|
"typescript": "~3.9.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
35
renovate.json
Normal file
35
renovate.json
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
"assignees": [
|
||||||
|
"samuel-p"
|
||||||
|
],
|
||||||
|
"baseBranches": [
|
||||||
|
"develop"
|
||||||
|
],
|
||||||
|
"rangeStrategy": "bump",
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"managers": [
|
||||||
|
"npm"
|
||||||
|
],
|
||||||
|
"packageNames": [
|
||||||
|
"@types/node",
|
||||||
|
"@types/jasmine",
|
||||||
|
"@types/jasminewd2",
|
||||||
|
"codelyzer",
|
||||||
|
"protractor",
|
||||||
|
"rxjs",
|
||||||
|
"ts-node",
|
||||||
|
"tslib",
|
||||||
|
"tslint",
|
||||||
|
"typescript",
|
||||||
|
"zone.js"
|
||||||
|
],
|
||||||
|
"packagePatterns": [
|
||||||
|
"^angular",
|
||||||
|
"^karma",
|
||||||
|
"^jasmine"
|
||||||
|
],
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -10,7 +10,14 @@
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
Made with <span class="fas fa-heart"></span> by <a href="https://sp-codes.de">sp-codes</a>
|
<div class="row">
|
||||||
|
<div class="col-12 col-sm-6">
|
||||||
|
Powered by <a href="https://git.sp-codes.de/samuel-p/universal-statuspage">universal-statuspage</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-sm-6 text-sm-right">
|
||||||
|
Made with <span class="fas fa-heart"></span> by <a href="https://sp-codes.de">sp-codes</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
<a *ngFor="let service of group.services; last as last" mat-list-item [href]="service.url" target="_blank">
|
<a *ngFor="let service of group.services; last as last" mat-list-item [href]="service.url" target="_blank">
|
||||||
<div matLine class="d-flex">
|
<div matLine class="d-flex">
|
||||||
<i [class]="stateClasses[service.state]"></i>
|
<i [class]="stateClasses[service.state]"></i>
|
||||||
<span>{{service.name}}</span>
|
<span class="text-truncate">{{service.name}}</span>
|
||||||
<span class="flex-grow-1"></span>
|
<span class="flex-grow-1"></span>
|
||||||
<span class="text-capitalize {{service.state}}">{{service.state}}</span>
|
<span class="text-capitalize {{service.state}}">{{service.state}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,15 +1,21 @@
|
||||||
import {json, Router} from 'express';
|
import {json, Router} from 'express';
|
||||||
import {CurrentStatus, State} from "./app/_data/data";
|
import {CurrentStatus, State} from './app/_data/data';
|
||||||
import {existsSync, readFileSync, writeFileSync} from "fs";
|
import {existsSync, readFileSync, writeFileSync} from 'fs';
|
||||||
|
import * as jp from 'jsonpath';
|
||||||
|
|
||||||
interface Cache {
|
interface Cache {
|
||||||
[id: string]: State
|
[id: string]: State;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Config {
|
interface Config {
|
||||||
authToken: string;
|
authToken: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
statePath: string;
|
||||||
|
stateValues: {
|
||||||
|
operational: string[];
|
||||||
|
maintenance: string[];
|
||||||
|
};
|
||||||
groups: {
|
groups: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -17,34 +23,40 @@ interface Config {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
statePath?: string;
|
||||||
|
stateValues?: {
|
||||||
|
operational?: string[];
|
||||||
|
maintenance?: string[];
|
||||||
|
};
|
||||||
}[];
|
}[];
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GrafanaWebhookBody {
|
interface StateKey {
|
||||||
dashboardId: number;
|
statePath: string;
|
||||||
evalMatches: {
|
stateValues: {
|
||||||
value: number,
|
operational: string[];
|
||||||
metric: string,
|
maintenance: 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();
|
const api = Router();
|
||||||
api.use(json());
|
api.use(json());
|
||||||
|
|
||||||
const config = JSON.parse(readFileSync('config.json', {encoding: 'UTF-8'})) as Config;
|
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;
|
const stateKeys: { [service: string]: StateKey } = config.groups
|
||||||
|
.map(g => g.services).reduce((x, y) => x.concat(y), [])
|
||||||
|
.reduce((services, service) => {
|
||||||
|
services[service.id] = {
|
||||||
|
statePath: service.statePath || config.statePath,
|
||||||
|
stateValues: {
|
||||||
|
operational: service.stateValues ? service.stateValues.operational || config.stateValues.operational : config.stateValues.operational,
|
||||||
|
maintenance: service.stateValues ? service.stateValues.maintenance || config.stateValues.maintenance : config.stateValues.maintenance,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return services;
|
||||||
|
}, {});
|
||||||
|
const serviceStates = existsSync('cache.json') ? JSON.parse(readFileSync('cache.json', {encoding: 'utf-8'})) : {} as Cache;
|
||||||
|
|
||||||
let cache: CurrentStatus;
|
let cache: CurrentStatus;
|
||||||
updateCache();
|
updateCache();
|
||||||
|
@ -55,23 +67,20 @@ api.post('/update/health', (req, res) => {
|
||||||
return res.status(401).send('invalid token');
|
return res.status(401).send('invalid token');
|
||||||
}
|
}
|
||||||
const serviceId = req.query.service as string;
|
const serviceId = req.query.service as string;
|
||||||
const message = req.body as GrafanaWebhookBody;
|
const keys = stateKeys[serviceId];
|
||||||
|
const state = jp.value(req.body, keys.statePath);
|
||||||
|
|
||||||
switch (message.state) {
|
if (keys.stateValues.operational.includes(state)) {
|
||||||
case "no_data":
|
serviceStates[serviceId] = 'operational';
|
||||||
case "alerting":
|
} else if (keys.stateValues.maintenance.includes(state)) {
|
||||||
serviceStates[serviceId] = "outage";
|
serviceStates[serviceId] = 'maintenance';
|
||||||
break;
|
} else {
|
||||||
case "paused":
|
serviceStates[serviceId] = 'outage';
|
||||||
serviceStates[serviceId] = "maintenance";
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
serviceStates[serviceId] = "operational"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCache();
|
updateCache();
|
||||||
|
|
||||||
writeFileSync('cache.json', JSON.stringify(serviceStates), {encoding: 'UTF-8'});
|
writeFileSync('cache.json', JSON.stringify(serviceStates), {encoding: 'utf-8'});
|
||||||
|
|
||||||
return res.send('OK');
|
return res.send('OK');
|
||||||
});
|
});
|
||||||
|
@ -94,15 +103,15 @@ function updateCache(): void {
|
||||||
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'
|
||||||
}
|
};
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
id: group.id,
|
id: group.id,
|
||||||
name: group.name,
|
name: group.name,
|
||||||
state: calculateOverallState(services.map(s => s.state)),
|
state: calculateOverallState(services.map(s => s.state)),
|
||||||
services: services
|
services: services
|
||||||
}
|
};
|
||||||
});
|
});
|
||||||
cache = {
|
cache = {
|
||||||
state: calculateOverallState(groups.map(g => g.state)),
|
state: calculateOverallState(groups.map(g => g.state)),
|
||||||
|
@ -111,7 +120,7 @@ function updateCache(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
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';
|
||||||
}
|
}
|
||||||
|
|
||||||
export {api};
|
export {api};
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./out-tsc/app",
|
"outDir": "./out-tsc/app",
|
||||||
"types": []
|
"types": []
|
||||||
|
|
23
tsconfig.base.json
Normal file
23
tsconfig.base.json
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"compileOnSave": false,
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": "./",
|
||||||
|
"outDir": "./dist/out-tsc",
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": false,
|
||||||
|
"downlevelIteration": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"module": "es2020",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"importHelpers": true,
|
||||||
|
"target": "es2015",
|
||||||
|
"lib": [
|
||||||
|
"es2018",
|
||||||
|
"dom"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"fullTemplateTypeCheck": true,
|
||||||
|
"strictInjectionParameters": true
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,23 +1,20 @@
|
||||||
|
/*
|
||||||
|
This is a "Solution Style" tsconfig.json file, and is used by editors and TypeScript’s language server to improve development experience.
|
||||||
|
It is not intended to be used to perform a compilation.
|
||||||
|
|
||||||
|
To learn more about this file see: https://angular.io/config/solution-tsconfig.
|
||||||
|
*/
|
||||||
{
|
{
|
||||||
"compileOnSave": false,
|
"files": [],
|
||||||
"compilerOptions": {
|
"references": [
|
||||||
"baseUrl": "./",
|
{
|
||||||
"outDir": "./dist/out-tsc",
|
"path": "./tsconfig.app.json"
|
||||||
"sourceMap": true,
|
},
|
||||||
"declaration": false,
|
{
|
||||||
"downlevelIteration": true,
|
"path": "./tsconfig.spec.json"
|
||||||
"experimentalDecorators": true,
|
},
|
||||||
"module": "esnext",
|
{
|
||||||
"moduleResolution": "node",
|
"path": "./tsconfig.server.json"
|
||||||
"importHelpers": true,
|
}
|
||||||
"target": "es2015",
|
]
|
||||||
"lib": [
|
}
|
||||||
"es2018",
|
|
||||||
"dom"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"angularCompilerOptions": {
|
|
||||||
"fullTemplateTypeCheck": true,
|
|
||||||
"strictInjectionParameters": true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -2,11 +2,11 @@
|
||||||
"extends": "./tsconfig.app.json",
|
"extends": "./tsconfig.app.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./out-tsc/app-server",
|
"outDir": "./out-tsc/app-server",
|
||||||
"module": "commonjs",
|
|
||||||
"types": [
|
"types": [
|
||||||
"node"
|
"node"
|
||||||
]
|
]
|
||||||
},
|
, "target": "es2016"
|
||||||
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"src/main.server.ts",
|
"src/main.server.ts",
|
||||||
"server.ts"
|
"server.ts"
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./out-tsc/spec",
|
"outDir": "./out-tsc/spec",
|
||||||
"types": [
|
"types": [
|
||||||
|
|
Reference in a new issue