diff --git a/Makefile b/Makefile index 7789196..a96c0ae 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,7 @@ test: ./internal/distworker \ ./internal/installer \ ./internal/webapp \ + ./internal/compose \ ./internal/workercluster \ ./internal/wire \ ./internal/checkexec \ diff --git a/internal/compose/discover.go b/internal/compose/discover.go new file mode 100644 index 0000000..c60edda --- /dev/null +++ b/internal/compose/discover.go @@ -0,0 +1,185 @@ +package compose + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +// psItem is one NDJSON object from `docker ps --format json`. Only the +// fields discovery consumes are decoded; the rest are ignored. Field +// names match Docker's template JSON exactly (ID, Names, Image, State, +// Status, HealthStatus, Labels, Ports). +type psItem struct { + ID string `json:"ID"` + Names string `json:"Names"` + Image string `json:"Image"` + State string `json:"State"` + Status string `json:"Status"` + HealthStatus string `json:"HealthStatus"` + Labels string `json:"Labels"` + Ports string `json:"Ports"` +} + +// Discover collects the current Compose project snapshot. It is the +// single entry point for the refresh loop and the operator console. +// +// The flow is: +// 1. `docker compose ls --all --format json` seeds the project map so +// exited/stopped projects (which have no running containers) still +// appear with their status and compose file. +// 2. `docker ps --format json` lists every running container; those +// carrying `com.docker.compose.project` are inspected and grouped. +// 3. Project-level metadata from container labels refines (and usually +// matches) the `compose ls` data. +// +// Docker being unavailable is reported as a non-empty Errors slice with +// an empty Projects map, so the caller renders a banner rather than a +// blank page. Per-container inspect failures are skipped silently (the +// container may have been removed mid-scan) and never abort the scan. +func Discover(ctx context.Context) (*DiscoveryResult, error) { + res := &DiscoveryResult{Projects: map[string]Project{}} + if !Available(ctx) { + res.Errors = append(res.Errors, "docker daemon is not available") + return res, nil + } + + ls := composeLS(ctx) + for name, item := range ls { + res.Projects[name] = Project{ + Name: name, + Status: item.Status, + ConfigFiles: item.ConfigFiles, + Services: map[string]Service{}, + } + } + + items, err := psItems(ctx) + if err != nil { + res.Errors = append(res.Errors, err.Error()) + return res, nil + } + + containers, err := enrichContainers(ctx, items) + if err != nil { + // enrichContainers never returns a hard error today; keep the + // signature so future enrichment steps can report partial loss. + res.Errors = append(res.Errors, err.Error()) + } + groupContainers(res, containers) + return res, nil +} + +// psItems runs `docker ps --format json` and decodes the NDJSON stream. +// Each line is one container; malformed lines are skipped. +func psItems(ctx context.Context) ([]psItem, error) { + out, err := runDocker(ctx, "ps", "--format", "json") + if err != nil { + return nil, fmt.Errorf("docker ps: %w", err) + } + var items []psItem + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var it psItem + if err := json.Unmarshal([]byte(line), &it); err != nil { + continue + } + items = append(items, it) + } + return items, nil +} + +// enrichContainers maps `docker ps` rows into enriched Container values. +// Containers without a Compose project label are skipped. Each retained +// container is `docker inspect`-ed for its host PID, mounts, published +// ports, and full label set; inspect failures downgrade to the `ps` data +// so a transient inspect error never drops a known container. +func enrichContainers(ctx context.Context, items []psItem) ([]Container, error) { + containers := make([]Container, 0, len(items)) + for _, it := range items { + labels := parseLabels(it.Labels) + if labels[LabelComposeProject] == "" { + continue + } + c := Container{ + ID: it.ID, + Name: strings.TrimPrefix(it.Names, "/"), + Image: it.Image, + State: it.State, + Status: it.Status, + Health: it.HealthStatus, + Labels: labels, + } + if data, err := inspectContainer(ctx, it.ID); err == nil && data != nil { + c.PID = data.State.Pid + c.Ports = extractPorts(data) + c.Mounts = extractMounts(data) + for k, v := range data.Config.Labels { + c.Labels[k] = v + } + } + containers = append(containers, c) + } + return containers, nil +} + +// groupContainers folds enriched containers into the project map. The +// project and service come from the Compose labels; the working dir and +// config file paths are lifted from any container in the project (they +// are identical across containers of one project) and override the +// `compose ls` values when present, since labels carry the exact paths +// the project was deployed with. +// +// Pure (no I/O) so it can be unit-tested with fixture containers. +func groupContainers(res *DiscoveryResult, containers []Container) { + for _, c := range containers { + projectName := c.Labels[LabelComposeProject] + if projectName == "" { + continue + } + serviceName := c.Labels[LabelComposeService] + if serviceName == "" { + serviceName = c.Name + } + c.Service = serviceName + + project, ok := res.Projects[projectName] + if !ok { + project = Project{Name: projectName, Services: map[string]Service{}} + } + // Container labels carry the authoritative working dir / config + // file; prefer them over the `compose ls` row when present. + if wd := c.Labels[LabelComposeWorkingDir]; wd != "" { + project.WorkingDir = wd + } + if cf := c.Labels[LabelComposeConfigFiles]; cf != "" { + project.ConfigFiles = cf + } + + service := project.Services[serviceName] + service.Name = serviceName + service.Containers = append(service.Containers, c) + project.Services[serviceName] = service + + res.Projects[projectName] = project + } +} + +// Snapshot is a sorted, JSON-friendly view of a DiscoveryResult for the +// operator console: projects ordered by name. It is the shape rendered +// on /compose and returned by GET /web/api/compose. +type Snapshot struct { + Projects []ProjectSummary `json:"projects"` + Errors []string `json:"errors,omitempty"` +} + +// Summarize builds the sorted Snapshot from a raw DiscoveryResult. +func Summarize(res *DiscoveryResult) Snapshot { + snap := Snapshot{Errors: append([]string(nil), res.Errors...)} + snap.Projects = BuildAllProjectSummaries(res) + return snap +} diff --git a/internal/compose/discover_test.go b/internal/compose/discover_test.go new file mode 100644 index 0000000..e8de113 --- /dev/null +++ b/internal/compose/discover_test.go @@ -0,0 +1,144 @@ +package compose + +import ( + "context" + "testing" +) + +func TestParseLabels(t *testing.T) { + got := parseLabels("com.docker.compose.project=rsmon,com.docker.compose.service=web,foo=a=b") + if got[LabelComposeProject] != "rsmon" { + t.Fatalf("project = %q", got[LabelComposeProject]) + } + if got[LabelComposeService] != "web" { + t.Fatalf("service = %q", got[LabelComposeService]) + } + if got["foo"] != "a=b" { // value may contain '=' + t.Fatalf("foo = %q", got["foo"]) + } + if len(parseLabels("")) != 0 { + t.Fatal("empty labels should yield empty map") + } +} + +func TestGroupContainers(t *testing.T) { + res := &DiscoveryResult{Projects: map[string]Project{ + "rsmon": {Name: "rsmon", Status: "running(2)", ConfigFiles: "/compose/rsmon/docker-compose.yml", Services: map[string]Service{}}, + }} + containers := []Container{ + { + ID: "a", + Name: "rsmon-web", + Image: "nginx", + State: "running", + Labels: map[string]string{LabelComposeProject: "rsmon", LabelComposeService: "web", LabelComposeWorkingDir: "/compose/rsmon", LabelComposeConfigFiles: "/compose/rsmon/docker-compose.yml"}, + }, + { + ID: "b", + Name: "rsmon-backend", + Image: "rsmon", + State: "running", + Labels: map[string]string{LabelComposeProject: "rsmon", LabelComposeService: "backend"}, + }, + { + ID: "c", + Name: "loose", + State: "running", + Labels: map[string]string{}, // not a compose container + }, + } + groupContainers(res, containers) + + proj, ok := res.Projects["rsmon"] + if !ok { + t.Fatal("rsmon project missing") + } + if proj.WorkingDir != "/compose/rsmon" { + t.Fatalf("working dir = %q", proj.WorkingDir) + } + if len(proj.Services) != 2 { + t.Fatalf("service count = %d", len(proj.Services)) + } + if got := proj.Services["web"].Containers[0].Service; got != "web" { + t.Fatalf("web service tag = %q", got) + } + if _, ok := res.Projects["loose"]; ok { + t.Fatal("non-compose container should not create a project") + } +} + +func TestParseTraefikLabels(t *testing.T) { + routes := ParseTraefikLabels(map[string]string{ + "traefik.http.routers.rsmon.rule": "Host(`rsmon.rscx.ru`)", + "traefik.http.routers.rsmon-api.rule": "PathPrefix(`/api`)", + "traefik.http.routers.rsmon.service": "rsmon", // non-rule label, ignored + "traefik.http.middlewares.redirect.rule": "not a router", // wrong prefix path + }) + if len(routes) != 2 { + t.Fatalf("route count = %d", len(routes)) + } + var hosts, prefixes []string + for _, r := range routes { + hosts = append(hosts, r.Hostnames...) + prefixes = append(prefixes, r.PathPrefixes...) + } + if !contains(hosts, "rsmon.rscx.ru") { + t.Fatalf("hosts = %v", hosts) + } + if !contains(prefixes, "/api") { + t.Fatalf("prefixes = %v", prefixes) + } +} + +func contains(s []string, v string) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} + +func TestBuildProjectSummary(t *testing.T) { + res := &DiscoveryResult{Projects: map[string]Project{ + "p": { + Name: "p", + Services: map[string]Service{ + "web": {Name: "web", Containers: []Container{ + {Name: "web-1", State: "running", Image: "nginx", Ports: []PortBinding{{HostPort: "80"}}}, + }}, + "db": {Name: "db", Containers: []Container{ + {Name: "db-1", State: "exited", Image: "postgres"}, + }}, + }, + }, + }} + snap := Summarize(res) + if len(snap.Projects) != 1 { + t.Fatalf("project count = %d", len(snap.Projects)) + } + p := snap.Projects[0] + if p.ContainerCount != 2 || p.RunningCount != 1 || p.ServiceCount != 2 { + t.Fatalf("counts = svc=%d c=%d run=%d", p.ServiceCount, p.ContainerCount, p.RunningCount) + } + if p.Services[0].Name != "db" { // sorted alphabetically + t.Fatalf("first service = %q", p.Services[0].Name) + } +} + +func TestValidProjectAction(t *testing.T) { + for _, a := range []string{"up", "down", "stop", "restart", "pull"} { + if _, ok := ValidProjectAction(a); !ok { + t.Errorf("%q should be valid", a) + } + } + if _, ok := ValidProjectAction("rm"); ok { + t.Error("rm should be rejected") + } +} + +func TestManageProjectRejectsUnknownAction(t *testing.T) { + if _, err := ManageProject(context.Background(), ProjectRefs{Name: "x", WorkingDir: "/tmp"}, "bogus"); err == nil { + t.Fatal("expected error for unknown action") + } +} diff --git a/internal/compose/docker.go b/internal/compose/docker.go new file mode 100644 index 0000000..e1748a9 --- /dev/null +++ b/internal/compose/docker.go @@ -0,0 +1,130 @@ +package compose + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + "time" +) + +// dockerBin is the Docker CLI binary name. Overridable in tests via +// SetDockerBin so the management/discovery code never hard-codes a path. +var dockerBin = "docker" + +// SetDockerBin overrides the Docker CLI binary used by discovery and +// management. Pass an empty value to restore the default ("docker"). +// Tests use it to point at a stub binary; production leaves it alone. +func SetDockerBin(name string) { + if name == "" { + dockerBin = "docker" + return + } + dockerBin = name +} + +// commandTimeout is the deadline applied to every Docker CLI call so a +// hung dockerd cannot wedge the inventory refresh loop or a management +// request. 30s is well above `docker ps` / `docker inspect` on a busy +// host but short enough that the operator notices. +const commandTimeout = 30 * time.Second + +// Available reports whether the Docker CLI can reach the daemon. Used +// by the refresh loop to no-op (instead of erroring) on hosts without +// Docker, and by the UI to show a "Docker not available" banner. +func Available(ctx context.Context) bool { + c, cancel := context.WithTimeout(ctx, commandTimeout) + defer cancel() + if err := exec.CommandContext(c, dockerBin, "version").Run(); err != nil { + return false + } + // `docker version` succeeds against the client even when the daemon + // is down on some builds; `docker ps` requires the daemon, so it is + // the authoritative liveness probe. + pc, pcancel := context.WithTimeout(ctx, commandTimeout) + defer pcancel() + return exec.CommandContext(pc, dockerBin, "ps", "--format", "{{.ID}}").Run() == nil +} + +// composeLSItem is one row of `docker compose ls --all --format json`. +type composeLSItem struct { + Name string `json:"Name"` + Status string `json:"Status"` + ConfigFiles string `json:"ConfigFiles"` +} + +// composeLS runs `docker compose ls --all --format json` and returns +// the projects keyed by name. Unlike `docker ps --format json` (NDJSON), +// `docker compose ls` emits a single JSON array, so the whole stdout is +// unmarshalled at once. Non-fatal: returns an empty map on any error. +func composeLS(ctx context.Context) map[string]composeLSItem { + out, err := runDocker(ctx, "compose", "ls", "--all", "--format", "json") + if err != nil { + return nil + } + trimmed := strings.TrimSpace(out) + if trimmed == "" { + return nil + } + var items []composeLSItem + if err := json.Unmarshal([]byte(trimmed), &items); err != nil { + return nil + } + m := make(map[string]composeLSItem, len(items)) + for _, it := range items { + m[it.Name] = it + } + return m +} + +// runDocker executes the Docker CLI with the given args and returns +// stdout. The context deadline is enforced by Go (CommandContext); a +// non-zero exit yields an error that carries the daemon's stderr so the +// caller can surface the daemon's diagnostic instead of a bare exit code. +func runDocker(ctx context.Context, args ...string) (string, error) { + c, cancel := context.WithTimeout(ctx, commandTimeout) + defer cancel() + cmd := exec.CommandContext(c, dockerBin, args...) + out, err := cmd.Output() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 { + return "", fmt.Errorf("%s: %s", strings.Join(args, " "), strings.TrimSpace(string(ee.Stderr))) + } + return "", err + } + return string(out), nil +} + +// dockerCombined runs the Docker CLI with a working directory and +// returns combined stdout+stderr. Used by management operations, which +// must run inside the project's working directory so Compose resolves +// the compose file and `.env` exactly as the operator would. Unlike +// runDocker, stderr is merged into the returned string because compose +// progress output goes to stderr and is useful to the operator. +func dockerCombined(ctx context.Context, dir string, args ...string) (string, error) { + c, cancel := context.WithTimeout(ctx, commandTimeout) + defer cancel() + cmd := exec.CommandContext(c, dockerBin, args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + return string(out), err +} + +// parseLabels parses the comma-separated `key=value` label string that +// `docker ps --format json` emits in the Labels field. Tolerates empty +// input and values containing '=' (only the first '=' splits). +func parseLabels(labels string) map[string]string { + out := make(map[string]string) + if labels == "" { + return out + } + for _, pair := range strings.Split(labels, ",") { + k, v, ok := strings.Cut(pair, "=") + if !ok { + continue + } + out[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + return out +} diff --git a/internal/compose/inspect.go b/internal/compose/inspect.go new file mode 100644 index 0000000..494f49d --- /dev/null +++ b/internal/compose/inspect.go @@ -0,0 +1,94 @@ +package compose + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +// inspectData mirrors the subset of `docker inspect ` output that +// discovery consumes. Env is intentionally absent: the worker must not +// collect container environment (which carries secrets). Only Config +// labels, runtime State (for the host PID), Mounts, and published Ports +// are read. +type inspectData struct { + ID string `json:"Id"` + State inspectState `json:"State"` + Config inspectConfig `json:"Config"` + Mounts []inspectMount `json:"Mounts"` + NetworkSettings inspectNetworks `json:"NetworkSettings"` +} + +type inspectState struct { + Status string `json:"Status"` + Running bool `json:"Running"` + Pid int `json:"Pid"` + ExitCode int `json:"ExitCode"` +} + +type inspectConfig struct { + Labels map[string]string `json:"Labels"` +} + +type inspectMount struct { + Type string `json:"Type"` + Source string `json:"Source"` + Destination string `json:"Destination"` +} + +type inspectNetworks struct { + Ports portMap `json:"Ports"` +} + +// portMap mirrors the Docker inspect "Ports" object: the key is the +// container port ("80/tcp") and the value is the list of host bindings +// (nil when the port is exposed but not published). +type portMap map[string][]portBinding + +type portBinding struct { + HostIP string `json:"HostIp"` + HostPort string `json:"HostPort"` +} + +// inspectContainer runs `docker inspect ` and decodes the first +// element. A missing container (race with `docker rm`) is reported as +// an error so the caller can skip it without aborting the whole scan. +func inspectContainer(ctx context.Context, id string) (*inspectData, error) { + out, err := runDocker(ctx, "inspect", id) + if err != nil { + return nil, err + } + var data []inspectData + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &data); err != nil { + return nil, err + } + if len(data) == 0 { + return nil, fmt.Errorf("no inspect data for %s", id) + } + return &data[0], nil +} + +// extractPorts flattens the inspect Ports map into a slice of host +// bindings. Container ports that are exposed but not published (nil +// binding list) are skipped because they have no host-side footprint. +func extractPorts(data *inspectData) []PortBinding { + var ports []PortBinding + for _, bindings := range data.NetworkSettings.Ports { + for _, b := range bindings { + ports = append(ports, PortBinding{HostIP: b.HostIP, HostPort: b.HostPort}) + } + } + return ports +} + +// extractMounts copies the inspect Mounts into the wire Mount shape, +// dropping Docker-internal fields (mode, propagation, rw) the operator +// console does not render. +func extractMounts(data *inspectData) []Mount { + mounts := make([]Mount, 0, len(data.Mounts)) + for _, m := range data.Mounts { + mounts = append(mounts, Mount{Source: m.Source, Destination: m.Destination, Type: m.Type}) + } + return mounts +} diff --git a/internal/compose/manage.go b/internal/compose/manage.go new file mode 100644 index 0000000..abfd3cb --- /dev/null +++ b/internal/compose/manage.go @@ -0,0 +1,124 @@ +package compose + +import ( + "context" + "fmt" + "strings" +) + +// Action is a management operation the operator console can run against +// a project. The string values are the suffix used in the management +// API path (/compose/{project}/{action}) and the audit log target. +type Action string + +const ( + ActionUp Action = "up" + ActionDown Action = "down" + ActionStop Action = "stop" + ActionRestart Action = "restart" + ActionPull Action = "pull" + ActionLogs Action = "logs" +) + +// projectActions is the allowlist of actions a project-level POST +// accepts. It maps the action to the compose subcommand(s). The map is +// the single source of truth so the route handler and the executor +// agree on what is permitted. +var projectActions = map[Action][]string{ + ActionUp: {"up", "-d", "--remove-orphans"}, + ActionDown: {"down", "--remove-orphans"}, + ActionStop: {"stop"}, + ActionRestart: {"restart"}, + ActionPull: {"pull"}, +} + +// serviceActions is the allowlist for service-level operations +// (start/stop/restart a single service inside a project). +var serviceActions = map[Action][]string{ + ActionUp: {"up", "-d", "--no-deps"}, + ActionStop: {"stop"}, + ActionRestart: {"restart"}, +} + +// ValidProjectAction reports whether name is an accepted project action. +func ValidProjectAction(name string) (Action, bool) { + a := Action(name) + _, ok := projectActions[a] + if !ok && a == ActionLogs { + return a, true + } + return a, ok +} + +// ValidServiceAction reports whether name is an accepted service action. +func ValidServiceAction(name string) (Action, bool) { + a := Action(name) + _, ok := serviceActions[a] + return a, ok +} + +// ManagementResult is returned by every management operation. Output is +// the combined compose stdout/stderr; OK is false when the command +// exited non-zero. The handler serializes it as JSON for the API path +// and renders Output in a
 block on the HTML path.
