(feat) complete app

This commit is contained in:
pblaesi 2022-08-01 12:56:35 +02:00
parent 7d626ee276
commit dd8a5bfa8d
23 changed files with 4992 additions and 0 deletions

11
.eslintrc Normal file
View File

@ -0,0 +1,11 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
]
}

5
.prettierignore Normal file
View File

@ -0,0 +1,5 @@
node_modules
.idea
dist
data

0
.prettierrc.json Normal file
View File

4408
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
package.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "jira-graph",
"version": "1.0.0",
"scripts": {
"dev": "run-p copy-resources run-dev",
"run-dev": "nodemon src/server.ts",
"copy-resources": "cpx src/public/**/* dist/public -w",
"prettyprint": "prettier --write .",
"build": "tsc --project tsconfig.json"
},
"type": "module",
"dependencies": {
"express": "^4.18.1",
"axios": "^0.27.2",
"typescript": "^4.7.4",
"lodash.snakecase": "^4.1.1",
"typedi": "^0.10.0",
"reflect-metadata": "^0.1.13",
"chart.js": "^3.8.2",
"red-agate-svg-canvas": "^0.5.0",
"lowdb": "^3.0.0",
"serve-favicon": "^2.5.0"
},
"devDependencies": {
"@tsconfig/node14": "^1.0.3",
"ts-node": "^10.9.1",
"nodemon": "^2.0.19",
"eslint": "^8.20.0",
"@typescript-eslint/parser": "^5.31.0",
"@typescript-eslint/eslint-plugin": "^5.31.0",
"@types/lodash.snakecase": "^4.1.7",
"@types/node": "14.14.31",
"@types/express": "^4.17.13",
"prettier": "^2.7.1",
"eslint-config-prettier": "^8.5.0",
"cpx": "^1.5.0",
"npm-run-all": "^4.1.5",
"@types/serve-favicon": "^2.5.3"
}
}

28
src/handlers/api.ts Normal file
View File

@ -0,0 +1,28 @@
import express from "express";
import { Container } from "typedi";
import { HistoryService } from "../service/HistoryService.js";
import { ChartService } from "../service/ChartService.js";
import { ChartConfiguration } from "chart.js";
const historyService = Container.get(HistoryService);
const chartService = Container.get(ChartService);
export const apiRouter = express.Router();
async function getDataChartConfig(): Promise<ChartConfiguration | null> {
const historicalData = await historyService.getHistoricalData();
return chartService.toChartConfig(historicalData);
}
apiRouter.get("/ping", (req, res) => {
return res.send("API alive");
});
apiRouter.get("/history", async (req, res) => {
const chartConfig = await getDataChartConfig();
if (chartConfig) {
return res.json({ chartConfig });
} else {
return res.status(204).end();
}
});

25
src/handlers/appStatus.ts Normal file
View File

@ -0,0 +1,25 @@
import { Request, Response } from "express";
import { AppConfig, Configuration } from "../util/Configuration.js";
import { Container } from "typedi";
const version = process.env["npm_package_version"] || "snapshot";
const secretProps: Array<keyof Configuration> = [
"jiraUsername",
"jiraPassword",
"jiraAccessToken",
];
export function answerPing(req: Request, res: Response) {
res.json({ version });
}
export function createConfigurationHandler() {
const config = Container.get(AppConfig);
return (req: Request, res: Response) => {
const purifiedConfig = Object.assign({}, config.getConfiguration());
secretProps.forEach((secretProp) => {
purifiedConfig[secretProp] = "***";
});
res.json(purifiedConfig);
};
}

11
src/handlers/current.ts Normal file
View File

@ -0,0 +1,11 @@
import { Request, Response } from "express";
import { Container } from "typedi";
import { StatusService } from "../service/StatusService.js";
export function createCurrentHandler() {
const statusService = Container.get(StatusService);
return async (req: Request, res: Response) => {
const issues = await statusService.getCurrentStats();
res.json(issues);
};
}

87
src/handlers/graph.ts Normal file
View File

