feat: ui improvements on the home page

This commit is contained in:
Tine 2024-03-04 14:20:01 +01:00
parent d2c22b5403
commit 65e2d8fd73
Signed by: mentos1386
SSH key fingerprint: SHA256:MNtTsLbihYaWF8j1fkOHfkKNlnN1JQfxEU/rBU8nCGw
9 changed files with 317 additions and 137 deletions

View file

@ -13,7 +13,7 @@ import (
type IndexData struct {
*components.Base
Monitors map[string][]*Monitor
Monitors map[string]MonitorsAndStatus
MonitorsLength int
TimeRange string
Status models.MonitorStatus
@ -26,12 +26,22 @@ type Monitor struct {
History *History
}
type HistoryItem struct {
Status models.MonitorStatus
Date time.Time
}
type History struct {
List []models.MonitorStatus
List []HistoryItem
Uptime int
}
func getHour(date time.Time) string {
type MonitorsAndStatus struct {
Status models.MonitorStatus
Monitors []*Monitor
}
func getDateString(date time.Time) string {
return date.UTC().Format("2006-01-02T15:04:05")
}
@ -41,15 +51,15 @@ func getHistory(history []*models.MonitorHistory, period time.Duration, buckets
numTotal := 0
for i := 0; i < buckets; i++ {
datetime := getHour(time.Now().Add(period * time.Duration(-i)).Truncate(period))
historyMap[datetime] = models.MonitorUnknown
dateString := getDateString(time.Now().Add(period * time.Duration(-i)).Truncate(period))
historyMap[dateString] = models.MonitorUnknown
}
for _, _history := range history {
hour := getHour(_history.CreatedAt.Time.Truncate(period))
dateString := getDateString(_history.CreatedAt.Time.Truncate(period))
// Skip if not part of the "buckets"
if _, ok := historyMap[hour]; !ok {
if _, ok := historyMap[dateString]; !ok {
continue
}
@ -59,17 +69,23 @@ func getHistory(history []*models.MonitorHistory, period time.Duration, buckets
}
// skip if it is already set to failure
if historyMap[hour] == models.MonitorFailure {
if historyMap[dateString] == models.MonitorFailure {
continue
}
historyMap[hour] = _history.Status
// FIXME: This is wrong! As we can have multiple checks in dateString.
// We should look at only the newest one.
historyMap[dateString] = _history.Status
}
historyHourly := make([]models.MonitorStatus, buckets)
historyItems := make([]HistoryItem, buckets)
for i := 0; i < buckets; i++ {
datetime := getHour(time.Now().Add(period * time.Duration(-buckets+i+1)).Truncate(period))
historyHourly[i] = historyMap[datetime]
date := time.Now().Add(period * time.Duration(-buckets+i+1)).Truncate(period)
datestring := getDateString(date)
historyItems[i] = HistoryItem{
Status: historyMap[datestring],
Date: date,
}
}
uptime := 0
@ -78,7 +94,7 @@ func getHistory(history []*models.MonitorHistory, period time.Duration, buckets
}
return &History{
List: historyHourly,
List: historyItems,
Uptime: uptime,
}
}
@ -95,7 +111,8 @@ func (h *BaseHandler) Index(c echo.Context) error {
timeRange = "90days"
}
overallStatus := models.MonitorSuccess
overallStatus := models.MonitorUnknown
statusByGroup := make(map[string]models.MonitorStatus)
monitorsWithHistory := make([]*Monitor, len(monitors))
for i, monitor := range monitors {
@ -114,22 +131,38 @@ func (h *BaseHandler) Index(c echo.Context) error {
historyResult = getHistory(history, time.Minute, 90)
}
if statusByGroup[monitor.Group] == "" {
statusByGroup[monitor.Group] = models.MonitorUnknown
}
status := historyResult.List[len(historyResult.List)-1]
if status != models.MonitorSuccess {
overallStatus = status
if status.Status == models.MonitorSuccess {
if overallStatus == models.MonitorUnknown {
overallStatus = status.Status
}
if statusByGroup[monitor.Group] == models.MonitorUnknown {
statusByGroup[monitor.Group] = status.Status
}
}
if status.Status != models.MonitorSuccess && status.Status != models.MonitorUnknown {
overallStatus = status.Status
statusByGroup[monitor.Group] = status.Status
}
monitorsWithHistory[i] = &Monitor{
Name: monitor.Name,
Group: monitor.Group,
Status: status,
Status: status.Status,
History: historyResult,
}
}
monitorsByGroup := map[string][]*Monitor{}
monitorsByGroup := map[string]MonitorsAndStatus{}
for _, monitor := range monitorsWithHistory {
monitorsByGroup[monitor.Group] = append(monitorsByGroup[monitor.Group], monitor)
monitorsByGroup[monitor.Group] = MonitorsAndStatus{
Status: statusByGroup[monitor.Group],
Monitors: append(monitorsByGroup[monitor.Group].Monitors, monitor),
}
}
return c.Render(http.StatusOK, "index.tmpl", &IndexData{

View file

@ -16,6 +16,21 @@ import (
"github.com/labstack/echo/v4"
)
const example = `
import http from 'k6/http';
export const options = {
thresholds: {
// http errors should be less than 1%
http_req_failed: ['rate<0.01'],
},
};
export default function () {
http.get('https://example.com');
}
`
type CreateMonitor struct {
Name string `validate:"required"`
Group string `validate:"required"`
@ -48,6 +63,11 @@ type SettingsMonitor struct {
History []*models.MonitorHistory
}
type SettingsMonitorCreate struct {
*Settings
Example string
}
func (h *BaseHandler) SettingsMonitorsGET(c echo.Context) error {
cc := c.(AuthenticatedContext)
@ -188,8 +208,8 @@ func (h *BaseHandler) SettingsMonitorsDescribePOST(c echo.Context) error {
monitorId := c.Param("id")
update := UpdateMonitor{
Group: c.FormValue("group"),
WorkerGroups: strings.TrimSpace(c.FormValue("workergroups")),
Group: strings.ToLower(c.FormValue("group")),
WorkerGroups: strings.ToLower(strings.TrimSpace(c.FormValue("workergroups"))),
Schedule: c.FormValue("schedule"),
Script: c.FormValue("script"),
}
@ -251,14 +271,17 @@ func (h *BaseHandler) SettingsMonitorsDescribePOST(c echo.Context) error {
func (h *BaseHandler) SettingsMonitorsCreateGET(c echo.Context) error {
cc := c.(AuthenticatedContext)
return c.Render(http.StatusOK, "settings_monitors_create.tmpl", NewSettings(
return c.Render(http.StatusOK, "settings_monitors_create.tmpl", &SettingsMonitorCreate{
Settings: NewSettings(
cc.Principal.User,
GetPageByTitle(SettingsPages, "Monitors"),
[]*components.Page{
GetPageByTitle(SettingsPages, "Monitors"),
GetPageByTitle(SettingsPages, "Monitors Create"),
},
))
),
Example: example,
})
}
func (h *BaseHandler) SettingsMonitorsCreatePOST(c echo.Context) error {
@ -267,9 +290,9 @@ func (h *BaseHandler) SettingsMonitorsCreatePOST(c echo.Context) error {
create := CreateMonitor{
Name: c.FormValue("name"),
Group: c.FormValue("group"),
Group: strings.ToLower(c.FormValue("group")),
WorkerGroups: strings.ToLower(strings.TrimSpace(c.FormValue("workergroups"))),
Schedule: c.FormValue("schedule"),
WorkerGroups: c.FormValue("workergroups"),
Script: c.FormValue("script"),
}
err := validator.New(validator.WithRequiredStructEnabled()).Struct(create)

View file

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/http"
"strings"
"code.tjo.space/mentos1386/zdravko/database/models"
"code.tjo.space/mentos1386/zdravko/internal/jwt"
@ -134,7 +135,7 @@ func (h *BaseHandler) SettingsWorkerGroupsCreatePOST(c echo.Context) error {
id := slug.Make(c.FormValue("name"))
workerGroup := &models.WorkerGroup{
Name: c.FormValue("name"),
Name: strings.ToLower(c.FormValue("name")),
Id: id,
}

View file

@ -56,9 +56,6 @@ code {
@apply bg-blue-700 text-white;
}
.monitors .monitors-list {
@apply grid justify-items-stretch justify-stretch items-center bg-white shadow-md rounded-lg;
}
.monitors .time-range > a {
@apply font-medium text-sm px-2.5 py-1 rounded-lg;
@apply text-black bg-gray-100 hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-400;

View file

@ -590,6 +590,14 @@ video {
}
}
.absolute {
position: absolute;
}
.z-50 {
z-index: 50;
}
.col-span-1 {
grid-column: span 1 / span 1;
}
@ -598,6 +606,11 @@ video {
grid-column: span 2 / span 2;
}
.-mx-3 {
margin-left: -0.75rem;
margin-right: -0.75rem;
}
.mx-1 {
margin-left: 0.25rem;
margin-right: 0.25rem;
@ -613,6 +626,10 @@ video {
margin-right: auto;
}
.-ml-4 {
margin-left: -1rem;
}
.mb-2 {
margin-bottom: 0.5rem;
}
@ -641,6 +658,14 @@ video {
margin-top: 0.25rem;
}
.mt-10 {
margin-top: 2.5rem;
}
.mt-2 {
margin-top: 0.5rem;
}
.mt-20 {
margin-top: 5rem;
}
@ -768,6 +793,10 @@ video {
flex: 1 1 auto;
}
.cursor-pointer {
cursor: pointer;
}
.grid-flow-col {
grid-auto-flow: column;
}
@ -838,10 +867,6 @@ video {
justify-self: end;
}
.justify-self-center {
justify-self: center;
}
.overflow-hidden {
overflow: hidden;
}
@ -882,6 +907,11 @@ video {
border-bottom-width: 1px;
}
.border-gray-100 {
--tw-border-opacity: 1;
border-color: rgb(243 244 246 / var(--tw-border-opacity));
}
.border-gray-200 {
--tw-border-opacity: 1;
border-color: rgb(229 231 235 / var(--tw-border-opacity));
@ -975,6 +1005,10 @@ video {
padding: 0.625rem;
}
.p-3 {
padding: 0.75rem;
}
.p-4 {
padding: 1rem;
}
@ -983,6 +1017,10 @@ video {
padding: 1.25rem;
}
.p-6 {
padding: 1.5rem;
}
.px-2 {
padding-left: 0.5rem;
padding-right: 0.5rem;
@ -1023,6 +1061,10 @@ video {
padding-bottom: 2rem;
}
.pb-2 {
padding-bottom: 0.5rem;
}
.pt-8 {
padding-top: 2rem;
}
@ -1051,11 +1093,6 @@ video {
line-height: 1.25rem;
}
.text-xl {
font-size: 1.25rem;
line-height: 1.75rem;
}
.text-xs {
font-size: 0.75rem;
line-height: 1rem;
@ -1085,6 +1122,10 @@ video {
text-transform: uppercase;
}
.capitalize {
text-transform: capitalize;
}
.leading-5 {
line-height: 1.25rem;
}
@ -1185,11 +1226,33 @@ video {
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.shadow-lg {
--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.shadow-md {
--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.blur {
--tw-blur: blur(8px);
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
}
.transition-all {
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.duration-300 {
transition-duration: 300ms;
}
.link,
p > a {
font-weight: 500;
@ -1332,19 +1395,6 @@ code {
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.monitors .monitors-list {
display: grid;
align-items: center;
justify-content: stretch;
justify-items: stretch;
border-radius: 0.5rem;
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.monitors .time-range > a {
border-radius: 0.5rem;
padding-left: 0.625rem;
@ -1626,6 +1676,19 @@ code {
letter-spacing: 0.05em;
}
.last-of-type\:border-0:last-of-type {
border-width: 0px;
}
.last-of-type\:pb-0:last-of-type {
padding-bottom: 0px;
}
.hover\:bg-blue-50:hover {
--tw-bg-opacity: 1;
background-color: rgb(239 246 255 / var(--tw-bg-opacity));
}
.hover\:bg-blue-800:hover {
--tw-bg-opacity: 1;
background-color: rgb(30 64 175 / var(--tw-bg-opacity));
@ -1723,15 +1786,6 @@ code {
margin-bottom: calc(0px * var(--tw-space-y-reverse));
}
.sm\:justify-self-end {
justify-self: end;
}
.sm\:px-0 {
padding-left: 0px;
padding-right: 0px;
}
.sm\:px-8 {
padding-left: 2rem;
padding-right: 2rem;
@ -1751,6 +1805,10 @@ code {
flex-direction: row;
}
.md\:justify-end {
justify-content: flex-end;
}
.md\:px-40 {
padding-left: 10rem;
padding-right: 10rem;
@ -1799,3 +1857,16 @@ code {
line-height: 2.5rem;
}
}
.\[\&_\.tooltip\]\:hover\:visible:hover .tooltip {
visibility: visible;
}
.\[\&_\.tooltip\]\:hover\:flex:hover .tooltip {
display: flex;
}
.\[\&_svg\]\:open\:-rotate-180[open] svg {
--tw-rotate: -180deg;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}

View file

@ -51,7 +51,7 @@
>
<use href="/static/icons/feather-sprite.svg#alert-triangle" />
</svg>
<h3 class="text-slate-500 mt-4">Degraded performance</h3>
<h1 class="text-slate-500 mt-4">Degraded performance</h1>
<p class="text-slate-500 text-sm">
Last updated on
{{ Now.UTC.Format "Jan 02 at 15:04 MST" }}
@ -59,14 +59,8 @@
</div>
{{ end }}
<div class="monitors flex flex-col gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 px-4 sm:px-0">
<h2
class="text-xl font-normal text-gray-800 text-center sm:text-left"
>
Monitors
</h2>
<div
class="inline-flex gap-1 justify-self-center sm:justify-self-end time-range"
class="inline-flex gap-1 justify-center md:justify-end time-range"
role="group"
>
<a
@ -88,20 +82,39 @@
>90 Minutes</a
>
</div>
</div>
{{ range $group, $monitors := .Monitors }}
<div class="monitors-list gap-2">
<h3 class="flex flex-row gap-2 p-5 py-4 border-b border-gray-200">
{{ range $group, $monitorsAndStatus := .Monitors }}
<details
class="bg-white shadow-md rounded-lg p-6 py-4 gap-2 [&_svg]:open:-rotate-180"
>
<summary
class="flex flex-row gap-2 p-3 py-2 -mx-3 cursor-pointer hover:bg-blue-50 rounded-lg"
>
{{ if eq $monitorsAndStatus.Status "SUCCESS" }}
<span
class="flex w-3 h-3 bg-green-400 rounded-full self-center"
></span>
<span class="flex-1 font-semibold uppercase ">{{ $group }}</span>
<svg class="feather h-6 w-6 overflow-visible self-center">
{{ else if eq $monitorsAndStatus.Status "FAILURE" }}
<span
class="flex w-3 h-3 bg-red-400 rounded-full self-center"
></span>
{{ else }}
<span
class="flex w-3 h-3 bg-gray-200 rounded-full self-center"
></span>
{{ end }}
<h2 class="flex-1 font-semibold capitalize">
{{ $group }}
</h2>
<svg
class="feather h-6 w-6 overflow-visible self-center transition-all duration-300"
>
<use href="/static/icons/feather-sprite.svg#chevron-down" />
</svg>
</h3>
{{ range $monitors }}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 p-5 py-2">
</summary>
{{ range $monitorsAndStatus.Monitors }}
<div
class="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2 pb-2 border-b last-of-type:pb-0 last-of-type:border-0 border-gray-100"
>
<div class="flex items-center gap-2">
{{ if eq .Status "SUCCESS" }}
<span class="flex w-3 h-3 bg-green-400 rounded-full"></span>
@ -119,17 +132,47 @@
class="grid gap-px col-span-2 grid-flow-col h-8 rounded overflow-hidden"
>
{{ range .History.List }}
{{ if eq . "SUCCESS" }}
<div
class="has-tooltip [&_.tooltip]:hover:flex [&_.tooltip]:hover:visible flex"
>
{{ if eq .Status "SUCCESS" }}
<div
class="bg-green-400 hover:bg-green-500 flex-auto"
></div>
{{ else if eq . "FAILURE" }}
<div class="bg-red-400 hover:bg-red-500 flex-auto"></div>
{{ else if eq .Status "FAILURE" }}
<div
class="bg-red-400 hover:bg-red-500 flex-auto"
></div>
{{ else }}
<div
class="bg-gray-200 hover:bg-gray-300 flex-auto"
></div>
{{ end }}
<div
class="tooltip gap-2 bg-white border border-gray-200 rounded p-2 shadow-lg hidden z-50 absolute mt-10 -ml-4 flex-row text-xs"
>
{{ if eq .Status "SUCCESS" }}
<span
class="flex w-3 h-3 bg-green-400 rounded-full self-center"
></span>
{{ else if eq .Status "FAILURE" }}
<span
class="flex w-3 h-3 bg-red-400 rounded-full self-center"
></span>
{{ else }}
<span
class="flex w-3 h-3 bg-gray-200 rounded-full self-center"
></span>
{{ end }}
{{ if eq $.TimeRange "90days" }}
{{ .Date.Format "Jan 02" }}
{{ else if eq $.TimeRange "48hours" }}
{{ .Date.Format "Jan 02, 15:00 MST" }}
{{ else if eq $.TimeRange "90minutes" }}
{{ .Date.Format "Jan 02, 15:04 MST" }}
{{ end }}
</div>
</div>
{{ end }}
</div>
<div
@ -150,7 +193,7 @@
</div>
</div>
{{ end }}
</div>
</details>
{{ end }}
</div>
{{ end }}

View file

@ -50,7 +50,7 @@
</caption>
<thead class="text-xs text-gray-700 uppercase bg-gray-50">
<tr>
<th scope="col">Group</th>
<th scope="col">Monitor Group</th>
<th scope="col">Name</th>
<th scope="col">Worker Groups</th>
<th scope="col">Status</th>

View file

@ -4,14 +4,18 @@
<label for="name">Name</label>
<input type="text" name="name" id="name" placeholder="Github.com" />
<p>Name of the monitor can be anything.</p>
<label for="group">Group</label>
<label list="existing-groups" for="group">Monitor Group</label>
<input
type="text"
name="group"
id="group"
placeholder="default"
value="default"
required
/>
<datalist id="existing-groups">
<option value="default"></option>
</datalist>
<p>
Group monitors together. This affects how they are presented on the
homepage.
@ -22,6 +26,7 @@
name="workergroups"
id="workergroups"
placeholder="NA EU"
required
/>
<p>
Worker groups are used to distribute the monitor to specific workers.
@ -33,6 +38,7 @@
id="schedule"
placeholder="@every 1m"
value="@every 1m"
required
/>
<p>
Schedule is a cron expression that defines when the monitor should be
@ -44,10 +50,12 @@
<code>@yearly</code>.
</p>
<label for="script">Script</label>
<input required type="hidden" id="script" name="script" />
<textarea required id="script" name="script" class="h-96">
{{ .Example }}</textarea
>
<div
id="editor"
class="block w-full h-96 rounded-lg border border-gray-300 overflow-hidden"
class="hidden block w-full h-96 rounded-lg border border-gray-300 overflow-hidden"
></div>
<p>
Script is what determines the status of a service. You can read more
@ -62,25 +70,16 @@
<script src="/static/monaco/vs/loader.js"></script>
<script>
script = `{{ .Example }}`;
document.getElementById("editor").classList.remove("hidden");
document.getElementById("script").hidden = true;
function save() {
const script = window.editor.getValue();
document.getElementById("script").value = script;
}
script = `import http from 'k6/http';
export const options = {
thresholds: {
// http errors should be less than 1%
http_req_failed: ['rate<0.01'],
},
};
export default function () {
http.get('https://example.com');
}
`;
require.config({ paths: { vs: "/static/monaco/vs" } });
require(["vs/editor/editor.main"], function () {
window.editor = monaco.editor.create(document.getElementById("editor"), {

View file

@ -2,8 +2,14 @@
<section class="p-5">
<form action="/settings/monitors/{{ .Monitor.Id }}" method="post">
<h2>Configuration</h2>
<label for="group">Group</label>
<input type="text" name="group" id="group" value="{{ .Monitor.Group }}" />
<label for="group">Monitor Group</label>
<input
type="text"
name="group"
id="group"
value="{{ .Monitor.Group }}"
required
/>
<p>
Group monitors together. This affects how they are presented on the
homepage.
@ -14,6 +20,7 @@
name="workergroups"
id="workergroups"
value="{{ range .Monitor.WorkerGroups }}{{ . }}{{ end }}"
required
/>
<p>
Worker groups are used to distribute the monitor to specific workers.
@ -24,6 +31,7 @@
name="schedule"
id="schedule"
value="{{ .Monitor.Schedule }}"
required
/>
<p>
Schedule is a cron expression that defines when the monitor should be
@ -35,10 +43,12 @@
<code>@yearly</code>.
</p>
<label for="script">Script</label>
<input required type="hidden" id="script" name="script" />
<textarea required id="script" name="script" class="h-96">
{{ .Monitor.Script }}</textarea
>
<div
id="editor"
class="block w-full h-96 rounded-lg border border-gray-300 overflow-hidden"
class="block w-full h-96 rounded-lg border border-gray-300 overflow-hidden hidden"
></div>
<p>
Script is what determines the status of a service. You can read more
@ -150,6 +160,9 @@
<script src="/static/monaco/vs/loader.js"></script>
<script>
document.getElementById("editor").classList.remove("hidden");
document.getElementById("script").hidden = true;
function save() {
const script = window.editor.getValue();
document.getElementById('script').value = script;