diff --git a/ROADMAP.md b/ROADMAP.md index 97be431..50ecac3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -126,10 +126,10 @@ Be a **valid** Docker Desktop replacement for local development: same CLI (`dock - [x] Basic metrics (CPU, RAM, network per container) - [x] Volumes list, detail, file browser, clone, and remove - [x] Image layers, run, and push actions -- [x] Builds list (history from daemon) +- [x] Builds list (history persisted to disk) - [x] Docker Hub registry login (device flow) - [ ] Network management UI -- [ ] Build detail view UI +- [x] Build detail view UI **Exit criteria:** 3 reference stacks (LAMP, Node+Postgres, Laravel Sail) start with `docker compose up -d` without modifications. diff --git a/backend/api.test b/backend/api.test new file mode 100755 index 0000000..652af86 Binary files /dev/null and b/backend/api.test differ diff --git a/backend/buildstore.test b/backend/buildstore.test new file mode 100755 index 0000000..4a96f46 Binary files /dev/null and b/backend/buildstore.test differ diff --git a/backend/cmd/calf/main.go b/backend/cmd/calf/main.go index bfd0237..4ea3bd8 100644 --- a/backend/cmd/calf/main.go +++ b/backend/cmd/calf/main.go @@ -56,6 +56,9 @@ func run() int { } }() + go server.StartBuildSync(rtCtx) + go server.StartDockerContextManager(rtCtx) + errCh := make(chan error, 1) go func() { errCh <- server.Run() diff --git a/backend/internal/api/build_sync.go b/backend/internal/api/build_sync.go new file mode 100644 index 0000000..1897f4d --- /dev/null +++ b/backend/internal/api/build_sync.go @@ -0,0 +1,247 @@ +package api + +import ( + "context" + "fmt" + "os" + "reflect" + "time" + + "github.com/enegalan/calf/backend/internal/buildhistory" + "github.com/enegalan/calf/backend/internal/runtime" +) + +const buildSyncInterval = 30 * time.Second +const buildSyncEnrichTimeout = 2 * time.Minute + +func (s *Server) StartBuildSync(ctx context.Context) { + interval := buildSyncInterval + s.cfgMu.RLock() + if s.cfg.PollIntervalMs > 0 { + interval = time.Duration(s.cfg.PollIntervalMs) * time.Millisecond + } + s.cfgMu.RUnlock() + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + s.syncBuildHistory(ctx) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.syncBuildHistory(ctx) + } + } +} + +func (s *Server) syncBuildHistory(ctx context.Context) { + socket := s.runtime.DockerSocket() + if socket == "" { + return + } + + if _, err := os.Stat(socket); err != nil { + return + } + + status, err := s.runtime.Status(ctx) + if err != nil || status.State != runtime.StateRunning { + return + } + + rows, err := buildhistory.List(ctx, socket) + if err != nil { + s.logger.Debug("build history sync skipped", "error", err) + return + } + + if len(rows) == 0 { + return + } + + enrichCtx, cancel := context.WithTimeout(ctx, buildSyncEnrichTimeout) + defer cancel() + + s.buildsMu.RLock() + known := make(map[string]struct{}, len(s.builds)) + syncItems := make([]runtime.Build, 0) + for _, build := range s.builds { + if build.HistoryRef != "" { + known[build.HistoryRef] = struct{}{} + syncItems = append(syncItems, build) + } + } + importRows := buildhistory.MergeRows(known, rows) + s.buildsMu.RUnlock() + + byHistoryID := buildhistory.RowByHistoryID(rows) + + enrichedByID := make(map[string]runtime.Build, len(syncItems)) + for _, build := range syncItems { + row, ok := byHistoryID[build.HistoryRef] + if !ok { + continue + } + + updated := s.enrichHistoryBuild(enrichCtx, socket, applyHistoryRow(build, row)) + if !buildsEqual(updated, build) { + enrichedByID[build.ID] = updated + } + } + + newBuilds := make([]runtime.Build, 0, len(importRows)) + for _, row := range importRows { + newBuilds = append(newBuilds, s.enrichHistoryBuild(enrichCtx, socket, baseBuildFromHistoryRow(row))) + } + + s.buildsMu.Lock() + defer s.buildsMu.Unlock() + + changed := false + + for index, build := range s.builds { + updated, ok := enrichedByID[build.ID] + if !ok { + continue + } + + s.builds[index] = updated + changed = true + } + + if len(newBuilds) > 0 { + for index := range newBuilds { + s.buildSeq++ + newBuilds[index].ID = fmt.Sprintf("history-%d", s.buildSeq) + } + s.builds = append(newBuilds, s.builds...) + changed = true + } + + if changed { + _ = s.persistBuildsLocked() + } +} + +func buildsEqual(left, right runtime.Build) bool { + return left.Tag == right.Tag && + left.Status == right.Status && + left.CreatedAt == right.CreatedAt && + left.FinishedAt == right.FinishedAt && + left.DurationMs == right.DurationMs && + left.CachedSteps == right.CachedSteps && + left.TotalSteps == right.TotalSteps && + left.Context == right.Context && + left.Dockerfile == right.Dockerfile && + reflect.DeepEqual(left.Steps, right.Steps) && + left.RawLog == right.RawLog && + reflect.DeepEqual(left.Dependencies, right.Dependencies) && + reflect.DeepEqual(left.Results, right.Results) && + reflect.DeepEqual(left.Tags, right.Tags) && + timingEqual(left.Timing, right.Timing) +} + +func timingEqual(left, right runtime.BuildTiming) bool { + return left.ImagePullsMs == right.ImagePullsMs && + left.LocalTransfersMs == right.LocalTransfersMs && + left.ExecutionsMs == right.ExecutionsMs && + left.FileOperationsMs == right.FileOperationsMs && + left.ResultExportsMs == right.ResultExportsMs && + left.IdleMs == right.IdleMs +} + +func (s *Server) enrichHistoryBuild(ctx context.Context, socket string, build runtime.Build) runtime.Build { + if build.HistoryRef == "" { + return build + } + + if !runtime.IsResolvableBuildContext(build.Context) { + detail, err := buildhistory.Inspect(ctx, socket, build.HistoryRef) + if err == nil { + build.Context = detail.Context + if detail.Dockerfile != "" { + build.Dockerfile = runtime.NormalizeDockerfilePath(detail.Context, detail.Dockerfile) + } + + if revision, remote := runtime.CollectGitMetadata(build.Context); revision != "" || remote != "" { + build.SourceRevision = revision + build.RemoteSource = remote + } + } + } + + if isTerminalBuildStatus(build.Status) && build.RawLog == "" && len(build.Steps) == 0 { + logs, err := buildhistory.Logs(ctx, socket, build.HistoryRef) + if err == nil { + runtime.ApplyBuildLogs(&build, logs) + } + } + + runtime.EnrichSyncedBuild(ctx, socket, &build) + + if artifacts, err := buildhistory.BuildArtifacts(ctx, socket, build.HistoryRef, build.Platform); err == nil && len(artifacts) > 0 { + build.Results = artifacts + } + + return build +} + +func isTerminalBuildStatus(status string) bool { + switch status { + case "success", "failed": + return true + default: + return false + } +} + +func baseBuildFromHistoryRow(row buildhistory.Row) runtime.Build { + createdAt := row.BuildCreatedAt() + if createdAt == "" { + createdAt = time.Now().UTC().Format(time.RFC3339) + } + + return runtime.Build{ + HistoryRef: row.HistoryID(), + Tag: buildhistory.NormalizeTag(row.BuildName()), + Context: "", + Dockerfile: "Dockerfile", + Platform: defaultBuildPlatform(), + Status: buildhistory.NormalizeStatus(row.BuildStatus()), + CreatedAt: createdAt, + FinishedAt: row.CompletedAt, + DurationMs: row.BuildDurationMs(), + Builder: "default", + CachedSteps: row.CachedSteps, + TotalSteps: row.TotalSteps, + Steps: []runtime.BuildStep{}, + Dependencies: []runtime.BuildDependency{}, + Results: []runtime.BuildArtifact{}, + Tags: []runtime.BuildTag{}, + } +} + +func applyHistoryRow(build runtime.Build, row buildhistory.Row) runtime.Build { + updated := build + updated.Tag = buildhistory.NormalizeTag(row.BuildName()) + updated.Status = buildhistory.NormalizeStatus(row.BuildStatus()) + if createdAt := row.BuildCreatedAt(); createdAt != "" { + updated.CreatedAt = createdAt + } + if row.CompletedAt != "" { + updated.FinishedAt = row.CompletedAt + } + if durationMs := row.BuildDurationMs(); durationMs > 0 { + updated.DurationMs = durationMs + } + if row.CachedSteps > 0 { + updated.CachedSteps = row.CachedSteps + } + if row.TotalSteps > 0 { + updated.TotalSteps = row.TotalSteps + } + return updated +} diff --git a/backend/internal/api/builds.go b/backend/internal/api/builds.go index ab1d0c5..5c1e1fd 100644 --- a/backend/internal/api/builds.go +++ b/backend/internal/api/builds.go @@ -1,10 +1,15 @@ package api import ( + "context" "fmt" "net/http" + goruntime "runtime" + "strings" "time" + "github.com/enegalan/calf/backend/internal/buildhistory" + "github.com/enegalan/calf/backend/internal/buildstore" "github.com/enegalan/calf/backend/internal/runtime" ) @@ -16,15 +21,28 @@ func (s *Server) handleBuilds(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: + tagFilter := strings.TrimSpace(r.URL.Query().Get("tag")) s.buildsMu.RLock() builds := append([]runtime.Build{}, s.builds...) s.buildsMu.RUnlock() + + if tagFilter != "" { + filtered := make([]runtime.Build, 0) + for _, build := range builds { + if build.Tag == tagFilter { + filtered = append(filtered, build) + } + } + builds = filtered + } + writeJSON(w, http.StatusOK, builds) case http.MethodPost: var payload struct { Context string `json:"context"` Tag string `json:"tag"` Dockerfile string `json:"dockerfile"` + Platform string `json:"platform"` } if err := jsonDecode(r, &payload); err != nil { @@ -37,49 +55,405 @@ func (s *Server) handleBuilds(w http.ResponseWriter, r *http.Request) { return } - build := s.newBuild(payload.Context, payload.Tag, "running") - if err := s.runtime.RunBuild(r.Context(), payload.Context, payload.Tag, payload.Dockerfile); err != nil { - s.updateBuildStatus(build.ID, "failed") - if writeRuntimeError(w, err) { - return - } - - writeError(w, http.StatusInternalServerError, err.Error()) - return + platform := payload.Platform + if platform == "" { + platform = defaultBuildPlatform() } - s.updateBuildStatus(build.ID, "success") - writeJSON(w, http.StatusOK, build) + build := s.newBuild(payload.Context, payload.Tag, payload.Dockerfile, platform, "running") + go s.runBuildJob(build.ID, payload.Context, payload.Tag, payload.Dockerfile, platform) + writeJSON(w, http.StatusAccepted, build) default: methodNotAllowed(w, r) } } -func (s *Server) newBuild(contextPath, tag, status string) runtime.Build { +func (s *Server) handleBuildAction(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + path := strings.TrimPrefix(r.URL.Path, "/v1/builds/") + parts := strings.Split(path, "/") + if len(parts) == 0 || parts[0] == "" { + writeError(w, http.StatusNotFound, "build not found") + return + } + + buildID := parts[0] + if len(parts) == 2 && parts[1] == "source" { + s.handleBuildSource(w, r, buildID) + return + } + if len(parts) == 2 && parts[1] == "logs" { + s.handleBuildLogs(w, r, buildID) + return + } + + if r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + + build, ok := s.getBuild(buildID) + if !ok { + writeError(w, http.StatusNotFound, "build not found") + return + } + + build = s.enrichHistoryBuildIfNeeded(r.Context(), build) + + writeJSON(w, http.StatusOK, build) +} + +func (s *Server) handleBuildSource(w http.ResponseWriter, r *http.Request, buildID string) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + + build, ok := s.getBuild(buildID) + if !ok { + writeError(w, http.StatusNotFound, "build not found") + return + } + + contextPath, dockerfile := s.resolveBuildSourcePaths(r.Context(), build) + if !runtime.IsResolvableBuildContext(contextPath) { + writeError(w, http.StatusNotFound, "Dockerfile source is not available for this build") + return + } + + source, err := runtime.ReadBuildSource(contextPath, dockerfile, build.Platform) + if err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + + if build.Context != contextPath || build.Dockerfile != dockerfile { + s.updateBuildSourcePaths(buildID, contextPath, dockerfile) + } + + writeJSON(w, http.StatusOK, source) +} + +func (s *Server) handleBuildLogs(w http.ResponseWriter, r *http.Request, buildID string) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + + build, ok := s.getBuild(buildID) + if !ok { + writeError(w, http.StatusNotFound, "build not found") + return + } + + build = s.enrichHistoryBuildIfNeeded(r.Context(), build) + + steps := build.Steps + if steps == nil { + steps = []runtime.BuildStep{} + } + + writeJSON(w, http.StatusOK, map[string]any{ + "raw_log": build.RawLog, + "steps": steps, + }) +} + +func (s *Server) resolveBuildSourcePaths(ctx context.Context, build runtime.Build) (string, string) { + contextPath := build.Context + dockerfile := build.Dockerfile + if dockerfile == "" { + dockerfile = "Dockerfile" + } + + if runtime.IsResolvableBuildContext(contextPath) { + return contextPath, dockerfile + } + + if build.HistoryRef == "" { + return contextPath, dockerfile + } + + socket := s.runtime.DockerSocket() + if socket == "" { + return contextPath, dockerfile + } + + detail, err := buildhistory.Inspect(ctx, socket, build.HistoryRef) + if err != nil { + return contextPath, dockerfile + } + + contextPath = detail.Context + if detail.Dockerfile != "" { + dockerfile = detail.Dockerfile + } + + return contextPath, dockerfile +} + +func (s *Server) updateBuildSourcePaths(buildID, contextPath, dockerfile string) { + s.buildsMu.Lock() + defer s.buildsMu.Unlock() + + for index, build := range s.builds { + if build.ID != buildID { + continue + } + + s.builds[index].Context = contextPath + s.builds[index].Dockerfile = dockerfile + _ = s.persistBuildsLocked() + return + } +} + +const buildJobTimeout = 2 * time.Hour + +func (s *Server) runBuildJob(buildID, contextPath, tag, dockerfile, platform string) { + started := time.Now() + ctx, cancel := context.WithTimeout(context.Background(), buildJobTimeout) + defer cancel() + + result, err := s.runtime.RunBuild(ctx, contextPath, tag, dockerfile, platform) + finishedAt := time.Now().UTC().Format(time.RFC3339) + durationMs := time.Since(started).Milliseconds() + + revision, remote := runtime.CollectGitMetadata(contextPath) + + s.buildsMu.Lock() + defer s.buildsMu.Unlock() + + for index, build := range s.builds { + if build.ID != buildID { + continue + } + + build.FinishedAt = finishedAt + build.DurationMs = durationMs + build.SourceRevision = revision + build.RemoteSource = remote + build.Steps = result.Steps + build.Timing = result.Timing + build.CachedSteps = result.CachedSteps + build.TotalSteps = result.TotalSteps + build.Dependencies = result.Dependencies + build.Results = result.Results + build.Tags = result.Tags + build.RawLog = result.RawLog + + if len(build.Dependencies) == 0 { + build.Dependencies = []runtime.BuildDependency{} + } + if len(build.Results) == 0 { + build.Results = []runtime.BuildArtifact{} + } + if len(build.Tags) == 0 { + build.Tags = []runtime.BuildTag{} + } + if len(build.Steps) == 0 { + build.Steps = []runtime.BuildStep{} + } + + if err != nil { + build.Status = "failed" + if ctx.Err() == context.DeadlineExceeded { + build.Error = "build timed out" + } else if ctx.Err() == context.Canceled { + build.Error = "build canceled" + } else { + build.Error = err.Error() + } + } else { + build.Status = "success" + } + + s.builds[index] = build + _ = s.persistBuildsLocked() + return + } +} + +func (s *Server) newBuild(contextPath, tag, dockerfile, platform, status string) runtime.Build { s.buildsMu.Lock() defer s.buildsMu.Unlock() s.buildSeq++ + if dockerfile == "" { + dockerfile = "Dockerfile" + } + build := runtime.Build{ - ID: fmt.Sprintf("build-%d", s.buildSeq), - Tag: tag, - Context: contextPath, - Status: status, - CreatedAt: time.Now().UTC().Format(time.RFC3339), + ID: fmt.Sprintf("build-%d", s.buildSeq), + Tag: tag, + Context: contextPath, + Dockerfile: dockerfile, + Platform: platform, + Status: status, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + Builder: "default", + Steps: []runtime.BuildStep{}, + Dependencies: []runtime.BuildDependency{}, + Results: []runtime.BuildArtifact{}, + Tags: []runtime.BuildTag{}, } s.builds = append([]runtime.Build{build}, s.builds...) + _ = s.persistBuildsLocked() return build } -func (s *Server) updateBuildStatus(id, status string) { +func (s *Server) addMigratedBuild(build runtime.Build) { s.buildsMu.Lock() defer s.buildsMu.Unlock() - for index, build := range s.builds { + s.buildSeq++ + build.ID = fmt.Sprintf("migrated-%d", s.buildSeq) + if build.Builder == "" { + build.Builder = "default" + } + if build.Steps == nil { + build.Steps = []runtime.BuildStep{} + } + if build.Dependencies == nil { + build.Dependencies = []runtime.BuildDependency{} + } + if build.Results == nil { + build.Results = []runtime.BuildArtifact{} + } + if build.Tags == nil { + build.Tags = []runtime.BuildTag{} + } + + s.builds = append([]runtime.Build{build}, s.builds...) + _ = s.persistBuildsLocked() +} + +func hasLegacyBuildResults(results []runtime.BuildArtifact) bool { + return len(results) == 1 && results[0].Name == "application/vnd.docker.container.image.v1+json" +} + +func (s *Server) enrichHistoryBuildIfNeeded(ctx context.Context, build runtime.Build) runtime.Build { + if build.HistoryRef == "" { + return build + } + + needsEnrichment := !runtime.IsResolvableBuildContext(build.Context) || + (build.RawLog == "" && len(build.Steps) == 0) || + len(build.Dependencies) == 0 || + len(build.Results) == 0 || + hasLegacyBuildResults(build.Results) || + len(build.Tags) == 0 + + if !needsEnrichment { + return build + } + + socket := s.runtime.DockerSocket() + if socket == "" { + return build + } + + enriched := s.enrichHistoryBuild(ctx, socket, build) + if buildsEqual(build, enriched) { + return build + } + + s.buildsMu.Lock() + defer s.buildsMu.Unlock() + + for index, candidate := range s.builds { + if candidate.ID != build.ID { + continue + } + + merged := applyBuildEnrichment(candidate, enriched) + if buildsEqual(merged, candidate) { + return merged + } + + s.builds[index] = merged + _ = s.persistBuildsLocked() + return merged + } + + return enriched +} + +func applyBuildEnrichment(current, enriched runtime.Build) runtime.Build { + if enriched.Context != "" && !runtime.IsResolvableBuildContext(current.Context) { + current.Context = enriched.Context + } + if enriched.Dockerfile != "" && enriched.Dockerfile != "Dockerfile" { + if current.Dockerfile == "" || current.Dockerfile == "Dockerfile" { + current.Dockerfile = enriched.Dockerfile + } + } + if enriched.SourceRevision != "" && current.SourceRevision == "" { + current.SourceRevision = enriched.SourceRevision + } + if enriched.RemoteSource != "" && current.RemoteSource == "" { + current.RemoteSource = enriched.RemoteSource + } + if enriched.RawLog != "" && current.RawLog == "" { + current.RawLog = enriched.RawLog + } + if len(enriched.Steps) > 0 && len(current.Steps) == 0 { + current.Steps = enriched.Steps + } + if len(enriched.Dependencies) > 0 && len(current.Dependencies) == 0 { + current.Dependencies = enriched.Dependencies + } + if len(enriched.Results) > 0 && (len(current.Results) == 0 || hasLegacyBuildResults(current.Results)) { + current.Results = enriched.Results + } + if len(enriched.Tags) > 0 && len(current.Tags) == 0 { + current.Tags = enriched.Tags + } + if enriched.Timing != (runtime.BuildTiming{}) && current.Timing == (runtime.BuildTiming{}) { + current.Timing = enriched.Timing + } + if enriched.CachedSteps > 0 && current.CachedSteps == 0 { + current.CachedSteps = enriched.CachedSteps + } + if enriched.TotalSteps > 0 && current.TotalSteps == 0 { + current.TotalSteps = enriched.TotalSteps + } + return current +} + +func (s *Server) getBuild(id string) (runtime.Build, bool) { + s.buildsMu.RLock() + defer s.buildsMu.RUnlock() + + for _, build := range s.builds { if build.ID == id { - s.builds[index].Status = status - return + return build, true } } + + return runtime.Build{}, false +} + +func (s *Server) persistBuildsLocked() error { + return buildstore.Save(s.builds, s.buildSeq) +} + +func (s *Server) loadBuilds() { + file, err := buildstore.Load() + if err != nil { + s.logger.Warn("failed to load build history", "error", err) + return + } + + s.builds = file.Builds + s.buildSeq = file.Seq +} + +func defaultBuildPlatform() string { + return fmt.Sprintf("linux/%s", goruntime.GOARCH) } diff --git a/backend/internal/api/docker_cli.go b/backend/internal/api/docker_cli.go new file mode 100644 index 0000000..deb3ef2 --- /dev/null +++ b/backend/internal/api/docker_cli.go @@ -0,0 +1,77 @@ +package api + +import ( + "context" + "os" + "time" + + "github.com/enegalan/calf/backend/internal/dockercli" + "github.com/enegalan/calf/backend/internal/runtime" +) + +const dockerContextTimeout = 30 * time.Second + +func (s *Server) StartDockerContextManager(ctx context.Context) { + interval := 5 * time.Second + ticker := time.NewTicker(interval) + defer ticker.Stop() + + s.ensureDockerContext(ctx) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.ensureDockerContext(ctx) + } + } +} + +func (s *Server) ensureDockerContext(ctx context.Context) { + s.cfgMu.RLock() + managed := s.cfg.DockerContextManaged + s.cfgMu.RUnlock() + + if !managed { + return + } + + socket := s.runtime.DockerSocket() + if socket == "" { + return + } + + if _, err := os.Stat(socket); err != nil { + return + } + + status, err := s.runtime.Status(ctx) + if err != nil || status.State != runtime.StateRunning { + return + } + + activateCtx, cancel := context.WithTimeout(ctx, dockerContextTimeout) + defer cancel() + + if err := dockercli.EnsureAndActivate(activateCtx, socket); err != nil { + s.logger.Debug("docker context activation skipped", "error", err) + } +} + +func (s *Server) dockerCLIStatus() (dockercli.Status, error) { + s.cfgMu.RLock() + managed := s.cfg.DockerContextManaged + s.cfgMu.RUnlock() + + return dockercli.StatusFor(s.runtime.DockerSocket(), managed) +} + +func (s *Server) activateDockerContext(ctx context.Context) error { + socket := s.runtime.DockerSocket() + if socket == "" { + return nil + } + + return dockercli.EnsureAndActivate(ctx, socket) +} diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index e3a31c0..2212562 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" goruntime "runtime" "net/http" @@ -47,18 +48,23 @@ type statusResponse struct { } type configResponse struct { - PollIntervalMs int `json:"poll_interval_ms"` - CPUs int `json:"cpus"` - MemoryGB int `json:"memory_gb"` - MemorySwapGB int `json:"memory_swap_gb"` - HostCPUs int `json:"host_cpus"` - HostMemoryGB int `json:"host_memory_gb"` + PollIntervalMs int `json:"poll_interval_ms"` + CPUs int `json:"cpus"` + MemoryGB int `json:"memory_gb"` + MemorySwapGB int `json:"memory_swap_gb"` + HostCPUs int `json:"host_cpus"` + HostMemoryGB int `json:"host_memory_gb"` + DockerContextManaged bool `json:"docker_context_managed"` + DockerContextActive bool `json:"docker_context_active"` + DockerContextName string `json:"docker_context_name"` + DockerCLIAvailable bool `json:"docker_cli_available"` } type configUpdateRequest struct { - CPUs *int `json:"cpus,omitempty"` - MemoryGB *int `json:"memory_gb,omitempty"` - MemorySwapGB *int `json:"memory_swap_gb,omitempty"` + CPUs *int `json:"cpus,omitempty"` + MemoryGB *int `json:"memory_gb,omitempty"` + MemorySwapGB *int `json:"memory_swap_gb,omitempty"` + DockerContextManaged *bool `json:"docker_context_managed,omitempty"` } func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { @@ -69,14 +75,7 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - writeJSON(w, http.StatusOK, configResponse{ - PollIntervalMs: s.cfg.PollIntervalMs, - CPUs: s.cfg.CPUs, - MemoryGB: s.cfg.MemoryGB, - MemorySwapGB: s.cfg.MemorySwapGB, - HostCPUs: hostCPUs(), - HostMemoryGB: hostMemoryGB(), - }) + writeJSON(w, http.StatusOK, s.configResponse()) case http.MethodPut: var req configUpdateRequest @@ -85,6 +84,7 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { return } + s.cfgMu.Lock() if req.CPUs != nil { s.cfg.CPUs = *req.CPUs } @@ -94,20 +94,28 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { if req.MemorySwapGB != nil { s.cfg.MemorySwapGB = *req.MemorySwapGB } + if req.DockerContextManaged != nil { + s.cfg.DockerContextManaged = *req.DockerContextManaged + } if err := config.Save(s.cfg); err != nil { + s.cfgMu.Unlock() writeError(w, http.StatusInternalServerError, "failed to save config: "+err.Error()) return } - writeJSON(w, http.StatusOK, configResponse{ - PollIntervalMs: s.cfg.PollIntervalMs, - CPUs: s.cfg.CPUs, - MemoryGB: s.cfg.MemoryGB, - MemorySwapGB: s.cfg.MemorySwapGB, - HostCPUs: hostCPUs(), - HostMemoryGB: hostMemoryGB(), - }) + activateManaged := s.cfg.DockerContextManaged + s.cfgMu.Unlock() + + if activateManaged { + activateCtx, cancel := context.WithTimeout(r.Context(), dockerContextTimeout) + defer cancel() + if err := s.activateDockerContext(activateCtx); err != nil { + s.logger.Warn("failed to activate docker context", "error", err) + } + } + + writeJSON(w, http.StatusOK, s.configResponse()) default: writeError(w, http.StatusMethodNotAllowed, "method not allowed") @@ -153,3 +161,24 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { Runtime: runtimeStatus, }) } + +func (s *Server) configResponse() configResponse { + cliStatus, _ := s.dockerCLIStatus() + + s.cfgMu.RLock() + cfg := s.cfg + s.cfgMu.RUnlock() + + return configResponse{ + PollIntervalMs: cfg.PollIntervalMs, + CPUs: cfg.CPUs, + MemoryGB: cfg.MemoryGB, + MemorySwapGB: cfg.MemorySwapGB, + HostCPUs: hostCPUs(), + HostMemoryGB: hostMemoryGB(), + DockerContextManaged: cfg.DockerContextManaged, + DockerContextActive: cliStatus.CalfActive, + DockerContextName: cliStatus.CurrentContext, + DockerCLIAvailable: cliStatus.Available, + } +} diff --git a/backend/internal/api/migrate.go b/backend/internal/api/migrate.go index b543aa4..09849f2 100644 --- a/backend/internal/api/migrate.go +++ b/backend/internal/api/migrate.go @@ -76,23 +76,32 @@ func (s *Server) runDockerDesktopMigration() { s.migrateMu.Unlock() }, SaveConfig: func(cfg config.Config) error { + s.cfgMu.Lock() + defer s.cfgMu.Unlock() + s.cfg.CPUs = cfg.CPUs s.cfg.MemoryGB = cfg.MemoryGB s.cfg.MemorySwapGB = cfg.MemorySwapGB return config.Save(s.cfg) }, - AddBuild: func(build runtime.Build) { - s.buildsMu.Lock() - s.buildSeq++ - build.ID = fmt.Sprintf("migrated-%d", s.buildSeq) - s.builds = append([]runtime.Build{build}, s.builds...) - s.buildsMu.Unlock() - }, + AddBuild: s.addMigratedBuild, }) s.migrateMu.Lock() s.migrateStatus = status s.migrateMu.Unlock() + + s.cfgMu.RLock() + managed := s.cfg.DockerContextManaged + s.cfgMu.RUnlock() + + if status.Phase == migration.PhaseCompleted && managed { + activateCtx, cancel := context.WithTimeout(ctx, dockerContextTimeout) + defer cancel() + if err := s.activateDockerContext(activateCtx); err != nil { + s.logger.Warn("failed to activate docker context after migration", "error", err) + } + } } func (s *Server) runNerdctl(ctx context.Context, args ...string) error { diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go index 7f75e5b..495cd97 100644 --- a/backend/internal/api/server.go +++ b/backend/internal/api/server.go @@ -16,6 +16,7 @@ import ( type Server struct { cfg config.Config + cfgMu sync.RWMutex logger *slog.Logger runtime runtime.Runtime startTime time.Time @@ -37,7 +38,7 @@ var logsUpgrader = websocket.Upgrader{ } func New(cfg config.Config, logger *slog.Logger, rt runtime.Runtime) *Server { - return &Server{ + server := &Server{ cfg: cfg, logger: logger, runtime: rt, @@ -45,6 +46,8 @@ func New(cfg config.Config, logger *slog.Logger, rt runtime.Runtime) *Server { migrateStatus: migration.IdleStatus(), logBroadcaster: newLogBroadcaster(), } + server.loadBuilds() + return server } func (s *Server) Handler() http.Handler { @@ -58,6 +61,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/v1/volumes", s.handleVolumes) mux.HandleFunc("/v1/volumes/", s.handleVolumeAction) mux.HandleFunc("/v1/builds", s.handleBuilds) + mux.HandleFunc("/v1/builds/", s.handleBuildAction) mux.HandleFunc("/v1/registry", s.handleRegistry) mux.HandleFunc("/v1/registry/login", s.handleRegistryLogin) mux.HandleFunc("/v1/registry/login/", s.handleRegistryLogin) diff --git a/backend/internal/buildhistory/artifacts.go b/backend/internal/buildhistory/artifacts.go new file mode 100644 index 0000000..70aad51 --- /dev/null +++ b/backend/internal/buildhistory/artifacts.go @@ -0,0 +1,233 @@ +package buildhistory + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/enegalan/calf/backend/internal/runtime" +) + +type attachmentRow struct { + Type string + Platform string + Digest string +} + +type ociManifest struct { + Config struct { + MediaType string `json:"mediaType"` + Digest string `json:"digest"` + Size int64 `json:"size"` + } `json:"config"` +} + +func BuildArtifacts(ctx context.Context, socket, historyID, platform string) ([]runtime.BuildArtifact, error) { + historyID = strings.TrimSpace(historyID) + if historyID == "" { + return nil, fmt.Errorf("build history artifacts: missing history id") + } + + attachments, err := listAttachments(ctx, socket, historyID) + if err != nil { + return nil, err + } + + artifacts := make([]runtime.BuildArtifact, 0, len(attachments)+1) + manifestPlatform := platformArch(platform) + + for _, attachment := range attachments { + switch attachment.Type { + case "application/vnd.oci.image.manifest.v1+json": + if attachment.Platform != "" { + manifestPlatform = platformArch(attachment.Platform) + } + + manifestArtifacts, err := manifestArtifacts(ctx, socket, historyID, attachment.Digest, manifestPlatform) + if err != nil { + continue + } + artifacts = append(artifacts, manifestArtifacts...) + case "https://slsa.dev/provenance/v0.2": + artifact, err := attachmentArtifact(ctx, socket, historyID, attachment, "Provenance v1", manifestPlatform) + if err != nil { + continue + } + artifacts = append(artifacts, artifact) + } + } + + if len(artifacts) == 0 { + return []runtime.BuildArtifact{}, nil + } + + return orderBuildArtifacts(artifacts), nil +} + +func listAttachments(ctx context.Context, socket, historyID string) ([]attachmentRow, error) { + output, err := runDocker( + ctx, + socket, + "buildx", + "history", + "inspect", + historyID, + "--format", + "{{range .Attachments}}TYPE={{.Type}}|PLATFORM={{.Platform}}|DIGEST={{.Digest}}{{println}}{{end}}", + ) + if err != nil { + return nil, err + } + + return parseAttachmentRows(string(output)), nil +} + +func parseAttachmentRows(output string) []attachmentRow { + rows := make([]attachmentRow, 0) + for _, line := range strings.Split(strings.TrimSpace(output), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + row := attachmentRow{} + for _, part := range strings.Split(line, "|") { + switch { + case strings.HasPrefix(part, "TYPE="): + row.Type = strings.TrimPrefix(part, "TYPE=") + case strings.HasPrefix(part, "PLATFORM="): + row.Platform = strings.TrimPrefix(part, "PLATFORM=") + case strings.HasPrefix(part, "DIGEST="): + row.Digest = strings.TrimPrefix(part, "DIGEST=") + } + } + + if row.Type == "" || row.Digest == "" { + continue + } + + rows = append(rows, row) + } + + return rows +} + +func manifestArtifacts(ctx context.Context, socket, historyID, digest, platform string) ([]runtime.BuildArtifact, error) { + output, err := attachmentBody(ctx, socket, historyID, digest) + if err != nil { + return nil, err + } + + var manifest ociManifest + if err := json.Unmarshal(output, &manifest); err != nil { + return nil, err + } + + if manifest.Config.Digest == "" { + return nil, fmt.Errorf("build history artifacts: missing manifest config") + } + + return []runtime.BuildArtifact{ + { + Name: manifest.Config.MediaType, + Platform: platform, + Digest: manifest.Config.Digest, + Size: runtime.FormatBytes(manifest.Config.Size), + }, + }, nil +} + +func attachmentArtifact( + ctx context.Context, + socket, historyID string, + attachment attachmentRow, + name, platform string, +) (runtime.BuildArtifact, error) { + output, err := attachmentBody(ctx, socket, historyID, attachment.Digest) + if err != nil { + return runtime.BuildArtifact{}, err + } + + artifactPlatform := platform + + return runtime.BuildArtifact{ + Name: name, + Platform: artifactPlatform, + Digest: digestForBytes(output), + Size: runtime.FormatBytes(int64(len(output))), + }, nil +} + +func attachmentBody(ctx context.Context, socket, historyID, digest string) ([]byte, error) { + output, err := runDocker(ctx, socket, "buildx", "history", "inspect", "attachment", historyID, digest) + if err != nil { + return nil, err + } + + return bytesTrim(output), nil +} + +func digestForBytes(data []byte) string { + if len(data) == 0 { + return "" + } + + hash := sha256.Sum256(data) + return "sha256:" + hex.EncodeToString(hash[:]) +} + +func orderBuildArtifacts(artifacts []runtime.BuildArtifact) []runtime.BuildArtifact { + byName := make(map[string]runtime.BuildArtifact, len(artifacts)) + for _, artifact := range artifacts { + byName[artifact.Name] = artifact + } + + preferred := []string{ + "application/vnd.oci.image.config.v1+json", + "Provenance v1", + } + + ordered := make([]runtime.BuildArtifact, 0, len(artifacts)) + seen := make(map[string]struct{}, len(artifacts)) + for _, name := range preferred { + artifact, ok := byName[name] + if !ok { + continue + } + ordered = append(ordered, artifact) + seen[name] = struct{}{} + } + + for _, artifact := range artifacts { + if _, ok := seen[artifact.Name]; ok { + continue + } + ordered = append(ordered, artifact) + } + + return ordered +} + +func platformArch(platform string) string { + if platform == "" { + return "" + } + + parts := strings.Split(platform, "/") + if len(parts) == 2 { + return parts[1] + } + + if strings.HasPrefix(platform, "linux/") { + return strings.TrimPrefix(platform, "linux/") + } + + return platform +} + +func bytesTrim(output []byte) []byte { + return []byte(strings.TrimSpace(string(output))) +} diff --git a/backend/internal/buildhistory/artifacts_test.go b/backend/internal/buildhistory/artifacts_test.go new file mode 100644 index 0000000..ded55e4 --- /dev/null +++ b/backend/internal/buildhistory/artifacts_test.go @@ -0,0 +1,39 @@ +package buildhistory + +import ( + "testing" + + "github.com/enegalan/calf/backend/internal/runtime" +) + +func TestParseAttachmentRows(t *testing.T) { + rows := parseAttachmentRows(`TYPE=application/vnd.oci.image.index.v1+json|PLATFORM=|DIGEST=sha256:abc +TYPE=https://slsa.dev/provenance/v0.2|PLATFORM=|DIGEST=sha256:def +TYPE=application/vnd.oci.image.manifest.v1+json|PLATFORM=linux/arm64|DIGEST=sha256:ghi`) + + if len(rows) != 3 { + t.Fatalf("expected 3 rows, got %d", len(rows)) + } + + if rows[2].Platform != "linux/arm64" { + t.Fatalf("unexpected platform: %q", rows[2].Platform) + } +} + +func TestOrderBuildArtifacts(t *testing.T) { + ordered := orderBuildArtifacts([]runtime.BuildArtifact{ + {Name: "Provenance v1"}, + {Name: "application/vnd.oci.image.config.v1+json"}, + }) + + if len(ordered) != 2 { + t.Fatalf("expected 2 artifacts, got %d", len(ordered)) + } + + if ordered[0].Name != "application/vnd.oci.image.config.v1+json" { + t.Fatalf("unexpected first artifact: %q", ordered[0].Name) + } + if ordered[1].Name != "Provenance v1" { + t.Fatalf("unexpected second artifact: %q", ordered[1].Name) + } +} diff --git a/backend/internal/buildhistory/history.go b/backend/internal/buildhistory/history.go new file mode 100644 index 0000000..655644b --- /dev/null +++ b/backend/internal/buildhistory/history.go @@ -0,0 +1,201 @@ +package buildhistory + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +type Row struct { + ID string `json:"ID"` + Ref string `json:"ref"` + Name string `json:"Name"` + NameLower string `json:"name"` + Status string `json:"Status"` + StatusLower string `json:"status"` + CreatedAt string `json:"CreatedAt"` + CreatedAtLower string `json:"created_at"` + CompletedAt string `json:"completed_at"` + Duration string `json:"Duration"` + CachedSteps int `json:"cached_steps"` + TotalSteps int `json:"total_steps"` +} + +func (r Row) HistoryID() string { + if r.ID != "" { + return r.ID + } + + if r.Ref == "" { + return "" + } + + parts := strings.Split(r.Ref, "/") + return parts[len(parts)-1] +} + +func (r Row) BuildName() string { + if r.Name != "" { + return r.Name + } + + return r.NameLower +} + +func (r Row) BuildStatus() string { + if r.Status != "" { + return r.Status + } + + return r.StatusLower +} + +func (r Row) BuildCreatedAt() string { + if r.CreatedAt != "" { + return r.CreatedAt + } + + return r.CreatedAtLower +} + +func (r Row) BuildDurationMs() int64 { + if duration := ParseDurationMs(r.Duration); duration > 0 { + return duration + } + + created, err := time.Parse(time.RFC3339Nano, r.CreatedAtLower) + if err != nil { + return 0 + } + + completed, err := time.Parse(time.RFC3339Nano, r.CompletedAt) + if err != nil { + return 0 + } + + return completed.Sub(created).Milliseconds() +} + +func List(ctx context.Context, socket string) ([]Row, error) { + output, err := runDocker(ctx, socket, "buildx", "history", "ls", "--format", "{{json .}}") + if err != nil { + return nil, err + } + + return parseRows(output), nil +} + +func parseRows(output []byte) []Row { + rows := make([]Row, 0) + scanner := bufio.NewScanner(bytes.NewReader(output)) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + var row Row + if err := json.Unmarshal([]byte(line), &row); err != nil { + continue + } + + if row.HistoryID() == "" && row.BuildName() == "" { + continue + } + + rows = append(rows, row) + } + + return rows +} + +func MergeRows(existingRefs map[string]struct{}, rows []Row) []Row { + imported := make([]Row, 0) + for _, row := range rows { + historyID := row.HistoryID() + if historyID == "" { + continue + } + if _, exists := existingRefs[historyID]; exists { + continue + } + imported = append(imported, row) + existingRefs[historyID] = struct{}{} + } + return imported +} + +func RowByHistoryID(rows []Row) map[string]Row { + indexed := make(map[string]Row, len(rows)) + for _, row := range rows { + historyID := row.HistoryID() + if historyID == "" { + continue + } + indexed[historyID] = row + } + return indexed +} + +func NormalizeStatus(status string) string { + switch strings.ToLower(strings.TrimSpace(status)) { + case "completed", "complete", "success": + return "success" + case "error", "failed", "canceled", "cancelled": + return "failed" + case "running", "in progress", "in_progress": + return "running" + default: + if status == "" { + return "success" + } + return strings.ToLower(status) + } +} + +func NormalizeTag(name string) string { + tag := strings.TrimSpace(name) + if tag == "" { + return "untagged-build" + } + + return tag +} + +const dockerCLITimeout = 2 * time.Minute + +func ParseDurationMs(value string) int64 { + value = strings.TrimSpace(value) + if value == "" { + return 0 + } + + compact := strings.ReplaceAll(value, " ", "") + duration, err := time.ParseDuration(compact) + if err != nil { + return 0 + } + + return duration.Milliseconds() +} + +func runDocker(ctx context.Context, socket string, args ...string) ([]byte, error) { + runCtx, cancel := context.WithTimeout(ctx, dockerCLITimeout) + defer cancel() + + command := exec.CommandContext(runCtx, "docker", args...) + command.Env = append(os.Environ(), "DOCKER_HOST=unix://"+socket) + output, err := command.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("docker %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(output))) + } + + return output, nil +} diff --git a/backend/internal/buildhistory/history_test.go b/backend/internal/buildhistory/history_test.go new file mode 100644 index 0000000..89b4f29 --- /dev/null +++ b/backend/internal/buildhistory/history_test.go @@ -0,0 +1,56 @@ +package buildhistory + +import ( + "testing" +) + +func TestParseDurationMs(t *testing.T) { + if got := ParseDurationMs("39.9s"); got < 39900 || got > 40000 { + t.Fatalf("expected ~39900ms, got %d", got) + } + + if got := ParseDurationMs("1m 1s"); got < 61000 || got > 62000 { + t.Fatalf("expected ~61000ms, got %d", got) + } + + if got := ParseDurationMs("1h5m10s"); got < 3910000 || got > 3920000 { + t.Fatalf("expected ~3910000ms, got %d", got) + } +} + +func TestMergeRowsSkipsDuplicates(t *testing.T) { + refs := map[string]struct{}{"abc123": {}} + rows := []Row{ + {Ref: "default/default/abc123", NameLower: "demo"}, + {Ref: "default/default/def456", NameLower: "other"}, + } + + imported := MergeRows(refs, rows) + if len(imported) != 1 { + t.Fatalf("expected 1 imported row, got %d", len(imported)) + } + if imported[0].HistoryID() != "def456" { + t.Fatalf("unexpected imported id %q", imported[0].HistoryID()) + } +} + +func TestParseBuildxHistoryJSON(t *testing.T) { + rows := parseRows([]byte(`{"cached_steps":1,"completed_at":"2026-07-04T01:10:03.861183581Z","completed_steps":5,"created_at":"2026-07-04T01:10:03.808406711Z","name":"p2p-lan-p2p-lan","ref":"default/default/szzsnkdslb4c2ees6aok7xln6","status":"Completed","total_steps":5}`)) + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + + row := rows[0] + if row.BuildName() != "p2p-lan-p2p-lan" { + t.Fatalf("unexpected name %q", row.BuildName()) + } + if row.HistoryID() != "szzsnkdslb4c2ees6aok7xln6" { + t.Fatalf("unexpected history id %q", row.HistoryID()) + } + if row.CachedSteps != 1 || row.TotalSteps != 5 { + t.Fatalf("unexpected step counts") + } + if row.BuildDurationMs() <= 0 { + t.Fatalf("expected positive duration") + } +} diff --git a/backend/internal/buildhistory/inspect.go b/backend/internal/buildhistory/inspect.go new file mode 100644 index 0000000..e93f307 --- /dev/null +++ b/backend/internal/buildhistory/inspect.go @@ -0,0 +1,68 @@ +package buildhistory + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +type InspectDetail struct { + Context string + Dockerfile string +} + +func Inspect(ctx context.Context, socket, historyID string) (InspectDetail, error) { + historyID = strings.TrimSpace(historyID) + if historyID == "" { + return InspectDetail{}, fmt.Errorf("build history inspect: missing history id") + } + + output, err := runDocker( + ctx, + socket, + "buildx", + "history", + "inspect", + historyID, + "--format", + "{{json .}}", + ) + if err != nil { + return InspectDetail{}, err + } + + detail := parseInspectDetail(string(output)) + if detail.Context == "" { + return InspectDetail{}, fmt.Errorf("build history inspect: missing context") + } + + return detail, nil +} + +func parseInspectDetail(output string) InspectDetail { + detail := InspectDetail{Dockerfile: "Dockerfile"} + + output = strings.TrimSpace(output) + if output == "" { + return detail + } + + var payload map[string]string + if err := json.Unmarshal([]byte(output), &payload); err != nil { + return detail + } + + for key, value := range payload { + switch strings.ToLower(key) { + case "context": + detail.Context = value + case "dockerfile": + if value != "" { + detail.Dockerfile = value + } + } + } + + return detail +} diff --git a/backend/internal/buildhistory/inspect_test.go b/backend/internal/buildhistory/inspect_test.go new file mode 100644 index 0000000..9c5eace --- /dev/null +++ b/backend/internal/buildhistory/inspect_test.go @@ -0,0 +1,27 @@ +package buildhistory + +import "testing" + +func TestParseInspectDetail(t *testing.T) { + detail := parseInspectDetail(`{"Context":"/Users/egalan/git/toth","Dockerfile":"apps/api/Dockerfile"}`) + if detail.Context != "/Users/egalan/git/toth" { + t.Fatalf("unexpected context: %q", detail.Context) + } + if detail.Dockerfile != "apps/api/Dockerfile" { + t.Fatalf("unexpected dockerfile: %q", detail.Dockerfile) + } +} + +func TestParseInspectDetailDefaultsDockerfile(t *testing.T) { + detail := parseInspectDetail(`{"Context":"/tmp/build"}`) + if detail.Dockerfile != "Dockerfile" { + t.Fatalf("unexpected dockerfile: %q", detail.Dockerfile) + } +} + +func TestParseInspectDetailPreservesContextWithSpaces(t *testing.T) { + detail := parseInspectDetail(`{"Context":"/Users/egalan/git/my project","Dockerfile":"Dockerfile"}`) + if detail.Context != "/Users/egalan/git/my project" { + t.Fatalf("unexpected context: %q", detail.Context) + } +} diff --git a/backend/internal/buildhistory/logs.go b/backend/internal/buildhistory/logs.go new file mode 100644 index 0000000..338946a --- /dev/null +++ b/backend/internal/buildhistory/logs.go @@ -0,0 +1,21 @@ +package buildhistory + +import ( + "context" + "fmt" + "strings" +) + +func Logs(ctx context.Context, socket, historyID string) (string, error) { + historyID = strings.TrimSpace(historyID) + if historyID == "" { + return "", fmt.Errorf("build history logs: missing history id") + } + + output, err := runDocker(ctx, socket, "buildx", "history", "logs", historyID) + if err != nil { + return "", err + } + + return string(output), nil +} diff --git a/backend/internal/buildstore/store.go b/backend/internal/buildstore/store.go new file mode 100644 index 0000000..a7df225 --- /dev/null +++ b/backend/internal/buildstore/store.go @@ -0,0 +1,90 @@ +package buildstore + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + + "github.com/enegalan/calf/backend/internal/runtime" +) + +const maxBuilds = 200 + +type File struct { + Builds []runtime.Build `json:"builds"` + Seq int `json:"seq"` +} + +func Path() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + + return filepath.Join(home, ".config", "calf", "builds.json"), nil +} + +func Load() (File, error) { + path, err := Path() + if err != nil { + return File{}, err + } + + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return File{Builds: []runtime.Build{}}, nil + } + + if err != nil { + return File{}, err + } + + var file File + if err := json.Unmarshal(data, &file); err != nil { + return File{}, err + } + + if file.Builds == nil { + file.Builds = []runtime.Build{} + } + + return file, nil +} + +func Save(builds []runtime.Build, seq int) error { + path, err := Path() + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + + file := File{ + Builds: trimBuilds(builds), + Seq: seq, + } + + data, err := json.MarshalIndent(file, "", " ") + if err != nil { + return err + } + + tmpPath := path + ".tmp" + if err := os.WriteFile(tmpPath, data, 0o644); err != nil { + return err + } + + return os.Rename(tmpPath, path) +} + +// trimBuilds keeps the newest maxBuilds entries. Callers must maintain builds in newest-first order. +func trimBuilds(builds []runtime.Build) []runtime.Build { + if len(builds) <= maxBuilds { + return builds + } + + return builds[:maxBuilds] +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index ea6e5dd..9ff360a 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -13,15 +13,16 @@ import ( var defaultConfigYAML []byte type Config struct { - ListenAddr string `yaml:"listen_addr"` - LogLevel string `yaml:"log_level"` - VMName string `yaml:"vm_name"` - DockerSocket string `yaml:"docker_socket"` - PollIntervalMs int `yaml:"poll_interval_ms"` - CPUs int `yaml:"cpus"` - MemoryGB int `yaml:"memory_gb"` - MemorySwapGB int `yaml:"memory_swap_gb"` - DiskGB int `yaml:"disk_gb"` + ListenAddr string `yaml:"listen_addr"` + LogLevel string `yaml:"log_level"` + VMName string `yaml:"vm_name"` + DockerSocket string `yaml:"docker_socket"` + PollIntervalMs int `yaml:"poll_interval_ms"` + DockerContextManaged bool `yaml:"docker_context_managed"` + CPUs int `yaml:"cpus"` + MemoryGB int `yaml:"memory_gb"` + MemorySwapGB int `yaml:"memory_swap_gb"` + DiskGB int `yaml:"disk_gb"` } func Default() Config { @@ -68,6 +69,12 @@ func Load() (Config, error) { return cfg, err } + var raw map[string]any + _ = yaml.Unmarshal(data, &raw) + if _, ok := raw["docker_context_managed"]; !ok { + cfg.DockerContextManaged = defaultFromYAML().DockerContextManaged + } + cfg = withDefaults(cfg) return migrateLegacyDefaults(cfg), nil } diff --git a/backend/internal/config/config.yaml b/backend/internal/config/config.yaml index 65dc3ee..774eb20 100644 --- a/backend/internal/config/config.yaml +++ b/backend/internal/config/config.yaml @@ -3,6 +3,7 @@ log_level: info vm_name: calf docker_socket: "" poll_interval_ms: 3000 +docker_context_managed: true cpus: 4 memory_gb: 4 memory_swap_gb: 1 diff --git a/backend/internal/dockercli/context.go b/backend/internal/dockercli/context.go new file mode 100644 index 0000000..cceb8c8 --- /dev/null +++ b/backend/internal/dockercli/context.go @@ -0,0 +1,143 @@ +package dockercli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +const ContextName = "calf" +const cliTimeout = 30 * time.Second + +type Status struct { + Available bool `json:"available"` + CurrentContext string `json:"current_context"` + CalfActive bool `json:"calf_active"` + CalfExists bool `json:"calf_exists"` + Managed bool `json:"managed"` + Socket string `json:"socket"` +} + +type dockerConfig struct { + CurrentContext string `json:"currentContext"` +} + +func StatusFor(socket string, managed bool) (Status, error) { + status := Status{ + Managed: managed, + Socket: socket, + } + + if _, err := exec.LookPath("docker"); err != nil { + return status, nil + } + + status.Available = true + status.CurrentContext = readCurrentContext() + status.CalfActive = status.CurrentContext == ContextName + + ctx, cancel := context.WithTimeout(context.Background(), cliTimeout) + defer cancel() + status.CalfExists = contextExists(ctx, ContextName) + + return status, nil +} + +func readCurrentContext() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + + data, err := os.ReadFile(filepath.Join(home, ".docker", "config.json")) + if err != nil { + return "" + } + + var cfg dockerConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return "" + } + + return cfg.CurrentContext +} + +func contextExists(ctx context.Context, name string) bool { + command := exec.CommandContext(ctx, "docker", "context", "inspect", name, "--format", "{{.Name}}") + output, err := command.Output() + if err != nil { + return false + } + + return strings.TrimSpace(string(output)) == name +} + +func EnsureContext(ctx context.Context, socket string) error { + if socket == "" { + return errors.New("docker socket path is empty") + } + + if _, err := exec.LookPath("docker"); err != nil { + return fmt.Errorf("docker CLI not found") + } + + absSocket, err := filepath.Abs(socket) + if err != nil { + return err + } + + host := "unix://" + absSocket + if contextExists(ctx, ContextName) { + return updateContext(ctx, host) + } + + command := exec.CommandContext(ctx, "docker", "context", "create", ContextName, "--docker", "host="+host) + output, err := command.CombinedOutput() + if err != nil { + return fmt.Errorf("docker context create: %w: %s", err, strings.TrimSpace(string(output))) + } + + return nil +} + +func updateContext(ctx context.Context, host string) error { + command := exec.CommandContext(ctx, "docker", "context", "update", ContextName, "--docker", "host="+host) + output, err := command.CombinedOutput() + if err != nil { + return fmt.Errorf("docker context update: %w: %s", err, strings.TrimSpace(string(output))) + } + + return nil +} + +func ActivateContext(ctx context.Context) error { + if _, err := exec.LookPath("docker"); err != nil { + return fmt.Errorf("docker CLI not found") + } + + command := exec.CommandContext(ctx, "docker", "context", "use", ContextName) + output, err := command.CombinedOutput() + if err != nil { + return fmt.Errorf("docker context use: %w: %s", err, strings.TrimSpace(string(output))) + } + + return nil +} + +func EnsureAndActivate(ctx context.Context, socket string) error { + if err := EnsureContext(ctx, socket); err != nil { + return err + } + + if readCurrentContext() == ContextName { + return nil + } + + return ActivateContext(ctx) +} diff --git a/backend/internal/dockercli/context_test.go b/backend/internal/dockercli/context_test.go new file mode 100644 index 0000000..159e314 --- /dev/null +++ b/backend/internal/dockercli/context_test.go @@ -0,0 +1,35 @@ +package dockercli + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReadCurrentContextMissingFile(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + + if got := readCurrentContext(); got != "" { + t.Fatalf("expected empty context, got %q", got) + } +} + +func TestReadCurrentContextFromConfig(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + + dockerDir := filepath.Join(dir, ".docker") + if err := os.MkdirAll(dockerDir, 0o755); err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + + content := `{"currentContext":"calf"}` + if err := os.WriteFile(filepath.Join(dockerDir, "config.json"), []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + if got := readCurrentContext(); got != "calf" { + t.Fatalf("expected calf, got %q", got) + } +} diff --git a/backend/internal/runtime/build.go b/backend/internal/runtime/build.go new file mode 100644 index 0000000..6fbe9b2 --- /dev/null +++ b/backend/internal/runtime/build.go @@ -0,0 +1,80 @@ +package runtime + +type BuildStep struct { + Index int `json:"index"` + Total int `json:"total"` + Name string `json:"name"` + Cached bool `json:"cached"` + DurationMs int64 `json:"duration_ms"` + Log string `json:"log,omitempty"` +} + +type BuildDependency struct { + Source string `json:"source"` + Platform string `json:"platform"` + Digest string `json:"digest"` +} + +type BuildArtifact struct { + Name string `json:"name"` + Platform string `json:"platform"` + Digest string `json:"digest"` + Size string `json:"size"` +} + +type BuildTag struct { + Tag string `json:"tag"` + Digest string `json:"digest"` +} + +type BuildTiming struct { + ImagePullsMs int64 `json:"image_pulls_ms"` + LocalTransfersMs int64 `json:"local_transfers_ms"` + ExecutionsMs int64 `json:"executions_ms"` + FileOperationsMs int64 `json:"file_operations_ms"` + ResultExportsMs int64 `json:"result_exports_ms"` + IdleMs int64 `json:"idle_ms"` +} + +type Build struct { + ID string `json:"id"` + HistoryRef string `json:"history_ref,omitempty"` + Tag string `json:"tag"` + Context string `json:"context"` + Dockerfile string `json:"dockerfile"` + Platform string `json:"platform"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + FinishedAt string `json:"finished_at,omitempty"` + DurationMs int64 `json:"duration_ms"` + Error string `json:"error,omitempty"` + Builder string `json:"builder"` + CachedSteps int `json:"cached_steps"` + TotalSteps int `json:"total_steps"` + Steps []BuildStep `json:"steps"` + Dependencies []BuildDependency `json:"dependencies"` + Results []BuildArtifact `json:"results"` + Tags []BuildTag `json:"tags"` + Timing BuildTiming `json:"timing"` + SourceRevision string `json:"source_revision,omitempty"` + RemoteSource string `json:"remote_source,omitempty"` + RawLog string `json:"raw_log,omitempty"` +} + +type BuildResult struct { + RawLog string + Steps []BuildStep + Timing BuildTiming + CachedSteps int + TotalSteps int + Dependencies []BuildDependency + Results []BuildArtifact + Tags []BuildTag +} + +type BuildSource struct { + Path string `json:"path"` + Filename string `json:"filename"` + Content string `json:"content"` + Platform string `json:"platform"` +} diff --git a/backend/internal/runtime/build_enrich.go b/backend/internal/runtime/build_enrich.go new file mode 100644 index 0000000..808ec58 --- /dev/null +++ b/backend/internal/runtime/build_enrich.go @@ -0,0 +1,369 @@ +package runtime + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" +) + +var fromLineRe = regexp.MustCompile(`(?i)^FROM\s+(\S+)`) +var buildImageNameRe = regexp.MustCompile(`(?i)naming to\s+(\S+)`) + +type imageInspectRow struct { + ID string `json:"Id"` + Digest string `json:"Digest"` + Size int64 `json:"Size"` + RepoTags []string `json:"RepoTags"` +} + +func enrichBuildResult(ctx context.Context, run commandRunner, contextPath, dockerfile, tag, platform string, result BuildResult) BuildResult { + if dockerfile == "" { + dockerfile = "Dockerfile" + } + + result.Dependencies = parseDockerfileDependencies(contextPath, dockerfile, platform) + result = enrichDependenciesWithInspect(ctx, run, result, platform) + + if tag != "" { + artifacts, tags := inspectBuildImage(ctx, run, tag, platform) + if len(artifacts) > 0 { + result.Results = append(result.Results, artifacts...) + } + if len(tags) > 0 { + result.Tags = tags + } + } + + return result +} + +func parseDockerfileDependencies(contextPath, dockerfile, platform string) []BuildDependency { + path := filepath.Join(contextPath, dockerfile) + file, err := os.Open(path) + if err != nil { + return []BuildDependency{} + } + defer file.Close() + + dependencies := make([]BuildDependency, 0) + seen := make(map[string]struct{}) + scanner := bufio.NewScanner(file) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + matches := fromLineRe.FindStringSubmatch(line) + if len(matches) != 2 { + continue + } + + source := strings.Trim(matches[1], "\"'") + if _, ok := seen[source]; ok { + continue + } + + seen[source] = struct{}{} + dependencies = append(dependencies, BuildDependency{ + Source: source, + Platform: platformArch(platform), + }) + } + + return dependencies +} + +func enrichDependenciesWithInspect(ctx context.Context, run commandRunner, result BuildResult, platform string) BuildResult { + for index, dependency := range result.Dependencies { + output, err := run(ctx, "nerdctl", "image", "inspect", dependency.Source, "--format", "{{json .}}") + if err != nil { + continue + } + + var row imageInspectRow + if err := json.Unmarshal(bytesTrim(output), &row); err != nil { + continue + } + + digest := row.Digest + if digest == "" { + digest = row.ID + } + + result.Dependencies[index].Digest = digest + if result.Dependencies[index].Platform == "" { + result.Dependencies[index].Platform = platformArch(platform) + } + } + + return result +} + +func inspectBuildImage(ctx context.Context, run commandRunner, tag, platform string) ([]BuildArtifact, []BuildTag) { + output, err := run(ctx, "nerdctl", "image", "inspect", tag, "--format", "{{json .}}") + if err != nil { + return nil, nil + } + + var row imageInspectRow + if err := json.Unmarshal(bytesTrim(output), &row); err != nil { + return nil, nil + } + + digest := row.Digest + if digest == "" { + digest = row.ID + } + + size := formatBytes(row.Size) + arch := platformArch(platform) + + artifacts := []BuildArtifact{ + { + Name: "application/vnd.docker.container.image.v1+json", + Platform: arch, + Digest: digest, + Size: size, + }, + } + + tags := make([]BuildTag, 0, len(row.RepoTags)) + if len(row.RepoTags) == 0 { + tags = append(tags, BuildTag{Tag: tag, Digest: digest}) + } else { + for _, repoTag := range row.RepoTags { + tags = append(tags, BuildTag{Tag: repoTag, Digest: digest}) + } + } + + return artifacts, tags +} + +func IsResolvableBuildContext(contextPath string) bool { + if contextPath == "" || contextPath == "docker-cli" { + return false + } + + if !filepath.IsAbs(contextPath) { + return false + } + + _, err := os.Stat(contextPath) + return err == nil +} + +func ReadBuildSource(contextPath, dockerfile, platform string) (BuildSource, error) { + if dockerfile == "" { + dockerfile = "Dockerfile" + } + + absContext, err := filepath.Abs(contextPath) + if err != nil { + return BuildSource{}, err + } + + sourcePath := filepath.Join(absContext, dockerfile) + absSource, err := filepath.Abs(sourcePath) + if err != nil { + return BuildSource{}, err + } + + rel, err := filepath.Rel(absContext, absSource) + if err != nil || strings.HasPrefix(rel, "..") { + return BuildSource{}, os.ErrPermission + } + + content, err := os.ReadFile(absSource) + if err != nil { + return BuildSource{}, err + } + + return BuildSource{ + Path: rel, + Filename: filepath.Base(absSource), + Content: string(content), + Platform: platformArch(platform), + }, nil +} + +func CollectGitMetadata(contextPath string) (revision, remote string) { + gitDir := filepath.Join(contextPath, ".git") + if _, err := os.Stat(gitDir); err != nil { + return "", "" + } + + if output, err := exec.Command("git", "-C", contextPath, "rev-parse", "--short", "HEAD").Output(); err == nil { + revision = strings.TrimSpace(string(output)) + } + + if output, err := exec.Command("git", "-C", contextPath, "remote", "get-url", "origin").Output(); err == nil { + remote = strings.TrimSpace(string(output)) + } + + return revision, remote +} + +func platformArch(platform string) string { + if platform == "" { + return "" + } + + parts := strings.Split(platform, "/") + if len(parts) == 2 { + return parts[1] + } + + return platform +} + +func formatBytes(size int64) string { + if size <= 0 { + return "0 B" + } + + const unit = 1024 + if size < unit { + return fmt.Sprintf("%d B", size) + } + + div, exp := int64(unit), 0 + for numerator := size / unit; numerator >= unit; numerator /= unit { + div *= unit + exp++ + } + + value := float64(size) / float64(div) + suffix := []string{"KB", "MB", "GB", "TB"}[exp] + return fmt.Sprintf("%.1f %s", value, suffix) +} + +func FormatBytes(size int64) string { + return formatBytes(size) +} + +func bytesTrim(output []byte) []byte { + return []byte(strings.TrimSpace(string(output))) +} + +func NormalizeDockerfilePath(contextPath, dockerfile string) string { + if dockerfile == "" { + dockerfile = "Dockerfile" + } + + if contextPath == "" { + return dockerfile + } + + candidates := []string{ + dockerfile, + filepath.Base(dockerfile), + "Dockerfile", + } + + seen := make(map[string]struct{}, len(candidates)) + for _, candidate := range candidates { + candidate = strings.TrimSpace(candidate) + if candidate == "" { + continue + } + if _, ok := seen[candidate]; ok { + continue + } + seen[candidate] = struct{}{} + + if _, err := os.Stat(filepath.Join(contextPath, candidate)); err == nil { + return candidate + } + } + + return dockerfile +} + +func ParseImageRefFromBuildLog(rawLog string) string { + matches := buildImageNameRe.FindAllStringSubmatch(rawLog, -1) + for index := len(matches) - 1; index >= 0; index-- { + match := matches[index] + if len(match) != 2 { + continue + } + + imageRef := strings.TrimSpace(match[1]) + if imageRef == "" { + continue + } + + return normalizeImageRef(imageRef) + } + + return "" +} + +func normalizeImageRef(imageRef string) string { + imageRef = strings.TrimPrefix(imageRef, "docker.io/library/") + imageRef = strings.TrimPrefix(imageRef, "library/") + return imageRef +} + +func EnrichSyncedBuild(ctx context.Context, socket string, build *Build) { + if build == nil || socket == "" { + return + } + + build.Dockerfile = NormalizeDockerfilePath(build.Context, build.Dockerfile) + + if !IsResolvableBuildContext(build.Context) { + return + } + + imageRef := ParseImageRefFromBuildLog(build.RawLog) + enriched := enrichBuildResult( + ctx, + dockerCLIRunner(socket), + build.Context, + build.Dockerfile, + imageRef, + build.Platform, + BuildResult{}, + ) + + if len(build.Dependencies) == 0 && len(enriched.Dependencies) > 0 { + build.Dependencies = enriched.Dependencies + } + if len(build.Results) == 0 && len(enriched.Results) > 0 { + build.Results = enriched.Results + } + if len(build.Tags) == 0 && len(enriched.Tags) > 0 { + build.Tags = enriched.Tags + } + + if build.Dependencies == nil { + build.Dependencies = []BuildDependency{} + } + if build.Results == nil { + build.Results = []BuildArtifact{} + } + if build.Tags == nil { + build.Tags = []BuildTag{} + } +} + +func dockerCLIRunner(socket string) commandRunner { + return func(ctx context.Context, _ string, args ...string) ([]byte, error) { + command := exec.CommandContext(ctx, "docker", args...) + command.Env = append(os.Environ(), "DOCKER_HOST=unix://"+socket) + output, err := command.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("docker %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(output))) + } + + return output, nil + } +} diff --git a/backend/internal/runtime/build_enrich_test.go b/backend/internal/runtime/build_enrich_test.go new file mode 100644 index 0000000..6556858 --- /dev/null +++ b/backend/internal/runtime/build_enrich_test.go @@ -0,0 +1,30 @@ +package runtime + +import ( + "os" + "path/filepath" + "testing" +) + +func TestNormalizeDockerfilePathUsesExistingRelativePath(t *testing.T) { + dir := t.TempDir() + dockerfile := filepath.Join(dir, "Dockerfile") + if err := os.WriteFile(dockerfile, []byte("FROM alpine"), 0o644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + got := NormalizeDockerfilePath(dir, "examples/hello-world/Dockerfile") + if got != "Dockerfile" { + t.Fatalf("expected Dockerfile, got %q", got) + } +} + +func TestParseImageRefFromBuildLog(t *testing.T) { + rawLog := `#5 naming to docker.io/library/calf-sync-test:latest done +#6 naming to docker.io/library/toth-api:latest 0.0s done` + + got := ParseImageRefFromBuildLog(rawLog) + if got != "toth-api:latest" { + t.Fatalf("expected toth-api:latest, got %q", got) + } +} diff --git a/backend/internal/runtime/build_parser.go b/backend/internal/runtime/build_parser.go new file mode 100644 index 0000000..64b14d0 --- /dev/null +++ b/backend/internal/runtime/build_parser.go @@ -0,0 +1,175 @@ +package runtime + +import ( + "regexp" + "strconv" + "strings" + "time" +) + +var ( + buildStepHeaderRe = regexp.MustCompile(`^#(\d+)\s+(\[[^\]]+\]\s+)?(.+)$`) + buildStepDoneRe = regexp.MustCompile(`^#(\d+)\s+DONE\s+([\d.]+)s`) + buildStepCachedRe = regexp.MustCompile(`^#(\d+)\s+CACHED`) + buildStepIndexRe = regexp.MustCompile(`^#(\d+)\s+\[(\d+)/(\d+)\]`) +) + +func ParseBuildOutput(output string) BuildResult { + result := BuildResult{ + Steps: make([]BuildStep, 0), + } + + lines := strings.Split(output, "\n") + stepsByID := make(map[int]*BuildStep) + stepOrder := make([]int, 0) + var currentStepID int + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + + if matches := buildStepDoneRe.FindStringSubmatch(trimmed); len(matches) == 3 { + stepID, _ := strconv.Atoi(matches[1]) + durationMs := parseDurationMs(matches[2]) + step := ensureStep(stepsByID, &stepOrder, stepID) + step.DurationMs = durationMs + classifyTiming(&result.Timing, step.Name, durationMs) + currentStepID = 0 + continue + } + + if matches := buildStepCachedRe.FindStringSubmatch(trimmed); len(matches) == 2 { + stepID, _ := strconv.Atoi(matches[1]) + step := ensureStep(stepsByID, &stepOrder, stepID) + step.Cached = true + currentStepID = 0 + continue + } + + if matches := buildStepIndexRe.FindStringSubmatch(trimmed); len(matches) == 4 { + stepID, _ := strconv.Atoi(matches[1]) + index, _ := strconv.Atoi(matches[2]) + total, _ := strconv.Atoi(matches[3]) + step := ensureStep(stepsByID, &stepOrder, stepID) + step.Index = index + step.Total = total + rest := strings.TrimSpace(trimmed[len(matches[0]):]) + if rest != "" { + step.Name = rest + } + currentStepID = stepID + continue + } + + if matches := buildStepHeaderRe.FindStringSubmatch(trimmed); len(matches) == 4 { + stepID, _ := strconv.Atoi(matches[1]) + if currentStepID > 0 && stepID == currentStepID { + step := stepsByID[currentStepID] + if step != nil { + if step.Log != "" { + step.Log += "\n" + } + step.Log += trimmed + } + continue + } + + step := ensureStep(stepsByID, &stepOrder, stepID) + name := strings.TrimSpace(matches[3]) + if name != "" { + step.Name = name + } + currentStepID = stepID + continue + } + + if strings.HasPrefix(trimmed, "#") && currentStepID > 0 { + step := stepsByID[currentStepID] + if step != nil { + if step.Log != "" { + step.Log += "\n" + } + step.Log += trimmed + } + } + } + + for _, stepID := range stepOrder { + step := stepsByID[stepID] + if step == nil { + continue + } + result.Steps = append(result.Steps, *step) + if step.Cached { + result.CachedSteps++ + } + } + + result.TotalSteps = len(result.Steps) + result.RawLog = output + return result +} + +func ApplyBuildLogs(build *Build, logs string) { + if build == nil || strings.TrimSpace(logs) == "" { + return + } + + parsed := ParseBuildOutput(logs) + build.Steps = parsed.Steps + build.Timing = parsed.Timing + build.RawLog = parsed.RawLog + if parsed.CachedSteps > 0 { + build.CachedSteps = parsed.CachedSteps + } + if parsed.TotalSteps > 0 { + build.TotalSteps = parsed.TotalSteps + } +} + +func ensureStep(steps map[int]*BuildStep, order *[]int, stepID int) *BuildStep { + if step, ok := steps[stepID]; ok { + return step + } + + step := &BuildStep{} + steps[stepID] = step + *order = append(*order, stepID) + return step +} + +func parseDurationMs(value string) int64 { + value = strings.TrimSuffix(value, "s") + seconds, err := strconv.ParseFloat(value, 64) + if err != nil { + return 0 + } + + return int64(seconds * float64(time.Second/time.Millisecond)) +} + +func classifyTiming(timing *BuildTiming, stepName string, durationMs int64) { + lower := strings.ToLower(stepName) + switch { + case strings.Contains(lower, "load metadata") || strings.Contains(lower, "from "): + timing.ImagePullsMs += durationMs + case strings.Contains(lower, "transferring") || strings.Contains(lower, "load build context") || strings.Contains(lower, "load .dockerignore"): + timing.LocalTransfersMs += durationMs + case strings.Contains(lower, "exporting") || strings.Contains(lower, "export to"): + timing.ResultExportsMs += durationMs + case strings.Contains(lower, "resolve") || strings.Contains(lower, "load build definition"): + timing.FileOperationsMs += durationMs + case strings.Contains(lower, "run ") || strings.Contains(lower, "copy "): + timing.ExecutionsMs += durationMs + default: + if durationMs == 0 { + timing.IdleMs += durationMs + } else if strings.Contains(lower, "internal") { + timing.FileOperationsMs += durationMs + } else { + timing.ExecutionsMs += durationMs + } + } +} diff --git a/backend/internal/runtime/build_parser_test.go b/backend/internal/runtime/build_parser_test.go new file mode 100644 index 0000000..d1aa5d9 --- /dev/null +++ b/backend/internal/runtime/build_parser_test.go @@ -0,0 +1,131 @@ +package runtime + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseBuildOutputSteps(t *testing.T) { + output := `#1 [internal] load build definition from Dockerfile +#1 DONE 0.0s + +#2 [internal] load metadata for docker.io/library/alpine:latest +#2 DONE 0.7s + +#3 [internal] load .dockerignore +#3 transferring context: 1.1kB done +#3 DONE 0.0s + +#4 [1/3] FROM docker.io/library/alpine:latest +#4 CACHED + +#5 [2/3] RUN apk add --no-cache nginx +#5 CACHED + +#6 [3/3] COPY demo-app/ ./ +#6 DONE 0.3s +` + + result := ParseBuildOutput(output) + + if len(result.Steps) < 4 { + t.Fatalf("expected at least 4 steps, got %d", len(result.Steps)) + } + + if result.CachedSteps < 2 { + t.Fatalf("expected at least 2 cached steps, got %d", result.CachedSteps) + } + + foundCopy := false + for _, step := range result.Steps { + if strings.Contains(step.Name, "COPY demo-app") { + foundCopy = true + if step.Cached { + t.Fatalf("expected COPY step to not be cached") + } + if step.DurationMs <= 0 { + t.Fatalf("expected COPY step duration") + } + } + } + + if !foundCopy { + t.Fatalf("expected COPY step in parsed output") + } + + if result.Timing.ImagePullsMs <= 0 { + t.Fatalf("expected image pull timing from metadata step") + } + + foundDockerignore := false + for _, step := range result.Steps { + if strings.Contains(step.Name, "load .dockerignore") { + foundDockerignore = true + if strings.Contains(step.Name, "transferring context") { + t.Fatalf("expected dockerignore step name to stay on header, got %q", step.Name) + } + } + } + if !foundDockerignore { + t.Fatalf("expected dockerignore step in parsed output") + } +} + +func TestApplyBuildLogsPopulatesTiming(t *testing.T) { + build := Build{Steps: []BuildStep{}} + logs := `#1 [internal] load build definition from Dockerfile +#1 DONE 0.0s + +#2 [internal] load metadata for docker.io/library/alpine:latest +#2 DONE 0.7s + +#3 [2/3] RUN apk add --no-cache nginx +#3 DONE 1.2s +` + + ApplyBuildLogs(&build, logs) + + if len(build.Steps) != 3 { + t.Fatalf("expected 3 steps, got %d", len(build.Steps)) + } + + if build.Timing.ImagePullsMs <= 0 { + t.Fatalf("expected image pull timing, got %+v", build.Timing) + } + + if build.Timing.ExecutionsMs <= 0 { + t.Fatalf("expected execution timing, got %+v", build.Timing) + } +} + +func TestParseDockerfileDependencies(t *testing.T) { + dir := t.TempDir() + dockerfile := filepath.Join(dir, "Dockerfile") + content := "FROM php:8.4-fpm-alpine\nFROM node:20 AS assets\nCOPY . .\n" + if err := os.WriteFile(dockerfile, []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + deps := parseDockerfileDependencies(dir, "Dockerfile", "linux/arm64") + if len(deps) != 2 { + t.Fatalf("expected 2 dependencies, got %d", len(deps)) + } + + if deps[0].Source != "php:8.4-fpm-alpine" { + t.Fatalf("unexpected first dependency: %q", deps[0].Source) + } +} + +func TestReadBuildSourceRejectsTraversal(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte("FROM alpine"), 0o644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + _, err := ReadBuildSource(dir, "../Dockerfile", "linux/arm64") + if err == nil { + t.Fatalf("expected path traversal to be rejected") + } +} diff --git a/backend/internal/runtime/lima.go b/backend/internal/runtime/lima.go index 81341db..a933287 100644 --- a/backend/internal/runtime/lima.go +++ b/backend/internal/runtime/lima.go @@ -247,12 +247,12 @@ func (l *Lima) VolumeContainers(ctx context.Context, name string) ([]VolumeConta return volumeContainerUsages(ctx, l.runInVM, name) } -func (l *Lima) RunBuild(ctx context.Context, contextPath, tag, dockerfile string) error { +func (l *Lima) RunBuild(ctx context.Context, contextPath, tag, dockerfile, platform string) (BuildResult, error) { if err := requireRunning(ctx, l.Status); err != nil { - return err + return BuildResult{}, err } - return runBuild(ctx, l.runInVM, contextPath, tag, dockerfile) + return runBuild(ctx, l.runInVM, contextPath, tag, dockerfile, platform) } func (l *Lima) StartContainer(ctx context.Context, id string) error { diff --git a/backend/internal/runtime/mock.go b/backend/internal/runtime/mock.go index bf3b296..d2eaa1c 100644 --- a/backend/internal/runtime/mock.go +++ b/backend/internal/runtime/mock.go @@ -317,8 +317,24 @@ func (m *Mock) VolumeContainers(_ context.Context, name string) ([]VolumeContain }, nil } -func (m *Mock) RunBuild(_ context.Context, _, _, _ string) error { - return m.ImageErr +func (m *Mock) RunBuild(_ context.Context, _, tag, dockerfile, platform string) (BuildResult, error) { + if m.ImageErr != nil { + return BuildResult{}, m.ImageErr + } + + output := `#1 [internal] load build definition from Dockerfile +#1 DONE 0.0s +#2 [1/2] FROM alpine:latest +#2 CACHED +#3 [2/2] RUN echo hello +#3 DONE 0.1s +` + result := ParseBuildOutput(output) + result = enrichBuildResult(context.Background(), func(ctx context.Context, command string, args ...string) ([]byte, error) { + return []byte("{}"), nil + }, ".", dockerfile, tag, platform, result) + + return result, nil } func (m *Mock) StartContainer(_ context.Context, id string) error { diff --git a/backend/internal/runtime/native.go b/backend/internal/runtime/native.go index 624021b..c3a6708 100644 --- a/backend/internal/runtime/native.go +++ b/backend/internal/runtime/native.go @@ -157,12 +157,12 @@ func (n *Native) VolumeContainers(ctx context.Context, name string) ([]VolumeCon return volumeContainerUsages(ctx, n.runLocal, name) } -func (n *Native) RunBuild(ctx context.Context, contextPath, tag, dockerfile string) error { +func (n *Native) RunBuild(ctx context.Context, contextPath, tag, dockerfile, platform string) (BuildResult, error) { if err := requireRunning(ctx, n.Status); err != nil { - return err + return BuildResult{}, err } - return runBuild(ctx, n.runLocal, contextPath, tag, dockerfile) + return runBuild(ctx, n.runLocal, contextPath, tag, dockerfile, platform) } func (n *Native) StartContainer(ctx context.Context, id string) error { diff --git a/backend/internal/runtime/nerdctl.go b/backend/internal/runtime/nerdctl.go index 9ad0bde..8249acc 100644 --- a/backend/internal/runtime/nerdctl.go +++ b/backend/internal/runtime/nerdctl.go @@ -81,15 +81,23 @@ func listVolumes(ctx context.Context, run commandRunner) ([]Volume, error) { return ParseVolumeLines(output) } -func runBuild(ctx context.Context, run commandRunner, contextPath, tag, dockerfile string) error { - args := []string{"build", "-t", tag} +func runBuild(ctx context.Context, run commandRunner, contextPath, tag, dockerfile, platform string) (BuildResult, error) { + args := []string{"build", "--progress=plain", "-t", tag} if dockerfile != "" { args = append(args, "-f", dockerfile) } + if platform != "" { + args = append(args, "--platform", platform) + } args = append(args, contextPath) - _, err := run(ctx, "nerdctl", args...) - return err + output, err := run(ctx, "nerdctl", args...) + result := ParseBuildOutput(string(output)) + if err != nil { + return result, err + } + + return enrichBuildResult(ctx, run, contextPath, dockerfile, tag, platform, result), nil } func runImage(ctx context.Context, run commandRunner, ref string) (string, error) { diff --git a/backend/internal/runtime/runtime.go b/backend/internal/runtime/runtime.go index a43e72b..ebda188 100644 --- a/backend/internal/runtime/runtime.go +++ b/backend/internal/runtime/runtime.go @@ -87,14 +87,6 @@ type VolumeContainerUsage struct { Target string `json:"target"` } -type Build struct { - ID string `json:"id"` - Tag string `json:"tag"` - Context string `json:"context"` - Status string `json:"status"` - CreatedAt string `json:"created_at"` -} - type Runtime interface { Start(ctx context.Context) error Stop(ctx context.Context) error @@ -117,7 +109,7 @@ type Runtime interface { CreateVolume(ctx context.Context, name string) error CloneVolume(ctx context.Context, source, dest string) error RemoveVolume(ctx context.Context, name string) error - RunBuild(ctx context.Context, contextPath, tag, dockerfile string) error + RunBuild(ctx context.Context, contextPath, tag, dockerfile, platform string) (BuildResult, error) StreamLogs(ctx context.Context, id string, output func(string)) error StreamLogsFollow(ctx context.Context, id string, output func(string)) error InspectContainer(ctx context.Context, id string) (json.RawMessage, error) diff --git a/backend/runtime.test b/backend/runtime.test new file mode 100755 index 0000000..2c73d44 Binary files /dev/null and b/backend/runtime.test differ diff --git a/backend/test/api/api_test.go b/backend/test/api/api_test.go index c47603c..7f77979 100644 --- a/backend/test/api/api_test.go +++ b/backend/test/api/api_test.go @@ -19,6 +19,9 @@ import ( func newTestServer(t *testing.T) *httptest.Server { t.Helper() + dir := t.TempDir() + t.Setenv("HOME", dir) + cfg := config.Config{ ListenAddr: ":8765", LogLevel: "info", diff --git a/backend/test/api/builds_test.go b/backend/test/api/builds_test.go new file mode 100644 index 0000000..ab76e97 --- /dev/null +++ b/backend/test/api/builds_test.go @@ -0,0 +1,201 @@ +package api_test + +import ( + "bytes" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/enegalan/calf/backend/internal/api" + "github.com/enegalan/calf/backend/internal/buildstore" + "github.com/enegalan/calf/backend/internal/config" + "github.com/enegalan/calf/backend/internal/runtime" +) + +func TestBuildsPersistAcrossServerReload(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + + cfg := config.Config{ListenAddr: ":8765", LogLevel: "info"} + mock := runtime.NewMock() + + server := httptest.NewServer(api.New(cfg, slog.Default(), mock).Handler()) + response, err := http.Post(server.URL+"/v1/builds", "application/json", bytes.NewBufferString(`{ + "context": ".", + "tag": "demo:test" + }`)) + if err != nil { + t.Fatalf("POST /v1/builds error: %v", err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusAccepted { + body, _ := io.ReadAll(response.Body) + t.Fatalf("expected status 202, got %d: %s", response.StatusCode, body) + } + + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + listResponse, err := http.Get(server.URL + "/v1/builds") + if err != nil { + t.Fatalf("GET /v1/builds error: %v", err) + } + + var builds []map[string]any + _ = json.NewDecoder(listResponse.Body).Decode(&builds) + listResponse.Body.Close() + + if len(builds) == 1 && builds[0]["status"] == "success" { + break + } + + time.Sleep(100 * time.Millisecond) + } + + server.Close() + + file, err := buildstore.Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if len(file.Builds) != 1 { + t.Fatalf("expected 1 persisted build, got %d", len(file.Builds)) + } + + reloaded := httptest.NewServer(api.New(cfg, slog.Default(), mock).Handler()) + defer reloaded.Close() + + listResponse, err := http.Get(reloaded.URL + "/v1/builds") + if err != nil { + t.Fatalf("GET /v1/builds after reload error: %v", err) + } + defer listResponse.Body.Close() + + var builds []map[string]any + if err := json.NewDecoder(listResponse.Body).Decode(&builds); err != nil { + t.Fatalf("Decode() error: %v", err) + } + + if len(builds) != 1 { + t.Fatalf("expected 1 build after reload, got %d", len(builds)) + } +} + +func TestBuildDetailAndSourceEndpoints(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + + contextDir := filepath.Join(dir, "context") + if err := os.MkdirAll(contextDir, 0o755); err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + + dockerfile := "FROM alpine:latest\nRUN echo hello\n" + if err := os.WriteFile(filepath.Join(contextDir, "Dockerfile"), []byte(dockerfile), 0o644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cfg := config.Config{ListenAddr: ":8765", LogLevel: "info"} + mock := runtime.NewMock() + server := httptest.NewServer(api.New(cfg, slog.Default(), mock).Handler()) + defer server.Close() + + response, err := http.Post(server.URL+"/v1/builds", "application/json", bytes.NewBufferString(`{ + "context": "`+contextDir+`", + "tag": "demo:test" + }`)) + if err != nil { + t.Fatalf("POST /v1/builds error: %v", err) + } + response.Body.Close() + + var buildID string + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + listResponse, err := http.Get(server.URL + "/v1/builds") + if err != nil { + t.Fatalf("GET /v1/builds error: %v", err) + } + + var builds []map[string]any + _ = json.NewDecoder(listResponse.Body).Decode(&builds) + listResponse.Body.Close() + + if len(builds) == 1 && builds[0]["status"] == "success" { + buildID, _ = builds[0]["id"].(string) + break + } + + time.Sleep(100 * time.Millisecond) + } + + if buildID == "" { + t.Fatalf("expected completed build id") + } + + detailResponse, err := http.Get(server.URL + "/v1/builds/" + buildID) + if err != nil { + t.Fatalf("GET /v1/builds/{id} error: %v", err) + } + defer detailResponse.Body.Close() + + if detailResponse.StatusCode != http.StatusOK { + body, _ := io.ReadAll(detailResponse.Body) + t.Fatalf("expected status 200, got %d: %s", detailResponse.StatusCode, body) + } + + var detail map[string]any + if err := json.NewDecoder(detailResponse.Body).Decode(&detail); err != nil { + t.Fatalf("Decode() error: %v", err) + } + + if detail["id"] != buildID { + t.Fatalf("expected build id %q, got %v", buildID, detail["id"]) + } + + sourceResponse, err := http.Get(server.URL + "/v1/builds/" + buildID + "/source") + if err != nil { + t.Fatalf("GET /v1/builds/{id}/source error: %v", err) + } + defer sourceResponse.Body.Close() + + if sourceResponse.StatusCode != http.StatusOK { + body, _ := io.ReadAll(sourceResponse.Body) + t.Fatalf("expected status 200, got %d: %s", sourceResponse.StatusCode, body) + } + + var source map[string]any + if err := json.NewDecoder(sourceResponse.Body).Decode(&source); err != nil { + t.Fatalf("Decode() error: %v", err) + } + + if source["content"] != dockerfile { + t.Fatalf("unexpected dockerfile content: %v", source["content"]) + } + + logsResponse, err := http.Get(server.URL + "/v1/builds/" + buildID + "/logs") + if err != nil { + t.Fatalf("GET /v1/builds/{id}/logs error: %v", err) + } + defer logsResponse.Body.Close() + + if logsResponse.StatusCode != http.StatusOK { + body, _ := io.ReadAll(logsResponse.Body) + t.Fatalf("expected logs status 200, got %d: %s", logsResponse.StatusCode, body) + } + + var logs map[string]any + if err := json.NewDecoder(logsResponse.Body).Decode(&logs); err != nil { + t.Fatalf("Decode() error: %v", err) + } + + if logs["raw_log"] == "" { + t.Fatalf("expected build logs content") + } +} diff --git a/backend/test/buildstore/buildstore_test.go b/backend/test/buildstore/buildstore_test.go new file mode 100644 index 0000000..ee9d4c5 --- /dev/null +++ b/backend/test/buildstore/buildstore_test.go @@ -0,0 +1,89 @@ +package buildstore_test + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/enegalan/calf/backend/internal/buildstore" + "github.com/enegalan/calf/backend/internal/runtime" +) + +func TestSaveAndLoadBuilds(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + + builds := []runtime.Build{ + { + ID: "build-1", + Tag: "demo:latest", + Context: "/tmp/demo", + Status: "success", + CreatedAt: "2026-01-01T00:00:00Z", + Builder: "default", + Steps: []runtime.BuildStep{}, + }, + } + + if err := buildstore.Save(builds, 1); err != nil { + t.Fatalf("Save() error: %v", err) + } + + path := filepath.Join(dir, ".config", "calf", "builds.json") + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected builds file at %s: %v", path, err) + } + + file, err := buildstore.Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + if file.Seq != 1 { + t.Fatalf("expected seq 1, got %d", file.Seq) + } + + if len(file.Builds) != 1 || file.Builds[0].ID != "build-1" { + t.Fatalf("unexpected builds: %+v", file.Builds) + } +} + +func TestSaveTrimBuildsRetainsNewest(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + + builds := make([]runtime.Build, 0, 210) + for index := 1; index <= 210; index++ { + builds = append([]runtime.Build{{ + ID: fmt.Sprintf("build-%d", index), + Tag: fmt.Sprintf("demo:%d", index), + Context: "/tmp/demo", + Status: "success", + CreatedAt: fmt.Sprintf("2026-01-01T00:00:%02dZ", index%60), + Builder: "default", + Steps: []runtime.BuildStep{}, + }}, builds...) + } + + if err := buildstore.Save(builds, 210); err != nil { + t.Fatalf("Save() error: %v", err) + } + + file, err := buildstore.Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + if len(file.Builds) != 200 { + t.Fatalf("expected 200 builds, got %d", len(file.Builds)) + } + + if file.Builds[0].ID != "build-210" { + t.Fatalf("expected newest build first, got %q", file.Builds[0].ID) + } + + if file.Builds[len(file.Builds)-1].ID != "build-11" { + t.Fatalf("expected oldest retained build-11, got %q", file.Builds[len(file.Builds)-1].ID) + } +} diff --git a/ui/lib/api/client.dart b/ui/lib/api/client.dart index 025a05c..033d59b 100644 --- a/ui/lib/api/client.dart +++ b/ui/lib/api/client.dart @@ -449,6 +449,12 @@ class BuildItem { required this.context, required this.status, required this.createdAt, + this.dockerfile = 'Dockerfile', + this.platform = '', + this.durationMs = 0, + this.builder = 'default', + this.cachedSteps = 0, + this.totalSteps = 0, }); final String id; @@ -456,6 +462,12 @@ class BuildItem { final String context; final String status; final String createdAt; + final String dockerfile; + final String platform; + final int durationMs; + final String builder; + final int cachedSteps; + final int totalSteps; factory BuildItem.fromJson(Map json) { return BuildItem( @@ -464,10 +476,248 @@ class BuildItem { context: json['context'] as String? ?? '', status: json['status'] as String? ?? '', createdAt: json['created_at'] as String? ?? '', + dockerfile: json['dockerfile'] as String? ?? 'Dockerfile', + platform: json['platform'] as String? ?? '', + durationMs: (json['duration_ms'] as num?)?.toInt() ?? 0, + builder: json['builder'] as String? ?? 'default', + cachedSteps: (json['cached_steps'] as num?)?.toInt() ?? 0, + totalSteps: (json['total_steps'] as num?)?.toInt() ?? 0, ); } } +class BuildStep { + const BuildStep({ + required this.index, + required this.total, + required this.name, + required this.cached, + required this.durationMs, + this.log = '', + }); + + final int index; + final int total; + final String name; + final bool cached; + final int durationMs; + final String log; + + factory BuildStep.fromJson(Map json) { + return BuildStep( + index: (json['index'] as num?)?.toInt() ?? 0, + total: (json['total'] as num?)?.toInt() ?? 0, + name: json['name'] as String? ?? '', + cached: json['cached'] as bool? ?? false, + durationMs: (json['duration_ms'] as num?)?.toInt() ?? 0, + log: json['log'] as String? ?? '', + ); + } +} + +class BuildDependency { + const BuildDependency({ + required this.source, + required this.platform, + required this.digest, + }); + + final String source; + final String platform; + final String digest; + + factory BuildDependency.fromJson(Map json) { + return BuildDependency( + source: json['source'] as String? ?? '', + platform: json['platform'] as String? ?? '', + digest: json['digest'] as String? ?? '', + ); + } +} + +class BuildArtifact { + const BuildArtifact({ + required this.name, + required this.platform, + required this.digest, + required this.size, + }); + + final String name; + final String platform; + final String digest; + final String size; + + factory BuildArtifact.fromJson(Map json) { + return BuildArtifact( + name: json['name'] as String? ?? '', + platform: json['platform'] as String? ?? '', + digest: json['digest'] as String? ?? '', + size: json['size'] as String? ?? '', + ); + } +} + +class BuildTag { + const BuildTag({ + required this.tag, + required this.digest, + }); + + final String tag; + final String digest; + + factory BuildTag.fromJson(Map json) { + return BuildTag( + tag: json['tag'] as String? ?? '', + digest: json['digest'] as String? ?? '', + ); + } +} + +class BuildTiming { + const BuildTiming({ + this.imagePullsMs = 0, + this.localTransfersMs = 0, + this.executionsMs = 0, + this.fileOperationsMs = 0, + this.resultExportsMs = 0, + this.idleMs = 0, + }); + + final int imagePullsMs; + final int localTransfersMs; + final int executionsMs; + final int fileOperationsMs; + final int resultExportsMs; + final int idleMs; + + factory BuildTiming.fromJson(Map json) { + return BuildTiming( + imagePullsMs: (json['image_pulls_ms'] as num?)?.toInt() ?? 0, + localTransfersMs: (json['local_transfers_ms'] as num?)?.toInt() ?? 0, + executionsMs: (json['executions_ms'] as num?)?.toInt() ?? 0, + fileOperationsMs: (json['file_operations_ms'] as num?)?.toInt() ?? 0, + resultExportsMs: (json['result_exports_ms'] as num?)?.toInt() ?? 0, + idleMs: (json['idle_ms'] as num?)?.toInt() ?? 0, + ); + } +} + +class BuildDetail extends BuildItem { + const BuildDetail({ + required super.id, + required super.tag, + required super.context, + required super.status, + required super.createdAt, + super.dockerfile, + super.platform, + super.durationMs, + super.builder, + super.cachedSteps, + super.totalSteps, + this.finishedAt = '', + this.error = '', + this.steps = const [], + this.dependencies = const [], + this.results = const [], + this.tags = const [], + this.timing = const BuildTiming(), + this.sourceRevision = '', + this.remoteSource = '', + this.rawLog = '', + }); + + final String finishedAt; + final String error; + final List steps; + final List dependencies; + final List results; + final List tags; + final BuildTiming timing; + final String sourceRevision; + final String remoteSource; + final String rawLog; + + factory BuildDetail.fromJson(Map json) { + return BuildDetail( + id: json['id'] as String? ?? '', + tag: json['tag'] as String? ?? '', + context: json['context'] as String? ?? '', + status: json['status'] as String? ?? '', + createdAt: json['created_at'] as String? ?? '', + dockerfile: json['dockerfile'] as String? ?? 'Dockerfile', + platform: json['platform'] as String? ?? '', + durationMs: (json['duration_ms'] as num?)?.toInt() ?? 0, + builder: json['builder'] as String? ?? 'default', + cachedSteps: (json['cached_steps'] as num?)?.toInt() ?? 0, + totalSteps: (json['total_steps'] as num?)?.toInt() ?? 0, + finishedAt: json['finished_at'] as String? ?? '', + error: json['error'] as String? ?? '', + steps: _decodeObjectList(json['steps'], BuildStep.fromJson), + dependencies: _decodeObjectList(json['dependencies'], BuildDependency.fromJson), + results: _decodeObjectList(json['results'], BuildArtifact.fromJson), + tags: _decodeObjectList(json['tags'], BuildTag.fromJson), + timing: BuildTiming.fromJson(json['timing'] as Map? ?? const {}), + sourceRevision: json['source_revision'] as String? ?? '', + remoteSource: json['remote_source'] as String? ?? '', + rawLog: json['raw_log'] as String? ?? '', + ); + } +} + +class BuildLogs { + const BuildLogs({ + this.rawLog = '', + this.steps = const [], + }); + + final String rawLog; + final List steps; + + factory BuildLogs.fromJson(Map json) { + return BuildLogs( + rawLog: json['raw_log'] as String? ?? '', + steps: _decodeObjectList(json['steps'], BuildStep.fromJson), + ); + } +} + +class BuildSource { + const BuildSource({ + required this.path, + required this.filename, + required this.content, + required this.platform, + }); + + final String path; + final String filename; + final String content; + final String platform; + + factory BuildSource.fromJson(Map json) { + return BuildSource( + path: json['path'] as String? ?? '', + filename: json['filename'] as String? ?? '', + content: json['content'] as String? ?? '', + platform: json['platform'] as String? ?? '', + ); + } +} + +List _decodeObjectList(Object? value, T Function(Map json) mapper) { + if (value is! List) { + return const []; + } + + return value + .whereType() + .map((item) => mapper(Map.from(item))) + .toList(); +} + class RegistryLoginStatus { const RegistryLoginStatus({ required this.loggedIn, @@ -547,7 +797,10 @@ abstract class CalfClient implements StatusClient { Future fetchVolumeDetail(String name); Future> fetchVolumeFiles(String name, {String path = '/'}); Future> fetchVolumeContainers(String name); - Future> fetchBuilds(); + Future> fetchBuilds({String? tag}); + Future fetchBuildDetail(String id); + Future fetchBuildSource(String id); + Future fetchBuildLogs(String id); Future startContainer(String id); Future stopContainer(String id); Future removeContainer(String id); @@ -654,11 +907,32 @@ class ApiClient implements CalfClient { } @override - Future> fetchBuilds() async { - final response = await httpClient.get(Uri.parse('$baseUrl/v1/builds')).timeout(timeout); + Future> fetchBuilds({String? tag}) async { + final uri = Uri.parse('$baseUrl/v1/builds').replace( + queryParameters: tag == null || tag.isEmpty ? null : {'tag': tag}, + ); + final response = await httpClient.get(uri).timeout(timeout); return _decodeList(response, BuildItem.fromJson); } + @override + Future fetchBuildDetail(String id) async { + final json = await _getJson('/v1/builds/${Uri.encodeComponent(id)}'); + return BuildDetail.fromJson(json); + } + + @override + Future fetchBuildSource(String id) async { + final json = await _getJson('/v1/builds/${Uri.encodeComponent(id)}/source'); + return BuildSource.fromJson(json); + } + + @override + Future fetchBuildLogs(String id) async { + final json = await _getJson('/v1/builds/${Uri.encodeComponent(id)}/logs'); + return BuildLogs.fromJson(json); + } + @override Future fetchConfig() async { final json = await _getJson('/v1/config'); @@ -956,7 +1230,7 @@ class ApiClient implements CalfClient { ) .timeout(timeout); - if (response.statusCode != 200) { + if (response.statusCode != 200 && response.statusCode != 202) { throw ApiException(_errorMessage(response), statusCode: response.statusCode); } @@ -1173,6 +1447,10 @@ class Config { this.memorySwapGB = 1, this.hostCPUs = 4, this.hostMemoryGB = 8, + this.dockerContextManaged = true, + this.dockerContextActive = false, + this.dockerContextName = '', + this.dockerCliAvailable = false, }); final int pollIntervalMs; @@ -1181,11 +1459,16 @@ class Config { final int memorySwapGB; final int hostCPUs; final int hostMemoryGB; + final bool dockerContextManaged; + final bool dockerContextActive; + final String dockerContextName; + final bool dockerCliAvailable; Map toJson() => { 'cpus': cpus, 'memory_gb': memoryGB, 'memory_swap_gb': memorySwapGB, + 'docker_context_managed': dockerContextManaged, }; factory Config.fromJson(Map json) { @@ -1196,6 +1479,36 @@ class Config { memorySwapGB: (json['memory_swap_gb'] as num?)?.toInt() ?? 1, hostCPUs: (json['host_cpus'] as num?)?.toInt() ?? 4, hostMemoryGB: (json['host_memory_gb'] as num?)?.toInt() ?? 8, + dockerContextManaged: json['docker_context_managed'] as bool? ?? true, + dockerContextActive: json['docker_context_active'] as bool? ?? false, + dockerContextName: json['docker_context_name'] as String? ?? '', + dockerCliAvailable: json['docker_cli_available'] as bool? ?? false, + ); + } + + Config copyWith({ + int? pollIntervalMs, + int? cpus, + int? memoryGB, + int? memorySwapGB, + int? hostCPUs, + int? hostMemoryGB, + bool? dockerContextManaged, + bool? dockerContextActive, + String? dockerContextName, + bool? dockerCliAvailable, + }) { + return Config( + pollIntervalMs: pollIntervalMs ?? this.pollIntervalMs, + cpus: cpus ?? this.cpus, + memoryGB: memoryGB ?? this.memoryGB, + memorySwapGB: memorySwapGB ?? this.memorySwapGB, + hostCPUs: hostCPUs ?? this.hostCPUs, + hostMemoryGB: hostMemoryGB ?? this.hostMemoryGB, + dockerContextManaged: dockerContextManaged ?? this.dockerContextManaged, + dockerContextActive: dockerContextActive ?? this.dockerContextActive, + dockerContextName: dockerContextName ?? this.dockerContextName, + dockerCliAvailable: dockerCliAvailable ?? this.dockerCliAvailable, ); } } diff --git a/ui/lib/app_shell.dart b/ui/lib/app_shell.dart index 0bb4bb7..26480c0 100644 --- a/ui/lib/app_shell.dart +++ b/ui/lib/app_shell.dart @@ -254,6 +254,8 @@ class _SettingsScreenState extends State { double _draftSwap = 1; bool _migrating = false; MigrationStatus? _migrationStatus; + bool _dockerContextManaged = true; + bool _dockerContextSaving = false; bool get _dirty => _config != null && (_draftCpus.toInt() != _config!.cpus || @@ -280,6 +282,7 @@ class _SettingsScreenState extends State { _draftCpus = config.cpus.toDouble(); _draftMemory = config.memoryGB.toDouble(); _draftSwap = config.memorySwapGB.toDouble(); + _dockerContextManaged = config.dockerContextManaged; _configLoading = false; }); } catch (error) { @@ -299,13 +302,10 @@ class _SettingsScreenState extends State { try { final updated = await widget.apiClient.updateConfig( - Config( - pollIntervalMs: current.pollIntervalMs, + current.copyWith( cpus: _draftCpus.toInt(), memoryGB: _draftMemory.toInt(), memorySwapGB: _draftSwap.toInt(), - hostCPUs: current.hostCPUs, - hostMemoryGB: current.hostMemoryGB, ), ); if (!mounted) return; @@ -385,6 +385,35 @@ class _SettingsScreenState extends State { } } + Future setDockerContextManaged(bool value) async { + final current = _config; + if (current == null) return; + + setState(() { + _dockerContextManaged = value; + _dockerContextSaving = true; + }); + + try { + final updated = await widget.apiClient.updateConfig( + current.copyWith(dockerContextManaged: value), + ); + if (!mounted) return; + setState(() { + _config = updated; + _dockerContextManaged = updated.dockerContextManaged; + _dockerContextSaving = false; + }); + } catch (error) { + if (!mounted) return; + setState(() { + _dockerContextManaged = current.dockerContextManaged; + _dockerContextSaving = false; + _configError = error.toString(); + }); + } + } + @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); @@ -404,6 +433,25 @@ class _SettingsScreenState extends State { onChanged: (value) => setState(() => _startAtLogin = value), ), ), + const SizedBox(height: 12), + _settingRow( + 'Use Calf for Docker CLI', + ShadSwitch( + value: _dockerContextManaged, + onChanged: _dockerContextSaving ? null : setDockerContextManaged, + ), + ), + if (_config != null) ...[ + const SizedBox(height: 8), + Text( + _config!.dockerContextActive + ? 'Active context: calf' + : _config!.dockerContextName.isEmpty + ? 'Docker CLI context not set to calf' + : 'Active context: ${_config!.dockerContextName}', + style: theme.textTheme.muted, + ), + ], const SizedBox(height: 16), Text('Theme', style: theme.textTheme.large), const SizedBox(height: 8), diff --git a/ui/lib/screens/build_detail_screen.dart b/ui/lib/screens/build_detail_screen.dart new file mode 100644 index 0000000..965931e --- /dev/null +++ b/ui/lib/screens/build_detail_screen.dart @@ -0,0 +1,1408 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart' show BoxDecoration, BoxShadow, PopupMenuItem, RelativeRect, SelectableText, showMenu; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'package:ui/api/client.dart'; +import 'package:ui/widgets/calf_button.dart'; +import 'package:ui/widgets/hover_list_row.dart'; + +enum _BuildDetailTab { info, source, logs, history } + +class BuildDetailView extends StatefulWidget { + const BuildDetailView({ + super.key, + required this.buildId, + required this.apiClient, + required this.onBack, + this.onOpenBuild, + }); + + final String buildId; + final CalfClient apiClient; + final VoidCallback onBack; + final ValueChanged? onOpenBuild; + + @override + State createState() => _BuildDetailViewState(); +} + +class _BuildDetailViewState extends State { + _BuildDetailTab _tab = _BuildDetailTab.info; + BuildDetail? _detail; + BuildSource? _source; + List _history = []; + bool _detailLoading = true; + bool _sourceLoading = false; + bool _historyLoading = false; + String? _detailError; + String? _sourceError; + String? _historyError; + bool _logsLoading = false; + String? _logsError; + BuildLogs? _logs; + String _platformFilter = ''; + final Set _expandedSteps = {}; + bool _plainLogs = false; + + @override + void initState() { + super.initState(); + _loadDetail(); + } + + Future _loadDetail() async { + setState(() { + _detailLoading = true; + _detailError = null; + }); + + try { + final detail = await widget.apiClient.fetchBuildDetail(widget.buildId); + if (!mounted) { + return; + } + setState(() { + _detail = detail; + _detailLoading = false; + if (_platformFilter.isEmpty && detail.platform.isNotEmpty) { + _platformFilter = detail.platform; + } + }); + if (_tab == _BuildDetailTab.history) { + await _loadHistory(tagOverride: detail.tag); + } + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _detailError = error.toString(); + _detailLoading = false; + }); + } + } + + Future _loadSource() async { + setState(() { + _sourceLoading = true; + _sourceError = null; + }); + + try { + final source = await widget.apiClient.fetchBuildSource(widget.buildId); + if (!mounted) { + return; + } + setState(() { + _source = source; + _sourceLoading = false; + }); + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _sourceError = error.toString(); + _sourceLoading = false; + }); + } + } + + Future _loadHistory({String? tagOverride}) async { + final tag = tagOverride ?? _detail?.tag; + if (tag == null || tag.isEmpty) { + return; + } + + setState(() { + _historyLoading = true; + _historyError = null; + }); + + try { + final history = await widget.apiClient.fetchBuilds(tag: tag); + if (!mounted) { + return; + } + final detail = _detail; + setState(() { + _history = history.isNotEmpty + ? history + : detail != null + ? [detail] + : const []; + _historyLoading = false; + }); + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _historyError = error.toString(); + _historyLoading = false; + }); + } + } + + Future _loadLogs() async { + final detail = _detail; + if (detail != null && (detail.rawLog.isNotEmpty || detail.steps.isNotEmpty)) { + return; + } + if (_logs != null || _logsLoading) { + return; + } + + setState(() { + _logsLoading = true; + _logsError = null; + }); + + try { + final logs = await widget.apiClient.fetchBuildLogs(widget.buildId); + if (!mounted) { + return; + } + setState(() { + _logs = logs; + _logsLoading = false; + if (logs.steps.isEmpty && logs.rawLog.isNotEmpty) { + _plainLogs = true; + } + }); + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _logsError = error.toString(); + _logsLoading = false; + }); + } + } + + void _selectTab(_BuildDetailTab tab) { + if (_tab == tab) { + return; + } + + setState(() => _tab = tab); + if (tab == _BuildDetailTab.source && _source == null) { + _loadSource(); + } + if (tab == _BuildDetailTab.history) { + _loadHistory(); + } + if (tab == _BuildDetailTab.logs) { + _loadLogs(); + } + } + + Future _copyText(String value) async { + await Clipboard.setData(ClipboardData(text: value)); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final detail = _detail; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + CalfButton.ghost( + onPressed: widget.onBack, + child: Icon(LucideIcons.chevronLeft, size: 18, color: theme.colorScheme.foreground), + ), + const SizedBox(width: 4), + Text('Builds', style: theme.textTheme.muted), + Text(' / ', style: theme.textTheme.muted), + Expanded( + child: Text( + detail?.tag ?? widget.buildId, + style: theme.textTheme.muted, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 12), + if (_detailLoading) + Text('Loading...', style: theme.textTheme.muted) + else if (_detailError != null) + Text(_detailError!, style: theme.textTheme.small.copyWith(color: theme.colorScheme.destructive)) + else if (detail != null) ...[ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(detail.tag, style: theme.textTheme.h3), + const SizedBox(height: 8), + Row( + children: [ + Text(detail.id, style: theme.textTheme.muted), + const SizedBox(width: 8), + CalfButton.ghost( + padding: const EdgeInsets.all(4), + onPressed: () => _copyText(detail.id), + child: Icon(LucideIcons.copy, size: 14, color: theme.colorScheme.mutedForeground), + ), + ], + ), + ], + ), + ), + _SummaryColumn(theme: theme, label: 'Status', value: _statusLabel(detail.status), color: _statusColor(detail.status, theme)), + const SizedBox(width: 24), + _SummaryColumn(theme: theme, label: 'Duration', value: _formatDuration(detail.durationMs)), + const SizedBox(width: 24), + _SummaryColumn(theme: theme, label: 'Builder', value: detail.builder, link: true), + ], + ), + const SizedBox(height: 16), + _BuildTabBar(theme: theme, selected: _tab, onSelected: _selectTab), + const SizedBox(height: 16), + Expanded(child: _buildTabContent(theme, detail)), + ], + ], + ); + } + + Widget _buildTabContent(ShadThemeData theme, BuildDetail detail) { + switch (_tab) { + case _BuildDetailTab.info: + return _InfoTab( + theme: theme, + detail: detail, + platformFilter: _platformFilter, + onPlatformChanged: (value) => setState(() => _platformFilter = value), + onCopy: _copyText, + ); + case _BuildDetailTab.source: + return _SourceTab( + theme: theme, + loading: _sourceLoading, + error: _sourceError, + source: _source, + detail: detail, + ); + case _BuildDetailTab.logs: + return _LogsTab( + theme: theme, + detail: detail, + rawLog: _logs?.rawLog ?? detail.rawLog, + steps: _logs?.steps ?? detail.steps, + loading: _logsLoading, + error: _logsError, + plainLogs: _plainLogs, + expandedSteps: _expandedSteps, + onTogglePlain: (value) => setState(() => _plainLogs = value), + onToggleStep: (index) { + setState(() { + if (_expandedSteps.contains(index)) { + _expandedSteps.remove(index); + } else { + _expandedSteps.add(index); + } + }); + }, + ); + case _BuildDetailTab.history: + return _HistoryTab( + theme: theme, + loading: _historyLoading, + error: _historyError, + history: _history, + currentId: detail.id, + onOpenBuild: widget.onOpenBuild, + ); + } + } +} + +class _SummaryColumn extends StatelessWidget { + const _SummaryColumn({ + required this.theme, + required this.label, + required this.value, + this.color, + this.link = false, + }); + + final ShadThemeData theme; + final String label; + final String value; + final Color? color; + final bool link; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text(label, style: theme.textTheme.small.copyWith(color: theme.colorScheme.mutedForeground)), + const SizedBox(height: 4), + Text( + value, + style: theme.textTheme.large.copyWith( + color: color ?? (link ? theme.colorScheme.primary : theme.colorScheme.foreground), + ), + ), + ], + ); + } +} + +class _BuildTabBar extends StatelessWidget { + const _BuildTabBar({ + required this.theme, + required this.selected, + required this.onSelected, + }); + + final ShadThemeData theme; + final _BuildDetailTab selected; + final ValueChanged<_BuildDetailTab> onSelected; + + @override + Widget build(BuildContext context) { + const tabs = _BuildDetailTab.values; + const labels = ['Info', 'Source', 'Logs', 'History']; + + return Container( + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: theme.colorScheme.border)), + ), + child: Row( + children: [ + for (var index = 0; index < tabs.length; index++) ...[ + if (index > 0) const SizedBox(width: 20), + _BuildTabButton( + theme: theme, + label: labels[index], + selected: selected == tabs[index], + onTap: () => onSelected(tabs[index]), + ), + ], + ], + ), + ); + } +} + +class _BuildTabButton extends StatelessWidget { + const _BuildTabButton({ + required this.theme, + required this.label, + required this.selected, + required this.onTap, + }); + + final ShadThemeData theme; + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: selected ? theme.colorScheme.primary : const Color(0x00000000), + width: 2, + ), + ), + ), + child: Text( + label, + style: theme.textTheme.large.copyWith( + color: selected ? theme.colorScheme.foreground : theme.colorScheme.mutedForeground, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + ), + ), + ), + ); + } +} + +class _InfoTab extends StatelessWidget { + const _InfoTab({ + required this.theme, + required this.detail, + required this.platformFilter, + required this.onPlatformChanged, + required this.onCopy, + }); + + final ShadThemeData theme; + final BuildDetail detail; + final String platformFilter; + final ValueChanged onPlatformChanged; + final Future Function(String value) onCopy; + + @override + Widget build(BuildContext context) { + final platforms = { + if (detail.platform.isNotEmpty) detail.platform, + ...detail.dependencies.map((item) => item.platform).where((item) => item.isNotEmpty), + ...detail.results.map((item) => item.platform).where((item) => item.isNotEmpty), + }.toList() + ..sort(); + + final filterArch = _platformArch(platformFilter); + final dependencies = detail.dependencies.where((item) { + if (filterArch.isEmpty) { + return true; + } + return item.platform.isEmpty || item.platform == filterArch; + }).toList(); + final results = detail.results.where((item) { + if (filterArch.isEmpty) { + return true; + } + return item.platform.isEmpty || item.platform == filterArch; + }).toList(); + + return ListView( + children: [ + _SectionHeader( + theme: theme, + title: 'Source details', + trailing: platforms.isEmpty + ? null + : _PlatformFilter( + theme: theme, + value: platformFilter, + options: platforms, + onChanged: onPlatformChanged, + ), + ), + _InfoRow(theme: theme, label: 'File name', value: detail.dockerfile), + _InfoRow(theme: theme, label: 'Remote source location', value: detail.remoteSource, link: true), + _InfoRow(theme: theme, label: 'Revision', value: detail.sourceRevision), + _InfoRow(theme: theme, label: 'Dockerfile', value: detail.dockerfile), + const SizedBox(height: 24), + _SectionHeader(theme: theme, title: 'Build timing'), + const SizedBox(height: 12), + _TimingCharts(theme: theme, timing: detail.timing, totalMs: detail.durationMs, cachedSteps: detail.cachedSteps, totalSteps: detail.totalSteps), + const SizedBox(height: 24), + _SectionHeader(theme: theme, title: 'Dependencies'), + _DataTable( + theme: theme, + columns: const ['Source', 'Platform', 'Digest'], + rows: dependencies + .map( + (item) => [ + item.source, + item.platform, + item.digest, + ], + ) + .toList(), + onCopy: onCopy, + ), + const SizedBox(height: 24), + _SectionHeader(theme: theme, title: 'Build results'), + _DataTable( + theme: theme, + columns: const ['Artifact', 'Platform', 'Digest', 'Size'], + copyColumnIndex: 2, + rows: results + .map( + (item) => [ + item.name, + item.platform, + item.digest, + item.size, + ], + ) + .toList(), + onCopy: onCopy, + ), + const SizedBox(height: 24), + _SectionHeader(theme: theme, title: 'Tags'), + _DataTable( + theme: theme, + columns: const ['Tags', 'Digest'], + rows: detail.tags.map((item) => [item.tag, item.digest]).toList(), + onCopy: onCopy, + ), + ], + ); + } +} + +class _PlatformFilter extends StatelessWidget { + const _PlatformFilter({ + required this.theme, + required this.value, + required this.options, + required this.onChanged, + }); + + final ShadThemeData theme; + final String value; + final List options; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Filter by platform', style: theme.textTheme.small), + const SizedBox(width: 8), + CalfButton.outline( + onPressed: () async { + final renderBox = context.findRenderObject() as RenderBox?; + if (renderBox == null) { + return; + } + + final offset = renderBox.localToGlobal(Offset.zero); + final selected = await showMenu( + context: context, + position: RelativeRect.fromLTRB( + offset.dx, + offset.dy + renderBox.size.height, + offset.dx + renderBox.size.width, + offset.dy + renderBox.size.height, + ), + items: options + .map((option) => PopupMenuItem(value: option, child: Text(option))) + .toList(), + ); + if (selected != null) { + onChanged(selected); + } + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(value, style: theme.textTheme.small), + const SizedBox(width: 4), + Icon(LucideIcons.chevronDown, size: 14, color: theme.colorScheme.foreground), + ], + ), + ), + ], + ); + } +} + +class _TimingCharts extends StatelessWidget { + const _TimingCharts({ + required this.theme, + required this.timing, + required this.totalMs, + required this.cachedSteps, + required this.totalSteps, + }); + + final ShadThemeData theme; + final BuildTiming timing; + final int totalMs; + final int cachedSteps; + final int totalSteps; + + @override + Widget build(BuildContext context) { + final realTime = _timingSlices(timing); + final accumulatedTotal = timing.localTransfersMs + + timing.imagePullsMs + + timing.executionsMs + + timing.fileOperationsMs + + timing.resultExportsMs + + timing.idleMs; + final accumulatedSlices = realTime; + final uncachedSteps = (totalSteps - cachedSteps).clamp(0, totalSteps); + final cacheSlices = [ + _TimingSlice('Cached steps', cachedSteps.toDouble(), const Color(0xFF22C55E)), + _TimingSlice('Other steps', uncachedSteps.toDouble(), theme.colorScheme.mutedForeground), + ]; + final idleMs = timing.idleMs.clamp(0, totalMs); + final activeMs = (totalMs - idleMs).clamp(0, totalMs); + + return GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 1.4, + children: [ + _TimingChartCard( + theme: theme, + title: 'Real time (${_formatDuration(totalMs)})', + slices: realTime, + formatValue: (value) => _formatDuration(value.toInt()), + ), + _TimingChartCard( + theme: theme, + title: 'Accumulated time (${_formatDuration(accumulatedTotal)})', + slices: accumulatedSlices, + formatValue: (value) => _formatDuration(value.toInt()), + ), + _TimingChartCard( + theme: theme, + title: 'Cache usage ($cachedSteps/$totalSteps)', + slices: cacheSlices, + formatValue: (value) => '${value.toInt()}', + ), + _TimingChartCard( + theme: theme, + title: 'Parallel execution', + slices: [ + _TimingSlice('Active', activeMs > 0 ? activeMs.toDouble() : 1, const Color(0xFF3B82F6)), + _TimingSlice('Idle', idleMs > 0 ? idleMs.toDouble() : 0, theme.colorScheme.mutedForeground), + ].where((slice) => slice.value > 0).toList(), + formatValue: (value) => _formatDuration(value.toInt()), + ), + ], + ); + } + + List<_TimingSlice> _timingSlices(BuildTiming timing) { + return [ + _TimingSlice('Local file transfers', timing.localTransfersMs.toDouble(), const Color(0xFF166534)), + _TimingSlice('Image pulls', timing.imagePullsMs.toDouble(), const Color(0xFF4ADE80)), + _TimingSlice('Executions', timing.executionsMs.toDouble(), const Color(0xFF3B82F6)), + _TimingSlice('File operations', timing.fileOperationsMs.toDouble(), const Color(0xFFEF4444)), + _TimingSlice('Result exports', timing.resultExportsMs.toDouble(), const Color(0xFFA855F7)), + _TimingSlice('Idle', timing.idleMs.toDouble(), theme.colorScheme.mutedForeground), + ].where((slice) => slice.value > 0).toList(); + } +} + +class _TimingSlice { + const _TimingSlice(this.label, this.value, this.color); + + final String label; + final double value; + final Color color; +} + +class _TimingChartCard extends StatefulWidget { + const _TimingChartCard({ + required this.theme, + required this.title, + required this.slices, + required this.formatValue, + }); + + final ShadThemeData theme; + final String title; + final List<_TimingSlice> slices; + final String Function(double value) formatValue; + + @override + State<_TimingChartCard> createState() => _TimingChartCardState(); +} + +class _TimingChartCardState extends State<_TimingChartCard> { + int? _touchedIndex; + + @override + Widget build(BuildContext context) { + final total = widget.slices.fold(0, (sum, slice) => sum + slice.value); + final touchedSlice = _touchedIndex != null && + _touchedIndex! >= 0 && + _touchedIndex! < widget.slices.length + ? widget.slices[_touchedIndex!] + : null; + final touchedPercent = touchedSlice != null && total > 0 + ? (touchedSlice.value / total * 100).toStringAsFixed(1) + : null; + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: widget.theme.colorScheme.border), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.title, style: widget.theme.textTheme.small.copyWith(fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + Expanded( + child: total <= 0 + ? Center(child: Text('No timing data', style: widget.theme.textTheme.muted)) + : Stack( + alignment: Alignment.center, + children: [ + PieChart( + PieChartData( + sectionsSpace: 1, + centerSpaceRadius: 28, + pieTouchData: PieTouchData( + enabled: true, + touchCallback: (event, response) { + setState(() { + if (!event.isInterestedForInteractions || + response == null || + response.touchedSection == null) { + _touchedIndex = null; + return; + } + _touchedIndex = response.touchedSection!.touchedSectionIndex; + }); + }, + ), + sections: [ + for (var index = 0; index < widget.slices.length; index++) + PieChartSectionData( + value: widget.slices[index].value, + color: widget.slices[index].color, + radius: _touchedIndex == index ? 42 : 36, + title: '', + ), + ], + ), + duration: const Duration(milliseconds: 120), + ), + if (touchedSlice != null && touchedPercent != null) + IgnorePointer( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: widget.theme.colorScheme.background, + border: Border.all(color: widget.theme.colorScheme.border), + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: widget.theme.colorScheme.foreground.withValues(alpha: 0.08), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + touchedSlice.label, + style: widget.theme.textTheme.small.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 2), + Text( + '${widget.formatValue(touchedSlice.value)} ($touchedPercent%)', + style: widget.theme.textTheme.muted, + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({ + required this.theme, + required this.title, + this.trailing, + }); + + final ShadThemeData theme; + final String title; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Expanded(child: Text(title, style: theme.textTheme.large.copyWith(fontWeight: FontWeight.w600))), + ?trailing, + ], + ), + ); + } +} + +class _InfoRow extends StatelessWidget { + const _InfoRow({ + required this.theme, + required this.label, + required this.value, + this.link = false, + }); + + final ShadThemeData theme; + final String label; + final String value; + final bool link; + + @override + Widget build(BuildContext context) { + if (value.isEmpty) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 180, child: Text(label, style: theme.textTheme.muted)), + Expanded( + child: Text( + value, + style: theme.textTheme.large.copyWith(color: link ? theme.colorScheme.primary : null), + ), + ), + ], + ), + ); + } +} + +class _DataTable extends StatelessWidget { + const _DataTable({ + required this.theme, + required this.columns, + required this.rows, + required this.onCopy, + this.copyColumnIndex, + }); + + final ShadThemeData theme; + final List columns; + final List> rows; + final Future Function(String value) onCopy; + final int? copyColumnIndex; + + @override + Widget build(BuildContext context) { + if (rows.isEmpty) { + return Text('No data found', style: theme.textTheme.muted); + } + + return Column( + children: [ + Row( + children: [ + for (final column in columns) ...[ + Expanded(child: Text(column, style: theme.textTheme.small.copyWith(color: theme.colorScheme.mutedForeground))), + ], + const SizedBox(width: 32), + ], + ), + const SizedBox(height: 8), + for (final row in rows) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + for (var index = 0; index < columns.length; index++) ...[ + Expanded( + child: Text( + row.length > index ? row[index] : '', + style: theme.textTheme.large, + overflow: TextOverflow.ellipsis, + ), + ), + ], + if (row.isNotEmpty) ...[ + Builder( + builder: (context) { + final copyIndex = copyColumnIndex ?? row.length - 1; + if (copyIndex < 0 || copyIndex >= row.length || row[copyIndex].isEmpty) { + return const SizedBox.shrink(); + } + + return CalfButton.ghost( + padding: const EdgeInsets.all(4), + onPressed: () => onCopy(row[copyIndex]), + child: Icon(LucideIcons.copy, size: 14, color: theme.colorScheme.mutedForeground), + ); + }, + ), + ], + ], + ), + ), + ], + ); + } +} + +class _SourceTab extends StatelessWidget { + const _SourceTab({ + required this.theme, + required this.loading, + required this.error, + required this.source, + required this.detail, + }); + + final ShadThemeData theme; + final bool loading; + final String? error; + final BuildSource? source; + final BuildDetail detail; + + @override + Widget build(BuildContext context) { + if (loading) { + return Text('Loading source...', style: theme.textTheme.muted); + } + if (error != null) { + return Text(error!, style: theme.textTheme.small.copyWith(color: theme.colorScheme.destructive)); + } + + final content = source?.content ?? ''; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(LucideIcons.box, size: 16, color: theme.colorScheme.foreground), + const SizedBox(width: 8), + Text(source?.filename ?? detail.dockerfile, style: theme.textTheme.large), + const SizedBox(width: 12), + Text(_platformArch(detail.platform), style: theme.textTheme.muted), + ], + ), + const SizedBox(height: 12), + Expanded( + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.muted.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: theme.colorScheme.border), + ), + child: SingleChildScrollView( + child: SelectableText( + content, + style: theme.textTheme.small.copyWith(fontFamily: 'Menlo', height: 1.5), + ), + ), + ), + ), + ], + ); + } +} + +class _LogsTab extends StatelessWidget { + const _LogsTab({ + required this.theme, + required this.detail, + required this.rawLog, + required this.steps, + required this.loading, + required this.error, + required this.plainLogs, + required this.expandedSteps, + required this.onTogglePlain, + required this.onToggleStep, + }); + + final ShadThemeData theme; + final BuildDetail detail; + final String rawLog; + final List steps; + final bool loading; + final String? error; + final bool plainLogs; + final Set expandedSteps; + final ValueChanged onTogglePlain; + final ValueChanged onToggleStep; + + @override + Widget build(BuildContext context) { + if (loading) { + return Center(child: Text('Loading logs...', style: theme.textTheme.muted)); + } + if (error != null) { + return Text(error!, style: theme.textTheme.small.copyWith(color: theme.colorScheme.destructive)); + } + if (rawLog.isEmpty && steps.isEmpty) { + return Center(child: Text('No logs available for this build.', style: theme.textTheme.muted)); + } + + if (plainLogs) { + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + CalfButton.ghost( + onPressed: () => onTogglePlain(false), + child: Text('Step view', style: theme.textTheme.small), + ), + const SizedBox(height: 8), + Expanded( + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.muted.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: theme.colorScheme.border), + ), + child: SingleChildScrollView( + child: SelectableText( + rawLog, + style: theme.textTheme.small.copyWith(fontFamily: 'Menlo', height: 1.4), + ), + ), + ), + ), + ], + ); + } + + final totalMs = detail.durationMs <= 0 ? 1 : detail.durationMs; + + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + CalfButton.ghost( + onPressed: () => onTogglePlain(true), + child: Text('Plain view', style: theme.textTheme.small), + ), + ], + ), + const SizedBox(height: 8), + Expanded( + child: ListView.builder( + itemCount: steps.length, + itemBuilder: (context, index) { + final step = steps[index]; + final expanded = expandedSteps.contains(index); + final badge = step.index > 0 ? '${step.index}/${step.total}' : 'internal'; + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + children: [ + HoverListRow( + theme: theme, + onTap: step.log.isEmpty ? null : () => onToggleStep(index), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + child: Row( + children: [ + _StepBadge(theme: theme, label: badge), + const SizedBox(width: 8), + Expanded( + child: Text(step.name, style: theme.textTheme.large.copyWith(fontFamily: 'Menlo')), + ), + if (step.cached) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(999), + ), + child: Text('CACHED', style: theme.textTheme.small.copyWith(color: theme.colorScheme.primary)), + ), + const SizedBox(width: 8), + Text(_formatDuration(step.durationMs), style: theme.textTheme.muted), + SizedBox( + width: 120, + child: Align( + alignment: Alignment.centerRight, + child: FractionallySizedBox( + widthFactor: (step.durationMs / totalMs).clamp(0.05, 1.0), + child: Container( + height: 4, + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(999), + ), + ), + ), + ), + ), + ], + ), + ), + if (expanded && step.log.isNotEmpty) + Container( + width: double.infinity, + margin: const EdgeInsets.only(left: 40, top: 4), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.colorScheme.muted.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + ), + child: SelectableText( + step.log, + style: theme.textTheme.small.copyWith(fontFamily: 'Menlo'), + ), + ), + ], + ), + ); + }, + ), + ), + ], + ); + } +} + +class _StepBadge extends StatelessWidget { + const _StepBadge({required this.theme, required this.label}); + + final ShadThemeData theme; + final String label; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + border: Border.all(color: theme.colorScheme.border), + borderRadius: BorderRadius.circular(999), + ), + child: Text(label, style: theme.textTheme.small), + ); + } +} + +class _HistoryTab extends StatelessWidget { + const _HistoryTab({ + required this.theme, + required this.loading, + required this.error, + required this.history, + required this.currentId, + this.onOpenBuild, + }); + + final ShadThemeData theme; + final bool loading; + final String? error; + final List history; + final String currentId; + final ValueChanged? onOpenBuild; + + @override + Widget build(BuildContext context) { + if (loading) { + return Text('Loading history...', style: theme.textTheme.muted); + } + if (error != null) { + return Text(error!, style: theme.textTheme.small.copyWith(color: theme.colorScheme.destructive)); + } + + final items = history.take(30).toList().reversed.toList(); + final durations = items.map((item) => item.durationMs.toDouble()).toList(); + final totalSteps = items.map((item) => item.totalSteps.toDouble()).toList(); + final cachedSteps = items.map((item) => item.cachedSteps.toDouble()).toList(); + + return ListView( + children: [ + Text('Build history', style: theme.textTheme.large.copyWith(fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + Text( + 'Each series is scaled to its own peak in this window.', + style: theme.textTheme.muted, + ), + const SizedBox(height: 12), + SizedBox( + height: 220, + child: items.isEmpty + ? Center(child: Text('No builds to chart yet.', style: theme.textTheme.muted)) + : LineChart( + LineChartData( + minX: 0, + maxX: items.length <= 1 ? 1 : (items.length - 1).toDouble(), + minY: 0, + maxY: 1.2, + gridData: FlGridData( + show: true, + drawVerticalLine: false, + getDrawingHorizontalLine: (_) => FlLine(color: theme.colorScheme.border, strokeWidth: 1), + ), + titlesData: const FlTitlesData(show: false), + borderData: FlBorderData(show: false), + lineTouchData: LineTouchData( + enabled: items.isNotEmpty, + touchTooltipData: LineTouchTooltipData( + getTooltipItems: (spots) { + return spots.map((spot) { + final index = spot.x.round().clamp(0, items.length - 1); + final item = items[index]; + String label; + String value; + switch (spot.barIndex) { + case 1: + label = 'Steps'; + value = '${item.totalSteps}'; + case 2: + label = 'Cached'; + value = '${item.cachedSteps}'; + default: + label = 'Duration'; + value = _formatDuration(item.durationMs); + } + return LineTooltipItem( + '$label\n$value', + theme.textTheme.small.copyWith(color: theme.colorScheme.foreground), + ); + }).toList(); + }, + ), + ), + lineBarsData: [ + LineChartBarData( + spots: _normalizedHistorySpots(durations), + isCurved: items.length > 2, + color: theme.colorScheme.primary, + barWidth: 2, + dotData: FlDotData(show: items.length <= 3), + ), + LineChartBarData( + spots: _normalizedHistorySpots(totalSteps), + isCurved: items.length > 2, + color: const Color(0xFF22C55E), + barWidth: 2, + dotData: FlDotData(show: items.length <= 3), + ), + LineChartBarData( + spots: _normalizedHistorySpots(cachedSteps), + isCurved: items.length > 2, + color: theme.colorScheme.mutedForeground, + barWidth: 2, + dotData: FlDotData(show: items.length <= 3), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + Text('Past builds', style: theme.textTheme.large.copyWith(fontWeight: FontWeight.w600)), + const SizedBox(height: 12), + if (history.isEmpty) + Text('No builds found for this image.', style: theme.textTheme.muted) + else + for (final item in history) + HoverListRow( + theme: theme, + onTap: onOpenBuild == null || item.id == currentId ? null : () => onOpenBuild!(item.id), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + child: Row( + children: [ + Expanded(child: Text(item.tag, style: theme.textTheme.large)), + Expanded(child: Text(item.id, style: theme.textTheme.muted, overflow: TextOverflow.ellipsis)), + Text(item.builder, style: theme.textTheme.muted), + const SizedBox(width: 12), + Text(_platformArch(item.platform), style: theme.textTheme.muted), + const SizedBox(width: 12), + Text('${item.cachedSteps}/${item.totalSteps}', style: theme.textTheme.muted), + const SizedBox(width: 12), + Text(_formatDuration(item.durationMs), style: theme.textTheme.muted), + const SizedBox(width: 12), + Text(item.createdAt, style: theme.textTheme.muted), + ], + ), + ), + ], + ); + } +} + +String _statusLabel(String status) { + switch (status) { + case 'success': + return 'Completed'; + case 'failed': + return 'Failed'; + case 'running': + return 'Running'; + default: + return status; + } +} + +Color _statusColor(String status, ShadThemeData theme) { + switch (status) { + case 'success': + return const Color(0xFF22C55E); + case 'failed': + return theme.colorScheme.destructive; + case 'running': + return theme.colorScheme.primary; + default: + return theme.colorScheme.mutedForeground; + } +} + +String _formatDuration(int durationMs) { + if (durationMs <= 0) { + return '0.0s'; + } + + final seconds = durationMs / 1000; + if (seconds < 60) { + return '${seconds.toStringAsFixed(1)}s'; + } + + final minutes = seconds ~/ 60; + final remainder = seconds % 60; + return '${minutes}m ${remainder.toStringAsFixed(0)}s'; +} + +String _platformArch(String platform) { + final parts = platform.split('/'); + if (parts.length == 2) { + return parts[1]; + } + + return platform; +} + +List _normalizedHistorySpots(List values) { + if (values.isEmpty) { + return const []; + } + + final peak = values.fold(0, (current, value) => value > current ? value : current); + final normalized = values + .map((value) => peak <= 0 ? 0.0 : value / peak) + .toList(growable: false); + + return _historySpots(normalized); +} + +List _historySpots(List values) { + if (values.isEmpty) { + return const []; + } + + if (values.length == 1) { + return [ + FlSpot(0, values[0]), + FlSpot(1, values[0]), + ]; + } + + return [ + for (var index = 0; index < values.length; index++) + FlSpot(index.toDouble(), values[index]), + ]; +} diff --git a/ui/lib/screens/resources_screen.dart b/ui/lib/screens/resources_screen.dart index c7a44c3..265a5de 100644 --- a/ui/lib/screens/resources_screen.dart +++ b/ui/lib/screens/resources_screen.dart @@ -10,6 +10,7 @@ import 'package:flutter/widgets.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:ui/api/client.dart'; +import 'package:ui/screens/build_detail_screen.dart'; import 'package:ui/screens/volume_detail_screen.dart'; import 'package:ui/widgets/calf_button.dart'; import 'package:ui/widgets/hover_list_row.dart'; @@ -864,6 +865,7 @@ class _BuildsScreenState extends State { RuntimeStatus? _runtime; String? _error; bool _loading = true; + String? _selectedBuildId; final _searchController = TextEditingController(); String _searchQuery = ''; Timer? _timer; @@ -932,8 +934,25 @@ class _BuildsScreenState extends State { } } + void _openBuild(BuildItem build) { + setState(() => _selectedBuildId = build.id); + } + + void _closeBuild() { + setState(() => _selectedBuildId = null); + } + @override Widget build(BuildContext context) { + if (_selectedBuildId != null) { + return BuildDetailView( + buildId: _selectedBuildId!, + apiClient: widget.apiClient, + onBack: _closeBuild, + onOpenBuild: (id) => setState(() => _selectedBuildId = id), + ); + } + final theme = ShadTheme.of(context); final filtered = _searchQuery.isEmpty ? _builds @@ -975,13 +994,35 @@ class _BuildsScreenState extends State { return HoverListRow( theme: theme, + onTap: () => _openBuild(build), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - Text(build.tag, style: theme.textTheme.large), - Text('${build.context} · ${build.status}', style: theme.textTheme.muted), - Text(build.createdAt, style: theme.textTheme.muted), + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: _buildStatusColor(build.status, theme), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(build.tag, style: theme.textTheme.large), + Text('${build.context} · ${build.status}', style: theme.textTheme.muted), + Text( + [ + if (build.durationMs > 0) _formatBuildDuration(build.durationMs), + build.createdAt, + ].where((item) => item.isNotEmpty).join(' · '), + style: theme.textTheme.muted, + ), + ], + ), + ), ], ), ); @@ -992,3 +1033,31 @@ class _BuildsScreenState extends State { ); } } + +Color _buildStatusColor(String status, ShadThemeData theme) { + switch (status) { + case 'success': + return const Color(0xFF22C55E); + case 'failed': + return theme.colorScheme.destructive; + case 'running': + return theme.colorScheme.primary; + default: + return theme.colorScheme.mutedForeground; + } +} + +String _formatBuildDuration(int durationMs) { + if (durationMs <= 0) { + return ''; + } + + final seconds = durationMs / 1000; + if (seconds < 60) { + return '${seconds.toStringAsFixed(1)}s'; + } + + final minutes = seconds ~/ 60; + final remainder = seconds % 60; + return '${minutes}m ${remainder.toStringAsFixed(0)}s'; +} diff --git a/ui/test/widget_test.dart b/ui/test/widget_test.dart index 528d356..46c190e 100644 --- a/ui/test/widget_test.dart +++ b/ui/test/widget_test.dart @@ -69,7 +69,32 @@ class FakeCalfClient implements CalfClient { ]; @override - Future> fetchBuilds() async => const []; + Future> fetchBuilds({String? tag}) async => const []; + + @override + Future fetchBuildDetail(String id) async => BuildDetail( + id: id, + tag: 'demo', + context: '.', + status: 'success', + createdAt: '2026-01-01T00:00:00Z', + ); + + @override + Future fetchBuildSource(String id) async => const BuildSource( + path: 'Dockerfile', + filename: 'Dockerfile', + content: 'FROM alpine', + platform: 'arm64', + ); + + @override + Future fetchBuildLogs(String id) async => const BuildLogs( + rawLog: '#1 DONE 0.1s', + steps: [ + BuildStep(index: 1, total: 1, name: 'load build definition', cached: false, durationMs: 100), + ], + ); @override Future startContainer(String id) async {} @@ -347,7 +372,22 @@ class _ErrorCalfClient implements CalfClient { Future> fetchVolumeContainers(String name) async => const []; @override - Future> fetchBuilds() async => []; + Future> fetchBuilds({String? tag}) async => []; + + @override + Future fetchBuildDetail(String id) async { + throw ApiException('daemon unavailable', statusCode: 503); + } + + @override + Future fetchBuildSource(String id) async { + throw ApiException('daemon unavailable', statusCode: 503); + } + + @override + Future fetchBuildLogs(String id) async { + throw ApiException('daemon unavailable', statusCode: 503); + } @override Future startContainer(String id) async {}