Skip to content

Instantly share code, notes, and snippets.

@shricodev
Created March 30, 2026 16:16
Show Gist options
  • Select an option

  • Save shricodev/423f45e00bf9e9c65e202992c8cf2a6f to your computer and use it in GitHub Desktop.

Select an option

Save shricodev/423f45e00bf9e9c65e202992c8cf2a6f to your computer and use it in GitHub Desktop.
Uptime Kuma - Kombai
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