LEM/cmd/lem-desktop/docker.go
Claude 774f097855
feat: scaffold LEM Desktop app (Wails v3 system tray + Docker stack)
Inspired by BugSETI architecture — system tray with WebView2 windows,
Docker Compose stack (Forgejo + InfluxDB + inference proxy), and
scoring agent integration. Builds as signed native binary on macOS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 17:43:19 +00:00

226 lines
5.3 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
)
// DockerService manages the LEM Docker compose stack.
// Provides start/stop/status for Forgejo, InfluxDB, and inference services.
type DockerService struct {
composeFile string
mu sync.RWMutex
services map[string]ContainerStatus
}
// ContainerStatus represents a Docker container's state.
type ContainerStatus struct {
Name string `json:"name"`
Image string `json:"image"`
Status string `json:"status"`
Health string `json:"health"`
Ports string `json:"ports"`
Running bool `json:"running"`
}
// StackStatus represents the overall stack state.
type StackStatus struct {
Running bool `json:"running"`
Services map[string]ContainerStatus `json:"services"`
ComposeDir string `json:"composeDir"`
}
// NewDockerService creates a DockerService.
// composeDir should point to the deploy/ directory containing docker-compose.yml.
func NewDockerService(composeDir string) *DockerService {
return &DockerService{
composeFile: filepath.Join(composeDir, "docker-compose.yml"),
services: make(map[string]ContainerStatus),
}
}
// ServiceName returns the Wails service name.
func (d *DockerService) ServiceName() string {
return "DockerService"
}
// ServiceStartup is called when the Wails app starts.
func (d *DockerService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
log.Println("DockerService started")
go d.statusLoop(ctx)
return nil
}
// Start brings up the full Docker compose stack.
func (d *DockerService) Start() error {
log.Println("Starting LEM stack...")
return d.compose("up", "-d")
}
// Stop takes down the Docker compose stack.
func (d *DockerService) Stop() error {
log.Println("Stopping LEM stack...")
return d.compose("down")
}
// Restart restarts the full stack.
func (d *DockerService) Restart() error {
if err := d.Stop(); err != nil {
return err
}
return d.Start()
}
// StartService starts a single service.
func (d *DockerService) StartService(name string) error {
return d.compose("up", "-d", name)
}
// StopService stops a single service.
func (d *DockerService) StopService(name string) error {
return d.compose("stop", name)
}
// RestartService restarts a single service.
func (d *DockerService) RestartService(name string) error {
return d.compose("restart", name)
}
// Logs returns recent logs for a service.
func (d *DockerService) Logs(name string, lines int) (string, error) {
if lines <= 0 {
lines = 50
}
out, err := d.composeOutput("logs", "--tail", fmt.Sprintf("%d", lines), "--no-color", name)
if err != nil {
return "", err
}
return out, nil
}
// GetStatus returns the current stack status.
func (d *DockerService) GetStatus() StackStatus {
d.mu.RLock()
defer d.mu.RUnlock()
running := false
for _, s := range d.services {
if s.Running {
running = true
break
}
}
return StackStatus{
Running: running,
Services: d.services,
ComposeDir: filepath.Dir(d.composeFile),
}
}
// IsRunning returns whether any services are running.
func (d *DockerService) IsRunning() bool {
d.mu.RLock()
defer d.mu.RUnlock()
for _, s := range d.services {
if s.Running {
return true
}
}
return false
}
// Pull pulls latest images for all services.
func (d *DockerService) Pull() error {
return d.compose("pull")
}
func (d *DockerService) compose(args ...string) error {
fullArgs := append([]string{"compose", "-f", d.composeFile}, args...)
cmd := exec.Command("docker", fullArgs...)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("docker compose %s: %w: %s", strings.Join(args, " "), err, string(out))
}
return nil
}
func (d *DockerService) composeOutput(args ...string) (string, error) {
fullArgs := append([]string{"compose", "-f", d.composeFile}, args...)
cmd := exec.Command("docker", fullArgs...)
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("docker compose %s: %w: %s", strings.Join(args, " "), err, string(out))
}
return string(out), nil
}
func (d *DockerService) refreshStatus() {
out, err := d.composeOutput("ps", "--format", "json")
if err != nil {
return
}
d.mu.Lock()
defer d.mu.Unlock()
d.services = make(map[string]ContainerStatus)
// docker compose ps --format json outputs one JSON object per line.
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
if line == "" {
continue
}
var container struct {
Name string `json:"Name"`
Image string `json:"Image"`
Service string `json:"Service"`
Status string `json:"Status"`
Health string `json:"Health"`
State string `json:"State"`
Ports string `json:"Ports"`
}
if err := json.Unmarshal([]byte(line), &container); err != nil {
continue
}
name := container.Service
if name == "" {
name = container.Name
}
d.services[name] = ContainerStatus{
Name: container.Name,
Image: container.Image,
Status: container.Status,
Health: container.Health,
Ports: container.Ports,
Running: container.State == "running",
}
}
}
func (d *DockerService) statusLoop(ctx context.Context) {
d.refreshStatus()
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
d.refreshStatus()
}
}
}