1
0
mirror of https://github.com/pcvolkmer/etl-processor.git synced 2025-04-19 17:26:51 +00:00

Add start and statistics page

This commit is contained in:
Paul-Christian Volkmer 2023-07-25 16:50:20 +02:00
parent 46928964ef
commit cd46fa7e09
9 changed files with 695 additions and 0 deletions

View File

@ -0,0 +1,42 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* 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
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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/>.
*/
package dev.dnpm.etl.processor.web
import dev.dnpm.etl.processor.monitoring.RequestRepository
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
@Controller
@RequestMapping(path = ["/"])
class HomeController(
private val requestRepository: RequestRepository
) {
@GetMapping
fun index(model: Model): String {
val requests = requestRepository.findAll().sortedByDescending { it.processedAt }.take(25)
model.addAttribute("requests", requests)
return "index"
}
}

View File

@ -0,0 +1,35 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* 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
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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/>.
*/
package dev.dnpm.etl.processor.web
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
@Controller
@RequestMapping(path = ["/statistics"])
class StatisticsController {
@GetMapping
fun index(): String {
return "statistics"
}
}

View File

@ -0,0 +1,95 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* 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
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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/>.
*/
package dev.dnpm.etl.processor.web
import dev.dnpm.etl.processor.monitoring.RequestRepository
import dev.dnpm.etl.processor.monitoring.RequestStatus
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.time.Instant
import java.time.Month
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import java.time.temporal.TemporalUnit
@RestController
@RequestMapping(path = ["/statistics"])
class StatisticsRestController(
private val requestRepository: RequestRepository
) {
@GetMapping(path = ["requeststates"])
fun requestStates(): List<NameValue> {
return requestRepository.findAll()
.groupBy { it.status }
.map {
val color = when (it.key) {
RequestStatus.ERROR -> "red"
RequestStatus.WARNING -> "darkorange"
RequestStatus.SUCCESS -> "green"
else -> "slategray"
}
NameValue(it.key.toString(), it.value.size, color)
}
.sortedByDescending { it.value }
}
@GetMapping(path = ["requestslastmonth"])
fun requestsLastMonth(): List<DateNameValues> {
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneId.of("Europe/Berlin"))
val data = requestRepository.findAll()
.filter { it.processedAt.isAfter(Instant.now().minus(30, ChronoUnit.DAYS)) }
.groupBy { formatter.format(it.processedAt) }
.map {
val requestList = it.value
.groupBy { it.status }
.map {
Pair(it.key, it.value.size)
}
.toMap()
Pair(
it.key.toString(),
DateNameValues(it.key.toString(), NameValues(
error = requestList[RequestStatus.ERROR] ?: 0,
warning = requestList[RequestStatus.WARNING] ?: 0,
success = requestList[RequestStatus.SUCCESS] ?: 0,
duplication = requestList[RequestStatus.DUPLICATION] ?: 0,
unknown = requestList[RequestStatus.UNKNOWN] ?: 0,
))
)
}.toMap()
return (0L..30L).map { Instant.now().minus(it, ChronoUnit.DAYS) }
.map { formatter.format(it) }
.map {
DateNameValues(it, data[it]?.nameValues ?: NameValues())
}
.sortedBy { it.date }
}
}
data class NameValue(val name: String, val value: Int, val color: String)
data class DateNameValues(val date: String, val nameValues: NameValues)
data class NameValues(val error: Int = 0, val warning: Int = 0, val success: Int = 0, val duplication: Int = 0, val unknown: Int = 0)

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,113 @@
const dateFormatOptions = { year: 'numeric', month: '2-digit', day: '2-digit' };
const dateFormat = new Intl.DateTimeFormat('de-DE', dateFormatOptions);
const dateTimeFormatOptions = { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: 'numeric', second: 'numeric' };
const dateTimeFormat = new Intl.DateTimeFormat('de-DE', dateTimeFormatOptions);
window.onload = () => {
Array.from(document.getElementsByTagName('time')).forEach((timeTag) => {
let date = Date.parse(timeTag.getAttribute('datetime'));
if (! isNaN(date)) {
timeTag.innerText = dateTimeFormat.format(date);
}
});
};
function drawPieChart(url, elemId, title) {
fetch(url)
.then(resp => resp.json())
.then(data => {
let chartDom = document.getElementById(elemId);
let chart = echarts.init(chartDom);
let option= {
title: {
text: title,
left: 'center'
},
tooltip: {
trigger: 'item'
},
color: data.map(i => i.color),
series: [
{
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
label: {
show: false,
position: 'center'
},
labelLine: {
show: false
},
data: data
}
]
};
option && chart.setOption(option);
});
}
function drawBarChart(url, elemId, title) {
fetch(url)
.then(resp => resp.json())
.then(data => {
let chartDom = document.getElementById(elemId);
let chart = echarts.init(chartDom);
let option= {
title: {
text: title,
left: 'center'
},
xAxis: {
type: 'category',
data: data.map(i => dateFormat.format(Date.parse(i.date)))
},
yAxis: {
type: 'value',
minInterval: 2,
},
tooltip: {
trigger: 'item'
},
animation: false,
color: ['slategray', 'red', 'darkorange', 'green', 'slategray'],
series: [
{
name: 'UNKNOWN',
type: 'bar',
stack: 'total',
data: data.map(i => i.nameValues.unknown)
},
{
name: 'ERROR',
type: 'bar',
stack: 'total',
data: data.map(i => i.nameValues.error)
},
{
name: 'WARNING',
type: 'bar',
stack: 'total',
data: data.map(i => i.nameValues.warning)
},
{
name: 'SUCCESS',
type: 'bar',
stack: 'total',
data: data.map(i => i.nameValues.success)
},
{
name: 'DUPLICATION',
type: 'bar',
stack: 'total',
data: data.map(i => i.nameValues.duplication)
}
]
};
option && chart.setOption(option);
});
}

