feat: phase 1 MVP — sections, services, drag-drop, renderers

Backend: migration 0002 (sections/items/urls/settings), CRUD +
fractional reorder, icon upload, settings, export/import, cached
status pings. Handler tests cover reorder, URL scheme validation,
settings round-trip.

Frontend: shared board store over the generated client, dnd-kit
layer (same-section, cross-section, section reorder persisted),
URL chooser popover, add/edit service dialog, settings dialog,
Bento + Cards renderers behind the shared seam, dark/light theme.

Generated with [Devin](https://devin.ai)

Authored By: TDvorak <[email protected]>
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas Dvorak committed 2026-09-17 20:29:22 +02:00
1 parent b60821350a
commit 6004105038
37 files changed
+4187 -66

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
package api
import (
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// statusEntry is one cached reachability probe result.
type statusEntry struct {
Status string `json:"status"` // "up" | "down"
LatencyMs int64 `json:"latencyMs"`
CheckedAt time.Time `json:"checkedAt"`
}
// statusCache is a TTL map of url -> probe result. Pings are HEAD requests;
// any HTTP response < 500 means the host answered, so the service is up.
// ponytail: pings the primary URL only — extend to all urls if demanded.
type statusCache struct {
mu sync.Mutex
ttl time.Duration
entries map[string]statusEntry
client *http.Client
}
func newStatusCache(ttl time.Duration) *statusCache {
return &statusCache{
ttl: ttl,
entries: map[string]statusEntry{},
client: &http.Client{Timeout: 4 * time.Second},
}
}
// check returns the cached probe if fresh, otherwise pings synchronously.
func (sc *statusCache) check(url string) statusEntry {
sc.mu.Lock()
if e, ok := sc.entries[url]; ok && time.Since(e.CheckedAt) < sc.ttl {
sc.mu.Unlock()
return e
}
sc.mu.Unlock()
e := sc.ping(url)
sc.mu.Lock()
sc.entries[url] = e
sc.mu.Unlock()
return e
}
func (sc *statusCache) ping(url string) statusEntry {
start := time.Now()
e := statusEntry{Status: "down", CheckedAt: start}
req, err := http.NewRequest(http.MethodHead, url, nil)
if err != nil {
return e
}
resp, err := sc.client.Do(req)
e.LatencyMs = time.Since(start).Milliseconds()
if err != nil {
return e
}
defer resp.Body.Close()
if resp.StatusCode < 500 {
e.Status = "up"
}
return e
}
// getStatus probes the primary URL of every service item, in parallel,
// and returns a map keyed by item id.
func (s *Server) getStatus(c *gin.Context) {
rows, err := s.db.Query(
`SELECT i.id, u.url FROM items i
JOIN urls u ON u.item_id = i.id
WHERE i.kind = 'service'
AND u.position = (SELECT MIN(position) FROM urls WHERE item_id = i.id)`)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
type target struct{ id, url string }
var targets []target
for rows.Next() {
var t target
if err := rows.Scan(&t.id, &t.url); err != nil {
rows.Close()
fail(c, http.StatusInternalServerError, err.Error())
return
}
targets = append(targets, t)
}
rows.Close()
out := make(map[string]statusEntry, len(targets))
var mu sync.Mutex
var wg sync.WaitGroup
for _, t := range targets {
wg.Add(1)
go func(t target) {
defer wg.Done()
e := s.status.check(t.url)
mu.Lock()
out[t.id] = e
mu.Unlock()
}(t)
}
wg.Wait()
c.JSON(http.StatusOK, out)
}