feat(web): update-mode toggle and available-version badge (#8)
This commit is contained in:
@@ -32,6 +32,8 @@ var agentNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
|
|||||||
type Store interface {
|
type Store interface {
|
||||||
ListServices(ctx context.Context) ([]model.Service, error)
|
ListServices(ctx context.Context) ([]model.Service, error)
|
||||||
GetService(ctx context.Context, id int64) (model.Service, error)
|
GetService(ctx context.Context, id int64) (model.Service, 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)
|
GetJob(ctx context.Context, id int64) (model.Job, error)
|
||||||
ListJobs(ctx context.Context, f model.JobFilter) ([]model.Job, error)
|
ListJobs(ctx context.Context, f model.JobFilter) ([]model.Job, error)
|
||||||
ListAgents(ctx context.Context) ([]model.Agent, error)
|
ListAgents(ctx context.Context) ([]model.Agent, error)
|
||||||
@@ -46,10 +48,11 @@ type Hub interface {
|
|||||||
Disconnect(agentID int64)
|
Disconnect(agentID int64)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jobs starts updates and explains why a service cannot be updated.
|
// Jobs starts updates and explains why a service cannot be updated or set
|
||||||
// *jobs.Service satisfies it.
|
// to auto mode. *jobs.Service satisfies it.
|
||||||
type Jobs interface {
|
type Jobs interface {
|
||||||
Updatable(svc model.Service) error
|
Updatable(svc model.Service) error
|
||||||
|
AutoAllowed(svc model.Service) error
|
||||||
Trigger(ctx context.Context, serviceID int64, requestedBy string) (model.Job, error)
|
Trigger(ctx context.Context, serviceID int64, requestedBy string) (model.Job, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +107,7 @@ func (s *Server) Register(mux *http.ServeMux, protect func(http.Handler) http.Ha
|
|||||||
route("GET /services/{id}", s.servicePage)
|
route("GET /services/{id}", s.servicePage)
|
||||||
route("GET /fragments/services/{id}/jobs", s.serviceJobsFragment)
|
route("GET /fragments/services/{id}/jobs", s.serviceJobsFragment)
|
||||||
route("POST /services/{id}/update", s.updateService)
|
route("POST /services/{id}/update", s.updateService)
|
||||||
|
route("POST /services/{id}/mode", s.setMode)
|
||||||
route("GET /jobs", s.jobsPage)
|
route("GET /jobs", s.jobsPage)
|
||||||
route("GET /fragments/jobs", s.jobsFragment)
|
route("GET /fragments/jobs", s.jobsFragment)
|
||||||
route("GET /jobs/{id}", s.jobPage)
|
route("GET /jobs/{id}", s.jobPage)
|
||||||
@@ -127,6 +131,7 @@ type pageData struct {
|
|||||||
Service model.Service
|
Service model.Service
|
||||||
ConfigFiles []string
|
ConfigFiles []string
|
||||||
Reason string // why Service cannot be updated; "" when it can
|
Reason string // why Service cannot be updated; "" when it can
|
||||||
|
AutoBlocked string // why Service cannot be set to auto; "" when it can
|
||||||
Jobs []model.Job
|
Jobs []model.Job
|
||||||
Job model.Job
|
Job model.Job
|
||||||
}
|
}
|
||||||
@@ -150,7 +155,7 @@ func (s *Server) serviceData(r *http.Request) (pageData, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return data, err
|
return data, err
|
||||||
}
|
}
|
||||||
data.Groups = groupServices(services)
|
data.Groups = groupServices(services, s.autoBlocked)
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +177,15 @@ func (s *Server) servicesFragment(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.render(w, http.StatusOK, "overview.html", "services", data)
|
s.render(w, http.StatusOK, "overview.html", "services", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// autoBlocked returns the reason svc cannot be set to auto mode, or "".
|
||||||
|
func (s *Server) autoBlocked(svc model.Service) string {
|
||||||
|
var nu *model.NotUpdatableError
|
||||||
|
if err := s.jobs.AutoAllowed(svc); errors.As(err, &nu) {
|
||||||
|
return nu.Reason
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// pathID parses the {id} path value. On failure it has already answered 400.
|
// pathID parses the {id} path value. On failure it has already answered 400.
|
||||||
func pathID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
func pathID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
@@ -207,6 +221,7 @@ func (s *Server) serviceDetail(r *http.Request, id int64) (pageData, error) {
|
|||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return data, err
|
return data, err
|
||||||
}
|
}
|
||||||
|
data.AutoBlocked = s.autoBlocked(svc)
|
||||||
data.Jobs, err = s.store.ListJobs(r.Context(), model.JobFilter{ServiceID: id, Limit: serviceJobsLimit})
|
data.Jobs, err = s.store.ListJobs(r.Context(), model.JobFilter{ServiceID: id, Limit: serviceJobsLimit})
|
||||||
return data, err
|
return data, err
|
||||||
}
|
}
|
||||||
@@ -263,6 +278,54 @@ func (s *Server) updateService(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setMode switches a service between manual and auto. Manual is always
|
||||||
|
// allowed; auto only when jobs.AutoAllowed says so.
|
||||||
|
func (s *Server) setMode(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mode := r.PostFormValue("mode")
|
||||||
|
if mode != model.ModeManual && mode != model.ModeAuto {
|
||||||
|
http.Error(w, "invalid mode", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if mode == model.ModeAuto {
|
||||||
|
svc, err := s.store.GetService(r.Context(), id)
|
||||||
|
if err == nil && svc.RemovedAt != nil {
|
||||||
|
err = model.ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.failLookup(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var nu *model.NotUpdatableError
|
||||||
|
if err := s.jobs.AutoAllowed(svc); errors.As(err, &nu) {
|
||||||
|
data, derr := s.serviceDetail(r, id)
|
||||||
|
if derr != nil {
|
||||||
|
s.failLookup(w, derr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.Error = "Cannot enable auto: " + nu.Reason + "."
|
||||||
|
s.render(w, http.StatusConflict, "service.html", "layout", data)
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
s.fail(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.store.SetUpdateMode(r.Context(), id, mode); err != nil {
|
||||||
|
s.failLookup(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.broker.Publish(events.Services)
|
||||||
|
if r.PostFormValue("return") == "overview" {
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/services/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) jobsData(r *http.Request) (pageData, error) {
|
func (s *Server) jobsData(r *http.Request) (pageData, error) {
|
||||||
data := s.base(r, "Jobs")
|
data := s.base(r, "Jobs")
|
||||||
var err error
|
var err error
|
||||||
|
|||||||
@@ -46,6 +46,29 @@ func (f *fakeStore) GetService(_ context.Context, id int64) (model.Service, erro
|
|||||||
return model.Service{}, model.ErrNotFound
|
return model.Service{}, model.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) SetUpdateMode(_ context.Context, id int64, mode string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
for i := range f.services {
|
||||||
|
if f.services[i].ID == id && f.services[i].RemovedAt == nil {
|
||||||
|
f.services[i].UpdateMode = mode
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return model.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) modeOf(id int64) string {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
for _, s := range f.services {
|
||||||
|
if s.ID == id {
|
||||||
|
return s.UpdateMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeStore) GetJob(_ context.Context, id int64) (model.Job, error) {
|
func (f *fakeStore) GetJob(_ context.Context, id int64) (model.Job, error) {
|
||||||
for _, j := range f.jobs {
|
for _, j := range f.jobs {
|
||||||
if j.ID == id {
|
if j.ID == id {
|
||||||
@@ -99,6 +122,7 @@ func (f *fakeStore) revoked() int64 {
|
|||||||
// fakeJobs returns scripted results and records who asked for the update.
|
// fakeJobs returns scripted results and records who asked for the update.
|
||||||
type fakeJobs struct {
|
type fakeJobs struct {
|
||||||
updatable error
|
updatable error
|
||||||
|
autoErr error
|
||||||
job model.Job
|
job model.Job
|
||||||
err error
|
err error
|
||||||
|
|
||||||
@@ -108,6 +132,8 @@ type fakeJobs struct {
|
|||||||
|
|
||||||
func (f *fakeJobs) Updatable(model.Service) error { return f.updatable }
|
func (f *fakeJobs) Updatable(model.Service) error { return f.updatable }
|
||||||
|
|
||||||
|
func (f *fakeJobs) AutoAllowed(model.Service) error { return f.autoErr }
|
||||||
|
|
||||||
func (f *fakeJobs) Trigger(_ context.Context, _ int64, by string) (model.Job, error) {
|
func (f *fakeJobs) Trigger(_ context.Context, _ int64, by string) (model.Job, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
@@ -504,3 +530,114 @@ func TestJob_NotFound(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusNotFound, code)
|
assert.Equal(t, http.StatusNotFound, code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func postMode(t *testing.T, srv *httptest.Server, id string, form url.Values) (*http.Response, string) {
|
||||||
|
t.Helper()
|
||||||
|
form.Set("csrf_token", "csrf")
|
||||||
|
resp, err := noRedirectClient().PostForm(srv.URL+"/services/"+id+"/mode", form)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return resp, string(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetMode_AutoRedirectsToServiceAndPublishes(t *testing.T) {
|
||||||
|
srv, store, _, broker, _ := newTestEnv(t)
|
||||||
|
sub, cancel := broker.Subscribe()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp, _ := postMode(t, srv, "1", url.Values{"mode": {"auto"}})
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
||||||
|
assert.Equal(t, "/services/1", resp.Header.Get("Location"))
|
||||||
|
assert.Equal(t, "auto", store.modeOf(1))
|
||||||
|
assert.Equal(t, events.Services, <-sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetMode_ReturnToOverview(t *testing.T) {
|
||||||
|
srv, store, _, _, _ := newTestEnv(t)
|
||||||
|
resp, _ := postMode(t, srv, "1", url.Values{"mode": {"auto"}, "return": {"overview"}})
|
||||||
|
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
||||||
|
assert.Equal(t, "/", resp.Header.Get("Location"))
|
||||||
|
assert.Equal(t, "auto", store.modeOf(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetMode_AutoRefusedShows409(t *testing.T) {
|
||||||
|
srv, store, _, _, jobs := newTestEnv(t)
|
||||||
|
jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonBuilt}
|
||||||
|
|
||||||
|
resp, body := postMode(t, srv, "1", url.Values{"mode": {"auto"}})
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusConflict, resp.StatusCode)
|
||||||
|
assert.Contains(t, body, "Cannot enable auto: "+model.ReasonBuilt+".")
|
||||||
|
assert.Equal(t, "manual", store.modeOf(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetMode_ManualAlwaysAllowed(t *testing.T) {
|
||||||
|
srv, store, _, _, jobs := newTestEnv(t)
|
||||||
|
store.services[0].UpdateMode = "auto"
|
||||||
|
jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonBuilt}
|
||||||
|
|
||||||
|
resp, _ := postMode(t, srv, "1", url.Values{"mode": {"manual"}})
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
||||||
|
assert.Equal(t, "manual", store.modeOf(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetMode_InvalidModeAndUnknownService(t *testing.T) {
|
||||||
|
srv, store, _, _, _ := newTestEnv(t)
|
||||||
|
store.services = append(store.services, model.Service{ID: 5, Kind: protocol.KindCompose, UpdateMode: "manual", RemovedAt: ptr(time.Now())})
|
||||||
|
|
||||||
|
resp, _ := postMode(t, srv, "1", url.Values{"mode": {"yolo"}})
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
resp, _ = postMode(t, srv, "1", url.Values{})
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
resp, _ = postMode(t, srv, "99", url.Values{"mode": {"auto"}})
|
||||||
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||||
|
resp, _ = postMode(t, srv, "99", url.Values{"mode": {"manual"}})
|
||||||
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||||
|
resp, _ = postMode(t, srv, "5", url.Values{"mode": {"auto"}})
|
||||||
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||||
|
resp, _ = postMode(t, srv, "abc", url.Values{"mode": {"auto"}})
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
assert.Equal(t, "manual", store.modeOf(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverview_ShowsModeToggleAndAvailableBadge(t *testing.T) {
|
||||||
|
srv, store, _, _, jobs := newTestEnv(t)
|
||||||
|
store.services[0].AvailableRef = "gitea.shcizo.se/shcizo/slusk:1.5.0"
|
||||||
|
store.services[1].UpdateMode = "auto"
|
||||||
|
jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonSelf}
|
||||||
|
|
||||||
|
for _, path := range []string{"/", "/fragments/services"} {
|
||||||
|
_, body := get(t, srv, path)
|
||||||
|
assert.Contains(t, body, `action="/services/1/mode"`, path)
|
||||||
|
assert.Contains(t, body, `name="csrf_token" value="csrf"`, path)
|
||||||
|
assert.Contains(t, body, `name="return" value="overview"`, path)
|
||||||
|
assert.Contains(t, body, `class="badge badge-update" title="gitea.shcizo.se/shcizo/slusk:1.5.0"`, path)
|
||||||
|
assert.Contains(t, body, `<input type="hidden" name="mode" value="manual">`, "auto services toggle to manual: "+path)
|
||||||
|
assert.Contains(t, body, `disabled title="`+model.ReasonSelf+`"`, "blocked manual services get a disabled button: "+path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServicePage_ShowsAvailableAndModeForm(t *testing.T) {
|
||||||
|
srv, store, _, _, _ := newTestEnv(t)
|
||||||
|
at := time.Date(2026, 10, 12, 12, 30, 0, 0, time.UTC)
|
||||||
|
store.services[0].AvailableRef = "gitea.shcizo.se/shcizo/slusk:1.5.0"
|
||||||
|
store.services[0].AvailableSource = "webhook"
|
||||||
|
store.services[0].AvailableAt = &at
|
||||||
|
|
||||||
|
_, body := get(t, srv, "/services/1")
|
||||||
|
|
||||||
|
assert.Contains(t, body, "gitea.shcizo.se/shcizo/slusk:1.5.0")
|
||||||
|
assert.Contains(t, body, "via webhook, 2026-10-12 12:30")
|
||||||
|
assert.Contains(t, body, `action="/services/1/mode"`)
|
||||||
|
assert.Contains(t, body, `<input type="hidden" name="mode" value="auto">`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServicePage_AutoBlockedDisablesToggle(t *testing.T) {
|
||||||
|
srv, _, _, _, jobs := newTestEnv(t)
|
||||||
|
jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonContainer}
|
||||||
|
_, body := get(t, srv, "/services/1")
|
||||||
|
assert.Contains(t, body, "Auto unavailable: "+model.ReasonContainer+".")
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,3 +28,6 @@ form.inline { display: flex; gap: 8px; margin-bottom: 16px; }
|
|||||||
table.details { width: auto; }
|
table.details { width: auto; }
|
||||||
table.details th { padding-right: 24px; }
|
table.details th { padding-right: 24px; }
|
||||||
pre.log { white-space: pre-wrap; }
|
pre.log { white-space: pre-wrap; }
|
||||||
|
.badge-update { color: var(--warn); border-color: var(--warn); }
|
||||||
|
form.mode { display: inline; margin-left: 8px; }
|
||||||
|
form.mode button { font-size: 12px; padding: 0 6px; }
|
||||||
|
|||||||
@@ -14,10 +14,19 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{{range .Services}}
|
{{range .Services}}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="/services/{{.ID}}">{{.DisplayName}}</a>{{if .Self}} <span class="badge">managed outside</span>{{end}}</td>
|
<td><a href="/services/{{.ID}}">{{.DisplayName}}</a>{{if .Self}} <span class="badge">managed outside</span>{{end}}{{if .AvailableRef}} <span class="badge badge-update" title="{{.AvailableRef}}">update available</span>{{end}}</td>
|
||||||
<td><code>{{shortRef .ActualRef}}</code></td>
|
<td><code>{{shortRef .ActualRef}}</code></td>
|
||||||
<td class="status status-{{.Status}}">{{.Status}}</td>
|
<td class="status status-{{.Status}}">{{.Status}}</td>
|
||||||
<td>{{.UpdateMode}}</td>
|
<td>{{.UpdateMode}}
|
||||||
|
<form method="post" action="/services/{{.ID}}/mode" class="mode">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
|
||||||
|
<input type="hidden" name="return" value="overview">
|
||||||
|
{{if eq .UpdateMode "auto"}}<input type="hidden" name="mode" value="manual">
|
||||||
|
<button type="submit">Make manual</button>
|
||||||
|
{{else}}<input type="hidden" name="mode" value="auto">
|
||||||
|
<button type="submit"{{if .AutoBlocked}} disabled title="{{.AutoBlocked}}"{{end}}>Make auto</button>{{end}}
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
{{if .Service.ActualDigest}}<tr><th>Digest</th><td><code>{{.Service.ActualDigest}}</code></td></tr>{{end}}
|
{{if .Service.ActualDigest}}<tr><th>Digest</th><td><code>{{.Service.ActualDigest}}</code></td></tr>{{end}}
|
||||||
<tr><th>Status</th><td class="status status-{{.Service.Status}}">{{.Service.Status}}</td></tr>
|
<tr><th>Status</th><td class="status status-{{.Service.Status}}">{{.Service.Status}}</td></tr>
|
||||||
{{if .Service.DesiredRef}}<tr><th>Desired image</th><td><code>{{shortRef .Service.DesiredRef}}</code></td></tr>{{end}}
|
{{if .Service.DesiredRef}}<tr><th>Desired image</th><td><code>{{shortRef .Service.DesiredRef}}</code></td></tr>{{end}}
|
||||||
|
{{if .Service.AvailableRef}}<tr><th>Available</th><td><code>{{.Service.AvailableRef}}</code> <span class="muted">via {{.Service.AvailableSource}}{{with .Service.AvailableAt}}, {{.Format "2006-01-02 15:04"}}{{end}}</span></td></tr>{{end}}
|
||||||
|
<tr><th>Mode</th><td>{{.Service.UpdateMode}}</td></tr>
|
||||||
{{if .ConfigFiles}}<tr><th>Config files</th><td>{{range .ConfigFiles}}<code>{{.}}</code><br>{{end}}</td></tr>{{end}}
|
{{if .ConfigFiles}}<tr><th>Config files</th><td>{{range .ConfigFiles}}<code>{{.}}</code><br>{{end}}</td></tr>{{end}}
|
||||||
</table>
|
</table>
|
||||||
{{if .Reason}}
|
{{if .Reason}}
|
||||||
@@ -19,6 +21,16 @@
|
|||||||
</form>
|
</form>
|
||||||
<p class="muted">Pulls the current tag again and redeploys the service.</p>
|
<p class="muted">Pulls the current tag again and redeploys the service.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
<form method="post" action="/services/{{.Service.ID}}/mode">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
|
||||||
|
{{if eq .Service.UpdateMode "auto"}}<input type="hidden" name="mode" value="manual">
|
||||||
|
<button type="submit">Make manual</button>
|
||||||
|
<p class="muted">The webhook will only mark new versions as available.</p>
|
||||||
|
{{else}}<input type="hidden" name="mode" value="auto">
|
||||||
|
<button type="submit"{{if .AutoBlocked}} disabled{{end}}>Make auto</button>
|
||||||
|
<p class="muted">{{if .AutoBlocked}}Auto unavailable: {{.AutoBlocked}}.{{else}}The webhook will update this service as soon as CI pushes a new image.{{end}}</p>
|
||||||
|
{{end}}
|
||||||
|
</form>
|
||||||
<h2>Update history</h2>
|
<h2>Update history</h2>
|
||||||
<div id="history" hx-get="/fragments/services/{{.Service.ID}}/jobs" hx-trigger="sse:jobs" hx-swap="innerHTML">
|
<div id="history" hx-get="/fragments/services/{{.Service.ID}}/jobs" hx-trigger="sse:jobs" hx-swap="innerHTML">
|
||||||
{{template "history" .}}
|
{{template "history" .}}
|
||||||
|
|||||||
@@ -11,12 +11,19 @@ import (
|
|||||||
|
|
||||||
type serviceGroup struct {
|
type serviceGroup struct {
|
||||||
Title string
|
Title string
|
||||||
Services []model.Service
|
Services []serviceRow
|
||||||
|
}
|
||||||
|
|
||||||
|
// serviceRow is a service as shown in the overview table.
|
||||||
|
type serviceRow struct {
|
||||||
|
model.Service
|
||||||
|
AutoBlocked string // why the service cannot be set to auto; "" when it can
|
||||||
}
|
}
|
||||||
|
|
||||||
// groupServices groups machine-bound services per agent and Swarm services
|
// groupServices groups machine-bound services per agent and Swarm services
|
||||||
// in one group, keeping the store's order (machines first, Swarm last).
|
// in one group, keeping the store's order (machines first, Swarm last).
|
||||||
func groupServices(services []model.Service) []serviceGroup {
|
// autoBlocked returns the reason a service cannot be set to auto, or "".
|
||||||
|
func groupServices(services []model.Service, autoBlocked func(model.Service) string) []serviceGroup {
|
||||||
var groups []serviceGroup
|
var groups []serviceGroup
|
||||||
index := map[string]int{}
|
index := map[string]int{}
|
||||||
for _, s := range services {
|
for _, s := range services {
|
||||||
@@ -32,7 +39,7 @@ func groupServices(services []model.Service) []serviceGroup {
|
|||||||
index[title] = i
|
index[title] = i
|
||||||
groups = append(groups, serviceGroup{Title: title})
|
groups = append(groups, serviceGroup{Title: title})
|
||||||
}
|
}
|
||||||
groups[i].Services = append(groups[i].Services, s)
|
groups[i].Services = append(groups[i].Services, serviceRow{Service: s, AutoBlocked: autoBlocked(s)})
|
||||||
}
|
}
|
||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user