0da914221d1f50107e06d1f25f1c706a449fbd08
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/README.md b/README.md
2index ad73e578c92182580094fc426d9cf885d9226cd6..48fa8b907f907fcd53dea9b270a5da851c20b621 100644
3--- a/README.md
4+++ b/README.md
5@@ -226,6 +226,9 @@ http:
6 web_ui:
7 enabled: false
8
9+ # Internal worker URL used for current build status badges.
10+ build_status_url: ""
11+
12 # The cross-origin request security options
13 cors:
14 # The allowed cross-origin headers
15@@ -307,6 +310,7 @@ name all in uppercase. Here are some examples:
16 - `SOFT_SERVE_HTTP_LISTEN_ADDR`: HTTP listen address
17 - `SOFT_SERVE_HTTP_PUBLIC_URL`: HTTP public URL used for cloning
18 - `SOFT_SERVE_HTTP_WEB_UI_ENABLED`: Enable the read-only public web UI
19+- `SOFT_SERVE_HTTP_WEB_UI_BUILD_STATUS_URL`: Internal worker URL for current build status badges
20 - `SOFT_SERVE_GIT_MAX_CONNECTIONS`: The number of simultaneous connections to git daemon
21 - `SOFT_SERVE_ANON_ACCESS`: Overrides the `anon-access` setting (see [Authentication](#authentication))
22 - `SOFT_SERVE_ALLOW_KEYLESS`: Overrides the `allow-keyless` setting (see [Authentication](#authentication))
23@@ -320,7 +324,11 @@ Enable it in `config.yaml` with `http.web_ui.enabled: true`, or set
24 `SOFT_SERVE_HTTP_WEB_UI_ENABLED=true`.
25
26 The UI is public-only: it does not provide browser login, sessions, management
27-forms, or write operations. It serves the homepage (`/`), nested repository
28+forms, or write operations. Configure `http.web_ui.build_status_url` (or
29+`SOFT_SERVE_HTTP_WEB_UI_BUILD_STATUS_URL`) only with the internal worker URL,
30+such as `http://builder:8080`; browsers never contact that worker directly.
31+Build badges report the worker's current in-memory state for pushes to `main`.
32+A worker restart clears all previously reported statuses. It serves the homepage (`/`), nested repository
33 overviews (`/{repo}`), file trees and source (`/{repo}/@/tree?ref=&path=`),
34 downloads (`/{repo}/@/raw?ref=&path=`), commit history
35 (`/{repo}/@/commits?ref=&page=`), commit details (`/{repo}/@/commit?hash=`),
36diff --git a/pkg/config/config.go b/pkg/config/config.go
37index 04b271a75c3f885dc72df163c422204de5f02112..60c1719a84db8862f9d0c0871b0f4889bb004091 100644
38--- a/pkg/config/config.go
39+++ b/pkg/config/config.go
40@@ -73,7 +73,8 @@ type CORSConfig struct {
41
42 // WebUIConfig is the configuration for the public web UI.
43 type WebUIConfig struct {
44- Enabled bool `env:"ENABLED" yaml:"enabled"`
45+ Enabled bool `env:"ENABLED" yaml:"enabled"`
46+ BuildStatusURL string `env:"BUILD_STATUS_URL" yaml:"build_status_url"`
47 }
48
49 // HTTPConfig is the HTTP configuration for the server.
50@@ -245,6 +246,7 @@ func (c *Config) Environ() []string {
51 fmt.Sprintf("SOFT_SERVE_HTTP_CORS_ALLOWED_ORIGINS=%s", strings.Join(c.HTTP.CORS.AllowedOrigins, ",")),
52 fmt.Sprintf("SOFT_SERVE_HTTP_CORS_ALLOWED_METHODS=%s", strings.Join(c.HTTP.CORS.AllowedMethods, ",")),
53 fmt.Sprintf("SOFT_SERVE_HTTP_WEB_UI_ENABLED=%t", c.HTTP.WebUI.Enabled),
54+ fmt.Sprintf("SOFT_SERVE_HTTP_WEB_UI_BUILD_STATUS_URL=%s", c.HTTP.WebUI.BuildStatusURL),
55 fmt.Sprintf("SOFT_SERVE_STATS_ENABLED=%t", c.Stats.Enabled),
56 fmt.Sprintf("SOFT_SERVE_STATS_LISTEN_ADDR=%s", c.Stats.ListenAddr),
57 fmt.Sprintf("SOFT_SERVE_LOG_FORMAT=%s", c.Log.Format),
58@@ -416,7 +418,7 @@ func DefaultConfig() *Config {
59 Enabled: true,
60 ListenAddr: ":23232",
61 PublicURL: "http://localhost:23232",
62- WebUI: WebUIConfig{Enabled: false},
63+ WebUI: WebUIConfig{Enabled: false, BuildStatusURL: ""},
64 CORS: CORSConfig{
65 AllowedHeaders: []string{"Accept", "Accept-Language", "Content-Language", "Content-Type", "Origin", "X-Requested-With", "User-Agent", "Authorization", "Access-Control-Request-Method", "Access-Control-Allow-Origin"},
66 AllowedMethods: []string{"GET", "HEAD", "POST", "PUT", "OPTIONS"},
67diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
68index a7b051eab95305398f42185d393e6a44f56c2ddf..ef27ca170487b78c8418df86c343708efe7d6754 100644
69--- a/pkg/config/config_test.go
70+++ b/pkg/config/config_test.go
71@@ -2,6 +2,7 @@ package config
72
73 import (
74 "os"
75+ "strings"
76 "testing"
77
78 "github.com/charmbracelet/soft-serve/pkg/access"
79@@ -96,28 +97,37 @@ func TestParseWebUIEnabled(t *testing.T) {
80
81 cfg := DefaultConfig()
82 path := t.TempDir() + "/config.yaml"
83- is.NoErr(os.WriteFile(path, []byte("http:\n web_ui:\n enabled: true\n"), 0o600))
84+ is.NoErr(os.WriteFile(path, []byte("http:\n web_ui:\n enabled: true\n build_status_url: http://builder:8080\n"), 0o600))
85 is.NoErr(parseFile(cfg, path))
86 is.Equal(cfg.HTTP.WebUI.Enabled, true)
87+ is.Equal(cfg.HTTP.WebUI.BuildStatusURL, "http://builder:8080")
88
89 is.NoErr(os.Setenv("SOFT_SERVE_HTTP_WEB_UI_ENABLED", "true"))
90 t.Cleanup(func() {
91 is.NoErr(os.Unsetenv("SOFT_SERVE_HTTP_WEB_UI_ENABLED"))
92+ is.NoErr(os.Unsetenv("SOFT_SERVE_HTTP_WEB_UI_BUILD_STATUS_URL"))
93 })
94 cfg = DefaultConfig()
95 is.NoErr(cfg.ParseEnv())
96 is.Equal(cfg.HTTP.WebUI.Enabled, true)
97+
98+ is.NoErr(os.Setenv("SOFT_SERVE_HTTP_WEB_UI_BUILD_STATUS_URL", "http://builder:8080"))
99+ cfg = DefaultConfig()
100+ is.NoErr(cfg.ParseEnv())
101+ is.Equal(cfg.HTTP.WebUI.BuildStatusURL, "http://builder:8080")
102 }
103
104 func TestWebUIEnviron(t *testing.T) {
105 cfg := DefaultConfig()
106 cfg.HTTP.WebUI.Enabled = true
107- for _, env := range cfg.Environ() {
108- if env == "SOFT_SERVE_HTTP_WEB_UI_ENABLED=true" {
109- return
110- }
111+ cfg.HTTP.WebUI.BuildStatusURL = "http://builder:8080"
112+ environment := strings.Join(cfg.Environ(), "\n")
113+ if !strings.Contains(environment, "SOFT_SERVE_HTTP_WEB_UI_ENABLED=true") {
114+ t.Error("Config.Environ() did not include SOFT_SERVE_HTTP_WEB_UI_ENABLED=true")
115+ }
116+ if !strings.Contains(environment, "SOFT_SERVE_HTTP_WEB_UI_BUILD_STATUS_URL=http://builder:8080") {
117+ t.Error("Config.Environ() did not include SOFT_SERVE_HTTP_WEB_UI_BUILD_STATUS_URL")
118 }
119- t.Error("Config.Environ() did not include SOFT_SERVE_HTTP_WEB_UI_ENABLED=true")
120 }
121
122 func TestParseDefaultRepo(t *testing.T) {
123diff --git a/pkg/config/file.go b/pkg/config/file.go
124index 5cd3698a9278804384b8c131f9dbdcaadae338fb..d3e3ca6dce4f7bc82b660c2b6f9e5689052732d5 100644
125--- a/pkg/config/file.go
126+++ b/pkg/config/file.go
127@@ -93,6 +93,9 @@ http:
128 web_ui:
129 enabled: {{ .HTTP.WebUI.Enabled }}
130
131+ # Internal worker URL used for current build status badges.
132+ build_status_url: "{{ .HTTP.WebUI.BuildStatusURL }}"
133+
134 # The cross-origin request security options
135 cors:
136 # The allowed cross-origin headers
137diff --git a/pkg/config/file_test.go b/pkg/config/file_test.go
138index f067869c85ad8df18bcc0e26918999fe9572f944..62d4116c9de1d935eb9c998c5001d8dcb60e9f4a 100644
139--- a/pkg/config/file_test.go
140+++ b/pkg/config/file_test.go
141@@ -18,7 +18,7 @@ func TestNewConfigFile(t *testing.T) {
142 }
143
144 func TestNewConfigFileIncludesWebUI(t *testing.T) {
145- if s := newConfigFile(DefaultConfig()); !strings.Contains(s, "web_ui:\n enabled: false") {
146- t.Errorf("generated config does not include disabled web_ui: %q", s)
147+ if s := newConfigFile(DefaultConfig()); !strings.Contains(s, "web_ui:\n enabled: false\n\n # Internal worker URL used for current build status badges.\n build_status_url: \"\"") {
148+ t.Errorf("generated config does not include web_ui settings: %q", s)
149 }
150 }
151diff --git a/pkg/web/build_status_test.go b/pkg/web/build_status_test.go
152new file mode 100644
153index 0000000000000000000000000000000000000000..49d8451bffdeb672bb1c01f61b58a9d1c17d0510
154--- /dev/null
155+++ b/pkg/web/build_status_test.go
156@@ -0,0 +1,184 @@
157+package web
158+
159+import (
160+ "context"
161+ "encoding/json"
162+ "net/http"
163+ "net/http/httptest"
164+ "sort"
165+ "strings"
166+ "sync"
167+ "testing"
168+ "time"
169+
170+ "github.com/charmbracelet/soft-serve/pkg/access"
171+ "github.com/charmbracelet/soft-serve/pkg/config"
172+ "github.com/charmbracelet/soft-serve/pkg/proto"
173+)
174+
175+const buildStatusTestSHA = "0123456789abcdef0123456789abcdef01234567"
176+
177+type buildStatusTestResponse struct {
178+ Statuses map[string]buildStatusTestEntry `json:"statuses"`
179+}
180+
181+type buildStatusTestEntry struct {
182+ SHA string `json:"sha"`
183+ State string `json:"state"`
184+ QueuedAt string `json:"queued_at"`
185+}
186+
187+func TestHomepageRendersBuildStatusesOnlyForVisiblePublicRepositories(t *testing.T) {
188+ var requested []string
189+ server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
190+ if request.Method != http.MethodPost || request.URL.Path != "/build-statuses" {
191+ t.Fatalf("worker request = %s %s", request.Method, request.URL.Path)
192+ }
193+ var payload struct {
194+ Repositories []string `json:"repositories"`
195+ }
196+ if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
197+ t.Fatal(err)
198+ }
199+ requested = append(requested, payload.Repositories...)
200+ response := buildStatusTestResponse{Statuses: map[string]buildStatusTestEntry{
201+ "queued": buildStatusEntry("queued"),
202+ "running": buildStatusEntry("running"),
203+ "passed": buildStatusEntry("passed"),
204+ "failed": buildStatusEntry("failed"),
205+ }}
206+ writer.Header().Set("Content-Type", "application/json")
207+ if err := json.NewEncoder(writer).Encode(response); err != nil {
208+ t.Fatal(err)
209+ }
210+ }))
211+ defer server.Close()
212+
213+ ctx, be, _ := newLFSTestContext(t)
214+ configureBuildStatusTest(ctx, server.URL)
215+ owner, err := be.CreateUser(ctx, "owner", proto.UserOptions{})
216+ if err != nil {
217+ t.Fatal(err)
218+ }
219+ for _, repository := range []string{"queued", "running", "passed", "failed", "missing"} {
220+ createHomepageRepository(t, be, ctx, repository, owner, proto.RepositoryOptions{})
221+ }
222+ createHomepageRepository(t, be, ctx, "private", owner, proto.RepositoryOptions{Private: true})
223+ createHomepageRepository(t, be, ctx, "hidden", owner, proto.RepositoryOptions{Hidden: true})
224+
225+ response := httptest.NewRecorder()
226+ NewRouter(ctx).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/", nil))
227+ if response.Code != http.StatusOK {
228+ t.Fatalf("homepage = %d: %s", response.Code, response.Body.String())
229+ }
230+ for _, label := range []string{"Queued", "Building", "Passed", "Failed", "No build"} {
231+ if !strings.Contains(response.Body.String(), "Build status: <span") || !strings.Contains(response.Body.String(), ">"+label+"</span>") {
232+ t.Errorf("homepage did not render %q: %s", label, response.Body.String())
233+ }
234+ }
235+ sort.Strings(requested)
236+ if got, want := strings.Join(requested, ","), "failed,missing,passed,queued,running"; got != want {
237+ t.Errorf("worker status requested repositories = %q, want %q", got, want)
238+ }
239+}
240+
241+func TestRepositoryOverviewRendersBuildStatusAndChecksAccessBeforeWorker(t *testing.T) {
242+ var mutex sync.Mutex
243+ requests := 0
244+ server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
245+ mutex.Lock()
246+ requests++
247+ mutex.Unlock()
248+ var payload struct {
249+ Repositories []string `json:"repositories"`
250+ }
251+ if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
252+ t.Fatal(err)
253+ }
254+ response := buildStatusTestResponse{Statuses: make(map[string]buildStatusTestEntry)}
255+ for _, repository := range payload.Repositories {
256diff --git a/pkg/web/pages/assets/site.css b/pkg/web/pages/assets/site.css
257index aaf969d0e9251327b95d3d4e8b04a5145cc942cf..d481303fcb59f4e8b371964873aff2ce24cc5310 100644
258--- a/pkg/web/pages/assets/site.css
259+++ b/pkg/web/pages/assets/site.css
260@@ -61,6 +61,25 @@ a:focus-visible {
261 font-size: 0.9rem;
262 }
263
264+.build-status {
265+ font-size: 0.9rem;
266+}
267+
268+.status-badge {
269+ border-radius: 0.25rem;
270+ color: #111827;
271+ display: inline-block;
272+ font-weight: 600;
273+ padding: 0.1rem 0.4rem;
274+}
275+
276+.build-status-queued { background: #fef3c7; }
277+.build-status-running { background: #bfdbfe; }
278+.build-status-passed { background: #bbf7d0; }
279+.build-status-failed { background: #fecaca; }
280+.build-status-none,
281+.build-status-unavailable { background: #e5e7eb; }
282+
283 .markdown > :first-child {
284 margin-top: 0;
285 }
286diff --git a/pkg/web/pages/browser.go b/pkg/web/pages/browser.go
287index 6b03bc1a2ac5fa44c92c49c51d896021638dd9b3..ee35cabb9d69a681528e031f45e15879f2462734 100644
288--- a/pkg/web/pages/browser.go
289+++ b/pkg/web/pages/browser.go
290@@ -29,6 +29,7 @@ type repositoryPage struct {
291 RefsURL string
292 Empty bool
293 Readme template.HTML
294+ BuildStatus repositoryBuildStatus
295 }
296
297 type treePage struct {
298@@ -76,6 +77,7 @@ func repositoryOverview(w http.ResponseWriter, r *http.Request) {
299 if title == "" {
300 title = repo.Name()
301 }
302+ status := LoadBuildStatuses(r.Context(), cfg.HTTP.WebUI.BuildStatusURL, []string{repo.Name()})
303 page := repositoryPage{
304 ServerName: cfg.Name,
305 Repository: repo.Name(),
306@@ -84,6 +86,7 @@ func repositoryOverview(w http.ResponseWriter, r *http.Request) {
307 Empty: empty,
308 SSHCloneURL: common.RepoURL(cfg.SSH.PublicURL, repo.Name()),
309 HTTPCloneURL: common.RepoURL(cfg.HTTP.PublicURL, repo.Name()),
310+ BuildStatus: status[repo.Name()],
311 }
312 if !empty {
313 page.Ref = ref.Name().String()
314diff --git a/pkg/web/pages/controller.go b/pkg/web/pages/controller.go
315index 478456fd32041a50c15cbb5997ac075eabdf4125..0a9458456dd8040877882d1c1209c68fb5b31ff1 100644
316--- a/pkg/web/pages/controller.go
317+++ b/pkg/web/pages/controller.go
318@@ -46,6 +46,7 @@ type repositoryCard struct {
319 Description string
320 URL string
321 UpdatedAt string
322+ BuildStatus repositoryBuildStatus
323 }
324
325 func home(w http.ResponseWriter, r *http.Request) {
326@@ -73,15 +74,20 @@ func newHomePage(ctx context.Context) (homePage, error) {
327 ServerName: cfg.Name,
328 Cards: make([]repositoryCard, 0, len(repos)),
329 }
330+ names := make([]string, 0, len(repos))
331 for _, repo := range repos {
332- page.Cards = append(page.Cards, newRepositoryCard(repo))
333+ names = append(names, repo.Name())
334+ }
335+ statuses := LoadBuildStatuses(ctx, cfg.HTTP.WebUI.BuildStatusURL, names)
336+ for _, repo := range repos {
337+ page.Cards = append(page.Cards, newRepositoryCard(repo, statuses[repo.Name()]))
338 }
339
340 page.Profile = loadProfile(ctx)
341 return page, nil
342 }
343
344-func newRepositoryCard(repo proto.Repository) repositoryCard {
345+func newRepositoryCard(repo proto.Repository, status repositoryBuildStatus) repositoryCard {
346 title := repo.ProjectName()
347 if title == "" {
348 title = repo.Name()
349@@ -97,6 +103,7 @@ func newRepositoryCard(repo proto.Repository) repositoryCard {
350 Description: repo.Description(),
351 URL: RepositoryURL(repo.Name()),
352 UpdatedAt: updatedAt,
353+ BuildStatus: status,
354 }
355 }
356
357diff --git a/pkg/web/pages/status.go b/pkg/web/pages/status.go
358new file mode 100644
359index 0000000000000000000000000000000000000000..f1f7603c1a615051e21a1f4236052445954883b7
360--- /dev/null
361+++ b/pkg/web/pages/status.go
362@@ -0,0 +1,155 @@
363+package pages
364+
365+import (
366+ "bytes"
367+ "context"
368+ "encoding/json"
369+ "io"
370+ "net/http"
371+ "net/url"
372+ "time"
373+)
374+
375+const (
376+ buildStatusRequestBatchSize = 100
377+ maxBuildStatusResponseSize = 1 << 20
378+)
379+
380+var buildStatusHTTPClient = &http.Client{Timeout: 2 * time.Second}
381+
382+type repositoryBuildStatus struct {
383+ Label string
384+ Class string
385+}
386+
387+type workerBuildStatusRequest struct {
388+ Repositories []string `json:"repositories"`
389+}
390+
391+type workerBuildStatusResponse struct {
392+ Statuses map[string]workerBuildStatus `json:"statuses"`
393+}
394+
395+type workerBuildStatus struct {
396+ SHA string `json:"sha"`
397+ State string `json:"state"`
398+ QueuedAt time.Time `json:"queued_at"`
399+}
400+
401+// LoadBuildStatuses obtains current worker status without allowing an unavailable worker to fail a page.
402+func LoadBuildStatuses(ctx context.Context, workerURL string, repositories []string) map[string]repositoryBuildStatus {
403+ statuses := make(map[string]repositoryBuildStatus, len(repositories))
404+ for _, repository := range repositories {
405+ statuses[repository] = noBuildStatus()
406+ }
407+ if workerURL == "" || len(repositories) == 0 {
408+ return statuses
409+ }
410+
411+ endpoint, err := buildStatusEndpoint(workerURL)
412+ if err != nil {
413+ markBuildStatusUnavailable(statuses, repositories)
414+ return statuses
415+ }
416+ for start := 0; start < len(repositories); start += buildStatusRequestBatchSize {
417+ end := start + buildStatusRequestBatchSize
418+ if end > len(repositories) {
419+ end = len(repositories)
420+ }
421+ batch := repositories[start:end]
422+ workerStatuses, err := requestBuildStatuses(ctx, endpoint, batch)
423+ if err != nil {
424+ markBuildStatusUnavailable(statuses, batch)
425+ continue
426+ }
427+ for repository, status := range workerStatuses {
428+ statuses[repository] = status
429+ }
430+ }
431+ return statuses
432+}
433+
434+func buildStatusEndpoint(rawURL string) (string, error) {
435+ base, err := url.Parse(rawURL)
436+ if err != nil || (base.Scheme != "http" && base.Scheme != "https") || base.Host == "" {
437+ return "", ErrNotFound
438+ }
439+ return base.ResolveReference(&url.URL{Path: "/build-statuses"}).String(), nil
440+}
441+
442+func requestBuildStatuses(ctx context.Context, endpoint string, repositories []string) (map[string]repositoryBuildStatus, error) {
443+ body, err := json.Marshal(workerBuildStatusRequest{Repositories: repositories})
444+ if err != nil {
445+ return nil, err
446+ }
447+ request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
448+ if err != nil {
449+ return nil, err
450+ }
451+ request.Header.Set("Content-Type", "application/json")
452+ response, err := buildStatusHTTPClient.Do(request)
453+ if err != nil {
454+ return nil, err
455+ }
456+ defer response.Body.Close()
457+ if response.StatusCode != http.StatusOK {
458+ return nil, ErrNotFound
459+ }
460+
461+ limitedBody := &io.LimitedReader{R: response.Body, N: maxBuildStatusResponseSize + 1}
462diff --git a/pkg/web/pages/templates/home.gohtml b/pkg/web/pages/templates/home.gohtml
463index 6aae21f17c903ba2cc03d61a11c800f8b47009dc..3cefb5f3c459c1c1d1329bcb50971dfc51a32c77 100644
464--- a/pkg/web/pages/templates/home.gohtml
465+++ b/pkg/web/pages/templates/home.gohtml
466@@ -38,6 +38,7 @@
467 <h3><a href="{{ .URL }}">{{ .Title }}</a></h3>
468 {{ if ne .Title .Name }}<p class="repository-name">{{ .Name }}</p>{{ end }}
469 {{ if .Description }}<p>{{ .Description }}</p>{{ end }}
470+ <p class="build-status">Build status: <span class="status-badge {{ .BuildStatus.Class }}">{{ .BuildStatus.Label }}</span></p>
471 {{ if .UpdatedAt }}<p class="updated">Updated <time>{{ .UpdatedAt }}</time></p>{{ end }}
472 </article>
473 </li>
474diff --git a/pkg/web/pages/templates/repository.gohtml b/pkg/web/pages/templates/repository.gohtml
475index 23973ab9e013f942245242cb27a765a9aafbd107..89dd0949c68182b51bd66aaaabd16117b9e57b9d 100644
476--- a/pkg/web/pages/templates/repository.gohtml
477+++ b/pkg/web/pages/templates/repository.gohtml
478@@ -13,6 +13,7 @@
479 <h1>{{ .Title }}</h1>
480 {{ if ne .Title .Repository }}<p class="repository-name">{{ .Repository }}</p>{{ end }}
481 {{ if .Description }}<p>{{ .Description }}</p>{{ end }}
482+ <p class="build-status">Build status: <span class="status-badge {{ .BuildStatus.Class }}">{{ .BuildStatus.Label }}</span></p>
483 {{ if .Empty }}
484 <p>This repository is empty.</p>
485 {{ else }}