Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions cmd/ddns-updater/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -368,13 +368,13 @@ 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 {
return noop.New("server"), nil
}
serverLogger := logger.New(log.SetComponent("http server"))
return server.New(ctx, config.ListeningAddress, config.RootURL,
db, serverLogger, updaterService)
db, serverLogger, updaterService, ipGetter)
}
11 changes: 10 additions & 1 deletion internal/models/html.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,6 +21,8 @@ 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
HistoryJSON string // JSON-encoded history data for modal
}
234 changes: 213 additions & 21 deletions internal/records/html.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package records

import (
"encoding/json"
"fmt"
"strings"
"time"
Expand All @@ -9,60 +10,251 @@ import (
"github.com/qdm12/ddns-updater/internal/models"
)

const (
hoursPerDay = 24
daysPerWeek = 7
minutesPerHour = 60
)

const (
svgStart = `<svg width="14" height="14" viewBox="0 0 24 24" ` +
`fill="none" stroke="currentColor" stroke-width="2" ` +
`stroke-linecap="round" stroke-linejoin="round">`
svgStartSpin = `<svg class="animate-spin" width="14" height="14" ` +
`viewBox="0 0 24 24" fill="none" stroke="currentColor" ` +
`stroke-width="2" stroke-linecap="round" stroke-linejoin="round">`
svgEnd = `</svg>`
)

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 = "No IP change for " + 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(
`<div class="status-container"><div class="status-inline">%s`+
`<span class="status-time">%s</span></div></div>`,
statusBadge,
lastUpdate)
}

// Format current IP
currentIP := r.History.GetCurrentIP()
if currentIP.IsValid() {
row.CurrentIP = `<a href="https://ipinfo.io/` + currentIP.String() + `">` + currentIP.String() + "</a>"
} 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))
moreButton := fmt.Sprintf(
`<button class="history-more-btn" onclick="openHistoryModal(this)" `+
`title="View full IP change history">+%d more</button>`,
len(previousIPs)-i)
previousIPsHTML = append(previousIPsHTML, moreButton)
break
}
previousIPsStr = append(previousIPsStr, previousIP.String())
previousIPsHTML = append(previousIPsHTML,
fmt.Sprintf(`<span class="ip-badge">%s</span>`, previousIP.String()))
}
row.PreviousIPs = strings.Join(previousIPsStr, ", ")
row.PreviousIPs = strings.Join(previousIPsHTML, " ")
}

// Serialize history to JSON for modal
historyJSON, err := json.Marshal(r.History)
if err == nil {
row.HistoryJSON = string(historyJSON)
} else {
row.HistoryJSON = "[]"
}

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 < hoursPerDay*time.Hour {
hours := int(duration.Hours())
minutes := int(duration.Minutes()) % minutesPerHour
if minutes > 0 {
return fmt.Sprintf("%dh %dm ago", hours, minutes)
}
return fmt.Sprintf("%dh ago", hours)
}

// Days (less than 7 days)
if duration < daysPerWeek*hoursPerDay*time.Hour {
days := int(duration.Hours() / hoursPerDay)
hours := int(duration.Hours()) % hoursPerDay
if hours > 0 {
return fmt.Sprintf("%dd %dh ago", days, hours)
}
return fmt.Sprintf("%dd ago", days)
}

// Weeks
weeks := int(duration.Hours() / hoursPerDay / daysPerWeek)
days := int(duration.Hours()/hoursPerDay) % daysPerWeek
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 `<span class="success">Success</span>`
return fmt.Sprintf(`<span class="badge badge-success%s"%s>`, tooltipClass, tooltipAttr) +
svgStart +
`<polyline points="20 6 9 17 4 12"></polyline>` +
svgEnd +
`Success</span>`
case constants.FAIL:
return `<span class="error">Failure</span>`
return fmt.Sprintf(`<span class="badge badge-error%s"%s>`, tooltipClass, tooltipAttr) +
svgStart +
`<circle cx="12" cy="12" r="10"></circle>` +
`<line x1="15" y1="9" x2="9" y2="15"></line>` +
`<line x1="9" y1="9" x2="15" y2="15"></line>` +
svgEnd +
`Failed</span>`
case constants.UPTODATE:
return `<span class="uptodate">Up to date</span>`
return fmt.Sprintf(`<span class="badge badge-success%s"%s>`, tooltipClass, tooltipAttr) +
svgStart +
`<polyline points="20 6 9 17 4 12"></polyline>` +
svgEnd +
`Up to date</span>`
case constants.UPDATING:
return `<span class="updating">Updating</span>`
return fmt.Sprintf(`<span class="badge badge-info%s"%s>`, tooltipClass, tooltipAttr) +
svgStartSpin +
`<path d="M21 12a9 9 0 11-6.219-8.56"></path>` +
svgEnd +
`Updating</span>`
case constants.UNSET:
return `<span class="unset">Unset</span>`
return fmt.Sprintf(`<span class="badge badge-warning%s"%s>`, tooltipClass, tooltipAttr) +
svgStart +
`<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"></path>` +
`<line x1="12" y1="9" x2="12" y2="13"></line>` +
`<line x1="12" y1="17" x2="12.01" y2="17"></line>` +
svgEnd +
`Unset</span>`
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 `<span class="ip-version-badge ipv4-badge">` +
svgStart +
`<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>` +
`<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>` +
`<line x1="6" y1="6" x2="6.01" y2="6"></line>` +
`<line x1="6" y1="18" x2="6.01" y2="18"></line>` +
svgEnd +
`IPv4</span>`
case "ipv6":
return `<span class="ip-version-badge ipv6-badge">` +
svgStart +
`<polygon points="12 2 2 7 12 12 22 7 12 2"></polygon>` +
`<polyline points="2 17 12 22 22 17"></polyline>` +
`<polyline points="2 12 12 17 22 12"></polyline>` +
svgEnd +
`IPv6</span>`
case "ipv4 or ipv6":
return `<span class="ip-version-badge ipv4v6-badge">` +
svgStart +
`<circle cx="12" cy="12" r="10"></circle>` +
`<line x1="2" y1="12" x2="22" y2="12"></line>` +
`<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>` +
svgEnd +
`IPv4/6</span>`
default:
return ipVersion
}
}

// escapeHTML escapes special HTML characters to prevent XSS.
func escapeHTML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\"", "&quot;")
s = strings.ReplaceAll(s, "'", "&#39;")
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 ""
}
}
8 changes: 5 additions & 3 deletions internal/server/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type handlers struct {
// Objects
db Database
runner UpdateForcer
ipGetter PublicIPFetcher
indexTemplate *template.Template
// Mockable functions
timeNow func() time.Time
Expand All @@ -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"))

Expand All @@ -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()
Expand Down
Loading
Loading