From 07d5aeea43f9e4f46e8362e1404860e50732376f Mon Sep 17 00:00:00 2001 From: Ehsan Shirvanian Date: Sat, 27 Dec 2025 16:18:55 -0400 Subject: [PATCH 01/12] update ui inspired by shadcn --- cmd/ddns-updater/main.go | 6 +- internal/models/html.go | 10 +- internal/records/html.go | 203 +++++- internal/server/handler.go | 8 +- internal/server/index.go | 44 +- internal/server/interfaces.go | 6 + internal/server/server.go | 4 +- internal/server/ui/index.html | 165 +++-- internal/server/ui/static/app.js | 378 +++++++++++ internal/server/ui/static/styles.css | 923 ++++++++++++++++++++++++--- internal/server/ui/static/theme.js | 91 +++ 11 files changed, 1666 insertions(+), 172 deletions(-) create mode 100644 internal/server/ui/static/app.js create mode 100644 internal/server/ui/static/theme.js diff --git a/cmd/ddns-updater/main.go b/cmd/ddns-updater/main.go index d62721455..4e7524f7e 100644 --- a/cmd/ddns-updater/main.go +++ b/cmd/ddns-updater/main.go @@ -215,7 +215,7 @@ func _main(ctx context.Context, reader *reader.Reader, args []string, logger log return fmt.Errorf("creating health server: %w", err) } - server, err := createServer(ctx, config.Server, logger, db, updaterService) + server, err := createServer(ctx, config.Server, logger, db, updaterService, ipGetter) if err != nil { return fmt.Errorf("creating server: %w", err) } @@ -368,7 +368,7 @@ func createHealthServer(db health.AllSelecter, resolver health.LookupIPer, //nolint:ireturn func createServer(ctx context.Context, config config.Server, logger log.LoggerInterface, db server.Database, - updaterService server.UpdateForcer) ( + updaterService server.UpdateForcer, ipGetter server.PublicIPFetcher) ( service goservices.Service, err error, ) { if !*config.Enabled { @@ -376,5 +376,5 @@ func createServer(ctx context.Context, config config.Server, } serverLogger := logger.New(log.SetComponent("http server")) return server.New(ctx, config.ListeningAddress, config.RootURL, - db, serverLogger, updaterService) + db, serverLogger, updaterService, ipGetter) } diff --git a/internal/models/html.go b/internal/models/html.go index fc8d8681f..ba5d997f6 100644 --- a/internal/models/html.go +++ b/internal/models/html.go @@ -3,7 +3,14 @@ package models // HTMLData is a list of HTML fields to be rendered. // It is exported so that the HTML template engine can render it. type HTMLData struct { - Rows []HTMLRow + Rows []HTMLRow + TotalDomains int + SuccessCount int + ErrorCount int + UpdatingCount int + LastUpdate string + PublicIPv4 string + PublicIPv6 string } // HTMLRow contains HTML fields to be rendered @@ -14,6 +21,7 @@ type HTMLRow struct { Provider string IPVersion string Status string + StatusClass string // CSS class for row background tinting (status-success, status-error, etc.) CurrentIP string PreviousIPs string } diff --git a/internal/records/html.go b/internal/records/html.go index e0c6b033e..b745b1dc1 100644 --- a/internal/records/html.go +++ b/internal/records/html.go @@ -12,57 +12,218 @@ import ( func (r *Record) HTML(now time.Time) models.HTMLRow { const NotAvailable = "N/A" row := r.Provider.HTML() - message := r.Message - if r.Status == constants.UPTODATE { - message = "no IP change for " + r.History.GetDurationSinceSuccess(now) - } - if message != "" { - message = fmt.Sprintf("(%s)", message) - } + + // Format IP version with icon/badge + row.IPVersion = formatIPVersion(row.IPVersion) + + // Set status class for row background tinting + row.StatusClass = getStatusClass(r.Status) + + // Build status display if r.Status == "" { row.Status = NotAvailable } else { - row.Status = fmt.Sprintf("%s %s, %s", - convertStatus(r.Status), - message, - time.Since(r.Time).Round(time.Second).String()+" ago") + // Build message for tooltip + var fullMessage string + if r.Status == constants.UPTODATE { + duration := r.History.GetDurationSinceSuccess(now) + fullMessage = fmt.Sprintf("No IP change for %s", duration) + } else if r.Message != "" { + fullMessage = r.Message + } + + // Create status badge with tooltip + statusBadge := convertStatusWithTooltip(r.Status, fullMessage) + lastUpdate := formatTimeSince(r.Time, now) + + // Combine badge and time inline only + row.Status = fmt.Sprintf(`
%s%s
`, + statusBadge, + lastUpdate) } + + // Format current IP currentIP := r.History.GetCurrentIP() if currentIP.IsValid() { row.CurrentIP = `` + currentIP.String() + "" } else { row.CurrentIP = NotAvailable } + + // Format previous IPs previousIPs := r.History.GetPreviousIPs() row.PreviousIPs = NotAvailable if len(previousIPs) > 0 { - var previousIPsStr []string + var previousIPsHTML []string const maxPreviousIPs = 2 for i, previousIP := range previousIPs { if i == maxPreviousIPs { - previousIPsStr = append(previousIPsStr, fmt.Sprintf("and %d more", len(previousIPs)-i)) + previousIPsHTML = append(previousIPsHTML, + fmt.Sprintf(`+%d more`, len(previousIPs)-i)) break } - previousIPsStr = append(previousIPsStr, previousIP.String()) + previousIPsHTML = append(previousIPsHTML, + fmt.Sprintf(`%s`, previousIP.String())) } - row.PreviousIPs = strings.Join(previousIPsStr, ", ") + row.PreviousIPs = strings.Join(previousIPsHTML, " ") } return row } -func convertStatus(status models.Status) string { +// formatTimeSince formats a duration in a human-readable way +func formatTimeSince(t time.Time, now time.Time) string { + duration := now.Sub(t) + + // Just now (less than 10 seconds) + if duration < 10*time.Second { + return "just now" + } + + // Seconds (less than 1 minute) + if duration < time.Minute { + seconds := int(duration.Seconds()) + return fmt.Sprintf("%ds ago", seconds) + } + + // Minutes (less than 1 hour) + if duration < time.Hour { + minutes := int(duration.Minutes()) + return fmt.Sprintf("%dm ago", minutes) + } + + // Hours (less than 1 day) + if duration < 24*time.Hour { + hours := int(duration.Hours()) + minutes := int(duration.Minutes()) % 60 + if minutes > 0 { + return fmt.Sprintf("%dh %dm ago", hours, minutes) + } + return fmt.Sprintf("%dh ago", hours) + } + + // Days (less than 7 days) + if duration < 7*24*time.Hour { + days := int(duration.Hours() / 24) + hours := int(duration.Hours()) % 24 + if hours > 0 { + return fmt.Sprintf("%dd %dh ago", days, hours) + } + return fmt.Sprintf("%dd ago", days) + } + + // Weeks + weeks := int(duration.Hours() / 24 / 7) + days := int(duration.Hours()/24) % 7 + if days > 0 { + return fmt.Sprintf("%dw %dd ago", weeks, days) + } + return fmt.Sprintf("%dw ago", weeks) +} + +func convertStatusWithTooltip(status models.Status, message string) string { + // Determine if we need tooltip class + tooltipClass := "" + tooltipAttr := "" + if message != "" { + tooltipClass = " has-status-tooltip" + tooltipAttr = fmt.Sprintf(` data-tooltip="%s"`, escapeHTML(message)) + } + switch status { case constants.SUCCESS: - return `Success` + return fmt.Sprintf(``, tooltipClass, tooltipAttr) + + `` + + `` + + `` + + `Success` case constants.FAIL: - return `Failure` + return fmt.Sprintf(``, tooltipClass, tooltipAttr) + + `` + + `` + + `` + + `` + + `` + + `Failed` case constants.UPTODATE: - return `Up to date` + return fmt.Sprintf(``, tooltipClass, tooltipAttr) + + `` + + `` + + `` + + `Up to date` case constants.UPDATING: - return `Updating` + return fmt.Sprintf(``, tooltipClass, tooltipAttr) + + `` + + `` + + `` + + `Updating` case constants.UNSET: - return `Unset` + return fmt.Sprintf(``, tooltipClass, tooltipAttr) + + `` + + `` + + `` + + `` + + `` + + `Unset` default: return "Unknown status" } } + +// formatIPVersion formats IP version string with styled badge and icon +func formatIPVersion(ipVersion string) string { + switch strings.ToLower(ipVersion) { + case "ipv4": + return `` + + `` + + `` + + `` + + `` + + `` + + `` + + `IPv4` + case "ipv6": + return `` + + `` + + `` + + `` + + `` + + `` + + `IPv6` + case "ipv4 or ipv6": + return `` + + `` + + `` + + `` + + `` + + `` + + `IPv4/6` + default: + return ipVersion + } +} + +// escapeHTML escapes special HTML characters to prevent XSS +func escapeHTML(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, "\"", """) + s = strings.ReplaceAll(s, "'", "'") + return s +} + +// getStatusClass returns CSS class for row background tinting +func getStatusClass(status models.Status) string { + switch status { + case constants.SUCCESS, constants.UPTODATE: + return "status-success" + case constants.FAIL: + return "status-error" + case constants.UPDATING: + return "status-updating" + case constants.UNSET: + return "status-warning" + default: + return "" + } +} diff --git a/internal/server/handler.go b/internal/server/handler.go index cff3fdacc..13da99f63 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -18,6 +18,7 @@ type handlers struct { // Objects db Database runner UpdateForcer + ipGetter PublicIPFetcher indexTemplate *template.Template // Mockable functions timeNow func() time.Time @@ -27,7 +28,7 @@ type handlers struct { var uiFS embed.FS func newHandler(ctx context.Context, rootURL string, - db Database, runner UpdateForcer, + db Database, runner UpdateForcer, ipGetter PublicIPFetcher, ) http.Handler { indexTemplate := template.Must(template.ParseFS(uiFS, "ui/index.html")) @@ -41,8 +42,9 @@ func newHandler(ctx context.Context, rootURL string, db: db, indexTemplate: indexTemplate, // TODO build information - timeNow: time.Now, - runner: runner, + timeNow: time.Now, + runner: runner, + ipGetter: ipGetter, } router := chi.NewRouter() diff --git a/internal/server/index.go b/internal/server/index.go index 090721572..f846f3f48 100644 --- a/internal/server/index.go +++ b/internal/server/index.go @@ -3,15 +3,57 @@ package server import ( "net/http" + "github.com/qdm12/ddns-updater/internal/constants" "github.com/qdm12/ddns-updater/internal/models" ) func (h *handlers) index(w http.ResponseWriter, _ *http.Request) { + // Prevent caching to ensure status updates are always fresh + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") + var htmlData models.HTMLData - for _, record := range h.db.SelectAll() { + successCount := 0 + errorCount := 0 + updatingCount := 0 + + records := h.db.SelectAll() + for _, record := range records { row := record.HTML(h.timeNow()) htmlData.Rows = append(htmlData.Rows, row) + + // Count statuses for summary statistics + switch record.Status { + case constants.SUCCESS, constants.UPTODATE: + successCount++ + case constants.FAIL: + errorCount++ + case constants.UPDATING: + updatingCount++ + } + } + + // Set summary statistics + htmlData.TotalDomains = len(records) + htmlData.SuccessCount = successCount + htmlData.ErrorCount = errorCount + htmlData.UpdatingCount = updatingCount + htmlData.LastUpdate = h.timeNow().Format("15:04:05") + + // Fetch public IP addresses + if h.ipGetter != nil { + ipv4, err := h.ipGetter.IP4(h.ctx) + if err == nil && ipv4.IsValid() { + htmlData.PublicIPv4 = ipv4.String() + } + + ipv6, err := h.ipGetter.IP6(h.ctx) + if err == nil && ipv6.IsValid() { + htmlData.PublicIPv6 = ipv6.String() + } } + err := h.indexTemplate.ExecuteTemplate(w, "index.html", htmlData) if err != nil { httpError(w, http.StatusInternalServerError, "failed generating webpage: "+err.Error()) diff --git a/internal/server/interfaces.go b/internal/server/interfaces.go index 01c6df3e5..753d1d75e 100644 --- a/internal/server/interfaces.go +++ b/internal/server/interfaces.go @@ -2,6 +2,7 @@ package server import ( "context" + "net/netip" "github.com/qdm12/ddns-updater/internal/records" ) @@ -19,3 +20,8 @@ type Logger interface { Warn(s string) Error(s string) } + +type PublicIPFetcher interface { + IP4(ctx context.Context) (ipv4 netip.Addr, err error) + IP6(ctx context.Context) (ipv6 netip.Addr, err error) +} diff --git a/internal/server/server.go b/internal/server/server.go index 8523c1fbe..f8e302032 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -7,10 +7,10 @@ import ( ) func New(ctx context.Context, address, rootURL string, db Database, - logger Logger, runner UpdateForcer, + logger Logger, runner UpdateForcer, ipGetter PublicIPFetcher, ) (server *httpserver.Server, err error) { return httpserver.New(httpserver.Settings{ - Handler: newHandler(ctx, rootURL, db, runner), + Handler: newHandler(ctx, rootURL, db, runner, ipGetter), Address: &address, Logger: logger, }) diff --git a/internal/server/ui/index.html b/internal/server/ui/index.html index 92c88fbfb..c96020369 100644 --- a/internal/server/ui/index.html +++ b/internal/server/ui/index.html @@ -2,55 +2,138 @@ - DDNS Updater - + + DDNS Updater + + - - - - - - - - - - - - - - {{range .Rows}} - - - - - - - - - - {{end}} - -
DomainOwnerProviderIP VersionUpdate StatusCurrent IPPrevious IPs (reverse chronological order)
{{.Domain}}{{.Owner}}{{.Provider}}{{.IPVersion}}{{.Status}}{{.CurrentIP}}{{.PreviousIPs}}
- + + + diff --git a/internal/server/ui/static/app.js b/internal/server/ui/static/app.js index 03e20ab4d..82d6274f8 100644 --- a/internal/server/ui/static/app.js +++ b/internal/server/ui/static/app.js @@ -325,6 +325,258 @@ }); } + /** + * History Modal Logic + */ + let currentHistoryData = []; + let currentPage = 1; + let totalPages = 1; + const ITEMS_PER_PAGE = 20; + + /** + * Open history modal for a domain + */ + function openHistoryModal(button) { + const row = button.closest('tr'); + const historyJSON = row.dataset.history; + + // Get domain name from the domain cell's text content + const domainCell = row.querySelector('.domain-cell'); + const domain = domainCell ? domainCell.textContent.trim() : 'Unknown'; + + try { + currentHistoryData = JSON.parse(historyJSON); + } catch (e) { + currentHistoryData = []; + } + + // Reverse to show newest first (data is oldest first) + currentHistoryData = currentHistoryData.reverse(); + + // Calculate total pages + totalPages = Math.max(1, Math.ceil(currentHistoryData.length / ITEMS_PER_PAGE)); + currentPage = 1; + + // Update modal title + document.getElementById('modal-title').textContent = `IP History: ${domain}`; + + // Render first page + renderHistoryPage(); + + // Show modal + const modal = document.getElementById('history-modal'); + modal.classList.add('active'); + } + + /** + * Close history modal + */ + function closeHistoryModal() { + const modal = document.getElementById('history-modal'); + modal.classList.remove('active'); + } + + /** + * Go to previous page + */ + function previousPage() { + if (currentPage > 1) { + currentPage--; + renderHistoryPage(); + } + } + + /** + * Go to next page + */ + function nextPage() { + if (currentPage < totalPages) { + currentPage++; + renderHistoryPage(); + } + } + + /** + * Render current page of history + */ + function renderHistoryPage() { + const container = document.getElementById('history-table-container'); + + // Empty state + if (currentHistoryData.length === 0) { + container.innerHTML = ` +
+ + + + +

No history available

+
+ `; + updatePaginationControls(); + return; + } + + // Calculate page range + const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; + const endIndex = Math.min(startIndex + ITEMS_PER_PAGE, currentHistoryData.length); + const pageData = currentHistoryData.slice(startIndex, endIndex); + + // Build table HTML + let tableHTML = ` + + + + + + + + + + + + `; + + const now = new Date(); + + pageData.forEach((event, index) => { + const absoluteIndex = startIndex + index + 1; + const eventTime = new Date(event.time); + const timeAgo = formatTimeAgo(eventTime, now); + const formattedTime = formatDateTime(eventTime); + + // Calculate duration (how long this IP was active) + // Since we reversed the array, index 0 is newest (current) + let duration = '—'; + if (index === 0 && startIndex === 0) { + // This is the most recent IP (currently active) + duration = 'Current'; + } else { + // Calculate duration from this event to the previous one (which is earlier in the reversed array) + const prevEventIndex = startIndex + index - 1; + if (prevEventIndex >= 0 && prevEventIndex < currentHistoryData.length) { + const prevEvent = currentHistoryData[prevEventIndex]; + const prevTime = new Date(prevEvent.time); + duration = formatDuration(prevTime - eventTime); + } + } + + tableHTML += ` + + + + + + + + `; + }); + + tableHTML += ` + +
#IP AddressChanged AtTime AgoDuration
${absoluteIndex}${event.ip}${formattedTime}${timeAgo}${duration}
+ `; + + container.innerHTML = tableHTML; + updatePaginationControls(); + } + + /** + * Update pagination button states + */ + function updatePaginationControls() { + const prevBtn = document.getElementById('prev-page'); + const nextBtn = document.getElementById('next-page'); + const pageInfo = document.getElementById('page-info'); + + prevBtn.disabled = currentPage <= 1; + nextBtn.disabled = currentPage >= totalPages; + pageInfo.textContent = `Page ${currentPage} of ${totalPages}`; + } + + /** + * Format date/time for display + */ + function formatDateTime(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; + } + + /** + * Format time ago (e.g., "2h ago", "3d ago") + */ + function formatTimeAgo(eventTime, now) { + const diff = now - eventTime; + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + const weeks = Math.floor(days / 7); + + if (seconds < 10) return 'just now'; + if (seconds < 60) return `${seconds}s ago`; + if (minutes < 60) return `${minutes}m ago`; + if (hours < 24) { + const mins = minutes % 60; + return mins > 0 ? `${hours}h ${mins}m ago` : `${hours}h ago`; + } + if (days < 7) { + const hrs = hours % 24; + return hrs > 0 ? `${days}d ${hrs}h ago` : `${days}d ago`; + } + const dys = days % 7; + return dys > 0 ? `${weeks}w ${dys}d ago` : `${weeks}w ago`; + } + + /** + * Format duration between two times in human-readable format + */ + function formatDuration(milliseconds) { + const seconds = Math.floor(milliseconds / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + const weeks = Math.floor(days / 7); + const months = Math.floor(days / 30); + const years = Math.floor(days / 365); + + if (years > 0) { + const remainingMonths = Math.floor((days % 365) / 30); + return remainingMonths > 0 ? `${years} year${years > 1 ? 's' : ''} ${remainingMonths} month${remainingMonths > 1 ? 's' : ''}` : `${years} year${years > 1 ? 's' : ''}`; + } + if (months > 0) { + const remainingDays = days % 30; + return remainingDays > 0 ? `${months} month${months > 1 ? 's' : ''} ${remainingDays} day${remainingDays > 1 ? 's' : ''}` : `${months} month${months > 1 ? 's' : ''}`; + } + if (weeks > 0) { + const remainingDays = days % 7; + return remainingDays > 0 ? `${weeks} week${weeks > 1 ? 's' : ''} ${remainingDays} day${remainingDays > 1 ? 's' : ''}` : `${weeks} week${weeks > 1 ? 's' : ''}`; + } + if (days > 0) { + const remainingHours = hours % 24; + return remainingHours > 0 ? `${days} day${days > 1 ? 's' : ''} ${remainingHours} hour${remainingHours > 1 ? 's' : ''}` : `${days} day${days > 1 ? 's' : ''}`; + } + if (hours > 0) { + const remainingMinutes = minutes % 60; + return remainingMinutes > 0 ? `${hours} hour${hours > 1 ? 's' : ''} ${remainingMinutes} minute${remainingMinutes > 1 ? 's' : ''}` : `${hours} hour${hours > 1 ? 's' : ''}`; + } + if (minutes > 0) { + return `${minutes} minute${minutes > 1 ? 's' : ''}`; + } + return `${seconds} second${seconds !== 1 ? 's' : ''}`; + } + + // Expose functions to global scope + window.openHistoryModal = openHistoryModal; + window.closeHistoryModal = closeHistoryModal; + window.previousPage = previousPage; + window.nextPage = nextPage; + // Initialize button icons and tooltips when DOM is ready document.addEventListener('DOMContentLoaded', function () { // Set refresh button icon if it exists @@ -363,5 +615,25 @@ // Listen for page visibility changes document.addEventListener('visibilitychange', handleVisibilityChange); + + // Close modal when clicking outside + const modal = document.getElementById('history-modal'); + if (modal) { + modal.addEventListener('click', function(e) { + if (e.target === modal) { + closeHistoryModal(); + } + }); + } + + // Close modal with ESC key + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape') { + const modal = document.getElementById('history-modal'); + if (modal && modal.classList.contains('active')) { + closeHistoryModal(); + } + } + }); }); })(); diff --git a/internal/server/ui/static/styles.css b/internal/server/ui/static/styles.css index bd11a811b..4b86fffbd 100644 --- a/internal/server/ui/static/styles.css +++ b/internal/server/ui/static/styles.css @@ -802,6 +802,275 @@ footer { animation: spin 3s linear infinite; } +/* ============================================ + History Modal + ============================================ */ + +/* Modal overlay - covers entire viewport */ +.modal-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + z-index: 9999; + align-items: center; + justify-content: center; + padding: 1rem; + animation: fadeIn 0.2s ease-out; +} + +.modal-overlay.active { + display: flex; +} + +/* Modal content card */ +.modal-content { + background: var(--background); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); + max-width: 900px; + width: 100%; + max-height: 90vh; + display: flex; + flex-direction: column; + animation: modalSlideIn 0.2s ease-out; +} + +[data-theme="dark"] .modal-content { + box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.5), 0 8px 10px -6px rgb(0 0 0 / 0.5); +} + +@keyframes modalSlideIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +/* Modal header */ +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.25rem 1.5rem; + border-bottom: 1px solid var(--border); +} + +.modal-header h2 { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: var(--foreground); + letter-spacing: -0.025em; +} + +.modal-close { + background: transparent; + border: none; + color: var(--muted-foreground); + font-size: 1.5rem; + line-height: 1; + cursor: pointer; + padding: 0.25rem; + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius); + transition: all 0.15s ease; +} + +.modal-close:hover { + background: var(--muted); + color: var(--foreground); +} + +/* Modal body */ +.modal-body { + padding: 0; + overflow-y: auto; + overflow-x: hidden; + flex: 1; + position: relative; +} + +#history-table-container { + padding: 1.5rem; + padding-top: 0; +} + +/* History table inside modal */ +.history-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +.history-table thead { + background: var(--background); + position: sticky; + top: 0; + z-index: 10; + box-shadow: 0 1px 0 var(--border); +} + +.history-table thead::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 1px; + background: var(--border); +} + +.history-table th { + text-align: left; + padding: 0.75rem 1rem; + font-weight: 500; + font-size: 0.875rem; + color: var(--foreground); + background: var(--muted); +} + +.history-table tbody tr { + border-bottom: 1px solid var(--border); +} + +.history-table tbody tr:last-child { + border-bottom: none; +} + +.history-table tbody tr:nth-child(even) { + background: var(--muted); +} + +.history-table td { + padding: 0.75rem 1rem; + color: var(--foreground); +} + +/* Specific column styling */ +.history-table .col-num { + width: 3rem; + text-align: center; + color: var(--muted-foreground); + font-weight: 500; +} + +.history-table .col-ip { + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + font-size: 0.8125rem; +} + +.history-table .col-time { + font-size: 0.8125rem; +} + +.history-table .col-ago, +.history-table .col-duration { + font-size: 0.8125rem; + color: var(--muted-foreground); +} + +.history-current-badge { + display: inline-block; + padding: 0.125rem 0.375rem; + background: var(--success-foreground); + color: var(--success); + border-radius: calc(var(--radius) - 2px); + font-size: 0.6875rem; + font-weight: 600; + border: 1px solid var(--success); +} + +/* Modal footer with pagination */ +.modal-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.5rem; + border-top: 1px solid var(--border); + gap: 1rem; +} + +.page-nav-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.5rem 1rem; + height: 2.25rem; + border-radius: var(--radius); + font-size: 0.875rem; + font-weight: 500; + transition: all 0.15s ease; + cursor: pointer; + border: 1px solid var(--border); + background: var(--background); + color: var(--foreground); + font-family: inherit; + white-space: nowrap; +} + +.page-nav-btn:hover:not(:disabled) { + background: var(--accent); + border-color: var(--gray-300); +} + +.page-nav-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.page-info { + font-size: 0.875rem; + color: var(--muted-foreground); + white-space: nowrap; +} + +/* History more button - clickable text link */ +.history-more-btn { + background: transparent; + border: none; + color: var(--muted-foreground); + cursor: pointer; + padding: 0; + font-size: 0.75rem; + font-style: italic; + font-family: inherit; + text-decoration: underline; + text-underline-offset: 2px; + transition: all 0.15s ease; +} + +.history-more-btn:hover { + color: var(--foreground); + text-decoration-color: var(--foreground); +} + +/* Empty state */ +.history-empty { + text-align: center; + padding: 3rem 1rem; + color: var(--muted-foreground); +} + +.history-empty svg { + width: 3rem; + height: 3rem; + margin: 0 auto 1rem; + opacity: 0.5; +} + /* ============================================ Responsive Design ============================================ */ @@ -916,7 +1185,7 @@ footer { justify-content: flex-end; } - /* Optimize Previous IPs display on mobile */ + /* Optimize History display on mobile */ .ip-badge { font-size: 0.6875rem; padding: 0.0625rem 0.25rem; @@ -929,13 +1198,47 @@ footer { white-space: nowrap; } + .history-more-btn { + font-size: 0.6875rem; + } + /* Ensure IP content doesn't wrap */ - td[data-label="Previous IPs"], + td[data-label="History"], td[data-label="Current IP"] { white-space: nowrap; overflow-x: auto; font-size: 0.75rem; } + + /* Modal responsive adjustments */ + .modal-content { + max-height: 95vh; + margin: 0.5rem; + } + + .modal-header { + padding: 1rem; + } + + #history-table-container { + padding: 1rem; + padding-top: 0; + } + + .modal-footer { + padding: 0.75rem 1rem; + flex-wrap: wrap; + } + + .history-table th, + .history-table td { + padding: 0.5rem 0.75rem; + font-size: 0.8125rem; + } + + .history-table .col-num { + width: 2rem; + } } /* Desktop improvements */ From b145094c431ff8ed8d3cfca7531483033cb430d8 Mon Sep 17 00:00:00 2001 From: Ehsan Shirvanian Date: Tue, 6 Jan 2026 11:29:58 -0500 Subject: [PATCH 09/12] remoe unrelated files --- .../2026-01-06-ip-history-modal-design.md | 208 ------------------ 1 file changed, 208 deletions(-) delete mode 100644 docs/plans/2026-01-06-ip-history-modal-design.md diff --git a/docs/plans/2026-01-06-ip-history-modal-design.md b/docs/plans/2026-01-06-ip-history-modal-design.md deleted file mode 100644 index c436430c4..000000000 --- a/docs/plans/2026-01-06-ip-history-modal-design.md +++ /dev/null @@ -1,208 +0,0 @@ -# IP Change History Modal - Design Document - -**Date:** 2026-01-06 -**Status:** Approved - -## Overview - -Add an IP change history viewer that displays all historical IP changes for a domain in a modal popup. Users can view comprehensive information about when IPs changed, how long each IP was active, and navigate through paginated history. - -## User Requirements - -- View complete IP change history for each domain -- Display in a modal popup (small page) -- Show all available information from history data -- Easy to access from the main table - -## Architecture & Data Flow - -### Data Embedding Strategy - -When the Go template renders `index.html`, each table row includes complete history data as a JSON data attribute: - -```html - -``` - -### User Interaction Flow - -1. User clicks the history icon button in a domain row -2. JavaScript reads the `data-history` attribute from that row -3. Parses the JSON to get all HistoryEvent entries (IP + Time) -4. Calculates duration between consecutive changes -5. Creates paginated table HTML (20 entries per page) -6. Shows modal overlay with the history table -7. User can navigate pages with prev/next buttons -8. User closes modal by clicking close button or outside the modal - -### No Network Calls Required - -All data is already embedded in the page from the initial Go template render. This keeps the feature fast and simple, following the existing architecture pattern where `/` returns fully rendered HTML with embedded data. - -## Modal Structure - -### Visual Layout - -- Semi-transparent dark backdrop (blocks interaction with main page) -- Centered white card with rounded corners -- Header: Domain name + close button -- Body: Paginated history table -- Footer: Page navigation (« Previous | Page X of Y | Next ») - -### HTML Structure - -```html - -``` - -## History Table Design - -### Table Columns - -| Column | Description | Example | -|--------|-------------|---------| -| # | Sequential number (newest = 1) | 1, 2, 3... | -| IP Address | The IP from HistoryEvent.IP | 192.168.1.1 | -| Changed At | Formatted timestamp | 2026-01-06 15:30:45 | -| Time Ago | Human-readable duration since change | 2h ago, 3d ago | -| Duration | How long this IP was active | 2h 15m, 3d 4h, — (current) | - -### Data Calculations - -**Time Ago:** -- Use similar logic to `GetDurationSinceSuccess()` -- Calculate from event time to now -- Format: "2h ago", "3d ago", "45s ago" - -**Duration:** -- Calculate difference between consecutive HistoryEvent timestamps -- For current IP (newest): show "—" or "Current" -- For previous IPs: `nextEvent.Time - currentEvent.Time` -- Format: "2h 15m", "3d", "45s" - -### Display Order - -History array is already antichronological (newest first): -- Page 1: entries 1-20 (most recent changes) -- Page 2: entries 21-40 -- etc. - -### Table Styling - -- Use existing CSS classes from `styles.css` (same table styling as main page) -- Zebra striping for readability -- Monospace font for IP addresses -- Responsive: stack columns on mobile if needed - -## Modal UI Design - -### Visual Design - -- **Overlay**: Semi-transparent black background (`rgba(0,0,0,0.5)`) -- **Modal card**: Max-width 800px, white background (adapts to dark theme) -- **Shadow**: Elevated shadow to appear above main content -- **Animation**: Fade-in overlay + scale-up card (0.2s ease-out) -- **Responsive**: Full-screen on mobile with padding - -### Interaction Behavior - -- Click history icon → Modal appears with fade-in -- Click close button (×) → Modal disappears -- Click outside modal (on overlay) → Modal closes -- ESC key → Modal closes -- Pagination buttons disabled when on first/last page - -### History Icon Button - -Add a small clock/history icon to each row in the main table, positioned as a new dedicated column or integrated into an existing column. - -## Implementation Plan - -### Files to Modify - -1. **`/internal/server/ui/index.html`** (Go template) - - Add history icon button to each table row - - Embed history data as JSON in `data-history` attribute on each row - - Add modal HTML structure (hidden by default) - -2. **`/internal/server/ui/static/app.js`** (JavaScript) - - Add `openHistoryModal(rowElement)` function - - Add `renderHistoryTable(historyData, page)` function - - Add `formatDuration(seconds)` helper - - Add `formatTimeAgo(timestamp)` helper - - Add pagination logic - - Add modal close handlers (close button, overlay click, ESC key) - -3. **`/internal/server/ui/static/styles.css`** (Styling) - - Add `.modal-overlay` styles - - Add `.modal-content` styles - - Add `.modal-header`, `.modal-body`, `.modal-footer` styles - - Add `.history-table` styles - - Add animation keyframes for fade-in - - Add dark theme support for modal - -### No Backend Changes Required - -The history data is already available in the records (from `internal/models/history.go`), so no new endpoints or database queries are needed. - -### Implementation Steps - -1. Add CSS for modal styling -2. Add modal HTML structure to template -3. Add history icon to table rows with embedded data -4. Add JavaScript for modal logic and pagination -5. Test with domains that have varying amounts of history (0, 1, 5, 50+ changes) - -## Testing Scenarios - -- Domain with no history (0 changes) -- Domain with single IP (1 entry) -- Domain with few changes (2-5 entries) -- Domain with many changes (50+ entries requiring pagination) -- Mobile responsiveness -- Dark theme compatibility -- Keyboard navigation (ESC to close) -- Accessibility (ARIA labels, focus management) - -## Design Decisions - -### Why embed data instead of API endpoint? -- Follows existing architecture pattern (server-rendered templates) -- No additional server roundtrips -- Simpler implementation -- Data is already in memory when rendering the page - -### Why 20 entries per page? -- Balance between showing enough data and keeping modal scrollable -- Matches common pagination patterns -- Can be adjusted if needed - -### Why modal instead of inline expansion? -- Better for showing comprehensive information -- Doesn't disrupt the main table layout -- Easier to make responsive on mobile -- User explicitly requested "small page" / "pop up" - -## Future Enhancements (Out of Scope) - -- Export history to CSV/JSON -- Search/filter by date range -- Chart visualization of IP changes over time -- Comparison between multiple domains From ee8f7e314ef75b08dea79c90abf3468560068503 Mon Sep 17 00:00:00 2001 From: Ehsan Shirvanian Date: Tue, 6 Jan 2026 11:47:08 -0500 Subject: [PATCH 10/12] fix linter warnings --- internal/records/html.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/records/html.go b/internal/records/html.go index 6feb565cb..069afcf20 100644 --- a/internal/records/html.go +++ b/internal/records/html.go @@ -77,8 +77,11 @@ func (r *Record) HTML(now time.Time) models.HTMLRow { const maxPreviousIPs = 2 for i, previousIP := range previousIPs { if i == maxPreviousIPs { - previousIPsHTML = append(previousIPsHTML, - fmt.Sprintf(``, len(previousIPs)-i)) + moreButton := fmt.Sprintf( + ``, + len(previousIPs)-i) + previousIPsHTML = append(previousIPsHTML, moreButton) break } previousIPsHTML = append(previousIPsHTML, From 07a7f434ebe6d4623d56efc058336e760c01297f Mon Sep 17 00:00:00 2001 From: Ehsan Shirvanian Date: Tue, 6 Jan 2026 23:28:14 -0500 Subject: [PATCH 11/12] time ago column removed from history page --- internal/server/ui/static/app.js | 29 ---------------------------- internal/server/ui/static/styles.css | 1 - 2 files changed, 30 deletions(-) diff --git a/internal/server/ui/static/app.js b/internal/server/ui/static/app.js index 82d6274f8..f3cec3c3b 100644 --- a/internal/server/ui/static/app.js +++ b/internal/server/ui/static/app.js @@ -430,7 +430,6 @@ # IP Address Changed At - Time Ago Duration @@ -442,7 +441,6 @@ pageData.forEach((event, index) => { const absoluteIndex = startIndex + index + 1; const eventTime = new Date(event.time); - const timeAgo = formatTimeAgo(eventTime, now); const formattedTime = formatDateTime(eventTime); // Calculate duration (how long this IP was active) @@ -466,7 +464,6 @@ ${absoluteIndex} ${event.ip} ${formattedTime} - ${timeAgo} ${duration} `; @@ -507,32 +504,6 @@ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; } - /** - * Format time ago (e.g., "2h ago", "3d ago") - */ - function formatTimeAgo(eventTime, now) { - const diff = now - eventTime; - const seconds = Math.floor(diff / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - const weeks = Math.floor(days / 7); - - if (seconds < 10) return 'just now'; - if (seconds < 60) return `${seconds}s ago`; - if (minutes < 60) return `${minutes}m ago`; - if (hours < 24) { - const mins = minutes % 60; - return mins > 0 ? `${hours}h ${mins}m ago` : `${hours}h ago`; - } - if (days < 7) { - const hrs = hours % 24; - return hrs > 0 ? `${days}d ${hrs}h ago` : `${days}d ago`; - } - const dys = days % 7; - return dys > 0 ? `${weeks}w ${dys}d ago` : `${weeks}w ago`; - } - /** * Format duration between two times in human-readable format */ diff --git a/internal/server/ui/static/styles.css b/internal/server/ui/static/styles.css index 4b86fffbd..a965d1ab6 100644 --- a/internal/server/ui/static/styles.css +++ b/internal/server/ui/static/styles.css @@ -976,7 +976,6 @@ footer { font-size: 0.8125rem; } -.history-table .col-ago, .history-table .col-duration { font-size: 0.8125rem; color: var(--muted-foreground); From bc1fda92459a1d3d9baede89f44b7d35cb74b31a Mon Sep 17 00:00:00 2001 From: Ehsan Shirvanian Date: Tue, 6 Jan 2026 23:41:08 -0500 Subject: [PATCH 12/12] add app icon to the header --- internal/server/ui/index.html | 5 ++++- internal/server/ui/static/styles.css | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/internal/server/ui/index.html b/internal/server/ui/index.html index a2da3e81b..bfc73a030 100644 --- a/internal/server/ui/index.html +++ b/internal/server/ui/index.html @@ -17,7 +17,10 @@
-

DDNS Updater

+
+ DDNS Updater Icon +

DDNS Updater

+
{{.TotalDomains}} diff --git a/internal/server/ui/static/styles.css b/internal/server/ui/static/styles.css index a965d1ab6..b4dd52123 100644 --- a/internal/server/ui/static/styles.css +++ b/internal/server/ui/static/styles.css @@ -157,6 +157,18 @@ a:hover { flex-wrap: wrap; } +.header-title-container { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.header-icon { + width: 2rem; + height: 2rem; + flex-shrink: 0; +} + .header-title { font-size: 1.25rem; font-weight: 600; @@ -1080,6 +1092,11 @@ footer { display: none; } + .header-icon { + width: 1.75rem; + height: 1.75rem; + } + .header-title { font-size: 1.125rem; }