upaas/internal/models/deployment.go
sneak ab7e917b03 Add real-time deployment updates and refactor JavaScript
- Add deploy stats (last deploy time, total count) to dashboard
- Add recent-deployments API endpoint for real-time updates
- Add live build logs to deployments history page
- Fix git clone regression (preserve entrypoint for simple clones)
- Refactor JavaScript into shared app.js with page init functions
- Deploy button disables immediately on click
- Auto-refresh deployment list and logs during builds
- Format JavaScript with Prettier (4-space indent)
2026-01-01 05:22:56 +07:00

326 lines
7.7 KiB
Go

package models
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"git.eeqj.de/sneak/upaas/internal/database"
)
// DeploymentStatus represents the status of a deployment.
type DeploymentStatus string
// Deployment status constants.
const (
DeploymentStatusBuilding DeploymentStatus = "building"
DeploymentStatusDeploying DeploymentStatus = "deploying"
DeploymentStatusSuccess DeploymentStatus = "success"
DeploymentStatusFailed DeploymentStatus = "failed"
)
// Display constants.
const (
// secondsPerMinute is used for duration formatting.
secondsPerMinute = 60
// shortCommitLength is the number of characters to show for commit SHA.
shortCommitLength = 12
)
// Deployment represents a deployment attempt for an app.
type Deployment struct {
db *database.Database
ID int64
AppID string
WebhookEventID sql.NullInt64
CommitSHA sql.NullString
ImageID sql.NullString
ContainerID sql.NullString
Status DeploymentStatus
Logs sql.NullString
StartedAt time.Time
FinishedAt sql.NullTime
}
// NewDeployment creates a new Deployment with a database reference.
func NewDeployment(db *database.Database) *Deployment {
return &Deployment{
db: db,
Status: DeploymentStatusBuilding,
}
}
// Save inserts or updates the deployment in the database.
func (d *Deployment) Save(ctx context.Context) error {
if d.ID == 0 {
return d.insert(ctx)
}
return d.update(ctx)
}
// Reload refreshes the deployment from the database.
func (d *Deployment) Reload(ctx context.Context) error {
query := `
SELECT id, app_id, webhook_event_id, commit_sha, image_id,
container_id, status, logs, started_at, finished_at
FROM deployments WHERE id = ?`
row := d.db.QueryRow(ctx, query, d.ID)
return d.scan(row)
}
// AppendLog appends a log line to the deployment logs.
func (d *Deployment) AppendLog(ctx context.Context, line string) error {
var currentLogs string
if d.Logs.Valid {
currentLogs = d.Logs.String
}
d.Logs = sql.NullString{String: currentLogs + line + "\n", Valid: true}
return d.Save(ctx)
}
// MarkFinished marks the deployment as finished with the given status.
func (d *Deployment) MarkFinished(
ctx context.Context,
status DeploymentStatus,
) error {
d.Status = status
d.FinishedAt = sql.NullTime{Time: time.Now(), Valid: true}
return d.Save(ctx)
}
// Duration returns the duration of the deployment as a formatted string.
// Returns empty string if deployment is not finished.
func (d *Deployment) Duration() string {
if !d.FinishedAt.Valid {
return ""
}
duration := d.FinishedAt.Time.Sub(d.StartedAt)
// Format as minutes and seconds
minutes := int(duration.Minutes())
seconds := int(duration.Seconds()) % secondsPerMinute
if minutes > 0 {
return fmt.Sprintf("%dm %ds", minutes, seconds)
}
return fmt.Sprintf("%ds", seconds)
}
// ShortCommit returns a truncated commit SHA for display.
// Returns "-" if no commit SHA is set.
func (d *Deployment) ShortCommit() string {
if !d.CommitSHA.Valid || d.CommitSHA.String == "" {
return "-"
}
sha := d.CommitSHA.String
if len(sha) > shortCommitLength {
return sha[:shortCommitLength]
}
return sha
}
// FinishedAtISO returns the finished time in ISO format for JavaScript parsing.
// Falls back to started time if not finished yet.
func (d *Deployment) FinishedAtISO() string {
if d.FinishedAt.Valid {
return d.FinishedAt.Time.Format(time.RFC3339)
}
return d.StartedAt.Format(time.RFC3339)
}
// FinishedAtFormatted returns the finished time formatted for display.
// Falls back to started time if not finished yet.
func (d *Deployment) FinishedAtFormatted() string {
if d.FinishedAt.Valid {
return d.FinishedAt.Time.Format("2006-01-02 15:04:05")
}
return d.StartedAt.Format("2006-01-02 15:04:05")
}
func (d *Deployment) insert(ctx context.Context) error {
query := `
INSERT INTO deployments (
app_id, webhook_event_id, commit_sha, image_id,
container_id, status, logs
) VALUES (?, ?, ?, ?, ?, ?, ?)`
result, err := d.db.Exec(ctx, query,
d.AppID, d.WebhookEventID, d.CommitSHA, d.ImageID,
d.ContainerID, d.Status, d.Logs,
)
if err != nil {
return err
}
insertID, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("getting last insert id: %w", err)
}
d.ID = insertID
return d.Reload(ctx)
}
func (d *Deployment) update(ctx context.Context) error {
query := `
UPDATE deployments SET
image_id = ?, container_id = ?, status = ?, logs = ?, finished_at = ?
WHERE id = ?`
_, err := d.db.Exec(ctx, query,
d.ImageID, d.ContainerID, d.Status, d.Logs, d.FinishedAt, d.ID,
)
return err
}
func (d *Deployment) scan(row *sql.Row) error {
return row.Scan(
&d.ID, &d.AppID, &d.WebhookEventID, &d.CommitSHA, &d.ImageID,
&d.ContainerID, &d.Status, &d.Logs, &d.StartedAt, &d.FinishedAt,
)
}
// FindDeployment finds a deployment by ID.
//
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
func FindDeployment(
ctx context.Context,
deployDB *database.Database,
deployID int64,
) (*Deployment, error) {
deploy := NewDeployment(deployDB)
deploy.ID = deployID
row := deployDB.QueryRow(ctx, `
SELECT id, app_id, webhook_event_id, commit_sha, image_id,
container_id, status, logs, started_at, finished_at
FROM deployments WHERE id = ?`,
deployID,
)
err := deploy.scan(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("scanning deployment: %w", err)
}
return deploy, nil
}
// FindDeploymentsByAppID finds recent deployments for an app.
func FindDeploymentsByAppID(
ctx context.Context,
deployDB *database.Database,
appID string,
limit int,
) ([]*Deployment, error) {
query := `
SELECT id, app_id, webhook_event_id, commit_sha, image_id,
container_id, status, logs, started_at, finished_at
FROM deployments WHERE app_id = ?
ORDER BY started_at DESC, id DESC LIMIT ?`
rows, err := deployDB.Query(ctx, query, appID, limit)
if err != nil {
return nil, fmt.Errorf("querying deployments by app: %w", err)
}
defer func() { _ = rows.Close() }()
var deployments []*Deployment
for rows.Next() {
deploy := NewDeployment(deployDB)
scanErr := rows.Scan(
&deploy.ID, &deploy.AppID, &deploy.WebhookEventID,
&deploy.CommitSHA, &deploy.ImageID, &deploy.ContainerID,
&deploy.Status, &deploy.Logs, &deploy.StartedAt, &deploy.FinishedAt,
)
if scanErr != nil {
return nil, fmt.Errorf("scanning deployment row: %w", scanErr)
}
deployments = append(deployments, deploy)
}
rowsErr := rows.Err()
if rowsErr != nil {
return nil, fmt.Errorf("iterating deployment rows: %w", rowsErr)
}
return deployments, nil
}
// LatestDeploymentForApp finds the most recent deployment for an app.
//
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
func LatestDeploymentForApp(
ctx context.Context,
deployDB *database.Database,
appID string,
) (*Deployment, error) {
deploy := NewDeployment(deployDB)
row := deployDB.QueryRow(ctx, `
SELECT id, app_id, webhook_event_id, commit_sha, image_id,
container_id, status, logs, started_at, finished_at
FROM deployments WHERE app_id = ?
ORDER BY started_at DESC, id DESC LIMIT 1`,
appID,
)
err := deploy.scan(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("scanning latest deployment: %w", err)
}
return deploy, nil
}
// CountDeploymentsByAppID returns the total number of deployments for an app.
func CountDeploymentsByAppID(
ctx context.Context,
deployDB *database.Database,
appID string,
) (int, error) {
var count int
row := deployDB.QueryRow(ctx,
"SELECT COUNT(*) FROM deployments WHERE app_id = ?",
appID,
)
err := row.Scan(&count)
if err != nil {
return 0, fmt.Errorf("counting deployments: %w", err)
}
return count, nil
}