+type ManagementResult struct {
+	OK     bool   `json:"ok"`
+	Action string `json:"action"`
+	Output string `json:"output"`
+}
+
+// ManageProject runs an action against a whole project. refs supplies
+// the working directory (so Compose auto-discovers the compose file and
+// .env) and the project name (pinned with -p). A missing working dir is
+// a hard error: without it Compose cannot locate the project files.
+func ManageProject(ctx context.Context, refs ProjectRefs, action Action) (ManagementResult, error) {
+	args, ok := projectActions[action]
+	if !ok {
+		return ManagementResult{}, fmt.Errorf("compose: unknown project action %q", action)
+	}
+	return runCompose(ctx, refs, args...)
+}
+
+// ManageService runs an action against a single service in a project.
+// --no-deps on `up` ensures starting one service does not implicitly
+// recreate its dependencies (matching dockge's service-start behavior).
+func ManageService(ctx context.Context, refs ProjectRefs, service string, action Action) (ManagementResult, error) {
+	args, ok := serviceActions[action]
+	if !ok {
+		return ManagementResult{}, fmt.Errorf("compose: unknown service action %q", action)
+	}
+	if service == "" {
+		return ManagementResult{}, fmt.Errorf("compose: service is required")
+	}
+	full := append(append([]string{}, args...), service)
+	return runCompose(ctx, refs, full...)
+}
+
+// Logs returns the recent log output for a project as a single string.
+// It runs `docker compose logs --no-color --tail ` (no -f) so the
+// operator console gets a finite snapshot; streaming tails are a later
+// enhancement (dockge uses a PTY + websocket, out of scope here).
+func Logs(ctx context.Context, refs ProjectRefs, tail int) (ManagementResult, error) {
+	if tail <= 0 {
+		tail = 200
+	}
+	return runCompose(ctx, refs, "logs", "--no-color", "--tail", fmt.Sprintf("%d", tail))
+}
+
+// runCompose executes `docker compose -p  ` with the
+// working directory set to refs.WorkingDir. The working dir makes
+// Compose resolve the compose file and `.env` exactly as the operator
+// would from the shell; -p pins the project name so the command cannot
+// accidentally target a different project that shares the directory.
+func runCompose(ctx context.Context, refs ProjectRefs, args ...string) (ManagementResult, error) {
+	if refs.WorkingDir == "" {
+		return ManagementResult{OK: false, Output: "compose project working directory is unknown; cannot manage"}, fmt.Errorf("compose: empty working dir")
+	}
+	full := append([]string{"compose", "-p", refs.Name}, args...)
+	out, err := dockerCombined(ctx, refs.WorkingDir, full...)
+	res := ManagementResult{OK: err == nil, Action: strings.Join(args, " "), Output: out}
+	if err != nil {
+		return res, err
+	}
+	return res, nil
+}
diff --git a/internal/compose/summary.go b/internal/compose/summary.go
new file mode 100644
index 0000000..cb3a40b
--- /dev/null
+++ b/internal/compose/summary.go
@@ -0,0 +1,199 @@
+package compose
+
+import (
+	"fmt"
+	"sort"
+	"strings"
+)
+
+// ProjectSummary is the operator-facing view of one Compose project:
+// counts, per-service containers, grouped mounts, and Traefik routes.
+// It is the shape rendered on /compose and returned by the JSON API.
+type ProjectSummary struct {
+	Name             string                `json:"name"`
+	Status           string                `json:"status"`
+	ConfigFiles      string                `json:"config_files"`
+	WorkingDir       string                `json:"working_dir"`
+	ServiceCount     int                   `json:"service_count"`
+	ContainerCount   int                   `json:"container_count"`
+	RunningCount     int                   `json:"running_count"`
+	Services         []ServiceSummary      `json:"services"`
+	GroupedMounts    []ProjectMountInfo    `json:"grouped_mounts"`
+	AllTraefikRoutes []ProjectTraefikRoute `json:"traefik_routes"`
+}
+
+// ServiceSummary is a condensed view of a Compose service.
+type ServiceSummary struct {
+	Name       string             `json:"name"`
+	Containers []ContainerSummary `json:"containers"`
+}
+
+// ContainerSummary holds the key container fields the detail page shows.
+type ContainerSummary struct {
+	Name   string        `json:"name"`
+	ID     string        `json:"id"`
+	Image  string        `json:"image"`
+	State  string        `json:"state"`
+	Status string        `json:"status"`
+	Health string        `json:"health"`
+	PID    int           `json:"pid"`
+	Ports  []PortBinding `json:"ports"`
+}
+
+// ProjectMountInfo represents a mount that may be shared across the
+// services/containers of a project.
+type ProjectMountInfo struct {
+	Source      string       `json:"source"`
+	Destination string       `json:"destination"`
+	Type        string       `json:"type"`
+	UsedBy      []MountUsage `json:"used_by"`
+}
+
+// MountUsage identifies which service/container uses a mount.
+type MountUsage struct {
+	Service   string `json:"service"`
+	Container string `json:"container"`
+}
+
+// ProjectTraefikRoute is a Traefik route attributed to the project.
+type ProjectTraefikRoute struct {
+	RouterName   string   `json:"router_name"`
+	Service      string   `json:"service"`
+	Container    string   `json:"container"`
+	Hostnames    []string `json:"hostnames"`
+	PathPrefixes []string `json:"path_prefixes"`
+	Paths        []string `json:"paths"`
+	Rule         string   `json:"rule"`
+}
+
+// BuildProjectSummary creates the operator-facing summary from a raw
+// Project. Services, mounts, and routes are sorted for stable display.
+func BuildProjectSummary(p Project) ProjectSummary {
+	s := ProjectSummary{
+		Name:             p.Name,
+		Status:           p.Status,
+		ConfigFiles:      p.ConfigFiles,
+		WorkingDir:       p.WorkingDir,
+		Services:         make([]ServiceSummary, 0, len(p.Services)),
+		GroupedMounts:    make([]ProjectMountInfo, 0),
+		AllTraefikRoutes: make([]ProjectTraefikRoute, 0),
+	}
+	mountGroups := map[string]*ProjectMountInfo{}
+
+	for svcName, svc := range p.Services {
+		s.ServiceCount++
+		svcSummary := ServiceSummary{Name: svcName, Containers: make([]ContainerSummary, 0, len(svc.Containers))}
+		for _, c := range svc.Containers {
+			s.ContainerCount++
+			if c.State == "running" {
+				s.RunningCount++
+			}
+			svcSummary.Containers = append(svcSummary.Containers, ContainerSummary{
+				Name:   c.Name,
+				ID:     c.ID,
+				Image:  c.Image,
+				State:  c.State,
+				Status: c.Status,
+				Health: c.Health,
+				PID:    c.PID,
+				Ports:  c.Ports,
+			})
+			groupMount(mountGroups, svcName, c)
+			for _, r := range ExtractTraefikRoutes(c) {
+				s.AllTraefikRoutes = append(s.AllTraefikRoutes, ProjectTraefikRoute{
+					RouterName:   r.RouterName,
+					Service:      svcName,
+					Container:    c.Name,
+					Hostnames:    r.Hostnames,
+					PathPrefixes: r.PathPrefixes,
+					Paths:        r.Paths,
+					Rule:         r.Rule,
+				})
+			}
+		}
+		s.Services = append(s.Services, svcSummary)
+	}
+
+	for _, mi := range mountGroups {
+		s.GroupedMounts = append(s.GroupedMounts, *mi)
+	}
+	sort.Slice(s.Services, func(i, j int) bool { return s.Services[i].Name < s.Services[j].Name })
+	sort.Slice(s.GroupedMounts, func(i, j int) bool { return s.GroupedMounts[i].Source < s.GroupedMounts[j].Source })
+	sort.Slice(s.AllTraefikRoutes, func(i, j int) bool {
+		return s.AllTraefikRoutes[i].RouterName < s.AllTraefikRoutes[j].RouterName
+	})
+	return s
+}
+
+func groupMount(groups map[string]*ProjectMountInfo, svc string, c Container) {
+	for _, m := range c.Mounts {
+		key := fmt.Sprintf("%s|%s|%s", m.Source, m.Destination, m.Type)
+		usage := MountUsage{Service: svc, Container: c.Name}
+		if existing := groups[key]; existing != nil {
+			for _, u := range existing.UsedBy {
+				if u == usage {
+					goto next
+				}
+			}
+			existing.UsedBy = append(existing.UsedBy, usage)
+		next:
+			continue
+		}
+		groups[key] = &ProjectMountInfo{
+			Source: m.Source, Destination: m.Destination, Type: m.Type, UsedBy: []MountUsage{usage},
+		}
+	}
+}
+
+// BuildAllProjectSummaries returns every project summary sorted by name.
+func BuildAllProjectSummaries(res *DiscoveryResult) []ProjectSummary {
+	out := make([]ProjectSummary, 0, len(res.Projects))
+	for _, p := range res.Projects {
+		out = append(out, BuildProjectSummary(p))
+	}
+	sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
+	return out
+}
+
+// FormatMountSourceShort shortens a long mount source for table display.
+func FormatMountSourceShort(source string, maxLen int) string {
+	if len(source) <= maxLen || maxLen < 10 {
+		return source
+	}
+	keep := (maxLen - 3) / 2
+	return source[:keep] + "..." + source[len(source)-keep:]
+}
+
+// SharedMountSummary returns a short label describing who uses a mount.
+func (m *ProjectMountInfo) SharedMountSummary() string {
+	if len(m.UsedBy) == 0 {
+		return "unused"
+	}
+	if len(m.UsedBy) == 1 {
+		return fmt.Sprintf("%s/%s", m.UsedBy[0].Service, m.UsedBy[0].Container)
+	}
+	counts := map[string]int{}
+	for _, u := range m.UsedBy {
+		counts[u.Service]++
+	}
+	var parts []string
+	for svc, n := range counts {
+		if n == 1 {
+			parts = append(parts, svc)
+		} else {
+			parts = append(parts, fmt.Sprintf("%s(%d)", svc, n))
+		}
+	}
+	sort.Strings(parts)
+	return "shared: " + strings.Join(parts, ", ")
+}
+
+// FindProject returns the summary for the named project, or nil.
+func (s Snapshot) FindProject(name string) *ProjectSummary {
+	for i := range s.Projects {
+		if s.Projects[i].Name == name {
+			return &s.Projects[i]
+		}
+	}
+	return nil
+}
diff --git a/internal/compose/traefik.go b/internal/compose/traefik.go
new file mode 100644
index 0000000..d3620f2
--- /dev/null
+++ b/internal/compose/traefik.go
@@ -0,0 +1,87 @@
+package compose
+
+import (
+	"regexp"
+	"strings"
+)
+
+// Traefik label parsing. Compose projects fronted by Traefik encode
+// their router rules as `traefik.http.routers..rule=Host(...)`.
+// We surface hostnames/paths so the operator console can show which
+// domains a project serves without reading raw labels.
+
+const (
+	traefikLabelPrefix = "traefik.http.routers."
+	traefikRuleSuffix  = ".rule"
+)
+
+var (
+	// Host(`example.com`) or Host("example.com")
+	hostRegex = regexp.MustCompile(`Host\s*\(\s*[` + "`" + `"]([^` + "`" + `"]+)[` + "`" + `"]\s*\)`)
+	// Path(`/api`) or Path("/api")
+	pathRegex = regexp.MustCompile(`Path\s*\(\s*[` + "`" + `"]([^` + "`" + `"]+)[` + "`" + `"]\s*\)`)
+	// PathPrefix(`/api`) or PathPrefix("/api")
+	pathPrefixRegex = regexp.MustCompile(`PathPrefix\s*\(\s*[` + "`" + `"]([^` + "`" + `"]+)[` + "`" + `"]\s*\)`)
+)
+
+// TraefikRoute is one decoded Traefik router derived from labels.
+type TraefikRoute struct {
+	RouterName   string   `json:"router_name"`
+	Hostnames    []string `json:"hostnames"`
+	Paths        []string `json:"paths"`
+	PathPrefixes []string `json:"path_prefixes"`
+	Rule         string   `json:"rule"`
+}
+
+// ParseTraefikLabels scans a label map for `traefik.http.routers..rule`
+// entries and returns one TraefikRoute per router, with the rule's
+// Host/Path/PathPrefix operands extracted. Pure function: no I/O.
+func ParseTraefikLabels(labels map[string]string) []TraefikRoute {
+	routes := map[string]*TraefikRoute{}
+	for key, value := range labels {
+		if !strings.HasPrefix(key, traefikLabelPrefix) {
+			continue
+		}
+		suffix := strings.TrimPrefix(key, traefikLabelPrefix)
+		idx := strings.Index(suffix, ".")
+		if idx == -1 {
+			continue
+		}
+		routerName := suffix[:idx]
+		property := suffix[idx:]
+		if property != traefikRuleSuffix {
+			continue
+		}
+		route := routes[routerName]
+		if route == nil {
+			route = &TraefikRoute{RouterName: routerName}
+		}
+		route.Rule = value
+		route.Hostnames = extractAll(hostRegex, value)
+		route.Paths = extractAll(pathRegex, value)
+		route.PathPrefixes = extractAll(pathPrefixRegex, value)
+		routes[routerName] = route
+	}
+	out := make([]TraefikRoute, 0, len(routes))
+	for _, r := range routes {
+		out = append(out, *r)
+	}
+	return out
+}
+
+func extractAll(re *regexp.Regexp, rule string) []string {
+	matches := re.FindAllStringSubmatch(rule, -1)
+	out := make([]string, 0, len(matches))
+	for _, m := range matches {
+		if len(m) > 1 && m[1] != "" {
+			out = append(out, m[1])
+		}
+	}
+	return out
+}
+
+// ExtractTraefikRoutes returns the Traefik routes declared by a
+// container's labels.
+func ExtractTraefikRoutes(c Container) []TraefikRoute {
+	return ParseTraefikLabels(c.Labels)
+}
diff --git a/internal/compose/types.go b/internal/compose/types.go
new file mode 100644
index 0000000..aeed399
--- /dev/null
+++ b/internal/compose/types.go
@@ -0,0 +1,90 @@
+// Package compose implements Docker Compose project discovery and
+// management for the worker operator console.
+//
+// It talks to Docker exclusively through the `docker` / `docker compose`
+// CLI over os/exec. There is no dependency on the Docker Engine SDK or
+// compose-go: the CLI is the same surface deploymentd and dockge use, it
+// needs no extra libraries, and it is already present on every host the
+// worker manages. Discovery groups running containers by the
+// `com.docker.compose.project` / `com.docker.compose.service` labels and
+// merges the project-level metadata (status, compose file, working dir)
+// reported by `docker compose ls`. Management runs `docker compose`
+// against the project's working directory.
+package compose
+
+// Label constants written by Docker Compose onto every container it
+// creates. They are the stable identity keys that map a raw container
+// back to its Compose project and service.
+const (
+	LabelComposeProject     = "com.docker.compose.project"
+	LabelComposeService     = "com.docker.compose.service"
+	LabelComposeWorkingDir  = "com.docker.compose.project.working_dir"
+	LabelComposeConfigFiles = "com.docker.compose.project.config_files"
+)
+
+// PortBinding is a single host-side port mapping for a container.
+type PortBinding struct {
+	HostIP   string `json:"host_ip"`
+	HostPort string `json:"host_port"`
+}
+
+// Mount is a single bind/volume/tmpfs mount attached to a container.
+type Mount struct {
+	Source      string `json:"source"`
+	Destination string `json:"destination"`
+	Type        string `json:"type"`
+}
+
+// Container is the per-container view discovery collects. It is the
+// union of `docker ps` (name/image/state/status/health) and
+// `docker inspect` (pid/ports/mounts/full labels).
+type Container struct {
+	ID      string            `json:"id"`
+	Name    string            `json:"name"`
+	Image   string            `json:"image"`
+	State   string            `json:"state"`
+	Status  string            `json:"status"`
+	Health  string            `json:"health"`
+	PID     int               `json:"pid"`
+	Labels  map[string]string `json:"labels"`
+	Ports   []PortBinding     `json:"ports"`
+	Mounts  []Mount           `json:"mounts"`
+	Service string            `json:"service"`
+}
+
+// Service groups the containers that belong to one Compose service.
+type Service struct {
+	Name       string      `json:"name"`
+	Containers []Container `json:"containers"`
+}
+
+// Project is a single Compose project. Status / ConfigFiles / WorkingDir
+// come from `docker compose ls` and are refined from container labels
+// (which carry the exact absolute paths the project was deployed with).
+type Project struct {
+	Name        string             `json:"name"`
+	Status      string             `json:"status"`
+	ConfigFiles string             `json:"config_files"`
+	WorkingDir  string             `json:"working_dir"`
+	Services    map[string]Service `json:"services"`
+}
+
+// DiscoveryResult is the full snapshot returned by Discover. Projects
+// is keyed by project name. Errors carries non-fatal section failures
+// (e.g. docker unavailable) so the UI can render a banner instead of a
+// blank page.
+type DiscoveryResult struct {
+	Projects map[string]Project `json:"projects"`
+	Errors   []string           `json:"errors,omitempty"`
+}
+
+// ProjectRefs resolves the Compose CLI arguments a management operation
+// needs for a project: the working directory (so Compose auto-discovers
+// the compose file and `.env`) and the project name (pinned with -p so
+// the operation targets exactly this project even when the directory
+// basename differs).
+type ProjectRefs struct {
+	Name        string
+	WorkingDir  string
+	ConfigFiles string
+}
diff --git a/internal/webapp/compose.go b/internal/webapp/compose.go
new file mode 100644
index 0000000..c810e80
--- /dev/null
+++ b/internal/webapp/compose.go
@@ -0,0 +1,176 @@
+package webapp
+
+import (
+	"context"
+	"log"
+	"os"
+	"sync"
+	"time"
+
+	"rocketgit.ru/rsmon/worker/internal/compose"
+)
+
+// composeRefreshInterval is the cadence the Compose project snapshot is
+// rebuilt. 60s matches the process-inventory loop so the two collectors
+// stay in step; management operations are synchronous POSTs that do not
+// wait for the next tick.
+const composeRefreshInterval = 60 * time.Second
+
+// ComposeRefresher owns the periodic `docker compose` discovery loop and
+// exposes the latest snapshot for the operator console. It is the
+// Compose counterpart of Inventory: a self-contained refresher that is
+// constructed in New and started in Start.
+//
+// When Docker is unavailable (or compose is disabled) the refresher
+// stays idle and reports an empty snapshot with an "unavailable" error,
+// so /compose renders a banner instead of a half-built table.
+type ComposeRefresher struct {
+	enabled bool
+	log     *log.Logger
+	disco   composeDiscover
+
+	mu      sync.RWMutex
+	snap    compose.Snapshot
+	lastAt  time.Time
+	stopCh  chan struct{}
+	stopWG  sync.WaitGroup
+	started bool
+}
+
+// composeDiscover is the subset of the compose package the refresher
+// calls. A function field so tests can inject a fixture discovery
+// without exec'ing Docker.
+type composeDiscover func(ctx context.Context) (*compose.DiscoveryResult, error)
+
+// NewComposeRefresher constructs a refresher. When enabled is false the
+// refresher never starts its loop and Snapshot returns an empty result;
+// the page still renders (with the disabled notice). The discover hook
+// defaults to compose.Discover so production needs no wiring.
+func NewComposeRefresher(enabled bool, logger *log.Logger) *ComposeRefresher {
+	if logger == nil {
+		logger = log.New(os.Stderr, "webapp-compose: ", log.LstdFlags)
+	}
+	return &ComposeRefresher{
+		enabled: enabled,
+		log:     logger,
+		disco:   compose.Discover,
+		stopCh:  make(chan struct{}),
+	}
+}
+
+// SetDiscovery injects the discovery function. Used by tests; production
+// leaves the compose.Discover default set by NewComposeRefresher.
+func (c *ComposeRefresher) SetDiscovery(f composeDiscover) {
+	if f == nil {
+		c.disco = compose.Discover
+		return
+	}
+	c.disco = f
+}
+
+// Enabled reports whether compose management is turned on.
+func (c *ComposeRefresher) Enabled() bool { return c != nil && c.enabled }
+
+// Start launches the background refresh loop. The first refresh runs
+// immediately so /compose has data on the first request. Idempotent.
+func (c *ComposeRefresher) Start(ctx context.Context) {
+	if c == nil || !c.enabled {
+		return
+	}
+	c.mu.Lock()
+	if c.started {
+		c.mu.Unlock()
+		return
+	}
+	c.started = true
+	c.mu.Unlock()
+
+	c.stopWG.Add(1)
+	go c.loop(ctx)
+}
+
+// Stop cancels the refresh loop and waits for it to exit.
+func (c *ComposeRefresher) Stop() {
+	if c == nil {
+		return
+	}
+	c.mu.Lock()
+	if !c.started {
+		c.mu.Unlock()
+		return
+	}
+	select {
+	case <-c.stopCh:
+	default:
+		close(c.stopCh)
+	}
+	c.mu.Unlock()
+	c.stopWG.Wait()
+}
+
+// Snapshot returns the most recent discovery snapshot. Always safe to
+// call; returns a zero-value snapshot (with the unavailable notice when
+// disabled) before the first refresh completes.
+func (c *ComposeRefresher) Snapshot() compose.Snapshot {
+	if c == nil {
+		return compose.Snapshot{Errors: []string{"compose management is disabled"}}
+	}
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+	if !c.enabled {
+		return compose.Snapshot{Errors: []string{"compose management is disabled"}}
+	}
+	out := c.snap
+	out.Projects = append([]compose.ProjectSummary(nil), c.snap.Projects...)
+	return out
+}
+
+// LastRefreshAt returns the time of the last successful refresh.
+func (c *ComposeRefresher) LastRefreshAt() time.Time {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+	return c.lastAt
+}
+
+// Refs resolves the Compose CLI arguments for a project from the current
+// snapshot. Returns ok=false when the project is unknown or its working
+// directory was not discovered (Compose cannot be driven without it).
+func (c *ComposeRefresher) Refs(project string) (compose.ProjectRefs, bool) {
+	p := c.Snapshot().FindProject(project)
+	if p == nil || p.WorkingDir == "" {
+		return compose.ProjectRefs{}, false
+	}
+	return compose.ProjectRefs{Name: p.Name, WorkingDir: p.WorkingDir, ConfigFiles: p.ConfigFiles}, true
+}
+
+func (c *ComposeRefresher) loop(ctx context.Context) {
+	defer c.stopWG.Done()
+	c.refresh(ctx)
+	t := time.NewTicker(composeRefreshInterval)
+	defer t.Stop()
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		case <-c.stopCh:
+			return
+		case <-t.C:
+			c.refresh(ctx)
+		}
+	}
+}
+
+func (c *ComposeRefresher) refresh(ctx context.Context) {
+	res, err := c.disco(ctx)
+	if err != nil || res == nil {
+		if err != nil {
+			c.log.Printf("compose refresh: %v", err)
+		}
+		return
+	}
+	snap := compose.Summarize(res)
+	c.mu.Lock()
+	c.snap = snap
+	c.lastAt = time.Now().UTC()
+	c.mu.Unlock()
+}
diff --git a/internal/webapp/constants.go b/internal/webapp/constants.go
index 8b541d7..2dcdb3a 100644
--- a/internal/webapp/constants.go
+++ b/internal/webapp/constants.go
@@ -30,6 +30,7 @@ const (
 	envWorkerLogin    = "WORKER_LOGIN"
 	envWorkerPassword = "WORKER_PASSWORD"
 	envClusterEnabled = "WORKER_CLUSTER_ENABLED"
+	envComposeEnabled = "WORKER_COMPOSE_ENABLED"
 	envReleaseURL     = "WORKER_RELEASE_URL"
 )
 