@ -0,0 +1,87 @@
import {
SvgCanvas,
Rect2D,
SvgCanvas2DGradient,
} from "red-agate-svg-canvas/modules";
import * as ChartJs from "chart.js";
import { Request, Response } from "express";
// Get the global scope.
// If running on a node, "g" points to a "global" object.
// When running on the browser, "g" points to the "window" object.
const g = Function("return this")();
// Chart options
// https://www.chartjs.org/docs/latest/getting-started/usage.html
const labels = ["January", "February", "March", "April", "May", "June"];
const data = {
labels: labels,
datasets: [
{
label: "My First dataset",
backgroundColor: "rgb(255, 99, 132)",
borderColor: "rgb(255, 99, 132)",
data: [0, 10, 5, 2, 20, 30, 45],
},
],
};
const config: ChartJs.ChartConfiguration = {
type: "line",
data: data,
options: {},
};
function draw(): string {
// SvgCanvas has a "CanvasRenderingContext2D"-compatible interface.
const ctx = new SvgCanvas();
// SvgCanvas lacks the canvas property.
(ctx as any).canvas = {
width: 800,
height: 400,
style: {
width: "800px",
height: "400px",
},
};
// SvgCanvas does not have font glyph information,
// so manually set the ratio of (font height / font width).
ctx.fontHeightRatio = 2;
// Chart.js needs a "HTMLCanvasElement"-like interface that has "getContext()" method.
// "getContext()" should returns a "CanvasRenderingContext2D"-compatible interface.
const el = { getContext: () => ctx };
// If "devicePixelRatio" is not set, Chart.js get the devicePixelRatio from "window" object.
// node.js environment has no window object.
config.options!.devicePixelRatio = 1;
// Disable animations.
config.options!.animation = false;
config.options!.events = [];
config.options!.responsive = false;
// Chart.js needs the "CanvasGradient" in the global scope.
const savedGradient = g.CanvasGradient;
g.CanvasGradient = SvgCanvas2DGradient;
try {
const chart = new ChartJs.Chart(el as any, config);
} finally {
if (savedGradient) {
g.CanvasGradient = savedGradient;
}
}
// Render as SVG.
const svgString = ctx.render(new Rect2D(0, 0, 800, 400), "px");
console.log(svgString);
return svgString;
}
export function createDrawSvgHandler() {
return (req: Request, res: Response) => {
res.send(draw());
};
}

4
src/model/Datapoint.ts Normal file
View File

@ -0,0 +1,4 @@
export interface Datapoint {
time: Date;
remainingEffort: number;
}

View File

@ -0,0 +1,5 @@
import { Datapoint } from "./Datapoint.js";
export interface HistoricalData {
dataPoints: Array<Datapoint>;
}

8
src/model/Issue.ts Normal file
View File

@ -0,0 +1,8 @@
export interface Issue {
id: number;
key: string;
fields: {
updated: Date;
timeestimate: number;
};
}

View File

@ -0,0 +1,6 @@
import { Issue } from "./Issue.js";
export interface IssueOverview {
total: number;
issues: Array<Issue>;
}

6
src/model/Stats.ts Normal file
View File

@ -0,0 +1,6 @@
export interface Stats {
numIssues: number;
totalEstimateInS: number;
totalEstimateInH: number;
totalEstimateInD: number;
}

35
src/public/chart.html Normal file
View File

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>JIRA Stats V.2</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
</head>
<body>
<div>
<canvas id="myChart"></canvas>
</div>
<div id="myErrorArea"></div>
<script>
fetch("/api/history")
.then((response) => {
if (response.status === 200) {
response.json().then((data) => {
if (data) {
const chartConfig = data.chartConfig;
if (chartConfig) {
new Chart(document.getElementById("myChart"), chartConfig);
}
}
});
}
})
.catch((error) => {
document.getElementById(
"myErrorArea"
).innerHTML = `<strong>Error fetching data: ${error.message} - ${error.stack}`;
});
</script>
</body>
</html>

