about/internal/plugins/services.go

613 lines
16 KiB
Go

package plugins
import (
"context"
"fmt"
"html/template"
"net/http"
"sort"
"strings"
"sync"
"time"
"github.com/Alexander-D-Karpov/about/internal/storage"
"github.com/Alexander-D-Karpov/about/internal/stream"
)
type ServicesPlugin struct {
storage *storage.Storage
hub *stream.Hub
serviceStatuses map[string]ServiceStatus
lastCheck time.Time
mutex sync.RWMutex
httpClient *http.Client
}
type ServiceStatus struct {
Name string `json:"name"`
URL string `json:"url"`
Status string `json:"status"` // "online" | "offline" | "unknown"
ResponseTime int64 `json:"response_time"`
Description string `json:"description"`
Icon string `json:"icon"`
LastChecked time.Time `json:"last_checked"`
StatusCode int `json:"status_code"`
}
func NewServicesPlugin(storage *storage.Storage, hub *stream.Hub) *ServicesPlugin {
return &ServicesPlugin{
storage: storage,
hub: hub,
serviceStatuses: make(map[string]ServiceStatus),
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (p *ServicesPlugin) Name() string { return "services" }
func (p *ServicesPlugin) Render(ctx context.Context) (string, error) {
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}
config := p.storage.GetPluginConfig(p.Name())
settings := config.Settings
services, ok := settings["services"].([]interface{})
if !ok || len(services) == 0 {
return p.renderNoServices(), nil
}
sectionTitle := p.getConfigValue(settings, "ui.sectionTitle", "Local Services")
showStatus := p.getConfigBool(settings, "ui.showStatus", true)
showResponseTime := p.getConfigBool(settings, "ui.showResponseTime", true)
compact := p.getConfigBool(settings, "ui.compact", false)
sortBy := p.getConfigValue(settings, "ui.sort", "configured") // "configured" | "status" | "latency"
p.mutex.RLock()
lastUpdated := p.lastCheck
p.mutex.RUnlock()
lastUpdatedText := "never"
stale := true
if !lastUpdated.IsZero() {
lastUpdatedText = p.formatTimeAgo(lastUpdated)
stale = time.Since(lastUpdated) > 5*time.Minute
}
const tmpl = `
<section class="services-section section plugin {{if .Compact}}is-compact{{end}}" data-w="2" aria-labelledby="services-title" {{if .Stale}}data-stale="true"{{end}}>
<header class="plugin-header services-header">
<h3 id="services-title" class="plugin-title">{{.SectionTitle}}</h3>
<div class="services-header__meta">
<span class="services-updated" aria-live="polite" title="Last update">{{.LastUpdatedText}}</span>
</div>
</header>
<div class="plugin__inner">
<div class="services-grid" role="list">
{{range .Services}}
<article class="service-item" role="listitem"
data-url="{{.URL}}" data-status="{{.Status}}" data-rt="{{.ResponseTime}}" data-code="{{.StatusCode}}">
{{if .URL}}
<a class="card-overlay" href="{{.URL}}" target="_blank" rel="noopener noreferrer" aria-label="Open {{.Name}}"></a>
{{end}}
<div class="service-head">
<span class="service-icon" aria-hidden="true">
{{- if .IsImage -}}
<img src="{{.Icon}}" alt="" loading="lazy" decoding="async" />
{{- else if .IsInlineSVG -}}
{{.IconHTML}}
{{- else if .Icon -}}
{{.Icon}}
{{- else -}}
⚙️
{{- end -}}
</span>
<div class="service-id">
{{if .URL}}
<span class="service-title service-title-link">{{.Name}}</span>
{{else}}
<span class="service-title">{{.Name}}</span>
{{end}}
{{if $.ShowStatus}}
<span class="service-statuschip">
<span class="status-dot {{if eq .Status "online"}}status-online{{else if eq .Status "offline"}}status-offline{{end}}" aria-hidden="true"></span>
<span class="status-label">{{.StatusText}}</span>
</span>
{{end}}
</div>
</div>
{{if .Description}}
<p class="service-description">{{.Description}}</p>
{{end}}
<div class="service-stats">
{{if and $.ShowResponseTime .ResponseTime}}
<span class="chip chip-rt">{{.ResponseTime}}ms</span>
{{end}}
{{if .StatusCode}}
<span class="chip chip-code" data-code="{{.StatusCode}}">{{.StatusCode}}</span>
{{end}}
{{if .LastCheckedText}}
<span class="chip chip-last">{{.LastCheckedText}}</span>
{{end}}
</div>
</article>
{{end}}
</div>
<footer class="services-summary" aria-label="Summary">
<div class="summary-item">
<span class="summary-count online-count">{{.OnlineCount}}</span>
<span class="summary-label">Online</span>
</div>
<div class="summary-item">
<span class="summary-count offline-count">{{.OfflineCount}}</span>
<span class="summary-label">Offline</span>
</div>
<div class="summary-item">
<span class="summary-count total-count">{{.TotalCount}}</span>
<span class="summary-label">Total</span>
</div>
</footer>
</div>
</section>`
type serviceData struct {
Name string
URL string
Icon string
IconHTML template.HTML
IsImage bool
IsInlineSVG bool
Description string
Status string
StatusText string
ResponseTime int64
StatusCode int
LastChecked time.Time
LastCheckedText string
}
var list []serviceData
onlineCount := 0
offlineCount := 0
p.mutex.RLock()
for _, raw := range services {
m, ok := raw.(map[string]interface{})
if !ok {
continue
}
name := p.getStringFromMap(m, "name", "Service")
url := p.getStringFromMap(m, "url", "")
iconStr := p.getIconString(m) // <— use new helper
description := p.getStringFromMap(m, "description", "")
isImg := looksLikeImagePath(iconStr)
isSVG := false
var iconHTML template.HTML
if !isImg && isInlineSVGMarkup(iconStr) {
isSVG = true
iconHTML = template.HTML(iconStr) // admin-only content; treated as trusted
}
st := p.serviceStatuses[name]
statusText := "Unknown"
switch st.Status {
case "online":
statusText = "Online"
onlineCount++
case "offline":
statusText = "Offline"
offlineCount++
}
lastCheckedText := ""
if !st.LastChecked.IsZero() {
lastCheckedText = p.formatTimeAgo(st.LastChecked)
}
list = append(list, serviceData{
Name: name,
URL: url,
Icon: iconStr,
IconHTML: iconHTML,
IsImage: isImg,
IsInlineSVG: isSVG,
Description: description,
Status: st.Status,
StatusText: statusText,
ResponseTime: st.ResponseTime,
StatusCode: st.StatusCode,
LastChecked: st.LastChecked,
LastCheckedText: lastCheckedText,
})
}
p.mutex.RUnlock()
switch sortBy {
case "status":
sort.SliceStable(list, func(i, j int) bool {
pri := func(s string) int {
switch s {
case "online":
return 0
case "unknown":
return 1
case "offline":
return 2
default:
return 3
}
}
pi, pj := pri(list[i].Status), pri(list[j].Status)
if pi != pj {
return pi < pj
}
return list[i].Name < list[j].Name
})
case "latency":
sort.SliceStable(list, func(i, j int) bool {
ri := list[i].ResponseTime
rj := list[j].ResponseTime
if ri == 0 && rj != 0 {
return false
}
if rj == 0 && ri != 0 {
return true
}
if ri == rj {
return list[i].Name < list[j].Name
}
return ri < rj
})
}
data := struct {
SectionTitle string
Services []serviceData
ShowStatus bool
ShowResponseTime bool
OnlineCount int
OfflineCount int
TotalCount int
LastUpdatedText string
Stale bool
Compact bool
}{
SectionTitle: sectionTitle,
Services: list,
ShowStatus: showStatus,
ShowResponseTime: showResponseTime,
OnlineCount: onlineCount,
OfflineCount: offlineCount,
TotalCount: len(list),
LastUpdatedText: lastUpdatedText,
Stale: stale,
Compact: compact,
}
t, err := template.New("services").Parse(tmpl)
if err != nil {
return "", err
}
var buf strings.Builder
if err := t.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
func (p *ServicesPlugin) renderNoServices() string {
return `<section class="services-section section plugin" data-w="2" aria-labelledby="services-title">
<header class="plugin-header services-header">
<h3 id="services-title" class="plugin-title">Services</h3>
</header>
<div class="plugin__inner">
<div class="no-services">
<p class="text-muted">No services configured</p>
<p class="text-muted">Add services in the admin panel to monitor their status</p>
</div>
</div>
</section>`
}
func (p *ServicesPlugin) UpdateData(context.Context) error {
if time.Since(p.lastCheck) < 1*time.Minute {
return nil
}
config := p.storage.GetPluginConfig(p.Name())
services, ok := config.Settings["services"].([]interface{})
if !ok {
return nil
}
var wg sync.WaitGroup
for _, service := range services {
serviceMap, ok := service.(map[string]interface{})
if !ok {
continue
}
name := p.getStringFromMap(serviceMap, "name", "")
url := p.getStringFromMap(serviceMap, "url", "")
description := p.getStringFromMap(serviceMap, "description", "")
icon := p.getStringFromMap(serviceMap, "icon", "")
if name == "" || url == "" {
continue
}
wg.Add(1)
go func(serviceName, serviceURL, desc, icn string) {
defer wg.Done()
p.checkServiceStatus(serviceName, serviceURL, desc, icn)
}(name, url, description, icon)
}
wg.Wait()
p.lastCheck = time.Now()
p.broadcastStatusUpdate()
return nil
}
func (p *ServicesPlugin) checkServiceStatus(name, url, description, icon string) {
start := time.Now()
status := ServiceStatus{
Name: name,
URL: url,
Status: "unknown",
Description: description,
Icon: icon,
LastChecked: time.Now(),
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
status.Status = "offline"
p.updateServiceStatus(name, status)
return
}
req = req.WithContext(context.Background())
req.Header.Set("User-Agent", "AboutPage-ServiceMonitor/1.0")
req.Header.Set("Accept", "*/*")
resp, err := p.httpClient.Do(req)
if err != nil {
status.Status = "offline"
status.ResponseTime = time.Since(start).Milliseconds()
} else {
_ = resp.Body.Close()
status.ResponseTime = time.Since(start).Milliseconds()
status.StatusCode = resp.StatusCode
if resp.StatusCode >= 200 && resp.StatusCode < 400 {
status.Status = "online"
} else if resp.StatusCode >= 400 && resp.StatusCode < 600 {
status.Status = "offline"
} else {
status.Status = "unknown"
}
}
p.updateServiceStatus(name, status)
}
func (p *ServicesPlugin) updateServiceStatus(name string, status ServiceStatus) {
p.mutex.Lock()
oldStatus := p.serviceStatuses[name]
p.serviceStatuses[name] = status
p.mutex.Unlock()
if oldStatus.Status != status.Status {
p.hub.Broadcast("service_status_update", map[string]interface{}{
"name": name,
"status": status.Status,
"response_time": status.ResponseTime,
"status_code": status.StatusCode,
"timestamp": time.Now().Unix(),
})
}
}
func (p *ServicesPlugin) broadcastStatusUpdate() {
p.mutex.RLock()
onlineCount := 0
offlineCount := 0
totalCount := len(p.serviceStatuses)
statusMap := make(map[string]interface{})
for name, status := range p.serviceStatuses {
if status.Status == "online" {
onlineCount++
} else if status.Status == "offline" {
offlineCount++
}
statusMap[name] = map[string]interface{}{
"status": status.Status,
"response_time": status.ResponseTime,
"status_code": status.StatusCode,
}
}
p.mutex.RUnlock()
p.hub.Broadcast("services_summary_update", map[string]interface{}{
"online_count": onlineCount,
"offline_count": offlineCount,
"total_count": totalCount,
"services": statusMap,
"timestamp": time.Now().Unix(),
})
}
func (p *ServicesPlugin) formatTimeAgo(t time.Time) string {
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
default:
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
}
}
func (p *ServicesPlugin) GetSettings() map[string]interface{} {
config := p.storage.GetPluginConfig(p.Name())
return config.Settings
}
func (p *ServicesPlugin) SetSettings(settings map[string]interface{}) error {
config := p.storage.GetPluginConfig(p.Name())
config.Settings = settings
if err := p.storage.SetPluginConfig(p.Name(), config); err != nil {
return err
}
p.lastCheck = time.Time{}
p.hub.Broadcast("plugin_update", map[string]interface{}{
"plugin": p.Name(),
"action": "settings_changed",
})
return nil
}
func (p *ServicesPlugin) getConfigValue(settings map[string]interface{}, key string, def string) string {
keys := strings.Split(key, ".")
cur := settings
for i, k := range keys {
if i == len(keys)-1 {
if v, ok := cur[k].(string); ok {
return v
}
return def
}
next, ok := cur[k].(map[string]interface{})
if !ok {
return def
}
cur = next
}
return def
}
func (p *ServicesPlugin) getConfigBool(settings map[string]interface{}, key string, def bool) bool {
keys := strings.Split(key, ".")
cur := settings
for i, k := range keys {
if i == len(keys)-1 {
if v, ok := cur[k].(bool); ok {
return v
}
return def
}
next, ok := cur[k].(map[string]interface{})
if !ok {
return def
}
cur = next
}
return def
}
func (p *ServicesPlugin) getStringFromMap(m map[string]interface{}, key, def string) string {
if v, ok := m[key].(string); ok {
return v
}
return def
}
func (p *ServicesPlugin) RenderText(ctx context.Context) (string, error) {
p.mutex.RLock()
online := 0
offline := 0
total := len(p.serviceStatuses)
for _, st := range p.serviceStatuses {
switch st.Status {
case "online":
online++
case "offline":
offline++
}
}
p.mutex.RUnlock()
if total == 0 {
return "Services: No services configured", nil
}
return fmt.Sprintf("Services: %d online, %d offline (%d total)", online, offline, total), nil
}
func (p *ServicesPlugin) getIconString(m map[string]interface{}) string {
// Accept several common keys from admin payloads
for _, k := range []string{"icon", "iconPath", "icon_url", "logo", "image"} {
if v, ok := m[k].(string); ok && strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func looksLikeImagePath(s string) bool {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
return false
}
if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") ||
strings.HasPrefix(s, "/") || strings.HasPrefix(s, "data:image/") {
return true
}
for _, ext := range []string{".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".ico", ".bmp"} {
if strings.HasSuffix(s, ext) {
return true
}
}
return false
}
func isInlineSVGMarkup(s string) bool {
trim := strings.TrimSpace(s)
// Simple check; admin-provided content only
return strings.HasPrefix(trim, "<svg") && strings.Contains(trim, "</svg>")
}
func (p *ServicesPlugin) GetMetrics() map[string]interface{} {
p.mutex.RLock()
defer p.mutex.RUnlock()
onlineCount := 0
offlineCount := 0
totalResponseTime := int64(0)
for _, status := range p.serviceStatuses {
switch status.Status {
case "online":
onlineCount++
case "offline":
offlineCount++
}
totalResponseTime += status.ResponseTime
}
avgResponseTime := 0.0
if len(p.serviceStatuses) > 0 {
avgResponseTime = float64(totalResponseTime) / float64(len(p.serviceStatuses))
}
return map[string]interface{}{
"online_count": onlineCount,
"offline_count": offlineCount,
"total_count": len(p.serviceStatuses),
"avg_response_time_ms": avgResponseTime,
}
}