diff --git a/internal/webapp/handlers_compose.go b/internal/webapp/handlers_compose.go
new file mode 100644
index 0000000..3ca5c84
--- /dev/null
+++ b/internal/webapp/handlers_compose.go
@@ -0,0 +1,280 @@
+package webapp
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"rocketgit.ru/rsmon/worker/internal/compose"
+)
+
+// Compose management endpoints. The browser UI uses session+CSRF POSTs
+// under /compose; scripted callers use HTTP basic auth under
+// /web/api/compose (the /web/api/* prefix is the only one the auth
+// middleware accepts basic credentials on). Read endpoints (list,
+// detail, logs) are GET; lifecycle endpoints (up/down/stop/restart/pull)
+// are POST. Every mutation is audited via the shared webapp audit log.
+
+// handleComposeList renders the project list page.
+func (s *Server) handleComposeList(w http.ResponseWriter, r *http.Request) {
+	writeNoStore(w)
+	sess, _ := sessionFromContext(r.Context())
+	data := composeListPageData{
+		basePageData: s.newBasePage(r, "Compose projects", sess),
+		Snapshot:     s.compose.Snapshot(),
+		LastAt:       s.compose.LastRefreshAt(),
+		Enabled:      s.compose.Enabled(),
+	}
+	if err := s.templates.Execute(w, "compose.html", data); err != nil {
+		s.deps.Logger.Printf("render compose: %v", err)
+		http.Error(w, "template error", http.StatusInternalServerError)
+	}
+}
+
+// handleComposeDetail renders one project with its services, containers,
+// mounts, and Traefik routes, plus the management action buttons.
+func (s *Server) handleComposeDetail(w http.ResponseWriter, r *http.Request) {
+	writeNoStore(w)
+	sess, _ := sessionFromContext(r.Context())
+	snap := s.compose.Snapshot()
+	p := snap.FindProject(r.PathValue("project"))
+	if p == nil {
+		http.NotFound(w, r)
+		return
+	}
+	data := composeDetailPageData{
+		basePageData:  s.newBasePage(r, "Compose: "+p.Name, sess),
+		Project:       p,
+		LastAt:        s.compose.LastRefreshAt(),
+		ActionOK:      r.URL.Query().Get("ok") == "1",
+		ActionMessage: r.URL.Query().Get("msg"),
+	}
+	if err := s.templates.Execute(w, "compose_detail.html", data); err != nil {
+		s.deps.Logger.Printf("render compose detail: %v", err)
+		http.Error(w, "template error", http.StatusInternalServerError)
+	}
+}
+
+// handleComposeLogsPage renders the recent log tail for a project in a
+// 
 block so an operator can read it from the browser without curl.
+func (s *Server) handleComposeLogsPage(w http.ResponseWriter, r *http.Request) {
+	writeNoStore(w)
+	sess, _ := sessionFromContext(r.Context())
+	project := r.PathValue("project")
+	res, status := s.composeLogsResult(r.Context(), project)
+	if status != http.StatusOK {
+		http.Error(w, res.Output, status)
+		return
+	}
+	data := composeLogsPageData{
+		basePageData: s.newBasePage(r, "Compose logs: "+project, sess),
+		Project:      project,
+		Output:       res.Output,
+	}
+	if err := s.templates.Execute(w, "compose_logs.html", data); err != nil {
+		s.deps.Logger.Printf("render compose logs: %v", err)
+		http.Error(w, "template error", http.StatusInternalServerError)
+	}
+}
+
+// handleComposeProjectAction runs a lifecycle action against a project
+// from the HTML form (session+CSRF) or the API (basic auth). The
+// response is a redirect for the browser path and JSON for the API path.
+func (s *Server) handleComposeProjectAction(w http.ResponseWriter, r *http.Request) {
+	if !s.compose.Enabled() {
+		http.Error(w, "compose management is disabled", http.StatusServiceUnavailable)
+		return
+	}
+	if !validProjectPostAction(r.PathValue("action")) {
+		http.NotFound(w, r)
+		return
+	}
+	if !s.composeMutationAuthorized(w, r) {
+		return
+	}
+	project := r.PathValue("project")
+	action := r.PathValue("action")
+	refs, ok := s.compose.Refs(project)
+	if !ok {
+		http.Error(w, "compose project working directory is unknown", http.StatusNotFound)
+		return
+	}
+	res, _ := compose.ManageProject(r.Context(), refs, compose.Action(action))
+	s.auditCompose(r, action, project, "")
+	s.respondComposeAction(w, r, project, res)
+}
+
+// handleComposeServiceAction runs a lifecycle action against a single
+// service inside a project.
+func (s *Server) handleComposeServiceAction(w http.ResponseWriter, r *http.Request) {
+	if !s.compose.Enabled() {
+		http.Error(w, "compose management is disabled", http.StatusServiceUnavailable)
+		return
+	}
+	if _, ok := compose.ValidServiceAction(r.PathValue("action")); !ok {
+		http.NotFound(w, r)
+		return
+	}
+	if !s.composeMutationAuthorized(w, r) {
+		return
+	}
+	project := r.PathValue("project")
+	service := r.PathValue("service")
+	action := r.PathValue("action")
+	refs, ok := s.compose.Refs(project)
+	if !ok {
+		http.Error(w, "compose project working directory is unknown", http.StatusNotFound)
+		return
+	}
+	res, _ := compose.ManageService(r.Context(), refs, service, compose.Action(action))
+	s.auditCompose(r, action, project, service)
+	s.respondComposeAction(w, r, project, res)
+}
+
+// handleComposeAPIList returns the full discovery snapshot as JSON. The
+// shape matches the /compose HTML page (a Snapshot: projects + errors).
+func (s *Server) handleComposeAPIList(w http.ResponseWriter, r *http.Request) {
+	writeComposeJSON(w, s.compose.Snapshot())
+}
+
+// handleComposeAPIDetail returns one project summary as JSON.
+func (s *Server) handleComposeAPIDetail(w http.ResponseWriter, r *http.Request) {
+	p := s.compose.Snapshot().FindProject(r.PathValue("project"))
+	if p == nil {
+		http.Error(w, "compose project not found", http.StatusNotFound)
+		return
+	}
+	writeComposeJSON(w, p)
+}
+
+// handleComposeLogsAPI returns the recent log tail for a project as JSON.
+func (s *Server) handleComposeLogsAPI(w http.ResponseWriter, r *http.Request) {
+	res, status := s.composeLogsResult(r.Context(), r.PathValue("project"))
+	if status != http.StatusOK {
+		w.Header().Set("Content-Type", "application/json; charset=utf-8")
+		w.WriteHeader(status)
+		_ = json.NewEncoder(w).Encode(res)
+		return
+	}
+	writeComposeJSON(w, res)
+}
+
+// composeLogsResult resolves the log tail for a project and the HTTP
+// status the caller should write. A 404 means the project (or its
+// working directory) is unknown; 502 means Docker refused the call.
+func (s *Server) composeLogsResult(ctx context.Context, project string) (compose.ManagementResult, int) {
+	refs, ok := s.compose.Refs(project)
+	if !ok {
+		return compose.ManagementResult{OK: false, Action: "logs", Output: "compose project working directory is unknown"}, http.StatusNotFound
+	}
+	res, err := compose.Logs(ctx, refs, 300)
+	if err != nil && res.Output == "" {
+		return res, http.StatusBadGateway
+	}
+	return res, http.StatusOK
+}
+
+// composeMutationAuthorized enforces CSRF for browser (session) callers
+// while letting basic-auth API callers through without a token (the
+// middleware already validated their credentials). Returns true when the
+// request may proceed; writes a 403 and returns false otherwise.
+func (s *Server) composeMutationAuthorized(w http.ResponseWriter, r *http.Request) bool {
+	sess, hasSess := sessionFromContext(r.Context())
+	if !hasSess {
+		return true // basic-auth API path; middleware authenticated it
+	}
+	if !s.requireCSRF(sess, r) {
+		http.Error(w, "csrf token required", http.StatusForbidden)
+		return false
+	}
+	return true
+}
+
+// validProjectPostAction reports whether name is a POST-able project
+// action. It reuses compose.ValidProjectAction but excludes "logs",
+// which is served by a dedicated GET route.
+func validProjectPostAction(name string) bool {
+	if name == string(compose.ActionLogs) {
+		return false
+	}
+	_, ok := compose.ValidProjectAction(name)
+	return ok
+}
+
+// respondComposeAction sends the action result to the right consumer:
+// JSON for /web/api/* (scripting), a redirect with a flash banner for
+// the browser path. The action's combined output is URL-encoded into the
+// banner so the operator sees what Compose printed without a separate
+// request; very large output is truncated to keep the URL bounded.
+func (s *Server) respondComposeAction(w http.ResponseWriter, r *http.Request, project string, res compose.ManagementResult) {
+	if strings.HasPrefix(r.URL.Path, "/web/api/") {
+		writeComposeJSON(w, res)
+		return
+	}
+	msg := strings.TrimSpace(res.Output)
+	if len(msg) > 500 {
+		msg = msg[:500] + "…"
+	}
+	if !res.OK && msg == "" {
+		msg = res.Action + " failed"
+	}
+	loc := fmt.Sprintf("/compose/%s?ok=%t&msg=%s", project, res.OK, url.QueryEscape(msg))
+	http.Redirect(w, r, loc, http.StatusSeeOther)
+}
+
+// writeComposeJSON serializes a value as no-store JSON. Used by every
+// /web/api/compose endpoint so scripted callers get one consistent shape.
+func writeComposeJSON(w http.ResponseWriter, v interface{}) {
+	writeNoStore(w)
+	w.Header().Set("Content-Type", "application/json; charset=utf-8")
+	_ = json.NewEncoder(w).Encode(v)
+}
+
+// auditCompose records a management action in the worker audit log. The
+// target encodes project and service so the audit trail reconstructs the
+// exact scope (e.g. "compose:rsmon/web"); failures are still audited so
+// an operator can trace a stopped service back to the action.
+func (s *Server) auditCompose(r *http.Request, action, project, service string) {
+	target := "compose:" + project
+	if service != "" {
+		target += "/" + service
+	}
+	authMode := auditAuthModeBasic
+	if _, hasSess := sessionFromContext(r.Context()); hasSess {
+		authMode = auditAuthModeLocal
+	}
+	_ = s.store.WriteAudit(r.Context(), &AuditEntry{
+		Actor:    auditActorLocal,
+		Role:     auditRoleAdmin,
+		AuthMode: authMode,
+		IP:       clientIP(r),
+		UA:       r.UserAgent(),
+		Action:   "compose_" + action,
+		Target:   target,
+	})
+}
+
+type composeListPageData struct {
+	basePageData
+	Snapshot compose.Snapshot
+	LastAt   time.Time
+	Enabled  bool
+}
+
+type composeDetailPageData struct {
+	basePageData
+	Project       *compose.ProjectSummary
+	LastAt        time.Time
+	ActionOK      bool
+	ActionMessage string
+}
+
+type composeLogsPageData struct {
+	basePageData
+	Project string
+	Output  string
+}
diff --git a/internal/webapp/handlers_compose_test.go b/internal/webapp/handlers_compose_test.go
new file mode 100644
index 0000000..90e32d8
--- /dev/null
+++ b/internal/webapp/handlers_compose_test.go
@@ -0,0 +1,417 @@
+package webapp
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"rocketgit.ru/rsmon/worker/internal/compose"
+)
+
+// newComposeTestServer builds a session-auth server with the Compose
+// subsystem enabled. The default newTestServer leaves ComposeEnabled
+// false (zero-value Config), so the handlers would short-circuit to the
+// disabled banner; flipping the refresher flag on exercises the real
+// code paths without starting the background loop.
+func newComposeTestServer(t *testing.T, runner WorkerView) *Server {
+	t.Helper()
+	srv := newTestServer(t, runner)
+	srv.compose.enabled = true
+	return srv
+}
+
+// newComposeTestServerBasicAuth is the basic-auth variant used by the
+// /web/api/compose/* tests: those routes accept HTTP basic credentials
+// in lieu of a session cookie.
+func newComposeTestServerBasicAuth(t *testing.T, runner WorkerView, login, password string) *Server {
+	t.Helper()
+	srv := newTestServerWithBasicAuth(t, runner, login, password)
+	srv.compose.enabled = true
+	return srv
+}
+
+// injectComposeSnapshot seeds the refresher with a single "rsmon"
+// project so the list/detail/logs handlers have something to render
+// without exec'ing Docker. The project's WorkingDir is a real temp dir
+// so management operations (which chdir into it) succeed under a stub
+// Docker binary.
+func injectComposeSnapshot(t *testing.T, srv *Server, workDir string) {
+	t.Helper()
+	res := &compose.DiscoveryResult{
+		Projects: map[string]compose.Project{
+			"rsmon": {
+				Name:        "rsmon",
+				Status:      "running(1)",
+				WorkingDir:  workDir,
+				ConfigFiles: filepath.Join(workDir, "docker-compose.yml"),
+				Services: map[string]compose.Service{
+					"web": {Name: "web", Containers: []compose.Container{{
+						ID:     "c1",
+						Name:   "rsmon-web-1",
+						Image:  "nginx:latest",
+						State:  "running",
+						Status: "Up 5 minutes",
+						Health: "healthy",
+						PID:    4242,
+						Ports:  []compose.PortBinding{{HostIP: "0.0.0.0", HostPort: "8080"}},
+						Mounts: []compose.Mount{{Source: filepath.Join(workDir, "data"), Destination: "/data", Type: "bind"}},
+						Labels: map[string]string{
+							compose.LabelComposeProject: "rsmon",
+							compose.LabelComposeService: "web",
+						},
+					}}},
+				},
+			},
+		},
+	}
+	snap := compose.Summarize(res)
+	srv.compose.mu.Lock()
+	srv.compose.snap = snap
+	srv.compose.lastAt = time.Now().UTC()
+	srv.compose.mu.Unlock()
+}
+
+// stubDockerOK points the compose package at an executable stub that
+// echoes its arguments and exits 0, so management/logs handlers run the
+// full exec path without a real Docker daemon. Restored on cleanup.
+func stubDockerOK(t *testing.T) {
+	t.Helper()
+	dir := t.TempDir()
+	p := filepath.Join(dir, "stub-docker")
+	script := "#!/bin/sh\necho \"stub: $*\"\nexit 0\n"
+	require.NoError(t, os.WriteFile(p, []byte(script), 0o755))
+	compose.SetDockerBin(p)
+	t.Cleanup(func() { compose.SetDockerBin("") })
+}
+
+// stubDockerFail points at a stub that exits non-zero with a stderr
+// message, so the !OK management path is observable.
+func stubDockerFail(t *testing.T) {
+	t.Helper()
+	dir := t.TempDir()
+	p := filepath.Join(dir, "stub-docker")
+	script := "#!/bin/sh\necho \"compose boom\" >&2\nexit 1\n"
+	require.NoError(t, os.WriteFile(p, []byte(script), 0o755))
+	compose.SetDockerBin(p)
+	t.Cleanup(func() { compose.SetDockerBin("") })
+}
+
+// auditHasAction reports whether the audit log contains a row matching
+// action and a target substring (e.g. "compose_restart" / "rsmon").
+func auditHasAction(t *testing.T, srv *Server, action, targetSub string) bool {
+	t.Helper()
+	rows, err := srv.store.RecentAudit(context.Background(), 10)
+	require.NoError(t, err)
+	for _, r := range rows {
+		if r.Action == action && strings.Contains(r.Target, targetSub) {
+			return true
+		}
+	}
+	return false
+}
+
+func TestComposeList_RendersProjects(t *testing.T) {
+	srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
+	injectComposeSnapshot(t, srv, t.TempDir())
+	ts := newHTTPTestServer(t, srv)
+
+	c, _ := loginAsFirstRun(t, ts.URL, srv)
+	resp, err := c.Get(ts.URL + "/compose")
+	require.NoError(t, err)
+	body := mustBody(t, resp)
+	assert.Contains(t, body, "Docker Compose projects")
+	assert.Contains(t, body, "rsmon")
+	assert.Contains(t, body, "details") // link to the detail page
+}
+
+func TestComposeList_DisabledBanner(t *testing.T) {
+	// Default server leaves Compose disabled.
+	srv := newTestServer(t, &stubRunner{id: "w-1"})
+	ts := newHTTPTestServer(t, srv)
+
+	c, _ := loginAsFirstRun(t, ts.URL, srv)
+	resp, err := c.Get(ts.URL + "/compose")
+	require.NoError(t, err)
+	body := mustBody(t, resp)
+	assert.Contains(t, body, "Compose management is disabled")
+}
+
+func TestComposeDetail_FoundAndNotFound(t *testing.T) {
+	srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
+	injectComposeSnapshot(t, srv, t.TempDir())
+	ts := newHTTPTestServer(t, srv)
+
+	c, _ := loginAsFirstRun(t, ts.URL, srv)
+
+	resp, err := c.Get(ts.URL + "/compose/rsmon")
+	require.NoError(t, err)
+	body := mustBody(t, resp)
+	assert.Contains(t, body, "Project actions")
+	assert.Contains(t, body, "rsmon-web-1")
+
+	resp2, err := c.Get(ts.URL + "/compose/missing")
+	require.NoError(t, err)
+	defer resp2.Body.Close() //nolint:errcheck
+	assert.Equal(t, http.StatusNotFound, resp2.StatusCode)
+}
+
+func TestComposeAPIList_JSON(t *testing.T) {
+	srv := newComposeTestServerBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
+	injectComposeSnapshot(t, srv, t.TempDir())
+	ts := newHTTPTestServer(t, srv)
+
+	req, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/compose", nil)
+	req.SetBasicAuth("alice", "s3cret")
+	resp, err := http.DefaultClient.Do(req)
+	require.NoError(t, err)
+	defer resp.Body.Close() //nolint:errcheck
+	require.Equal(t, http.StatusOK, resp.StatusCode)
+
+	var snap compose.Snapshot
+	require.NoError(t, json.NewDecoder(resp.Body).Decode(&snap))
+	require.Len(t, snap.Projects, 1)
+	assert.Equal(t, "rsmon", snap.Projects[0].Name)
+}
+
+func TestComposeAPIDetail_JSONAndNotFound(t *testing.T) {
+	srv := newComposeTestServerBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
+	injectComposeSnapshot(t, srv, t.TempDir())
+	ts := newHTTPTestServer(t, srv)
+
+	req, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/compose/rsmon", nil)
+	req.SetBasicAuth("alice", "s3cret")
+	resp, err := http.DefaultClient.Do(req)
+	require.NoError(t, err)
+	defer resp.Body.Close() //nolint:errcheck
+	require.Equal(t, http.StatusOK, resp.StatusCode)
+	var p compose.ProjectSummary
+	require.NoError(t, json.NewDecoder(resp.Body).Decode(&p))
+	assert.Equal(t, "rsmon", p.Name)
+
+	req2, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/compose/missing", nil)
+	req2.SetBasicAuth("alice", "s3cret")
+	resp2, err := http.DefaultClient.Do(req2)
+	require.NoError(t, err)
+	defer resp2.Body.Close() //nolint:errcheck
+	assert.Equal(t, http.StatusNotFound, resp2.StatusCode)
+}
+
+func TestComposeProjectAction_RequiresCSRF(t *testing.T) {
+	srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
+	injectComposeSnapshot(t, srv, t.TempDir())
+	ts := newHTTPTestServer(t, srv)
+
+	c, _ := loginAsFirstRun(t, ts.URL, srv)
+	form := url.Values{}
+	form.Set("csrf_token", "") // missing token
+	req, _ := http.NewRequest(http.MethodPost, ts.URL+"/compose/rsmon/restart", strings.NewReader(form.Encode()))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	resp, err := c.Do(req)
+	require.NoError(t, err)
+	defer resp.Body.Close() //nolint:errcheck
+	assert.Equal(t, http.StatusForbidden, resp.StatusCode, "POST without CSRF must be 403")
+}
+
+func TestComposeProjectAction_HTMLRedirectAndAudit(t *testing.T) {
+	srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
+	injectComposeSnapshot(t, srv, t.TempDir())
+	stubDockerOK(t)
+	ts := newHTTPTestServer(t, srv)
+
+	c, _ := loginAsFirstRun(t, ts.URL, srv)
+
+	// Pull the CSRF token out of the detail page's action forms.
+	detail, err := c.Get(ts.URL + "/compose/rsmon")
+	require.NoError(t, err)
+	csrf := extractCSRFToken(t, mustBody(t, detail))
+
+	form := url.Values{}
+	form.Set("csrf_token", csrf)
+	req, _ := http.NewRequest(http.MethodPost, ts.URL+"/compose/rsmon/restart", strings.NewReader(form.Encode()))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	resp, err := c.Do(req)
+	require.NoError(t, err)
+	defer resp.Body.Close() //nolint:errcheck
+	assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
+	loc := resp.Header.Get("Location")
+	assert.True(t, strings.HasPrefix(loc, "/compose/rsmon?ok=true"), "redirect location=%q", loc)
+
+	assert.True(t, auditHasAction(t, srv, "compose_restart", "compose:rsmon"),
+		"audit row for project restart expected")
+}
+
+func TestComposeProjectAction_APIBasicAuthAndAudit(t *testing.T) {
+	srv := newComposeTestServerBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
+	injectComposeSnapshot(t, srv, t.TempDir())
+	stubDockerOK(t)
+	ts := newHTTPTestServer(t, srv)
+
+	req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/api/compose/rsmon/restart", nil)
+	req.SetBasicAuth("alice", "s3cret")
+	resp, err := http.DefaultClient.Do(req)
+	require.NoError(t, err)
+	defer resp.Body.Close() //nolint:errcheck
+	require.Equal(t, http.StatusOK, resp.StatusCode)
+
+	var res compose.ManagementResult
+	require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
+	assert.True(t, res.OK)
+	assert.Contains(t, res.Output, "stub:")
+
+	assert.True(t, auditHasAction(t, srv, "compose_restart", "compose:rsmon"),
+		"audit row for API restart expected")
+}
+
+func TestComposeProjectAction_FailureReportsNotOK(t *testing.T) {
+	srv := newComposeTestServerBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
+	injectComposeSnapshot(t, srv, t.TempDir())
+	stubDockerFail(t)
+	ts := newHTTPTestServer(t, srv)
+
+	req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/api/compose/rsmon/stop", nil)
+	req.SetBasicAuth("alice", "s3cret")
+	resp, err := http.DefaultClient.Do(req)
+	require.NoError(t, err)
+	defer resp.Body.Close() //nolint:errcheck
+	require.Equal(t, http.StatusOK, resp.StatusCode)
+
+	var res compose.ManagementResult
+	require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
+	assert.False(t, res.OK)
+	assert.Contains(t, res.Output, "compose boom")
+}
+
+func TestComposeProjectAction_UnknownActionRejected(t *testing.T) {
+	srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
+	injectComposeSnapshot(t, srv, t.TempDir())
+	ts := newHTTPTestServer(t, srv)
+
+	c, _ := loginAsFirstRun(t, ts.URL, srv)
+	for _, action := range []string{"bogus", "logs"} { // logs is GET-only
+		req, _ := http.NewRequest(http.MethodPost, ts.URL+"/compose/rsmon/"+action, strings.NewReader(""))
+		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+		resp, err := c.Do(req)
+		require.NoError(t, err)
+		assert.Equal(t, http.StatusNotFound, resp.StatusCode, "action %q should 404", action)
+		resp.Body.Close() //nolint:errcheck
+	}
+}
+
+func TestComposeProjectAction_DisabledReturns503(t *testing.T) {
+	srv := newTestServer(t, &stubRunner{id: "w-1"}) // compose disabled
+	injectComposeSnapshot(t, srv, t.TempDir())
+	ts := newHTTPTestServer(t, srv)
+
+	c, _ := loginAsFirstRun(t, ts.URL, srv)
+	req, _ := http.NewRequest(http.MethodPost, ts.URL+"/compose/rsmon/restart", strings.NewReader(""))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	resp, err := c.Do(req)
+	require.NoError(t, err)
+	defer resp.Body.Close() //nolint:errcheck
+	assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
+}
+
+func TestComposeServiceAction_HTMLRedirectAndAudit(t *testing.T) {
+	srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
+	injectComposeSnapshot(t, srv, t.TempDir())
+	stubDockerOK(t)
+	ts := newHTTPTestServer(t, srv)
+
+	c, _ := loginAsFirstRun(t, ts.URL, srv)
+	detail, err := c.Get(ts.URL + "/compose/rsmon")
+	require.NoError(t, err)
+	csrf := extractCSRFToken(t, mustBody(t, detail))
+
+	form := url.Values{}
+	form.Set("csrf_token", csrf)
+	req, _ := http.NewRequest(http.MethodPost, ts.URL+"/compose/rsmon/service/web/restart", strings.NewReader(form.Encode()))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	resp, err := c.Do(req)
+	require.NoError(t, err)
+	defer resp.Body.Close() //nolint:errcheck
+	assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
+	assert.True(t, strings.HasPrefix(resp.Header.Get("Location"), "/compose/rsmon"))
+	assert.True(t, auditHasAction(t, srv, "compose_restart", "compose:rsmon/web"),
+		"audit row for service restart expected")
+}
+
+func TestComposeServiceAction_UnknownActionRejected(t *testing.T) {
+	srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
+	injectComposeSnapshot(t, srv, t.TempDir())
+	ts := newHTTPTestServer(t, srv)
+
+	c, _ := loginAsFirstRun(t, ts.URL, srv)
+	req, _ := http.NewRequest(http.MethodPost, ts.URL+"/compose/rsmon/service/web/bogus", strings.NewReader(""))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	resp, err := c.Do(req)
+	require.NoError(t, err)
+	defer resp.Body.Close() //nolint:errcheck
+	assert.Equal(t, http.StatusNotFound, resp.StatusCode)
+}
+
+func TestComposeLogs_HTMLPage(t *testing.T) {
+	srv := newComposeTestServer(t, &stubRunner{id: "w-1"})
+	injectComposeSnapshot(t, srv, t.TempDir())
+	stubDockerOK(t)
+	ts := newHTTPTestServer(t, srv)
+
+	c, _ := loginAsFirstRun(t, ts.URL, srv)
+	resp, err := c.Get(ts.URL + "/compose/rsmon/logs")
+	require.NoError(t, err)
+	body := mustBody(t, resp)
+	assert.Contains(t, body, "Compose logs: rsmon")
+	assert.Contains(t, body, "stub:")
+}
+
+func TestComposeLogs_API_JSONAndNotFound(t *testing.T) {
+	srv := newComposeTestServerBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
+	injectComposeSnapshot(t, srv, t.TempDir())
+	stubDockerOK(t)
+	ts := newHTTPTestServer(t, srv)
+
+	req, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/compose/rsmon/logs", nil)
+	req.SetBasicAuth("alice", "s3cret")
+	resp, err := http.DefaultClient.Do(req)
+	require.NoError(t, err)
+	defer resp.Body.Close() //nolint:errcheck
+	require.Equal(t, http.StatusOK, resp.StatusCode)
+	var res compose.ManagementResult
+	require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
+	assert.Contains(t, res.Output, "stub:")
+
+	req2, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/compose/missing/logs", nil)
+	req2.SetBasicAuth("alice", "s3cret")
+	resp2, err := http.DefaultClient.Do(req2)
+	require.NoError(t, err)
+	defer resp2.Body.Close() //nolint:errcheck
+	assert.Equal(t, http.StatusNotFound, resp2.StatusCode)
+}
+
+func TestValidProjectPostAction(t *testing.T) {
+	for _, a := range []string{"up", "down", "stop", "restart", "pull"} {
+		assert.True(t, validProjectPostAction(a), "%q should be POST-able", a)
+	}
+	assert.False(t, validProjectPostAction("logs"), "logs is GET-only")
+	assert.False(t, validProjectPostAction("bogus"), "bogus is invalid")
+}
+
+func TestComposeAPIList_RequiresAuth(t *testing.T) {
+	srv := newComposeTestServerBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
+	injectComposeSnapshot(t, srv, t.TempDir())
+	ts := newHTTPTestServer(t, srv)
+
+	// No credentials: the basic-auth fast path returns 401.
+	resp, err := http.Get(ts.URL + "/web/api/compose")
+	require.NoError(t, err)
+	defer resp.Body.Close() //nolint:errcheck
+	assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
+}
diff --git a/internal/webapp/routes.go b/internal/webapp/routes.go
index d68bd31..63770f3 100644
--- a/internal/webapp/routes.go
+++ b/internal/webapp/routes.go
@@ -51,6 +51,23 @@ func (s *Server) routes() {
 	s.mux.Handle("POST /settings/rotate-token", s.requireSession(s.handleRotateToken))
 	s.mux.Handle("GET /updates", s.requireSession(s.handleUpdates))
 
+	// Docker Compose discovery + management. The list/detail/logs pages
+	// are session-protected HTML; the lifecycle endpoints accept either a
+	// session (browser, CSRF-checked in the handler) or HTTP basic auth
+	// (scripting, on the /web/api/compose/* prefix). Compose is disabled
+	// per host via WORKER_COMPOSE_ENABLED=false; when off, the management
+	// handlers return 503 and the list page renders a banner.
+	s.mux.Handle("GET /compose", s.requireSession(s.handleComposeList))
+	s.mux.Handle("GET /compose/{project}", s.requireSession(s.handleComposeDetail))
+	s.mux.Handle("GET /compose/{project}/logs", s.requireSession(s.handleComposeLogsPage))
+	s.mux.Handle("POST /compose/{project}/{action}", s.requireSession(s.handleComposeProjectAction))
+	s.mux.Handle("POST /compose/{project}/service/{service}/{action}", s.requireSession(s.handleComposeServiceAction))
+	s.mux.Handle("GET /web/api/compose", s.requireSession(s.handleComposeAPIList))
+	s.mux.Handle("GET /web/api/compose/{project}", s.requireSession(s.handleComposeAPIDetail))
+	s.mux.Handle("GET /web/api/compose/{project}/logs", s.requireSession(s.handleComposeLogsAPI))
+	s.mux.Handle("POST /web/api/compose/{project}/{action}", s.requireSession(s.handleComposeProjectAction))
+	s.mux.Handle("POST /web/api/compose/{project}/service/{service}/{action}", s.requireSession(s.handleComposeServiceAction))
+
 	// Health endpoint for the cmd health subcommand and for the
 	// operator to confirm the listener is up without going through the
 	// login form. Returns 200 with a tiny body.
diff --git a/internal/webapp/server.go b/internal/webapp/server.go
index 93d3a6d..4508639 100644
--- a/internal/webapp/server.go
+++ b/internal/webapp/server.go
@@ -87,6 +87,14 @@ type Config struct {
 	// "tag_name" field (GitHub release JSON is the canonical
 	// shape). WORKER_RELEASE_URL sets this.
 	ReleaseURL string
+
+	// ComposeEnabled turns the Docker Compose discovery + management
+	// subsystem on. Defaults to true (WORKER_COMPOSE_ENABLED=false to
+	// disable): the refresher no-ops when the Docker daemon is absent,
+	// so leaving it on is safe on non-Docker hosts. When enabled, the
+	// /compose page lists every Compose project on the host and the
+	// /web/api/compose/* endpoints drive up/down/stop/restart/pull.
+	ComposeEnabled bool
 }
 
 // ValidateBasicAuth enforces that WORKER_LOGIN and WORKER_PASSWORD
@@ -177,9 +185,23 @@ func ConfigFromEnv(env map[string]string, defaultDataDir string) (Config, error)
 		cfg.StorePath = v
 	}
 	cfg.ReleaseURL = strings.TrimSpace(env[envReleaseURL])
