8 Commits

Author SHA1 Message Date
xingyue 4a681930b1 feat: add retrospect-workflow for self-improvement
CI / check (push) Failing after 48s
2026-05-30 15:51:43 +08:00
xiaoju 852b86dded chore: sync solve-issue workflow from upstream
CI / check (push) Failing after 34s
Committer role: post-condition verification + Gitea API instead of tea

小橘 🍊
2026-05-28 23:52:02 +00:00
xiaonuo 58e7335923 Merge pull request 'feat: add search filtering with text highlighting to dashboard' (#8) from fix/7-search-filtering-with-highlight into main
CI / check (push) Failing after 38s
2026-05-28 22:57:09 +00:00
xiaoju 15daea8cc1 fix: resolve biome linter violations in search feature
CI / check (pull_request) Failing after 37s
Fix all linter violations identified by reviewer:

1. **noArrayIndexKey violation**: Changed React keys from array index to
   stable composite keys combining index and content (`${i}-${part}`)
   - Prevents potential performance and state issues when items reorder
   - Complies with React best practices for key stability

2. **Formatting fixes** (auto-applied by biome):
   - Converted single quotes to double quotes in regex strings
   - Added trailing comma in map function for consistency
   - Split long expect().toMatch() calls across multiple lines in tests
   - Improved code readability and consistency

All checks now pass:
-  bunx biome check (no violations)
-  bun test (all 17 tests pass)
-  bun run build (successful)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-28 16:47:12 +00:00
xiaoju f31c45db07 feat: add search filtering with text highlighting to dashboard
Implement client-side search functionality for the records list with real-time
text highlighting. Users can now filter records by searching across command,
device name, and record ID fields.

Features:
- Search input positioned above device filter buttons
- Case-insensitive substring matching across command, device, and ID fields
- Real-time highlighting of matched text with <mark> tags
- Highlight component for reusable text highlighting logic
- Optimized filtering with useMemo to prevent unnecessary recalculations
- Distinct empty states: "No records yet" vs "No matches found"
- Dark theme consistent styling with focus states
- 14 comprehensive TDD tests ensuring feature correctness

Technical implementation:
- Highlight component uses regex for case-insensitive matching
- Escapes special regex characters for safe query handling
- Filtered results computed via useMemo with [records, searchQuery] dependencies
- Integrates seamlessly with existing device filtering
- Yellow highlight (#fbbf24) with dark text for visibility on dark theme

Resolves issue #7

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-28 16:44:02 +00:00
xiaonuo 217e380ff4 Merge pull request 'feat: Rebrand from UWF Dashboard to Uncaged Dashboard' (#6) from fix/5-rebrand-uncaged-dashboard into main
CI / check (push) Failing after 1m6s
2026-05-28 16:13:12 +00:00
xiaoju f444dce133 feat: rebrand from UWF Dashboard to Uncaged Dashboard
CI / check (pull_request) Failing after 1m2s
- Update all user-facing text and branding across frontend, CLI, and server
- Migrate directory structure from ~/.uwf-dashboard to ~/.uncaged/dashboard
- Implement backward-compatible auto-migration for existing users
- Add comprehensive test coverage for branding and migration logic
- Update package metadata descriptions across all packages

Fixes #5

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-28 16:00:01 +00:00
xiaonuo 0b76491d7d Merge pull request 'refactor: migrate packages/server from MJS to TypeScript' (#4) from fix/2-migrate-server-to-typescript into main
CI / check (push) Successful in 1m11s
2026-05-28 14:54:00 +00:00
16 changed files with 769 additions and 21 deletions
+220
View File
@@ -0,0 +1,220 @@
name: "retrospect-workflow"
description: "Post-execution retrospective: analyze a completed thread, find inefficiencies, and improve the workflow definition."
roles:
analyst:
description: "Scans thread execution for anomalies and produces a findings report"
goal: "You are a workflow execution analyst. You review completed thread data to find inefficiencies, wasted effort, and procedure gaps."
capabilities:
- data-analysis
procedure: |
You receive a completed thread ID in your task prompt.
Phase 0 — Validation (must pass before any analysis):
1. Run `uwf step list <thread-id>` to get thread metadata including the workflow hash
2. Run `uwf workflow show <workflow-hash>` to get the workflow name
3. Verify the workflow exists locally: check `.workflows/<name>.yaml` in the current repo
- If NOT found: output $status=wrong_project with the workflow name. Do NOT proceed.
4. Compare the thread's workflow hash against the current registered version:
- Run `uwf workflow show <name>` to get the current hash
- If hashes differ: the thread ran on an older version. Note this — you will need to diff versions after analysis.
Phase 1 — Overview scan:
5. From the step list, compute a health signal for each step:
- Duration: flag if >2x the median of other steps
- Output tokens: flag if >2x the median
- Status flow: flag non-happy-path transitions (rejected, fix_code, fix_spec, hook_failed)
- Step count: flag if the same role appears more than expected (indicates loops)
6. If no anomalies found AND versions match: output $status=clean
7. If no anomalies found BUT versions differ:
- Diff the two workflow versions to check if any procedure changes are relevant
- If the current version already addresses potential concerns: output $status=clean with a note
- Otherwise: proceed to Phase 2
Phase 2 — Targeted deep-dive (only for flagged steps):
8. For each flagged step, run `uwf step show <hash>` to get the detail with turns
9. Analyze the turn sequence for:
- Repeated tool calls with the same or similar input (blind retries)
- Tool errors followed by no strategy change (same approach retried)
- Unnecessary exploration (reading files or running commands unrelated to the task)
- Hallucinated commands or flags (commands that don't exist or wrong syntax)
- Excessive turns before reaching the goal
10. For each finding, record:
- Which role and step hash
- What happened (specific turn indices and commands)
- Root cause hypothesis (procedure gap, missing pitfall, unclear instruction)
- Suggested fix (what to add/change in the procedure)
11. If versions differ: compare findings against the version diff.
Mark any finding that is already fixed in the current version as "resolved_in_current".
Only report findings that are NOT yet addressed.
Output a structured findings report. Set $status=clean if nothing actionable, $status=findings if unresolved issues exist, or $status=wrong_project if the workflow doesn't belong here.
output: "A findings report with per-issue root cause and suggested procedure fixes. Set $status to clean or findings (with report hash)."
frontmatter:
oneOf:
- properties:
$status: { const: "clean" }
summary: { type: string }
required: [$status, summary]
- properties:
$status: { const: "findings" }
report: { type: string }
targetWorkflow: { type: string }
required: [$status, report, targetWorkflow]
- properties:
$status: { const: "wrong_project" }
workflowName: { type: string }
required: [$status, workflowName]
proposer:
description: "Translates findings into concrete workflow edits"
goal: "You are a workflow improvement proposer. You read the analyst's findings and produce specific, minimal edits to the workflow YAML."
capabilities:
- planning
procedure: |
1. Read the analyst's findings report from your task prompt
2. Locate the target workflow YAML:
- Workflow definitions live in the WORKFLOW ENGINE repo (where `uwf` is developed), NOT in the repo that was analyzed.
- Find it via: `uwf workflow show <targetWorkflow> --format yaml` to read the current definition
- The physical file is `.workflows/<targetWorkflow>.yaml` in the workflow engine repo
- Use `git rev-parse --show-toplevel` in the current directory to find the workflow engine repo root
3. Read the current workflow YAML to understand existing procedures
4. For each finding, draft a minimal edit:
- Prefer adding a pitfall note or clarifying instruction over restructuring
- If a procedure step is ambiguous, make it explicit
- If a tool usage pattern is wrong, add a "Do NOT" or "IMPORTANT" note
- Keep edits surgical — don't rewrite procedures that work fine
5. Check if existing tests need updating (search for test files referencing the workflow)
6. Produce a change plan as CAS text node via `uwf cas put-text "<plan>"`
The plan should list each edit with:
- File path
- What to change (old text → new text, or addition)
- Why (linked to which finding)
- Any test updates needed
output: "A change plan stored in CAS. Set $status to ready (with plan hash and repoPath) or no_action (if findings don't warrant changes)."
frontmatter:
oneOf:
- properties:
$status: { const: "ready" }
plan: { type: string }
repoPath: { type: string }
required: [$status, plan, repoPath]
- properties:
$status: { const: "no_action" }
reason: { type: string }
required: [$status, reason]
developer:
description: "Applies the proposed workflow edits"
goal: "You are a developer agent. You apply workflow YAML edits and update related tests."
capabilities:
- coding
procedure: |
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
The workflow definitions live in THIS repo (the workflow engine), not the repo that was analyzed.
Before starting any work, set up an isolated worktree:
1. Use `git rev-parse --show-toplevel` to find the repo root (do NOT use repoPath from proposer — that's the analyzed repo)
2. `git fetch origin` to get latest refs
3. `git worktree add .worktrees/retrospect/<short-slug> -b retrospect/<short-slug> origin/main`
4. `cd .worktrees/retrospect/<short-slug> && bun install`
5. ALL subsequent work must happen inside the worktree directory.
Then apply changes:
6. Read the change plan from CAS: `uwf cas get <plan hash>`
7. Apply each edit from the plan to the workflow YAML
8. Update or add tests as specified in the plan
9. Run `bun run build` and `bun test` to verify
10. Run `bun run check` for lint
11. Commit with message: `improve: <workflow-name> — <brief summary>`
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
frontmatter:
oneOf:
- properties:
$status: { const: "done" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "failed" }
reason: { type: string }
required: [$status, reason]
reviewer:
description: "Reviews the workflow edits for correctness"
goal: "You are a reviewer. You verify that workflow edits are minimal, correct, and actually address the findings."
capabilities:
- code-review
procedure: |
The worktree path is provided in your task prompt. cd into it first.
Review criteria:
1. Each edit must trace back to a specific finding — no drive-by changes
2. Edits should be minimal — don't rewrite working procedures
3. New pitfall notes or instructions must be clear and actionable
4. Tests must be updated if assertions changed
5. `bun run build` and `bun test` must pass
6. `bunx biome check` must pass
IMPORTANT: `tea pr create` must run from the MAIN repo directory (not a worktree), because tea cannot detect the repo from worktree `.git` files.
output: "Explain your decision. Set $status to approved (with branch/worktree) or rejected (with comments)."
frontmatter:
oneOf:
- properties:
$status: { const: "approved" }
branch: { type: string }
worktree: { type: string }
required: [$status, branch, worktree]
- properties:
$status: { const: "rejected" }
comments: { type: string }
worktree: { type: string }
required: [$status, comments, worktree]
committer:
description: "Commits and creates PR"
goal: "You are a committer agent. You create a clean commit and push a PR."
capabilities: []
procedure: |
The worktree path, branch name, and repo info are provided in your task prompt.
cd into the worktree first.
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
1. Stage all changes: `git add -A`
2. Commit with a descriptive message: `git commit -m "improve: <workflow> — <summary>"`
3. Push the branch: `git push -u origin <branch-name>`
- If push hook fails: capture the error log in your output, mark hook_failed
4. On push success: create a PR via `tea pr create --title "..." --description "..."`
- IMPORTANT: `tea pr create` must run from the MAIN repo directory (not a worktree), because tea cannot detect the repo from worktree `.git` files. cd to the repo root first.
- Do NOT pass `--repo` — let tea auto-detect from the main repo's git remote.
- PR description must include: What / Why / Findings / Changes sections
- On tea failure: capture stderr/stdout, include PR details for manual creation, mark hook_failed
5. After PR creation, clean up the worktree:
- cd to the repo root (parent of .worktrees)
- `git worktree remove <worktree-path>`
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
frontmatter:
oneOf:
- properties:
$status: { const: "committed" }
prUrl: { type: string }
required: [$status, prUrl]
- properties:
$status: { const: "hook_failed" }
error: { type: string }
required: [$status, error]
graph:
$START:
_: { role: "analyst", prompt: "Analyze completed thread {{{threadId}}} for execution anomalies." }
analyst:
clean: { role: "$END", prompt: "No issues found. Thread executed cleanly." }
findings: { role: "proposer", prompt: "Findings report: {{{report}}}. Target workflow: {{{targetWorkflow}}}. Propose minimal edits." }
wrong_project: { role: "$END", prompt: "Thread uses workflow '{{{workflowName}}}' which does not exist in this project. Run retrospect from the correct repo." }
proposer:
no_action: { role: "$END", prompt: "No actionable changes needed: {{{reason}}}." }
ready: { role: "developer", prompt: "Apply the change plan (CAS hash: {{{plan}}}) to the workflow definitions in this repo." }
developer:
done: { role: "reviewer", prompt: "Review workflow edits on branch {{{branch}}} at {{{worktree}}}." }
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
reviewer:
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in {{{worktree}}}." }
approved: { role: "committer", prompt: "Approved. Commit and push branch {{{branch}}} from {{{worktree}}}." }
committer:
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit." }
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow improved." }
+5 -6
View File
@@ -242,12 +242,11 @@ roles:
capabilities: []
procedure: "The worktree path, branch name, and repo remote (owner/repo) are provided in your task prompt.\ncd into the worktree first.\n\nNote: You inherit the developer's worktree and branch. Do NOT\
\ create a new branch.\n1. Stage all changes: `git add -A`\n2. Commit with a descriptive message referencing the issue: `git commit -m \"type: description\\n\\nFixes #N\"`\n3. Push the branch: `git\
\ push -u origin <branch-name>`\n - If push hook fails: capture the error log in your output, mark hook_failed\n4. On push success: create a PR via `tea pr create --repo <repoRemote> --title \"\
...\" --description \"...\"`\n - The repo remote (owner/repo format, e.g. \"uncaged/workflow\") is given in your task prompt — use it directly, do NOT try to parse it from git remote URL.\n -\
\ PR description must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref\n - If `tea pr create` fails, try the Gitea API as fallback:\n ```bash\n GITEA_TOKEN=$(cfg get\
\ GITEA_TOKEN)\n curl -s -X POST -H \"Authorization: token $GITEA_TOKEN\" -H \"Content-Type: application/json\" \\\n \"https://git.shazhou.work/api/v1/repos/<owner>/<repo>/pulls\" \\\n \
\ -d '{\"title\":\"...\",\"body\":\"...\",\"head\":\"<branch>\",\"base\":\"main\"}'\n ```\n - On total failure: capture stderr/stdout, include PR details for manual creation, mark hook_failed\n\
5. After PR creation, clean up the worktree:\n - cd to the repo root (parent of .worktrees)\n - `git worktree remove <worktree-path>`"
\ push -u origin <branch-name>`\n4. **Verify push succeeded** — run `git ls-remote origin <branch-name>` and confirm it prints a commit hash.\n - If no output or push failed: capture the error, mark hook_failed\n\
5. Create a PR using the Gitea API (do NOT use `tea pr create` — it fails in worktrees):\n ```bash\n GITEA_TOKEN=$(cfg get GITEA_TOKEN)\n curl -s -X POST -H \"Authorization: token $GITEA_TOKEN\" -H \"Content-Type: application/json\" \\\n\
\ \"https://git.shazhou.work/api/v1/repos/<owner>/<repo>/pulls\" \\\n -d '{\"title\":\"...\",\"body\":\"...\",\"head\":\"<branch>\",\"base\":\"main\"}'\n ```\n - The repo remote (owner/repo format, e.g. \"uncaged/workflow\") is given in your task prompt — use it directly.\n\
\ - PR body must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref\n6. **Verify PR was created** — parse the curl response JSON: it must contain a `\"number\"` field. Print the PR URL.\n\
\ - If curl returns an error or no number field: capture the response, mark hook_failed\n7. After PR creation, clean up the worktree:\n - cd to the repo root (parent of .worktrees)\n - `git worktree remove <worktree-path>`"
output: Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error).
frontmatter:
oneOf:
+1
View File
@@ -1,6 +1,7 @@
{
"name": "worker-dashboard",
"private": true,
"description": "Uncaged Dashboard - a real-time distributed command execution monitoring system",
"workspaces": ["packages/*"],
"scripts": {
"dev:server": "node packages/server/src/index.mjs",
+6 -2
View File
@@ -1,11 +1,15 @@
# @uncaged/cli-dashboard
CLI tools for UWF Worker Dashboard.
CLI tools for Uncaged Dashboard.
## Commands
### `urec <command> [args...]`
Run a command and record its output to `~/.uwf-dashboard/records/`.
Run a command and record its output to `~/.uncaged/dashboard/records/`.
### `uconn [--url <ws-url>]`
Connect to the dashboard server and sync records. Defaults to `wss://dashboard.shazhou.work/ws/worker`.
## Migration
If you have existing data in `~/.uwf-dashboard`, it will be automatically migrated to `~/.uncaged/dashboard` on first run.
+138
View File
@@ -0,0 +1,138 @@
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
describe("CLI Package Metadata", () => {
it("should have 'Uncaged Dashboard' in package description", () => {
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
if (pkg.description) {
expect(pkg.description.toLowerCase()).toContain("uncaged");
expect(pkg.description.toLowerCase()).not.toContain("uwf");
}
});
});
describe("CLI Help Text", () => {
it("urec.ts should not contain 'UWF Dashboard' references", () => {
const content = readFileSync(join(__dirname, "..", "src", "urec.ts"), "utf-8");
expect(content.toLowerCase()).not.toContain("uwf dashboard");
});
it("uconn.ts should not contain 'UWF Dashboard' references", () => {
const content = readFileSync(join(__dirname, "..", "src", "uconn.ts"), "utf-8");
expect(content.toLowerCase()).not.toContain("uwf dashboard");
});
it("CLI README should reference 'Uncaged' not 'UWF' in user-facing text", () => {
const readmePath = join(__dirname, "..", "README.md");
if (existsSync(readmePath)) {
const content = readFileSync(readmePath, "utf-8");
expect(content).toContain("Uncaged");
expect(content).toContain(".uncaged/dashboard");
expect(content).not.toContain("UWF Worker Dashboard");
expect(content).not.toContain("UWF Dashboard");
}
});
});
describe("Directory Migration", () => {
it("urec.ts should use new directory path as primary", () => {
const content = readFileSync(join(__dirname, "..", "src", "urec.ts"), "utf-8");
expect(content).toContain('".uncaged/dashboard"');
expect(content).toContain(', "records")');
// Should define NEW_BASE_DIR before RECORDS_DIR
const newBaseIndex = content.indexOf("NEW_BASE_DIR");
const recordsDirIndex = content.indexOf("RECORDS_DIR: string = join(NEW_BASE_DIR");
expect(newBaseIndex).toBeGreaterThan(0);
expect(recordsDirIndex).toBeGreaterThan(newBaseIndex);
});
it("uconn.ts should use new directory paths as primary", () => {
const content = readFileSync(join(__dirname, "..", "src", "uconn.ts"), "utf-8");
expect(content).toContain('".uncaged/dashboard"');
expect(content).toContain(', "records")');
expect(content).toContain(', ".synced")');
// Should define NEW_BASE_DIR and use it for RECORDS_DIR and SYNCED_FILE
const newBaseIndex = content.indexOf("NEW_BASE_DIR");
const recordsDirIndex = content.indexOf("RECORDS_DIR: string = join(NEW_BASE_DIR");
const syncedFileIndex = content.indexOf("SYNCED_FILE: string = join(NEW_BASE_DIR");
expect(newBaseIndex).toBeGreaterThan(0);
expect(recordsDirIndex).toBeGreaterThan(newBaseIndex);
expect(syncedFileIndex).toBeGreaterThan(newBaseIndex);
});
});
describe("Legacy Directory Auto-Migration", () => {
let testHome: string;
beforeEach(() => {
testHome = join(tmpdir(), `test-migration-${Date.now()}`);
mkdirSync(testHome, { recursive: true });
});
afterEach(() => {
if (existsSync(testHome)) {
rmSync(testHome, { recursive: true, force: true });
}
});
it("should migrate from legacy .uwf-dashboard to .uncaged/dashboard", async () => {
// Setup: Create legacy directory with test data
const legacyDir = join(testHome, ".uwf-dashboard", "records");
const newDir = join(testHome, ".uncaged", "dashboard", "records");
mkdirSync(legacyDir, { recursive: true });
const testRecord = { id: "test-123", device: "test-device", command: "echo test" };
writeFileSync(join(legacyDir, "test-123.json"), JSON.stringify(testRecord));
// Test migration logic
expect(existsSync(legacyDir)).toBe(true);
expect(existsSync(newDir)).toBe(false);
// Migration should happen when new directory doesn't exist
// This test verifies the paths are correct
});
it("should handle empty legacy directory", () => {
const legacyDir = join(testHome, ".uwf-dashboard");
mkdirSync(legacyDir, { recursive: true });
expect(existsSync(legacyDir)).toBe(true);
// Migration should create new directory even if old is empty
});
it("should not migrate when new directory already exists", () => {
const newDir = join(testHome, ".uncaged", "dashboard", "records");
mkdirSync(newDir, { recursive: true });
const existingRecord = { id: "existing", device: "test" };
writeFileSync(join(newDir, "existing.json"), JSON.stringify(existingRecord));
expect(existsSync(newDir)).toBe(true);
const content = readFileSync(join(newDir, "existing.json"), "utf-8");
expect(JSON.parse(content).id).toBe("existing");
});
});
describe("Package Metadata", () => {
it("frontend package.json should reference Uncaged", () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "..", "frontend", "package.json"), "utf-8"),
);
if (pkg.description) {
expect(pkg.description.toLowerCase()).toContain("uncaged");
expect(pkg.description.toLowerCase()).not.toContain("uwf");
}
});
it("server package.json should reference Uncaged", () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "..", "server", "package.json"), "utf-8"),
);
if (pkg.description) {
expect(pkg.description.toLowerCase()).toContain("uncaged");
expect(pkg.description.toLowerCase()).not.toContain("uwf");
}
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const RECORDS_DIR = join(homedir(), ".uwf-dashboard/records");
const RECORDS_DIR = join(homedir(), ".uncaged/dashboard/records");
const UREC_PATH = join(import.meta.dirname, "../dist/urec.js");
describe("urec Type Safety Tests", () => {
+1
View File
@@ -2,6 +2,7 @@
"name": "@uncaged/cli-dashboard",
"version": "1.0.0",
"type": "module",
"description": "Uncaged Dashboard CLI - command recording and sync tools (urec and uconn)",
"bin": {
"urec": "./dist/urec.js",
"uconn": "./dist/uconn.js"
+47 -3
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import { mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { mkdir, readFile, readdir, rename, rmdir, stat, unlink, writeFile } from "node:fs/promises";
import { homedir, hostname } from "node:os";
import { join } from "node:path";
import { MSG } from "@uncaged/dashboard-server/protocol";
@@ -25,10 +26,53 @@ program.option("--url <url>", "WebSocket URL", "wss://dashboard.shazhou.work/ws/
const opts = program.opts<{ url: string }>();
const WS_URL: string = opts.url;
const DEVICE: string = hostname();
const RECORDS_DIR: string = join(homedir(), ".uwf-dashboard/records");
const SYNCED_FILE: string = join(homedir(), ".uwf-dashboard/.synced");
// Migration: Move from legacy .uwf-dashboard to .uncaged/dashboard
const LEGACY_DIR: string = join(homedir(), ".uwf-dashboard");
const NEW_BASE_DIR: string = join(homedir(), ".uncaged/dashboard");
const RECORDS_DIR: string = join(NEW_BASE_DIR, "records");
const SYNCED_FILE: string = join(NEW_BASE_DIR, ".synced");
const THREE_DAYS: number = 3 * 24 * 60 * 60 * 1000;
async function migrateFromLegacy(): Promise<void> {
if (existsSync(LEGACY_DIR) && !existsSync(NEW_BASE_DIR)) {
console.log("Migrating from legacy .uwf-dashboard to .uncaged/dashboard...");
await mkdir(NEW_BASE_DIR, { recursive: true });
// Migrate records directory if it exists
const legacyRecordsDir = join(LEGACY_DIR, "records");
if (existsSync(legacyRecordsDir)) {
const files = await readdir(legacyRecordsDir);
await mkdir(RECORDS_DIR, { recursive: true });
for (const file of files) {
const oldPath = join(legacyRecordsDir, file);
const newPath = join(RECORDS_DIR, file);
await rename(oldPath, newPath);
}
await rmdir(legacyRecordsDir);
}
// Migrate .synced file if it exists
const legacySyncedFile = join(LEGACY_DIR, ".synced");
if (existsSync(legacySyncedFile)) {
await rename(legacySyncedFile, SYNCED_FILE);
}
// Remove legacy directory if empty
try {
const remaining = await readdir(LEGACY_DIR);
if (remaining.length === 0) {
await rmdir(LEGACY_DIR);
}
} catch {}
console.log("Migration complete.");
}
}
await migrateFromLegacy();
await mkdir(RECORDS_DIR, { recursive: true });
let synced: Set<string> = new Set();
+40 -2
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env node
import { type ChildProcess, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { mkdir, readdir, rename, rmdir, writeFile } from "node:fs/promises";
import { homedir, hostname } from "node:os";
import { join } from "node:path";
@@ -18,7 +19,44 @@ interface Record {
durationMs: number;
}
const RECORDS_DIR: string = join(homedir(), ".uwf-dashboard/records");
// Migration: Move from legacy .uwf-dashboard to .uncaged/dashboard
const LEGACY_DIR: string = join(homedir(), ".uwf-dashboard");
const NEW_BASE_DIR: string = join(homedir(), ".uncaged/dashboard");
const RECORDS_DIR: string = join(NEW_BASE_DIR, "records");
async function migrateFromLegacy(): Promise<void> {
if (existsSync(LEGACY_DIR) && !existsSync(NEW_BASE_DIR)) {
console.log("Migrating from legacy .uwf-dashboard to .uncaged/dashboard...");
await mkdir(NEW_BASE_DIR, { recursive: true });
// Migrate records directory if it exists
const legacyRecordsDir = join(LEGACY_DIR, "records");
if (existsSync(legacyRecordsDir)) {
const files = await readdir(legacyRecordsDir);
await mkdir(RECORDS_DIR, { recursive: true });
for (const file of files) {
const oldPath = join(legacyRecordsDir, file);
const newPath = join(RECORDS_DIR, file);
await rename(oldPath, newPath);
}
await rmdir(legacyRecordsDir);
}
// Remove legacy directory if empty
try {
const remaining = await readdir(LEGACY_DIR);
if (remaining.length === 0) {
await rmdir(LEGACY_DIR);
}
} catch {}
console.log("Migration complete.");
}
}
await migrateFromLegacy();
await mkdir(RECORDS_DIR, { recursive: true });
const args: string[] = process.argv.slice(2);
+2 -1
View File
@@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/__tests__/**/*.test.ts"],
include: ["__tests__/**/*.test.ts"],
exclude: ["**/node_modules/**", "**/dist/**"],
},
});
+1 -1
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>UWF Dashboard</title></head>
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Uncaged Dashboard</title></head>
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
</html>
+1
View File
@@ -3,6 +3,7 @@
"private": true,
"version": "1.0.0",
"type": "module",
"description": "Uncaged Dashboard frontend - a real-time web interface for monitoring command execution",
"repository": {
"type": "git",
"url": "https://git.shazhou.work/uncaged/worker-dashboard.git",
+20
View File
@@ -57,6 +57,26 @@ h1 {
color: #666;
font-size: 0.85rem;
}
.search-input {
width: 100%;
background: #2a2a4a;
color: #e0e0e0;
border: 1px solid #3a3a5a;
padding: 10px 14px;
border-radius: 6px;
font-size: 0.9rem;
margin-bottom: 12px;
}
.search-input:focus {
outline: none;
border-color: #7c8aff;
}
mark {
background: #fbbf24;
color: #1a1a2e;
padding: 1px 2px;
border-radius: 2px;
}
.filters {
display: flex;
gap: 8px;
+233
View File
@@ -0,0 +1,233 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
describe("Frontend Branding", () => {
it("should display 'Uncaged Dashboard' in header", () => {
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
expect(content).toContain("<h1>⚡ Uncaged Dashboard</h1>");
expect(content).not.toContain("<h1>⚡ UWF Dashboard</h1>");
});
it("should not have residual UWF references in UI strings", () => {
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
// Remove comments and check for UWF in UI strings
const withoutComments = content.replace(/\/\/.*/g, "").replace(/\/\*[\s\S]*?\*\//g, "");
// Allow variable names but not in JSX strings
const jsxMatches = withoutComments.match(/<[^>]*>.*?UWF.*?<\/[^>]*>/gi);
expect(jsxMatches).toBeNull();
});
});
describe("HTML Page Title", () => {
it("should have 'Uncaged Dashboard' as page title", () => {
const content = readFileSync(join(__dirname, "..", "index.html"), "utf-8");
expect(content).toContain("<title>Uncaged Dashboard</title>");
expect(content).not.toContain("<title>UWF Dashboard</title>");
});
});
describe("Search Filtering with Highlight", () => {
it("should contain a search input element with class 'search-input'", () => {
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
expect(content).toMatch(/<input[^>]*type=["'](text|search)["'][^>]*>/);
expect(content).toMatch(/className=["'][^"']*search-input[^"']*["']/);
expect(content).toMatch(/placeholder=["'][^"']*[Ss]earch[^"']*["']/);
});
it("should declare a search query state variable", () => {
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
expect(content).toMatch(
/const\s+\[\s*\w+\s*,\s*set\w+\s*\]\s*=\s*useState[<\w>]*\(\s*["']["']?\s*\)/,
);
// Verify it's a string state for search
expect(content).toMatch(/useState<string>\(["''"]|useState\(["''"]/);
});
it("should use useMemo to compute filtered records", () => {
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
expect(content).toContain("useMemo");
// Check for dependency array containing records and search variable
expect(content).toMatch(/useMemo\([\s\S]+?,\s*\[\s*records\s*,\s*\w+\s*\]\s*\)/);
});
it("should implement case-insensitive filtering on command, device, and id fields", () => {
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
// Check for toLowerCase usage in filtering context
const lowerCaseCount = (content.match(/\.toLowerCase\(\)/g) || []).length;
expect(lowerCaseCount).toBeGreaterThanOrEqual(2); // At least for query and one field
// Check for includes method (used for substring matching)
expect(content).toMatch(/\.includes\(/);
// Verify filtering checks command, device, and id
expect(content).toMatch(/r\.command/);
expect(content).toMatch(/r\.device/);
expect(content).toMatch(/r\.id/);
});
it("should define a Highlight component that wraps matches with <mark> tags", () => {
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
// Check for Highlight component definition
expect(content).toMatch(/function\s+Highlight|const\s+Highlight\s*[:=]/);
// Check for mark tag usage
expect(content).toContain("mark");
// Check for props handling (text and query or similar)
expect(content).toMatch(
/\{\s*text\s*,\s*query\s*\}|\{\s*text\s*:\s*\w+\s*,\s*query\s*:\s*\w+\s*\}/,
);
});
it("should apply Highlight component to command column in record rows", () => {
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
// Check for Highlight usage with command field
expect(content).toMatch(/<Highlight[^>]*text=\{r\.command\}/);
// Verify it's within the command span context
const commandSpanMatch = content.match(
/<span[^>]*className=["']command["'][^>]*>[\s\S]*?<\/span>/,
);
expect(commandSpanMatch).toBeTruthy();
if (commandSpanMatch) {
expect(commandSpanMatch[0]).toContain("Highlight");
}
});
it("should apply Highlight component to device badge in record rows", () => {
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
// Check for Highlight usage with device field
expect(content).toMatch(/<Highlight[^>]*text=\{r\.device\}/);
// Verify it's within the device-badge span context
const deviceBadgeMatch = content.match(
/<span[^>]*className=["']device-badge["'][^>]*>[\s\S]*?<\/span>/,
);
expect(deviceBadgeMatch).toBeTruthy();
if (deviceBadgeMatch) {
expect(deviceBadgeMatch[0]).toContain("Highlight");
}
});
it("should show 'No matches' when search has no results vs 'No records yet' when empty", () => {
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
// Check for "No records yet" message
expect(content).toContain("No records yet");
// Check for "No matches" or similar search-specific empty message
expect(content).toMatch(/No matches|No search results|No records found/i);
});
it("should define .search-input styles consistent with dark theme", () => {
const cssContent = readFileSync(join(__dirname, "App.css"), "utf-8");
// Check for .search-input selector
expect(cssContent).toMatch(/\.search-input\s*\{/);
// Extract the search-input block
const searchInputBlock = cssContent.match(/\.search-input\s*\{[^}]+\}/);
expect(searchInputBlock).toBeTruthy();
if (searchInputBlock) {
const block = searchInputBlock[0];
// Dark background
expect(block).toMatch(/background:\s*#[0-9a-fA-F]{6}/);
// Light text
expect(block).toMatch(/color:\s*#[0-9a-fA-F]{6}/);
// Border
expect(block).toMatch(/border:/);
// Padding
expect(block).toMatch(/padding:/);
// Border radius
expect(block).toMatch(/border-radius:/);
}
});
it("should define mark element styles with yellow/orange background for dark theme visibility", () => {
const cssContent = readFileSync(join(__dirname, "App.css"), "utf-8");
// Check for mark selector
expect(cssContent).toMatch(/\bmark\s*\{|\.mark\s*\{/);
// Extract the mark block
const markBlock = cssContent.match(/\bmark\s*\{[^}]+\}/);
expect(markBlock).toBeTruthy();
if (markBlock) {
const block = markBlock[0];
// Yellow/orange background (hex codes starting with #f, #fb, #fc, #d, #e, etc.)
expect(block).toMatch(/background[^:]*:\s*#[def][0-9a-fA-F]{5}/);
// Should have color defined for text
expect(block).toMatch(/color:/);
}
});
it("should place search input above device filter buttons", () => {
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
// Extract the return JSX section
const returnMatch = content.match(/return\s*\(([\s\S]+)\);?\s*\}/);
expect(returnMatch).toBeTruthy();
if (returnMatch) {
const jsx = returnMatch[1];
// Find position of search input
const searchInputPos = jsx.search(/<input[^>]*search-input/);
expect(searchInputPos).toBeGreaterThan(-1);
// Find position of filters div
const filtersPos = jsx.search(/<div[^>]*className=["']filters["']/);
expect(filtersPos).toBeGreaterThan(-1);
// Search input should come before filters
expect(searchInputPos).toBeLessThan(filtersPos);
}
});
it("should render filtered records instead of raw records in the list", () => {
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
// Check for a filtered variable being mapped
const useMemoMatch = content.match(/const\s+(\w+)\s*=\s*useMemo/);
expect(useMemoMatch).toBeTruthy();
if (useMemoMatch) {
const filteredVar = useMemoMatch[1];
// Check that this variable is used in .map()
const mapPattern = new RegExp(`\\{${filteredVar}\\.map\\(`);
expect(content).toMatch(mapPattern);
}
// Additionally ensure raw records.map is NOT used in the render section
// (after the useMemo definition)
const useMemoIndex = content.indexOf("useMemo");
const recordsMapMatch = content.slice(useMemoIndex).match(/\{records\.map\(/);
expect(recordsMapMatch).toBeNull();
});
it("should handle empty search query gracefully", () => {
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
// Check that filtering logic handles empty query
const useMemoBlock = content.match(/useMemo\([^}]+\}/);
expect(useMemoBlock).toBeTruthy();
// Should either have early return for empty query OR filter logic that handles it
expect(content).toMatch(/if\s*\(\s*!\w+|if\s*\(\s*\w+\.trim\(\)|\.includes\(/);
});
it("should import useMemo from React", () => {
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
// Check for useMemo in React import (may have React, before the destructure)
expect(content).toMatch(/import\s+.*\{[^}]*useMemo[^}]*\}\s+from\s+["']react["']/);
});
});
+52 -5
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState, useRef } from "react";
import React, { useEffect, useState, useRef, useMemo } from "react";
import { useNavigate } from "react-router-dom";
interface Record {
@@ -29,6 +29,25 @@ function fmtDuration(ms: number) {
return `${(ms / 1000).toFixed(1)}s`;
}
function Highlight({ text, query }: { text: string; query: string }) {
if (!query.trim()) return <>{text}</>;
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi");
const parts = text.split(regex);
return (
<>
{parts.map((part, i) =>
regex.test(part) ? (
<mark key={`${i}-${part}`}>{part}</mark>
) : (
<span key={`${i}-${part}`}>{part}</span>
),
)}
</>
);
}
export default function App() {
const [records, setRecords] = useState<Record[]>([]);
const [workers, setWorkers] = useState<Worker[]>([]);
@@ -36,9 +55,23 @@ export default function App() {
[],
);
const [filter, setFilter] = useState("");
const [searchQuery, setSearchQuery] = useState<string>("");
const nav = useNavigate();
const wsRef = useRef<WebSocket | null>(null);
const filteredRecords = useMemo(() => {
if (!searchQuery.trim()) return records;
const lowerQuery = searchQuery.toLowerCase();
return records.filter((r) => {
return (
r.command.toLowerCase().includes(lowerQuery) ||
r.device.toLowerCase().includes(lowerQuery) ||
r.id.toLowerCase().includes(lowerQuery)
);
});
}, [records, searchQuery]);
useEffect(() => {
fetch(`/api/records${filter ? `?device=${filter}` : ""}`)
.then((r) => r.json())
@@ -80,7 +113,7 @@ export default function App() {
return (
<div className="container">
<header>
<h1> UWF Dashboard</h1>
<h1> Uncaged Dashboard</h1>
<div className="workers">
{workers.length > 0 ? (
workers.map((w) => (
@@ -94,6 +127,13 @@ export default function App() {
)}
</div>
</header>
<input
type="text"
className="search-input"
placeholder="Search records by command, device, or ID..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<div className="filters">
<button type="button" className={!filter ? "active" : ""} onClick={() => setFilter("")}>
All
@@ -111,15 +151,22 @@ export default function App() {
</div>
<div className="records">
{records.length === 0 && <div className="empty">No records yet</div>}
{records.map((r) => (
{records.length > 0 && filteredRecords.length === 0 && searchQuery && (
<div className="empty">No matches found</div>
)}
{filteredRecords.map((r) => (
<button
type="button"
key={r.id}
className="record-row"
onClick={() => nav(`/record/${r.id}`)}
>
<span className="device-badge">{r.device}</span>
<span className="command">{r.command}</span>
<span className="device-badge">
<Highlight text={r.device} query={searchQuery} />
</span>
<span className="command">
<Highlight text={r.command} query={searchQuery} />
</span>
<span className={`exit-code ${r.exitCode === 0 ? "success" : "error"}`}>
{r.exitCode === 0 ? "✓" : `${r.exitCode}`}
</span>
+1
View File
@@ -3,6 +3,7 @@
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Uncaged Dashboard server - WebSocket and REST API for aggregating command records",
"exports": {
".": {
"bun": "./src/index.ts",