diff --git a/.gitignore b/.gitignore index b034978..e6198de 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /bin/ .playwright-mcp/ +.obs/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1485fb7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,37 @@ +# AI Agent Guide + +This is a monorepo harness for the Observability UI team. See [ARCHITECTURE.md](ARCHITECTURE.md) for the project catalog, dependency graph, and +feature delivery stages. + +## Setup + +Run `make setup` after cloning to install tools and initialize submodules. + +## Tools + +- **obs** (`./bin/obs`) — CLI for running development and deployment recipes. Build with `make tools`. Run `./bin/obs list` to see available recipes. + Always use `--non-interactive` or `--output-json` when invoking from an agent. See [tools/obs/AGENTS.md](tools/obs/AGENTS.md) for the full command + reference. + +## Task workflow + +Tasks live under `tasks//` with a structured pipeline: + +1. `spec.md` — problem statement, acceptance criteria, related projects +2. `plan.md` — phased implementation plan with file tables and verification +3. `execution.md` — checklist tracking progress through the plan + +Use the obsui plugin skills to drive this workflow: + +- `/obsui:planner` — create a plan from a spec +- `/obsui:executor` — execute a plan +- `/obsui:bug-diagnostic` — diagnose a bug from a spec +- `/obsui:dev-env` — manage project dev environments +- `/obsui:code-reviewer` — review a PR + +## Projects + +Projects are git submodules under `projects/`. Each has its own `CLAUDE.md` or `AGENTS.md` with project-specific guidance. Always use relative paths +from the repo root when referencing project files (`projects//path/to/file`). + +Git commands in submodules: `git -C ./projects/ ` diff --git a/Makefile b/Makefile index 35ff711..34f90fd 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ +# ── Variables ──────────────────────────────────────────────────────── + BIN_DIR := $(CURDIR)/bin -# Platform detection UNAME_S := $(shell uname -s | tr '[:upper:]' '[:lower:]') UNAME_M := $(shell uname -m) @@ -18,21 +19,29 @@ else ifeq ($(UNAME_S),linux) DPRINT_TARGET := $(ARCH)-unknown-linux-gnu endif -# Tool versions -DPRINT_VERSION := 0.54.0 +DPRINT_VERSION := 0.54.0 +DPRINT := $(BIN_DIR)/dprint +DPRINT_RELEASE_URL := https://github.com/dprint/dprint/releases/download/$(DPRINT_VERSION)/dprint-$(DPRINT_TARGET).zip -# Tool paths -DPRINT := $(BIN_DIR)/dprint +OBS := $(BIN_DIR)/obs -TOOLS := $(DPRINT) +# ── Setup ──────────────────────────────────────────────────────────── -.PHONY: tools setup fmt-md check-md clean reset-projects +.PHONY: setup clean reset-projects setup: tools reset-projects -tools: $(TOOLS) +clean: + rm -rf $(BIN_DIR) -DPRINT_RELEASE_URL := https://github.com/dprint/dprint/releases/download/$(DPRINT_VERSION)/dprint-$(DPRINT_TARGET).zip +reset-projects: + @./scripts/reset-projects.sh + +# ── Tools ──────────────────────────────────────────────────────────── + +.PHONY: tools obs + +tools: $(DPRINT) obs $(DPRINT): @mkdir -p $(BIN_DIR) @@ -43,14 +52,16 @@ $(DPRINT): @chmod +x $(DPRINT) @echo "Installed dprint -> $(DPRINT)" +obs: + @mkdir -p $(BIN_DIR) + cd tools/obs && go build -o $(OBS) ./cmd/obs + +# ── Lint ───────────────────────────────────────────────────────────── + +.PHONY: lint check + lint: $(DPRINT) $(DPRINT) fmt check: $(DPRINT) $(DPRINT) check - -clean: - rm -rf $(BIN_DIR) - -reset-projects: - @./scripts/reset-projects.sh diff --git a/README.md b/README.md index efe5187..98dd118 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,9 @@ for using AI coding agents across the team's project portfolio. Each task follows a three-document workflow: -1. **`spec.md`** - Problem statement, related projects/branches, and acceptance criteria. -2. **`plan.md`** - Step-by-step breakdown an AI agent can execute against. -3. **`execution.md`** - Progress tracking with checkboxes and notes captured during execution. +1. **`spec.md`** — Problem statement, related projects/branches, and acceptance criteria. +2. **`plan.md`** — Step-by-step breakdown an AI agent can execute against. +3. **`execution.md`** — Progress tracking with checkboxes and notes captured during execution. Tasks live in `tasks/`. The `projects/` directory contains git submodules for every repo in scope, giving agents direct access to source code. @@ -20,23 +20,56 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for the full project catalog and system a ## Repository layout ``` -tasks/ # Active tasks (description + work-plan + execution) +tasks/ # Active tasks (spec + plan + execution) completed/ # Archived completed tasks projects/ # Git submodules for all in-scope repos -bin/ # Local tooling (dprint) -claude/plugins/obsui/ # Claude Code plugin for assisted development and code reviews +tools/obs/ # obs CLI source (Go) +bin/ # Built tools (obs + dprint) — gitignored +claude/plugins/obsui/ # Claude Code plugin (skills for planning, execution, debugging, dev environments, code review) ``` ## Setup ```sh git clone --recurse-submodules https://github.com/observability-ui/harness/ -make setup # install tools and reset submodules to their configured branches +make setup # install tools, build obs CLI, and reset submodules to their configured branches ``` +## Tools + +### obs CLI + +The `obs` CLI runs development and deployment recipes for projects in the harness. Built with `make tools`, the binary lands in `bin/obs`. + +```sh +obs list # list available recipes +obs start mp # start monitoring plugin (frontend + backend + console) +obs start mp --force # kill processes on busy ports, then start +obs --dry-run start mp # show what would run without executing +obs deploy coo # deploy cluster observability operator +obs status # show running processes +obs cleanup # stop all processes +``` + +Runs in interactive mode (TUI with tabs per process) by default, falls back to non-interactive (docker-compose style) in CI or with +`--non-interactive`. See [tools/obs/README.md](tools/obs/README.md) for the full reference. + +### AI agent skills + +The [obsui plugin](claude/plugins/obsui/) provides skills for AI-assisted development: + +| Skill | Purpose | +| ----------------------- | ------------------------------------------------- | +| `/obsui:planner` | Create an implementation plan from a spec | +| `/obsui:executor` | Execute a plan with parallel agents | +| `/obsui:bug-diagnostic` | Diagnose a bug from a spec | +| `/obsui:dev-env` | Manage project dev environments via the obsui CLI | +| `/obsui:code-reviewer` | Multi-angle PR review | + ## Resetting projects -After working on tasks, submodules may have checked-out branches or uncommitted changes. Run `make reset-projects` to reset all submodules back to the branches defined in `.gitmodules` at the latest remote HEAD. This prevents intermediate states from being committed to this meta-repo. +After working on tasks, submodules may have checked-out branches or uncommitted changes. Run `make reset-projects` to reset all submodules back to the +branches defined in `.gitmodules` at the latest remote HEAD. ## Markdown formatting diff --git a/claude/plugins/obsui/skills/bug-diagnostic/SKILL.md b/claude/plugins/obsui/skills/bug-diagnostic/SKILL.md new file mode 100644 index 0000000..bcaf4bd --- /dev/null +++ b/claude/plugins/obsui/skills/bug-diagnostic/SKILL.md @@ -0,0 +1,450 @@ +--- +name: bug-diagnostic +description: Diagnose a bug from a spec, produce a diagnostic.md with root cause analysis and a plan.md for the executor to implement the fix. +allowed-tools: Read, Bash(find:*), Bash(grep:*), Bash(rg:*), Bash(git log:*), Bash(git diff:*), Bash(git show:*), Bash(git branch:*), Bash(git checkout:*), Bash(git tag:*), Bash(git -C:*), Bash(wc:*), Bash(ls:*), Bash(npm test:*), Bash(npm run:*), Bash(make:*), Bash(go test:*), Bash(./bin/obs *), LSP, Agent +--- + +## Input + +$ARGUMENTS is a task folder name. The folder must exist under `tasks/` and contain a `spec.md` file describing the bug. + +The spec should include: + +- **Description** of the bug (what is broken, when it happens) +- **Reproduction steps** (commands, user actions, or test cases that trigger it) +- **Expected vs. actual behavior** +- **Related projects and branches** (which codebases are affected) +- **Hints** (optional — error messages, stack traces, suspected areas) + +## Prerequisites + +The projects referenced in the spec live as git submodules under `projects/`. The repository's `.claude/settings.json` already includes +`additionalDirectories` for the project submodules, so file reads and bash commands against those paths will not trigger permission prompts. + +**Path rules — ALWAYS use relative paths from the repo root:** + +- File reads: `projects//path/to/file` (relative, no leading `./`) +- Bash find/grep/ls: `./projects//...` +- Git commands in submodules: `git -C ./projects/ ` (e.g., `git -C ./projects/perses log --oneline -5`) +- NEVER use absolute paths or `cd /absolute/path && git ...` — these trigger permission prompts for untrusted hooks +- The permission allowlist matches `cd ./projects/* && git *`, so if you must use `cd`, always use the relative form: + `cd ./projects/ && git ...` + +**Iron law: NO FIXES WITHOUT ROOT CAUSE INVESTIGATION FIRST.** + +Do not propose a fix, write a plan, or modify any code until the root cause is identified and documented in the diagnostic. Resist the urge to "just +try something" — trace the data flow first. + +## Steps + +### 1. Check out the affected branch + +The bug may only exist on a specific branch or version. This step ensures every project is on the correct branch **before** any reproduction or +investigation begins. + +**1a. Read the spec and system context** + +Read these files in order: + +``` +tasks/$ARGUMENTS/spec.md +ARCHITECTURE.md +``` + +For each project listed in the spec's "Related projects and branches" section, use the Read tool with relative paths to read these files if they +exist: + +- `projects//CLAUDE.md` +- `projects//AGENTS.md` +- `projects//README.md` + +Run all project reads in parallel across projects to minimize round-trips. + +**1b. Determine the target branch per project** + +For each project in scope, check what branch is currently checked out and what branches/tags are available: + +```bash +git -C ./projects/ branch --show-current && echo "---" && git -C ./projects/ log --oneline -5 +git -C ./projects/ tag --sort=-creatordate | head -10 +``` + +Now compare against the spec: + +- If the spec's "Related projects and branches" section **specifies a branch or tag** for the project → use that. +- If the spec **does not specify a branch** for a project → ask the user before proceeding. Use AskUserQuestion with the current branch, recent tags, + and `main` as options: + +``` +Which branch/version of has the bug? +Options: +- (currently checked out) +- main +- (latest release) +- [Other — user types a branch or tag] +``` + +Do NOT assume the current branch is correct. A version-specific bug on a release tag will not reproduce on `main`. + +**1c. Switch to the target branch** + +For each project, check out the confirmed branch: + +```bash +git -C ./projects/ checkout +``` + +Verify the checkout succeeded and record the exact commit: + +```bash +git -C ./projects/ log --oneline -1 +``` + +**1d. Identify scope** + +After reading and checking out branches, identify: + +- Which repositories are in scope and on which branch/version +- What the bug symptoms are (error messages, incorrect behavior, test failures) +- What the spec's reproduction steps are +- Any hints about suspected root cause or affected areas + +**1e. Check dev environment readiness** + +Before attempting reproduction, check what recipes and environments are available, and whether processes are already running. + +1. Ensure the CLI is built, then discover available recipes and check running state: + +```bash +make obs 2>/dev/null +./bin/obs list +./bin/obs status +``` + +2. Use `--dry-run` to preview what a recipe would do without executing: + +```bash +./bin/obs --dry-run start +./bin/obs --dry-run deploy +``` + +3. Based on the bug and available recipes, decide what to set up: + + - **Bug requires a running dev server** (UI behavior, frontend rendering, API responses): Start the relevant recipe. Always use + `--non-interactive` when running from an agent. Use `--force` to kill any processes on busy ports: + + ```bash + ./bin/obs --non-interactive --force start + ``` + + - **Bug requires a deployed component on a cluster** (operator behavior, plugin loading, CRD reconciliation): Deploy the relevant + recipe: + + ```bash + ./bin/obs --non-interactive deploy + ``` + + - **Already running:** `./bin/obs status` shows active processes — note them and proceed to reproduction. + + - **No matching recipe exists:** Skip this step and proceed to reproduction using the project's own commands (Makefile targets, + npm scripts, go test). + + - **Bug reproduces via unit tests or code inspection alone:** Skip environment setup entirely. + +4. After reproduction and investigation are complete, clean up any environments you started: + +```bash +./bin/obs cleanup --force +``` + +**Principle: suggest, never block.** Many bugs can be reproduced through unit tests or code inspection without a full dev environment. +Do not refuse to proceed if no recipe exists or if the dev environment is not running. The obs tool is a convenience, not a gate. + +**Agent-specific notes:** +- Always use `--non-interactive` or `--output-json` — interactive mode requires a TTY. +- Exit codes: 0 = success, 1 = recipe failure, 2 = requirements not met (e.g., `oc` not logged in). +- Use `--dry-run` before running to understand what commands and ports a recipe uses. +- Use `--force` to automatically kill processes on busy ports instead of failing. + +### 2. Reproduce the bug + +Attempt to reproduce the bug using the steps from the spec. The goal is to see the failure firsthand and capture exact output. + +**Run reproduction steps:** + +- Execute the commands or test cases from the spec +- Use the project's own test/build commands (from CLAUDE.md, Makefile, package.json): + +```bash +# Check available commands +grep -E '^[a-zA-Z_-]+:' ./projects//Makefile 2>/dev/null | head -20 +grep -A 30 '"scripts"' ./projects//package.json 2>/dev/null +``` + +- Capture exact error messages, stack traces, and failing test output +- Note the environment: branch, commit, any relevant configuration + +**Command rules:** NEVER run `npx` commands directly — they trigger permission prompts. Always use Makefile targets or npm scripts like +`npm run build`, `npm run test`, `npm run lint` or `npm run type-check`. Go commands (`go test`, `go build`) are fine. + +**If reproduction fails:** + +- Try variations (different inputs, different order of steps) +- Check whether the spec's branch/commit is checked out +- If the bug cannot be reproduced after reasonable effort, ask the user for clarification using AskUserQuestion before proceeding. Do NOT guess or + skip ahead. + +**Document what you observed** — exact commands run, exact output, and how it differs from expected behavior. This goes into the diagnostic. + +### 3. Investigate root cause + +Follow a structured investigation. Work backward from the symptom to the cause. + +**3a. Trace the error to its source** + +Start at the error message or failing assertion and trace backward through the code: + +```bash +# Find where the error is raised +grep -rn "error message text" projects//src/ --include="*.ts" +grep -rn "error message text" projects// --include="*.go" + +# Check recent changes to affected files +git -C ./projects/ log --oneline -20 -- path/to/affected/file +git -C ./projects/ diff HEAD~5 -- path/to/affected/file +``` + +Use LSP when available: + +- `goToDefinition` — follow function calls from the error site +- `findReferences` — find all callers of a broken function +- `incomingCalls` / `outgoingCalls` — trace the call chain +- `hover` — check type signatures at suspicious points + +**3b. Multi-repo bugs** + +When the bug spans multiple repositories, launch parallel Explore agents (one per repo) to investigate simultaneously. Each agent should report: + +- The relevant code paths in that repo +- Recent changes that could have introduced the bug +- How the repo interacts with other affected repos (API contracts, shared types) + +Synthesize their findings to identify where the contract is broken. + +**3c. Compare against working state** + +- Find similar code that works correctly and compare +- Check the last known good commit: `git -C ./projects/ log --oneline --all -- path/to/file` +- Use `git -C ./projects/ diff -- path/to/file` to isolate what changed + +**3d. Form and test hypotheses** + +1. Form a single hypothesis based on the evidence gathered +2. Test it minimally — read one file, run one command, check one output +3. If confirmed, proceed to Step 4 +4. If refuted, record the hypothesis and evidence, then form a new one + +**Red flags — restart the investigation if you notice:** + +- Proposing a fix before tracing the full data flow +- "Just try changing X and see if it works" +- Third failed hypothesis in a row — step back and question your assumptions +- Each "fix" reveals a new problem in a different place (symptom of wrong root cause) + +### 4. Clarify and confirm + +Before writing artifacts, present your findings to the user: + +1. State the root cause you identified (one sentence) +2. Show the key evidence (file:line references, command output) +3. Describe the fix approach at a high level + +Ask targeted questions using AskUserQuestion: + +- **Fix scope** — should the fix be minimal (patch the symptom) or structural (address the underlying design issue)? +- **Acceptable trade-offs** — performance vs. correctness, backward compatibility constraints +- **Testing expectations** — unit tests sufficient, or integration/E2E tests needed? + +Wait for the user's answers before proceeding to Steps 5 and 6. + +### 5. Write diagnostic.md + +Save to `tasks/$ARGUMENTS/diagnostic.md`. This document is the evidence record — it must stand on its own without requiring someone to re-run the +investigation. + +Use the diagnostic template below. + +### 6. Write plan.md + +Save to `tasks/$ARGUMENTS/plan.md` using the plan template below. This plan must be in the exact format the executor skill expects — same sections, +same table structures, same phase conventions. + +**Plan authoring rules:** + +- The Problem section should reference the diagnostic: `See tasks/$ARGUMENTS/diagnostic.md for full root cause analysis.` +- Current State table must include the buggy components with their current (broken) behavior +- Each phase's Details section should include code snippets for non-obvious fixes +- Verification section must include the reproduction case — after the fix, the original bug must not reproduce + +**Detail calibration:** + +- **Code snippets:** Include for type signature changes, API contract changes, non-obvious logic, tricky merge patterns +- **Line references:** Include when the exact insertion/modification point matters +- **Prose:** Use for straightforward config changes, import updates +- **Files Modified table:** Required for every phase that modifies files + +**Parallel execution annotations:** + +Each phase must declare its dependency and whether it can run in parallel with other phases. The constraint: only one agent should modify a given file +at a time. Phases touching different repos or non-overlapping files can run in parallel via separate agents. + +**Self-review before saving:** + +1. **Root cause coverage** — the plan addresses the root cause from the diagnostic, not just the symptom +2. **Regression test** — at least one phase adds or modifies a test that would have caught this bug +3. **Dependency ordering and parallelism** — phases reference correct dependencies, parallel phases don't modify overlapping files +4. **File path accuracy** — every path exists in the codebase or is marked as a new file +5. **Reproduction case in verification** — the spec's reproduction steps appear in the Verification section with "should no longer reproduce" as + expected outcome + +## Diagnostic template + +``` +# Diagnostic: [Bug Name] + +## Bug Summary + +[One-paragraph description: what is broken, what the user experiences, and the severity/impact.] + +## Reproduction + +| Step | Command / Action | Expected | Actual | +| ---- | ---------------- | -------- | ------ | +| 1 | [command or action] | [what should happen] | [what actually happens] | +| ... | ... | ... | ... | + +### Environment + +- **Branch:** [branch name per project] +- **Commit:** [short SHA per project] +- **Relevant config:** [any environment-specific settings] + +### Error Output +``` + +[Exact error message, stack trace, or failing test output — verbatim, not paraphrased] + +``` +## Investigation + +### Hypothesis 1: [short description] + +**Evidence:** [what was checked — files read, commands run, LSP queries] +**Result:** Confirmed | Refuted +**Details:** [what was found and why it confirms or rules out this hypothesis] + +### Hypothesis N: [short description] + +[Repeat for each hypothesis tested. Include refuted hypotheses — they narrow the search space and prevent re-investigation.] + +## Root Cause + +[Clear, precise explanation of why the bug occurs. Reference specific file:line locations. Explain the mechanism — what triggers it, what state +becomes incorrect, and why the current code produces wrong output.] + +### Affected Components + +| Component | File / Location | Impact | +| --------- | --------------- | ------ | +| [name] | `project/path/to/file.ext:line` | [How this component is affected] | +| ... | ... | ... | + +### Contributing Factors + +[Environment, configuration, timing, or data conditions that contribute to the bug. Omit this section if the bug is purely a code defect with no +environmental factors.] + +## Fix Strategy + +[High-level approach to fixing the bug. Explain why this approach was chosen over alternatives. Mention any trade-offs (e.g., "minimal patch now, +structural fix in follow-up" vs. "fix the root design issue").] +``` + +## Plan template + +``` +# Plan: Fix — [Bug Name] + +## Problem + +[Why this fix is needed. Reference the diagnostic: "See `tasks/$ARGUMENTS/diagnostic.md` for full root cause analysis." +Summarize the root cause in 2-3 sentences. Link upstream issues if relevant.] + +## Current State + +| Component | File / Location | Current Behavior | +| --------- | --------------- | ---------------- | +| [name] | `project/path/to/file.ext:line` | [What it does now — the buggy behavior] | +| ... | ... | ... | + +## Changes + +### Phase 1: [Name] + +**Dependency:** None +**Parallel with:** None | Phase N (when touching different repos/files) + +#### Files Modified + +| File | Change | +| ---- | ------ | +| `project/path/to/file.ext` | [Brief description of what changes] | +| ... | ... | + +#### Details + +[Detailed description of the changes. Include code snippets for type changes and non-obvious logic. Include line references when the exact point matters.] + +##### [Sub-section for complex changes within this phase] + +[For phases with multiple independent changes, use sub-sections.] + +#### Phase 1 Verification + +- [Specific command and expected output] +- [Manual check if automated verification is not possible] + +### Phase 2: [Name] + +**Dependency:** Phase 1 +**Parallel with:** Phase 3 (different repo) + +[Same structure as Phase 1] + +... + +## PR Strategy + +| PR | Repository | Branch | Description | Dependencies | +| -- | ---------- | ------ | ----------- | ------------ | +| 1 | [repo] | [branch] | [what this PR contains] | None | +| 2 | [repo] | [branch] | [what this PR contains] | PR 1 merged | +| ...| ... | ... | ... | ... | + +[If all changes fit in a single PR, use one row. For multi-repo tasks, list PRs in merge order. Note which can be reviewed in parallel.] + +## Verification + +[End-to-end verification mapped to the spec's acceptance criteria and the original reproduction case.] + +- [Original reproduction case] - should no longer reproduce after fix +- [Acceptance criterion] - [how to verify] +- ... + +## Risks + +| Risk | Impact | Mitigation | +| ---- | ------ | ---------- | +| [What could go wrong] | [What breaks] | [How to prevent or recover] | +| ... | ... | ... | +``` diff --git a/projects/console b/projects/console index 7aaf3d3..1aa76c1 160000 --- a/projects/console +++ b/projects/console @@ -1 +1 @@ -Subproject commit 7aaf3d3309e22e14abef7b92231785945de7b40d +Subproject commit 1aa76c1be2b396596bdfd912ddf79e3b95b78509 diff --git a/projects/konflux-coo b/projects/konflux-coo index 550e1e0..f43f9bb 160000 --- a/projects/konflux-coo +++ b/projects/konflux-coo @@ -1 +1 @@ -Subproject commit 550e1e0ca23b40084be6c723cce27a0e971b2cf8 +Subproject commit f43f9bbf04564f68afcfbb6abbe33b84d5f1510e diff --git a/projects/logging-view-plugin b/projects/logging-view-plugin index 81c3a0f..e40bfca 160000 --- a/projects/logging-view-plugin +++ b/projects/logging-view-plugin @@ -1 +1 @@ -Subproject commit 81c3a0fdbca10905bbd903d8d98284f90367f25e +Subproject commit e40bfca1d5ad55817941f454cf35222bb71dd5d9 diff --git a/projects/monitoring-plugin b/projects/monitoring-plugin index 3327aad..4716e57 160000 --- a/projects/monitoring-plugin +++ b/projects/monitoring-plugin @@ -1 +1 @@ -Subproject commit 3327aad1094c6854f48a4acd3f710c20ccccd018 +Subproject commit 4716e5757516271f6adf8d60937bbddbc38fd235 diff --git a/projects/observability-operator b/projects/observability-operator index cd67068..d8d5180 160000 --- a/projects/observability-operator +++ b/projects/observability-operator @@ -1 +1 @@ -Subproject commit cd67068ee83e2a690532b2ede2ed8d6b7800323b +Subproject commit d8d51802b035c4b5f8a80f6a5ac13bfaab977661 diff --git a/projects/perses b/projects/perses index a9048a9..564f7a1 160000 --- a/projects/perses +++ b/projects/perses @@ -1 +1 @@ -Subproject commit a9048a9b611df8f5d22b011ae5821af910b247a6 +Subproject commit 564f7a1ad27cf6e2e83372373ea5f114a2e6fb09 diff --git a/projects/perses-operator b/projects/perses-operator index dc5d01b..1882b51 160000 --- a/projects/perses-operator +++ b/projects/perses-operator @@ -1 +1 @@ -Subproject commit dc5d01b5f7f4ed6a684ad6e555ea73840ee452ca +Subproject commit 1882b5124a6dca05086dfff629d3faa23c01e291 diff --git a/projects/perses-plugins b/projects/perses-plugins index c37625a..e2a25cf 160000 --- a/projects/perses-plugins +++ b/projects/perses-plugins @@ -1 +1 @@ -Subproject commit c37625a836fbf56d837e0842603d6daa7552ba4f +Subproject commit e2a25cf3bfff858632800ee240699262d2e7b713 diff --git a/projects/perses-shared b/projects/perses-shared index 7da5e3f..ccc48f4 160000 --- a/projects/perses-shared +++ b/projects/perses-shared @@ -1 +1 @@ -Subproject commit 7da5e3f76580c3d099e148e22977b9d83eb084a9 +Subproject commit ccc48f4ae9b026a7f30c28263a10a22333d6d9c0 diff --git a/projects/perses-spec b/projects/perses-spec index b04221f..b02434d 160000 --- a/projects/perses-spec +++ b/projects/perses-spec @@ -1 +1 @@ -Subproject commit b04221f0e1a9a0ab89e704e5718b739a14834e71 +Subproject commit b02434d2bfae1a328d1028ec25ac50fb88899163 diff --git a/scripts/reset-projects.sh b/scripts/reset-projects.sh index 482d192..f506d49 100755 --- a/scripts/reset-projects.sh +++ b/scripts/reset-projects.sh @@ -80,6 +80,8 @@ reset_submodule() { git -C "$submodule_dir" clean -fd --quiet + git -C "$submodule_dir" submodule update --init --recursive --quiet 2>&1 + echo " reset to ${remote}/${branch} ✓" } > "$logfile" 2>&1 } @@ -124,8 +126,6 @@ for i in "${!pids[@]}"; do fi done -git submodule update --init --recursive - echo "═══════════════════════════════════════════════════════════" if [[ ${#errors[@]} -gt 0 ]]; then echo " Done with ${#errors[@]} error(s):" diff --git a/tasks/add-logs-table-csv-export/execution.md b/tasks/add-logs-table-csv-export/execution.md new file mode 100644 index 0000000..b4c3beb --- /dev/null +++ b/tasks/add-logs-table-csv-export/execution.md @@ -0,0 +1,61 @@ +# Execution: Add CSV Export to Logs Table + +> Results are annotated inline: `-- **value**` for discovered values, `-- **passes/FAILED**` for verification. + +## Phase 1: Create CSV Export Action Component +Depends on: nothing | Parallel with: none | Type: implementation | Projects: perses-plugins + +### 1a. Extract testable CSV generation logic +- [x] Create pure function `collectLabelKeys(entries: LogEntry[]): string[]` to gather and sort unique label keys +- [x] Create pure function `buildLogsCsvString(entries: LogEntry[]): string` to generate CSV content +- [x] Write failing tests for `collectLabelKeys` and `buildLogsCsvString` - `logstable/src/LogsTableCsvExportAction.test.ts` -- **11 tests** +- [x] Implement functions to pass tests - `logstable/src/LogsTableCsvExportAction.tsx` + +### 1b. Build the React action component +- [x] Create `LogsTableCsvExportAction` React component using extracted functions - `logstable/src/LogsTableCsvExportAction.tsx` +- [x] Uses `FileDelimitedOutline` icon from `mdi-material-ui/FileDelimitedOutline` +- [x] Uses `escapeCsvValue`, `sanitizeFilename`, `formatTimestampISO` from `@perses-dev/plugin-system` +- [x] Uses `stripAnsi` from `./utils/ansi` +- [x] Tooltip: "Export as CSV", aria-label: "Export Logs Table Data as CSV" +- [x] Downloads as `{sanitizeFilename(title)}_data.csv` with MIME type `text/csv;charset=utf-8` + +### Phase 1 Verification +- [x] `npx tsx node_modules/.bin/jest --config logstable/jest.config.ts` — **55 tests pass, 11 new** (pre-existing: `npm test` fails due to Node 25/Jest 30 config resolution, `LogsTablePanel.test.tsx` fails due to echarts init in jsdom) +- [x] `cd logstable && npm run type-check` — **pre-existing errors only** (`Cannot find module '@perses-dev/spec'` across all project files, not specific to new code) +- [x] `cd logstable && npm run lint` — **passes, no errors** + +## Phase 2: Register CSV Export Action +Depends on: Phase 1 | Parallel with: none | Type: configuration | Projects: perses-plugins + +- [x] Import `LogsTableCsvExportAction` in `logstable/src/LogsTable.ts` +- [x] Add `{ component: LogsTableCsvExportAction, location: 'header' }` to `actions` array + +### Phase 2 Verification +- [x] `cd logstable && npm run lint` — **passes, no errors** +- [x] All 11 CSV tests still pass after registration + +--- + +## Summary + +**Status:** Complete (2 of 2 phases done) + +### Files changed + +| File | Change | +| ---- | ------ | +| `logstable/src/LogsTableCsvExportAction.tsx` | New file. CSV export component with `collectLabelKeys()` and `buildLogsCsvString()` pure functions + `LogsTableCsvExportAction` React component | +| `logstable/src/LogsTableCsvExportAction.test.ts` | New file. 11 unit tests covering label key collection and CSV string generation | +| `logstable/src/LogsTable.ts` | Added import and registered CSV export action alongside existing JSON export | + +### Outstanding items + +- [ ] Commit changes on `feat/logs-table-csv-export` branch +- [ ] Manual verification: load a dashboard with logs table data, verify CSV download works correctly +- [ ] Push branch and create PR to `perses/plugins` + +### Notes + +- Pre-existing issue: `npm test` does not work in logstable (Node 25 / Jest 30 config resolution for `jest.shared.ts`). Workaround: `npx tsx node_modules/.bin/jest --config logstable/jest.config.ts` +- Pre-existing issue: `npm run type-check` shows errors for `@perses-dev/spec` across all files in the project — not related to new code +- The codebase uses `@perses-dev/spec` for `LogEntry` (not `@perses-dev/core` as initially assumed from the plan) diff --git a/tasks/add-logs-table-csv-export/plan.md b/tasks/add-logs-table-csv-export/plan.md new file mode 100644 index 0000000..e45b9e6 --- /dev/null +++ b/tasks/add-logs-table-csv-export/plan.md @@ -0,0 +1,164 @@ +# Plan: Add CSV Export to Logs Table + +## Problem + +The logs table panel currently supports exporting logs as JSON only. Users need the ability to export logs as CSV, a more universally compatible format for use in spreadsheets, data analysis tools, and other systems. The CSV export should include timestamps in ISO 8601 format, the raw log message, and extracted labels as additional columns. + +## Current State + +| Component | File / Location | Current Behavior | +| --------- | --------------- | ---------------- | +| JSON export action | `projects/perses-plugins/logstable/src/LogsTableExportAction.tsx` | Exports all log entries as a JSON file. Extracts entries from `queryResults`, stringifies with 2-space indent, downloads as `{title}_data.json` | +| Plugin definition | `projects/perses-plugins/logstable/src/LogsTable.ts:33` | Registers one action: `LogsTableExportAction` at `location: 'header'` | +| Data model | `@perses-dev/core` → `LogEntry` | `{ timestamp: number, line: string, labels: Labels }` where `timestamp` is seconds since epoch and `Labels = Record` | +| CSV utilities | `@perses-dev/plugin-system` (from `perses-shared/plugin-system/src/utils/csv-export.ts`) | Provides `escapeCsvValue()`, `formatTimestampISO()`, and `sanitizeFilename()` — all already used by other export actions (table, bar chart, time series) | +| ANSI stripping | `projects/perses-plugins/logstable/src/utils/ansi.ts` | Provides `stripAnsi()` to remove ANSI escape codes from log lines | +| Table CSV export | `projects/perses-plugins/table/src/TableExportAction.tsx` | Reference implementation: builds header row + data rows using `escapeCsvValue`, creates `text/csv` blob, downloads | + +## Changes + +### Phase 1: Create CSV Export Action Component + +**Dependency:** None +**Parallel with:** None + +#### Files Modified + +| File | Change | +| ---- | ------ | +| `projects/perses-plugins/logstable/src/LogsTableCsvExportAction.tsx` | **New file.** CSV export action component | + +#### Details + +Create `LogsTableCsvExportAction.tsx` following the same pattern as `LogsTableExportAction.tsx` (JSON) and `table/src/TableExportAction.tsx` (CSV reference). + +**Imports:** +- `escapeCsvValue`, `sanitizeFilename`, `formatTimestampISO` from `@perses-dev/plugin-system` +- `InfoTooltip` from `@perses-dev/components` +- `IconButton` from `@mui/material` +- `FileDelimitedOutline` from `mdi-material-ui/FileDelimitedOutline` (CSV-specific icon, visually distinct from the JSON export's `Download` icon) +- `LogEntry` from `@perses-dev/core` +- `stripAnsi` from `./utils/ansi` +- `LogsTableProps` from `./model` + +**Component logic:** + +1. **Extract entries** — same as JSON export: `queryResults.flatMap((q) => q.data?.logs?.entries ?? [])` + +2. **Collect all label keys** — iterate all entries, gather the union of all label keys, sort alphabetically. This handles entries with different label sets gracefully. + +3. **Build CSV string:** + - **Header row:** `timestamp,body,{label1},{label2},...` — fixed columns `timestamp` and `body` first, then sorted label columns + - **Data rows:** For each entry: + - `formatTimestampISO(entry.timestamp)` for the timestamp column (ISO 8601) + - `stripAnsi(entry.line)` for the body column (raw message without ANSI codes) + - `entry.labels[key] ?? ''` for each label column + - All values escaped with `escapeCsvValue()` + - Join rows with `\n`, add trailing newline + +4. **Download** — create `Blob` with `text/csv;charset=utf-8` MIME type, use same download pattern as JSON export. Filename: `{sanitizeFilename(title)}_data.csv` + +5. **UI** — same `InfoTooltip` + `IconButton` pattern. Tooltip: `"Export as CSV"`. Aria label: `"Export Logs Table Data as CSV"`. Disabled when no data. + +**Code snippet for CSV generation core:** + +```typescript +const allLabelKeys = useMemo(() => { + const keys = new Set(); + for (const entry of entries) { + if (entry.labels) { + for (const key of Object.keys(entry.labels)) { + keys.add(key); + } + } + } + return Array.from(keys).sort(); +}, [entries]); + +const handleDownload = useCallback((): void => { + if (isDisabled) return; + try { + const headerRow = ['timestamp', 'body', ...allLabelKeys].map(escapeCsvValue).join(','); + const dataRows = entries.map((entry) => { + const timestamp = escapeCsvValue(formatTimestampISO(entry.timestamp)); + const body = escapeCsvValue(stripAnsi(entry.line)); + const labels = allLabelKeys.map((key) => escapeCsvValue(entry.labels?.[key] ?? '')); + return [timestamp, body, ...labels].join(','); + }); + const csvString = [headerRow, ...dataRows].join('\n') + '\n'; + // ... blob creation and download (same pattern as JSON export) + } catch (error) { + console.error('Logs table CSV export failed:', error); + } +}, [entries, allLabelKeys, isDisabled, definition]); +``` + +#### Phase 1 Verification + +- File compiles without TypeScript errors: `cd ./projects/perses-plugins && npx tsc --noEmit --project logstable/tsconfig.json` (or equivalent) +- Manual review: CSV output for test data has correct header, ISO 8601 timestamps, stripped ANSI codes, properly escaped values + +### Phase 2: Register CSV Export Action + +**Dependency:** Phase 1 +**Parallel with:** None + +#### Files Modified + +| File | Change | +| ---- | ------ | +| `projects/perses-plugins/logstable/src/LogsTable.ts` | Add `LogsTableCsvExportAction` import and register as second action | + +#### Details + +Add the CSV export action to the `actions` array in `LogsTable.ts:33`: + +```typescript +import { LogsTableCsvExportAction } from './LogsTableCsvExportAction'; + +// ... +actions: [ + { component: LogsTableExportAction, location: 'header' }, + { component: LogsTableCsvExportAction, location: 'header' }, +], +``` + +Both actions render as separate icon buttons in the panel header. The CSV action uses the `FileDelimitedOutline` icon (a document with delimiter lines) while the JSON action keeps its existing `Download` icon, making them visually distinct at a glance. Tooltips further clarify each button's function. + +#### Phase 2 Verification + +- File compiles without TypeScript errors +- Both actions are registered in the plugin definition +- Lint passes: `cd ./projects/perses-plugins && npm run lint -- --filter logstable` (or equivalent) + +## PR Strategy + +| PR | Repository | Branch | Description | Dependencies | +| -- | ---------- | ------ | ----------- | ------------ | +| 1 | perses/plugins | `feat/logs-table-csv-export` from `main` | Add CSV export action to logs table panel | None | + +Single PR since all changes are in one repo (perses-plugins) and the two phases are tightly coupled. + +## Verification + +- **The logs table supports exporting logs as CSV** — Load a dashboard with a logs table panel that has data. Verify a second download button appears in the panel header with tooltip "Export as CSV". Click it. Verify the downloaded `.csv` file: + - Has a header row with `timestamp,body,{sorted label columns}` + - Timestamps are in ISO 8601 format (e.g., `2026-01-21T15:32:31.000Z`) + - Log body is the raw message text with ANSI codes stripped + - Labels are correctly extracted into individual columns + - Values containing commas, quotes, or newlines are properly escaped + - File is named `{panelName}_data.csv` +- **JSON export still works** — Verify the existing JSON export button still functions correctly and is unaffected +- **Empty state** — When no log data is available, both export buttons should be disabled with appropriate tooltips +- **Build** — `npm run build` in the logstable plugin directory succeeds +- **Lint** — `npm run lint` passes + +## Risks + +| Risk | Impact | Mitigation | +| ---- | ------ | ---------- | +| Entries with highly heterogeneous label sets produce many sparse columns | CSV file has many empty cells, making it harder to read | This is inherent to the data shape; sorted columns and empty-string defaults keep it parseable. Could add a future option to select which labels to export. | +| Log lines containing CSV-special characters (commas, quotes, newlines) | Malformed CSV if not properly escaped | Using `escapeCsvValue()` from `@perses-dev/plugin-system`, which handles all RFC 4180 escaping (wraps in quotes, doubles internal quotes) | +| Log lines containing ANSI escape codes | Raw ANSI codes pollute CSV data | Using `stripAnsi()` to clean log lines before export | +| Header button clutter as more actions are added | Panel header becomes crowded | Each action has a distinct icon (`Download` for JSON, `FileDelimitedOutline` for CSV) and tooltip. This is consistent with how action buttons work across panels. | +| Large log datasets produce large CSV files | Browser may lag or run out of memory | Same risk exists for JSON export; no regression. Could add streaming/pagination in a future iteration. | diff --git a/tasks/add-logs-table-csv-export/spec.md b/tasks/add-logs-table-csv-export/spec.md new file mode 100644 index 0000000..35dd1fc --- /dev/null +++ b/tasks/add-logs-table-csv-export/spec.md @@ -0,0 +1,23 @@ +# Spec: Add support for CSV export to logs table + +## Related projects and branches + +- perses-plugins: branch `main` +- perses-shared: branch `main` + +## Description + +The logs table, that supports many datsources has support to export logs as JSON. We want to add support to export logs as CSV. This will allow users +to download logs in a more common format that can be used in other tools. + +## Acceptance criteria + +- The logs table supports exporting logs as CSV + +## Hints + +- The export functionallity is added into a panel through actions that the plugins can register. The logs table plugin already has an action to export + logs as JSON, you can use that as a reference to add support for CSV. +- The format for the CSV should include the timestamp and a time field in ISO 8601 format, log line and extracted labels. The log line should be the + raw log message, and the extracted labels should be included as additional columns in the CSV. Bear in mind the scape characters and the CSV format. + Reuse existing libraries for CSV generation if possible. diff --git a/tasks/customize-columns-in-logs-table/execution.md b/tasks/customize-columns-in-logs-table/execution.md new file mode 100644 index 0000000..6994be7 --- /dev/null +++ b/tasks/customize-columns-in-logs-table/execution.md @@ -0,0 +1,134 @@ +# Execution: Customize Columns in Logs Table + +> Results are annotated inline: `-- **value**` for discovered values, `-- **passes/FAILED**` for verification. + +## Phase 1: Types, Model, and CUE Schema +Depends on: nothing | Parallel with: none | Type: implementation | Projects: perses-plugins (logstable) + +### 1a. TypeScript types +- [x] Add `SortDirection`, `LogsColumnSortMode`, `LogsColumnDefinition` types to model - `logstable/src/model.ts` +- [x] Add `columns?: LogsColumnDefinition[]` to `LogsTableOptions` - `logstable/src/model.ts` + +### 1b. CUE schema +- [x] Add `columns` field with column definition to CUE spec - `logstable/schemas/logstable.cue` + +### Phase 1 Verification +- [x] `cd logstable && npm run type-check` — **passes** +- [ ] CUE schema validates — skipped (cue CLI not available locally, schema follows table.cue pattern) + +--- + +## Phase 2: Column Editor UI +Depends on: Phase 1 | Parallel with: none | Type: implementation | Projects: perses-plugins (logstable) + +### 2a. Generic ColumnsEditor component +- [x] Create generic `ColumnsEditor` component (adapted from alertmanager PR #647 with `renderExtraFields` extension) - `logstable/src/components/ColumnsEditor.tsx` -- **20 tests** +- [x] Tests for ColumnsEditor - `logstable/src/components/ColumnsEditor.test.tsx` + +### 2b. LogsTableColumnsEditor wrapper +- [x] Write tests for LogsTableColumnsEditor (add/remove/reorder/update columns, wrap toggle) - `logstable/src/LogsTableColumnsEditor.test.tsx` -- **13 tests** +- [x] Create `LogsTableColumnsEditor` wrapper with sort mode labels and wrap content checkbox - `logstable/src/LogsTableColumnsEditor.tsx` + +### 2c. Plugin registration and dependency +- [x] Add `immer` to logstable `dependencies` in package.json - `logstable/package.json` +- [x] Add "Columns" tab to `panelOptionsEditorComponents` in `LogsTable.ts` - `logstable/src/LogsTable.ts` + +### Phase 2 Verification +- [x] `cd logstable && npm test` — **33 tests pass** (20 ColumnsEditor + 13 LogsTableColumnsEditor) +- [x] `cd logstable && npm run type-check` — **passes** (6 errors are pre-existing in LogRow.test.tsx from Phase 3 parallel work) + +--- +## Phases 2 and 3 touch different files and can run in parallel after Phase 1 +--- + +## Phase 3: Column Rendering and Sorting +Depends on: Phase 1 | Parallel with: Phase 2 (different files) | Type: implementation | Projects: perses-plugins (logstable) + +### 3a. Sort comparators +- [x] Write tests for sort comparators (alphabetical, numeric, timestamp modes; asc/desc directions) - `logstable/src/components/logs-table-sorting.test.ts` -- **14 tests** +- [x] Implement `SortState`, `compareLogsByColumn`, and mode-specific comparators - `logstable/src/components/logs-table-sorting.ts` + +### 3b. Column resolution and grid template +- [x] Write tests for `resolveColumns` (default columns, custom columns, showTime fallback, hidden columns) - `logstable/src/components/column-resolution.test.ts` -- **17 tests** +- [x] Implement `ResolvedColumn` interface and `resolveColumns` function - `logstable/src/components/column-resolution.ts` +- [x] Implement `buildGridTemplate` function from resolved columns - `logstable/src/components/column-resolution.ts` + +### 3c. LogLabelCell component +- [x] Write tests for LogLabelCell (renders value, renders em-dash for missing, wrap vs no-wrap) - `logstable/src/components/LogRow/LogLabelCell.test.tsx` -- **5 tests** +- [x] Implement LogLabelCell component with wrap/ellipsis styling - `logstable/src/components/LogRow/LogLabelCell.tsx` + +### 3d. LogsTableHeader component +- [x] Write tests for LogsTableHeader (renders column headers, sort indicators, click-to-sort) - `logstable/src/components/LogsTableHeader.test.tsx` -- **7 tests** +- [x] Implement LogsTableHeader with grid layout and MUI TableSortLabel - `logstable/src/components/LogsTableHeader.tsx` + +### 3e. LogRow dynamic columns and details panel +- [x] Update LogRow tests for dynamic columns (resolvedColumns prop, label columns, expanded details spanning full width) - `logstable/src/components/LogRow/LogRow.test.tsx` -- **3 new tests** +- [x] Make `LogRowContent` accept `gridTemplateColumns` string prop instead of computing it - `logstable/src/components/LogRow/LogsStyles.tsx` +- [x] Update LogRow to accept `resolvedColumns` and `gridTemplateColumns`, render columns dynamically - `logstable/src/components/LogRow/LogRow.tsx` +- [x] Replace hardcoded details alignment grid with full-width span (paddingLeft indent) - `logstable/src/components/LogRow/LogRow.tsx` + +### 3f. VirtualizedLogsList integration +- [x] Add header row, sort state management, column resolution, and sorted logs to VirtualizedLogsList - `logstable/src/components/VirtualizedLogsList.tsx` +- [x] Remove hardcoded timestamp sort from LogsTableComponent (sorting now in VirtualizedLogsList) - `logstable/src/LogsTableComponent.tsx` + +### Phase 3 Verification +- [x] `cd logstable && npm run type-check` — **passes** +- [x] `cd logstable && npm test` — **128 tests pass** (10 suites) + +--- + +## Phase 4: Integration Testing and Polish +Depends on: Phase 2, Phase 3 | Parallel with: none | Type: configuration | Projects: perses-plugins (logstable) + +- [x] Verify `createInitialOptions` still works with no `columns` field - `logstable/src/LogsTable.ts` -- **confirmed, no `columns` in initial options** +- [x] Verify copy functionality still works with custom columns - `logstable/src/utils/copyHelpers.ts` -- **confirmed, uses `labels` directly (0 refs to `columns`)** +- [x] Run npm install at repo root to update lock file for immer dependency -- **done** + +### Phase 4 Verification +- [x] `npm run build --workspace=logstable` — **passes** (33 files compiled, types emitted) +- [x] `cd logstable && npm test` — **128 tests pass** (10 suites) +- [x] `cd logstable && npm run lint` — **no lint errors** + +--- + +## Summary + +**Status:** Complete (4 of 4 phases done) + +### What was built + +- **Types & schema:** `LogsColumnDefinition` with name, header, enableSorting, sort, sortMode, allowWrap fields added to `LogsTableOptions.columns`. CUE schema updated. +- **Column editor UI:** Generic `ColumnsEditor` component (adapted from alertmanager PR #647) + `LogsTableColumnsEditor` wrapper with wrap content toggle. Registered as "Columns" tab. +- **Column rendering:** Dynamic grid-based columns in LogRow, LogLabelCell for label values (with wrap/ellipsis per column), LogsTableHeader with sticky sort indicators, full-width details panel on expand. +- **Sorting:** `compareLogsByColumn` with alphabetical/numeric/timestamp modes. Sort state in VirtualizedLogsList with click-to-toggle (asc → desc → none). +- **Backward compat:** No `columns` in initial options = default behavior (timestamp + log line). `showTime` respected when no columns configured. + +### Test coverage + +- 128 tests total (79 new across 6 test files) +- Sort comparators: 14 tests +- Column resolution: 17 tests +- LogLabelCell: 5 tests +- LogsTableHeader: 7 tests +- ColumnsEditor: 20 tests +- LogsTableColumnsEditor: 13 tests +- LogRow: 3 new tests (existing tests updated) + +### Git state + +``` +Branch: feat/logstable-column-settings (2 commits ahead of main) + 5406f6d feat: add column rendering, sorting, and header for logs table plugin + 15ca7ac feat: add column editor UI for logs table plugin (Phase 2) +``` + +### Outstanding items + +- [ ] Push branch and create PR on perses/plugins +- [ ] Manual testing with a live Perses instance (dev server + real log data) + +### Notes + +- `immer` added as a dependency to logstable/package.json (was already installed at workspace root) +- `jest.shared.ts` was fixed for Node 25 ESM compatibility (pre-existing issue with `__dirname`) +- Phase 1 types were committed as part of Phase 2/3 agent commits (direct execution, no separate commit) diff --git a/tasks/customize-columns-in-logs-table/plan.md b/tasks/customize-columns-in-logs-table/plan.md new file mode 100644 index 0000000..62cb772 --- /dev/null +++ b/tasks/customize-columns-in-logs-table/plan.md @@ -0,0 +1,798 @@ +# Plan: Customize Columns in Logs Table + +## Problem + +The logs table panel currently shows only two fixed columns: timestamp and log line. Log entries contain structured labels (e.g., `app`, `host`, `status`, `method`, `namespace`) that are only visible when expanding a row's details panel. Users need to see these labels as first-class columns to scan, sort, and compare log entries without expanding each row individually. + +## Current State + +| Component | File / Location | Current Behavior | +| --------- | --------------- | ---------------- | +| Panel options type | `logstable/src/model.ts:30-39` | `LogsTableOptions` has `showTime`, `allowWrap`, `enableDetails`, `showAll`, `selection`, `actions` — no column customization | +| Plugin registration | `logstable/src/LogsTable.ts:21-34` | Two editor tabs: "Settings" and "Item Actions". No column editor tab | +| Settings editor | `logstable/src/LogsTableSettingsEditor.tsx:26-47` | Legend + Thresholds only — no column controls | +| Row rendering | `logstable/src/components/LogRow/LogRow.tsx:56-368` | Fixed grid: `16px` (expand) + `minmax(160px, max-content)` (timestamp) + `1fr` (log line) + optional actions. No dynamic columns | +| Grid layout | `logstable/src/components/LogRow/LogsStyles.tsx:26-48` | `LogRowContent` styled component with hardcoded `gridTemplateColumns` | +| Data flow | `logstable/src/LogsTableComponent.tsx:19-43` | Flattens all query results, sorts by timestamp descending, passes to `LogsList` | +| Virtualized list | `logstable/src/components/VirtualizedLogsList.tsx:39-462` | Renders `LogRow` per entry via Virtuoso. No header row, no sort controls | +| Log entry type | `@perses-dev/spec` LogEntry | `{ timestamp: number; line: string; labels: Labels }` where `Labels = Record` | +| CUE schema | `logstable/schemas/logstable.cue:21-27` | `showTime`, `allowWrap`, `enableDetails`, `selection`, `actions` — no column settings | + +## Reference Implementation: Alertmanager Column Editor (PR #647) + +> Source: [perses/plugins#647](https://github.com/perses/plugins/pull/647) on branch `feat/alert-manager-plugin` + +The alertmanager plugin introduces a reusable generic `ColumnsEditor` component and an `AlertTableColumnsEditor` wrapper. This is the pattern we follow for the logs table — **not** the table plugin's `ColumnsEditor/ColumnEditorContainer/ColumnEditor` hierarchy, which is heavier (drag-and-drop, expand/collapse panels, embedded visualization editors). + +### Architecture overview + +``` +ColumnsEditor (generic, reusable) + └── ColumnEntry (per-column card with form fields) + ├── Name field (via renderNameField prop — caller decides the input type) + ├── Header text field + ├── Enable sorting checkbox + ├── Sort mode select + ├── Default sort select + └── Extra fields (via renderExtraFields prop — caller adds domain-specific controls) + +AlertTableColumnsEditor (alert-table-specific wrapper) + └── ColumnsEditor + renderNameField → + +LogsTableColumnsEditor (logs-table-specific wrapper) + └── ColumnsEditor + renderNameField → + renderExtraFields → +``` + +### Key files from PR #647 + +**Generic column editor — `alertmanager/src/components/ColumnsEditor.tsx`:** + +```typescript +export interface BaseColumnDefinition { + name: string; + header?: string; + enableSorting?: boolean; + sort?: 'asc' | 'desc'; + sortMode?: string; +} + +export type ColumnUpdater = (index: number, updater: (draft: C) => void) => void; + +export interface ColumnsEditorProps { + columns: C[]; + description: string; // help text above the column list + sortModeLabels: Record; // map of sortMode values → display labels + defaultSortMode: string; // initial sortMode for new columns + getDisplayName: (column: C) => string; // title shown in each column card + getHeaderPlaceholder: (column: C) => string; // placeholder for the header text field + onAdd: () => void; + onRemove: (index: number) => void; + onUpdate: ColumnUpdater; // immer-style draft updater + onMoveUp: (index: number) => void; + onMoveDown: (index: number) => void; + renderNameField: (column: C, index: number, onUpdate: ColumnUpdater) => ReactElement; + renderExtraFields?: (column: C, index: number, onUpdate: ColumnUpdater) => ReactElement; // domain-specific fields (e.g., wrap toggle) +} +``` + +Each `ColumnEntry` renders a bordered card (`Box` with `border: 1`) containing: +1. **Header row:** Column display name + ArrowUp/ArrowDown/Delete icon buttons +2. **Name + Header row:** Side-by-side text fields (name via `renderNameField` prop, header as plain `TextField`) +3. **Enable sorting:** `FormControlLabel` with `Checkbox` — checked by default (`enableSorting !== false`) +4. **Sort mode + Default sort:** Side-by-side `Select` dropdowns + +The list uses a stable `idCounterRef` + `idsRef` pattern for React keys (not array index) to avoid unmount/remount on reorder. + +**Alert-table-specific wrapper — `alertmanager/src/plugins/alert-table/AlertTableColumnsEditor.tsx`:** + +```typescript +const SORT_MODE_LABELS: Record = { + alphabetical: 'Alphabetical', + numeric: 'Numeric', + severity: 'Severity (critical → other)', +}; + +export function AlertTableColumnsEditor(props: OptionsEditorProps): ReactElement { + const { value, onChange } = props; + const columns = value.columns ?? []; + + const handleAddColumn = useCallback((): void => { + onChange(produce(value, (draft) => { + if (!draft.columns) draft.columns = []; + draft.columns.push({ name: 'severity' }); + })); + }, [value, onChange]); + + // handleRemoveColumn, handleUpdateColumn, handleMoveUp, handleMoveDown + // all use immer's `produce` for immutable state updates + + return ( + + columns={columns} + description="Status and Alert Name are always shown. Add extra columns below." + sortModeLabels={SORT_MODE_LABELS} + defaultSortMode="alphabetical" + getDisplayName={(col) => col.header || col.name || 'New column'} + getHeaderPlaceholder={(col) => col.name || 'Column header'} + onAdd={handleAddColumn} + onRemove={handleRemoveColumn} + onUpdate={handleUpdateColumn} + onMoveUp={handleMoveUp} + onMoveDown={handleMoveDown} + renderNameField={(col, index, onUpdate) => ( + onUpdate(index, (draft) => { draft.name = e.target.value; })} + size="small" fullWidth /> + )} + /> + ); +} +``` + +**Column data model — `alertmanager/src/plugins/alert-table/alert-table-model.ts`:** + +```typescript +export type SortDirection = 'asc' | 'desc'; +export type ColumnSortMode = 'alphabetical' | 'numeric' | 'severity'; + +export interface ColumnDefinition { + name: string; + header?: string; + enableSorting?: boolean; + sort?: SortDirection; + sortMode?: ColumnSortMode; +} + +export interface AlertTableOptions { + defaultGroupBy?: string[]; + columns?: ColumnDefinition[]; + // ... other options +} +``` + +**Sorting — `alertmanager/src/plugins/alert-table/alert-table-sorting.ts`:** + +```typescript +export interface SortState { + columnName: string; + direction: SortDirection; + mode: ColumnSortMode; +} + +// Comparators by mode: compareAlphabetical, compareNumeric, compareSeverity +// Main function: compareAlertsByColumn(a, b, sort) → number + +export function compareAlertsByColumn(a: Alert, b: Alert, sort: SortState): number { + const va = a.labels[sort.columnName]; + const vb = b.labels[sort.columnName]; + // switch on sort.mode, multiply by direction +} +``` + +**Sort state in AlertTablePanel — `alertmanager/src/plugins/alert-table/AlertTablePanel.tsx:426-440`:** + +```typescript +const initialSort = useMemo(() => { + const col = columnDefs.find((c) => c.sort && c.enableSorting !== false); + if (!col?.sort) return null; + return { columnName: col.name, direction: col.sort, mode: col.sortMode ?? 'alphabetical' }; +}, [columnDefs]); +const [sortState, setSortState] = useState(initialSort); + +const handleSortClick = useCallback((col: ColumnDefinition): void => { + setSortState((prev) => { + if (prev?.columnName === col.name) { + return prev.direction === 'asc' ? { ...prev, direction: 'desc' } : null; + } + return { columnName: col.name, direction: 'asc', mode: col.sortMode ?? 'alphabetical' }; + }); +}, []); +``` + +**Column header rendering with sort labels — `AlertTablePanel.tsx:621-638`:** + +```typescript +{columnDefs.map((col) => ( + + {col.enableSorting !== false ? ( + handleSortClick(col)}> + {col.header ?? col.name} + + ) : (col.header ?? col.name)} + +))} +``` + +**Column cell rendering in AlertRow — `AlertTablePanel.tsx:156-168`:** + +```typescript +{columnDefs.map((col) => { + const value = alert.labels[col.name]; + // render with color mapping if configured, else plain text + return {value ?? ''}; +})} +``` + +**CUE schema — `alertmanager/schemas/alert-table/alert-table.cue:19-25`:** + +```cue +columns?: [...close({ + name: string + header?: string + enableSorting?: bool + sort?: "asc" | "desc" + sortMode?: "alphabetical" | "numeric" | "severity" +})] +``` + +**Plugin registration — `alertmanager/src/plugins/alert-table/AlertTable.ts:28-33`:** + +```typescript +panelOptionsEditorComponents: [ + { label: 'General', content: AlertTableOptionsEditor }, + { label: 'Columns', content: AlertTableColumnsEditor }, + { label: 'Labels', content: AlertTableLabelsEditor }, + { label: 'Deduplication', content: AlertTableDeduplicationEditor }, +], +``` + +### Key differences from table plugin's column editor + +| Aspect | Table plugin | Alertmanager PR #647 | +| ------ | ------------ | -------------------- | +| Layout | Expand/collapse panels per column | Flat cards, always fully visible | +| Reorder | `DragButton` + `useDragAndDropMonitor` + `DragAndDropElement` from `@perses-dev/components` | Simple ArrowUp/ArrowDown `IconButton`s with stable ref-based key tracking | +| State updates | Direct array mutation (`onChange(updatedColumns)`) | `immer` `produce` for immutable drafts | +| Column fields | name, header, headerDescription, cellDescription, plugin, format, align, enableSorting, sort, width, hide, cellSettings, dataLink | name, header, enableSorting, sort, sortMode | +| Name field | Customizable (text input) | Customizable via `renderNameField` prop — caller controls the input | +| Generic | No (hardcoded `ColumnSettings` type) | Yes — `ColumnsEditor` | +| React keys | Array index (`key={i}`) | Stable `idCounterRef`/`idsRef` pattern | +| Dependencies | `@perses-dev/components` drag utilities | Only MUI + `@perses-dev/components` `OptionsEditorGroup` | + +**We follow the alertmanager pattern** because: (1) the spec says "similar to the alertmanager table", (2) it's simpler with fewer dependencies, (3) the generic `ColumnsEditor` component can be reused directly, and (4) the flat card layout is appropriate for our fewer column properties. + +## Changes + +### Phase 1: Types, Model, and CUE Schema + +**Dependency:** None +**Parallel with:** None + +#### Files Modified + +| File | Change | +| ---- | ------ | +| `logstable/src/model.ts` | Add `LogsColumnDefinition`, `LogsColumnSortMode`, `SortDirection` types and `columns` to `LogsTableOptions` | +| `logstable/schemas/logstable.cue` | Add column settings definition to spec | + +#### Details + +**TypeScript types** (`model.ts`): + +Follow the alertmanager's `ColumnDefinition` pattern. The logs table needs `alphabetical` and `numeric` sort modes (no `severity` — that's alert-specific). Add a `timestamp` sort mode for the built-in timestamp column: + +```typescript +export type SortDirection = 'asc' | 'desc'; + +export type LogsColumnSortMode = 'alphabetical' | 'numeric' | 'timestamp'; + +export interface LogsColumnDefinition { + name: string; // 'timestamp', 'line', or a label key + header?: string; // Display name. Defaults to name if unset + enableSorting?: boolean; // Default true (same as alertmanager) + sort?: SortDirection; // Default sort direction for this column + sortMode?: LogsColumnSortMode; // How to compare values. Default: 'alphabetical' + allowWrap?: boolean; // When true, content wraps (pre-wrap). When false, overflow hidden + ellipsis. Default: false +} +``` + +The `name` field identifies the column source: +- `"timestamp"` — the log entry's timestamp (sortMode defaults to `'timestamp'`) +- `"line"` — the log entry's message/line +- Any other string — treated as a label key from `LogEntry.labels` + +Add to `LogsTableOptions`: + +```typescript +export interface LogsTableOptions { + // ... existing fields ... + columns?: LogsColumnDefinition[]; +} +``` + +When `columns` is `undefined` or empty, the panel falls back to current default behavior (timestamp if `showTime=true`, then log line). When `columns` is defined, it determines exactly which columns are shown and in what order, overriding `showTime`. + +**CUE schema** (`logstable.cue`): + +```cue +package model + +import ( + "github.com/perses/shared/cue/common" +) + +kind: "LogsTable" +spec: close({ + allowWrap?: bool + enableDetails?: bool + showTime?: bool + columns?: [...close({ + name: string + header?: string + enableSorting?: bool + sort?: "asc" | "desc" + sortMode?: "alphabetical" | "numeric" | "timestamp" + allowWrap?: bool + })] + selection?: common.#selection + actions?: common.#actions +}) +``` + +#### Phase 1 Verification + +- `cd ./projects/perses-plugins/logstable && npx tsc --noEmit` — TypeScript compiles without errors +- CUE schema validates: `cd ./projects/perses-plugins/logstable && cue vet ./schemas/logstable.cue` + +--- + +### Phase 2: Column Editor UI + +**Dependency:** Phase 1 +**Parallel with:** None + +#### Files Modified + +| File | Change | +| ---- | ------ | +| `logstable/src/components/ColumnsEditor.tsx` | **New file.** Generic `ColumnsEditor` component — copy from alertmanager's `ColumnsEditor.tsx` | +| `logstable/src/LogsTableColumnsEditor.tsx` | **New file.** Logs-specific wrapper that uses `ColumnsEditor` | +| `logstable/src/LogsTable.ts` | Add "Columns" tab to `panelOptionsEditorComponents` | + +#### Details + +##### ColumnsEditor (generic, reusable) + +Copy `alertmanager/src/components/ColumnsEditor.tsx` from PR #647 into `logstable/src/components/ColumnsEditor.tsx`. Add one extension: an optional `renderExtraFields` prop on `ColumnsEditorProps` (and pass it through to `ColumnEntry`). When provided, `ColumnEntry` renders the extra fields after the sort mode/default sort row. This keeps the generic component reusable while allowing the logs table to add a wrap toggle. The alertmanager doesn't pass `renderExtraFields`, so its behavior is unchanged. + +Exports: `BaseColumnDefinition`, `ColumnUpdater`, `ColumnsEditorProps`, `ColumnsEditor`. + +##### LogsTableColumnsEditor (logs-specific wrapper) + +Create `logstable/src/LogsTableColumnsEditor.tsx` following the `AlertTableColumnsEditor` pattern: + +```typescript +import { Checkbox, FormControlLabel, TextField } from '@mui/material'; +import { OptionsEditorProps } from '@perses-dev/plugin-system'; +import { produce } from 'immer'; +import { ReactElement, useCallback } from 'react'; +import { ColumnsEditor } from './components/ColumnsEditor'; +import { LogsTableOptions, LogsColumnDefinition, LogsColumnSortMode } from './model'; + +const SORT_MODE_LABELS: Record = { + alphabetical: 'Alphabetical', + numeric: 'Numeric', + timestamp: 'Timestamp', +}; + +export function LogsTableColumnsEditor(props: OptionsEditorProps): ReactElement { + const { value, onChange } = props; + const columns = value.columns ?? []; + + const handleAddColumn = useCallback((): void => { + onChange(produce(value, (draft) => { + if (!draft.columns) draft.columns = []; + draft.columns.push({ name: '' }); + })); + }, [value, onChange]); + + const handleRemoveColumn = useCallback((index: number): void => { + onChange(produce(value, (draft) => { + draft.columns?.splice(index, 1); + })); + }, [value, onChange]); + + const handleUpdateColumn = useCallback( + (index: number, updater: (draft: LogsColumnDefinition) => void): void => { + onChange(produce(value, (draft) => { + const column = draft.columns?.[index]; + if (column) updater(column); + })); + }, [value, onChange]); + + const handleMoveUp = useCallback((index: number): void => { + if (index <= 0) return; + onChange(produce(value, (draft) => { + if (!draft.columns) return; + const item = draft.columns.splice(index, 1)[0]!; + draft.columns.splice(index - 1, 0, item); + })); + }, [value, onChange]); + + const handleMoveDown = useCallback((index: number): void => { + onChange(produce(value, (draft) => { + if (!draft.columns || index >= draft.columns.length - 1) return; + const item = draft.columns.splice(index, 1)[0]!; + draft.columns.splice(index + 1, 0, item); + })); + }, [value, onChange]); + + return ( + + columns={columns} + description="Timestamp and Log line are shown by default. Add columns below to customize which columns are visible and their order." + sortModeLabels={SORT_MODE_LABELS} + defaultSortMode="alphabetical" + getDisplayName={(col) => col.header || col.name || 'New column'} + getHeaderPlaceholder={(col) => col.name || 'Column header'} + onAdd={handleAddColumn} + onRemove={handleRemoveColumn} + onUpdate={handleUpdateColumn} + onMoveUp={handleMoveUp} + onMoveDown={handleMoveDown} + renderNameField={(col, index, onUpdate) => ( + onUpdate(index, (draft) => { draft.name = e.target.value; })} + size="small" + fullWidth + helperText="Use 'timestamp', 'line', or a label key" + /> + )} + renderExtraFields={(col, index, onUpdate) => ( + + onUpdate(index, (draft) => { + draft.allowWrap = e.target.checked || undefined; + }) + } + size="small" + /> + } + label="Wrap content" + /> + )} + /> + ); +} +``` + +##### Plugin registration update + +In `LogsTable.ts`, add the new tab: + +```typescript +panelOptionsEditorComponents: [ + { label: 'Settings', content: LogsTableSettingsEditor }, + { label: 'Columns', content: LogsTableColumnsEditor }, + { label: 'Item Actions', content: LogsTableItemSelectionActionsEditor }, +], +``` + +##### Dependency: `immer` + +Check if `immer` is already a dependency of `logstable/package.json`. The alertmanager plugin uses `immer`'s `produce` for all state updates in the columns editor. If not present, add it as a dependency (it's already used by other plugins in the monorepo). + +#### Phase 2 Verification + +- `cd ./projects/perses-plugins/logstable && npx tsc --noEmit` — TypeScript compiles +- Manual: open the logs table panel editor and verify the "Columns" tab appears +- Manual: add, remove, reorder columns in the editor; verify options are persisted in the panel spec JSON + +--- + +### Phase 3: Column Rendering and Sorting + +**Dependency:** Phase 1 +**Parallel with:** Phase 2 (touches different files) + +#### Files Modified + +| File | Change | +| ---- | ------ | +| `logstable/src/components/LogRow/LogRow.tsx` | Accept resolved column definitions, render dynamic columns | +| `logstable/src/components/LogRow/LogsStyles.tsx` | Make `LogRowContent` grid template dynamic — accept `gridTemplateColumns` string prop | +| `logstable/src/components/VirtualizedLogsList.tsx` | Add header row, sort state, resolve column config from spec, apply sort | +| `logstable/src/components/LogsTableHeader.tsx` | **New file.** Header row with column names and `TableSortLabel`-style sort indicators | +| `logstable/src/components/LogRow/LogLabelCell.tsx` | **New file.** Renders a label value cell | +| `logstable/src/components/logs-table-sorting.ts` | **New file.** Sort comparators following alertmanager's `alert-table-sorting.ts` pattern | +| `logstable/src/LogsTableComponent.tsx` | Remove hardcoded timestamp sort — let VirtualizedLogsList handle sorting | + +#### Details + +##### Column resolution logic + +In `VirtualizedLogsList`, resolve the effective columns from `spec.columns`: + +```typescript +interface ResolvedColumn { + name: string; + header: string; + type: 'timestamp' | 'line' | 'label'; + enableSorting: boolean; + sortMode: LogsColumnSortMode; + allowWrap: boolean; + width?: number; +} +``` + +When `spec.columns` is undefined or empty, produce default columns: +- If `spec.showTime !== false`: `{ name: 'timestamp', header: 'Timestamp', type: 'timestamp', enableSorting: true, sortMode: 'timestamp' }` +- Always: `{ name: 'line', header: 'Log line', type: 'line', enableSorting: false, sortMode: 'alphabetical' }` + +When `spec.columns` is defined, map each entry to a `ResolvedColumn`, determining `type` from the `name` field. The `sortMode` defaults to `'timestamp'` for the timestamp column and `'alphabetical'` for all others, unless explicitly set. + +##### Grid layout changes + +`LogRowContent` currently has a hardcoded grid. The grid template needs to become dynamic: + +- Expand button column: `16px` (always present when `isExpandable`) +- For each resolved column: + - `timestamp` type: `minmax(160px, max-content)` + - `label` type: `minmax(80px, max-content)` + - `line` type: `1fr` (fills remaining space) +- Copy/action area: `min-content` + +`LogsStyles.tsx:LogRowContent` will accept a `gridTemplateColumns` string prop instead of computing it internally: + +```typescript +export const LogRowContent = styled(Box, { + shouldForwardProp: (prop) => + prop !== 'gridTemplateColumns' && prop !== 'isHighlighted' && prop !== 'isSelected', +})<{ gridTemplateColumns: string; isHighlighted?: boolean; isSelected?: boolean }>( + ({ theme, gridTemplateColumns, isHighlighted, isSelected }) => ({ + display: 'grid', + gridTemplateColumns, + // ... rest stays the same + }) +); +``` + +The parent (`VirtualizedLogsList`) computes the grid template string from the resolved columns and passes it down. + +##### LogRow changes + +`LogRow` currently renders timestamp and log line in fixed positions. With dynamic columns: + +1. Accept `resolvedColumns: ResolvedColumn[]` and `gridTemplateColumns: string` props +2. After the optional expand button, iterate over `resolvedColumns` and render each: + - `timestamp` → `` + - `line` → `` with ANSI rendering (existing code, wrapped in a Box with copy menu and action buttons) + - `label` → `` (new component) +3. The copy menu and action buttons remain associated with the log line area + +The `LogLabelCell` component renders a monospace text value styled consistently with `LogText`. For missing labels, it renders `—` (em-dash). + +**Wrap behavior per column** (`allowWrap` on `LogsColumnDefinition`): +- `allowWrap: false` (default): `overflow: hidden; text-overflow: ellipsis; white-space: nowrap` — long values are truncated with an ellipsis. The full value is shown in a tooltip on hover. +- `allowWrap: true`: `word-break: break-word; white-space: pre-wrap; overflow: visible` — content wraps to new lines (same CSS as the existing `LogText` wrap mode in `LogsStyles.tsx:67-74`). + +This mirrors the existing global `allowWrap` option on `LogsTableOptions` but at the per-column level. The `line` column inherits the global `spec.allowWrap` when no per-column `allowWrap` is set; label columns default to `false` (no wrap) unless explicitly toggled. + +##### Expanded details panel (enableDetails) + +Currently, when a row is expanded, the `Collapse` section in `LogRow.tsx:343-363` renders `LogDetailsTable` (all labels as key-value pairs). This `Collapse` is a sibling of `LogRowContent` inside `LogRowContainer` — it sits below the grid row. The current code uses an inner alignment grid that matches the old fixed columns: + +```typescript +// Current code (LogRow.tsx:345-361) — broken with dynamic columns + + {showTime && (<>)} // spacer boxes to align under log line + + +``` + +This hardcoded alignment grid must be replaced. With dynamic columns, the details panel should **span the full width** of all columns rather than trying to align under a specific column: + +```typescript +// New code — details span all columns + + + + + +``` + +The `paddingLeft` indents the details to visually nest under the row content (past the expand chevron). The details table itself takes the full container width, displaying all extracted labels regardless of which columns are configured. This is the correct behavior because: + +1. Custom columns show a **subset** of labels inline — the details panel shows the **complete** set +2. Trying to column-align the details under dynamic columns adds complexity for no UX benefit +3. The full-width layout is consistent with how details/expansion panels work in other Perses table plugins + +##### Header row + +`LogsTableHeader` renders a fixed header row above the Virtuoso list, using the same grid template. Following the alertmanager's pattern (`AlertTablePanel.tsx:619-638`), each header cell shows the column's `header` text. If `enableSorting` is true, uses MUI's `TableSortLabel` for sort direction indicators and click handling. + +The header row lives outside the Virtuoso component (above it in the flex column) so it doesn't scroll. + +```typescript +interface LogsTableHeaderProps { + resolvedColumns: ResolvedColumn[]; + gridTemplateColumns: string; + isExpandable: boolean; + sortState: SortState | null; + onSortClick: (column: ResolvedColumn) => void; +} +``` + +##### Sorting + +Follow the alertmanager's sorting pattern exactly. + +**Sort state type** (`logs-table-sorting.ts`): + +```typescript +export interface SortState { + columnName: string; + direction: SortDirection; + mode: LogsColumnSortMode; +} +``` + +**Sort comparators** (`logs-table-sorting.ts`): + +```typescript +export function compareLogsByColumn(a: LogEntry, b: LogEntry, sort: SortState): number { + let va: string | number | undefined; + let vb: string | number | undefined; + + if (sort.columnName === 'timestamp') { + va = a.timestamp; + vb = b.timestamp; + } else if (sort.columnName === 'line') { + va = a.line; + vb = b.line; + } else { + va = a.labels[sort.columnName]; + vb = b.labels[sort.columnName]; + } + + // switch on sort.mode: 'timestamp' (numeric comparison on timestamp), + // 'numeric' (parseFloat), 'alphabetical' (localeCompare) + // multiply result by direction multiplier +} +``` + +**Sort state management in VirtualizedLogsList** (following `AlertTablePanel.tsx:426-440`): + +```typescript +const initialSort = useMemo(() => { + if (!resolvedColumns.length) return { columnName: 'timestamp', direction: 'desc', mode: 'timestamp' }; + const col = resolvedColumns.find((c) => c.enableSorting && /* has default sort from spec */); + if (col) return { columnName: col.name, direction: col.sort!, mode: col.sortMode }; + return { columnName: 'timestamp', direction: 'desc', mode: 'timestamp' }; +}, [resolvedColumns]); + +const [sortState, setSortState] = useState(initialSort); + +const handleSortClick = useCallback((col: ResolvedColumn): void => { + setSortState((prev) => { + if (prev?.columnName === col.name) { + return prev.direction === 'asc' ? { ...prev, direction: 'desc' } : null; + } + return { columnName: col.name, direction: 'asc', mode: col.sortMode }; + }); +}, []); + +// Apply sort to logs +const sortedLogs = useMemo(() => { + if (!sortState) return logs; + return [...logs].sort((a, b) => compareLogsByColumn(a, b, sortState)); +}, [logs, sortState]); +``` + +**Move sorting out of LogsTableComponent:** Currently `LogsTableComponent.tsx:23-25` sorts by timestamp. Remove that sort and let `VirtualizedLogsList` handle it via `sortState`, so sorting is unified and controllable by the user. + +##### Component prop threading + +Update the component chain to pass columns through: + +1. `LogsTableComponent` → `LogsList`: already passes `spec` +2. `LogsList` → `VirtualizedLogsList`: already passes `spec` +3. `VirtualizedLogsList`: resolves columns from `spec`, manages sort state, computes grid template, renders header + list +4. `VirtualizedLogsList` → `LogRow`: add `resolvedColumns` and `gridTemplateColumns` props +5. `LogRow`: renders cells dynamically + +#### Phase 3 Verification + +- `cd ./projects/perses-plugins/logstable && npx tsc --noEmit` — TypeScript compiles +- Manual: with no `columns`, verify default behavior is unchanged (timestamp + log line, sorted by timestamp desc) +- Manual: add columns (e.g., `app`, `status`, `method`) and verify they appear with label values +- Manual: click a sortable column header and verify sort order toggles (asc → desc → none) +- Manual: verify expanded details panel spans full width across all columns and shows all labels (not just configured columns) + +--- + +### Phase 4: Integration Testing and Polish + +**Dependency:** Phase 2, Phase 3 +**Parallel with:** None + +#### Files Modified + +| File | Change | +| ---- | ------ | +| `logstable/src/LogsTable.ts` | Verify `createInitialOptions` still works, ensure backward compat | +| `logstable/src/utils/copyHelpers.ts` | May need updates if copy format should include custom column values | + +#### Details + +- Verify backward compatibility: panels with no `columns` in their spec should render identically to before +- Verify the column editor persists settings correctly in the panel spec JSON +- Verify `showTime` is respected when no `columns` is defined +- Verify copy functionality still works with custom columns (log copy should include all labels regardless of visible columns) +- Test with different datasource results that have varying label sets (some entries missing labels that are configured as columns) +- Verify row expand/collapse and details table still works alongside custom columns + +#### Phase 4 Verification + +- `cd ./projects/perses-plugins && npm run build` — full build passes +- Run the dev server with a dashboard containing a logs table panel and test all interactions + +## PR Strategy + +| PR | Repository | Branch | Description | Dependencies | +| -- | ---------- | ------ | ----------- | ------------ | +| 1 | perses/plugins | `feat/logstable-column-settings` | Add customizable column support to logs table panel | None | + +All changes are in a single repository (perses-plugins) and can be delivered in a single PR. The generic `ColumnsEditor` component is copied into the logstable plugin (same pattern as alertmanager keeping its own copy). No upstream `@perses-dev/*` package changes are needed. + +## Verification + +End-to-end verification mapped to acceptance criteria: + +- **"The logs table supports customizing the columns to show in the table, including the log line, timestamp and extracted labels"** + - Create a logs table panel, open editor, go to "Columns" tab + - Add columns: `timestamp`, `line`, `app`, `status` + - Verify all four columns render with correct values from log entries + - Remove `timestamp` column, verify it disappears + - Verify labels with missing values show `—` + +- **"The edit should be similar to the alertmanager table"** + - Compare the column editor UI side-by-side with the alertmanager table's column editor + - Verify: add/delete columns, ArrowUp/ArrowDown reorder, header text, enable sorting checkbox, sort mode and default sort selects all work identically + - Verify the same card-based layout with bordered cards and dividers between entries + +- **"The user should be able to save the configuration for the panel"** + - Add column settings, save the dashboard + - Reload the page, verify column settings persist + - Inspect the dashboard JSON, verify `columns` array is in the panel spec + +- **"The column editor should be added as a new tab in the panel editor"** + - Open the logs table panel editor + - Verify three tabs: "Settings", "Columns", "Item Actions" + +- **Backward compatibility** + - Load an existing dashboard with a logs table panel that has no `columns` + - Verify it renders with default timestamp + log line (unchanged) + +- **Column sort** + - Configure a column with `enableSorting: true` + - Click the column header, verify rows sort ascending + - Click again, verify descending + - Click again, verify returns to default sort (timestamp desc) + +## Risks + +| Risk | Impact | Mitigation | +| ---- | ------ | ---------- | +| Performance with many label columns | Grid with many columns + virtualized list could degrade scroll performance | Keep column cells lightweight (plain text), avoid heavy re-renders. Test with 10+ columns and 1000+ log entries | +| Variable label sets across log entries | Different log entries may have different labels, causing empty cells | Render `—` for missing labels. Document this behavior | +| Grid layout breaks with very long label values | Long label values could overflow or push columns off-screen | Use `overflow: hidden; text-overflow: ellipsis` on label cells. Tooltip on hover for full value | +| `showTime` and `columns` interaction confusion | Users may set `showTime: false` but also add a `timestamp` column in settings | When `columns` is defined, it takes precedence over `showTime`. Document this | +| CUE schema backward compatibility | Existing dashboards with logs table panels must validate against updated schema | `columns` is optional, so existing specs without it remain valid | +| `immer` dependency | logstable may not currently depend on `immer` | Check `package.json`; add if missing. `immer` is already used by other plugins in the monorepo (alertmanager, table) | diff --git a/tasks/customize-columns-in-logs-table/spec.md b/tasks/customize-columns-in-logs-table/spec.md new file mode 100644 index 0000000..103821c --- /dev/null +++ b/tasks/customize-columns-in-logs-table/spec.md @@ -0,0 +1,23 @@ +# Spec: Customize columns in logs table + +## Related projects and branches + +- perses-plugins: branch `main` + +## Description + +The logs table, that supports many datsources includes a column for the log line and a column for the timestamp. We want to add support to add custom +columns from labels or remove the default columns. This will allow users to see the extracted labels in a more structured way and be able to sort and +filter by them. + +## Acceptance criteria + +- The logs table supports customizing the columns to show in the table, including the log line, timestamp and extracted labels. +- The edit should be similar to the alertmanager table, where the user can select which columns to show and in which order. The user should be able to + save the configuration for the panel. +- The column editor should be added as a new tab in the panel editor. + +## Hints + +- The alert manager column editor is in + `https://github.com/perses/plugins/pull/647/changes#diff-e428cf7c445b42c208064b4b228d820f7329e1594d21b206c72f1e525707c087` diff --git a/tools/obs/AGENTS.md b/tools/obs/AGENTS.md new file mode 100644 index 0000000..9e352d4 --- /dev/null +++ b/tools/obs/AGENTS.md @@ -0,0 +1,63 @@ +# obs — Agent Guide + +obs is a CLI tool for running development and deployment recipes for Observability UI projects. + +## Quick Reference + +```bash +# List available recipes +./bin/obs list + +# Start a recipe (interactive TUI) +./bin/obs start [flags] + +# Start in non-interactive mode (for agents/CI) +./bin/obs start --non-interactive + +# Deploy recipes +./bin/obs deploy [flags] + +# Multiple recipes at once +./bin/obs start mp con + +# Per-recipe flags +./bin/obs start mp --version=4.18 con --version=4.18 + +# Dry run (show what would happen without executing) +./bin/obs start mp --dry-run + +# JSON output (for machine parsing) +./bin/obs start mp --non-interactive --output-json + +# Detach (background processes) +./bin/obs start mp --detach + +# Force (kill processes on busy ports) +./bin/obs start mp --force + +# Check status of running processes +./bin/obs status + +# Attach to running processes +./bin/obs attach + +# Clean up +./bin/obs cleanup [--force] +``` + +## For Agents + +- Always use `--non-interactive` or `--output-json` when invoking from an agent. +- Use `--dry-run` to preview what a recipe will do before running it. +- Use `--force` to kill processes on busy ports before starting. +- Exit codes: 0 = success, 1 = recipe failure, 2 = requirements not met. +- JSON output emits one JSON object per line with fields: `type`, `step`, `status`, `error`. + +## Adding New Recipes + +1. Create a new directory under `tools/obs/recipes/` (e.g., `recipes/coo/`). +2. Implement the `recipe.Recipe` interface in a `start.go` and/or `deploy.go` file. +3. Register it in `recipes/register.go`. +4. Build: `make obs` + +See `recipes/mp/start.go` for a complete example. diff --git a/tools/obs/README.md b/tools/obs/README.md new file mode 100644 index 0000000..8af7c0d --- /dev/null +++ b/tools/obs/README.md @@ -0,0 +1,45 @@ +# obs + +CLI tool for the Observability UI team to run development and deployment recipes. + +## Build + +```bash +make obs +``` + +## Usage + +```bash +# List available recipes +./bin/obs list + +# Start development servers +./bin/obs start monitoring-plugin +./bin/obs start mp con # multiple recipes + +# Deploy to cluster +./bin/obs deploy coo --mode=bundle + +# See all options +./bin/obs --help +``` + +## Modes + +- **Interactive** (default): Bubbletea TUI with tabs per process, spinners, keyboard navigation. +- **Non-interactive** (`--non-interactive` or non-TTY): Docker Compose-style prefixed output. +- **JSON** (`--output-json`): Machine-readable JSON lines for CI/agents. +- **Detach** (`--detach`): Start processes in background, exit immediately. + +## Architecture + +- `cmd/obs/` — Entry point +- `internal/cli/` — Cobra commands +- `internal/recipe/` — Recipe interface, registry, engine +- `internal/process/` — Process lifecycle, ring buffer, port checks +- `internal/ui/` — Bubbletea TUI components +- `internal/runner/` — Interactive and non-interactive runners +- `internal/state/` — .obs/ state directory management +- `internal/output/` — JSON output emitter +- `recipes/` — Recipe implementations diff --git a/tools/obs/cmd/obs/main.go b/tools/obs/cmd/obs/main.go new file mode 100644 index 0000000..980a2b3 --- /dev/null +++ b/tools/obs/cmd/obs/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "obs/internal/cli" + _ "obs/recipes" // registers all built-in recipes +) + +var version = "dev" + +func main() { + cli.Execute(version) +} diff --git a/tools/obs/go.mod b/tools/obs/go.mod new file mode 100644 index 0000000..86799da --- /dev/null +++ b/tools/obs/go.mod @@ -0,0 +1,35 @@ +module obs + +go 1.25.5 + +require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/tools/obs/go.sum b/tools/obs/go.sum new file mode 100644 index 0000000..8ba1ac8 --- /dev/null +++ b/tools/obs/go.sum @@ -0,0 +1,61 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/tools/obs/internal/cli/attach.go b/tools/obs/internal/cli/attach.go new file mode 100644 index 0000000..1a03f77 --- /dev/null +++ b/tools/obs/internal/cli/attach.go @@ -0,0 +1,57 @@ +package cli + +import ( + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "github.com/spf13/cobra" + "obs/internal/state" +) + +func newAttachCmd() *cobra.Command { + return &cobra.Command{ + Use: "attach", + Short: "Attach to running processes and show their output", + RunE: func(cmd *cobra.Command, args []string) error { + store := state.NewStore(state.DefaultStateDir()) + rs, err := store.Load() + if err != nil { + return fmt.Errorf("no active processes to attach to") + } + rs = store.FilterAlive(rs) + if len(rs.Processes) == 0 { + return fmt.Errorf("no running processes found") + } + + fmt.Println("Attached to running processes. Press Ctrl+C to detach.") + state.PrintStatus(rs) + + // Wait for interrupt + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT) + + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + select { + case <-sigCh: + fmt.Println("\nDetached.") + return nil + case <-ticker.C: + rs, _ = store.Load() + if rs != nil { + rs = store.FilterAlive(rs) + if len(rs.Processes) == 0 { + fmt.Println("All processes have exited.") + return nil + } + } + } + } + }, + } +} diff --git a/tools/obs/internal/cli/cleanup.go b/tools/obs/internal/cli/cleanup.go new file mode 100644 index 0000000..6571d39 --- /dev/null +++ b/tools/obs/internal/cli/cleanup.go @@ -0,0 +1,52 @@ +package cli + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "obs/internal/process" + "obs/internal/state" +) + +func newCleanupCmd() *cobra.Command { + var force bool + cmd := &cobra.Command{ + Use: "cleanup [recipe]", + Short: "Stop all processes and clean up state", + RunE: func(cmd *cobra.Command, args []string) error { + store := state.NewStore(state.DefaultStateDir()) + rs, err := store.Load() + if err != nil { + fmt.Println("Nothing to clean up.") + return nil + } + rs = store.FilterAlive(rs) + + if len(rs.Processes) > 0 && !force { + fmt.Printf("This will stop %d running process(es). Continue? [y/N] ", len(rs.Processes)) + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(answer)), "y") { + fmt.Println("Aborted.") + return nil + } + } + + for _, p := range rs.Processes { + if process.IsAlive(p.PID) { + fmt.Printf("Stopping %s (PID %d)…\n", p.Name, p.PID) + process.KillGroup(p.PID) + } + store.RemovePID(p.Name) + } + store.Clean() + fmt.Println("Cleanup complete.") + return nil + }, + } + cmd.Flags().BoolVar(&force, "force", false, "skip confirmation") + return cmd +} diff --git a/tools/obs/internal/cli/list.go b/tools/obs/internal/cli/list.go new file mode 100644 index 0000000..315b853 --- /dev/null +++ b/tools/obs/internal/cli/list.go @@ -0,0 +1,30 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "obs/internal/recipe" +) + +func newListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List available recipes", + Run: func(cmd *cobra.Command, args []string) { + entries := recipe.DefaultRegistry.ListAll() + if len(entries) == 0 { + fmt.Println("No recipes registered.") + return + } + for _, entry := range entries { + aliases := "" + if len(entry.Recipe.Aliases()) > 0 { + aliases = " (" + strings.Join(entry.Recipe.Aliases(), ", ") + ")" + } + fmt.Printf(" %-8s %-25s %s%s\n", entry.Command, entry.Recipe.Name(), entry.Recipe.Description(), aliases) + } + }, + } +} diff --git a/tools/obs/internal/cli/recipe_cmd.go b/tools/obs/internal/cli/recipe_cmd.go new file mode 100644 index 0000000..38ba428 --- /dev/null +++ b/tools/obs/internal/cli/recipe_cmd.go @@ -0,0 +1,146 @@ +package cli + +import ( + "context" + "fmt" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/spf13/cobra" + "obs/internal/output" + "obs/internal/process" + "obs/internal/recipe" + "obs/internal/runner" + "obs/internal/state" +) + +func newRecipeCmd(command, shortDesc string) *cobra.Command { + cmd := &cobra.Command{ + Use: command + " [recipe...] [flags]", + Short: shortDesc, + DisableFlagParsing: true, + RunE: func(_ *cobra.Command, args []string) error { + return runRecipes(command, args) + }, + } + return cmd +} + +func runRecipes(command string, args []string) error { + // Parse global flags from args before filtering + var filteredArgs []string + for _, arg := range args { + switch arg { + case "--dry-run": + dryRun = true + case "--non-interactive": + nonInteractive = true + case "--detach": + detach = true + case "--output-json": + outputJSON = true + case "--force": + force = true + case "--help", "-h": + // Skip help flag - it shouldn't reach here but just in case + default: + filteredArgs = append(filteredArgs, arg) + } + } + + segments, err := recipe.ParseRecipeArgs(recipe.DefaultRegistry, command, filteredArgs) + if err != nil { + return err + } + + portCheck := process.CheckPorts + if force { + portCheck = process.FreePorts + } + + eng := recipe.NewEngine() + prepare := func() ([]*recipe.Step, error) { + return eng.Prepare(segments, dryRun, portCheck) + } + + // Set up signal handling + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + cancel() + }() + + mgr := process.NewManager() + defer mgr.StopAll() + + updates := make(chan recipe.StepUpdate, 100) + + // Interactive mode handles requirements inside the TUI + if !nonInteractive && !outputJSON && !detach && !dryRun && runner.IsTerminal() { + return runner.RunInteractive(ctx, mgr, prepare, updates) + } + + // Non-interactive: prepare upfront, exit on failure + ordered, err := prepare() + if err != nil { + if reqErr, ok := err.(*recipe.RequirementsError); ok { + fmt.Fprintln(os.Stderr, reqErr) + os.Exit(2) + } + return err + } + + if dryRun { + fmt.Println("Dry run — steps that would execute:") + for i, step := range ordered { + fmt.Printf(" %d. %s\n", i+1, step.Name) + for _, spec := range step.Processes { + fmt.Printf(" $ %s %s\n", spec.Command, strings.Join(spec.Args, " ")) + if len(spec.Ports) > 0 { + fmt.Printf(" ports: %v\n", spec.Ports) + } + } + } + return nil + } + + // JSON output mode + if outputJSON { + emitter := output.NewJSONEmitter(os.Stdout) + go func() { + for u := range updates { + ev := output.Event{Type: "step_status", Step: u.StepName, Status: u.Status.String()} + if u.Err != nil { + ev.Error = u.Err.Error() + } + emitter.Emit(ev) + } + }() + } + + // Select runner + var r runner.Runner + if detach { + r = runner.NewDetach(state.DefaultStateDir()) + } else { + r = runner.NewNonInteractive(os.Stdout) + go func() { + for u := range updates { + if u.Err != nil { + fmt.Fprintf(os.Stderr, "[%s] %s: %v\n", u.Status, u.StepName, u.Err) + } else { + fmt.Fprintf(os.Stderr, "[%s] %s\n", u.Status, u.StepName) + } + } + }() + } + + return r.Run(ctx, mgr, ordered, updates) +} + diff --git a/tools/obs/internal/cli/root.go b/tools/obs/internal/cli/root.go new file mode 100644 index 0000000..fc1f845 --- /dev/null +++ b/tools/obs/internal/cli/root.go @@ -0,0 +1,47 @@ +package cli + +import ( + "os" + + "github.com/spf13/cobra" +) + +var ( + nonInteractive bool + outputJSON bool + dryRun bool + detach bool + force bool +) + +func NewRootCmd(version string) *cobra.Command { + root := &cobra.Command{ + Use: "obs", + Short: "Observability UI development tool", + Long: "A CLI tool to run recipes for developing, deploying, and managing Observability UI projects.", + SilenceUsage: true, + } + + root.PersistentFlags().BoolVar(&nonInteractive, "non-interactive", false, "force non-interactive mode") + root.PersistentFlags().BoolVar(&outputJSON, "output-json", false, "emit JSON events instead of terminal output") + root.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "show what would run without executing") + root.PersistentFlags().BoolVar(&detach, "detach", false, "start processes in background and exit") + root.PersistentFlags().BoolVar(&force, "force", false, "kill processes on busy ports before starting") + + root.AddCommand(newVersionCmd(version)) + root.AddCommand(newListCmd()) + root.AddCommand(newRecipeCmd("start", "Start development processes")) + root.AddCommand(newRecipeCmd("deploy", "Deploy to an OpenShift cluster")) + root.AddCommand(newStatusCmd()) + root.AddCommand(newAttachCmd()) + root.AddCommand(newCleanupCmd()) + + return root +} + +func Execute(version string) { + root := NewRootCmd(version) + if err := root.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/tools/obs/internal/cli/status.go b/tools/obs/internal/cli/status.go new file mode 100644 index 0000000..7d9a4c2 --- /dev/null +++ b/tools/obs/internal/cli/status.go @@ -0,0 +1,26 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + "obs/internal/state" +) + +func newStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status [recipe]", + Short: "Show status of running processes", + RunE: func(cmd *cobra.Command, args []string) error { + store := state.NewStore(state.DefaultStateDir()) + rs, err := store.Load() + if err != nil { + fmt.Println("No active processes.") + return nil + } + rs = store.FilterAlive(rs) + state.PrintStatus(rs) + return nil + }, + } +} diff --git a/tools/obs/internal/cli/version.go b/tools/obs/internal/cli/version.go new file mode 100644 index 0000000..daf2dad --- /dev/null +++ b/tools/obs/internal/cli/version.go @@ -0,0 +1,17 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func newVersionCmd(version string) *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Show obs version", + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("obs %s\n", version) + }, + } +} diff --git a/tools/obs/internal/output/json.go b/tools/obs/internal/output/json.go new file mode 100644 index 0000000..3a2a62f --- /dev/null +++ b/tools/obs/internal/output/json.go @@ -0,0 +1,32 @@ +package output + +import ( + "encoding/json" + "io" + "sync" +) + +type Event struct { + Type string `json:"type"` + Step string `json:"step,omitempty"` + Process string `json:"process,omitempty"` + Status string `json:"status,omitempty"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` + PID int `json:"pid,omitempty"` +} + +type JSONEmitter struct { + mu sync.Mutex + enc *json.Encoder +} + +func NewJSONEmitter(w io.Writer) *JSONEmitter { + return &JSONEmitter{enc: json.NewEncoder(w)} +} + +func (e *JSONEmitter) Emit(ev Event) { + e.mu.Lock() + defer e.mu.Unlock() + e.enc.Encode(ev) +} diff --git a/tools/obs/internal/output/json_test.go b/tools/obs/internal/output/json_test.go new file mode 100644 index 0000000..7a31cd7 --- /dev/null +++ b/tools/obs/internal/output/json_test.go @@ -0,0 +1,40 @@ +package output_test + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "obs/internal/output" + "obs/internal/recipe" +) + +func TestJSONEmitter(t *testing.T) { + var buf bytes.Buffer + emitter := output.NewJSONEmitter(&buf) + + emitter.Emit(output.Event{ + Type: "step_status", + Step: "install-clo", + Status: recipe.StatusRunning.String(), + }) + emitter.Emit(output.Event{ + Type: "step_status", + Step: "install-clo", + Status: recipe.StatusDone.String(), + }) + + lines := strings.Split(strings.TrimSpace(buf.String()), "\n") + if len(lines) != 2 { + t.Fatalf("expected 2 JSON lines, got %d", len(lines)) + } + + var ev output.Event + if err := json.Unmarshal([]byte(lines[0]), &ev); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if ev.Type != "step_status" || ev.Step != "install-clo" { + t.Fatalf("unexpected event: %+v", ev) + } +} diff --git a/tools/obs/internal/process/manager.go b/tools/obs/internal/process/manager.go new file mode 100644 index 0000000..33f0ec2 --- /dev/null +++ b/tools/obs/internal/process/manager.go @@ -0,0 +1,110 @@ +package process + +import ( + "context" + "fmt" + "io" + "sync" + + "obs/internal/recipe" +) + +const DefaultMaxLogLines = 10000 + +type Manager struct { + mu sync.RWMutex + processes map[string]*Process +} + +func NewManager() *Manager { + return &Manager{ + processes: make(map[string]*Process), + } +} + +func (m *Manager) StartProcess(ctx context.Context, spec recipe.ProcessSpec, writers ...io.Writer) (*Process, error) { + m.mu.Lock() + if existing, ok := m.processes[spec.Name]; ok && existing.Running() { + m.mu.Unlock() + return nil, fmt.Errorf("process %q is already running", spec.Name) + } + + proc := NewProcess(spec, DefaultMaxLogLines) + m.processes[spec.Name] = proc + m.mu.Unlock() + + if err := proc.Start(ctx, writers...); err != nil { + return nil, fmt.Errorf("starting %q: %w", spec.Name, err) + } + return proc, nil +} + +func (m *Manager) Get(name string) (*Process, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + p, ok := m.processes[name] + return p, ok +} + +func (m *Manager) StopAll() { + m.mu.RLock() + procs := make([]*Process, 0, len(m.processes)) + for _, p := range m.processes { + procs = append(procs, p) + } + m.mu.RUnlock() + + var wg sync.WaitGroup + for _, p := range procs { + wg.Add(1) + go func(proc *Process) { + defer wg.Done() + proc.Stop() + }(p) + } + wg.Wait() +} + +func (m *Manager) StopProcess(name string) error { + m.mu.RLock() + p, ok := m.processes[name] + m.mu.RUnlock() + if !ok { + return fmt.Errorf("process %q not found", name) + } + return p.Stop() +} + +func (m *Manager) RestartProcess(ctx context.Context, name string, writers ...io.Writer) (*Process, error) { + m.mu.Lock() + old, ok := m.processes[name] + if !ok { + m.mu.Unlock() + return nil, fmt.Errorf("process %q not found", name) + } + spec := old.Spec + m.mu.Unlock() + + old.Stop() + + m.mu.Lock() + proc := NewProcess(spec, DefaultMaxLogLines) + proc.Output.Write([]byte("── restarting ──\n")) + m.processes[name] = proc + m.mu.Unlock() + + if err := proc.Start(ctx, writers...); err != nil { + return nil, fmt.Errorf("restarting %q: %w", name, err) + } + return proc, nil +} + +func (m *Manager) All() []*Process { + m.mu.RLock() + defer m.mu.RUnlock() + result := make([]*Process, 0, len(m.processes)) + for _, p := range m.processes { + result = append(result, p) + } + return result +} diff --git a/tools/obs/internal/process/manager_test.go b/tools/obs/internal/process/manager_test.go new file mode 100644 index 0000000..5791cb0 --- /dev/null +++ b/tools/obs/internal/process/manager_test.go @@ -0,0 +1,74 @@ +package process_test + +import ( + "context" + "testing" + "time" + + "obs/internal/process" + "obs/internal/recipe" +) + +func TestManager_StartAndStop(t *testing.T) { + mgr := process.NewManager() + ctx := context.Background() + + spec := recipe.ProcessSpec{ + Name: "sleeper", + Command: "sleep", + Args: []string{"30"}, + } + + proc, err := mgr.StartProcess(ctx, spec) + if err != nil { + t.Fatalf("StartProcess failed: %v", err) + } + if !proc.Running() { + t.Fatal("process should be running") + } + + // Duplicate start should fail + _, err = mgr.StartProcess(ctx, spec) + if err == nil { + t.Fatal("duplicate start should fail") + } + + err = mgr.StopProcess("sleeper") + if err != nil { + t.Fatalf("StopProcess failed: %v", err) + } + + select { + case <-proc.Wait(): + case <-time.After(10 * time.Second): + t.Fatal("process did not stop in time") + } +} + +func TestManager_OutputCapture(t *testing.T) { + mgr := process.NewManager() + ctx := context.Background() + + spec := recipe.ProcessSpec{ + Name: "echo", + Command: "echo", + Args: []string{"hello world"}, + } + + proc, err := mgr.StartProcess(ctx, spec) + if err != nil { + t.Fatalf("StartProcess failed: %v", err) + } + <-proc.Wait() + + // Give the ring buffer a moment to flush + time.Sleep(50 * time.Millisecond) + + lines := proc.Output.Lines() + if len(lines) == 0 { + t.Fatal("expected captured output") + } + if lines[0] != "hello world" { + t.Fatalf("expected 'hello world', got %q", lines[0]) + } +} diff --git a/tools/obs/internal/process/port.go b/tools/obs/internal/process/port.go new file mode 100644 index 0000000..1acb938 --- /dev/null +++ b/tools/obs/internal/process/port.go @@ -0,0 +1,126 @@ +package process + +import ( + "fmt" + "net" + "os/exec" + "strconv" + "strings" + "syscall" + "time" +) + +func CheckPort(port int) error { + addr := fmt.Sprintf(":%d", port) + // Check both IPv4 and IPv6 + ln4, err4 := net.Listen("tcp4", addr) + if err4 != nil { + return fmt.Errorf("port %d is already in use", port) + } + ln4.Close() + ln6, err6 := net.Listen("tcp6", addr) + if err6 != nil { + return fmt.Errorf("port %d is already in use", port) + } + ln6.Close() + return nil +} + +func CheckPorts(ports []int) error { + for _, p := range ports { + if err := CheckPort(p); err != nil { + return err + } + } + return nil +} + +func FreePorts(ports []int) error { + for _, p := range ports { + if err := CheckPort(p); err != nil { + if freeErr := freePort(p); freeErr != nil { + return fmt.Errorf("port %d in use and could not free it: %w", p, freeErr) + } + } + } + return nil +} + +func freePort(port int) error { + pids, err := findPIDsOnPort(port) + if err != nil { + return err + } + for _, pid := range pids { + fmt.Printf("Killing process %d on port %d\n", pid, port) + syscall.Kill(pid, syscall.SIGTERM) + } + // Wait for port to become available + for i := 0; i < 20; i++ { + time.Sleep(250 * time.Millisecond) + if err := CheckPort(port); err == nil { + return nil + } + } + // Force kill if still occupied + for _, pid := range pids { + if IsAlive(pid) { + syscall.Kill(pid, syscall.SIGKILL) + } + } + time.Sleep(500 * time.Millisecond) + if err := CheckPort(port); err != nil { + return fmt.Errorf("port %d still in use after killing processes", port) + } + return nil +} + +func findPIDsOnPort(port int) ([]int, error) { + out, err := exec.Command("lsof", "-ti", fmt.Sprintf(":%d", port)).Output() + if err != nil { + return nil, fmt.Errorf("no process found on port %d", port) + } + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + var pids []int + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + pid, err := strconv.Atoi(line) + if err != nil { + continue + } + pids = append(pids, pid) + } + if len(pids) == 0 { + return nil, fmt.Errorf("no process found on port %d", port) + } + return pids, nil +} + +func ProbePort(port int) bool { + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 500*time.Millisecond) + if err != nil { + return false + } + conn.Close() + return true +} + +func ProbePorts(ports []int) bool { + for _, p := range ports { + if !ProbePort(p) { + return false + } + } + return len(ports) > 0 +} + +func IsAlive(pid int) bool { + return syscall.Kill(pid, 0) == nil +} + +func KillGroup(pid int) { + syscall.Kill(-pid, syscall.SIGINT) +} diff --git a/tools/obs/internal/process/port_test.go b/tools/obs/internal/process/port_test.go new file mode 100644 index 0000000..048a53c --- /dev/null +++ b/tools/obs/internal/process/port_test.go @@ -0,0 +1,27 @@ +package process_test + +import ( + "net" + "testing" + + "obs/internal/process" +) + +func TestCheckPort_Available(t *testing.T) { + if err := process.CheckPort(59123); err != nil { + t.Fatalf("unused port should be available: %v", err) + } +} + +func TestCheckPort_InUse(t *testing.T) { + ln, err := net.Listen("tcp", ":0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + port := ln.Addr().(*net.TCPAddr).Port + if err := process.CheckPort(port); err == nil { + t.Fatal("occupied port should return error") + } +} diff --git a/tools/obs/internal/process/process.go b/tools/obs/internal/process/process.go new file mode 100644 index 0000000..12023e1 --- /dev/null +++ b/tools/obs/internal/process/process.go @@ -0,0 +1,148 @@ +package process + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + "sync" + "syscall" + "time" + + "obs/internal/recipe" +) + +type ProcessStatus int + +const ( + ProcessPending ProcessStatus = iota + ProcessRunning + ProcessDone + ProcessStopped + ProcessFailed +) + +const ShutdownTimeout = 5 * time.Second + +type Process struct { + Spec recipe.ProcessSpec + Status ProcessStatus + Err error + Output *RingBuffer + + cmd *exec.Cmd + mu sync.Mutex + done chan struct{} +} + +func NewProcess(spec recipe.ProcessSpec, maxLogLines int) *Process { + return &Process{ + Spec: spec, + Status: ProcessPending, + Output: NewRingBuffer(maxLogLines), + done: make(chan struct{}), + } +} + +func (p *Process) Start(ctx context.Context, extraWriters ...io.Writer) error { + p.mu.Lock() + defer p.mu.Unlock() + + p.cmd = exec.CommandContext(ctx, p.Spec.Command, p.Spec.Args...) + if p.Spec.Dir != "" { + p.cmd.Dir = p.Spec.Dir + } + if len(p.Spec.Env) > 0 { + p.cmd.Env = os.Environ() + for k, v := range p.Spec.Env { + p.cmd.Env = append(p.cmd.Env, fmt.Sprintf("%s=%s", k, v)) + } + } + + // Put child in its own process group for clean shutdown + p.cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + if p.Spec.Stdin != "" { + p.cmd.Stdin = strings.NewReader(p.Spec.Stdin) + } + + writers := []io.Writer{p.Output} + writers = append(writers, extraWriters...) + mw := io.MultiWriter(writers...) + p.cmd.Stdout = mw + p.cmd.Stderr = mw + + if err := p.cmd.Start(); err != nil { + p.Status = ProcessFailed + p.Err = err + close(p.done) + return err + } + + p.Status = ProcessRunning + + go func() { + err := p.cmd.Wait() + p.mu.Lock() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == -1 { + p.Status = ProcessStopped + } else { + p.Status = ProcessFailed + p.Err = err + } + } else { + p.Status = ProcessDone + } + p.mu.Unlock() + close(p.done) + }() + + return nil +} + +func (p *Process) Stop() error { + p.mu.Lock() + if p.cmd == nil || p.cmd.Process == nil || p.Status != ProcessRunning { + p.mu.Unlock() + return nil + } + pid := p.cmd.Process.Pid + p.mu.Unlock() + + // SIGINT to the process group + syscall.Kill(-pid, syscall.SIGINT) + + select { + case <-p.done: + return nil + case <-time.After(ShutdownTimeout): + syscall.Kill(-pid, syscall.SIGKILL) + <-p.done + return nil + } +} + +func (p *Process) Wait() <-chan struct{} { + return p.done +} + +func (p *Process) Running() bool { + p.mu.Lock() + defer p.mu.Unlock() + return p.Status == ProcessRunning +} + +func (p *Process) PID() int { + p.mu.Lock() + defer p.mu.Unlock() + if p.cmd != nil && p.cmd.Process != nil { + return p.cmd.Process.Pid + } + return 0 +} + diff --git a/tools/obs/internal/process/ringbuf.go b/tools/obs/internal/process/ringbuf.go new file mode 100644 index 0000000..4e4340d --- /dev/null +++ b/tools/obs/internal/process/ringbuf.go @@ -0,0 +1,62 @@ +package process + +import ( + "strings" + "sync" +) + +type RingBuffer struct { + mu sync.Mutex + lines []string + maxSize int + start int + count int + partial string +} + +func NewRingBuffer(maxLines int) *RingBuffer { + return &RingBuffer{ + lines: make([]string, maxLines), + maxSize: maxLines, + } +} + +func (rb *RingBuffer) Write(p []byte) (int, error) { + rb.mu.Lock() + defer rb.mu.Unlock() + + text := rb.partial + string(p) + parts := strings.Split(text, "\n") + + // Last element is either empty (if text ended with \n) or a partial line + rb.partial = parts[len(parts)-1] + completedLines := parts[:len(parts)-1] + + for _, line := range completedLines { + idx := (rb.start + rb.count) % rb.maxSize + rb.lines[idx] = line + if rb.count < rb.maxSize { + rb.count++ + } else { + rb.start = (rb.start + 1) % rb.maxSize + } + } + return len(p), nil +} + +func (rb *RingBuffer) Lines() []string { + rb.mu.Lock() + defer rb.mu.Unlock() + + result := make([]string, rb.count) + for i := 0; i < rb.count; i++ { + result[i] = rb.lines[(rb.start+i)%rb.maxSize] + } + return result +} + +func (rb *RingBuffer) Len() int { + rb.mu.Lock() + defer rb.mu.Unlock() + return rb.count +} diff --git a/tools/obs/internal/process/ringbuf_test.go b/tools/obs/internal/process/ringbuf_test.go new file mode 100644 index 0000000..7b9c7be --- /dev/null +++ b/tools/obs/internal/process/ringbuf_test.go @@ -0,0 +1,44 @@ +package process_test + +import ( + "testing" + + "obs/internal/process" +) + +func TestRingBuffer_Basic(t *testing.T) { + rb := process.NewRingBuffer(3) + rb.Write([]byte("line1\nline2\nline3\n")) + + lines := rb.Lines() + if len(lines) != 3 { + t.Fatalf("expected 3 lines, got %d: %v", len(lines), lines) + } + if lines[0] != "line1" || lines[2] != "line3" { + t.Fatalf("unexpected lines: %v", lines) + } +} + +func TestRingBuffer_Overflow(t *testing.T) { + rb := process.NewRingBuffer(2) + rb.Write([]byte("a\nb\nc\n")) + + lines := rb.Lines() + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d", len(lines)) + } + if lines[0] != "b" || lines[1] != "c" { + t.Fatalf("oldest line should be evicted: %v", lines) + } +} + +func TestRingBuffer_PartialLine(t *testing.T) { + rb := process.NewRingBuffer(5) + rb.Write([]byte("hel")) + rb.Write([]byte("lo\nworld\n")) + + lines := rb.Lines() + if len(lines) != 2 || lines[0] != "hello" { + t.Fatalf("partial writes should be joined: %v", lines) + } +} diff --git a/tools/obs/internal/recipe/engine.go b/tools/obs/internal/recipe/engine.go new file mode 100644 index 0000000..3b92b1b --- /dev/null +++ b/tools/obs/internal/recipe/engine.go @@ -0,0 +1,159 @@ +package recipe + +import ( + "fmt" + "strings" +) + +type Engine struct{} + +func NewEngine() *Engine { + return &Engine{} +} + +func (e *Engine) Prepare(segments []RecipeSegment, dryRun bool, checkPorts func([]int) error) ([]*Step, error) { + var allReqs []Requirement + var allSteps []*Step + + for _, seg := range segments { + allReqs = append(allReqs, seg.Recipe.Requirements(seg.Flags)...) + + cfg := &Config{Flags: seg.Flags, DryRun: dryRun} + steps, err := seg.Recipe.Steps(cfg) + if err != nil { + return nil, fmt.Errorf("recipe %q: %w", seg.Recipe.Name(), err) + } + allSteps = append(allSteps, steps...) + } + + providerSteps, err := e.resolveProviders(segments) + if err != nil { + return nil, err + } + allSteps = append(allSteps, providerSteps...) + + if !dryRun { + if err := e.checkRequirements(allReqs); err != nil { + return nil, &RequirementsError{Err: err} + } + if checkPorts != nil { + if err := checkPortAvailability(allSteps, checkPorts); err != nil { + return nil, &RequirementsError{Err: err} + } + } + } + + return resolveDependencies(allSteps) +} + +func (e *Engine) resolveProviders(segments []RecipeSegment) ([]*Step, error) { + grouped := make(map[string][]StepNeed) + for _, seg := range segments { + nr, ok := seg.Recipe.(NeedfulRecipe) + if !ok { + continue + } + for _, need := range nr.Needs() { + grouped[need.Provider] = append(grouped[need.Provider], need) + } + } + + var steps []*Step + for providerName, needs := range grouped { + provider, ok := GetProvider(providerName) + if !ok { + return nil, fmt.Errorf("unknown step provider %q", providerName) + } + provided, err := provider.Provide(needs, &Config{}) + if err != nil { + return nil, fmt.Errorf("provider %q: %w", providerName, err) + } + steps = append(steps, provided...) + } + return steps, nil +} + +func checkPortAvailability(steps []*Step, checkPorts func([]int) error) error { + seen := make(map[int]bool) + var ports []int + for _, step := range steps { + for _, spec := range step.Processes { + for _, p := range spec.Ports { + if !seen[p] { + seen[p] = true + ports = append(ports, p) + } + } + } + } + if len(ports) == 0 { + return nil + } + if err := checkPorts(ports); err != nil { + return fmt.Errorf("port availability check failed:\n - %v", err) + } + return nil +} + +type RequirementsError struct { + Err error +} + +func (e *RequirementsError) Error() string { return e.Err.Error() } + +func (e *Engine) checkRequirements(reqs []Requirement) error { + var failures []string + for _, r := range reqs { + if err := r.Check(); err != nil { + failures = append(failures, fmt.Sprintf(" - %s: %v", r.Name, err)) + } + } + if len(failures) > 0 { + return fmt.Errorf("requirements not met:\n%s", strings.Join(failures, "\n")) + } + return nil +} + +func resolveDependencies(steps []*Step) ([]*Step, error) { + byName := make(map[string]*Step, len(steps)) + for _, s := range steps { + byName[s.Name] = s + } + + inDegree := make(map[string]int, len(steps)) + dependents := make(map[string][]string) + for _, s := range steps { + for _, dep := range s.DependsOn { + if _, ok := byName[dep]; !ok { + return nil, fmt.Errorf("step %q depends on unknown step %q", s.Name, dep) + } + inDegree[s.Name]++ + dependents[dep] = append(dependents[dep], s.Name) + } + } + + var queue []string + for _, s := range steps { + if inDegree[s.Name] == 0 { + queue = append(queue, s.Name) + } + } + + var ordered []*Step + for len(queue) > 0 { + name := queue[0] + queue = queue[1:] + ordered = append(ordered, byName[name]) + for _, dep := range dependents[name] { + inDegree[dep]-- + if inDegree[dep] == 0 { + queue = append(queue, dep) + } + } + } + + if len(ordered) != len(steps) { + return nil, fmt.Errorf("circular dependency detected among steps") + } + return ordered, nil +} diff --git a/tools/obs/internal/recipe/engine_test.go b/tools/obs/internal/recipe/engine_test.go new file mode 100644 index 0000000..4b809aa --- /dev/null +++ b/tools/obs/internal/recipe/engine_test.go @@ -0,0 +1,367 @@ +package recipe_test + +import ( + "fmt" + "testing" + + "github.com/spf13/pflag" + "obs/internal/recipe" +) + +type prepareStubRecipe struct { + name string + reqs []recipe.Requirement + steps []*recipe.Step +} + +func (r *prepareStubRecipe) Name() string { return r.name } +func (r *prepareStubRecipe) Aliases() []string { return nil } +func (r *prepareStubRecipe) Description() string { return "" } +func (r *prepareStubRecipe) Flags() *pflag.FlagSet { return pflag.NewFlagSet(r.name, pflag.ContinueOnError) } +func (r *prepareStubRecipe) Requirements(_ *pflag.FlagSet) []recipe.Requirement { return r.reqs } +func (r *prepareStubRecipe) Steps(_ *recipe.Config) ([]*recipe.Step, error) { return r.steps, nil } + +func TestEngine_Prepare(t *testing.T) { + eng := recipe.NewEngine() + + seg := recipe.RecipeSegment{ + Recipe: &prepareStubRecipe{ + name: "test", + steps: []*recipe.Step{ + {Name: "step1"}, + {Name: "step2", DependsOn: []string{"step1"}}, + }, + }, + Flags: pflag.NewFlagSet("test", pflag.ContinueOnError), + } + + ordered, err := eng.Prepare([]recipe.RecipeSegment{seg}, false, nil) + if err != nil { + t.Fatalf("Prepare failed: %v", err) + } + if len(ordered) != 2 { + t.Fatalf("expected 2 steps, got %d", len(ordered)) + } + if ordered[0].Name != "step1" { + t.Fatalf("expected step1 first, got %s", ordered[0].Name) + } +} + +func TestEngine_Prepare_RequirementsFail(t *testing.T) { + eng := recipe.NewEngine() + + seg := recipe.RecipeSegment{ + Recipe: &prepareStubRecipe{ + name: "test", + reqs: []recipe.Requirement{{Name: "missing", Check: func() error { return fmt.Errorf("not found") }}}, + steps: []*recipe.Step{{Name: "s1"}}, + }, + Flags: pflag.NewFlagSet("test", pflag.ContinueOnError), + } + + _, err := eng.Prepare([]recipe.RecipeSegment{seg}, false, nil) + if err == nil { + t.Fatal("should fail on requirement check") + } + if _, ok := err.(*recipe.RequirementsError); !ok { + t.Fatalf("expected RequirementsError, got %T", err) + } +} + +func TestEngine_Prepare_AutoPortRequirements(t *testing.T) { + eng := recipe.NewEngine() + + checkedPorts := make(map[int]bool) + portChecker := func(ports []int) error { + for _, p := range ports { + checkedPorts[p] = true + } + return nil + } + + seg := recipe.RecipeSegment{ + Recipe: &prepareStubRecipe{ + name: "test", + steps: []*recipe.Step{ + { + Name: "s1", + Processes: []recipe.ProcessSpec{ + {Name: "frontend", Ports: []int{9001}}, + }, + }, + { + Name: "s2", + Processes: []recipe.ProcessSpec{ + {Name: "backend", Ports: []int{9443}}, + {Name: "console", Ports: []int{9000}}, + }, + }, + }, + }, + Flags: pflag.NewFlagSet("test", pflag.ContinueOnError), + } + + _, err := eng.Prepare([]recipe.RecipeSegment{seg}, false, portChecker) + if err != nil { + t.Fatalf("Prepare failed: %v", err) + } + + for _, port := range []int{9001, 9443, 9000} { + if !checkedPorts[port] { + t.Errorf("port %d was not checked", port) + } + } +} + +func TestEngine_Prepare_AutoPortRequirements_Dedup(t *testing.T) { + eng := recipe.NewEngine() + + callCount := 0 + portChecker := func(ports []int) error { + callCount++ + return nil + } + + seg := recipe.RecipeSegment{ + Recipe: &prepareStubRecipe{ + name: "test", + steps: []*recipe.Step{ + {Name: "s1", Processes: []recipe.ProcessSpec{{Name: "a", Ports: []int{9001}}}}, + {Name: "s2", Processes: []recipe.ProcessSpec{{Name: "b", Ports: []int{9001}}}}, + }, + }, + Flags: pflag.NewFlagSet("test", pflag.ContinueOnError), + } + + _, err := eng.Prepare([]recipe.RecipeSegment{seg}, false, portChecker) + if err != nil { + t.Fatalf("Prepare failed: %v", err) + } + if callCount != 1 { + t.Errorf("expected port 9001 checked once, got %d calls", callCount) + } +} + +func TestEngine_Prepare_AutoPortRequirements_Fail(t *testing.T) { + eng := recipe.NewEngine() + + portChecker := func(ports []int) error { + return fmt.Errorf("port %d is already in use", ports[0]) + } + + seg := recipe.RecipeSegment{ + Recipe: &prepareStubRecipe{ + name: "test", + steps: []*recipe.Step{ + {Name: "s1", Processes: []recipe.ProcessSpec{{Name: "a", Ports: []int{9001}}}}, + }, + }, + Flags: pflag.NewFlagSet("test", pflag.ContinueOnError), + } + + _, err := eng.Prepare([]recipe.RecipeSegment{seg}, false, portChecker) + if err == nil { + t.Fatal("expected error for busy port") + } + if _, ok := err.(*recipe.RequirementsError); !ok { + t.Fatalf("expected RequirementsError, got %T: %v", err, err) + } +} + +func TestEngine_Prepare_RequireFlag_Missing(t *testing.T) { + eng := recipe.NewEngine() + + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + fs.String("image", "", "container image") + + seg := recipe.RecipeSegment{ + Recipe: &prepareStubRecipe{ + name: "test", + reqs: []recipe.Requirement{recipe.RequireFlag(fs, "image", "container image to deploy")}, + steps: []*recipe.Step{{Name: "s1"}}, + }, + Flags: fs, + } + + _, err := eng.Prepare([]recipe.RecipeSegment{seg}, false, nil) + if err == nil { + t.Fatal("should fail when required flag is not set") + } + if _, ok := err.(*recipe.RequirementsError); !ok { + t.Fatalf("expected RequirementsError, got %T", err) + } +} + +func TestEngine_Prepare_RequireFlag_Set(t *testing.T) { + eng := recipe.NewEngine() + + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + fs.String("image", "", "container image") + fs.Parse([]string{"--image=quay.io/my/image:latest"}) + + seg := recipe.RecipeSegment{ + Recipe: &prepareStubRecipe{ + name: "test", + reqs: []recipe.Requirement{recipe.RequireFlag(fs, "image", "container image to deploy")}, + steps: []*recipe.Step{{Name: "s1"}}, + }, + Flags: fs, + } + + _, err := eng.Prepare([]recipe.RecipeSegment{seg}, false, nil) + if err != nil { + t.Fatalf("should pass when required flag is set: %v", err) + } +} + +// --- Provider tests --- + +type stubProvider struct { + name string + steps []*recipe.Step +} + +func (p *stubProvider) Name() string { return p.name } +func (p *stubProvider) Provide(needs []recipe.StepNeed, cfg *recipe.Config) ([]*recipe.Step, error) { + return p.steps, nil +} + +type needfulStubRecipe struct { + prepareStubRecipe + needs []recipe.StepNeed +} + +func (r *needfulStubRecipe) Needs() []recipe.StepNeed { return r.needs } + +func TestEngine_Prepare_ProviderMergesNeeds(t *testing.T) { + eng := recipe.NewEngine() + + var receivedNeeds []recipe.StepNeed + recipe.RegisterProvider(&stubProvider{ + name: "console", + steps: []*recipe.Step{{Name: "start-console"}}, + }) + // Override with a provider that captures needs + recipe.RegisterProvider(&captureProvider{ + name: "console", + needs: &receivedNeeds, + steps: []*recipe.Step{{Name: "start-console"}}, + }) + + seg1 := recipe.RecipeSegment{ + Recipe: &needfulStubRecipe{ + prepareStubRecipe: prepareStubRecipe{name: "mp", steps: []*recipe.Step{{Name: "mp-frontend"}}}, + needs: []recipe.StepNeed{{Provider: "console", Config: map[string]string{"plugin": "monitoring-plugin"}}}, + }, + Flags: pflag.NewFlagSet("mp", pflag.ContinueOnError), + } + seg2 := recipe.RecipeSegment{ + Recipe: &needfulStubRecipe{ + prepareStubRecipe: prepareStubRecipe{name: "lp", steps: []*recipe.Step{{Name: "lp-frontend"}}}, + needs: []recipe.StepNeed{{Provider: "console", Config: map[string]string{"plugin": "logging-view-plugin"}}}, + }, + Flags: pflag.NewFlagSet("lp", pflag.ContinueOnError), + } + + ordered, err := eng.Prepare([]recipe.RecipeSegment{seg1, seg2}, true, nil) + if err != nil { + t.Fatalf("Prepare failed: %v", err) + } + + if len(receivedNeeds) != 2 { + t.Fatalf("expected provider called with 2 needs, got %d", len(receivedNeeds)) + } + + hasConsole := false + for _, s := range ordered { + if s.Name == "start-console" { + hasConsole = true + } + } + if !hasConsole { + t.Fatal("expected provider-generated start-console step in output") + } +} + +type captureProvider struct { + name string + needs *[]recipe.StepNeed + steps []*recipe.Step +} + +func (p *captureProvider) Name() string { return p.name } +func (p *captureProvider) Provide(needs []recipe.StepNeed, cfg *recipe.Config) ([]*recipe.Step, error) { + *p.needs = needs + return p.steps, nil +} + +func TestEngine_Prepare_NoNeedsRecipeUnchanged(t *testing.T) { + eng := recipe.NewEngine() + + seg := recipe.RecipeSegment{ + Recipe: &prepareStubRecipe{ + name: "test", + steps: []*recipe.Step{{Name: "s1"}, {Name: "s2", DependsOn: []string{"s1"}}}, + }, + Flags: pflag.NewFlagSet("test", pflag.ContinueOnError), + } + + ordered, err := eng.Prepare([]recipe.RecipeSegment{seg}, true, nil) + if err != nil { + t.Fatalf("Prepare failed: %v", err) + } + if len(ordered) != 2 { + t.Fatalf("expected 2 steps, got %d", len(ordered)) + } +} + +func TestEngine_Prepare_ProviderStepsInDependencyResolution(t *testing.T) { + eng := recipe.NewEngine() + + recipe.RegisterProvider(&stubProvider{ + name: "infra", + steps: []*recipe.Step{{Name: "setup-infra"}}, + }) + + seg := recipe.RecipeSegment{ + Recipe: &needfulStubRecipe{ + prepareStubRecipe: prepareStubRecipe{ + name: "app", + steps: []*recipe.Step{{Name: "start-app", DependsOn: []string{"setup-infra"}}}, + }, + needs: []recipe.StepNeed{{Provider: "infra"}}, + }, + Flags: pflag.NewFlagSet("app", pflag.ContinueOnError), + } + + ordered, err := eng.Prepare([]recipe.RecipeSegment{seg}, true, nil) + if err != nil { + t.Fatalf("Prepare failed: %v", err) + } + if len(ordered) != 2 { + t.Fatalf("expected 2 steps, got %d", len(ordered)) + } + if ordered[0].Name != "setup-infra" { + t.Fatalf("expected setup-infra first, got %s", ordered[0].Name) + } +} + +func TestEngine_Prepare_CircularDeps(t *testing.T) { + eng := recipe.NewEngine() + + seg := recipe.RecipeSegment{ + Recipe: &prepareStubRecipe{ + name: "test", + steps: []*recipe.Step{ + {Name: "a", DependsOn: []string{"b"}}, + {Name: "b", DependsOn: []string{"a"}}, + }, + }, + Flags: pflag.NewFlagSet("test", pflag.ContinueOnError), + } + + _, err := eng.Prepare([]recipe.RecipeSegment{seg}, false, nil) + if err == nil { + t.Fatal("circular dependency should return error") + } +} diff --git a/tools/obs/internal/recipe/parser.go b/tools/obs/internal/recipe/parser.go new file mode 100644 index 0000000..acede66 --- /dev/null +++ b/tools/obs/internal/recipe/parser.go @@ -0,0 +1,75 @@ +package recipe + +import ( + "fmt" + + "github.com/spf13/pflag" +) + +type RecipeSegment struct { + Recipe Recipe + Flags *pflag.FlagSet +} + +func ParseRecipeArgs(reg *Registry, command string, args []string) ([]RecipeSegment, error) { + if len(args) == 0 { + return nil, fmt.Errorf("no recipe specified — run 'obs list' to see available recipes") + } + + var segments []RecipeSegment + var currentRecipe Recipe + var currentFlags []string + + flush := func() error { + if currentRecipe == nil { + return nil + } + fs := copyFlagSet(currentRecipe.Flags()) + if err := fs.Parse(currentFlags); err != nil { + return fmt.Errorf("invalid flags for %q: %w", currentRecipe.Name(), err) + } + segments = append(segments, RecipeSegment{Recipe: currentRecipe, Flags: fs}) + currentFlags = nil + return nil + } + + for _, arg := range args { + if arg == "" { + continue + } + // Flags start with - + if len(arg) > 0 && arg[0] == '-' { + if currentRecipe == nil { + return nil, fmt.Errorf("flag %q before any recipe name", arg) + } + currentFlags = append(currentFlags, arg) + continue + } + // Try to look up as a recipe + rec, ok := reg.Lookup(command, arg) + if !ok { + return nil, fmt.Errorf("unknown recipe %q for command %q — run 'obs list' to see available recipes", arg, command) + } + if err := flush(); err != nil { + return nil, err + } + currentRecipe = rec + } + + if currentRecipe == nil { + return nil, fmt.Errorf("no valid recipe found in arguments") + } + if err := flush(); err != nil { + return nil, err + } + + return segments, nil +} + +func copyFlagSet(src *pflag.FlagSet) *pflag.FlagSet { + dst := pflag.NewFlagSet(src.Name(), pflag.ContinueOnError) + src.VisitAll(func(f *pflag.Flag) { + dst.AddFlag(f) + }) + return dst +} diff --git a/tools/obs/internal/recipe/parser_test.go b/tools/obs/internal/recipe/parser_test.go new file mode 100644 index 0000000..083b691 --- /dev/null +++ b/tools/obs/internal/recipe/parser_test.go @@ -0,0 +1,69 @@ +package recipe_test + +import ( + "testing" + + "github.com/spf13/pflag" + "obs/internal/recipe" +) + +func TestParseRecipeArgs_Single(t *testing.T) { + reg := recipe.NewRegistry() + reg.Register("start", newStubWithFlags("monitoring-plugin", []string{"mp"}, func(fs *pflag.FlagSet) { + fs.String("version", "", "version to use") + })) + + segments, err := recipe.ParseRecipeArgs(reg, "start", []string{"mp", "--version=4.18"}) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + if len(segments) != 1 { + t.Fatalf("expected 1 segment, got %d", len(segments)) + } + if segments[0].Recipe.Name() != "monitoring-plugin" { + t.Fatalf("expected monitoring-plugin, got %s", segments[0].Recipe.Name()) + } + v, _ := segments[0].Flags.GetString("version") + if v != "4.18" { + t.Fatalf("expected version=4.18, got %q", v) + } +} + +func TestParseRecipeArgs_Multiple(t *testing.T) { + reg := recipe.NewRegistry() + reg.Register("start", newStubWithFlags("monitoring-plugin", []string{"mp"}, func(fs *pflag.FlagSet) { + fs.String("version", "", "") + })) + reg.Register("start", newStubWithFlags("console", []string{"con"}, func(fs *pflag.FlagSet) { + fs.String("version", "", "") + })) + + segments, err := recipe.ParseRecipeArgs(reg, "start", []string{"mp", "--version=4.18", "con", "--version=4.19"}) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + if len(segments) != 2 { + t.Fatalf("expected 2 segments, got %d", len(segments)) + } + v0, _ := segments[0].Flags.GetString("version") + v1, _ := segments[1].Flags.GetString("version") + if v0 != "4.18" || v1 != "4.19" { + t.Fatalf("versions wrong: %q %q", v0, v1) + } +} + +func TestParseRecipeArgs_UnknownRecipe(t *testing.T) { + reg := recipe.NewRegistry() + _, err := recipe.ParseRecipeArgs(reg, "start", []string{"nonexistent"}) + if err == nil { + t.Fatal("unknown recipe should return error") + } +} + +func TestParseRecipeArgs_NoArgs(t *testing.T) { + reg := recipe.NewRegistry() + _, err := recipe.ParseRecipeArgs(reg, "start", []string{}) + if err == nil { + t.Fatal("no args should return error") + } +} diff --git a/tools/obs/internal/recipe/provider.go b/tools/obs/internal/recipe/provider.go new file mode 100644 index 0000000..9c4c552 --- /dev/null +++ b/tools/obs/internal/recipe/provider.go @@ -0,0 +1,12 @@ +package recipe + +var providers = make(map[string]StepProvider) + +func RegisterProvider(p StepProvider) { + providers[p.Name()] = p +} + +func GetProvider(name string) (StepProvider, bool) { + p, ok := providers[name] + return p, ok +} diff --git a/tools/obs/internal/recipe/recipe.go b/tools/obs/internal/recipe/recipe.go new file mode 100644 index 0000000..fbd9b3c --- /dev/null +++ b/tools/obs/internal/recipe/recipe.go @@ -0,0 +1,173 @@ +package recipe + +import ( + "fmt" + "io/fs" + "os/exec" + + "github.com/spf13/pflag" +) + +type Status int + +const ( + StatusPending Status = iota + StatusWaiting // blocked on a dependency + StatusRunning + StatusStarted // processes launched and kept running (long-lived) + StatusReady // ports accepting connections (long-lived, confirmed ready) + StatusDone // processes completed and exited + StatusStopped // processes intentionally stopped by user + StatusFailed + StatusSkipped +) + +func (s Status) String() string { + switch s { + case StatusPending: + return "pending" + case StatusWaiting: + return "waiting" + case StatusRunning: + return "running" + case StatusStarted: + return "started" + case StatusReady: + return "ready" + case StatusDone: + return "done" + case StatusStopped: + return "stopped" + case StatusFailed: + return "failed" + case StatusSkipped: + return "skipped" + default: + return "unknown" + } +} + +type Requirement struct { + Name string + Check func() error +} + +type FileRef struct { + FS fs.FS + Path string +} + +type ProcessSpec struct { + Name string + Command string + Args []string + Dir string + Env map[string]string + Ports []int + Stdin string + StdinFile string + Files map[string]FileRef +} + +type Step struct { + Name string + Processes []ProcessSpec + DependsOn []string +} + +func (s *Step) HasPorts() bool { + for _, p := range s.Processes { + if len(p.Ports) > 0 { + return true + } + } + return false +} + +type Config struct { + Flags *pflag.FlagSet + DryRun bool +} + +type StepUpdate struct { + StepName string + Status Status + Err error +} + +type Recipe interface { + Name() string + Aliases() []string + Description() string + Flags() *pflag.FlagSet + Requirements(flags *pflag.FlagSet) []Requirement + Steps(cfg *Config) ([]*Step, error) +} + +type StepNeed struct { + Provider string + Config map[string]string +} + +type NeedfulRecipe interface { + Recipe + Needs() []StepNeed +} + +type StepProvider interface { + Name() string + Provide(needs []StepNeed, cfg *Config) ([]*Step, error) +} + +func RequireNode() Requirement { return RequireTool("node", "install via nvm or brew") } +func RequireNPM() Requirement { return RequireTool("npm", "install via nvm or brew") } +func RequireGo() Requirement { return RequireTool("go", "") } +func RequirePodman() Requirement { return RequireTool("podman", "install via brew or dnf") } +func RequireJQ() Requirement { return RequireTool("jq", "install via brew or dnf") } + +func RequireTool(name, hint string) Requirement { + return Requirement{ + Name: name, + Check: func() error { + if _, err := exec.LookPath(name); err != nil { + if hint != "" { + return fmt.Errorf("%s is not installed — %s", name, hint) + } + return fmt.Errorf("%s is not installed", name) + } + return nil + }, + } +} + +func RequireFlag(flags *pflag.FlagSet, name, usage string) Requirement { + return Requirement{ + Name: fmt.Sprintf("--%s", name), + Check: func() error { + if flags == nil { + return fmt.Errorf("--%s is required — %s", name, usage) + } + f := flags.Lookup(name) + if f == nil || !f.Changed { + return fmt.Errorf("--%s is required — %s", name, usage) + } + return nil + }, + } +} + +func RequireOCLogin() Requirement { + return Requirement{ + Name: "oc (logged in)", + Check: func() error { + if _, err := exec.LookPath("oc"); err != nil { + return fmt.Errorf("oc is not installed — install the OpenShift CLI") + } + out, err := exec.Command("oc", "whoami").CombinedOutput() + if err != nil { + return fmt.Errorf("not logged in to OpenShift cluster — run 'oc login' first (oc whoami: %s)", out) + } + return nil + }, + } +} diff --git a/tools/obs/internal/recipe/registry.go b/tools/obs/internal/recipe/registry.go new file mode 100644 index 0000000..4aefc1b --- /dev/null +++ b/tools/obs/internal/recipe/registry.go @@ -0,0 +1,90 @@ +package recipe + +import ( + "fmt" + "sort" + "sync" +) + +type RecipeEntry struct { + Command string + Recipe Recipe +} + +type Registry struct { + mu sync.RWMutex + byCmd map[string]map[string]Recipe // command -> name -> recipe + aliases map[string]map[string]string // command -> alias -> name +} + +func NewRegistry() *Registry { + return &Registry{ + byCmd: make(map[string]map[string]Recipe), + aliases: make(map[string]map[string]string), + } +} + +func (r *Registry) Register(command string, rec Recipe) error { + r.mu.Lock() + defer r.mu.Unlock() + + if r.byCmd[command] == nil { + r.byCmd[command] = make(map[string]Recipe) + r.aliases[command] = make(map[string]string) + } + if _, exists := r.byCmd[command][rec.Name()]; exists { + return fmt.Errorf("recipe %q already registered for command %q", rec.Name(), command) + } + r.byCmd[command][rec.Name()] = rec + for _, alias := range rec.Aliases() { + r.aliases[command][alias] = rec.Name() + } + return nil +} + +func (r *Registry) Lookup(command, nameOrAlias string) (Recipe, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + + recipes := r.byCmd[command] + if recipes == nil { + return nil, false + } + if rec, ok := recipes[nameOrAlias]; ok { + return rec, true + } + if name, ok := r.aliases[command][nameOrAlias]; ok { + return recipes[name], true + } + return nil, false +} + +func (r *Registry) List(command string) []Recipe { + r.mu.RLock() + defer r.mu.RUnlock() + + recipes := r.byCmd[command] + result := make([]Recipe, 0, len(recipes)) + for _, rec := range recipes { + result = append(result, rec) + } + sort.Slice(result, func(i, j int) bool { return result[i].Name() < result[j].Name() }) + return result +} + +func (r *Registry) ListAll() []RecipeEntry { + r.mu.RLock() + defer r.mu.RUnlock() + + var result []RecipeEntry + for cmd, recipes := range r.byCmd { + for _, rec := range recipes { + result = append(result, RecipeEntry{Command: cmd, Recipe: rec}) + } + } + sort.Slice(result, func(i, j int) bool { return result[i].Recipe.Name() < result[j].Recipe.Name() }) + return result +} + +// DefaultRegistry is the global registry used by CLI commands. +var DefaultRegistry = NewRegistry() diff --git a/tools/obs/internal/recipe/registry_test.go b/tools/obs/internal/recipe/registry_test.go new file mode 100644 index 0000000..554a011 --- /dev/null +++ b/tools/obs/internal/recipe/registry_test.go @@ -0,0 +1,87 @@ +package recipe_test + +import ( + "testing" + + "github.com/spf13/pflag" + "obs/internal/recipe" +) + +type stubRecipe struct { + name string + aliases []string + flags *pflag.FlagSet +} + +func (s *stubRecipe) Name() string { return s.name } +func (s *stubRecipe) Aliases() []string { return s.aliases } +func (s *stubRecipe) Description() string { return s.name + " recipe" } +func (s *stubRecipe) Flags() *pflag.FlagSet { + if s.flags != nil { + return s.flags + } + return pflag.NewFlagSet(s.name, pflag.ContinueOnError) +} +func (s *stubRecipe) Requirements(_ *pflag.FlagSet) []recipe.Requirement { return nil } +func (s *stubRecipe) Steps(_ *recipe.Config) ([]*recipe.Step, error) { return nil, nil } + +func newStubWithFlags(name string, aliases []string, flags func(fs *pflag.FlagSet)) *stubRecipe { + s := &stubRecipe{name: name, aliases: aliases} + if flags != nil { + s.flags = pflag.NewFlagSet(name, pflag.ContinueOnError) + flags(s.flags) + } + return s +} + +func TestRegistryRegisterAndLookup(t *testing.T) { + reg := recipe.NewRegistry() + r := &stubRecipe{name: "monitoring-plugin", aliases: []string{"mp"}} + + if err := reg.Register("start", r); err != nil { + t.Fatalf("Register failed: %v", err) + } + + got, ok := reg.Lookup("start", "monitoring-plugin") + if !ok || got.Name() != "monitoring-plugin" { + t.Fatalf("Lookup by name failed: ok=%v got=%v", ok, got) + } + + got, ok = reg.Lookup("start", "mp") + if !ok || got.Name() != "monitoring-plugin" { + t.Fatalf("Lookup by alias failed: ok=%v got=%v", ok, got) + } + + _, ok = reg.Lookup("deploy", "mp") + if ok { + t.Fatal("Lookup wrong command should return false") + } +} + +func TestRegistryDuplicateRegister(t *testing.T) { + reg := recipe.NewRegistry() + r := &stubRecipe{name: "mp", aliases: nil} + if err := reg.Register("start", r); err != nil { + t.Fatal(err) + } + if err := reg.Register("start", r); err == nil { + t.Fatal("duplicate Register should return error") + } +} + +func TestRegistryList(t *testing.T) { + reg := recipe.NewRegistry() + reg.Register("start", &stubRecipe{name: "a"}) + reg.Register("deploy", &stubRecipe{name: "b"}) + reg.Register("start", &stubRecipe{name: "c"}) + + startRecipes := reg.List("start") + if len(startRecipes) != 2 { + t.Fatalf("expected 2 start recipes, got %d", len(startRecipes)) + } + + all := reg.ListAll() + if len(all) != 3 { + t.Fatalf("expected 3 total recipes, got %d", len(all)) + } +} diff --git a/tools/obs/internal/recipe/resolve.go b/tools/obs/internal/recipe/resolve.go new file mode 100644 index 0000000..7502c90 --- /dev/null +++ b/tools/obs/internal/recipe/resolve.go @@ -0,0 +1,93 @@ +package recipe + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +func ResolveSpec(spec ProcessSpec) (ProcessSpec, func(), error) { + resolved := spec + + args, tempDir, err := resolveFiles(spec.Args, spec.Files) + if err != nil { + return spec, nil, err + } + resolved.Args = args + + if spec.StdinFile != "" && len(spec.Files) > 0 { + _, content, err := readFileRef(spec.StdinFile, spec.Files) + if err != nil { + if tempDir != "" { + os.RemoveAll(tempDir) + } + return spec, nil, err + } + resolved.Stdin = string(content) + resolved.StdinFile = "" + } + + resolved.Files = nil + + cleanup := func() { + if tempDir != "" { + os.RemoveAll(tempDir) + } + } + return resolved, cleanup, nil +} + +func readFileRef(name string, files map[string]FileRef) (string, []byte, error) { + ref, ok := files[name] + if !ok { + return "", nil, fmt.Errorf("file %q not found in Files map", name) + } + content, err := fs.ReadFile(ref.FS, ref.Path) + if err != nil { + return "", nil, fmt.Errorf("read file %q: %w", ref.Path, err) + } + return ref.Path, content, nil +} + +func resolveFiles(args []string, files map[string]FileRef) ([]string, string, error) { + if len(files) == 0 { + return args, "", nil + } + + resolved := make([]string, len(args)) + var tempDir string + + for i, arg := range args { + if strings.HasPrefix(arg, "{{content:") && strings.HasSuffix(arg, "}}") { + name := arg[len("{{content:") : len(arg)-2] + _, content, err := readFileRef(name, files) + if err != nil { + return nil, "", err + } + resolved[i] = string(content) + } else if strings.HasPrefix(arg, "{{path:") && strings.HasSuffix(arg, "}}") { + name := arg[len("{{path:") : len(arg)-2] + path, content, err := readFileRef(name, files) + if err != nil { + return nil, "", err + } + if tempDir == "" { + tempDir, err = os.MkdirTemp("", "obs-files-*") + if err != nil { + return nil, "", fmt.Errorf("create temp dir: %w", err) + } + } + tmpPath := filepath.Join(tempDir, filepath.Base(path)) + if err := os.WriteFile(tmpPath, content, 0o644); err != nil { + return nil, tempDir, fmt.Errorf("write temp file: %w", err) + } + resolved[i] = tmpPath + } else { + resolved[i] = arg + } + } + return resolved, tempDir, nil +} + diff --git a/tools/obs/internal/runner/detach.go b/tools/obs/internal/runner/detach.go new file mode 100644 index 0000000..647df05 --- /dev/null +++ b/tools/obs/internal/runner/detach.go @@ -0,0 +1,49 @@ +package runner + +import ( + "context" + "fmt" + "io" + "time" + + "obs/internal/process" + "obs/internal/recipe" + "obs/internal/state" +) + +type DetachRunner struct { + store *state.Store + lock *state.Lock +} + +func NewDetach(stateDir string) *DetachRunner { + return &DetachRunner{ + store: state.NewStore(stateDir), + lock: state.NewLock(stateDir), + } +} + +func (r *DetachRunner) Run(ctx context.Context, mgr *process.Manager, steps []*recipe.Step, updates chan<- recipe.StepUpdate) error { + if err := r.lock.Acquire(); err != nil { + return err + } + + inner := NewNonInteractive(io.Discard) + go inner.Run(ctx, mgr, steps, updates) + + time.Sleep(500 * time.Millisecond) + + var procs []state.ProcessState + for _, p := range mgr.All() { + procs = append(procs, state.ProcessState{ + Name: p.Spec.Name, + PID: p.PID(), + }) + r.store.WritePID(p.Spec.Name, p.PID()) + } + r.store.Save(&state.RunState{Processes: procs}) + + fmt.Println("Processes started in background:") + state.PrintStatus(&state.RunState{Processes: procs}) + return nil +} diff --git a/tools/obs/internal/runner/executor.go b/tools/obs/internal/runner/executor.go new file mode 100644 index 0000000..73b1f42 --- /dev/null +++ b/tools/obs/internal/runner/executor.go @@ -0,0 +1,93 @@ +package runner + +import ( + "context" + "fmt" + "io" + + "obs/internal/process" + "obs/internal/recipe" +) + +type StartedProc struct { + StepName string + Proc *process.Process +} + +type StepCallbacks struct { + OnUpdate func(recipe.StepUpdate) + OnProcess func(step *recipe.Step, spec recipe.ProcessSpec, proc *process.Process) + Writers func(specName string) []io.Writer +} + +func ExecuteSteps(ctx context.Context, mgr *process.Manager, steps []*recipe.Step, cb StepCallbacks) ([]StartedProc, error) { + for _, step := range steps { + if len(step.DependsOn) > 0 { + cb.OnUpdate(recipe.StepUpdate{StepName: step.Name, Status: recipe.StatusWaiting}) + } + } + + ready := make(map[string]chan struct{}) + stepErr := make(map[string]error) + for _, step := range steps { + ready[step.Name] = make(chan struct{}) + } + + var launched []StartedProc + + for _, step := range steps { + skip, err := waitDeps(ctx, step, ready, stepErr) + if err != nil { + return launched, err + } + if skip { + cb.OnUpdate(recipe.StepUpdate{StepName: step.Name, Status: recipe.StatusSkipped}) + stepErr[step.Name] = fmt.Errorf("dependency failed") + close(ready[step.Name]) + continue + } + + cb.OnUpdate(recipe.StepUpdate{StepName: step.Name, Status: recipe.StatusRunning}) + + var stepProcs []*process.Process + for _, spec := range step.Processes { + resolved, cleanup, err := recipe.ResolveSpec(spec) + if err != nil { + cb.OnUpdate(recipe.StepUpdate{StepName: step.Name, Status: recipe.StatusFailed, Err: err}) + return launched, fmt.Errorf("failed to resolve %q: %w", spec.Name, err) + } + + var writers []io.Writer + if cb.Writers != nil { + writers = cb.Writers(spec.Name) + } + + proc, err := mgr.StartProcess(ctx, resolved, writers...) + if err != nil { + if cleanup != nil { + cleanup() + } + cb.OnUpdate(recipe.StepUpdate{StepName: step.Name, Status: recipe.StatusFailed, Err: err}) + return launched, fmt.Errorf("failed to start %q: %w", spec.Name, err) + } + if cleanup != nil { + go func() { <-proc.Wait(); cleanup() }() + } + + if cb.OnProcess != nil { + cb.OnProcess(step, spec, proc) + } + + launched = append(launched, StartedProc{StepName: step.Name, Proc: proc}) + stepProcs = append(stepProcs, proc) + } + + cb.OnUpdate(recipe.StepUpdate{StepName: step.Name, Status: recipe.StatusStarted}) + + go watchStepReady(ctx, step, stepProcs, ready[step.Name], stepErr, func(name string, status recipe.Status) { + cb.OnUpdate(recipe.StepUpdate{StepName: name, Status: status}) + }) + } + + return launched, nil +} diff --git a/tools/obs/internal/runner/interactive.go b/tools/obs/internal/runner/interactive.go new file mode 100644 index 0000000..c94a499 --- /dev/null +++ b/tools/obs/internal/runner/interactive.go @@ -0,0 +1,70 @@ +package runner + +import ( + "context" + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + "obs/internal/process" + "obs/internal/recipe" + "obs/internal/ui" +) + +func RunInteractive(ctx context.Context, mgr *process.Manager, prepare func() ([]*recipe.Step, error), updates chan<- recipe.StepUpdate) error { + internalUpdates := make(chan recipe.StepUpdate, 100) + retryCh := make(chan struct{}, 1) + model := ui.NewModel(mgr, internalUpdates, retryCh) + + p := tea.NewProgram(model, tea.WithAltScreen()) + + go func() { + for { + p.Send(ui.RequirementsCheckingMsg{}) + + steps, err := prepare() + if err != nil { + p.Send(ui.RequirementsFailedMsg{Err: err}) + select { + case <-retryCh: + mgr.StopAll() + continue + case <-ctx.Done(): + return + } + } + + p.Send(ui.RequirementsPassedMsg{Steps: steps}) + + cb := StepCallbacks{ + OnUpdate: func(u recipe.StepUpdate) { internalUpdates <- u }, + OnProcess: func(step *recipe.Step, spec recipe.ProcessSpec, proc *process.Process) { + p.Send(ui.AddProcessTabMsg{StepName: step.Name, Name: spec.Name, Proc: proc}) + }, + } + + ExecuteSteps(ctx, mgr, steps, cb) + + select { + case <-retryCh: + mgr.StopAll() + continue + case <-ctx.Done(): + return + } + } + }() + + if _, err := p.Run(); err != nil { + return fmt.Errorf("TUI error: %w", err) + } + return nil +} + +func IsTerminal() bool { + fi, err := os.Stdout.Stat() + if err != nil { + return false + } + return (fi.Mode() & os.ModeCharDevice) != 0 +} diff --git a/tools/obs/internal/runner/noninteractive.go b/tools/obs/internal/runner/noninteractive.go new file mode 100644 index 0000000..8f6656a --- /dev/null +++ b/tools/obs/internal/runner/noninteractive.go @@ -0,0 +1,125 @@ +package runner + +import ( + "context" + "fmt" + "io" + "strings" + "sync" + + "github.com/charmbracelet/lipgloss" + "obs/internal/process" + "obs/internal/recipe" + "obs/internal/ui" +) + +var prefixColors = []lipgloss.Color{ + lipgloss.Color("6"), // cyan + lipgloss.Color("3"), // yellow + lipgloss.Color("2"), // green + lipgloss.Color("5"), // magenta + lipgloss.Color("4"), // blue + lipgloss.Color("1"), // red +} + +type PrefixWriter struct { + w io.Writer + prefix string + partial string + mu sync.Mutex +} + +func NewPrefixWriter(w io.Writer, name string, colorIdx int) *PrefixWriter { + color := prefixColors[colorIdx%len(prefixColors)] + style := lipgloss.NewStyle().Foreground(color) + prefix := style.Render(fmt.Sprintf("%-20s | ", name)) + return &PrefixWriter{w: w, prefix: prefix} +} + +func (pw *PrefixWriter) Write(p []byte) (int, error) { + pw.mu.Lock() + defer pw.mu.Unlock() + + text := pw.partial + string(p) + lines := strings.Split(text, "\n") + pw.partial = lines[len(lines)-1] + + for _, line := range lines[:len(lines)-1] { + fmt.Fprintf(pw.w, "%s%s\n", pw.prefix, line) + } + return len(p), nil +} + +type NonInteractiveRunner struct { + Out io.Writer +} + +func NewNonInteractive(out io.Writer) *NonInteractiveRunner { + return &NonInteractiveRunner{Out: out} +} + +func (r *NonInteractiveRunner) Run(ctx context.Context, mgr *process.Manager, steps []*recipe.Step, updates chan<- recipe.StepUpdate) error { + colorIdx := 0 + + cb := StepCallbacks{ + OnUpdate: func(u recipe.StepUpdate) { updates <- u }, + Writers: func(specName string) []io.Writer { + pw := NewPrefixWriter(r.Out, specName, colorIdx) + colorIdx++ + return []io.Writer{pw} + }, + } + + launched, err := ExecuteSteps(ctx, mgr, steps, cb) + if err != nil { + close(updates) + return err + } + + type procResult struct { + stepName string + err error + } + var wg sync.WaitGroup + results := make(chan procResult, len(launched)) + + for _, sp := range launched { + wg.Add(1) + go func(s StartedProc) { + defer wg.Done() + <-s.Proc.Wait() + + status := ui.MapProcessStatus(s.Proc.Status) + updates <- recipe.StepUpdate{StepName: s.StepName, Status: status, Err: s.Proc.Err} + var err error + if status == recipe.StatusFailed { + err = fmt.Errorf("process %q failed: %v", s.Proc.Spec.Name, s.Proc.Err) + } + results <- procResult{s.StepName, err} + }(sp) + } + + allDone := make(chan struct{}) + go func() { + wg.Wait() + close(allDone) + }() + + var firstErr error + for remaining := len(launched); remaining > 0; { + select { + case r := <-results: + remaining-- + if r.err != nil && firstErr == nil { + firstErr = r.err + } + case <-ctx.Done(): + mgr.StopAll() + <-allDone + close(updates) + return nil + } + } + close(updates) + return firstErr +} diff --git a/tools/obs/internal/runner/noninteractive_test.go b/tools/obs/internal/runner/noninteractive_test.go new file mode 100644 index 0000000..e28e8af --- /dev/null +++ b/tools/obs/internal/runner/noninteractive_test.go @@ -0,0 +1,29 @@ +package runner_test + +import ( + "bytes" + "strings" + "testing" + + "obs/internal/runner" +) + +func TestPrefixWriter(t *testing.T) { + var buf bytes.Buffer + pw := runner.NewPrefixWriter(&buf, "backend", 0) + + pw.Write([]byte("starting server\n")) + pw.Write([]byte("listening on :8080\n")) + + output := buf.String() + lines := strings.Split(strings.TrimSpace(output), "\n") + + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d: %q", len(lines), output) + } + for _, line := range lines { + if !strings.Contains(line, "backend") { + t.Fatalf("line should contain prefix 'backend': %q", line) + } + } +} diff --git a/tools/obs/internal/runner/ready.go b/tools/obs/internal/runner/ready.go new file mode 100644 index 0000000..f7bf540 --- /dev/null +++ b/tools/obs/internal/runner/ready.go @@ -0,0 +1,81 @@ +package runner + +import ( + "context" + "fmt" + "time" + + "obs/internal/process" + "obs/internal/recipe" +) + +func waitDeps(ctx context.Context, step *recipe.Step, ready map[string]chan struct{}, stepErr map[string]error) (skip bool, err error) { + for _, dep := range step.DependsOn { + if ch, ok := ready[dep]; ok { + select { + case <-ch: + if stepErr[dep] != nil { + return true, nil + } + case <-ctx.Done(): + return false, ctx.Err() + } + } + } + return false, nil +} + +func watchStepReady(ctx context.Context, step *recipe.Step, procs []*process.Process, ch chan struct{}, stepErr map[string]error, onReady func(string, recipe.Status)) { + if step.HasPorts() { + var allPorts []int + for _, spec := range step.Processes { + allPorts = append(allPorts, spec.Ports...) + } + + allDone := make(chan struct{}) + go func() { + for _, proc := range procs { + <-proc.Wait() + } + close(allDone) + }() + + for { + if process.ProbePorts(allPorts) { + onReady(step.Name, recipe.StatusReady) + close(ch) + return + } + select { + case <-time.After(time.Second): + case <-allDone: + stepErr[step.Name] = fmt.Errorf("process exited before ports were ready") + close(ch) + return + case <-ctx.Done(): + return + } + } + } + + for _, proc := range procs { + select { + case <-proc.Wait(): + case <-ctx.Done(): + return + } + } + for _, proc := range procs { + if proc.Status == process.ProcessFailed { + stepErr[step.Name] = fmt.Errorf("process %q failed: %v", proc.Spec.Name, proc.Err) + close(ch) + return + } + if proc.Status == process.ProcessStopped { + stepErr[step.Name] = fmt.Errorf("process %q was stopped", proc.Spec.Name) + close(ch) + return + } + } + close(ch) +} diff --git a/tools/obs/internal/runner/runner.go b/tools/obs/internal/runner/runner.go new file mode 100644 index 0000000..e2faffc --- /dev/null +++ b/tools/obs/internal/runner/runner.go @@ -0,0 +1,12 @@ +package runner + +import ( + "context" + + "obs/internal/process" + "obs/internal/recipe" +) + +type Runner interface { + Run(ctx context.Context, mgr *process.Manager, steps []*recipe.Step, updates chan<- recipe.StepUpdate) error +} diff --git a/tools/obs/internal/state/lock.go b/tools/obs/internal/state/lock.go new file mode 100644 index 0000000..a5f9ece --- /dev/null +++ b/tools/obs/internal/state/lock.go @@ -0,0 +1,39 @@ +package state + +import ( + "fmt" + "os" + "path/filepath" + "syscall" +) + +type Lock struct { + path string + file *os.File +} + +func NewLock(stateDir string) *Lock { + return &Lock{path: filepath.Join(stateDir, "obs.lock")} +} + +func (l *Lock) Acquire() error { + os.MkdirAll(filepath.Dir(l.path), 0755) + f, err := os.OpenFile(l.path, os.O_CREATE|os.O_RDWR, 0644) + if err != nil { + return fmt.Errorf("cannot open lock file: %w", err) + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + f.Close() + return fmt.Errorf("another obs instance is already running (lock: %s)", l.path) + } + l.file = f + return nil +} + +func (l *Lock) Release() { + if l.file != nil { + syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN) + l.file.Close() + os.Remove(l.path) + } +} diff --git a/tools/obs/internal/state/store.go b/tools/obs/internal/state/store.go new file mode 100644 index 0000000..8d12cf2 --- /dev/null +++ b/tools/obs/internal/state/store.go @@ -0,0 +1,137 @@ +package state + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + + "obs/internal/process" +) + +type ProcessState struct { + Name string `json:"name"` + PID int `json:"pid"` +} + +type RunState struct { + Processes []ProcessState `json:"processes"` +} + +type Store struct { + dir string + once sync.Once +} + +func NewStore(dir string) *Store { + return &Store{dir: dir} +} + +func (s *Store) init() error { + var err error + s.once.Do(func() { + for _, sub := range []string{"", "pids"} { + if e := os.MkdirAll(filepath.Join(s.dir, sub), 0755); e != nil { + err = e + return + } + } + }) + return err +} + +func (s *Store) Save(state *RunState) error { + if err := s.init(); err != nil { + return err + } + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + tmp := filepath.Join(s.dir, "state.json.tmp") + if err := os.WriteFile(tmp, data, 0644); err != nil { + return err + } + return os.Rename(tmp, filepath.Join(s.dir, "state.json")) +} + +func (s *Store) Load() (*RunState, error) { + data, err := os.ReadFile(filepath.Join(s.dir, "state.json")) + if err != nil { + return nil, err + } + var state RunState + if err := json.Unmarshal(data, &state); err != nil { + return nil, err + } + return &state, nil +} + +func (s *Store) Clean() { + os.Remove(filepath.Join(s.dir, "state.json")) + os.Remove(filepath.Join(s.dir, "state.json.tmp")) + os.RemoveAll(filepath.Join(s.dir, "pids")) +} + +func (s *Store) WritePID(name string, pid int) error { + if err := s.init(); err != nil { + return err + } + return os.WriteFile(filepath.Join(s.dir, "pids", name+".pid"), []byte(strconv.Itoa(pid)), 0644) +} + +func (s *Store) ReadPID(name string) (int, error) { + data, err := os.ReadFile(filepath.Join(s.dir, "pids", name+".pid")) + if err != nil { + return 0, err + } + return strconv.Atoi(strings.TrimSpace(string(data))) +} + +func (s *Store) RemovePID(name string) { + os.Remove(filepath.Join(s.dir, "pids", name+".pid")) +} + +func DefaultStateDir() string { + dir, _ := os.Getwd() + for { + if _, err := os.Stat(filepath.Join(dir, "tools")); err == nil { + return filepath.Join(dir, ".obs") + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + cwd, _ := os.Getwd() + return filepath.Join(cwd, ".obs") +} + +func (s *Store) FilterAlive(rs *RunState) *RunState { + var alive []ProcessState + for _, p := range rs.Processes { + if process.IsAlive(p.PID) { + alive = append(alive, p) + } + } + return &RunState{Processes: alive} +} + +func PrintStatus(rs *RunState) { + if len(rs.Processes) == 0 { + fmt.Println("No running processes.") + return + } + fmt.Printf("%-25s %-8s %s\n", "PROCESS", "PID", "STATUS") + for _, p := range rs.Processes { + alive := "dead" + if process.IsAlive(p.PID) { + alive = "running" + } + fmt.Printf("%-25s %-8d %s\n", p.Name, p.PID, alive) + } +} diff --git a/tools/obs/internal/state/store_test.go b/tools/obs/internal/state/store_test.go new file mode 100644 index 0000000..fe6535e --- /dev/null +++ b/tools/obs/internal/state/store_test.go @@ -0,0 +1,65 @@ +package state_test + +import ( + "os" + "path/filepath" + "testing" + + "obs/internal/state" +) + +func TestStore_SaveAndLoad(t *testing.T) { + dir := t.TempDir() + store := state.NewStore(dir) + + s := &state.RunState{ + Processes: []state.ProcessState{ + {Name: "mp-frontend", PID: 12345}, + {Name: "mp-backend", PID: 12346}, + }, + } + + if err := store.Save(s); err != nil { + t.Fatalf("Save failed: %v", err) + } + + loaded, err := store.Load() + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if len(loaded.Processes) != 2 { + t.Fatalf("expected 2 processes, got %d", len(loaded.Processes)) + } + if loaded.Processes[0].PID != 12345 { + t.Fatalf("PID mismatch: %d", loaded.Processes[0].PID) + } +} + +func TestStore_Clean(t *testing.T) { + dir := t.TempDir() + store := state.NewStore(dir) + + store.Save(&state.RunState{Processes: []state.ProcessState{{Name: "test", PID: 1}}}) + store.Clean() + + if _, err := os.Stat(filepath.Join(dir, "state.json")); !os.IsNotExist(err) { + t.Fatal("state.json should be removed after Clean") + } +} + +func TestStore_WritePID(t *testing.T) { + dir := t.TempDir() + store := state.NewStore(dir) + + if err := store.WritePID("test-proc", 99999); err != nil { + t.Fatalf("WritePID failed: %v", err) + } + + pid, err := store.ReadPID("test-proc") + if err != nil { + t.Fatalf("ReadPID failed: %v", err) + } + if pid != 99999 { + t.Fatalf("expected PID 99999, got %d", pid) + } +} diff --git a/tools/obs/internal/ui/keys.go b/tools/obs/internal/ui/keys.go new file mode 100644 index 0000000..f3f452f --- /dev/null +++ b/tools/obs/internal/ui/keys.go @@ -0,0 +1,62 @@ +package ui + +import ( + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" +) + +type keyMap struct { + NextTab key.Binding + PrevTab key.Binding + Restart key.Binding + Quit key.Binding + Up key.Binding + Down key.Binding + PageUp key.Binding + PageDown key.Binding +} + +var keys = keyMap{ + NextTab: key.NewBinding(key.WithKeys("tab", "right"), key.WithHelp("tab/→", "next tab")), + PrevTab: key.NewBinding(key.WithKeys("shift+tab", "left"), key.WithHelp("shift+tab/←", "prev tab")), + Restart: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "restart")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q/ctrl+c", "quit")), + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "scroll up")), + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "scroll down")), + PageUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "page up")), + PageDown: key.NewBinding(key.WithKeys("pgdown"), key.WithHelp("pgdn", "page down")), +} + +type mainKeyMap struct{} + +func (mainKeyMap) ShortHelp() []key.Binding { + return []key.Binding{keys.Restart, keys.NextTab, keys.PrevTab, keys.Quit} +} + +func (mainKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {keys.Restart, keys.NextTab, keys.PrevTab}, + {keys.Quit}, + } +} + +type processKeyMap struct{} + +func (processKeyMap) ShortHelp() []key.Binding { + return []key.Binding{keys.Up, keys.Down, keys.PageUp, keys.PageDown, keys.Restart, keys.NextTab, keys.PrevTab, keys.Quit} +} + +func (processKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {keys.Up, keys.Down, keys.PageUp, keys.PageDown}, + {keys.Restart, keys.NextTab, keys.PrevTab}, + {keys.Quit}, + } +} + +func keyMapForTab(activeTabIndex int) help.KeyMap { + if activeTabIndex == 0 { + return mainKeyMap{} + } + return processKeyMap{} +} diff --git a/tools/obs/internal/ui/maintab.go b/tools/obs/internal/ui/maintab.go new file mode 100644 index 0000000..e6130f6 --- /dev/null +++ b/tools/obs/internal/ui/maintab.go @@ -0,0 +1,185 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/viewport" + "github.com/charmbracelet/lipgloss" + "obs/internal/process" + "obs/internal/recipe" +) + +var ( + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + failStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) +) + +type statusDef struct { + Icon string + Style lipgloss.Style + UseSpinner bool +} + +var statusDefs = map[recipe.Status]statusDef{ + recipe.StatusPending: {Icon: "○"}, + recipe.StatusWaiting: {Icon: "◷", Style: lipgloss.NewStyle().Foreground(lipgloss.Color("3"))}, + recipe.StatusRunning: {UseSpinner: true}, + recipe.StatusStarted: {UseSpinner: true}, + recipe.StatusReady: {Icon: "●", Style: lipgloss.NewStyle().Foreground(lipgloss.Color("2"))}, + recipe.StatusDone: {Icon: "✓", Style: lipgloss.NewStyle().Foreground(lipgloss.Color("2"))}, + recipe.StatusStopped: {Icon: "■", Style: lipgloss.NewStyle().Foreground(lipgloss.Color("8"))}, + recipe.StatusFailed: {Icon: "✗", Style: failStyle}, + recipe.StatusSkipped: {Icon: "⊘"}, +} + +func StatusIcon(status recipe.Status, spinnerView string) string { + def := statusDefs[status] + if def.UseSpinner { + return spinnerView + } + if def.Icon == "" { + return "" + } + if def.Style.GetForeground() != (lipgloss.NoColor{}) { + return def.Style.Render(def.Icon) + } + return def.Icon +} + +func MapProcessStatus(ps process.ProcessStatus) recipe.Status { + switch ps { + case process.ProcessFailed: + return recipe.StatusFailed + case process.ProcessStopped: + return recipe.StatusStopped + case process.ProcessDone: + return recipe.StatusDone + default: + return recipe.StatusDone + } +} + +type ProcessInfo struct { + Name string + Status recipe.Status +} + +type StepState struct { + Name string + Status recipe.Status + Err error + Processes []ProcessInfo +} + +type MainTab struct { + steps []StepState + spinner spinner.Model + viewport viewport.Model +} + +func NewMainTab() MainTab { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("6")) + return MainTab{spinner: s} +} + +func (mt *MainTab) SetSize(width, height int) { + mt.viewport.Width = width + mt.viewport.Height = height +} + +func (mt *MainTab) AddStepWithProcesses(name string, processNames []string) { + var procs []ProcessInfo + for _, pn := range processNames { + procs = append(procs, ProcessInfo{Name: pn, Status: recipe.StatusPending}) + } + mt.steps = append(mt.steps, StepState{Name: name, Status: recipe.StatusPending, Processes: procs}) +} + +func (mt *MainTab) UpdateProcess(stepName, procName string, status recipe.Status) { + step := mt.GetStep(stepName) + if step == nil { + return + } + for i := range step.Processes { + if step.Processes[i].Name == procName { + step.Processes[i].Status = status + return + } + } +} + +func (mt *MainTab) GetStep(name string) *StepState { + for i := range mt.steps { + if mt.steps[i].Name == name { + return &mt.steps[i] + } + } + return nil +} + +func (mt *MainTab) UpdateStep(name string, status recipe.Status, err error) { + for i := range mt.steps { + if mt.steps[i].Name == name { + mt.steps[i].Status = status + mt.steps[i].Err = err + if status == recipe.StatusDone || status == recipe.StatusStopped || + status == recipe.StatusFailed || status == recipe.StatusSkipped || + status == recipe.StatusReady { + for j := range mt.steps[i].Processes { + mt.steps[i].Processes[j].Status = status + } + } + return + } + } +} + +func (mt MainTab) View(width, height int) string { + return mt.ViewWithRequirements(width, height, 1, nil) // reqPassed = 1 +} + +func (mt MainTab) ViewWithRequirements(width, height int, reqStatus reqState, reqErr error) string { + var lines []string + + // Requirements section + switch reqStatus { + case reqChecking: + lines = append(lines, lipgloss.NewStyle().Bold(true).Render("Requirements:")) + lines = append(lines, fmt.Sprintf(" %s Checking requirements…", mt.spinner.View())) + lines = append(lines, "") + case reqFailed: + lines = append(lines, lipgloss.NewStyle().Bold(true).Render("Requirements:")) + lines = append(lines, fmt.Sprintf(" %s %s", failStyle.Render("✗"), failStyle.Render(reqErr.Error()))) + lines = append(lines, "") + case reqPassed: + lines = append(lines, lipgloss.NewStyle().Bold(true).Render("Requirements:")) + lines = append(lines, fmt.Sprintf(" %s All requirements met", StatusIcon(recipe.StatusDone, ""))) + lines = append(lines, "") + } + + if len(mt.steps) > 0 { + lines = append(lines, lipgloss.NewStyle().Bold(true).Render("Recipe Steps:")) + lines = append(lines, "") + } + + spinnerView := mt.spinner.View() + for _, step := range mt.steps { + icon := StatusIcon(step.Status, spinnerView) + line := fmt.Sprintf(" %s %s", icon, step.Name) + if step.Status == recipe.StatusFailed && step.Err != nil { + line += failStyle.Render(fmt.Sprintf(" — %v", step.Err)) + } + lines = append(lines, line) + for _, proc := range step.Processes { + procIcon := StatusIcon(proc.Status, spinnerView) + lines = append(lines, fmt.Sprintf(" %s %s %s", dimStyle.Render("│"), procIcon, proc.Name)) + } + } + + mt.viewport.SetContent(strings.Join(lines, "\n")) + return mt.viewport.View() +} diff --git a/tools/obs/internal/ui/maintab_test.go b/tools/obs/internal/ui/maintab_test.go new file mode 100644 index 0000000..c37249b --- /dev/null +++ b/tools/obs/internal/ui/maintab_test.go @@ -0,0 +1,120 @@ +package ui + +import ( + "testing" + + "obs/internal/process" + "obs/internal/recipe" +) + +func newTestMainTab() MainTab { + mt := NewMainTab() + mt.AddStepWithProcesses("step1", []string{"proc-a", "proc-b"}) + mt.AddStepWithProcesses("step2", []string{"proc-c"}) + return mt +} + +func TestUpdateStep_PropagatesDone(t *testing.T) { + mt := newTestMainTab() + mt.UpdateStep("step1", recipe.StatusDone, nil) + + step := mt.GetStep("step1") + if step.Status != recipe.StatusDone { + t.Fatalf("step status: got %v, want Done", step.Status) + } + for _, p := range step.Processes { + if p.Status != recipe.StatusDone { + t.Errorf("process %q: got %v, want Done", p.Name, p.Status) + } + } +} + +func TestUpdateStep_PropagatesStopped(t *testing.T) { + mt := newTestMainTab() + mt.UpdateStep("step1", recipe.StatusStopped, nil) + + for _, p := range mt.GetStep("step1").Processes { + if p.Status != recipe.StatusStopped { + t.Errorf("process %q: got %v, want Stopped", p.Name, p.Status) + } + } +} + +func TestUpdateStep_PropagatesFailed(t *testing.T) { + mt := newTestMainTab() + mt.UpdateStep("step1", recipe.StatusFailed, nil) + + for _, p := range mt.GetStep("step1").Processes { + if p.Status != recipe.StatusFailed { + t.Errorf("process %q: got %v, want Failed", p.Name, p.Status) + } + } +} + +func TestUpdateStep_PropagatesReady(t *testing.T) { + mt := newTestMainTab() + mt.UpdateStep("step1", recipe.StatusReady, nil) + + for _, p := range mt.GetStep("step1").Processes { + if p.Status != recipe.StatusReady { + t.Errorf("process %q: got %v, want Ready", p.Name, p.Status) + } + } +} + +func TestUpdateStep_DoesNotPropagateStarted(t *testing.T) { + mt := newTestMainTab() + mt.UpdateStep("step1", recipe.StatusStarted, nil) + + for _, p := range mt.GetStep("step1").Processes { + if p.Status != recipe.StatusPending { + t.Errorf("process %q: got %v, want Pending (unchanged)", p.Name, p.Status) + } + } +} + +func TestUpdateStep_DoesNotAffectOtherSteps(t *testing.T) { + mt := newTestMainTab() + mt.UpdateStep("step1", recipe.StatusDone, nil) + + step2 := mt.GetStep("step2") + if step2.Status != recipe.StatusPending { + t.Errorf("step2 status: got %v, want Pending", step2.Status) + } + for _, p := range step2.Processes { + if p.Status != recipe.StatusPending { + t.Errorf("step2 process %q: got %v, want Pending", p.Name, p.Status) + } + } +} + +func TestUpdateProcess_IndividualUpdate(t *testing.T) { + mt := newTestMainTab() + mt.UpdateProcess("step1", "proc-a", recipe.StatusStarted) + + step := mt.GetStep("step1") + if step.Processes[0].Status != recipe.StatusStarted { + t.Errorf("proc-a: got %v, want Started", step.Processes[0].Status) + } + if step.Processes[1].Status != recipe.StatusPending { + t.Errorf("proc-b: got %v, want Pending (unchanged)", step.Processes[1].Status) + } +} + +func TestMapProcessStatus(t *testing.T) { + tests := []struct { + input process.ProcessStatus + want recipe.Status + }{ + {process.ProcessFailed, recipe.StatusFailed}, + {process.ProcessStopped, recipe.StatusStopped}, + {process.ProcessDone, recipe.StatusDone}, + {process.ProcessPending, recipe.StatusDone}, + } + for _, tt := range tests { + got := MapProcessStatus(tt.input) + if got != tt.want { + t.Errorf("MapProcessStatus(%v): got %v, want %v", tt.input, got, tt.want) + } + } +} diff --git a/tools/obs/internal/ui/model.go b/tools/obs/internal/ui/model.go new file mode 100644 index 0000000..fa21b2a --- /dev/null +++ b/tools/obs/internal/ui/model.go @@ -0,0 +1,378 @@ +package ui + +import ( + "context" + "time" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "obs/internal/process" + "obs/internal/recipe" +) + +type stepUpdateMsg recipe.StepUpdate +type processOutputMsg struct{} +type shutdownCompleteMsg struct{} + +type AddProcessTabMsg struct { + StepName string + Name string + Proc *process.Process +} + +type processRestartedMsg struct { + name string + proc *process.Process + err error +} + +type RequirementsCheckingMsg struct{} +type RequirementsPassedMsg struct{ Steps []*recipe.Step } +type RequirementsFailedMsg struct{ Err error } + +type reqState int + +const ( + reqChecking reqState = iota + reqPassed + reqFailed +) + +type Model struct { + tabBar TabBar + mainTab MainTab + processTabs []ProcessTab + manager *process.Manager + help help.Model + width int + height int + quitting bool + shutdown bool + updates <-chan recipe.StepUpdate + statusMsg string + reqStatus reqState + reqErr error + retryCh chan<- struct{} + cachedContentHeight int +} + +func NewModel(mgr *process.Manager, updates <-chan recipe.StepUpdate, retryCh chan<- struct{}) Model { + tabs := []string{"main"} + mt := NewMainTab() + h := help.New() + h.ShortSeparator = " · " + + return Model{ + tabBar: NewTabBar(tabs), + mainTab: mt, + manager: mgr, + help: h, + updates: updates, + reqStatus: reqChecking, + retryCh: retryCh, + } +} + +func (m Model) Init() tea.Cmd { + return tea.Batch( + m.mainTab.spinner.Tick, + waitForUpdate(m.updates), + tickOutputRefresh(), + ) +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.recomputeLayout() + + case tea.KeyMsg: + switch { + case key.Matches(msg, keys.Quit): + if m.shutdown { + return m, tea.Quit + } + if !m.quitting { + m.quitting = true + m.statusMsg = "Stopping processes… press q again to exit" + return m, stopProcesses(m.manager) + } + return m, tea.Quit + case key.Matches(msg, keys.NextTab): + m.tabBar.Next() + case key.Matches(msg, keys.PrevTab): + m.tabBar.Prev() + case key.Matches(msg, keys.Restart): + if m.tabBar.Active() == 0 && m.reqStatus != reqChecking { + m.reqStatus = reqChecking + m.reqErr = nil + m.statusMsg = "" + m.mainTab = NewMainTab() + m.processTabs = nil + m.tabBar = NewTabBar([]string{"main"}) + cmds = append(cmds, m.mainTab.spinner.Tick) + select { + case m.retryCh <- struct{}{}: + default: + } + } else if tab := m.activeProcessTab(); tab != nil { + name := tab.Name + mgr := m.manager + cmds = append(cmds, func() tea.Msg { + proc, err := mgr.RestartProcess(context.Background(), name) + return processRestartedMsg{name: name, proc: proc, err: err} + }) + } + case key.Matches(msg, keys.Up): + if m.tabBar.Active() == 0 { + m.mainTab.viewport.LineUp(1) + } else if tab := m.activeProcessTab(); tab != nil { + tab.viewport.LineUp(1) + } + case key.Matches(msg, keys.Down): + if m.tabBar.Active() == 0 { + m.mainTab.viewport.LineDown(1) + } else if tab := m.activeProcessTab(); tab != nil { + tab.viewport.LineDown(1) + } + case key.Matches(msg, keys.PageUp): + if m.tabBar.Active() == 0 { + m.mainTab.viewport.ViewUp() + } else if tab := m.activeProcessTab(); tab != nil { + tab.viewport.ViewUp() + } + case key.Matches(msg, keys.PageDown): + if m.tabBar.Active() == 0 { + m.mainTab.viewport.ViewDown() + } else if tab := m.activeProcessTab(); tab != nil { + tab.viewport.ViewDown() + } + } + + case spinner.TickMsg: + var cmd tea.Cmd + m.mainTab.spinner, cmd = m.mainTab.spinner.Update(msg) + cmds = append(cmds, cmd) + + case stepUpdateMsg: + m.mainTab.UpdateStep(msg.StepName, msg.Status, msg.Err) + cmds = append(cmds, waitForUpdate(m.updates)) + + case processOutputMsg: + if tab := m.activeProcessTab(); tab != nil { + tab.Sync() + } + // Monitor process lifecycle: exit detection + readiness probing + for i := range m.processTabs { + tab := &m.processTabs[i] + proc := tab.Process() + if proc == nil { + continue + } + stepName := tab.StepName + if stepName == "" { + continue + } + step := m.mainTab.GetStep(stepName) + if step == nil || step.Status == recipe.StatusDone || step.Status == recipe.StatusStopped || step.Status == recipe.StatusFailed { + continue + } + if !proc.Running() { + procStatus := MapProcessStatus(proc.Status) + var stepErr error + if procStatus == recipe.StatusFailed { + stepErr = proc.Err + } + m.mainTab.UpdateStep(stepName, procStatus, stepErr) + m.mainTab.UpdateProcess(stepName, tab.Name, procStatus) + } else if step.Status != recipe.StatusReady { + m.mainTab.UpdateProcess(stepName, tab.Name, recipe.StatusStarted) + } + } + cmds = append(cmds, tickOutputRefresh()) + + case AddProcessTabMsg: + found := false + for i := range m.processTabs { + if m.processTabs[i].Name == msg.Name { + m.processTabs[i].SetProcess(msg.Proc) + if msg.StepName != "" { + m.processTabs[i].StepName = msg.StepName + } + found = true + break + } + } + if !found { + m.addProcessTab(msg.Name, msg.Proc) + } + + case processRestartedMsg: + if msg.err != nil { + m.statusMsg = "Restart failed: " + msg.err.Error() + } else { + for i := range m.processTabs { + if m.processTabs[i].Name == msg.name { + m.processTabs[i].SetProcess(msg.proc) + m.mainTab.UpdateStep(m.processTabs[i].StepName, recipe.StatusStarted, nil) + break + } + } + } + + case RequirementsCheckingMsg: + m.reqStatus = reqChecking + m.reqErr = nil + m.mainTab = NewMainTab() + m.processTabs = nil + m.tabBar = NewTabBar([]string{"main"}) + cmds = append(cmds, m.mainTab.spinner.Tick) + + case RequirementsPassedMsg: + m.reqStatus = reqPassed + for _, step := range msg.Steps { + procNames := make([]string, 0, len(step.Processes)) + for _, spec := range step.Processes { + procNames = append(procNames, spec.Name) + pt := NewProcessTab(spec.Name, nil, m.width, m.contentHeight()) + pt.StepName = step.Name + pt.DependsOn = step.DependsOn + m.tabBar.Add(spec.Name) + m.processTabs = append(m.processTabs, pt) + } + m.mainTab.AddStepWithProcesses(step.Name, procNames) + } + m.recomputeLayout() + + case RequirementsFailedMsg: + m.reqStatus = reqFailed + m.reqErr = msg.Err + + case shutdownCompleteMsg: + m.shutdown = true + } + + return m, tea.Batch(cmds...) +} + +func (m Model) View() string { + if m.width == 0 { + return "initializing…" + } + + icons := m.tabIcons() + tabBarView := m.tabBar.ViewWithIcons(m.width, icons) + + m.help.Width = m.width + helpBar := m.help.View(keyMapForTab(m.tabBar.Active())) + + ch := m.contentHeight() + + var content string + if m.tabBar.Active() == 0 { + content = m.mainTab.ViewWithRequirements(m.width, ch, m.reqStatus, m.reqErr) + } else { + idx := m.tabBar.Active() - 1 + if idx < len(m.processTabs) { + content = m.processTabs[idx].View() + } + } + + bottom := helpBar + if m.statusMsg != "" { + bottom = dimStyle.Width(m.width).Render(m.statusMsg) + "\n" + bottom + } + + return tabBarView + "\n" + content + "\n" + bottom +} + +func (m Model) contentHeight() int { + return m.cachedContentHeight +} + +func (m *Model) recomputeLayout() { + tabBarHeight := lipgloss.Height(m.tabBar.View(m.width)) + bottomHeight := lipgloss.Height(m.help.View(keyMapForTab(0))) + if m.statusMsg != "" { + bottomHeight++ + } + h := m.height - tabBarHeight - bottomHeight - 1 + if h < 1 { + h = 1 + } + m.cachedContentHeight = h + m.mainTab.SetSize(m.width, h) + for i := range m.processTabs { + m.processTabs[i].SetSize(m.width, h) + } +} + +func (m *Model) addProcessTab(name string, proc *process.Process) { + m.tabBar.Add(name) + ch := m.contentHeight() + pt := NewProcessTab(name, proc, m.width, ch) + m.processTabs = append(m.processTabs, pt) + m.recomputeLayout() +} + +func (m Model) tabIcons() []string { + icons := make([]string, m.tabBar.Count()) + spinnerView := m.mainTab.spinner.View() + for i := range m.processTabs { + tabIdx := i + 1 + if tabIdx >= len(icons) { + break + } + stepName := m.processTabs[i].StepName + if stepName == "" { + continue + } + step := m.mainTab.GetStep(stepName) + if step == nil { + continue + } + icons[tabIdx] = StatusIcon(step.Status, spinnerView) + } + return icons +} + + +func (m *Model) activeProcessTab() *ProcessTab { + if m.tabBar.Active() > 0 { + idx := m.tabBar.Active() - 1 + if idx < len(m.processTabs) { + return &m.processTabs[idx] + } + } + return nil +} + +func waitForUpdate(ch <-chan recipe.StepUpdate) tea.Cmd { + return func() tea.Msg { + update, ok := <-ch + if !ok { + return nil + } + return stepUpdateMsg(update) + } +} + +func tickOutputRefresh() tea.Cmd { + return tea.Tick(time.Millisecond*100, func(t time.Time) tea.Msg { + return processOutputMsg{} + }) +} + +func stopProcesses(mgr *process.Manager) tea.Cmd { + return func() tea.Msg { + mgr.StopAll() + return shutdownCompleteMsg{} + } +} diff --git a/tools/obs/internal/ui/model_test.go b/tools/obs/internal/ui/model_test.go new file mode 100644 index 0000000..938c762 --- /dev/null +++ b/tools/obs/internal/ui/model_test.go @@ -0,0 +1,22 @@ +package ui_test + +import ( + "testing" + + "obs/internal/process" + "obs/internal/recipe" + "obs/internal/ui" +) + +func TestModel_Init(t *testing.T) { + mgr := process.NewManager() + updates := make(chan recipe.StepUpdate) + close(updates) + + retryCh := make(chan struct{}, 1) + model := ui.NewModel(mgr, updates, retryCh) + cmd := model.Init() + if cmd == nil { + t.Fatal("Init should return a Cmd") + } +} diff --git a/tools/obs/internal/ui/processtab.go b/tools/obs/internal/ui/processtab.go new file mode 100644 index 0000000..b7e95c7 --- /dev/null +++ b/tools/obs/internal/ui/processtab.go @@ -0,0 +1,67 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + "obs/internal/process" +) + + +type ProcessTab struct { + Name string + StepName string + DependsOn []string + proc *process.Process + viewport viewport.Model + lastRenderedCount int +} + +func NewProcessTab(name string, proc *process.Process, width, height int) ProcessTab { + vp := viewport.New(width, height) + vp.SetContent("") + return ProcessTab{Name: name, proc: proc, viewport: vp} +} + +func (pt *ProcessTab) Sync() { + if pt.proc == nil { + return + } + count := pt.proc.Output.Len() + if count == pt.lastRenderedCount { + return + } + content := strings.Join(pt.proc.Output.Lines(), "\n") + pt.viewport.SetContent(content) + pt.viewport.GotoBottom() + pt.lastRenderedCount = count +} + +func (pt *ProcessTab) SetProcess(proc *process.Process) { + pt.proc = proc + pt.lastRenderedCount = 0 + pt.viewport.SetContent("") +} + +func (pt *ProcessTab) Process() *process.Process { + return pt.proc +} + +func (pt *ProcessTab) SetSize(width, height int) { + pt.viewport.Width = width + pt.viewport.Height = height +} + +func (pt ProcessTab) View() string { + if pt.proc == nil && len(pt.DependsOn) > 0 { + var lines []string + lines = append(lines, "") + lines = append(lines, dimStyle.Render("Waiting for:")) + for _, dep := range pt.DependsOn { + lines = append(lines, dimStyle.Render(fmt.Sprintf(" • %s", dep))) + } + return strings.Join(lines, "\n") + } + return pt.viewport.View() +} diff --git a/tools/obs/internal/ui/tabs.go b/tools/obs/internal/ui/tabs.go new file mode 100644 index 0000000..ee34d09 --- /dev/null +++ b/tools/obs/internal/ui/tabs.go @@ -0,0 +1,77 @@ +package ui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +var ( + tabPadding = lipgloss.NewStyle().Padding(0, 2) + + activeNameStyle = lipgloss.NewStyle(). + Bold(true). + Underline(true) + + tabBarStyle = lipgloss.NewStyle(). + BorderBottom(true). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("8")) +) + +type TabBar struct { + tabs []string + active int +} + +func NewTabBar(tabs []string) TabBar { + return TabBar{tabs: tabs, active: 0} +} + +func (tb *TabBar) Add(name string) { + tb.tabs = append(tb.tabs, name) +} + +func (tb *TabBar) Next() { + if len(tb.tabs) > 0 { + tb.active = (tb.active + 1) % len(tb.tabs) + } +} + +func (tb *TabBar) Prev() { + if len(tb.tabs) > 0 { + tb.active = (tb.active - 1 + len(tb.tabs)) % len(tb.tabs) + } +} + +func (tb *TabBar) Active() int { return tb.active } +func (tb *TabBar) Count() int { return len(tb.tabs) } +func (tb *TabBar) ActiveName() string { + if tb.active < len(tb.tabs) { + return tb.tabs[tb.active] + } + return "" +} + +func (tb TabBar) View(width int) string { + return tb.ViewWithIcons(width, nil) +} + +func (tb TabBar) ViewWithIcons(width int, icons []string) string { + var rendered []string + for i, name := range tb.tabs { + nameStyle := dimStyle + if i == tb.active { + nameStyle = activeNameStyle + } + var display string + if i < len(icons) && icons[i] != "" { + display = icons[i] + " " + nameStyle.Render(name) + } else { + display = nameStyle.Render(name) + } + rendered = append(rendered, tabPadding.Render(display)) + } + row := strings.Join(rendered, " ") + return tabBarStyle.Width(width).Render(row) +} diff --git a/tools/obs/recipes/lp/start.go b/tools/obs/recipes/lp/start.go new file mode 100644 index 0000000..c278183 --- /dev/null +++ b/tools/obs/recipes/lp/start.go @@ -0,0 +1,100 @@ +package lp + +import ( + "obs/internal/recipe" + + "github.com/spf13/pflag" +) + +type StartLoggingPlugin struct{} + +func (r *StartLoggingPlugin) Name() string { return "logging-view-plugin" } +func (r *StartLoggingPlugin) Aliases() []string { return []string{"lp"} } +func (r *StartLoggingPlugin) Description() string { + return "Start logging view plugin frontend and backend dev servers" +} + +func (r *StartLoggingPlugin) Flags() *pflag.FlagSet { + return pflag.NewFlagSet("logging-view-plugin", pflag.ContinueOnError) +} + +func (r *StartLoggingPlugin) Requirements(_ *pflag.FlagSet) []recipe.Requirement { + return []recipe.Requirement{ + recipe.RequireNode(), + recipe.RequireNPM(), + recipe.RequireGo(), + recipe.RequireOCLogin(), + recipe.RequirePodman(), + } +} + +func (r *StartLoggingPlugin) Steps(cfg *recipe.Config) ([]*recipe.Step, error) { + dir := "projects/logging-view-plugin" + + return []*recipe.Step{ + { + Name: "install-lp-frontend-deps", + DependsOn: []string{}, + Processes: []recipe.ProcessSpec{ + { + Name: "install lp frontend dependencies", + Command: "npm", + Args: []string{"install"}, + Dir: dir + "/web", + }, + }, + }, + { + Name: "start-lp-frontend", + DependsOn: []string{"install-lp-frontend-deps"}, + Processes: []recipe.ProcessSpec{ + { + Name: "start lp frontend", + Command: "make", + Args: []string{"start-frontend"}, + Dir: dir, + Ports: []int{9001}, + }, + }, + }, + { + Name: "start-lp-backend", + DependsOn: []string{"start-lp-frontend"}, + Processes: []recipe.ProcessSpec{ + { + Name: "start lp backend", + Command: "make", + Args: []string{"start-backend"}, + Dir: dir, + Ports: []int{9002}, + }, + }, + }, + { + Name: "start-lp-console", + DependsOn: []string{}, + Processes: []recipe.ProcessSpec{ + { + Name: "start lp console", + Command: "make", + Args: []string{"start-console"}, + Dir: dir, + Ports: []int{9000}, + }, + }, + }, + { + Name: "start-lp-local-loki", + DependsOn: []string{}, + Processes: []recipe.ProcessSpec{ + { + Name: "lp-local-loki", + Command: "podman", + Args: []string{"compose", "-f", "hack/docker-compose/docker-compose.test.yml", "up"}, + Dir: dir, + Ports: []int{3100}, + }, + }, + }, + }, nil +} diff --git a/tools/obs/recipes/mp/deploy.go b/tools/obs/recipes/mp/deploy.go new file mode 100644 index 0000000..5cdbc73 --- /dev/null +++ b/tools/obs/recipes/mp/deploy.go @@ -0,0 +1,124 @@ +package mp + +import ( + "obs/internal/recipe" + + "github.com/spf13/pflag" +) + +type DeployMonitoringPlugin struct{} + +func (r *DeployMonitoringPlugin) Name() string { return "monitoring-plugin" } +func (r *DeployMonitoringPlugin) Aliases() []string { return []string{"mp"} } +func (r *DeployMonitoringPlugin) Description() string { + return "Deploy monitoring plugin image in a running OpenShift cluster" +} + +func (r *DeployMonitoringPlugin) Flags() *pflag.FlagSet { + fs := pflag.NewFlagSet("monitoring-plugin", pflag.ContinueOnError) + fs.String("image", "", "container image to build and push (e.g. quay.io/user/monitoring-plugin:tag)") + return fs +} + +func (r *DeployMonitoringPlugin) Requirements(flags *pflag.FlagSet) []recipe.Requirement { + return []recipe.Requirement{ + recipe.RequireFlag(flags, "image", "container image to build and push"), + recipe.RequireOCLogin(), + recipe.RequirePodman(), + recipe.RequireJQ(), + } +} + +func (r *DeployMonitoringPlugin) Steps(cfg *recipe.Config) ([]*recipe.Step, error) { + dir := "projects/monitoring-plugin" + + image, _ := cfg.Flags.GetString("image") + + return []*recipe.Step{ + { + Name: "build-mp-image", + DependsOn: []string{}, + Processes: []recipe.ProcessSpec{ + { + Name: "build mp image", + Command: "podman", + Args: []string{"build", "-f", "Dockerfile.dev", "--platform=linux/amd64", "-t", image}, + Dir: dir, + }, + }, + }, + { + Name: "push-mp-image", + DependsOn: []string{"build-mp-image"}, + Processes: []recipe.ProcessSpec{ + { + Name: "push mp image", + Command: "podman", + Args: []string{"push", image}, + Dir: dir, + }, + }, + }, + { + Name: "set-mco-to-unmanaged", + DependsOn: []string{"push-mp-image"}, + Processes: []recipe.ProcessSpec{ + { + Name: "set mco to unmanaged", + Command: "oc", + Args: []string{"patch", "clusterversion", "version", "--type", "json", "-p", "{{content:mco-patch}}"}, + Files: map[string]recipe.FileRef{ + "mco-patch": {FS: filesFS, Path: "files/set-mco-to-unmanaged.yaml"}, + }, + }, + }, + }, + { + Name: "scale-down-cmo", + DependsOn: []string{"set-mco-to-unmanaged"}, + Processes: []recipe.ProcessSpec{ + { + Name: "scale down cmo", + Command: "oc", + Args: []string{"scale", "--replicas=0", "-n", "openshift-monitoring", "deployment/cluster-monitoring-operator"}, + }, + { + Name: "scale down monitoring plugin", + Command: "oc", + Args: []string{"scale", "--replicas=0", "-n", "openshift-monitoring", "deployment/monitoring-plugin"}, + }, + }, + }, + { + Name: "patch-cmo", + DependsOn: []string{"scale-down-cmo"}, + Processes: []recipe.ProcessSpec{ + { + Name: "patch cmo", + Command: "bash", + Args: []string{"-c", "{{content:patch-cmo-script}}"}, + Env: map[string]string{"MP_IMAGE": image}, + Files: map[string]recipe.FileRef{ + "patch-cmo-script": {FS: filesFS, Path: "files/patch-cmo.sh"}, + }, + }, + }, + }, + { + Name: "scale-up-cmo", + DependsOn: []string{"patch-cmo"}, + Processes: []recipe.ProcessSpec{ + { + Name: "scale up cmo", + Command: "oc", + Args: []string{"scale", "--replicas=1", "-n", "openshift-monitoring", "deployment/cluster-monitoring-operator"}, + }, + { + Name: "scale up monitoring plugin", + Command: "oc", + Args: []string{"scale", "--replicas=1", "-n", "openshift-monitoring", "deployment/monitoring-plugin"}, + }, + }, + }, + }, nil +} diff --git a/tools/obs/recipes/mp/files.go b/tools/obs/recipes/mp/files.go new file mode 100644 index 0000000..31e9843 --- /dev/null +++ b/tools/obs/recipes/mp/files.go @@ -0,0 +1,6 @@ +package mp + +import "embed" + +//go:embed files/* +var filesFS embed.FS diff --git a/tools/obs/recipes/mp/files/patch-cmo.sh b/tools/obs/recipes/mp/files/patch-cmo.sh new file mode 100644 index 0000000..e4cbae9 --- /dev/null +++ b/tools/obs/recipes/mp/files/patch-cmo.sh @@ -0,0 +1,5 @@ +set -e +INDEX=$(oc get deploy cluster-monitoring-operator -n openshift-monitoring -o json | \ + jq -r '.spec.template.spec.containers[0].args | to_entries[] | select(.value | contains("monitoring-plugin")) | .key') +oc patch deploy cluster-monitoring-operator -n openshift-monitoring --type=json \ + -p "[{\"op\":\"replace\",\"path\":\"/spec/template/spec/containers/0/args/$INDEX\",\"value\":\"--images=monitoring-plugin=$MP_IMAGE\"}]" diff --git a/tools/obs/recipes/mp/files/set-mco-to-unmanaged.yaml b/tools/obs/recipes/mp/files/set-mco-to-unmanaged.yaml new file mode 100644 index 0000000..5db00c5 --- /dev/null +++ b/tools/obs/recipes/mp/files/set-mco-to-unmanaged.yaml @@ -0,0 +1,8 @@ +- op: add + path: /spec/overrides + value: + - kind: Deployment + group: apps + name: cluster-monitoring-operator + namespace: openshift-monitoring + unmanaged: true diff --git a/tools/obs/recipes/mp/start.go b/tools/obs/recipes/mp/start.go new file mode 100644 index 0000000..416d761 --- /dev/null +++ b/tools/obs/recipes/mp/start.go @@ -0,0 +1,87 @@ +package mp + +import ( + "obs/internal/recipe" + + "github.com/spf13/pflag" +) + +type StartMonitoringPlugin struct{} + +func (r *StartMonitoringPlugin) Name() string { return "monitoring-plugin" } +func (r *StartMonitoringPlugin) Aliases() []string { return []string{"mp"} } +func (r *StartMonitoringPlugin) Description() string { + return "Start monitoring plugin frontend and backend dev servers" +} + +func (r *StartMonitoringPlugin) Flags() *pflag.FlagSet { + return pflag.NewFlagSet("monitoring-plugin", pflag.ContinueOnError) +} + +func (r *StartMonitoringPlugin) Requirements(_ *pflag.FlagSet) []recipe.Requirement { + return []recipe.Requirement{ + recipe.RequireNode(), + recipe.RequireNPM(), + recipe.RequireGo(), + recipe.RequirePodman(), + recipe.RequireOCLogin(), + } +} + +func (r *StartMonitoringPlugin) Steps(cfg *recipe.Config) ([]*recipe.Step, error) { + dir := "projects/monitoring-plugin" + + return []*recipe.Step{ + { + Name: "install-mp-frontend-deps", + DependsOn: []string{}, + Processes: []recipe.ProcessSpec{ + { + Name: "install mp frontend dependencies", + Command: "npm", + Args: []string{"install"}, + Dir: dir + "/web", + }, + }, + }, + { + Name: "start-mp-frontend", + DependsOn: []string{"install-mp-frontend-deps"}, + Processes: []recipe.ProcessSpec{ + { + Name: "start mp frontend", + Command: "make", + Args: []string{"start-frontend"}, + Dir: dir, + Ports: []int{9001}, + }, + }, + }, + { + Name: "start-mp-backend", + DependsOn: []string{}, + Processes: []recipe.ProcessSpec{ + { + Name: "start mp backend", + Command: "make", + Args: []string{"start-feature-backend"}, + Dir: dir, + Ports: []int{9443}, + }, + }, + }, + { + Name: "start-mp-console", + DependsOn: []string{}, + Processes: []recipe.ProcessSpec{ + { + Name: "start mp console", + Command: "make", + Args: []string{"start-feature-console"}, + Dir: dir, + Ports: []int{9000}, + }, + }, + }, + }, nil +} diff --git a/tools/obs/recipes/register.go b/tools/obs/recipes/register.go new file mode 100644 index 0000000..ae51d24 --- /dev/null +++ b/tools/obs/recipes/register.go @@ -0,0 +1,13 @@ +package recipes + +import ( + "obs/internal/recipe" + "obs/recipes/lp" + "obs/recipes/mp" +) + +func init() { + recipe.DefaultRegistry.Register("start", &mp.StartMonitoringPlugin{}) + recipe.DefaultRegistry.Register("deploy", &mp.DeployMonitoringPlugin{}) + recipe.DefaultRegistry.Register("start", &lp.StartLoggingPlugin{}) +}