-
-
Save shricodev/423f45e00bf9e9c65e202992c8cf2a6f to your computer and use it in GitHub Desktop.
Uptime Kuma - Kombai
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| From fad044b605c0aa5e6abc0a00b1a6d10922e48773 Mon Sep 17 00:00:00 2001 | |
| From: Shrijal Acharya <shrijal.acharya@gmail.com> | |
| Date: Fri, 20 Mar 2026 19:52:26 +0545 | |
| Subject: [PATCH] kombai edits | |
| --- | |
| server/routers/status-page-router.js | 72 +++ | |
| src/components/PublicGroupList.vue | 8 + | |
| src/components/UptimeHeatmap.vue | 719 +++++++++++++++++++++++++++ | |
| src/lang/en.json | 11 +- | |
| 4 files changed, 809 insertions(+), 1 deletion(-) | |
| create mode 100644 src/components/UptimeHeatmap.vue | |
| diff --git a/server/routers/status-page-router.js b/server/routers/status-page-router.js | |
| index fda29626..3bce6eb0 100644 | |
| --- a/server/routers/status-page-router.js | |
| +++ b/server/routers/status-page-router.js | |
| @@ -8,6 +8,8 @@ const { badgeConstants } = require("../../src/util"); | |
| const { makeBadge } = require("badge-maker"); | |
| const { UptimeCalculator } = require("../uptime-calculator"); | |
| +const Database = require("../database"); | |
| + | |
| let router = express.Router(); | |
| let cache = apicache.middleware; | |
| @@ -261,4 +263,74 @@ router.get("/api/status-page/:slug/badge", cache("5 minutes"), async (request, r | |
| } | |
| }); | |
| +// Heatmap data: daily aggregated heartbeat stats for a public monitor | |
| +router.get("/api/status-page/heatmap/:slug", cache("5 minutes"), async (request, response) => { | |
| + allowDevAllOrigin(response); | |
| + | |
| + try { | |
| + const slug = request.params.slug.toLowerCase(); | |
| + const monitorId = parseInt(request.query.monitorId, 10); | |
| + const requestedDays = parseInt(request.query.days, 10) || 30; | |
| + | |
| + if (!monitorId || isNaN(monitorId)) { | |
| + sendHttpError(response, "Invalid monitor ID"); | |
| + return; | |
| + } | |
| + | |
| + const validDays = [ 30, 90, 365 ].includes(requestedDays) ? requestedDays : 30; | |
| + | |
| + const statusPageID = await StatusPage.slugToID(slug); | |
| + | |
| + if (!statusPageID) { | |
| + sendHttpError(response, "Status Page Not Found"); | |
| + return; | |
| + } | |
| + | |
| + // Ensure the monitor is public on this status page | |
| + const monitorIDList = await R.getCol( | |
| + ` | |
| + SELECT monitor_group.monitor_id FROM monitor_group, \`group\` | |
| + WHERE monitor_group.group_id = \`group\`.id | |
| + AND public = 1 | |
| + AND \`group\`.status_page_id = ? | |
| + `, | |
| + [ statusPageID ] | |
| + ); | |
| + | |
| + if (!monitorIDList.map(Number).includes(monitorId)) { | |
| + sendHttpError(response, "Monitor Not Found"); | |
| + return; | |
| + } | |
| + | |
| + const hours = validDays * 24; | |
| + const sqlHourOffset = Database.sqlHourOffset(); | |
| + | |
| + const rows = await R.getAll( | |
| + ` | |
| + SELECT | |
| + DATE(time) AS date, | |
| + COUNT(*) AS total_checks, | |
| + SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) AS up_checks, | |
| + SUM(CASE WHEN status = 0 THEN 1 ELSE 0 END) AS down_checks, | |
| + AVG(CASE WHEN ping IS NOT NULL AND ping > 0 THEN ping ELSE NULL END) AS avg_ping | |
| + FROM heartbeat | |
| + WHERE monitor_id = ? | |
| + AND time > ${sqlHourOffset} | |
| + GROUP BY DATE(time) | |
| + ORDER BY date ASC | |
| + `, | |
| + [ monitorId, -hours ] | |
| + ); | |
| + | |
| + response.json({ | |
| + ok: true, | |
| + days: validDays, | |
| + data: rows, | |
| + }); | |
| + } catch (error) { | |
| + sendHttpError(response, error.message); | |
| + } | |
| +}); | |
| + | |
| module.exports = router; | |
| + | |
| diff --git a/src/components/PublicGroupList.vue b/src/components/PublicGroupList.vue | |
| index 7512190e..57374cae 100644 | |
| --- a/src/components/PublicGroupList.vue | |
| +++ b/src/components/PublicGroupList.vue | |
| @@ -63,6 +63,7 @@ | |
| <div class="row"> | |
| <div class="col-9 col-xl-6 small-padding"> | |
| <div class="info"> | |
| + | |
| <font-awesome-icon | |
| v-if="editMode" | |
| icon="arrows-alt-v" | |
| @@ -132,6 +133,11 @@ | |
| <HeartbeatBar size="mid" :monitor-id="monitor.element.id" /> | |
| </div> | |
| </div> | |
| + <!-- Uptime heatmap (hidden in edit mode to keep the UI clean) --> | |
| + <UptimeHeatmap | |
| + v-if="!editMode" | |
| + :monitor-id="monitor.element.id" | |
| + /> | |
| </div> | |
| </template> | |
| </Draggable> | |
| @@ -151,6 +157,7 @@ import Uptime from "./Uptime.vue"; | |
| import Tag from "./Tag.vue"; | |
| import Status from "./Status.vue"; | |
| import GroupSortDropdown from "./GroupSortDropdown.vue"; | |
| +import UptimeHeatmap from "./UptimeHeatmap.vue"; | |
| export default { | |
| components: { | |
| @@ -161,6 +168,7 @@ export default { | |
| Tag, | |
| Status, | |
| GroupSortDropdown, | |
| + UptimeHeatmap, | |
| }, | |
| props: { | |
| /** Are we in edit mode? */ | |
| diff --git a/src/components/UptimeHeatmap.vue b/src/components/UptimeHeatmap.vue | |
| new file mode 100644 | |
| index 00000000..ddf7ae2d | |
| --- /dev/null | |
| +++ b/src/components/UptimeHeatmap.vue | |
| @@ -0,0 +1,719 @@ | |
| +<template> | |
| + <div class="uptime-heatmap"> | |
| + <!-- Controls row --> | |
| + <div class="heatmap-controls"> | |
| + <span class="heatmap-history-label">{{ $t("heatmapHistory") }}</span> | |
| + <div class="heatmap-range-group" role="group" :aria-label="$t('heatmapHistory')"> | |
| + <button | |
| + v-for="range in ranges" | |
| + :key="range.value" | |
| + type="button" | |
| + class="heatmap-range-btn" | |
| + :class="{ active: selectedRange === range.value }" | |
| + :aria-pressed="selectedRange === range.value" | |
| + @click="setRange(range.value)" | |
| + > | |
| + {{ range.label }} | |
| + </button> | |
| + </div> | |
| + </div> | |
| + | |
| + <!-- Loading --> | |
| + <div v-if="loading" class="heatmap-loading" aria-live="polite"> | |
| + <div class="spinner-border spinner-border-sm text-secondary" role="status"> | |
| + <span class="visually-hidden">{{ $t("Loading...") }}</span> | |
| + </div> | |
| + </div> | |
| + | |
| + <!-- Heatmap grid --> | |
| + <div v-else class="heatmap-scroll-wrapper" @mouseleave="hideTooltip"> | |
| + <div | |
| + class="heatmap-grid" | |
| + :style="gridStyle" | |
| + role="img" | |
| + :aria-label="$t('heatmapAriaLabel', [selectedRange])" | |
| + > | |
| + <div | |
| + v-for="(day, index) in flatDays" | |
| + :key="index" | |
| + class="heatmap-cell" | |
| + :class="getCellClass(day)" | |
| + :aria-label="getCellAriaLabel(day)" | |
| + :role="day.inRange && day.date ? 'button' : undefined" | |
| + :tabindex="day.inRange && day.date ? 0 : undefined" | |
| + @mouseenter="onCellEnter(day, $event)" | |
| + @touchstart.passive="onCellTap(day, $event)" | |
| + @focus="onCellEnter(day, $event)" | |
| + @blur="hideTooltip" | |
| + @keydown.enter="onCellEnter(day, $event)" | |
| + @keydown.escape="hideTooltip" | |
| + /> | |
| + </div> | |
| + </div> | |
| + | |
| + <!-- Legend --> | |
| + <div class="heatmap-legend" aria-hidden="true"> | |
| + <span class="heatmap-legend-text">{{ $t("heatmapLegendLess") }}</span> | |
| + <div class="heatmap-cell heatmap-legend-cell status-no-data"></div> | |
| + <div class="heatmap-cell heatmap-legend-cell status-outage"></div> | |
| + <div class="heatmap-cell heatmap-legend-cell status-degraded"></div> | |
| + <div class="heatmap-cell heatmap-legend-cell status-healthy"></div> | |
| + <span class="heatmap-legend-text">{{ $t("heatmapLegendMore") }}</span> | |
| + </div> | |
| + | |
| + <!-- Tooltip (teleported to body to avoid clipping) --> | |
| + <teleport to="body"> | |
| + <div | |
| + v-if="tooltip.visible && tooltip.day" | |
| + class="heatmap-tooltip" | |
| + :class="{ 'heatmap-tooltip-above': tooltip.above }" | |
| + :style="tooltipPositionStyle" | |
| + role="tooltip" | |
| + aria-live="polite" | |
| + > | |
| + <div class="ht-date">{{ tooltip.day.date }}</div> | |
| + <div class="ht-status" :class="'status-' + getStatus(tooltip.day)"> | |
| + {{ getStatusLabel(tooltip.day) }} | |
| + </div> | |
| + <div class="ht-row"> | |
| + <span>{{ $t("Uptime") }}</span> | |
| + <span class="ht-value">{{ formatUptime(tooltip.day) }}</span> | |
| + </div> | |
| + <div v-if="getDowntimeMinutes(tooltip.day) > 0" class="ht-row"> | |
| + <span>{{ $t("heatmapDowntime") }}</span> | |
| + <span class="ht-value">{{ formatDuration(getDowntimeMinutes(tooltip.day)) }}</span> | |
| + </div> | |
| + <div v-if="tooltip.day.avg_ping != null && tooltip.day.avg_ping > 0" class="ht-row"> | |
| + <span>{{ $t("Avg. Response") }}</span> | |
| + <span class="ht-value">{{ Math.round(tooltip.day.avg_ping) }} ms</span> | |
| + </div> | |
| + </div> | |
| + </teleport> | |
| + </div> | |
| +</template> | |
| + | |
| +<script> | |
| +import axios from "axios"; | |
| +import dayjs from "dayjs"; | |
| +import isSameOrBefore from "dayjs/plugin/isSameOrBefore"; | |
| +import isSameOrAfter from "dayjs/plugin/isSameOrAfter"; | |
| + | |
| +dayjs.extend(isSameOrBefore); | |
| +dayjs.extend(isSameOrAfter); | |
| + | |
| +const CELL_SIZE = 11; // px | |
| +const CELL_GAP = 2; // px | |
| + | |
| +export default { | |
| + name: "UptimeHeatmap", | |
| + | |
| + props: { | |
| + /** Monitor ID to show heatmap for */ | |
| + monitorId: { | |
| + type: Number, | |
| + required: true, | |
| + }, | |
| + }, | |
| + | |
| + data() { | |
| + return { | |
| + selectedRange: 90, | |
| + loading: false, | |
| + heatmapData: [], | |
| + tooltip: { | |
| + visible: false, | |
| + day: null, | |
| + x: 0, | |
| + y: 0, | |
| + above: false, | |
| + }, | |
| + ranges: [ | |
| + { value: 30, label: "30d" }, | |
| + { value: 90, label: "90d" }, | |
| + { value: 365, label: "365d" }, | |
| + ], | |
| + }; | |
| + }, | |
| + | |
| + computed: { | |
| + /** | |
| + * Resolve the status page slug from the current route. | |
| + * Falls back to "default" when no slug is present in the URL. | |
| + * @returns {string} slug | |
| + */ | |
| + statusPageSlug() { | |
| + return this.$route.params.slug || "default"; | |
| + }, | |
| + | |
| + /** | |
| + * CSS grid style that lays out cells column-by-column, 7 rows per column. | |
| + * @returns {object} style object | |
| + */ | |
| + gridStyle() { | |
| + return { | |
| + gridTemplateRows: `repeat(7, ${CELL_SIZE}px)`, | |
| + gridAutoColumns: `${CELL_SIZE}px`, | |
| + gap: `${CELL_GAP}px`, | |
| + }; | |
| + }, | |
| + | |
| + /** | |
| + * Build the flat array of day objects used to populate the grid. | |
| + * Days flow in columns (Mon→Sun per column, oldest column on the left). | |
| + * @returns {object[]} flat array of day objects | |
| + */ | |
| + flatDays() { | |
| + const dataMap = {}; | |
| + for (const d of this.heatmapData) { | |
| + dataMap[d.date] = d; | |
| + } | |
| + | |
| + const today = dayjs(); | |
| + const startDate = today.subtract(this.selectedRange - 1, "day"); | |
| + | |
| + // Align grid start to Monday of the week containing startDate | |
| + const dow = startDate.day(); // 0=Sun,1=Mon...6=Sat | |
| + const daysBackToMonday = dow === 0 ? 6 : dow - 1; | |
| + const gridStart = startDate.subtract(daysBackToMonday, "day"); | |
| + | |
| + const days = []; | |
| + let cursor = gridStart; | |
| + | |
| + while (cursor.isSameOrBefore(today, "day")) { | |
| + const dateStr = cursor.format("YYYY-MM-DD"); | |
| + const inRange = cursor.isSameOrAfter(startDate, "day"); | |
| + | |
| + if (inRange) { | |
| + days.push({ | |
| + date: dateStr, | |
| + inRange: true, | |
| + ...(dataMap[dateStr] || {}), | |
| + }); | |
| + } else { | |
| + // Padding cell before the range starts | |
| + days.push({ date: null, inRange: false }); | |
| + } | |
| + | |
| + cursor = cursor.add(1, "day"); | |
| + } | |
| + | |
| + return days; | |
| + }, | |
| + | |
| + /** | |
| + * CSS style for the tooltip, positioned as a fixed overlay. | |
| + * @returns {object} style object | |
| + */ | |
| + tooltipPositionStyle() { | |
| + return { | |
| + position: "fixed", | |
| + left: this.tooltip.x + "px", | |
| + top: this.tooltip.y + "px", | |
| + zIndex: 9999, | |
| + pointerEvents: "none", | |
| + transform: this.tooltip.above | |
| + ? "translateX(-50%) translateY(-100%)" | |
| + : "translateX(-50%)", | |
| + }; | |
| + }, | |
| + }, | |
| + | |
| + watch: { | |
| + selectedRange() { | |
| + this.fetchData(); | |
| + }, | |
| + monitorId() { | |
| + this.fetchData(); | |
| + }, | |
| + }, | |
| + | |
| + mounted() { | |
| + this.fetchData(); | |
| + }, | |
| + | |
| + methods: { | |
| + /** | |
| + * Switch the displayed time range. | |
| + * @param {number} value days | |
| + * @returns {void} | |
| + */ | |
| + setRange(value) { | |
| + this.selectedRange = value; | |
| + }, | |
| + | |
| + /** | |
| + * Fetch daily aggregated heartbeat data from the server. | |
| + * @returns {Promise<void>} | |
| + */ | |
| + async fetchData() { | |
| + this.loading = true; | |
| + try { | |
| + const res = await axios.get(`/api/status-page/heatmap/${this.statusPageSlug}`, { | |
| + params: { | |
| + monitorId: this.monitorId, | |
| + days: this.selectedRange, | |
| + }, | |
| + }); | |
| + this.heatmapData = res.data.data || []; | |
| + } catch (_e) { | |
| + this.heatmapData = []; | |
| + } finally { | |
| + this.loading = false; | |
| + } | |
| + }, | |
| + | |
| + /** | |
| + * Compute the status string for a day object. | |
| + * @param {object} day day object from flatDays | |
| + * @returns {"healthy"|"degraded"|"outage"|"no-data"|"out-of-range"} status | |
| + */ | |
| + getStatus(day) { | |
| + if (!day || !day.inRange) { | |
| + return "out-of-range"; | |
| + } | |
| + const total = Number(day.total_checks) || 0; | |
| + if (total === 0) { | |
| + return "no-data"; | |
| + } | |
| + const uptime = Number(day.up_checks) / total; | |
| + if (uptime >= 0.99) { | |
| + return "healthy"; | |
| + } | |
| + if (uptime >= 0.5) { | |
| + return "degraded"; | |
| + } | |
| + return "outage"; | |
| + }, | |
| + | |
| + /** | |
| + * Returns translated label for a day's status. | |
| + * @param {object} day day object | |
| + * @returns {string} label | |
| + */ | |
| + getStatusLabel(day) { | |
| + const map = { | |
| + "healthy": this.$t("heatmapStatusHealthy"), | |
| + "degraded": this.$t("heatmapStatusDegraded"), | |
| + "outage": this.$t("heatmapStatusOutage"), | |
| + "no-data": this.$t("heatmapStatusNoData"), | |
| + }; | |
| + return map[this.getStatus(day)] || ""; | |
| + }, | |
| + | |
| + /** | |
| + * Returns the CSS class array for a heatmap cell. | |
| + * @param {object} day day object | |
| + * @returns {string[]} class list | |
| + */ | |
| + getCellClass(day) { | |
| + if (!day.inRange) { | |
| + return [ "heatmap-cell-empty" ]; | |
| + } | |
| + return [ `status-${this.getStatus(day)}` ]; | |
| + }, | |
| + | |
| + /** | |
| + * Returns an accessible aria-label for a cell. | |
| + * @param {object} day day object | |
| + * @returns {string} aria-label | |
| + */ | |
| + getCellAriaLabel(day) { | |
| + if (!day.inRange || !day.date) { | |
| + return ""; | |
| + } | |
| + return `${day.date}: ${this.getStatusLabel(day)} — ${this.formatUptime(day)}`; | |
| + }, | |
| + | |
| + /** | |
| + * Format uptime percentage for a day. | |
| + * @param {object} day day object | |
| + * @returns {string} formatted string | |
| + */ | |
| + formatUptime(day) { | |
| + const total = Number(day.total_checks) || 0; | |
| + if (total === 0) { | |
| + return this.$t("notAvailableShort"); | |
| + } | |
| + const pct = (Number(day.up_checks) / total) * 100; | |
| + return pct.toFixed(2) + "%"; | |
| + }, | |
| + | |
| + /** | |
| + * Estimate downtime in minutes for a day. | |
| + * Uses the proportion of failed checks × 24h. | |
| + * @param {object} day day object | |
| + * @returns {number} downtime in minutes | |
| + */ | |
| + getDowntimeMinutes(day) { | |
| + const total = Number(day.total_checks) || 0; | |
| + if (total === 0) { | |
| + return 0; | |
| + } | |
| + const downFraction = Number(day.down_checks) / total; | |
| + return Math.round(downFraction * 24 * 60); | |
| + }, | |
| + | |
| + /** | |
| + * Format a duration in minutes to a human-readable string. | |
| + * @param {number} minutes duration in minutes | |
| + * @returns {string} formatted string | |
| + */ | |
| + formatDuration(minutes) { | |
| + if (minutes <= 0) { | |
| + return "0m"; | |
| + } | |
| + if (minutes < 60) { | |
| + return minutes + "m"; | |
| + } | |
| + const h = Math.floor(minutes / 60); | |
| + const m = minutes % 60; | |
| + return m > 0 ? `${h}h ${m}m` : `${h}h`; | |
| + }, | |
| + | |
| + /** | |
| + * Handle mouseenter / focus on a day cell. | |
| + * @param {object} day day object | |
| + * @param {Event} event DOM event | |
| + * @returns {void} | |
| + */ | |
| + onCellEnter(day, event) { | |
| + if (!day.inRange || !day.date) { | |
| + this.hideTooltip(); | |
| + return; | |
| + } | |
| + | |
| + const el = event.currentTarget || event.target; | |
| + const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : { left: 0, top: 0, width: 0, height: 0 }; | |
| + const cellCenterX = rect.left + rect.width / 2; | |
| + const spaceAbove = rect.top; | |
| + const tooltipApproxHeight = 110; | |
| + const above = spaceAbove > tooltipApproxHeight; | |
| + | |
| + this.tooltip.x = cellCenterX; | |
| + this.tooltip.y = above ? rect.top - 8 : rect.bottom + 8; | |
| + this.tooltip.above = above; | |
| + this.tooltip.day = day; | |
| + this.tooltip.visible = true; | |
| + }, | |
| + | |
| + /** | |
| + * Handle touchstart on a day cell (toggle tooltip). | |
| + * @param {object} day day object | |
| + * @param {TouchEvent} event touch event | |
| + * @returns {void} | |
| + */ | |
| + onCellTap(day, event) { | |
| + if (this.tooltip.visible && this.tooltip.day && this.tooltip.day.date === day.date) { | |
| + this.hideTooltip(); | |
| + return; | |
| + } | |
| + const touch = event.touches[0]; | |
| + if (!touch) { | |
| + return; | |
| + } | |
| + // Approximate position from touch coordinates | |
| + this.tooltip.x = touch.clientX; | |
| + this.tooltip.y = touch.clientY - 8; | |
| + this.tooltip.above = true; | |
| + this.tooltip.day = day; | |
| + this.tooltip.visible = true; | |
| + }, | |
| + | |
| + /** | |
| + * Hide the tooltip. | |
| + * @returns {void} | |
| + */ | |
| + hideTooltip() { | |
| + this.tooltip.visible = false; | |
| + }, | |
| + }, | |
| +}; | |
| +</script> | |
| + | |
| +<style lang="scss" scoped> | |
| +@import "../assets/vars.scss"; | |
| + | |
| +$cell-size: 11px; | |
| +$cell-gap: 2px; | |
| + | |
| +// Healthy shades from faint to strong | |
| +$color-no-data-light: #ebedf0; | |
| +$color-no-data-dark: #21262d; | |
| + | |
| +.uptime-heatmap { | |
| + margin-top: 8px; | |
| + padding: 4px 0 2px; | |
| +} | |
| + | |
| +/* --- Controls --- */ | |
| +.heatmap-controls { | |
| + display: flex; | |
| + align-items: center; | |
| + gap: 8px; | |
| + margin-bottom: 6px; | |
| + flex-wrap: wrap; | |
| +} | |
| + | |
| +.heatmap-history-label { | |
| + font-size: 11px; | |
| + color: $secondary-text; | |
| + white-space: nowrap; | |
| +} | |
| + | |
| +.heatmap-range-group { | |
| + display: flex; | |
| + gap: 2px; | |
| +} | |
| + | |
| +.heatmap-range-btn { | |
| + font-size: 10px; | |
| + padding: 1px 7px; | |
| + border: 1px solid $dark-border-color; | |
| + border-radius: 25px; | |
| + background: transparent; | |
| + cursor: pointer; | |
| + color: $secondary-text; | |
| + transition: all 0.15s ease; | |
| + line-height: 1.6; | |
| + | |
| + &:hover { | |
| + background-color: $highlight-white; | |
| + border-color: $primary; | |
| + color: darken($primary, 15%); | |
| + } | |
| + | |
| + &.active { | |
| + background-color: $primary; | |
| + border-color: $primary; | |
| + color: #fff; | |
| + font-weight: 500; | |
| + } | |
| + | |
| + .dark & { | |
| + border-color: $dark-border-color; | |
| + color: $dark-font-color; | |
| + | |
| + &:hover { | |
| + background-color: $dark-bg2; | |
| + border-color: $primary; | |
| + color: $primary; | |
| + } | |
| + | |
| + &.active { | |
| + background-color: $primary; | |
| + border-color: $primary; | |
| + color: $dark-font-color2; | |
| + } | |
| + } | |
| +} | |
| + | |
| +/* --- Loading --- */ | |
| +.heatmap-loading { | |
| + min-height: 55px; | |
| + display: flex; | |
| + align-items: center; | |
| + padding: 4px 0; | |
| +} | |
| + | |
| +/* --- Grid wrapper --- */ | |
| +.heatmap-scroll-wrapper { | |
| + overflow-x: auto; | |
| + overflow-y: hidden; | |
| + padding-bottom: 2px; | |
| + | |
| + // Hide scrollbar on most browsers while keeping scroll functionality | |
| + scrollbar-width: thin; | |
| + scrollbar-color: $dark-border-color transparent; | |
| + | |
| + &::-webkit-scrollbar { | |
| + height: 3px; | |
| + } | |
| + | |
| + &::-webkit-scrollbar-track { | |
| + background: transparent; | |
| + } | |
| + | |
| + &::-webkit-scrollbar-thumb { | |
| + background: $dark-border-color; | |
| + border-radius: 10px; | |
| + } | |
| +} | |
| + | |
| +/* --- Calendar grid --- */ | |
| +.heatmap-grid { | |
| + display: grid; | |
| + grid-auto-flow: column; | |
| + width: fit-content; | |
| + // rows and gap set via inline :style binding | |
| +} | |
| + | |
| +/* --- Cells --- */ | |
| +.heatmap-cell { | |
| + width: $cell-size; | |
| + height: $cell-size; | |
| + border-radius: 2px; | |
| + box-sizing: border-box; | |
| + transition: transform 0.1s ease, opacity 0.1s ease; | |
| + | |
| + &:hover:not(.heatmap-cell-empty) { | |
| + transform: scale(1.3); | |
| + opacity: 0.85; | |
| + cursor: pointer; | |
| + } | |
| + | |
| + &.heatmap-cell-empty { | |
| + background: transparent; | |
| + pointer-events: none; | |
| + } | |
| + | |
| + &.status-healthy { | |
| + background-color: $primary; | |
| + } | |
| + | |
| + &.status-degraded { | |
| + background-color: $warning; | |
| + } | |
| + | |
| + &.status-outage { | |
| + background-color: $danger; | |
| + } | |
| + | |
| + &.status-no-data { | |
| + background-color: $color-no-data-light; | |
| + | |
| + .dark & { | |
| + background-color: $color-no-data-dark; | |
| + } | |
| + } | |
| +} | |
| + | |
| +/* --- Legend --- */ | |
| +.heatmap-legend { | |
| + display: flex; | |
| + align-items: center; | |
| + gap: 3px; | |
| + margin-top: 4px; | |
| +} | |
| + | |
| +.heatmap-legend-text { | |
| + font-size: 10px; | |
| + color: $secondary-text; | |
| +} | |
| + | |
| +.heatmap-legend-cell { | |
| + // same size as normal cell, no hover effect | |
| + &:hover { | |
| + transform: none; | |
| + opacity: 1; | |
| + cursor: default; | |
| + } | |
| +} | |
| +</style> | |
| + | |
| +<!-- Tooltip styles are NOT scoped because the element is teleported to <body> --> | |
| +<style lang="scss"> | |
| +@import "../assets/vars.scss"; | |
| + | |
| +.heatmap-tooltip { | |
| + background: rgba(17, 24, 39, 0.96); | |
| + backdrop-filter: blur(8px); | |
| + border: 1px solid rgba(75, 85, 99, 0.35); | |
| + border-radius: 8px; | |
| + padding: 8px 12px; | |
| + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28); | |
| + min-width: 148px; | |
| + font-size: 12px; | |
| + color: #e5e7eb; | |
| + animation: htFadeIn 0.15s ease; | |
| + | |
| + .ht-date { | |
| + font-weight: 600; | |
| + font-size: 12px; | |
| + color: #f9fafb; | |
| + margin-bottom: 3px; | |
| + } | |
| + | |
| + .ht-status { | |
| + font-size: 11px; | |
| + font-weight: 600; | |
| + text-transform: uppercase; | |
| + letter-spacing: 0.5px; | |
| + margin-bottom: 6px; | |
| + padding-bottom: 5px; | |
| + border-bottom: 1px solid rgba(75, 85, 99, 0.3); | |
| + | |
| + &.status-healthy { | |
| + color: $primary; | |
| + } | |
| + | |
| + &.status-degraded { | |
| + color: $warning; | |
| + } | |
| + | |
| + &.status-outage { | |
| + color: $danger; | |
| + } | |
| + | |
| + &.status-no-data { | |
| + color: #9ca3af; | |
| + } | |
| + } | |
| + | |
| + .ht-row { | |
| + display: flex; | |
| + justify-content: space-between; | |
| + align-items: center; | |
| + gap: 12px; | |
| + line-height: 1.6; | |
| + color: #d1d5db; | |
| + | |
| + .ht-value { | |
| + color: #f3f4f6; | |
| + font-weight: 500; | |
| + } | |
| + } | |
| + | |
| + // Arrow pointing downward (tooltip shown above cell) | |
| + &.heatmap-tooltip-above::after { | |
| + content: ""; | |
| + position: absolute; | |
| + bottom: -5px; | |
| + left: 50%; | |
| + transform: translateX(-50%) rotate(45deg); | |
| + width: 8px; | |
| + height: 8px; | |
| + background: rgba(17, 24, 39, 0.96); | |
| + border-right: 1px solid rgba(75, 85, 99, 0.35); | |
| + border-bottom: 1px solid rgba(75, 85, 99, 0.35); | |
| + } | |
| + | |
| + // Arrow pointing upward (tooltip shown below cell) | |
| + &:not(.heatmap-tooltip-above)::before { | |
| + content: ""; | |
| + position: absolute; | |
| + top: -5px; | |
| + left: 50%; | |
| + transform: translateX(-50%) rotate(45deg); | |
| + width: 8px; | |
| + height: 8px; | |
| + background: rgba(17, 24, 39, 0.96); | |
| + border-left: 1px solid rgba(75, 85, 99, 0.35); | |
| + border-top: 1px solid rgba(75, 85, 99, 0.35); | |
| + } | |
| +} | |
| + | |
| +@keyframes htFadeIn { | |
| + from { | |
| + opacity: 0; | |
| + } | |
| + to { | |
| + opacity: 1; | |
| + } | |
| +} | |
| + | |
| +@media (prefers-reduced-motion: reduce) { | |
| + .heatmap-tooltip { | |
| + animation: none !important; | |
| + } | |
| +} | |
| +</style> | |
| diff --git a/src/lang/en.json b/src/lang/en.json | |
| index 6235dbf3..4e555162 100644 | |
| --- a/src/lang/en.json | |
| +++ b/src/lang/en.json | |
| @@ -1514,5 +1514,14 @@ | |
| "teltonikaModem": "Modem Id", | |
| "teltonikaModemHelptext": "The id of the SMS modem, must be in the format {0}. Refer to https://developers.teltonika-networks.com/reference/ for guidance.", | |
| "teltonikaPhoneNumber": "Phone number", | |
| - "teltonikaPhoneNumberHelptext": "The number must be in the international format {0}, {1}. Only one number is allowed." | |
| + "teltonikaPhoneNumberHelptext": "The number must be in the international format {0}, {1}. Only one number is allowed.", | |
| + "heatmapHistory": "History", | |
| + "heatmapLegendLess": "Less", | |
| + "heatmapLegendMore": "More", | |
| + "heatmapStatusHealthy": "Healthy", | |
| + "heatmapStatusDegraded": "Degraded", | |
| + "heatmapStatusOutage": "Outage", | |
| + "heatmapStatusNoData": "No data", | |
| + "heatmapDowntime": "Downtime", | |
| + "heatmapAriaLabel": "Uptime heatmap for the last {0} days" | |
| } | |
| -- | |
| 2.53.0 | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment