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:
parent
46928964ef
commit
cd46fa7e09
42
src/main/kotlin/dev/dnpm/etl/processor/web/HomeController.kt
Normal file
42
src/main/kotlin/dev/dnpm/etl/processor/web/HomeController.kt
Normal 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"
|
||||
}
|
||||
|
||||
}
|
@ -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"
|
||||
}
|
||||
|
||||
}
|
@ -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)
|
45
src/main/resources/static/echarts.min.js
vendored
Normal file
45
src/main/resources/static/echarts.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
113
src/main/resources/static/scripts.js
Normal file
113
src/main/resources/static/scripts.js
Normal 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);
|
||||
});
|
||||
}
|
284
src/main/resources/static/style.css
Normal file
284
src/main/resources/static/style.css
Normal 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;
|
||||
}
|
17
src/main/resources/templates/fragments.html
Normal file
17
src/main/resources/templates/fragments.html
Normal 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>
|
40
src/main/resources/templates/index.html
Normal file
40
src/main/resources/templates/index.html
Normal 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>
|
24
src/main/resources/templates/statistics.html
Normal file
24
src/main/resources/templates/statistics.html
Normal 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>
|
Loading…
x
Reference in New Issue
Block a user