View File

@ -0,0 +1,284 @@
body {
margin: 0;
font-family: sans-serif;
font-size: .8rem;
color: #333;
}
nav {
margin: 0 auto;
background: #d5dad5;
height: 3rem;
max-width: 1140px;
}
nav a {
color: #004a8f;
text-transform: uppercase;
text-decoration: none;
line-height: 2rem;
font-weight: 700;
}
nav a:hover {
text-decoration: underline;
}
nav > ul {
margin: 0 3rem;
padding: 0;
}
nav > ul > li {
background: #fbfbfb;
display: block;
float: left;
padding: 2px 1rem;
border-left: 1px solid #d5dad5;
}
nav > ul > li:first-of-type {
border-left: none;
}
.breadcrumps {
margin: 0 auto;
max-width: 1140px;
}
.breadcrumps ul {
margin: 2px 0;
padding: .4rem 1rem;
list-style: none;
background: #eee;
}
.breadcrumps ul li {
display: inline;
}
.breadcrumps ul li+li:before {
padding: .4rem;
color: gray;
content: "/\00a0";
}
.breadcrumps ul li a {
color: #333333;
text-decoration: none;
}
main {
margin: 0 auto;
max-width: 1140px;
}
form {
margin: 1rem 0;
padding: 1rem;
border: 1px solid lightgray;
border-radius: 3px;
background: #eee;
text-align: center;
}
form > h2 {
margin: 0;
}
form.samplecode-input > div {
padding: 0.6rem;
display: inline-block;
border: 1px solid lightgray;
border-radius: 3px;
background: white;
}
form.samplecode-input input {
padding: 0;
border: none;
outline: none;
text-align: left;
appearance: textfield;
font-size: 1.2rem;
font-weight: bold;
}
form.samplecode-input input:focus-visible {
background: lightgreen;
}
table {
border-top: 1px solid lightgray;
border-left: 1px solid lightgray;
border-spacing: 0;
border-radius: 3px;
min-width: 100%;
font-family: sans-serif;
}
#samples-table.max {
width: 100vw;
position: fixed;
padding: 1rem;
top: 0;
left: 0;
background: white;
min-height: 100vh;
}
table.samples {
max-width: 100%;
overflow-x: scroll;
display: block;
}
th {
background: #eee;
}
td, th {
padding: .2rem;
border-right: 1px solid lightgray;
border-bottom: 1px solid lightgray;
text-align: left;
white-space: nowrap;
vertical-align: top;
}
td {
font-family: monospace;
}
td.bg-green, th.bg-green {
background: green;
color: white;
}
td.bg-yellow, th.bg-yellow {
background: darkorange;
color: white;
}
td.bg-red, th.bg-red {
background: red;
color: white;
}
td.bg-gray, th.bg-gray {
background: slategray;
color: white;
}
td.bg-shaded, th.bg-shaded {
background: repeating-linear-gradient(140deg, white, #e5e5f5 4px, white 8px);
}
td.clipboard {
cursor: copy;
}
td.clipboard.clipped {
box-shadow: 0 0 1rem lightgreen inset;
}
.btn {
margin: 4px;
padding: 4px 8px;
line-height: 1.2rem;
vertical-align: middle;
border: 0 solid transparent;
border-radius: 3px;
text-decoration: none;
font-size: 0.8rem;
font-weight: normal;
cursor: pointer;
}
.btn:hover {
filter: drop-shadow(1px 2px 2px gray);
}
.btn:active {
filter: drop-shadow(1px 1px 2px gray);
translate: 0 1px;
}
.btn.btn-red {
background: red;
color: white;
}
.btn.btn-red:hover, .btn.btn-red:active {
background: darkred !important;
}
.btn.btn-blue {
background: slategray;
color: white;
}
.btn.btn-blue:hover, .btn.btn-blue:active {
background: darkslategray !important;
}
.btn.btn-delete:before {
content: '\1F5D1';
padding: .2rem;
}
input.inline {
border: none;
font-size: 1.1rem;
outline: none;
}
input.inline:focus-visible {
background: lightgreen;
}
.monospace {
font-family: monospace;
color: #333333;
border-bottom: 1px dotted gray !important;
}
.help {
padding: 1rem;
border: 1px solid darkslategray;
border-radius: 3px;
background: slategray;
color: white;
}
.help.error {
border: 3px dashed red;
background: darkorange;
}
.help .help-header {
font-size: 1.2rem;
font-weight: bold;
}
.chart {
padding: 1rem;
margin: .2rem;
border: 1px solid lightgray;
border-radius: 3px;
}

View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<link rel="stylesheet" th:href="@{/style.css}" />
</head>
<body>
<div th:fragment="nav">
<nav>
<ul>
<li><a th:href="@{/}">Übersicht</a></li>
<li><a th:href="@{/statistics}">Statistiken</a></li>
</ul>
</nav>
</div>
</body>
</html>

View File

@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="de" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>ETL-Prozessor</title>
<link rel="stylesheet" th:href="@{/style.css}" />
</head>
<body>
<div th:replace="~{fragments.html :: nav}"></div>
<main>
<h1>Letzte Anfragen</h1>
<table>
<thead>
<tr>
<th>Status</th>
<th>ID</th>
<th>Datum</th>
<th>Patienten-ID</th>
</tr>
</thead>
<tbody>
<tr th:each="request : ${requests}">
<td th:if="${request.status.value == 'success'}" class="bg-green"><small>[[ ${request.status} ]]</small></td>
<td th:if="${request.status.value == 'warning'}" class="bg-yellow"><small>[[ ${request.status} ]]</small></td>
<td th:if="${request.status.value == 'error'}" class="bg-red"><small>[[ ${request.status} ]]</small></td>
<td th:if="${request.status.value == 'unknown'}" class="bg-gray"><small>[[ ${request.status} ]]</small></td>
<td th:if="${request.status.value == 'duplication'}" class="bg-gray"><small>[[ ${request.status} ]]</small></td>
<td>[[ ${request.uuid} ]]</td>
<td><time th:datetime="${request.processedAt}">[[ ${request.processedAt} ]]</time></td>
<td>[[ ${request.patientId} ]]</td>
</tr>
</tbody>
</table>
</main>
<script th:src="@{/scripts.js}"></script>
</body>
</html>

View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="de" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>ETL-Prozessor</title>
<link rel="stylesheet" th:href="@{/style.css}" />
</head>
<body>
<div th:replace="~{fragments.html :: nav}"></div>
<main>
<h1>Statistiken</h1>
<div id="piechart" class="chart" style="width: 320px; height: 320px; display: inline-block"></div>
<div id="barchart" class="chart" style="width: 720px; height: 320px; display: inline-block"></div>
</main>
<script th:src="@{/echarts.min.js}"></script>
<script th:src="@{/scripts.js}"></script>
<script>
drawPieChart('statistics/requeststates', 'piechart', 'Statusverteilung der Anfragen');
drawBarChart('statistics/requestslastmonth', 'barchart', 'Anfragen des letzten Monats');
</script>
</body>
</html>