Fix 1.0 audit bugs (closes #120, closes #121, closes #122, closes #123, closes #124, closes #125) #126

Open
clawbot wants to merge 11 commits from fix/audit-bugs-120-125 into main
10 changed files with 3704 additions and 596 deletions
Showing only changes of commit 002fdd87a7 - Show all commits

View File

@ -176,6 +176,29 @@ docker run -d \
upaas
```
### Docker Compose
```yaml
services:
upaas:
build: .
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${HOST_DATA_DIR:-./data}:/var/lib/upaas
environment:
- UPAAS_HOST_DATA_DIR=${HOST_DATA_DIR:-./data}
Outdated
Review

setting the env var to a relative path (where docker-compose is being run) wouldn't work at all, given that docker-compose doesn't even necessarily run on the same machine as µPaaS.

setting the env var to a relative path (where docker-compose is being run) wouldn't work at all, given that docker-compose doesn't even necessarily run on the same machine as µPaaS.
Outdated
Review
@clawbot
# Optional: uncomment to enable debug logging
# - DEBUG=true
# Optional: Sentry error reporting
# - SENTRY_DSN=https://...
# Optional: Prometheus metrics auth
# - METRICS_USERNAME=prometheus
# - METRICS_PASSWORD=secret
```
**Important**: When running µPaaS inside a container, set `UPAAS_HOST_DATA_DIR` to the host path
that maps to `UPAAS_DATA_DIR`. This is required for Docker bind mounts during builds to work correctly.

View File

@ -1,18 +0,0 @@
services:
upaas:
build: .
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${HOST_DATA_DIR:-./data}:/var/lib/upaas
environment:
- UPAAS_HOST_DATA_DIR=${HOST_DATA_DIR:-./data}
# Optional: uncomment to enable debug logging
# - DEBUG=true
# Optional: Sentry error reporting
# - SENTRY_DSN=https://...
# Optional: Prometheus metrics auth
# - METRICS_USERNAME=prometheus
# - METRICS_PASSWORD=secret

View File

@ -14,7 +14,7 @@ import (
"strconv"
"strings"
"github.com/docker/docker/api/types"
dockertypes "github.com/docker/docker/api/types"
sneak marked this conversation as resolved Outdated
Outdated
Review

it is not idiomatic go to make a package just for types, and furthermore alias imports mean you haven't named something properly. define the types alongside the implementations that use them.

it is not idiomatic go to make a package just for types, and furthermore alias imports mean you haven't named something properly. define the types alongside the implementations that use them.
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/image"
@ -26,6 +26,7 @@ import (
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/domain"
"git.eeqj.de/sneak/upaas/internal/logger"
)
@ -116,7 +117,7 @@ type BuildImageOptions struct {
func (c *Client) BuildImage(
ctx context.Context,
opts BuildImageOptions,
) (string, error) {
) (domain.ImageID, error) {
if c.docker == nil {
return "", ErrNotConnected
}
@ -188,7 +189,7 @@ func buildPortConfig(ports []PortMapping) (nat.PortSet, nat.PortMap) {
func (c *Client) CreateContainer(
ctx context.Context,
opts CreateContainerOptions,
) (string, error) {
) (domain.ContainerID, error) {
if c.docker == nil {
return "", ErrNotConnected
}
@ -241,18 +242,18 @@ func (c *Client) CreateContainer(
return "", fmt.Errorf("failed to create container: %w", err)
}
return resp.ID, nil
return domain.ContainerID(resp.ID), nil
}
// StartContainer starts a container.
func (c *Client) StartContainer(ctx context.Context, containerID string) error {
func (c *Client) StartContainer(ctx context.Context, containerID domain.ContainerID) error {
if c.docker == nil {
return ErrNotConnected
}
c.log.Info("starting container", "id", containerID)
err := c.docker.ContainerStart(ctx, containerID, container.StartOptions{})
err := c.docker.ContainerStart(ctx, string(containerID), container.StartOptions{})
if err != nil {
return fmt.Errorf("failed to start container: %w", err)
}
@ -261,7 +262,7 @@ func (c *Client) StartContainer(ctx context.Context, containerID string) error {
}
// StopContainer stops a container.
func (c *Client) StopContainer(ctx context.Context, containerID string) error {
func (c *Client) StopContainer(ctx context.Context, containerID domain.ContainerID) error {
if c.docker == nil {
return ErrNotConnected
}
@ -270,7 +271,7 @@ func (c *Client) StopContainer(ctx context.Context, containerID string) error {
timeout := stopTimeoutSeconds
err := c.docker.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout})
err := c.docker.ContainerStop(ctx, string(containerID), container.StopOptions{Timeout: &timeout})
if err != nil {
return fmt.Errorf("failed to stop container: %w", err)
}
@ -281,7 +282,7 @@ func (c *Client) StopContainer(ctx context.Context, containerID string) error {
// RemoveContainer removes a container.
func (c *Client) RemoveContainer(
ctx context.Context,
containerID string,
containerID domain.ContainerID,
Outdated
Review

"domain" is a bad name for this. think of something better.

"domain" is a bad name for this. think of something better.
force bool,
) error {
if c.docker == nil {
@ -290,7 +291,7 @@ func (c *Client) RemoveContainer(
c.log.Info("removing container", "id", containerID, "force", force)
err := c.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: force})
err := c.docker.ContainerRemove(ctx, string(containerID), container.RemoveOptions{Force: force})
if err != nil {
return fmt.Errorf("failed to remove container: %w", err)
}
@ -301,7 +302,7 @@ func (c *Client) RemoveContainer(
// ContainerLogs returns the logs for a container.
func (c *Client) ContainerLogs(
ctx context.Context,
containerID string,
containerID domain.ContainerID,
tail string,
) (string, error) {
if c.docker == nil {
@ -314,7 +315,7 @@ func (c *Client) ContainerLogs(
Tail: tail,
}
reader, err := c.docker.ContainerLogs(ctx, containerID, opts)
reader, err := c.docker.ContainerLogs(ctx, string(containerID), opts)
Outdated
Review

ContainerLogs signature should be updated to take the correct type (if that's our code). that's the whole point of using custom string types.

ContainerLogs signature should be updated to take the correct type (if that's our code). that's the whole point of using custom string types.
if err != nil {
return "", fmt.Errorf("failed to get container logs: %w", err)
}
@ -337,13 +338,13 @@ func (c *Client) ContainerLogs(
// IsContainerRunning checks if a container is running.
func (c *Client) IsContainerRunning(
ctx context.Context,
containerID string,
containerID domain.ContainerID,
) (bool, error) {
if c.docker == nil {
return false, ErrNotConnected
}
inspect, err := c.docker.ContainerInspect(ctx, containerID)
inspect, err := c.docker.ContainerInspect(ctx, string(containerID))
if err != nil {
return false, fmt.Errorf("failed to inspect container: %w", err)
}
@ -354,13 +355,13 @@ func (c *Client) IsContainerRunning(
// IsContainerHealthy checks if a container is healthy.
func (c *Client) IsContainerHealthy(
ctx context.Context,
containerID string,
containerID domain.ContainerID,
) (bool, error) {
if c.docker == nil {
return false, ErrNotConnected
}
inspect, err := c.docker.ContainerInspect(ctx, containerID)
inspect, err := c.docker.ContainerInspect(ctx, string(containerID))
Outdated
Review

is c.docker our object (and can these function signatures be updated) or is that docker's code?

is c.docker our object (and can these function signatures be updated) or is that docker's code?
if err != nil {
return false, fmt.Errorf("failed to inspect container: %w", err)
}
@ -378,7 +379,7 @@ const LabelUpaasID = "upaas.id"
// ContainerInfo contains basic information about a container.
type ContainerInfo struct {
ID string
ID domain.ContainerID
Running bool
}
@ -413,7 +414,7 @@ func (c *Client) FindContainerByAppID(
ctr := containers[0]
return &ContainerInfo{
ID: ctr.ID,
ID: domain.ContainerID(ctr.ID),
Running: ctr.State == "running",
}, nil
}
@ -482,8 +483,8 @@ func (c *Client) CloneRepo(
// RemoveImage removes a Docker image by ID or tag.
// It returns nil if the image was successfully removed or does not exist.
func (c *Client) RemoveImage(ctx context.Context, imageID string) error {
_, err := c.docker.ImageRemove(ctx, imageID, image.RemoveOptions{
func (c *Client) RemoveImage(ctx context.Context, imageID domain.ImageID) error {
_, err := c.docker.ImageRemove(ctx, string(imageID), image.RemoveOptions{
Force: true,
PruneChildren: true,
})
@ -497,7 +498,7 @@ func (c *Client) RemoveImage(ctx context.Context, imageID string) error {
func (c *Client) performBuild(
ctx context.Context,
opts BuildImageOptions,
) (string, error) {
) (domain.ImageID, error) {
// Create tar archive of build context
tarArchive, err := archive.TarWithOptions(opts.ContextDir, &archive.TarOptions{})
if err != nil {
@ -512,7 +513,7 @@ func (c *Client) performBuild(
}()
// Build image
resp, err := c.docker.ImageBuild(ctx, tarArchive, types.ImageBuildOptions{
resp, err := c.docker.ImageBuild(ctx, tarArchive, dockertypes.ImageBuildOptions{
Dockerfile: opts.DockerfilePath,
Tags: opts.Tags,
Remove: true,
@ -542,7 +543,7 @@ func (c *Client) performBuild(
return "", fmt.Errorf("failed to inspect image: %w", inspectErr)
}
return inspect.ID, nil
return domain.ImageID(inspect.ID), nil
}
return "", nil
@ -603,22 +604,22 @@ func (c *Client) performClone(ctx context.Context, cfg *cloneConfig) (*CloneResu
}
}()
containerID, err := c.createGitContainer(ctx, cfg)
gitContainerID, err := c.createGitContainer(ctx, cfg)
if err != nil {
return nil, err
}
defer func() {
_ = c.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: true})
_ = c.docker.ContainerRemove(ctx, string(gitContainerID), container.RemoveOptions{Force: true})
}()
return c.runGitClone(ctx, containerID)
return c.runGitClone(ctx, gitContainerID)
}
func (c *Client) createGitContainer(
ctx context.Context,
cfg *cloneConfig,
) (string, error) {
) (domain.ContainerID, error) {
gitSSHCmd := "ssh -i /keys/deploy_key -o StrictHostKeyChecking=no"
// Build the git command using environment variables to avoid shell injection.
@ -675,16 +676,16 @@ func (c *Client) createGitContainer(
return "", fmt.Errorf("failed to create git container: %w", err)
}
return resp.ID, nil
return domain.ContainerID(resp.ID), nil
}
func (c *Client) runGitClone(ctx context.Context, containerID string) (*CloneResult, error) {
err := c.docker.ContainerStart(ctx, containerID, container.StartOptions{})
func (c *Client) runGitClone(ctx context.Context, containerID domain.ContainerID) (*CloneResult, error) {
err := c.docker.ContainerStart(ctx, string(containerID), container.StartOptions{})
if err != nil {
return nil, fmt.Errorf("failed to start git container: %w", err)
}
statusCh, errCh := c.docker.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)
statusCh, errCh := c.docker.ContainerWait(ctx, string(containerID), container.WaitConditionNotRunning)
select {
case err := <-errCh:

16
internal/domain/types.go Normal file
View File

@ -0,0 +1,16 @@
// Package domain defines domain-specific string types for compile-time safety.
// Using named types prevents accidentally passing the wrong string argument
// (e.g. a container ID where an image ID is expected).
package domain
Outdated
Review

read https://git.eeqj.de/sneak/prompts/src/branch/main/prompts/CODE_STYLEGUIDE_GO.md and if it doesn't have an item about not defining a package just to hold types, add one with a PR to the prompts repo. make a note in your memory about the existence of the files in this prompts directory in the sneak/prompts repo and how they should be loaded into context when authoring code.

then, apply the policy here and define the types alongside where they are primarily used, not in a dedicated types package, which is un-idiomatic.

read https://git.eeqj.de/sneak/prompts/src/branch/main/prompts/CODE_STYLEGUIDE_GO.md and if it doesn't have an item about not defining a package just to hold types, add one with a PR to the prompts repo. make a note in your memory about the existence of the files in this `prompts` directory in the `sneak/prompts` repo and how they should be loaded into context when authoring code. then, apply the policy here and define the types alongside where they are primarily used, not in a dedicated types package, which is un-idiomatic.
// ImageID is a Docker image identifier (ID or tag).
type ImageID string
// ContainerID is a Docker container identifier.
type ContainerID string
// UnparsedURL is a URL stored as a plain string without parsing.
// Use this instead of string when the value is known to be a URL
// but should not be parsed into a net/url.URL (e.g. webhook URLs,
// compare URLs from external payloads).
type UnparsedURL string

View File

@ -20,6 +20,7 @@ import (
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/docker"
"git.eeqj.de/sneak/upaas/internal/domain"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/notify"
@ -417,7 +418,7 @@ func (svc *Service) executeRollback(
svc.removeOldContainer(ctx, app, deployment)
rollbackOpts, err := svc.buildContainerOptions(ctx, app, previousImageID)
rollbackOpts, err := svc.buildContainerOptions(ctx, app, domain.ImageID(previousImageID))
if err != nil {
svc.failDeployment(bgCtx, app, deployment, err)
@ -431,8 +432,8 @@ func (svc *Service) executeRollback(
return fmt.Errorf("failed to create rollback container: %w", err)
}
deployment.ContainerID = sql.NullString{String: containerID, Valid: true}
_ = deployment.AppendLog(bgCtx, "Rollback container created: "+containerID)
deployment.ContainerID = sql.NullString{String: string(containerID), Valid: true}
_ = deployment.AppendLog(bgCtx, "Rollback container created: "+string(containerID))
startErr := svc.docker.StartContainer(ctx, containerID)
if startErr != nil {
@ -514,7 +515,7 @@ func (svc *Service) buildImageWithTimeout(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
) (string, error) {
) (domain.ImageID, error) {
buildCtx, cancel := context.WithTimeout(ctx, buildTimeout)
defer cancel()
@ -539,7 +540,7 @@ func (svc *Service) deployContainerWithTimeout(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
imageID string,
imageID domain.ImageID,
) error {
deployCtx, cancel := context.WithTimeout(ctx, deployTimeout)
defer cancel()
@ -667,7 +668,7 @@ func (svc *Service) checkCancelled(
bgCtx context.Context,
app *models.App,
deployment *models.Deployment,
imageID string,
imageID domain.ImageID,
) error {
if !errors.Is(deployCtx.Err(), context.Canceled) {
return nil
@ -687,7 +688,7 @@ func (svc *Service) cleanupCancelledDeploy(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
imageID string,
imageID domain.ImageID,
) {
// Clean up the intermediate Docker image if one was built
if imageID != "" {
@ -695,11 +696,11 @@ func (svc *Service) cleanupCancelledDeploy(
if removeErr != nil {
svc.log.Error("failed to remove image from cancelled deploy",
"error", removeErr, "app", app.Name, "image", imageID)
_ = deployment.AppendLog(ctx, "WARNING: failed to clean up image "+imageID+": "+removeErr.Error())
_ = deployment.AppendLog(ctx, "WARNING: failed to clean up image "+string(imageID)+": "+removeErr.Error())
} else {
svc.log.Info("cleaned up image from cancelled deploy",
"app", app.Name, "image", imageID)
_ = deployment.AppendLog(ctx, "Cleaned up intermediate image: "+imageID)
_ = deployment.AppendLog(ctx, "Cleaned up intermediate image: "+string(imageID))
}
}
@ -816,7 +817,7 @@ func (svc *Service) buildImage(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
) (string, error) {
) (domain.ImageID, error) {
workDir, cleanup, err := svc.cloneRepository(ctx, app, deployment)
if err != nil {
return "", err
@ -850,8 +851,8 @@ func (svc *Service) buildImage(
return "", fmt.Errorf("failed to build image: %w", err)
}
deployment.ImageID = sql.NullString{String: imageID, Valid: true}
_ = deployment.AppendLog(ctx, "Image built: "+imageID)
deployment.ImageID = sql.NullString{String: string(imageID), Valid: true}
_ = deployment.AppendLog(ctx, "Image built: "+string(imageID))
return imageID, nil
}
@ -1009,15 +1010,15 @@ func (svc *Service) removeOldContainer(
svc.log.Warn("failed to remove old container", "error", removeErr)
}
_ = deployment.AppendLog(ctx, "Old container removed: "+containerInfo.ID[:12])
_ = deployment.AppendLog(ctx, "Old container removed: "+string(containerInfo.ID[:12]))
}
func (svc *Service) createAndStartContainer(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
imageID string,
) (string, error) {
imageID domain.ImageID,
) (domain.ContainerID, error) {
containerOpts, err := svc.buildContainerOptions(ctx, app, imageID)
if err != nil {
svc.failDeployment(ctx, app, deployment, err)
@ -1038,8 +1039,8 @@ func (svc *Service) createAndStartContainer(
return "", fmt.Errorf("failed to create container: %w", err)
}
deployment.ContainerID = sql.NullString{String: containerID, Valid: true}
_ = deployment.AppendLog(ctx, "Container created: "+containerID)
deployment.ContainerID = sql.NullString{String: string(containerID), Valid: true}
_ = deployment.AppendLog(ctx, "Container created: "+string(containerID))
startErr := svc.docker.StartContainer(ctx, containerID)
if startErr != nil {
@ -1062,7 +1063,7 @@ func (svc *Service) createAndStartContainer(
func (svc *Service) buildContainerOptions(
ctx context.Context,
app *models.App,
imageID string,
imageID domain.ImageID,
) (docker.CreateContainerOptions, error) {
envVars, err := app.GetEnvVars(ctx)
if err != nil {
@ -1096,7 +1097,7 @@ func (svc *Service) buildContainerOptions(
return docker.CreateContainerOptions{
Name: "upaas-" + app.Name,
Image: imageID,
Image: string(imageID),
Env: envMap,
Labels: buildLabelMap(app, labels),
Volumes: buildVolumeMounts(volumes),
@ -1146,9 +1147,9 @@ func buildPortMappings(ports []*models.Port) []docker.PortMapping {
func (svc *Service) updateAppRunning(
ctx context.Context,
app *models.App,
imageID string,
imageID domain.ImageID,
) error {
app.ImageID = sql.NullString{String: imageID, Valid: true}
app.ImageID = sql.NullString{String: string(imageID), Valid: true}
app.Status = models.AppStatusRunning
saveErr := app.Save(ctx)

View File

@ -7,6 +7,7 @@ import (
"testing"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/domain"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/deploy"
)
@ -27,14 +28,14 @@ func TestBuildContainerOptionsUsesImageID(t *testing.T) {
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
svc := deploy.NewTestService(log)
const expectedImageID = "sha256:abc123def456"
const expectedImageID = domain.ImageID("sha256:abc123def456")
opts, err := svc.BuildContainerOptionsExported(context.Background(), app, expectedImageID)
if err != nil {
t.Fatalf("buildContainerOptions returned error: %v", err)
}
if opts.Image != expectedImageID {
if opts.Image != string(expectedImageID) {
t.Errorf("expected Image=%q, got %q", expectedImageID, opts.Image)
}

View File

@ -10,6 +10,7 @@ import (
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/docker"
"git.eeqj.de/sneak/upaas/internal/domain"
"git.eeqj.de/sneak/upaas/internal/models"
)
@ -86,7 +87,7 @@ func (svc *Service) GetBuildDirExported(appName string) string {
func (svc *Service) BuildContainerOptionsExported(
ctx context.Context,
app *models.App,
imageID string,
imageID domain.ImageID,
) (docker.CreateContainerOptions, error) {
return svc.buildContainerOptions(ctx, app, imageID)
}

View File

@ -11,6 +11,7 @@ import (
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/domain"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/deploy"
@ -50,12 +51,12 @@ type GiteaPushPayload struct {
Ref string `json:"ref"`
Before string `json:"before"`
After string `json:"after"`
CompareURL string `json:"compare_url"`
CompareURL domain.UnparsedURL `json:"compare_url"`
Repository struct {
FullName string `json:"full_name"`
CloneURL string `json:"clone_url"`
CloneURL domain.UnparsedURL `json:"clone_url"`
SSHURL string `json:"ssh_url"`
HTMLURL string `json:"html_url"`
HTMLURL domain.UnparsedURL `json:"html_url"`
} `json:"repository"`
Pusher struct {
Username string `json:"username"`
@ -63,7 +64,7 @@ type GiteaPushPayload struct {
} `json:"pusher"`
Commits []struct {
ID string `json:"id"`
URL string `json:"url"`
URL domain.UnparsedURL `json:"url"`
Message string `json:"message"`
Author struct {
Name string `json:"name"`
@ -104,7 +105,7 @@ func (svc *Service) HandleWebhook(
event.EventType = eventType
event.Branch = branch
event.CommitSHA = sql.NullString{String: commitSHA, Valid: commitSHA != ""}
event.CommitURL = sql.NullString{String: commitURL, Valid: commitURL != ""}
event.CommitURL = sql.NullString{String: string(commitURL), Valid: commitURL != ""}
event.Payload = sql.NullString{String: string(payload), Valid: true}
event.Matched = matched
event.Processed = false
@ -168,7 +169,7 @@ func extractBranch(ref string) string {
// extractCommitURL extracts the commit URL from the webhook payload.
// Prefers the URL from the head commit, falls back to constructing from repo URL.
func extractCommitURL(payload GiteaPushPayload) string {
func extractCommitURL(payload GiteaPushPayload) domain.UnparsedURL {
// Try to find the URL from the head commit (matching After SHA)
for _, commit := range payload.Commits {
if commit.ID == payload.After && commit.URL != "" {
@ -178,7 +179,7 @@ func extractCommitURL(payload GiteaPushPayload) string {
// Fall back to constructing URL from repo HTML URL
if payload.Repository.HTMLURL != "" && payload.After != "" {
return payload.Repository.HTMLURL + "/commit/" + payload.After
return domain.UnparsedURL(string(payload.Repository.HTMLURL) + "/commit/" + payload.After)
}
return ""

3047
static/js/alpine.min.js vendored

File diff suppressed because one or more lines are too long

View File

@ -22,7 +22,9 @@ document.addEventListener("alpine:init", () => {
if (diffSec < 60) return "just now";
if (diffMin < 60)
return diffMin + (diffMin === 1 ? " minute ago" : " minutes ago");
return (
diffMin + (diffMin === 1 ? " minute ago" : " minutes ago")
);
if (diffHour < 24)
return diffHour + (diffHour === 1 ? " hour ago" : " hours ago");
if (diffDay < 7)
@ -34,7 +36,8 @@ document.addEventListener("alpine:init", () => {
* Get the badge class for a given status
*/
statusBadgeClass(status) {
if (status === "running" || status === "success") return "badge-success";
if (status === "running" || status === "success")
return "badge-success";
if (status === "building" || status === "deploying")
return "badge-warning";
if (status === "failed" || status === "error") return "badge-error";
@ -73,7 +76,9 @@ document.addEventListener("alpine:init", () => {
*/
isScrolledToBottom(el, tolerance = 30) {
if (!el) return true;
return el.scrollHeight - el.scrollTop - el.clientHeight <= tolerance;
return (
el.scrollHeight - el.scrollTop - el.clientHeight <= tolerance
);
},
/**
@ -194,14 +199,22 @@ document.addEventListener("alpine:init", () => {
// Set up scroll listeners after DOM is ready
this.$nextTick(() => {
this._initScrollTracking(this.$refs.containerLogsWrapper, '_containerAutoScroll');
this._initScrollTracking(this.$refs.buildLogsWrapper, '_buildAutoScroll');
this._initScrollTracking(
this.$refs.containerLogsWrapper,
"_containerAutoScroll",
);
this._initScrollTracking(
this.$refs.buildLogsWrapper,
"_buildAutoScroll",
);
});
},
_schedulePoll() {
if (this._pollTimer) clearTimeout(this._pollTimer);
const interval = Alpine.store("utils").isDeploying(this.appStatus) ? 1000 : 10000;
const interval = Alpine.store("utils").isDeploying(this.appStatus)
? 1000
: 10000;
this._pollTimer = setTimeout(() => {
this.fetchAll();
this._schedulePoll();
@ -210,18 +223,29 @@ document.addEventListener("alpine:init", () => {
_initScrollTracking(el, flag) {
if (!el) return;
el.addEventListener('scroll', () => {
el.addEventListener(
"scroll",
() => {
this[flag] = Alpine.store("utils").isScrolledToBottom(el);
}, { passive: true });
},
{ passive: true },
);
},
fetchAll() {
this.fetchAppStatus();
// Only fetch logs when the respective pane is visible
if (this.$refs.containerLogsWrapper && this._isElementVisible(this.$refs.containerLogsWrapper)) {
if (
this.$refs.containerLogsWrapper &&
this._isElementVisible(this.$refs.containerLogsWrapper)
) {
this.fetchContainerLogs();
}
if (this.showBuildLogs && this.$refs.buildLogsWrapper && this._isElementVisible(this.$refs.buildLogsWrapper)) {
if (
this.showBuildLogs &&
this.$refs.buildLogsWrapper &&
this._isElementVisible(this.$refs.buildLogsWrapper)
) {
this.fetchBuildLogs();
}
this.fetchRecentDeployments();
@ -270,7 +294,9 @@ document.addEventListener("alpine:init", () => {
this.containerStatus = data.status;
if (changed && this._containerAutoScroll) {
this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(this.$refs.containerLogsWrapper);
Alpine.store("utils").scrollToBottom(
this.$refs.containerLogsWrapper,
);
});
}
} catch (err) {
@ -291,7 +317,9 @@ document.addEventListener("alpine:init", () => {
this.buildStatus = data.status;
if (changed && this._buildAutoScroll) {
this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(this.$refs.buildLogsWrapper);
Alpine.store("utils").scrollToBottom(
this.$refs.buildLogsWrapper,
);
});
}
} catch (err) {
@ -301,7 +329,9 @@ document.addEventListener("alpine:init", () => {
async fetchRecentDeployments() {
try {
const res = await fetch(`/apps/${this.appId}/recent-deployments`);
const res = await fetch(
`/apps/${this.appId}/recent-deployments`,
);
const data = await res.json();
this.deployments = data.deployments || [];
} catch (err) {
@ -334,7 +364,8 @@ document.addEventListener("alpine:init", () => {
get buildStatusBadgeClass() {
return (
Alpine.store("utils").statusBadgeClass(this.buildStatus) + " text-xs"
Alpine.store("utils").statusBadgeClass(this.buildStatus) +
" text-xs"
);
},
@ -375,9 +406,16 @@ document.addEventListener("alpine:init", () => {
this.$nextTick(() => {
const wrapper = this.$refs.logsWrapper;
if (wrapper) {
wrapper.addEventListener('scroll', () => {
this._autoScroll = Alpine.store("utils").isScrolledToBottom(wrapper);
}, { passive: true });
wrapper.addEventListener(
"scroll",
() => {
this._autoScroll =
Alpine.store("utils").isScrolledToBottom(
wrapper,
);
},
{ passive: true },
);
}
});
@ -408,7 +446,9 @@ document.addEventListener("alpine:init", () => {
// Scroll to bottom only when content changes AND user hasn't scrolled up
if (logsChanged && this._autoScroll) {
this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(this.$refs.logsWrapper);
Alpine.store("utils").scrollToBottom(
this.$refs.logsWrapper,
);
});
}
@ -533,7 +573,8 @@ document.addEventListener("alpine:init", () => {
this.$el.querySelectorAll("[data-time]").forEach((el) => {
const time = el.getAttribute("data-time");
if (time) {
el.textContent = Alpine.store("utils").formatRelativeTime(time);
el.textContent =
Alpine.store("utils").formatRelativeTime(time);
}
});
}, 60000);