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

+116 -8
View File
@@ -2,31 +2,139 @@
package api
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"net/url"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// Server holds handler dependencies: DB, icon dir, status cache.
type Server struct {
db *sql.DB
iconsDir string
status *statusCache
}
// NewRouter builds the HTTP handler: zap access log, recovery, /api routes.
func NewRouter(logger *zap.Logger, db *sql.DB) *gin.Engine {
func NewRouter(logger *zap.Logger, db *sql.DB, iconsDir string) *gin.Engine {
s := &Server{db: db, iconsDir: iconsDir, status: newStatusCache(60 * time.Second)}
r := gin.New()
r.Use(accessLog(logger), gin.Recovery())
v1 := r.Group("/api")
v1.GET("/healthz", func(c *gin.Context) {
if err := db.PingContext(c.Request.Context()); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "db unreachable"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
v1.GET("/healthz", s.healthz)
v1.GET("/sections", s.listSections)
v1.POST("/sections", s.createSection)
v1.PATCH("/sections/:id", s.updateSection)
v1.DELETE("/sections/:id", s.deleteSection)
v1.POST("/sections/reorder", s.reorderSection)
v1.POST("/items", s.createItem)
v1.PATCH("/items/:id", s.updateItem)
v1.DELETE("/items/:id", s.deleteItem)
v1.POST("/items/reorder", s.reorderItem)
v1.POST("/items/:id/icon", s.uploadIcon)
v1.GET("/icons/:file", s.getIcon)
v1.GET("/status", s.getStatus)
v1.GET("/settings", s.getSettings)
v1.PUT("/settings", s.putSettings)
v1.GET("/export", s.exportBoard)
v1.POST("/import", s.importBoard)
return r
}
// ---------- shared types (mirror openapi.yaml) ----------
// URL is one launch target of an item (local, external, custom label).
type URL struct {
ID string `json:"id"`
URL string `json:"url"`
Label string `json:"label"`
Position float64 `json:"position"`
}
// URLInput is a URL as accepted on create/update (id/position assigned).
type URLInput struct {
URL string `json:"url"`
Label string `json:"label"`
}
// Item is a board cell: a service tile today, a widget in Phase 2.
type Item struct {
ID string `json:"id"`
SectionID string `json:"sectionId"`
Kind string `json:"kind"`
Name string `json:"name"`
Icon string `json:"icon"`
Position float64 `json:"position"`
Config *json.RawMessage `json:"config"`
URLs []URL `json:"urls"`
}
// Section is a named, collapsible group of items.
type Section struct {
ID string `json:"id"`
Name string `json:"name"`
Position float64 `json:"position"`
Collapsed bool `json:"collapsed"`
Items []Item `json:"items"`
}
// ---------- helpers ----------
func newID(prefix string) string {
b := make([]byte, 8)
_, _ = rand.Read(b)
return prefix + "_" + hex.EncodeToString(b)
}
// midpoint returns a fractional position between two optional neighbors.
func midpoint(before, after *float64) float64 {
switch {
case before == nil && after == nil:
return 1024
case before == nil:
return *after - 1024
case after == nil:
return *before + 1024
default:
return (*before + *after) / 2
}
}
func fail(c *gin.Context, code int, msg string) {
c.AbortWithStatusJSON(code, gin.H{"error": msg})
}
// validURL enforces http(s) launch targets — also keeps /api/status safe
// from file:// or internal-scheme abuse.
func validURL(raw string) bool {
u, err := url.Parse(raw)
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}
func notFound(err error) bool { return errors.Is(err, sql.ErrNoRows) }
// healthz reports liveness and DB reachability.
func (s *Server) healthz(c *gin.Context) {
if err := s.db.PingContext(c.Request.Context()); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "db unreachable"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// accessLog is gin.Logger backed by zap: one structured line per request.
func accessLog(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
+152
View File
@@ -0,0 +1,152 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/tdvorak/dash/internal/db"
"go.uber.org/zap"
)
func testServer(t *testing.T) *httptest.Server {
t.Helper()
d, err := db.Open(filepath.Join(t.TempDir(), "dash.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { d.Close() })
gin.SetMode(gin.TestMode)
return httptest.NewServer(NewRouter(zap.NewNop(), d, t.TempDir()))
}
func do(t *testing.T, method, url, body string) (*http.Response, map[string]any) {
t.Helper()
req, err := http.NewRequest(method, url, strings.NewReader(body))
if err != nil {
t.Fatalf("request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, url, err)
}
defer res.Body.Close()
var out map[string]any
_ = json.NewDecoder(res.Body).Decode(&out)
return res, out
}
func TestSectionItemReorderFlow(t *testing.T) {
srv := testServer(t)
defer srv.Close()
_, a := do(t, "POST", srv.URL+"/api/sections", `{"name":"A"}`)
_, b := do(t, "POST", srv.URL+"/api/sections", `{"name":"B"}`)
secA, secB := a["id"].(string), b["id"].(string)
// Move B before A: position should go below A's.
res, mv := do(t, "POST", srv.URL+"/api/sections/reorder", `{"id":"`+secB+`","afterId":"`+secA+`"}`)
if res.StatusCode != http.StatusOK {
t.Fatalf("reorder: %v", mv)
}
if mv["position"].(float64) >= a["position"].(float64) {
t.Fatalf("expected B position < A position, got %v vs %v", mv["position"], a["position"])
}
// Item with two urls into A.
_, it := do(t, "POST", srv.URL+"/api/items", `{"sectionId":"`+secA+`","name":"Svc","urls":[{"url":"http://x.local","label":"local"},{"url":"https://x.dev","label":"external"}]}`)
if res := it["id"]; res == nil {
t.Fatalf("create item: %v", it)
}
// Move the item across sections.
res, mv = do(t, "POST", srv.URL+"/api/items/reorder", `{"id":"`+it["id"].(string)+`","sectionId":"`+secB+`"}`)
if res.StatusCode != http.StatusOK {
t.Fatalf("item reorder: %v", mv)
}
// Board reflects the move.
var secs []Section
resp, err := http.Get(srv.URL + "/api/sections")
if err != nil {
t.Fatalf("get sections: %v", err)
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&secs); err != nil {
t.Fatalf("decode board: %v", err)
}
if len(secs) != 2 || secs[0].Name != "B" || secs[1].Name != "A" {
t.Fatalf("unexpected section order: %+v", secs)
}
if len(secs[0].Items) != 1 || secs[0].Items[0].Name != "Svc" {
t.Fatalf("item did not move to section B: %+v", secs[0].Items)
}
if len(secs[0].Items[0].URLs) != 2 {
t.Fatalf("expected 2 urls, got %+v", secs[0].Items[0].URLs)
}
}
func TestRejectsNonHTTPURLs(t *testing.T) {
srv := testServer(t)
defer srv.Close()
_, a := do(t, "POST", srv.URL+"/api/sections", `{"name":"A"}`)
res, body := do(t, "POST", srv.URL+"/api/items", `{"sectionId":"`+a["id"].(string)+`","name":"Bad","urls":[{"url":"file:///etc/passwd","label":"x"}]}`)
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for file:// url, got %d: %v", res.StatusCode, body)
}
}
func TestSettingsRoundTrip(t *testing.T) {
srv := testServer(t)
defer srv.Close()
res, _ := do(t, "PUT", srv.URL+"/api/settings", `{"theme":"light","renderer":"cards"}`)
if res.StatusCode != http.StatusOK {
t.Fatalf("put settings: %d", res.StatusCode)
}
resp, err := http.Get(srv.URL + "/api/settings")
if err != nil {
t.Fatalf("get settings: %v", err)
}
defer resp.Body.Close()
var got map[string]string
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatalf("decode settings: %v", err)
}
if got["theme"] != "light" || got["renderer"] != "cards" {
t.Fatalf("settings mismatch: %v", got)
}
}
func TestMidpoint(t *testing.T) {
f := func(v float64) *float64 { return &v }
if got := midpoint(nil, nil); got != 1024 {
t.Fatalf("empty midpoint: %v", got)
}
if got := midpoint(nil, f(1024)); got != 0 {
t.Fatalf("before-only midpoint: %v", got)
}
if got := midpoint(f(1024), nil); got != 2048 {
t.Fatalf("after-only midpoint: %v", got)
}
if got := midpoint(f(0), f(1024)); got != 512 {
t.Fatalf("bounded midpoint: %v", got)
}
}
func TestValidURL(t *testing.T) {
for _, u := range []string{"http://a.b", "https://a.b:8443/x"} {
if !validURL(u) {
t.Fatalf("expected valid: %s", u)
}
}
for _, u := range []string{"file:///etc/passwd", "javascript:alert(1)", "http://", "notaurl", ""} {
if validURL(u) {
t.Fatalf("expected invalid: %s", u)
}
}
}
+236
View File
@@ -0,0 +1,236 @@
package api
import (
"database/sql"
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
)
// board loads the full tree: sections ordered, each with items and urls.
// Three flat queries assembled in memory — the whole board is ~100 rows.
func (s *Server) board() ([]Section, error) {
secRows, err := s.db.Query(`SELECT id, name, position, collapsed FROM sections ORDER BY position`)
if err != nil {
return nil, err
}
defer secRows.Close()
sections := []Section{}
byID := map[string]*Section{}
for secRows.Next() {
var sec Section
if err := secRows.Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed); err != nil {
return nil, err
}
sec.Items = []Item{}
sections = append(sections, sec)
}
for i := range sections {
byID[sections[i].ID] = &sections[i]
}
if err := secRows.Err(); err != nil {
return nil, err
}
itemRows, err := s.db.Query(`SELECT id, section_id, kind, name, icon, position, config FROM items ORDER BY position`)
if err != nil {
return nil, err
}
defer itemRows.Close()
items := []*Item{}
itemByID := map[string]*Item{}
for itemRows.Next() {
var it Item
var cfg sql.NullString
if err := itemRows.Scan(&it.ID, &it.SectionID, &it.Kind, &it.Name, &it.Icon, &it.Position, &cfg); err != nil {
return nil, err
}
if cfg.Valid {
raw := json.RawMessage(cfg.String)
it.Config = &raw
}
it.URLs = []URL{}
items = append(items, &it)
itemByID[it.ID] = &it
}
if err := itemRows.Err(); err != nil {
return nil, err
}
urlRows, err := s.db.Query(`SELECT id, item_id, url, label, position FROM urls ORDER BY position`)
if err != nil {
return nil, err
}
defer urlRows.Close()
for urlRows.Next() {
var u URL
var itemID string
if err := urlRows.Scan(&u.ID, &itemID, &u.URL, &u.Label, &u.Position); err != nil {
return nil, err
}
if it, ok := itemByID[itemID]; ok {
it.URLs = append(it.URLs, u)
}
}
if err := urlRows.Err(); err != nil {
return nil, err
}
for _, it := range items {
if sec, ok := byID[it.SectionID]; ok {
sec.Items = append(sec.Items, *it)
}
}
return sections, nil
}
func (s *Server) listSections(c *gin.Context) {
sections, err := s.board()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, sections)
}
func (s *Server) createSection(c *gin.Context) {
var in struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&in); err != nil || in.Name == "" {
fail(c, http.StatusBadRequest, "name required")
return
}
var maxPos sql.NullFloat64
if err := s.db.QueryRow(`SELECT MAX(position) FROM sections`).Scan(&maxPos); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
sec := Section{ID: newID("s"), Name: in.Name, Position: midpoint(ptrOr(maxPos), nil), Items: []Item{}}
if _, err := s.db.Exec(`INSERT INTO sections (id, name, position) VALUES (?, ?, ?)`, sec.ID, sec.Name, sec.Position); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusCreated, sec)
}
func (s *Server) updateSection(c *gin.Context) {
var in struct {
Name *string `json:"name"`
Collapsed *bool `json:"collapsed"`
}
if err := c.ShouldBindJSON(&in); err != nil {
fail(c, http.StatusBadRequest, "invalid body")
return
}
if in.Name != nil {
if _, err := s.db.Exec(`UPDATE sections SET name = ? WHERE id = ?`, *in.Name, c.Param("id")); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
}
if in.Collapsed != nil {
if _, err := s.db.Exec(`UPDATE sections SET collapsed = ? WHERE id = ?`, *in.Collapsed, c.Param("id")); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
}
var sec Section
err := s.db.QueryRow(`SELECT id, name, position, collapsed FROM sections WHERE id = ?`, c.Param("id")).
Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed)
if notFound(err) {
fail(c, http.StatusNotFound, "section not found")
return
}
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
sec.Items = []Item{}
c.JSON(http.StatusOK, sec)
}
func (s *Server) deleteSection(c *gin.Context) {
res, err := s.db.Exec(`DELETE FROM sections WHERE id = ?`, c.Param("id"))
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
if n, _ := res.RowsAffected(); n == 0 {
fail(c, http.StatusNotFound, "section not found")
return
}
c.Status(http.StatusNoContent)
}
func (s *Server) reorderSection(c *gin.Context) {
var in struct {
ID string `json:"id"`
BeforeID *string `json:"beforeId"`
AfterID *string `json:"afterId"`
}
if err := c.ShouldBindJSON(&in); err != nil || in.ID == "" {
fail(c, http.StatusBadRequest, "id required")
return
}
pos, err := s.neighborPosition("sections", in.BeforeID, in.AfterID)
if err != nil {
fail(c, http.StatusBadRequest, err.Error())
return
}
res, err := s.db.Exec(`UPDATE sections SET position = ? WHERE id = ?`, pos, in.ID)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
if n, _ := res.RowsAffected(); n == 0 {
fail(c, http.StatusNotFound, "section not found")
return
}
var sec Section
if err := s.db.QueryRow(`SELECT id, name, position, collapsed FROM sections WHERE id = ?`, in.ID).
Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
sec.Items = []Item{}
c.JSON(http.StatusOK, sec)
}
// neighborPosition resolves the new fractional position between two neighbors.
func (s *Server) neighborPosition(table string, beforeID, afterID *string) (float64, error) {
var before, after *float64
if beforeID != nil {
p, err := s.positionOf(table, *beforeID)
if err != nil {
return 0, err
}
before = &p
}
if afterID != nil {
p, err := s.positionOf(table, *afterID)
if err != nil {
return 0, err
}
after = &p
}
return midpoint(before, after), nil
}
func (s *Server) positionOf(table, id string) (float64, error) {
var p float64
// table is an internal constant (sections|items) — never user input.
err := s.db.QueryRow(`SELECT position FROM `+table+` WHERE id = ?`, id).Scan(&p)
return p, err
}
func ptrOr(v sql.NullFloat64) *float64 {
if v.Valid {
return &v.Float64
}
return nil
}
+64
View File
@@ -0,0 +1,64 @@
package api
import (
"net/http"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
// Allowed icon extensions — whitelist keeps the upload dir free of HTML/JS.
var iconExts = map[string]struct{}{
".png": {}, ".jpg": {}, ".jpeg": {}, ".webp": {},
".gif": {}, ".svg": {}, ".ico": {}, ".avif": {},
}
// uploadIcon stores a multipart file under iconsDir as <itemID><ext> and
// points the item's icon field at /api/icons/<file>.
func (s *Server) uploadIcon(c *gin.Context) {
id := c.Param("id")
var exists int
if err := s.db.QueryRow(`SELECT 1 FROM items WHERE id = ?`, id).Scan(&exists); err != nil {
fail(c, http.StatusNotFound, "item not found")
return
}
fh, err := c.FormFile("file")
if err != nil {
fail(c, http.StatusBadRequest, "file field required")
return
}
if fh.Size > 4<<20 {
fail(c, http.StatusBadRequest, "icon too large (max 4 MB)")
return
}
ext := strings.ToLower(filepath.Ext(fh.Filename))
if _, ok := iconExts[ext]; !ok {
fail(c, http.StatusBadRequest, "unsupported icon type")
return
}
name := id + ext
if err := c.SaveUploadedFile(fh, filepath.Join(s.iconsDir, name)); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
if _, err := s.db.Exec(`UPDATE items SET icon = ? WHERE id = ?`, "/api/icons/"+name, id); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
it, err := s.loadItem(id)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, it)
}
// getIcon serves a stored icon. filepath.Base strips any traversal before
// the path is pinned inside iconsDir.
func (s *Server) getIcon(c *gin.Context) {
name := filepath.Base(c.Param("file"))
http.ServeFile(c.Writer, c.Request, filepath.Join(s.iconsDir, name))
}
+227
View File
@@ -0,0 +1,227 @@
package api
import (
"database/sql"
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
)
type itemInput struct {
SectionID string `json:"sectionId"`
Name string `json:"name"`
Icon string `json:"icon"`
URLs []URLInput `json:"urls"`
}
func (s *Server) createItem(c *gin.Context) {
var in itemInput
if err := c.ShouldBindJSON(&in); err != nil || in.SectionID == "" || in.Name == "" {
fail(c, http.StatusBadRequest, "sectionId and name required")
return
}
for _, u := range in.URLs {
if !validURL(u.URL) {
fail(c, http.StatusBadRequest, "invalid url: "+u.URL)
return
}
}
var maxPos sql.NullFloat64
err := s.db.QueryRow(`SELECT MAX(position) FROM items WHERE section_id = ?`, in.SectionID).Scan(&maxPos)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
it := Item{ID: newID("i"), SectionID: in.SectionID, Kind: "service", Name: in.Name, Icon: in.Icon,
Position: midpoint(ptrOr(maxPos), nil), URLs: []URL{}}
tx, err := s.db.Begin()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(`INSERT INTO items (id, section_id, name, icon, position) VALUES (?, ?, ?, ?, ?)`,
it.ID, it.SectionID, it.Name, it.Icon, it.Position); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
if err := insertURLs(tx, it.ID, in.URLs, &it.URLs); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
if err := tx.Commit(); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusCreated, it)
}
// insertURLs writes the url list (position = index * 1024) and fills out.
func insertURLs(tx *sql.Tx, itemID string, in []URLInput, out *[]URL) error {
for i, u := range in {
row := URL{ID: newID("u"), URL: u.URL, Label: u.Label, Position: float64(i+1) * 1024}
if _, err := tx.Exec(`INSERT INTO urls (id, item_id, url, label, position) VALUES (?, ?, ?, ?, ?)`,
row.ID, itemID, row.URL, row.Label, row.Position); err != nil {
return err
}
*out = append(*out, row)
}
return nil
}
func (s *Server) updateItem(c *gin.Context) {
var in struct {
Name *string `json:"name"`
Icon *string `json:"icon"`
URLs *[]URLInput `json:"urls"`
}
if err := c.ShouldBindJSON(&in); err != nil {
fail(c, http.StatusBadRequest, "invalid body")
return
}
if in.URLs != nil {
for _, u := range *in.URLs {
if !validURL(u.URL) {
fail(c, http.StatusBadRequest, "invalid url: "+u.URL)
return
}
}
}
id := c.Param("id")
tx, err := s.db.Begin()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
defer func() { _ = tx.Rollback() }()
if in.Name != nil {
if _, err := tx.Exec(`UPDATE items SET name = ? WHERE id = ?`, *in.Name, id); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
}
if in.Icon != nil {
if _, err := tx.Exec(`UPDATE items SET icon = ? WHERE id = ?`, *in.Icon, id); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
}
if in.URLs != nil {
if _, err := tx.Exec(`DELETE FROM urls WHERE item_id = ?`, id); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
var out []URL
if err := insertURLs(tx, id, *in.URLs, &out); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
}
if err := tx.Commit(); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
it, err := s.loadItem(id)
if notFound(err) {
fail(c, http.StatusNotFound, "item not found")
return
}
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, it)
}
// loadItem reads one item with its urls.
func (s *Server) loadItem(id string) (Item, error) {
var it Item
var cfg sql.NullString
err := s.db.QueryRow(`SELECT id, section_id, kind, name, icon, position, config FROM items WHERE id = ?`, id).
Scan(&it.ID, &it.SectionID, &it.Kind, &it.Name, &it.Icon, &it.Position, &cfg)
if err != nil {
return it, err
}
if cfg.Valid {
raw := json.RawMessage(cfg.String)
it.Config = &raw
}
it.URLs = []URL{}
rows, err := s.db.Query(`SELECT id, url, label, position FROM urls WHERE item_id = ? ORDER BY position`, id)
if err != nil {
return it, err
}
defer rows.Close()
for rows.Next() {
var u URL
if err := rows.Scan(&u.ID, &u.URL, &u.Label, &u.Position); err != nil {
return it, err
}
it.URLs = append(it.URLs, u)
}
return it, rows.Err()
}
func (s *Server) deleteItem(c *gin.Context) {
res, err := s.db.Exec(`DELETE FROM items WHERE id = ?`, c.Param("id"))
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
if n, _ := res.RowsAffected(); n == 0 {
fail(c, http.StatusNotFound, "item not found")
return
}
c.Status(http.StatusNoContent)
}
func (s *Server) reorderItem(c *gin.Context) {
var in struct {
ID string `json:"id"`
SectionID *string `json:"sectionId"`
BeforeID *string `json:"beforeId"`
AfterID *string `json:"afterId"`
}
if err := c.ShouldBindJSON(&in); err != nil || in.ID == "" {
fail(c, http.StatusBadRequest, "id required")
return
}
pos, err := s.neighborPosition("items", in.BeforeID, in.AfterID)
if err != nil {
fail(c, http.StatusBadRequest, err.Error())
return
}
if in.SectionID == nil {
if err := s.db.QueryRow(`SELECT section_id FROM items WHERE id = ?`, in.ID).Scan(&in.SectionID); notFound(err) {
fail(c, http.StatusNotFound, "item not found")
return
} else if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
}
res, err := s.db.Exec(`UPDATE items SET position = ?, section_id = ? WHERE id = ?`, pos, *in.SectionID, in.ID)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
if n, _ := res.RowsAffected(); n == 0 {
fail(c, http.StatusNotFound, "item not found")
return
}
it, err := s.loadItem(in.ID)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, it)
}
+68
View File
@@ -0,0 +1,68 @@
package api
import (
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
)
// allSettings reads the whole settings table into a key/value map.
func (s *Server) allSettings() (map[string]json.RawMessage, error) {
rows, err := s.db.Query(`SELECT key, value FROM settings`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]json.RawMessage{}
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, err
}
out[k] = json.RawMessage(v)
}
return out, rows.Err()
}
func (s *Server) getSettings(c *gin.Context) {
m, err := s.allSettings()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, m)
}
// putSettings merges the posted keys into the settings table.
func (s *Server) putSettings(c *gin.Context) {
var in map[string]json.RawMessage
if err := c.ShouldBindJSON(&in); err != nil {
fail(c, http.StatusBadRequest, "invalid body")
return
}
tx, err := s.db.Begin()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
defer func() { _ = tx.Rollback() }()
for k, v := range in {
if _, err := tx.Exec(
`INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, k, string(v)); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
}
if err := tx.Commit(); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
m, err := s.allSettings()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, m)
}
+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)
}
+121
View File
@@ -0,0 +1,121 @@
package api
import (
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
)
// Export is the portable backup shape: version + settings + board tree.
type Export struct {
Version int `json:"version"`
Settings map[string]json.RawMessage `json:"settings"`
Sections []Section `json:"sections"`
}
func (s *Server) exportBoard(c *gin.Context) {
settings, err := s.allSettings()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
sections, err := s.board()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, Export{Version: 1, Settings: settings, Sections: sections})
}
// importBoard replaces board state inside one transaction. Ids from the
// payload are preserved so a round-trip restores identical state.
func (s *Server) importBoard(c *gin.Context) {
var in Export
if err := c.ShouldBindJSON(&in); err != nil || in.Sections == nil {
fail(c, http.StatusBadRequest, "invalid export payload")
return
}
tx, err := s.db.Begin()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
defer func() { _ = tx.Rollback() }()
for _, q := range []string{`DELETE FROM urls`, `DELETE FROM items`, `DELETE FROM sections`} {
if _, err := tx.Exec(q); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
}
for _, sec := range in.Sections {
if sec.ID == "" || sec.Name == "" {
fail(c, http.StatusBadRequest, "section missing id or name")
return
}
if _, err := tx.Exec(`INSERT INTO sections (id, name, position, collapsed) VALUES (?, ?, ?, ?)`,
sec.ID, sec.Name, sec.Position, sec.Collapsed); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
for _, it := range sec.Items {
if it.ID == "" || it.Name == "" {
fail(c, http.StatusBadRequest, "item missing id or name")
return
}
var cfg any
if it.Config != nil {
cfg = string(*it.Config)
}
if _, err := tx.Exec(
`INSERT INTO items (id, section_id, kind, name, icon, position, config) VALUES (?, ?, ?, ?, ?, ?, ?)`,
it.ID, sec.ID, orDefault(it.Kind, "service"), it.Name, it.Icon, it.Position, cfg); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
for _, u := range it.URLs {
if !validURL(u.URL) {
fail(c, http.StatusBadRequest, "invalid url: "+u.URL)
return
}
uid := u.ID
if uid == "" {
uid = newID("u")
}
if _, err := tx.Exec(`INSERT INTO urls (id, item_id, url, label, position) VALUES (?, ?, ?, ?, ?)`,
uid, it.ID, u.URL, u.Label, u.Position); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
}
}
}
for k, v := range in.Settings {
if _, err := tx.Exec(
`INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, k, string(v)); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
}
if err := tx.Commit(); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
sections, err := s.board()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, sections)
}
func orDefault(v, def string) string {
if v == "" {
return def
}
return v
}
@@ -0,0 +1,36 @@
-- +goose Up
-- Board tree: sections > items > urls. position REAL = fractional ordering;
-- a drag writes one row instead of renumbering the set.
CREATE TABLE sections (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
position REAL NOT NULL,
collapsed INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE items (
id TEXT PRIMARY KEY,
section_id TEXT NOT NULL REFERENCES sections(id) ON DELETE CASCADE,
kind TEXT NOT NULL DEFAULT 'service' CHECK (kind IN ('service', 'widget')),
name TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT '',
position REAL NOT NULL,
config JSON,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX items_section_pos ON items(section_id, position);
CREATE TABLE urls (
id TEXT PRIMARY KEY,
item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
url TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
position REAL NOT NULL
);
CREATE INDEX urls_item_pos ON urls(item_id, position);
-- +goose Down
DROP TABLE urls;
DROP TABLE items;
DROP TABLE sections;