-
-
Save shricodev/f6e66c056cd2bb1c868e640b25f2a953 to your computer and use it in GitHub Desktop.
Uptime Kuma - Opus 4.6
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 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