diff options
| -rw-r--r-- | config.example.yaml | 8 | ||||
| -rw-r--r-- | internal/server/server.go | 25 | ||||
| -rw-r--r-- | internal/server/templates/index.html | 173 |
3 files changed, 183 insertions, 23 deletions
diff --git a/config.example.yaml b/config.example.yaml index d5355da..81297c5 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -40,8 +40,12 @@ display: # false = Grow dynamically as pings come in ping_fixed_slots: true - # Timezone for display (e.g., "UTC", "America/New_York", "Local") - timezone: "Local" + # Timezone for display: + # "Browser" - Use visitor's browser timezone (recommended for public status pages) + # "Local" - Use server's local timezone + # "UTC" - Use UTC timezone + # "America/New_York" - Use specific IANA timezone (e.g., "Europe/London", "Asia/Tokyo") + timezone: "Browser" # Show the theme toggle button (true by default) # Set to false to hide the light/dark mode toggle diff --git a/internal/server/server.go b/internal/server/server.go index abd18cb..d0f6fa5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -127,6 +127,7 @@ type PageData struct { TickCount int ShowThemeToggle bool Timezone string // Timezone for display + UseBrowserTimezone bool // Use client-side timezone conversion } // GroupData contains data for a monitor group @@ -190,11 +191,12 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { // Build page data data := PageData{ - Site: s.config.Site, - TickMode: s.config.Display.TickMode, - TickCount: s.config.Display.TickCount, - ShowThemeToggle: s.config.Display.ShowThemeToggle != nil && *s.config.Display.ShowThemeToggle, - Timezone: s.config.Display.Timezone, + Site: s.config.Site, + TickMode: s.config.Display.TickMode, + TickCount: s.config.Display.TickCount, + ShowThemeToggle: s.config.Display.ShowThemeToggle != nil && *s.config.Display.ShowThemeToggle, + Timezone: s.config.Display.Timezone, + UseBrowserTimezone: s.config.Display.Timezone == "Browser", } overallUp := true @@ -522,9 +524,12 @@ func templateFuncs() template.FuncMap { return string(b) } - // Convert timestamp to configured timezone + // If using browser timezone, include raw timestamp for client-side conversion + useBrowserTz := timezone == "Browser" + + // Convert timestamp to configured timezone (fallback for non-JS users) loc := time.Local - if timezone != "" && timezone != "Local" { + if timezone != "" && timezone != "Local" && timezone != "Browser" { if l, err := time.LoadLocation(timezone); err == nil { loc = l } @@ -547,6 +552,12 @@ func templateFuncs() template.FuncMap { data := make(map[string]interface{}) rows := []map[string]string{} + // Include raw timestamp for browser timezone conversion + if useBrowserTz { + data["timestamp"] = tick.Timestamp.Format(time.RFC3339) + data["mode"] = mode + } + switch mode { case "ping": header = t.Format("Jan 2, 15:04:05") diff --git a/internal/server/templates/index.html b/internal/server/templates/index.html index c351c73..c6e2a81 100644 --- a/internal/server/templates/index.html +++ b/internal/server/templates/index.html @@ -56,7 +56,7 @@ </div> <span class="font-medium">{{.OverallStatus}}</span> </div> - <span class="text-sm text-neutral-500 dark:text-neutral-400" data-tooltip='{{.TimezoneTooltip}}'>{{.CurrentTime}}</span> + <span class="text-sm text-neutral-500 dark:text-neutral-400" data-tooltip='{{.TimezoneTooltip}}'{{if .UseBrowserTimezone}} data-timestamp="{{.LastUpdated.Format "2006-01-02T15:04:05Z07:00"}}" data-format="datetime"{{end}}>{{.CurrentTime}}</span> </div> </div> @@ -153,11 +153,11 @@ <span class="font-medium">{{.Title}}</span> </div> <p class="text-sm text-neutral-600 dark:text-neutral-400">{{.Message}}</p> - {{if .IsScheduled}} - <p class="text-xs text-neutral-500 dark:text-neutral-500 mt-2"> - Scheduled: {{if .ScheduledStart}}{{formatTime .ScheduledStart}}{{end}} - {{if .ScheduledEnd}}{{formatTime .ScheduledEnd}}{{end}} - </p> - {{end}} + {{if .IsScheduled}} + <p class="text-xs text-neutral-500 dark:text-neutral-500 mt-2"{{if $.UseBrowserTimezone}}{{if .ScheduledStart}} data-timestamp-start="{{.ScheduledStart.Format "2006-01-02T15:04:05Z07:00"}}"{{end}}{{if .ScheduledEnd}} data-timestamp-end="{{.ScheduledEnd.Format "2006-01-02T15:04:05Z07:00"}}"{{end}} data-format="scheduled"{{end}}> + Scheduled: {{if .ScheduledStart}}{{formatTime .ScheduledStart}}{{end}} - {{if .ScheduledEnd}}{{formatTime .ScheduledEnd}}{{end}} + </p> + {{end}} </div> <div class="flex-shrink-0"> <span class="text-xs px-2 py-1 rounded-full {{if eq .Status "resolved"}}bg-emerald-100 dark:bg-emerald-900/50 text-emerald-700 dark:text-emerald-300{{else if eq .Status "scheduled"}}bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300{{else if eq .Status "investigating"}}bg-yellow-100 dark:bg-yellow-900/50 text-yellow-700 dark:text-yellow-300{{else if eq .Status "identified"}}bg-orange-100 dark:bg-orange-900/50 text-orange-700 dark:text-orange-300{{else}}bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300{{end}} capitalize"> @@ -169,13 +169,13 @@ {{if .Updates}} <div class="border-t border-neutral-200 dark:border-neutral-800 p-4 space-y-3 bg-white dark:bg-neutral-950"> {{range .Updates}} - <div class="text-sm"> - <div class="flex items-center gap-2 text-neutral-500 dark:text-neutral-400 mb-1"> - <span class="capitalize font-medium">{{.Status}}</span> - <span class="text-xs">{{formatTime .Time}}</span> - </div> - <p class="text-neutral-700 dark:text-neutral-300">{{.Message}}</p> - </div> + <div class="text-sm"> + <div class="flex items-center gap-2 text-neutral-500 dark:text-neutral-400 mb-1"> + <span class="capitalize font-medium">{{.Status}}</span> + <span class="text-xs"{{if $.UseBrowserTimezone}} data-timestamp="{{.Time.Format "2006-01-02T15:04:05Z07:00"}}" data-format="datetime-short"{{end}}>{{formatTime .Time}}</span> + </div> + <p class="text-neutral-700 dark:text-neutral-300">{{.Message}}</p> + </div> {{end}} </div> {{end}} @@ -188,7 +188,7 @@ <!-- Footer --> <footer class="mt-12 pt-6 border-t border-neutral-200 dark:border-neutral-800"> <div class="flex items-center justify-between text-xs text-neutral-500 dark:text-neutral-400"> - <span data-tooltip='{{.LastUpdatedTooltip}}'>Updated {{timeAgo .LastUpdated}}</span> + <span data-tooltip='{{.LastUpdatedTooltip}}'{{if $.UseBrowserTimezone}} data-timestamp="{{$.LastUpdated.Format "2006-01-02T15:04:05Z07:00"}}" data-format="timeago"{{end}}>Updated {{timeAgo $.LastUpdated}}</span> <span>Powered by <a href="https://github.com/Fuwn/kaze" class="hover:text-neutral-900 dark:hover:text-neutral-100 underline underline-offset-2">Kaze</a></span> </div> </footer> @@ -258,6 +258,43 @@ let hideTimeout = null; function renderTooltip(data) { + {{if .UseBrowserTimezone}} + // Convert tick tooltip timestamps to browser timezone + if (data.timestamp && data.mode) { + const date = new Date(data.timestamp); + const tzInfo = { + tzName: Intl.DateTimeFormat().resolvedOptions().timeZone || 'Local', + offset: -date.getTimezoneOffset() + }; + const hours = Math.floor(tzInfo.offset / 60); + const minutes = Math.abs(tzInfo.offset % 60); + const utcOffset = 'UTC' + (hours >= 0 ? '+' : '') + hours + (minutes ? ':' + String(minutes).padStart(2, '0') : ''); + + // Update header based on mode + if (data.mode === 'ping') { + data.header = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' + + date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); + } else if (data.mode === 'minute') { + data.header = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' + + date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); + } else if (data.mode === 'hour') { + const hourStr = String(date.getHours()).padStart(2, '0') + ':00'; + data.header = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' + hourStr; + } else if (data.mode === 'day') { + data.header = date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); + } + + // Update timezone info in rows + if (data.rows) { + data.rows.forEach(function(row) { + if (row.label === 'Timezone') { + row.value = tzInfo.tzName + ' (' + utcOffset + ')'; + } + }); + } + } + {{end}} + let html = '<span class="tooltip-header">' + data.header + '</span>'; if (data.error) { html += '<div style="white-space:normal;max-width:260px;margin-top:0.25rem">' + data.error + '</div>'; @@ -352,6 +389,114 @@ // Auto-refresh every 30 seconds setTimeout(() => location.reload(), 30000); + + // Client-side timezone conversion + {{if .UseBrowserTimezone}} + (function() { + function formatDateTime(date) { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const month = months[date.getMonth()]; + const day = date.getDate(); + const year = date.getFullYear(); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return month + ' ' + day + ', ' + year + ' ' + hours + ':' + minutes; + } + + function formatDateTimeShort(date) { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const month = months[date.getMonth()]; + const day = date.getDate(); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return month + ' ' + day + ', ' + hours + ':' + minutes; + } + + function timeAgo(date) { + const seconds = Math.floor((new Date() - date) / 1000); + if (seconds < 60) return seconds + ' seconds ago'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return minutes + ' minute' + (minutes === 1 ? '' : 's') + ' ago'; + const hours = Math.floor(minutes / 60); + if (hours < 24) return hours + ' hour' + (hours === 1 ? '' : 's') + ' ago'; + const days = Math.floor(hours / 24); + return days + ' day' + (days === 1 ? '' : 's') + ' ago'; + } + + function getTimezoneInfo() { + const date = new Date(); + const tzName = Intl.DateTimeFormat().resolvedOptions().timeZone || 'Local'; + const offset = -date.getTimezoneOffset(); + const hours = Math.floor(offset / 60); + const minutes = Math.abs(offset % 60); + const utcOffset = 'UTC' + (hours >= 0 ? '+' : '') + hours + (minutes ? ':' + String(minutes).padStart(2, '0') : ''); + const gmtOffset = 'GMT' + (hours >= 0 ? '+' : '') + hours + (minutes ? ':' + String(minutes).padStart(2, '0') : ''); + return { tzName: tzName, utcOffset: utcOffset, gmtOffset: gmtOffset }; + } + + // Convert all timestamps + document.querySelectorAll('[data-timestamp]').forEach(function(el) { + const timestamp = el.getAttribute('data-timestamp'); + const format = el.getAttribute('data-format'); + const date = new Date(timestamp); + + if (format === 'datetime') { + el.textContent = formatDateTime(date); + } else if (format === 'datetime-short') { + el.textContent = formatDateTimeShort(date); + } else if (format === 'timeago') { + el.textContent = 'Updated ' + timeAgo(date); + } else if (format === 'scheduled') { + const startTimestamp = el.getAttribute('data-timestamp-start'); + const endTimestamp = el.getAttribute('data-timestamp-end'); + let text = 'Scheduled: '; + if (startTimestamp) { + text += formatDateTimeShort(new Date(startTimestamp)); + } + text += ' - '; + if (endTimestamp) { + text += formatDateTimeShort(new Date(endTimestamp)); + } + el.textContent = text; + } + + // Update tooltips to show browser timezone + const tooltipData = el.getAttribute('data-tooltip'); + if (tooltipData) { + try { + const data = JSON.parse(tooltipData); + if (data.rows) { + const tzInfo = getTimezoneInfo(); + data.rows.forEach(function(row) { + if (row.label === 'Timezone') { + row.value = tzInfo.tzName; + } else if (row.label === 'UTC Offset') { + row.value = tzInfo.utcOffset; + } else if (row.label === 'GMT Offset') { + row.value = tzInfo.gmtOffset; + } else if (row.label === 'Date & Time' && format === 'timeago') { + // Update "Last Check" tooltip with browser timezone + const dtFormat = new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }); + row.value = dtFormat.format(date); + } + }); + el.setAttribute('data-tooltip', JSON.stringify(data)); + } + } catch (e) { + // Ignore JSON parse errors + } + } + }); + })(); + {{end}} </script> </body> </html> |