Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

Save shricodev/f6e66c056cd2bb1c868e640b25f2a953 to your computer and use it in GitHub Desktop.
Uptime Kuma - Opus 4.6
From fc4a644b6dac101b6e6c339fd31f08ba1f81de7b Mon Sep 17 00:00:00 2001
From: Shrijal Acharya <shrijal.acharya@gmail.com>
Date: Fri, 20 Mar 2026 21:57:31 +0545
Subject: [PATCH] opus
---
server/routers/status-page-router.js | 55 +++
src/components/PublicGroupList.vue | 23 +
src/components/UptimeHeatmap.vue | 678 +++++++++++++++++++++++++++
src/pages/StatusPage.vue | 28 ++
4 files changed, 784 insertions(+)
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..cff252b4 100644
--- a/server/routers/status-page-router.js
+++ b/server/routers/status-page-router.js
@@ -166,6 +166,61 @@ router.get("/api/status-page/:slug/incident-history", cache("5 minutes"), async
}
});
+// Status Page Daily Stats for Heatmap
+router.get("/api/status-page/heatmap/:slug", cache("5 minutes"), async (request, response) => {
+ allowDevAllOrigin(response);
+
+ try {
+ let slug = request.params.slug;
+ slug = slug.toLowerCase();
+ let statusPageID = await StatusPage.slugToID(slug);
+
+ if (!statusPageID) {
+ sendHttpError(response, "Status Page Not Found");
+ return;
+ }
+
+ let days = parseInt(request.query.days) || 90;
+ if (days > 365) {
+ days = 365;
+ }
+ if (days < 1) {
+ days = 1;
+ }
+
+ let 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]
+ );
+
+ let heatmapData = {};
+
+ for (let monitorID of monitorIDList) {
+ const uptimeCalculator = await UptimeCalculator.getUptimeCalculator(monitorID);
+ const dataArray = uptimeCalculator.getDataArray(days, "day");
+
+ heatmapData[monitorID] = dataArray.map((d) => ({
+ timestamp: d.timestamp,
+ up: d.up,
+ down: d.down,
+ avgPing: d.avgPing,
+ maintenance: d.maintenance || 0,
+ }));
+ }
+
+ response.json({
+ heatmapData,
+ });
+ } catch (error) {
+ sendHttpError(response, error.message);
+ }
+});
+
// overall status-page status badge
router.get("/api/status-page/:slug/badge", cache("5 minutes"), async (request, response) => {
allowDevAllOrigin(response);
diff --git a/src/components/PublicGroupList.vue b/src/components/PublicGroupList.vue
index d9c13aa2..8f0d557c 100644
--- a/src/components/PublicGroupList.vue
+++ b/src/components/PublicGroupList.vue
@@ -120,6 +120,16 @@
<HeartbeatBar size="mid" :monitor-id="monitor.element.id" />
</div>
</div>
+ <div class="row mt-2">
+ <div class="col-12">
+ <UptimeHeatmap
+ :monitor-id="monitor.element.id"
+ :heatmap-data="heatmapData"
+ :selected-days="heatmapDays"
+ @change-range="$emit('change-heatmap-range', $event)"
+ />
+ </div>
+ </div>
</div>
</template>
</Draggable>
@@ -134,6 +144,7 @@
import MonitorSettingDialog from "./MonitorSettingDialog.vue";
import Draggable from "vuedraggable";
import HeartbeatBar from "./HeartbeatBar.vue";
+import UptimeHeatmap from "./UptimeHeatmap.vue";
import Uptime from "./Uptime.vue";
import Tag from "./Tag.vue";
import Status from "./Status.vue";
@@ -144,6 +155,7 @@ export default {
MonitorSettingDialog,
Draggable,
HeartbeatBar,
+ UptimeHeatmap,
Uptime,
Tag,
Status,
@@ -167,7 +179,18 @@ export default {
showOnlyLastHeartbeat: {
type: Boolean,
},
+ /** Daily heatmap data keyed by monitor ID */
+ heatmapData: {
+ type: Object,
+ default: () => ({}),
+ },
+ /** Number of days for heatmap */
+ heatmapDays: {
+ type: Number,
+ default: 90,
+ },
},
+ emits: [ "change-heatmap-range" ],
data() {
return {};
},
diff --git a/src/components/UptimeHeatmap.vue b/src/components/UptimeHeatmap.vue
new file mode 100644
index 00000000..799e46de
--- /dev/null
+++ b/src/components/UptimeHeatmap.vue
@@ -0,0 +1,678 @@
+<template>
+ <div class="uptime-heatmap">
+ <div class="heatmap-header">
+ <div class="heatmap-range-selector">
+ <button
+ v-for="option in rangeOptions"
+ :key="option.days"
+ class="range-btn"
+ :class="{ active: selectedDays === option.days }"
+ @click="selectRange(option.days)"
+ >
+ {{ option.label }}
+ </button>
+ </div>
+ </div>
+
+ <div class="heatmap-body">
+ <div v-if="loading" class="heatmap-loading">
+ <div class="loading-placeholder"></div>
+ </div>
+ <div v-else class="heatmap-grid-wrapper">
+ <div class="heatmap-day-labels">
+ <div
+ v-for="label in dayLabels"
+ :key="label.index"
+ class="day-label"
+ :class="{ 'day-label-hidden': !label.show }"
+ >
+ {{ label.text }}
+ </div>
+ </div>
+ <div class="heatmap-scroll-container">
+ <div class="heatmap-months">
+ <div
+ v-for="month in monthLabels"
+ :key="month.key"
+ class="month-label"
+ :style="{ left: month.offset + 'px' }"
+ >
+ {{ month.label }}
+ </div>
+ </div>
+ <div class="heatmap-grid" :style="gridStyle">
+ <div
+ v-for="(cell, idx) in cells"
+ :key="idx"
+ class="heatmap-cell"
+ :class="cellClass(cell)"
+ :style="cellPosition(cell)"
+ @mouseenter="showTooltip($event, cell)"
+ @mouseleave="hideTooltip"
+ @click="toggleTooltip($event, cell)"
+ ></div>
+ </div>
+ </div>
+ </div>
+
+ <div class="heatmap-legend">
+ <span class="legend-label">Less</span>
+ <div class="legend-cell legend-no-data" title="No data"></div>
+ <div class="legend-cell legend-healthy" title="Healthy (>99%)"></div>
+ <div class="legend-cell legend-degraded" title="Degraded (95-99%)"></div>
+ <div class="legend-cell legend-warning" title="Warning (90-95%)"></div>
+ <div class="legend-cell legend-outage" title="Outage (<90%)"></div>
+ <span class="legend-label">More downtime</span>
+ </div>
+ </div>
+
+ <!-- Tooltip -->
+ <Teleport to="body">
+ <div
+ v-if="tooltip.visible"
+ ref="tooltipEl"
+ class="heatmap-tooltip"
+ :style="tooltipPosition"
+ >
+ <div class="tooltip-date">{{ tooltip.date }}</div>
+ <div class="tooltip-status" :class="'tooltip-' + tooltip.statusClass">
+ {{ tooltip.statusLabel }}
+ </div>
+ <div v-if="tooltip.hasData" class="tooltip-details">
+ <div class="tooltip-row">
+ <span class="tooltip-key">Uptime</span>
+ <span class="tooltip-value">{{ tooltip.uptime }}</span>
+ </div>
+ <div class="tooltip-row">
+ <span class="tooltip-key">Downtime</span>
+ <span class="tooltip-value">{{ tooltip.downtime }}</span>
+ </div>
+ <div v-if="tooltip.avgPing !== null" class="tooltip-row">
+ <span class="tooltip-key">Avg Response</span>
+ <span class="tooltip-value">{{ tooltip.avgPing }}</span>
+ </div>
+ </div>
+ </div>
+ </Teleport>
+ </div>
+</template>
+
+<script>
+import dayjs from "dayjs";
+import utc from "dayjs/plugin/utc";
+
+dayjs.extend(utc);
+
+const CELL_SIZE = 13;
+const CELL_GAP = 3;
+
+export default {
+ props: {
+ monitorId: {
+ type: Number,
+ required: true,
+ },
+ heatmapData: {
+ type: Object,
+ default: () => ({}),
+ },
+ selectedDays: {
+ type: Number,
+ default: 90,
+ },
+ },
+ emits: [ "change-range" ],
+ data() {
+ return {
+ tooltip: {
+ visible: false,
+ x: 0,
+ y: 0,
+ date: "",
+ uptime: "",
+ downtime: "",
+ avgPing: null,
+ statusLabel: "",
+ statusClass: "",
+ hasData: false,
+ },
+ rangeOptions: [
+ { days: 30, label: "30d" },
+ { days: 90, label: "90d" },
+ { days: 365, label: "1y" },
+ ],
+ pinnedCell: null,
+ };
+ },
+ computed: {
+ loading() {
+ return !this.heatmapData || !this.heatmapData[this.monitorId];
+ },
+
+ dailyData() {
+ if (this.loading) {
+ return {};
+ }
+ let map = {};
+ let data = this.heatmapData[this.monitorId] || [];
+ for (let d of data) {
+ map[d.timestamp] = d;
+ }
+ return map;
+ },
+
+ cells() {
+ let result = [];
+ let now = dayjs.utc().startOf("day");
+
+ for (let i = this.selectedDays - 1; i >= 0; i--) {
+ let date = now.subtract(i, "day");
+ let timestamp = date.unix();
+ let data = this.dailyData[timestamp] || null;
+ let dayOfWeek = date.day(); // 0=Sun, 6=Sat
+
+ result.push({
+ date,
+ timestamp,
+ data,
+ dayOfWeek,
+ });
+ }
+
+ return result;
+ },
+
+ totalWeeks() {
+ if (this.cells.length === 0) {
+ return 0;
+ }
+ let firstDow = this.cells[0].dayOfWeek;
+ return Math.ceil((this.cells.length + firstDow) / 7);
+ },
+
+ gridStyle() {
+ let cols = this.totalWeeks;
+ return {
+ width: cols * (CELL_SIZE + CELL_GAP) + "px",
+ height: 7 * (CELL_SIZE + CELL_GAP) + "px",
+ position: "relative",
+ };
+ },
+
+ dayLabels() {
+ const names = [ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" ];
+ return names.map((text, index) => ({
+ text,
+ index,
+ show: index % 2 === 1,
+ }));
+ },
+
+ monthLabels() {
+ let labels = [];
+ let seen = {};
+
+ for (let cell of this.cells) {
+ let monthKey = cell.date.format("YYYY-MM");
+ if (!seen[monthKey]) {
+ seen[monthKey] = true;
+ let firstDow = this.cells[0].dayOfWeek;
+ let cellIndex = this.cells.indexOf(cell);
+ let col = Math.floor((cellIndex + firstDow) / 7);
+ labels.push({
+ key: monthKey,
+ label: cell.date.format("MMM"),
+ offset: col * (CELL_SIZE + CELL_GAP),
+ });
+ }
+ }
+
+ return labels;
+ },
+
+ tooltipPosition() {
+ return {
+ left: this.tooltip.x + "px",
+ top: this.tooltip.y + "px",
+ };
+ },
+ },
+ methods: {
+ selectRange(days) {
+ this.$emit("change-range", days);
+ },
+
+ cellPosition(cell) {
+ let firstDow = this.cells[0].dayOfWeek;
+ let idx = this.cells.indexOf(cell);
+ let col = Math.floor((idx + firstDow) / 7);
+ let row = cell.dayOfWeek;
+
+ return {
+ left: col * (CELL_SIZE + CELL_GAP) + "px",
+ top: row * (CELL_SIZE + CELL_GAP) + "px",
+ width: CELL_SIZE + "px",
+ height: CELL_SIZE + "px",
+ };
+ },
+
+ cellClass(cell) {
+ if (!cell.data) {
+ return "cell-no-data";
+ }
+ let total = cell.data.up + cell.data.down;
+ if (total === 0) {
+ return "cell-no-data";
+ }
+ let uptime = cell.data.up / total;
+ if (uptime >= 0.99) {
+ return "cell-healthy";
+ }
+ if (uptime >= 0.95) {
+ return "cell-degraded";
+ }
+ if (uptime >= 0.90) {
+ return "cell-warning";
+ }
+ return "cell-outage";
+ },
+
+ computeTooltipData(cell) {
+ let dateStr = cell.date.format("ddd, MMM D, YYYY");
+ let hasData = cell.data && (cell.data.up + cell.data.down) > 0;
+
+ if (!hasData) {
+ return {
+ date: dateStr,
+ statusLabel: "No Data",
+ statusClass: "no-data",
+ hasData: false,
+ uptime: "",
+ downtime: "",
+ avgPing: null,
+ };
+ }
+
+ let total = cell.data.up + cell.data.down;
+ let uptimeRatio = cell.data.up / total;
+ let uptimePercent = Math.round(uptimeRatio * 10000) / 100;
+ let downtimeChecks = cell.data.down;
+
+ // Estimate downtime duration based on check intervals
+ // Each check is roughly 1 minute apart for most monitors
+ let downtimeMinutes = downtimeChecks;
+ let downtimeStr;
+ if (downtimeMinutes === 0) {
+ downtimeStr = "None";
+ } else if (downtimeMinutes < 60) {
+ downtimeStr = downtimeMinutes + "m";
+ } else if (downtimeMinutes < 1440) {
+ let hours = Math.floor(downtimeMinutes / 60);
+ let mins = downtimeMinutes % 60;
+ downtimeStr = hours + "h" + (mins > 0 ? " " + mins + "m" : "");
+ } else {
+ let hours = Math.floor(downtimeMinutes / 60);
+ downtimeStr = Math.floor(hours / 24) + "d " + (hours % 24) + "h";
+ }
+
+ let statusLabel;
+ let statusClass;
+ if (uptimeRatio >= 0.99) {
+ statusLabel = "Healthy";
+ statusClass = "healthy";
+ } else if (uptimeRatio >= 0.95) {
+ statusLabel = "Degraded";
+ statusClass = "degraded";
+ } else if (uptimeRatio >= 0.90) {
+ statusLabel = "Warning";
+ statusClass = "warning";
+ } else {
+ statusLabel = "Outage";
+ statusClass = "outage";
+ }
+
+ let avgPing = null;
+ if (cell.data.avgPing && cell.data.avgPing > 0) {
+ avgPing = Math.round(cell.data.avgPing) + " ms";
+ }
+
+ return {
+ date: dateStr,
+ statusLabel,
+ statusClass,
+ hasData: true,
+ uptime: uptimePercent + "%",
+ downtime: downtimeStr,
+ avgPing,
+ };
+ },
+
+ showTooltip(event, cell) {
+ if (this.pinnedCell) {
+ return;
+ }
+ this.displayTooltip(event, cell);
+ },
+
+ displayTooltip(event, cell) {
+ let data = this.computeTooltipData(cell);
+ let rect = event.target.getBoundingClientRect();
+
+ this.tooltip = {
+ visible: true,
+ x: rect.left + rect.width / 2,
+ y: rect.top - 8,
+ ...data,
+ };
+
+ this.$nextTick(() => {
+ if (this.$refs.tooltipEl) {
+ let el = this.$refs.tooltipEl;
+ let elRect = el.getBoundingClientRect();
+ // Center horizontally
+ this.tooltip.x = rect.left + rect.width / 2 - elRect.width / 2;
+ // Position above
+ this.tooltip.y = rect.top - elRect.height - 8 + window.scrollY;
+
+ // Keep within viewport
+ if (this.tooltip.x < 4) {
+ this.tooltip.x = 4;
+ }
+ if (this.tooltip.x + elRect.width > window.innerWidth - 4) {
+ this.tooltip.x = window.innerWidth - elRect.width - 4;
+ }
+ if (rect.top - elRect.height - 8 < 0) {
+ this.tooltip.y = rect.bottom + 8 + window.scrollY;
+ }
+ }
+ });
+ },
+
+ hideTooltip() {
+ if (this.pinnedCell) {
+ return;
+ }
+ this.tooltip.visible = false;
+ },
+
+ toggleTooltip(event, cell) {
+ if (this.pinnedCell === cell) {
+ this.pinnedCell = null;
+ this.tooltip.visible = false;
+ } else {
+ this.pinnedCell = cell;
+ this.displayTooltip(event, cell);
+ }
+ },
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+@import "../assets/vars";
+
+.uptime-heatmap {
+ margin-top: 8px;
+}
+
+.heatmap-header {
+ display: flex;
+ justify-content: flex-end;
+ margin-bottom: 6px;
+}
+
+.heatmap-range-selector {
+ display: flex;
+ gap: 2px;
+ background: rgba(128, 128, 128, 0.1);
+ border-radius: 6px;
+ padding: 2px;
+}
+
+.range-btn {
+ background: transparent;
+ border: none;
+ color: #666;
+ font-size: 11px;
+ padding: 2px 8px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-weight: 500;
+ transition: all 0.15s ease;
+ line-height: 1.4;
+
+ &:hover {
+ background: rgba(128, 128, 128, 0.15);
+ }
+
+ &.active {
+ background: $primary;
+ color: #fff;
+ }
+}
+
+.heatmap-body {
+ position: relative;
+}
+
+.heatmap-loading {
+ .loading-placeholder {
+ height: 112px;
+ background: rgba(128, 128, 128, 0.08);
+ border-radius: 6px;
+ animation: pulse 1.5s ease-in-out infinite;
+ }
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 0.4; }
+ 50% { opacity: 0.7; }
+}
+
+.heatmap-grid-wrapper {
+ display: flex;
+ gap: 4px;
+}
+
+.heatmap-day-labels {
+ display: flex;
+ flex-direction: column;
+ padding-top: 20px;
+
+ .day-label {
+ height: 16px;
+ font-size: 10px;
+ color: #999;
+ line-height: 16px;
+ text-align: right;
+ padding-right: 4px;
+ user-select: none;
+ }
+
+ .day-label-hidden {
+ visibility: hidden;
+ }
+}
+
+.heatmap-scroll-container {
+ overflow-x: auto;
+ overflow-y: hidden;
+ flex: 1;
+ -webkit-overflow-scrolling: touch;
+}
+
+.heatmap-months {
+ position: relative;
+ height: 16px;
+ margin-bottom: 4px;
+
+ .month-label {
+ position: absolute;
+ font-size: 10px;
+ color: #999;
+ white-space: nowrap;
+ user-select: none;
+ }
+}
+
+.heatmap-grid {
+ position: relative;
+}
+
+.heatmap-cell {
+ position: absolute;
+ border-radius: 2px;
+ cursor: pointer;
+ transition: outline 0.1s ease;
+
+ &:hover {
+ outline: 2px solid rgba(128, 128, 128, 0.5);
+ outline-offset: -1px;
+ }
+}
+
+.cell-no-data {
+ background-color: #ebedf0;
+}
+
+.cell-healthy {
+ background-color: #40c463;
+}
+
+.cell-degraded {
+ background-color: #ffc107;
+}
+
+.cell-warning {
+ background-color: #fd7e14;
+}
+
+.cell-outage {
+ background-color: #dc3545;
+}
+
+.heatmap-legend {
+ display: flex;
+ align-items: center;
+ gap: 3px;
+ justify-content: flex-end;
+ margin-top: 6px;
+
+ .legend-label {
+ font-size: 10px;
+ color: #999;
+ margin: 0 2px;
+ }
+
+ .legend-cell {
+ width: 11px;
+ height: 11px;
+ border-radius: 2px;
+ }
+}
+
+.legend-no-data { background-color: #ebedf0; }
+.legend-healthy { background-color: #40c463; }
+.legend-degraded { background-color: #ffc107; }
+.legend-warning { background-color: #fd7e14; }
+.legend-outage { background-color: #dc3545; }
+
+// Dark mode
+.dark {
+ .range-btn {
+ color: #aaa;
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.1);
+ }
+
+ &.active {
+ background: $primary;
+ color: #fff;
+ }
+ }
+
+ .cell-no-data {
+ background-color: #2d333b;
+ }
+
+ .legend-no-data {
+ background-color: #2d333b;
+ }
+
+ .heatmap-day-labels .day-label,
+ .heatmap-months .month-label,
+ .heatmap-legend .legend-label {
+ color: #666;
+ }
+
+ .heatmap-loading .loading-placeholder {
+ background: rgba(255, 255, 255, 0.05);
+ }
+}
+
+// Mobile
+.mobile {
+ .heatmap-grid-wrapper {
+ gap: 2px;
+ }
+
+ .heatmap-day-labels {
+ display: none;
+ }
+}
+</style>
+
+<style lang="scss">
+.heatmap-tooltip {
+ position: absolute;
+ z-index: 10000;
+ background: #24292e;
+ color: #fff;
+ border-radius: 6px;
+ padding: 8px 10px;
+ font-size: 12px;
+ pointer-events: none;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
+ min-width: 150px;
+ max-width: 220px;
+
+ .tooltip-date {
+ font-weight: 600;
+ margin-bottom: 4px;
+ font-size: 11px;
+ color: #ccc;
+ }
+
+ .tooltip-status {
+ font-weight: 700;
+ margin-bottom: 6px;
+ font-size: 13px;
+ }
+
+ .tooltip-healthy { color: #40c463; }
+ .tooltip-degraded { color: #ffc107; }
+ .tooltip-warning { color: #fd7e14; }
+ .tooltip-outage { color: #dc3545; }
+ .tooltip-no-data { color: #8b949e; }
+
+ .tooltip-details {
+ border-top: 1px solid #3a3f44;
+ padding-top: 4px;
+ }
+
+ .tooltip-row {
+ display: flex;
+ justify-content: space-between;
+ padding: 1px 0;
+ }
+
+ .tooltip-key {
+ color: #8b949e;
+ }
+
+ .tooltip-value {
+ font-weight: 600;
+ }
+}
+</style>
diff --git a/src/pages/StatusPage.vue b/src/pages/StatusPage.vue
index 4003b4a0..99b35c58 100644
--- a/src/pages/StatusPage.vue
+++ b/src/pages/StatusPage.vue
@@ -493,6 +493,9 @@
:show-tags="config.showTags"
:show-certificate-expiry="config.showCertificateExpiry"
:show-only-last-heartbeat="config.showOnlyLastHeartbeat"
+ :heatmap-data="heatmapData"
+ :heatmap-days="heatmapDays"
+ @change-heatmap-range="changeHeatmapRange"
/>
</div>
@@ -709,6 +712,8 @@ export default {
incidentHistoryLoading: false,
incidentHistoryNextCursor: null,
incidentHistoryHasMore: false,
+ heatmapData: {},
+ heatmapDays: 90,
};
},
computed: {
@@ -1032,6 +1037,7 @@ export default {
});
this.updateHeartbeatList();
+ this.loadHeatmapData();
this.loadIncidentHistory();
// Go to edit page if ?edit present
@@ -1101,6 +1107,28 @@ export default {
}
},
+ /**
+ * Load heatmap data for all monitors
+ * @returns {void}
+ */
+ loadHeatmapData() {
+ if (!this.editMode) {
+ axios.get("/api/status-page/heatmap/" + this.slug + "?days=" + this.heatmapDays).then((res) => {
+ this.heatmapData = res.data.heatmapData;
+ });
+ }
+ },
+
+ /**
+ * Change heatmap range and reload data
+ * @param {number} days Number of days
+ * @returns {void}
+ */
+ changeHeatmapRange(days) {
+ this.heatmapDays = days;
+ this.loadHeatmapData();
+ },
+
/**
* Setup timer to display countdown to refresh
* @returns {void}
--
2.53.0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment