feat: master/agent phase 4 — registry polling and version history (#9) (#23)
Docker Build / docker (push) Successful in 47s

This commit was merged in pull request #23.
This commit is contained in:
2026-10-13 07:02:34 +00:00
parent 41a619b316
commit 1664e47183
46 changed files with 3357 additions and 130 deletions
+36 -6
View File
@@ -26,12 +26,18 @@ var assets embed.FS
// serviceJobsLimit is how many jobs the service page lists.
const serviceJobsLimit = 20
// versionsLimit is how many image versions the service page lists.
const versionsLimit = 10
var agentNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
// Store is the storage the UI reads and writes. *store.Store satisfies it.
type Store interface {
ListServices(ctx context.Context) ([]model.Service, error)
GetService(ctx context.Context, id int64) (model.Service, error)
// RecentVersions returns up to limit versions of repo the master has
// seen, most recently first seen first.
RecentVersions(ctx context.Context, repo string, limit int) ([]model.ImageVersion, error)
// SetUpdateMode returns model.ErrNotFound for an unknown or removed service.
SetUpdateMode(ctx context.Context, id int64, mode string) error
GetJob(ctx context.Context, id int64) (model.Job, error)
@@ -54,6 +60,9 @@ type Jobs interface {
Updatable(svc model.Service) error
AutoAllowed(svc model.Service) error
Trigger(ctx context.Context, serviceID int64, requestedBy string) (model.Job, error)
// TriggerVersion updates to exactly the image version versionID of the
// service's repo; model.ErrNotFound for an unknown service or version.
TriggerVersion(ctx context.Context, serviceID, versionID int64, requestedBy string) (model.Job, error)
}
// Broker delivers change signals to open streams and accepts signals from
@@ -63,11 +72,12 @@ type Broker interface {
Publish(t events.Topic)
}
// Config holds values shown in the "add agent" snippet.
// Config holds values shown in the "add agent" snippet and on the service page.
type Config struct {
AgentURL string // what agents dial, e.g. wss://10.0.0.5:8090/agent
MasterKey string // the master's fingerprint
AgentImage string
AgentURL string // what agents dial, e.g. wss://10.0.0.5:8090/agent
MasterKey string // the master's fingerprint
AgentImage string
PollInterval time.Duration // shown as "every <interval>" on the service page
}
// Server renders the UI.
@@ -85,7 +95,7 @@ type Server struct {
// New parses the templates. Each page is its own template set so every page
// can define its own "content" block.
func New(store Store, hub Hub, jobs Jobs, broker Broker, cfg Config, log *slog.Logger) (*Server, error) {
funcs := template.FuncMap{"shortRef": shortRef, "duration": jobDuration}
funcs := template.FuncMap{"shortRef": shortRef, "duration": jobDuration, "followsNoTag": followsNoTag}
pages := map[string]*template.Template{}
for _, page := range []string{"overview.html", "agents.html", "agent_created.html", "service.html", "jobs.html", "job.html"} {
t, err := template.New(page).Funcs(funcs).ParseFS(assets, "templates/layout.html", "templates/"+page)
@@ -132,6 +142,9 @@ type pageData struct {
ConfigFiles []string
Reason string // why Service cannot be updated; "" when it can
AutoBlocked string // why Service cannot be set to auto; "" when it can
Versions []versionRow
Polling string // what the service page says about registry polling
PollFailed bool // Polling is the last registry error
Jobs []model.Job
Job model.Job
}
@@ -222,6 +235,12 @@ func (s *Server) serviceDetail(r *http.Request, id int64) (pageData, error) {
return data, err
}
data.AutoBlocked = s.autoBlocked(svc)
versions, err := s.store.RecentVersions(r.Context(), svc.ImageRepo, versionsLimit)
if err != nil {
return data, err
}
data.Versions = versionRows(versions, svc.ActualDigest)
data.Polling, data.PollFailed = pollingStatus(svc, s.cfg.PollInterval)
data.Jobs, err = s.store.ListJobs(r.Context(), model.JobFilter{ServiceID: id, Limit: serviceJobsLimit})
return data, err
}
@@ -260,7 +279,18 @@ func (s *Server) updateService(w http.ResponseWriter, r *http.Request) {
return
}
sess, _ := webauth.SessionFrom(r.Context())
job, err := s.jobs.Trigger(r.Context(), id, sess.Subject)
var job model.Job
var err error
if v := r.PostFormValue("version"); v == "" {
job, err = s.jobs.Trigger(r.Context(), id, sess.Subject)
} else {
versionID, perr := strconv.ParseInt(v, 10, 64)
if perr != nil {
http.Error(w, "invalid version", http.StatusBadRequest)
return
}
job, err = s.jobs.TriggerVersion(r.Context(), id, versionID, sess.Subject)
}
var nu *model.NotUpdatableError
switch {
case err == nil || errors.Is(err, model.ErrJobActive):