+	// Compose discovery defaults to on; only an explicit false disables it.
+	cfg.ComposeEnabled = !parseBoolFalseDefault(env[envComposeEnabled])
 	return cfg, nil
 }
 
+// parseBoolFalseDefault returns true for any value that is not an
+// explicit falsy literal. Used by ComposeEnabled so that the default
+// (unset env var) keeps the feature on, unlike parseBool where the
+// default is false.
+func parseBoolFalseDefault(v string) bool {
+	switch strings.ToLower(strings.TrimSpace(v)) {
+	case "false", "0", "no", "off", "":
+		return false
+	}
+	return true
+}
+
 // parseBool returns true for the strings "true", "1", "yes" (any
 // case, trimmed). Anything else is false. Used for opt-in feature
 // flags wired through env vars without dragging in a config-package
@@ -201,6 +223,7 @@ func ConfigFromEnvOrDefault() Config {
 		envWorkerHost, envWorkerPort, envWorkerURL, envWorkerLogin, envWorkerPassword,
 		"RSMON_WEBAPP_DATA_DIR", "RSMON_WEBAPP_STORE_PATH",
 		"WORKER_CLUSTER_ENABLED",
+		envComposeEnabled,
 		envReleaseURL,
 	} {
 		if v := os.Getenv(k); v != "" {
@@ -336,6 +359,7 @@ type Server struct {
 	templates *Templates
 	logBuffer *LogBuffer
 	inventory *Inventory
+	compose   *ComposeRefresher
 	metrics   *Metrics
 	cluster   ClusterView
 	pruneStop chan struct{}
@@ -402,6 +426,7 @@ func New(cfg Config, deps *Deps) (*Server, error) { //nolint:gocritic // Config
 		templates:     tmpl,
 		logBuffer:     NewLogBuffer(5000),
 		inventory:     NewInventory(store, deps.Logger),
+		compose:       NewComposeRefresher(cfg.ComposeEnabled, deps.Logger),
 		metrics:       NewMetrics(),
 		cluster:       deps.Cluster,
 		pruneStop:     make(chan struct{}),
@@ -460,6 +485,10 @@ func (s *Server) SetCluster(c ClusterView) { s.cluster = c }
 // Cluster returns the attached cluster subsystem (or nil).
 func (s *Server) Cluster() ClusterView { return s.cluster }
 
+// Compose returns the Compose discovery/management refresher so tests
+// and the cmd binary can drive it (e.g. inject a fixture discovery).
+func (s *Server) Compose() *ComposeRefresher { return s.compose }
+
 // Close shuts down the HTTP listener, the prune goroutine, and the
 // embedded store. Safe to call multiple times.
 func (s *Server) Close(ctx context.Context) error {
@@ -472,6 +501,9 @@ func (s *Server) Close(ctx context.Context) error {
 		close(s.pruneStop)
 	}
 	s.pruneWG.Wait()
+	if s.compose != nil {
+		s.compose.Stop()
+	}
 	if s.httpServer != nil {
 		shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
 		defer cancel()
@@ -493,6 +525,12 @@ func (s *Server) Start(ctx context.Context) error {
 	s.pruneWG.Add(1)
 	go s.pruneLoop(ctx)
 
+	// Start the Compose discovery loop alongside the listener. It no-ops
+	// when disabled or when Docker is absent, so it is always safe to
+	// start. The first refresh runs immediately so /compose has data on
+	// the first request.
+	s.compose.Start(ctx)
+
 	// Run ListenAndServe in a goroutine so we can race it against ctx.
 	errCh := make(chan error, 1)
 	go func() {
diff --git a/internal/webapp/templates/compose.html b/internal/webapp/templates/compose.html
new file mode 100644
index 0000000..ac1e9cf
--- /dev/null
+++ b/internal/webapp/templates/compose.html
@@ -0,0 +1,44 @@
+{{define "body"}}
+

Docker Compose projects

+ {{if not .Enabled}} +

Compose management is disabled (WORKER_COMPOSE_ENABLED=false). Re-enable it and restart the worker to see projects here.

+ {{else}} +

Discovered from docker compose ls and docker ps labels, refreshed every 60s. Lifecycle actions (up/down/stop/restart/pull) run docker compose in each project's working directory.

+ {{if .LastAt.IsZero}} +

No refresh yet — the first scan runs within 60s of start.

+ {{else}} +

Last refresh: {{fmtTime .LastAt}}

+ {{end}} + {{range .Snapshot.Errors}} +

⚠ {{.}}

+ {{end}} + + + + + + + + + + + + + + {{range .Snapshot.Projects}} + + + + + + + + + + {{else}} + + {{end}} + +
ProjectStatusServicesContainersRunningCompose file
{{.Name}}{{.Status}}{{.ServiceCount}}{{.ContainerCount}}{{.RunningCount}}{{.ConfigFiles}}details
No Compose projects discovered.
+ {{end}} +
{{end}} diff --git a/internal/webapp/templates/compose_detail.html b/internal/webapp/templates/compose_detail.html new file mode 100644 index 0000000..a02c6db --- /dev/null +++ b/internal/webapp/templates/compose_detail.html @@ -0,0 +1,81 @@ +{{define "body"}}
+

{{.Project.Name}}

+

Working dir: {{.Project.WorkingDir}}{{if .Project.ConfigFiles}} · compose file: {{.Project.ConfigFiles}}{{end}} · last refresh: {{fmtTime .LastAt}}

+ + {{if .ActionMessage}} +

{{if .ActionOK}}✓{{else}}⚠{{end}} {{.ActionMessage}}

+ {{end}} + +

Project actions

+
+
+
+
+
+
+ +
+ +

Services ({{.Project.ServiceCount}})

+ + + + {{$root := .}} + {{range .Project.Services}} + + + + + + + + + + + + {{else}} + + {{end}} + +
ServiceContainerImageStateStatusHealthPIDPortsActions
{{.Name}}{{range .Containers}}{{.Name}}
{{end}}
{{range .Containers}}{{.Image}}
{{end}}
{{range .Containers}}{{.State}}
{{end}}
{{range .Containers}}{{.Status}}
{{end}}
{{range .Containers}}{{.Health}}
{{end}}
{{range .Containers}}{{.PID}}
{{end}}
{{range .Containers}}{{range .Ports}}{{.HostPort}}
{{end}}{{end}}
+
+
+
+
No services discovered for this project.
+ + {{if .Project.GroupedMounts}} +

Mounts

+ + + + {{range .Project.GroupedMounts}} + + + + + + + {{end}} + +
SourceDestinationTypeUsed by
{{.Source}}{{.Destination}}{{.Type}}{{.SharedMountSummary}}
+ {{end}} + + {{if .Project.AllTraefikRoutes}} +

Traefik routes

+ + + + {{range .Project.AllTraefikRoutes}} + + + + + + + + + {{end}} + +
RouterServiceContainerHostnamesPrefixesRule
{{.RouterName}}{{.Service}}{{.Container}}{{range .Hostnames}}{{.}}
{{end}}
{{range .PathPrefixes}}{{.}}
{{end}}
{{.Rule}}
+ {{end}} +
{{end}} diff --git a/internal/webapp/templates/compose_logs.html b/internal/webapp/templates/compose_logs.html new file mode 100644 index 0000000..25efad1 --- /dev/null +++ b/internal/webapp/templates/compose_logs.html @@ -0,0 +1,6 @@ +{{define "body"}}
+

Compose logs: {{.Project}}

+

Last 300 lines from docker compose logs --no-color --tail 300. Streaming tails are not supported in the web UI yet.

+
{{if .Output}}{{.Output}}{{else}}(no output){{end}}
+

← back to {{.Project}}

+
{{end}} diff --git a/internal/webapp/templates/layout.html b/internal/webapp/templates/layout.html index 12accc5..d004158 100644 --- a/internal/webapp/templates/layout.html +++ b/internal/webapp/templates/layout.html @@ -14,6 +14,7 @@