Compare commits
6
Commits
112cbfae1b
..
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3b9c6fca9 | ||
|
|
23f378cfde | ||
|
|
db3b47e423 | ||
|
|
b101bc1a80 | ||
|
|
5c836c085d | ||
|
|
5168db69d9 |
+32
-5
@@ -1,8 +1,35 @@
|
||||
# .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
|
||||
# the version would end in -dirty.
|
||||
.env
|
||||
bin/
|
||||
.vscode/
|
||||
.idea/
|
||||
*.test
|
||||
|
||||
# The patterns of .gitignore; **/ makes Docker match them in every directory.
|
||||
**/.DS_Store
|
||||
**/Thumbs.db
|
||||
**/*.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
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# .dockerignore repeats these patterns; change both together.
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -29,3 +31,6 @@ bin/
|
||||
*.dylib
|
||||
*.test
|
||||
*.out
|
||||
|
||||
# upaasd's data directory when it runs from the checkout (UPAAS_DATA_DIR default)
|
||||
/data/
|
||||
|
||||
+2
-10
@@ -117,13 +117,11 @@ import (
|
||||
var (
|
||||
Appname string = "CHANGEME"
|
||||
Version string
|
||||
Buildarch string
|
||||
)
|
||||
|
||||
func main() {
|
||||
globals.Appname = Appname
|
||||
globals.Version = Version
|
||||
globals.Buildarch = Buildarch
|
||||
|
||||
fx.New(
|
||||
fx.Provide(
|
||||
@@ -823,7 +821,7 @@ func (l *Logger) Identify() {
|
||||
l.log.Info("starting",
|
||||
"appname", l.params.Globals.Appname,
|
||||
"version", l.params.Globals.Version,
|
||||
"buildarch", l.params.Globals.Buildarch,
|
||||
"arch", runtime.GOARCH,
|
||||
)
|
||||
}
|
||||
```
|
||||
@@ -945,20 +943,17 @@ import "go.uber.org/fx"
|
||||
var (
|
||||
Appname string
|
||||
Version string
|
||||
Buildarch string
|
||||
)
|
||||
|
||||
// Struct for DI
|
||||
type Globals struct {
|
||||
Appname string
|
||||
Version string
|
||||
Buildarch string
|
||||
}
|
||||
|
||||
func New(lc fx.Lifecycle) (*Globals, error) {
|
||||
n := &Globals{
|
||||
Appname: Appname,
|
||||
Buildarch: Buildarch,
|
||||
Version: Version,
|
||||
}
|
||||
return n, nil
|
||||
@@ -972,13 +967,11 @@ func New(lc fx.Lifecycle) (*Globals, error) {
|
||||
var (
|
||||
Appname string = "CHANGEME" // Default, overridden by build
|
||||
Version string // Set at build time
|
||||
Buildarch string // Set at build time
|
||||
)
|
||||
|
||||
func main() {
|
||||
globals.Appname = Appname
|
||||
globals.Version = Version
|
||||
globals.Buildarch = Buildarch
|
||||
// ...
|
||||
}
|
||||
```
|
||||
@@ -989,10 +982,9 @@ Use ldflags to inject version information at build time:
|
||||
|
||||
```makefile
|
||||
VERSION := $(shell git describe --tags --always)
|
||||
BUILDARCH := $(shell go env GOARCH)
|
||||
|
||||
build:
|
||||
go build -ldflags "-X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)" ./cmd/httpd
|
||||
go build -ldflags "-X main.Version=$(VERSION)" ./cmd/httpd
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
BINARY := upaasd
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
BUILDARCH := $(shell go env GOARCH)
|
||||
LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
|
||||
LDFLAGS := -X main.Version=$(VERSION)
|
||||
|
||||
all: check build
|
||||
|
||||
|
||||
@@ -20,11 +20,35 @@ regress.
|
||||
|
||||
# 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
|
||||
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
|
||||
forms check the same rule; browsers ignored their old pattern, which was not a
|
||||
valid regular expression there (#260).
|
||||
letters and numbers joined by single dots or by hyphens, 2 to 63 characters.
|
||||
Docker accepts every such name in the image name `upaas-<name>`; a dot needs a
|
||||
letter or number on both sides because Docker requires it. The new and edit
|
||||
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
|
||||
form of the commit built, instead of the deployment number. A redeploy of a
|
||||
|
||||
@@ -27,13 +27,11 @@ import (
|
||||
var (
|
||||
Appname = "upaas" //nolint:gochecknoglobals // build-time variable
|
||||
Version string //nolint:gochecknoglobals // build-time variable
|
||||
Buildarch string //nolint:gochecknoglobals // build-time variable
|
||||
)
|
||||
|
||||
func main() {
|
||||
globals.SetAppname(Appname)
|
||||
globals.SetVersion(Version)
|
||||
globals.SetBuildarch(Buildarch)
|
||||
|
||||
fx.New(
|
||||
fx.Provide(
|
||||
|
||||
@@ -15,14 +15,12 @@ var (
|
||||
mu sync.RWMutex
|
||||
appname string
|
||||
version string
|
||||
buildarch string
|
||||
)
|
||||
|
||||
// Globals holds build-time variables for dependency injection.
|
||||
type Globals struct {
|
||||
Appname string
|
||||
Version string
|
||||
Buildarch string
|
||||
}
|
||||
|
||||
// New creates a new Globals instance from package-level variables.
|
||||
@@ -33,7 +31,6 @@ func New(_ fx.Lifecycle) (*Globals, error) {
|
||||
return &Globals{
|
||||
Appname: appname,
|
||||
Version: version,
|
||||
Buildarch: buildarch,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -52,11 +49,3 @@ func SetVersion(ver string) {
|
||||
|
||||
version = ver
|
||||
}
|
||||
|
||||
// SetBuildarch sets the build architecture (used for testing and main init).
|
||||
func SetBuildarch(arch string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
buildarch = arch
|
||||
}
|
||||
|
||||
@@ -14,8 +14,9 @@ const (
|
||||
)
|
||||
|
||||
// validAppNameRe matches runs of lowercase letters and digits joined by
|
||||
// single dots or by hyphens, such as "my-app" or "sneak.berlin". This is
|
||||
// Docker's rule for an image name, and the app's image is upaas-<name>.
|
||||
// single dots or by hyphens, such as "my-app" or "sneak.berlin". Docker
|
||||
// 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
|
||||
// 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.
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
package handlers //nolint:testpackage // testing unexported validateAppName
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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) {
|
||||
@@ -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-")
|
||||
}
|
||||
@@ -4,6 +4,7 @@ package logger
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"go.uber.org/fx"
|
||||
|
||||
@@ -81,6 +82,6 @@ func (l *Logger) Identify() {
|
||||
l.log.Info("starting",
|
||||
"appname", l.params.Globals.Appname,
|
||||
"version", l.params.Globals.Version,
|
||||
"buildarch", l.params.Globals.Buildarch,
|
||||
"arch", runtime.GOARCH,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/distribution/reference"
|
||||
@@ -10,11 +16,17 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"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/service/app"
|
||||
)
|
||||
|
||||
// TestDeployAppWithDotInName deploys an app named sneak.berlin against a
|
||||
// fake Docker API and checks that Docker accepts the names of the image it
|
||||
// TestDeployAppWithDotInName creates an app named sneak.berlin through the
|
||||
// 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.
|
||||
func TestDeployAppWithDotInName(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -27,15 +39,15 @@ func TestDeployAppWithDotInName(t *testing.T) {
|
||||
svc, db := newImageTestService(t, api)
|
||||
ctx := context.Background()
|
||||
|
||||
app := saveApp(t, db, "sneak.berlin", "", "")
|
||||
createdApp := createAppWithForm(t, db, "sneak.berlin")
|
||||
|
||||
deployment := models.NewDeployment(db)
|
||||
deployment.AppID = app.ID
|
||||
deployment.AppID = createdApp.ID
|
||||
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, svc.DeployContainer(ctx, app, deployment, imageID))
|
||||
require.NoError(t, svc.DeployContainer(ctx, createdApp, deployment, imageID))
|
||||
|
||||
images, _ := api.state()
|
||||
|
||||
@@ -53,5 +65,57 @@ func TestDeployAppWithDotInName(t *testing.T) {
|
||||
require.Equal(t, []string{"upaas-sneak.berlin"}, containers)
|
||||
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
@@ -26,7 +26,7 @@
|
||||
|
||||
{{define "nav"}}
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</div>
|
||||
|
||||
{{if .AppStats}}
|
||||
<div class="card overflow-hidden">
|
||||
<div class="card overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead class="table-header">
|
||||
<tr>
|
||||
@@ -41,7 +41,7 @@
|
||||
{{.App.Name}}
|
||||
</a>
|
||||
</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>
|
||||
{{if eq .App.Status "running"}}
|
||||
|
||||
Reference in New Issue
Block a user