BIN
src/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@ -0,0 +1,37 @@
import { Service } from "typedi";
import { Low, JSONFile } from "lowdb";
import { Datapoint } from "../model/Datapoint.js";
import { join } from "path";
import { __dirname } from "../util/Configuration.js";
type Data = {
points: Array<Datapoint>;
};
const filename = join(__dirname, "../data/db.json");
@Service()
export class HistoryRepository {
private readonly db: Low<Data>;
constructor() {
const adapter = new JSONFile<Data>(filename);
this.db = new Low(adapter);
this.db.read().then(() => {
this.db.data = this.db.data || { points: [] };
});
}
async getHistory(): Promise<Array<Datapoint>> {
return this.db.data!.points;
}
async save(time: Date, remainingEffort: number): Promise<Array<Datapoint>> {
this.db.data!.points.push({
time,
remainingEffort,
});
await this.db.write();
return this.db.data!.points;
}
}

28
src/server.ts Normal file
View File

@ -0,0 +1,28 @@
import "reflect-metadata";
import express from "express";
import {
answerPing,
createConfigurationHandler,
} from "./handlers/appStatus.js";
import { Container } from "typedi";
import { createCurrentHandler } from "./handlers/current.js";
import path from "path";
import { apiRouter } from "./handlers/api.js";
import { AppConfig, __dirname } from "./util/Configuration.js";
import favicon from "serve-favicon";
const app = express();
const config = Container.get(AppConfig);
const port = config.getConfiguration().listenPort;
app.use(favicon(path.join(__dirname, "public", "favicon.png")));
app.get("/ping", answerPing);
app.get("/config", createConfigurationHandler());
app.get("/current", createCurrentHandler());
app.use("/api", apiRouter);
app.use(express.static(path.join(__dirname, "public")));
app.listen(port, () => {
console.log(`App listening on port ${port}`);
});

View File

@ -0,0 +1,48 @@
import { Service } from "typedi";
import { AppConfig, Configuration } from "../util/Configuration.js";
import { ChartConfiguration, ChartTypeRegistry } from "chart.js";
import { HistoricalData } from "../model/HistoricalData.js";
@Service()
export class ChartService {
private readonly config: Configuration;
constructor(configService: AppConfig) {
this.config = configService.getConfiguration();
}
toChartConfig(history: HistoricalData): ChartConfiguration<"line"> | null {
if (!history.dataPoints.length) {
return null;
}
const labels: Array<Date> = [];
const values: Array<number> = [];
history.dataPoints.forEach((mapping) => {
labels.push(mapping.time);
values.push(mapping.remainingEffort);
});
const data = {
labels,
datasets: [
{
label: this.config.datasetLabel,
backgroundColor: "rgb(255, 99, 132)",
borderColor: "rgb(255, 99, 132)",
data: values,
},
],
};
const config = {
type: "line" as "line",
data: data,
options: {
scales: {
x: {
type: "time" as "time",
},
},
},
};
return config;
}
}

View File

@ -0,0 +1,25 @@
import { Service } from "typedi";
import { HistoricalData } from "../model/HistoricalData.js";
import { HistoryRepository } from "../repository/HistoryRepository.js";
import { Datapoint } from "../model/Datapoint.js";
@Service()
export class HistoryService {
constructor(private readonly repository: HistoryRepository) {}
private toHistoricalData(datapoints: Array<Datapoint>): HistoricalData {
return {
dataPoints: datapoints,
};
}
async getHistoricalData(): Promise<HistoricalData> {
const history = await this.repository.getHistory();
return this.toHistoricalData(history);
}
async saveCurrent(time: Date, remainingEffort: number): Promise<number> {
const ret = await this.repository.save(time, remainingEffort);
return ret.length;
}
}

View File

