6 Commits
Author SHA1 Message Date
clawbot d3b9c6fca9 Wrap the top bar's buttons onto a second row when narrow (closes #272)
Check / check (pull_request) Successful in 4m21s
The top bar's two sides were held apart only by justify-between, with
no gap and no wrapping, so at 390 px "by @sneak" ended where the New
App button began. The bar's row now wraps and has a gap: in a window
too narrow for one row, New App and Logout move to a second row; on
wider windows the bar looks as before.

Model: opus-5-5
2026-10-02 07:04:17 +02:00
clawbot 23f378cfde Allow dots in app names (closes #260)
Check / check (pull_request) Successful in 4m19s
App names may now contain dots, such as sneak.berlin: runs of
lowercase letters and numbers joined by single dots or by hyphens,
2 to 63 characters. Docker accepts every such name in the app's image
name, upaas-<name>; a dot needs a letter or number on both sides
because Docker requires it. The rule also keeps a dot off either end,
so the name is safe as a directory and log file name.

The new and edit app forms use the same pattern. Their old one was not
a valid regular expression under the flag browsers compile it with, so
browsers ignored it.

Model: opus-5-5
2026-10-02 06:41:30 +02:00
clawbot db3b47e423 Keep the Applications list's table inside its card (closes #262)
Check / check (pull_request) Successful in 5m7s
Table cells never wrap, so a long repository URL made the table wider
than its card, which hides what does not fit and so cut off the Actions
column and the Deploy buttons. The repository URL now wraps within its
column, and the card scrolls sideways when the table still does not
fit. A handler test checks both. Every class used was already in the
committed static/css/tailwind.css.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-02 06:12:23 +02:00
clawbot b101bc1a80 Keep git-ignored files and data/ out of the Docker build context (closes #266)
Check / check (pull_request) Successful in 4m12s
.dockerignore now lists every .gitignore pattern, each with **/ so Docker
matches it in every directory as git does, plus the top-level data/
directory. git-ignored secrets such as .env.local, *.key files and
data/session.key no longer reach the build stages or the build cache. A last
!.git/** line sends all of .git again, since git never applies these patterns
inside it, so a branch named like fix/session.key still resolves. No tracked
file is listed, so the version still comes from git describe without -dirty.

data/, where upaasd keeps its database and session key when run from the
checkout, is now git-ignored.

Model: opus-5-5
2026-10-02 05:43:40 +02:00
clawbot 5c836c085d Keep .git/config out of the Docker build context (closes #269)
Check / check (pull_request) Successful in 5m3s
.git goes into the build so `make build` can stamp the version, and with
it went .git/config, where a remote URL can carry a credential that then
stays in the builder stage's layers on the build host. `git describe`
does not need it, so .dockerignore now leaves it out.

Model: opus-5-5
2026-10-02 04:43:03 +02:00
clawbot 5168db69d9 Read the architecture at run time instead of a Buildarch ldflag (closes #259)
Check / check (pull_request) Successful in 5m19s
The Makefile no longer passes the architecture to the linker. The
Buildarch variable in main and the field and setter in globals are
gone; the startup log line reports runtime.GOARCH as `arch`.
CONVENTIONS.md drops Buildarch from its main, globals, logger and
Makefile examples, as the sneak/prompts conventions do.

Model: opus-5-5
2026-10-02 03:21:51 +02:00
15 changed files with 325 additions and 66 deletions
+32 -5
View File
@@ -1,8 +1,35 @@
# .git is sent so that `make build` in the Dockerfile can stamp the commit into # .git is sent so that `make build` in the Dockerfile can stamp the commit into
# upaas. List no tracked file here: git would see it as deleted in the build and # upaas. List no tracked file here: git would see it as deleted in the build and
# the version would end in -dirty. # the version would end in -dirty.
.env
bin/ # The patterns of .gitignore; **/ makes Docker match them in every directory.
.vscode/ **/.DS_Store
.idea/ **/Thumbs.db
*.test **/*.swp
**/*.swo
**/*~
**/*.bak
**/.idea/
**/.vscode/
**/*.sublime-*
**/node_modules/
**/.env
**/.env.*
**/*.pem
**/*.key
**/bin/
**/*.exe
**/*.exe~
**/*.dll
**/*.so
**/*.dylib
**/*.test
**/*.out
/data/
# Git never applies its ignore patterns inside .git; send all of it again.
!.git/**
# .git is sent without its config, because a remote URL there can carry a
# credential; `git describe` does not need it. Keep this after !.git/**.
.git/config
+5
View File
@@ -1,3 +1,5 @@
# .dockerignore repeats these patterns; change both together.
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
@@ -29,3 +31,6 @@ bin/
*.dylib *.dylib
*.test *.test
*.out *.out
# upaasd's data directory when it runs from the checkout (UPAAS_DATA_DIR default)
/data/
+12 -20
View File
@@ -115,15 +115,13 @@ import (
) )
var ( var (
Appname string = "CHANGEME" Appname string = "CHANGEME"
Version string Version string
Buildarch string
) )
func main() { func main() {
globals.Appname = Appname globals.Appname = Appname
globals.Version = Version globals.Version = Version
globals.Buildarch = Buildarch
fx.New( fx.New(
fx.Provide( fx.Provide(
@@ -823,7 +821,7 @@ func (l *Logger) Identify() {
l.log.Info("starting", l.log.Info("starting",
"appname", l.params.Globals.Appname, "appname", l.params.Globals.Appname,
"version", l.params.Globals.Version, "version", l.params.Globals.Version,
"buildarch", l.params.Globals.Buildarch, "arch", runtime.GOARCH,
) )
} }
``` ```
@@ -943,23 +941,20 @@ import "go.uber.org/fx"
// Package-level variables (set from main) // Package-level variables (set from main)
var ( var (
Appname string Appname string
Version string Version string
Buildarch string
) )
// Struct for DI // Struct for DI
type Globals struct { type Globals struct {
Appname string Appname string
Version string Version string
Buildarch string
} }
func New(lc fx.Lifecycle) (*Globals, error) { func New(lc fx.Lifecycle) (*Globals, error) {
n := &Globals{ n := &Globals{
Appname: Appname, Appname: Appname,
Buildarch: Buildarch, Version: Version,
Version: Version,
} }
return n, nil return n, nil
} }
@@ -970,15 +965,13 @@ func New(lc fx.Lifecycle) (*Globals, error) {
```go ```go
// cmd/httpd/main.go // cmd/httpd/main.go
var ( var (
Appname string = "CHANGEME" // Default, overridden by build Appname string = "CHANGEME" // Default, overridden by build
Version string // Set at build time Version string // Set at build time
Buildarch string // Set at build time
) )
func main() { func main() {
globals.Appname = Appname globals.Appname = Appname
globals.Version = Version globals.Version = Version
globals.Buildarch = Buildarch
// ... // ...
} }
``` ```
@@ -989,10 +982,9 @@ Use ldflags to inject version information at build time:
```makefile ```makefile
VERSION := $(shell git describe --tags --always) VERSION := $(shell git describe --tags --always)
BUILDARCH := $(shell go env GOARCH)
build: build:
go build -ldflags "-X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)" ./cmd/httpd go build -ldflags "-X main.Version=$(VERSION)" ./cmd/httpd
``` ```
--- ---
+1 -2
View File
@@ -2,8 +2,7 @@
BINARY := upaasd BINARY := upaasd
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
BUILDARCH := $(shell go env GOARCH) LDFLAGS := -X main.Version=$(VERSION)
LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
all: check build all: check build
+28 -4
View File
@@ -20,11 +20,35 @@ regress.
# Completed Steps # Completed Steps
- 2026-10-02: In a window too narrow for the top bar, such as 390 px, the New
App and Logout buttons move to a second row instead of running into "by
@sneak"; the bar keeps a gap between its two sides at every width (#272).
- 2026-10-02: App names may contain dots, such as `sneak.berlin`: lowercase - 2026-10-02: App names may contain dots, such as `sneak.berlin`: lowercase
letters and numbers joined by single dots or by hyphens, 2 to 63 characters, letters and numbers joined by single dots or by hyphens, 2 to 63 characters.
which is Docker's rule for the image name `upaas-<name>`. The new and edit app Docker accepts every such name in the image name `upaas-<name>`; a dot needs a
forms check the same rule; browsers ignored their old pattern, which was not a letter or number on both sides because Docker requires it. The new and edit
valid regular expression there (#260). app forms check the same rule; browsers ignored their old pattern, which was
not a valid regular expression there (#260).
- 2026-10-02: On the Applications list, a long repository URL now wraps within
its column instead of making the table wider than its card, which cut off the
Actions column and the Deploy buttons. In a window too narrow for the table,
the card scrolls sideways instead of cutting the table off (#262).
- 2026-10-02: `docker build .` no longer sends git-ignored files, such as
`.env.local`, `*.key` files or upaasd's `data/` directory with its session
key, into the build stages and the build cache: `.dockerignore` now leaves out
everything `.gitignore` does, and `data/` is git-ignored (#266).
- 2026-10-02: `.dockerignore` leaves out `.git/config`, so a remote URL there
that carries a credential no longer goes into the Docker build; the image
still shows the commit it was built from (#269).
- 2026-10-02: The build no longer passes the CPU architecture in: upaas reads it
from Go's `runtime.GOARCH` when it runs, and the startup log line reports it
as `arch`. `CONVENTIONS.md` follows the updated conventions in `sneak/prompts`
(#259).
- 2026-10-01: Built images are tagged `upaas-<app>:<short hash>`, git's short - 2026-10-01: Built images are tagged `upaas-<app>:<short hash>`, git's short
form of the commit built, instead of the deployment number. A redeploy of a form of the commit built, instead of the deployment number. A redeploy of a
+2 -4
View File
@@ -25,15 +25,13 @@ import (
// Build-time variables injected by linker flags (-ldflags). // Build-time variables injected by linker flags (-ldflags).
// These must be exported package-level variables for the build system. // These must be exported package-level variables for the build system.
var ( var (
Appname = "upaas" //nolint:gochecknoglobals // build-time variable Appname = "upaas" //nolint:gochecknoglobals // build-time variable
Version string //nolint:gochecknoglobals // build-time variable Version string //nolint:gochecknoglobals // build-time variable
Buildarch string //nolint:gochecknoglobals // build-time variable
) )
func main() { func main() {
globals.SetAppname(Appname) globals.SetAppname(Appname)
globals.SetVersion(Version) globals.SetVersion(Version)
globals.SetBuildarch(Buildarch)
fx.New( fx.New(
fx.Provide( fx.Provide(
+7 -18
View File
@@ -12,17 +12,15 @@ import (
// //
//nolint:gochecknoglobals // Required for ldflags injection at build time //nolint:gochecknoglobals // Required for ldflags injection at build time
var ( var (
mu sync.RWMutex mu sync.RWMutex
appname string appname string
version string version string
buildarch string
) )
// Globals holds build-time variables for dependency injection. // Globals holds build-time variables for dependency injection.
type Globals struct { type Globals struct {
Appname string Appname string
Version string Version string
Buildarch string
} }
// New creates a new Globals instance from package-level variables. // New creates a new Globals instance from package-level variables.
@@ -31,9 +29,8 @@ func New(_ fx.Lifecycle) (*Globals, error) {
defer mu.RUnlock() defer mu.RUnlock()
return &Globals{ return &Globals{
Appname: appname, Appname: appname,
Version: version, Version: version,
Buildarch: buildarch,
}, nil }, nil
} }
@@ -52,11 +49,3 @@ func SetVersion(ver string) {
version = ver version = ver
} }
// SetBuildarch sets the build architecture (used for testing and main init).
func SetBuildarch(arch string) {
mu.Lock()
defer mu.Unlock()
buildarch = arch
}
+3 -2
View File
@@ -14,8 +14,9 @@ const (
) )
// validAppNameRe matches runs of lowercase letters and digits joined by // validAppNameRe matches runs of lowercase letters and digits joined by
// single dots or by hyphens, such as "my-app" or "sneak.berlin". This is // single dots or by hyphens, such as "my-app" or "sneak.berlin". Docker
// Docker's rule for an image name, and the app's image is upaas-<name>. // accepts every name it allows as the app's image name, upaas-<name>; a
// dot needs a letter or digit on both sides because Docker requires it.
// It also keeps the name from being "." or ".." or starting or ending // It also keeps the name from being "." or ".." or starting or ending
// with a dot, so it is safe as a directory and file name. The pattern // with a dot, so it is safe as a directory and file name. The pattern
// attribute of the name field on the new and edit app forms is the same. // attribute of the name field on the new and edit app forms is the same.
@@ -1,7 +1,16 @@
package handlers //nolint:testpackage // testing unexported validateAppName package handlers //nolint:testpackage // testing unexported validateAppName
import ( import (
"bytes"
"strconv"
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/templates"
) )
func TestValidateAppName(t *testing.T) { func TestValidateAppName(t *testing.T) {
@@ -63,3 +72,42 @@ func TestValidateAppName(t *testing.T) {
}) })
} }
} }
func TestValidateAppNameErrorMentionsDots(t *testing.T) {
t.Parallel()
err := validateAppName("a..b")
require.ErrorIs(t, err, errAppNamePattern)
assert.Contains(t, err.Error(), "dots")
}
// TestAppFormsCheckAppNameLikeServer checks that the name field on the new
// and edit app forms has the server's pattern and length limits, and that
// its hint mentions dots.
func TestAppFormsCheckAppNameLikeServer(t *testing.T) {
t.Parallel()
pattern := strings.TrimSuffix(strings.TrimPrefix(validAppNameRe.String(), "^"), "$")
pages := map[string]map[string]any{
"app_new.html": {},
"app_edit.html": {dataKeyApp: &models.App{}},
}
for page, data := range pages {
var out bytes.Buffer
require.NoError(t, templates.GetParsed().ExecuteTemplate(&out, page, data))
// The name field and its hint, up to the end of their div.
_, field, found := strings.Cut(out.String(), `id="name"`)
require.True(t, found, page)
field, _, _ = strings.Cut(field, "</div>")
assert.Contains(t, field, `pattern="`+pattern+`"`, page)
assert.Contains(t, field, `minlength="`+strconv.Itoa(appNameMinLength)+`"`, page)
assert.Contains(t, field, `maxlength="`+strconv.Itoa(appNameMaxLength)+`"`, page)
assert.Contains(t, field, "hyphens, and dots", page)
}
}
@@ -0,0 +1,77 @@
package handlers_test
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/upaas/internal/service/app"
)
// TestDashboardTableFitsCard checks that the card around the app table
// scrolls sideways instead of hiding what does not fit, and that a long
// repository URL wraps instead of pushing the action buttons out.
func TestDashboardTableFitsCard(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
repoURL := "https://git.example.com/user/" +
"a-repository-name-long-enough-to-push-the-action-buttons-out.git"
_, err := testCtx.appSvc.CreateApp(t.Context(), app.CreateAppInput{
Name: "long-url-app",
RepoURL: repoURL,
})
require.NoError(t, err)
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
recorder := httptest.NewRecorder()
testCtx.handlers.HandleDashboard().ServeHTTP(recorder, request)
require.Equal(t, http.StatusOK, recorder.Code)
body := recorder.Body.String()
beforeTable, _, found := strings.Cut(body, `<table class="table">`)
require.True(t, found, "dashboard has no app table")
cardTag := beforeTable[strings.LastIndex(beforeTable, "<div"):]
assert.Contains(t, cardTag, "overflow-x-auto")
assert.NotContains(t, cardTag, "overflow-hidden")
beforeURL, _, found := strings.Cut(body, ">"+repoURL+"</td>")
require.True(t, found, "dashboard has no repository cell")
cellTag := beforeURL[strings.LastIndex(beforeURL, "<td"):]
assert.Contains(t, cellTag, "whitespace-normal")
assert.Contains(t, cellTag, "break-all")
}
// TestTopBarWrapsWhenNarrow checks that the top bar keeps a gap between the
// µPaaS title and the New App and Logout buttons, and puts the buttons on a
// second row in a window too narrow for one, instead of letting them meet.
func TestTopBarWrapsWhenNarrow(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
recorder := httptest.NewRecorder()
testCtx.handlers.HandleDashboard().ServeHTTP(recorder, request)
require.Equal(t, http.StatusOK, recorder.Code)
_, afterNav, found := strings.Cut(recorder.Body.String(), `<nav class="app-bar">`)
require.True(t, found, "dashboard has no top bar")
rowTag, _, _ := strings.Cut(strings.TrimSpace(afterNav), ">")
assert.Contains(t, rowTag, "flex-wrap")
assert.Contains(t, rowTag, "gap-")
}
+2 -1
View File
@@ -4,6 +4,7 @@ package logger
import ( import (
"log/slog" "log/slog"
"os" "os"
"runtime"
"go.uber.org/fx" "go.uber.org/fx"
@@ -81,6 +82,6 @@ func (l *Logger) Identify() {
l.log.Info("starting", l.log.Info("starting",
"appname", l.params.Globals.Appname, "appname", l.params.Globals.Appname,
"version", l.params.Globals.Version, "version", l.params.Globals.Version,
"buildarch", l.params.Globals.Buildarch, "arch", runtime.GOARCH,
) )
} }
+34
View File
@@ -0,0 +1,34 @@
package logger //nolint:testpackage // sets the unexported log and params fields
import (
"bytes"
"encoding/json"
"log/slog"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/upaas/internal/globals"
)
func TestIdentifyLogsArchitectureFromRuntime(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
l := &Logger{
log: slog.New(slog.NewJSONHandler(&buf, nil)),
params: Params{
Globals: &globals.Globals{Appname: "upaas-test", Version: "test"},
},
}
l.Identify()
var line map[string]any
require.NoError(t, json.Unmarshal(buf.Bytes(), &line))
assert.Equal(t, runtime.GOARCH, line["arch"])
}
@@ -2,7 +2,13 @@ package deploy_test
import ( import (
"context" "context"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"slices" "slices"
"strings"
"testing" "testing"
"github.com/distribution/reference" "github.com/distribution/reference"
@@ -10,11 +16,17 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/handlers"
"sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/models" "sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/app"
) )
// TestDeployAppWithDotInName deploys an app named sneak.berlin against a // TestDeployAppWithDotInName creates an app named sneak.berlin through the
// fake Docker API and checks that Docker accepts the names of the image it // new app form, as a user does, then builds and deploys it against a fake
// Docker API and checks that Docker accepts the names of the image it
// builds and of the container it runs, using Docker's own rules for each. // builds and of the container it runs, using Docker's own rules for each.
func TestDeployAppWithDotInName(t *testing.T) { func TestDeployAppWithDotInName(t *testing.T) {
t.Parallel() t.Parallel()
@@ -27,15 +39,15 @@ func TestDeployAppWithDotInName(t *testing.T) {
svc, db := newImageTestService(t, api) svc, db := newImageTestService(t, api)
ctx := context.Background() ctx := context.Background()
app := saveApp(t, db, "sneak.berlin", "", "") createdApp := createAppWithForm(t, db, "sneak.berlin")
deployment := models.NewDeployment(db) deployment := models.NewDeployment(db)
deployment.AppID = app.ID deployment.AppID = createdApp.ID
require.NoError(t, deployment.Save(ctx)) require.NoError(t, deployment.Save(ctx))
imageID, err := svc.BuildImage(ctx, app, deployment) imageID, err := svc.BuildImage(ctx, createdApp, deployment)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, svc.DeployContainer(ctx, app, deployment, imageID)) require.NoError(t, svc.DeployContainer(ctx, createdApp, deployment, imageID))
images, _ := api.state() images, _ := api.state()
@@ -53,5 +65,57 @@ func TestDeployAppWithDotInName(t *testing.T) {
require.Equal(t, []string{"upaas-sneak.berlin"}, containers) require.Equal(t, []string{"upaas-sneak.berlin"}, containers)
assert.Regexp(t, names.RestrictedNamePattern, containers[0]) assert.Regexp(t, names.RestrictedNamePattern, containers[0])
assert.DirExists(t, svc.GetBuildDirExported(app.Name)) assert.DirExists(t, svc.GetBuildDirExported(createdApp.Name))
}
// createAppWithForm posts the new app form with the given name, which
// checks the name as it does for a user, and returns the app it created.
func createAppWithForm(
t *testing.T,
db *database.Database,
name string,
) *models.App {
t.Helper()
log := logger.NewForTest(slog.New(slog.NewTextHandler(os.Stderr, nil)))
appSvc, err := app.New(nil, app.ServiceParams{Logger: log, Database: db})
require.NoError(t, err)
globalInstance, err := globals.New(nil)
require.NoError(t, err)
handlersInstance, err := handlers.New(nil, handlers.Params{
Logger: log,
Globals: globalInstance,
Database: db,
App: appSvc,
})
require.NoError(t, err)
form := url.Values{
"name": {name},
"repo_url": {"git@example.com:sneak/" + name + ".git"},
}
request := httptest.NewRequestWithContext(
t.Context(), http.MethodPost, "/apps", strings.NewReader(form.Encode()),
)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
recorder := httptest.NewRecorder()
handlersInstance.HandleAppCreate().ServeHTTP(recorder, request)
// The form redirects to the new app's page; it shows the form again,
// with the error, when it refuses the name.
require.Equal(t, http.StatusSeeOther, recorder.Code, recorder.Body.String())
appID, found := strings.CutPrefix(recorder.Header().Get("Location"), "/apps/")
require.True(t, found)
createdApp, err := models.FindApp(t.Context(), db, appID)
require.NoError(t, err)
require.NotNil(t, createdApp)
return createdApp
} }
+1 -1
View File
@@ -26,7 +26,7 @@
{{define "nav"}} {{define "nav"}}
<nav class="app-bar"> <nav class="app-bar">
<div class="max-w-6xl mx-auto flex justify-between items-center"> <div class="max-w-6xl mx-auto flex flex-wrap justify-between items-center gap-3">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<a href="/" class="text-xl font-medium text-gray-900 hover:text-primary-600 transition-colors">µPaaS</a> <a href="/" class="text-xl font-medium text-gray-900 hover:text-primary-600 transition-colors">µPaaS</a>
<span class="text-sm text-gray-500">by <a href="https://sneak.berlin" class="text-primary-600 hover:text-primary-800">@sneak</a></span> <span class="text-sm text-gray-500">by <a href="https://sneak.berlin" class="text-primary-600 hover:text-primary-800">@sneak</a></span>
+2 -2
View File
@@ -20,7 +20,7 @@
</div> </div>
{{if .AppStats}} {{if .AppStats}}
<div class="card overflow-hidden"> <div class="card overflow-x-auto">
<table class="table"> <table class="table">
<thead class="table-header"> <thead class="table-header">
<tr> <tr>
@@ -41,7 +41,7 @@
{{.App.Name}} {{.App.Name}}
</a> </a>
</td> </td>
<td class="text-gray-500 font-mono text-xs">{{.App.RepoURL}}</td> <td class="text-gray-500 font-mono text-xs whitespace-normal break-all">{{.App.RepoURL}}</td>
<td class="text-gray-500">{{.App.Branch}}</td> <td class="text-gray-500">{{.App.Branch}}</td>
<td> <td>
{{if eq .App.Status "running"}} {{if eq .App.Status "running"}}