@ -0,0 +1,75 @@
import { Service } from "typedi";
import { AppConfig, Configuration } from "../util/Configuration.js";
import axios from "axios";
import { IssueOverview } from "../model/IssueOverview.js";
import { Issue } from "../model/Issue.js";
import { Stats } from "../model/Stats.js";
import { HistoryService } from "./HistoryService.js";
@Service()
export class StatusService {
private readonly config: Configuration;
constructor(
configService: AppConfig,
private readonly historyService: HistoryService
) {
this.config = configService.getConfiguration();
}
private getDefaultHeaders() {
return {
headers: { Authorization: `Bearer ${this.config.jiraAccessToken}` },
};
}
private async getSearchUrl(): Promise<string> {
const filterUrl = `${this.config.jiraUrl}filter/${this.config.jiraFilter}`;
const response = await axios.get(filterUrl, this.getDefaultHeaders());
const ret = response.data["searchUrl"];
if (!ret || typeof ret !== "string") {
throw new Error(`Could not read searchUrl, received: ${ret}`);
}
return ret;
}
private async getIssues(): Promise<IssueOverview> {
const searchUrl = await this.getSearchUrl();
const ret: Array<Issue> = [];
let currentStartItem = 0;
let total;
let maxResults;
do {
let searchUrlWithPage = `${searchUrl}&startAt=${currentStartItem}`;
const response = await axios.get(
searchUrlWithPage,
this.getDefaultHeaders()
);
const data = response.data;
total = data.total;
maxResults = data.maxResults;
currentStartItem += maxResults;
ret.push(...data.issues);
} while (currentStartItem <= total);
return {
total,
issues: ret,
};
}
async getCurrentStats(): Promise<Stats> {
const issues = await this.getIssues();
const totalEstimateInS = issues.issues.reduce((sum, issue) => {
return sum + issue.fields.timeestimate;
}, 0);
const totalEstimateInH = totalEstimateInS / 3600;
const totalEstimateInD = totalEstimateInS / 3600 / 8;
await this.historyService.saveCurrent(new Date(), totalEstimateInD);
return {
numIssues: issues.total,
totalEstimateInS,
totalEstimateInH,
totalEstimateInD,
};
}
}

80
src/util/Configuration.ts Normal file
View File

@ -0,0 +1,80 @@
import * as process from "process";
import snakecase from "lodash.snakecase";
import { Service } from "typedi";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
export interface Configuration {
jiraUrl: string;
jiraFilter: string;
dbType: string;
dbUrl?: string;
listenPort: string;
jiraUsername: string;
jiraPassword: string;
jiraAccessToken: string;
datasetLabel: string;
}
const CONFIG_DEFAULTS: Configuration = {
jiraUrl: "https://www.exxcellent.de/jira/rest/api/latest/",
jiraFilter: "55931",
dbType: "local",
listenPort: "3000",
dbUrl: "",
jiraUsername: "pblaesi",
jiraPassword: "mySecretJiraPassword",
jiraAccessToken: "MzA4MzE5MDc3NjUxOqEVKdb9imjQsdxSoyBxegQQlTfN",
datasetLabel: "Remaining Effort",
};
interface ConfigOption {
key: keyof Configuration;
defaultValue?: string;
envVarName?: string;
}
function toEnvVar(key: string): string {
return snakecase(key).toUpperCase();
}
function toConfiguration(opts: ConfigOption[]): Configuration {
const configBuilder: Configuration = Object.assign({}, CONFIG_DEFAULTS);
opts.forEach((option) => {
const effectiveEnvVarName = option.envVarName || toEnvVar(option.key);
const valueFromEnv = process.env[effectiveEnvVarName];
const value = valueFromEnv || option.defaultValue;
if (value !== null && value !== undefined) {
configBuilder[option.key] = value;
}
if (
configBuilder[option.key] === null ||
configBuilder[option.key] === undefined
) {
throw new Error(
`Could not determine value of option ${option.key} using environment variable name ${effectiveEnvVarName}; no default given`
);
}
});
return configBuilder;
}
function getConfiguration() {
const options: Array<ConfigOption> = [
{ key: "jiraUrl" },
{ key: "jiraFilter" },
{ key: "dbType", defaultValue: "local" },
{ key: "dbUrl" },
];
return toConfiguration(options);
}
@Service()
export class AppConfig {
getConfiguration(): Configuration {
return getConfiguration();
}
}
// Since we're in "util" subfolder, basedir is one level up
export const __dirname = join(dirname(fileURLToPath(import.meta.url)), "..");

20
tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"extends": "@tsconfig/node14/tsconfig.json",
"compilerOptions": {
"preserveConstEnums": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"outDir": "dist",
"esModuleInterop": true,
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "Node",
"lib": ["ES2020"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.test.ts"],
"ts-node": {
"esm": true,
"experimentalSpecifierResolution": true
}
}