1372 lines
67 KiB
Bash
Executable File
1372 lines
67 KiB
Bash
Executable File
#!/bin/bash
|
|
# Generate and open an HTML dashboard for all Claude orchestrator sessions
|
|
# Usage: ~/claude-dashboard [--no-open]
|
|
|
|
NO_OPEN=false
|
|
REGEN_ONLY=false
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
--no-open) NO_OPEN=true ;;
|
|
--regen-only) REGEN_ONLY=true; NO_OPEN=true ;;
|
|
esac
|
|
done
|
|
|
|
DASHBOARD="/tmp/claude-dashboard.html"
|
|
|
|
echo "Gathering session data..."
|
|
|
|
# ─── Gather session data ───────────────────────────────────────────────────────
|
|
|
|
sessions_json="["
|
|
first=true
|
|
|
|
for s in $(tmux list-sessions -F "#{session_name}" 2>/dev/null | grep -E "^integrator-" | sort -V); do
|
|
project="integrator"
|
|
meta_file="$HOME/.integrator-sessions/$s"
|
|
repo="ComposioHQ/integrator"
|
|
|
|
# Get branch
|
|
# Get pane working directory (used for both branch detection and activity)
|
|
pane_cwd=$(tmux display-message -t "$s" -p "#{pane_current_path}" 2>/dev/null)
|
|
|
|
# Branch detection: pane cwd (most reliable) → worktree → secondary_checkouts → metadata
|
|
branch=""
|
|
if [ -n "$pane_cwd" ] && [ -d "$pane_cwd/.git" -o -f "$pane_cwd/.git" ]; then
|
|
branch=$(cd "$pane_cwd" && git branch --show-current 2>/dev/null)
|
|
fi
|
|
if [ -z "$branch" ]; then
|
|
worktree=$(grep "^worktree=" "$meta_file" 2>/dev/null | cut -d= -f2-)
|
|
if [ -d "$worktree" ]; then
|
|
branch=$(cd "$worktree" && git branch --show-current 2>/dev/null)
|
|
fi
|
|
fi
|
|
if [ -z "$branch" ]; then
|
|
num=$(echo "$s" | sed 's/[^0-9-]//g' | cut -d- -f1)
|
|
checkout="$HOME/secondary_checkouts/integrator-$num"
|
|
[ -d "$checkout" ] && branch=$(cd "$checkout" && git branch --show-current 2>/dev/null)
|
|
fi
|
|
[ -z "$branch" ] && branch=$(grep "^branch=" "$meta_file" 2>/dev/null | cut -d= -f2-)
|
|
[ -z "$branch" ] && branch="unknown"
|
|
|
|
meta_status=$(grep "^status=" "$meta_file" 2>/dev/null | cut -d= -f2-)
|
|
summary=$(grep "^summary=" "$meta_file" 2>/dev/null | cut -d= -f2-)
|
|
# Capture all PRs (sessions can have multiple)
|
|
pr=$(grep "^pr=" "$meta_file" 2>/dev/null | cut -d= -f2- | paste -sd',' -)
|
|
issue=$(grep "^issue=" "$meta_file" 2>/dev/null | cut -d= -f2- | head -1)
|
|
slack=$(grep "^slack=" "$meta_file" 2>/dev/null | cut -d= -f2- | head -1)
|
|
notes=$(grep "^notes=" "$meta_file" 2>/dev/null | cut -d= -f2-)
|
|
|
|
# Activity detection: JSONL file + process check
|
|
activity="idle"
|
|
if [ -n "$pane_cwd" ]; then
|
|
proj_key=$(echo "$pane_cwd" | tr '/.' '--')
|
|
session_dir="$HOME/.claude/projects/$proj_key"
|
|
jsonl=$(ls -t "$session_dir"/*.jsonl 2>/dev/null | grep -v agent- | head -1)
|
|
if [ -n "$jsonl" ]; then
|
|
mod_age=$(( $(date +%s) - $(stat -f %m "$jsonl") ))
|
|
last_type=$(tail -c 4096 "$jsonl" | grep -o '"type":"[^"]*"' | tail -1 | cut -d'"' -f4)
|
|
if [ "$mod_age" -lt 30 ] && [ "$last_type" != "assistant" ] && [ "$last_type" != "system" ]; then
|
|
activity="working"
|
|
fi
|
|
fi
|
|
fi
|
|
# If not working, check if claude process exists
|
|
if [ "$activity" != "working" ]; then
|
|
pane_pid=$(tmux list-panes -t "$s" -F "#{pane_pid}" 2>/dev/null | head -1)
|
|
if [ -n "$pane_pid" ]; then
|
|
has_claude=false
|
|
# Check if pane process itself is claude (batch-spawned sessions)
|
|
pane_comm=$(ps -o comm= -p "$pane_pid" 2>/dev/null)
|
|
if echo "$pane_comm" | grep -q claude; then
|
|
has_claude=true
|
|
else
|
|
# Check children and grandchildren
|
|
for child in $(pgrep -P "$pane_pid" 2>/dev/null); do
|
|
child_comm=$(ps -o comm= -p "$child" 2>/dev/null)
|
|
if echo "$child_comm" | grep -q claude; then
|
|
has_claude=true
|
|
break
|
|
fi
|
|
if pgrep -P "$child" -f claude > /dev/null 2>&1; then
|
|
has_claude=true
|
|
break
|
|
fi
|
|
done
|
|
fi
|
|
if [ "$has_claude" = false ]; then
|
|
activity="exited"
|
|
fi
|
|
fi
|
|
fi
|
|
pane=$(tmux capture-pane -t "$s" -p -S -5 2>/dev/null)
|
|
|
|
# Extract PR number from status bar if not in metadata
|
|
pr_from_bar=$(echo "$pane" | grep -oE 'PR #[0-9]+' | tail -1 | sed 's/PR #//')
|
|
[ -z "$pr" ] && [ -n "$pr_from_bar" ] && pr="https://github.com/$repo/pull/$pr_from_bar"
|
|
|
|
# Launch background PR lookup from branch name
|
|
if [ -n "$branch" ] && [ "$branch" != "unknown" ] && [ "$branch" != "next" ] && [ "$branch" != "main" ]; then
|
|
mkdir -p /tmp/claude-dashboard-prlookup.$$
|
|
( gh pr list --repo "$repo" --head "$branch" --json url --jq '.[0].url' 2>/dev/null > "/tmp/claude-dashboard-prlookup.$$/$s" ) &
|
|
fi
|
|
|
|
# Store session data for later JSON assembly (after PR lookups complete)
|
|
echo "$s|$project|$branch|$meta_status|$summary|$pr|$issue|$activity|$slack|$notes" >> /tmp/claude-dashboard-sessions.$$
|
|
done
|
|
|
|
# Wait for all background PR lookups to finish
|
|
wait
|
|
|
|
# Now build sessions_json, merging auto-detected PRs with metadata PRs
|
|
sessions_json="["
|
|
first=true
|
|
while IFS='|' read -r s project branch meta_status summary pr issue activity slack notes; do
|
|
# Merge auto-detected PR from branch
|
|
detected_pr=""
|
|
if [ -f "/tmp/claude-dashboard-prlookup.$$/$s" ]; then
|
|
detected_pr=$(cat "/tmp/claude-dashboard-prlookup.$$/$s" | tr -d '[:space:]')
|
|
fi
|
|
if [ -n "$detected_pr" ] && [ "$detected_pr" != "null" ]; then
|
|
if [ -z "$pr" ]; then
|
|
pr="$detected_pr"
|
|
elif ! echo "$pr" | grep -qF "$detected_pr"; then
|
|
pr="$pr,$detected_pr"
|
|
fi
|
|
echo " $s: branch PR $detected_pr"
|
|
fi
|
|
|
|
# Escape quotes for JSON
|
|
summary=$(echo "$summary" | sed 's/"/\\"/g')
|
|
meta_status=$(echo "$meta_status" | sed 's/"/\\"/g')
|
|
slack=$(echo "$slack" | sed 's/"/\\"/g')
|
|
notes=$(echo "$notes" | sed 's/"/\\"/g')
|
|
|
|
[ "$first" = true ] && first=false || sessions_json+=","
|
|
sessions_json+="{\"name\":\"$s\",\"project\":\"$project\",\"branch\":\"$branch\",\"status\":\"$meta_status\",\"summary\":\"$summary\",\"pr\":\"$pr\",\"issue\":\"$issue\",\"activity\":\"$activity\",\"slack\":\"$slack\",\"notes\":\"$notes\"}"
|
|
done < /tmp/claude-dashboard-sessions.$$
|
|
sessions_json+="]"
|
|
rm -rf /tmp/claude-dashboard-prlookup.$$ /tmp/claude-dashboard-sessions.$$
|
|
|
|
# ─── Gather PR details (with review threads, CI, comments) ────────────────────
|
|
|
|
echo "Fetching PR details..."
|
|
|
|
# Extract unique PR URLs
|
|
pr_urls=$(echo "$sessions_json" | grep -oE 'https://github.com/[^",]+/pull/[0-9]+' | sort -u)
|
|
|
|
# Fetch all PRs in parallel
|
|
pr_tmp=$(mktemp -d /tmp/claude-dashboard-pr-XXXXXX)
|
|
|
|
for url in $pr_urls; do
|
|
num=$(echo "$url" | grep -oE '[0-9]+$')
|
|
repo=$(echo "$url" | sed 's|https://github.com/||;s|/pull/.*||')
|
|
owner=$(echo "$repo" | cut -d/ -f1)
|
|
reponame=$(echo "$repo" | cut -d/ -f2)
|
|
[ -z "$num" ] || [ -z "$owner" ] && continue
|
|
|
|
# Launch each PR fetch in background
|
|
(
|
|
info=$(gh pr view "$num" --repo "$repo" --json title,state,mergeable,reviewDecision,additions,deletions,createdAt 2>/dev/null)
|
|
[ -z "$info" ] && exit 0
|
|
|
|
gql=$(gh api graphql -f query="query {
|
|
repository(owner: \"$owner\", name: \"$reponame\") {
|
|
pullRequest(number: $num) {
|
|
reviewThreads(first: 100) {
|
|
nodes { isResolved comments(first: 1) { nodes { url path } } }
|
|
}
|
|
commits(last: 1) {
|
|
nodes {
|
|
commit {
|
|
statusCheckRollup {
|
|
contexts(first: 50) {
|
|
nodes {
|
|
... on CheckRun { crName: name conclusion status: status detailsUrl }
|
|
... on StatusContext { scName: context cstate: state targetUrl }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}" 2>/dev/null)
|
|
|
|
printf '%s\n%s' "$info" "$gql" | python3 -c "
|
|
import sys, json
|
|
lines = sys.stdin.read().strip().split('\n')
|
|
d = json.loads(lines[0])
|
|
try:
|
|
gql = json.loads(lines[1])
|
|
pr = gql['data']['repository']['pullRequest']
|
|
threads = pr['reviewThreads']['nodes']
|
|
unresolved = [{'url': t.get('comments',{}).get('nodes',[{}])[0].get('url',''), 'path': t.get('comments',{}).get('nodes',[{}])[0].get('path','')} for t in threads if not t['isResolved']]
|
|
d['unresolvedThreads'] = len(unresolved)
|
|
d['unresolvedList'] = unresolved
|
|
nodes = pr['commits']['nodes'][0]['commit']['statusCheckRollup']['contexts']['nodes']
|
|
counts, checks = {}, []
|
|
for n in nodes:
|
|
name = n.get('crName') or n.get('scName') or 'unknown'
|
|
s = (n.get('conclusion') or n.get('status') or n.get('cstate') or 'UNKNOWN').upper()
|
|
url = n.get('detailsUrl') or n.get('targetUrl') or ''
|
|
counts[s] = counts.get(s, 0) + 1
|
|
checks.append({'name': name, 'status': s, 'url': url})
|
|
parts = []
|
|
for k in ['SUCCESS', 'FAILURE', 'PENDING', 'NEUTRAL', 'SKIPPED', 'IN_PROGRESS']:
|
|
if k in counts: parts.append(f'{counts[k]} {k.lower()}')
|
|
for k in counts:
|
|
if k not in ['SUCCESS', 'FAILURE', 'PENDING', 'NEUTRAL', 'SKIPPED', 'IN_PROGRESS']:
|
|
parts.append(f'{counts[k]} {k.lower()}')
|
|
d['ciSummary'] = ','.join(parts) if parts else 'none'
|
|
d['ciChecks'] = checks
|
|
except: d.update({'unresolvedThreads': 0, 'unresolvedList': [], 'ciSummary': 'none', 'ciChecks': []})
|
|
print(json.dumps(d))
|
|
" > "$pr_tmp/$num.json" 2>/dev/null
|
|
echo " PR #$num: done"
|
|
) &
|
|
done
|
|
wait
|
|
|
|
# Merge results
|
|
pr_json="{"
|
|
first_pr=true
|
|
for f in "$pr_tmp"/*.json; do
|
|
[ -f "$f" ] || continue
|
|
num=$(basename "$f" .json)
|
|
unresolved=$(python3 -c "import sys,json; print(json.load(open('$f')).get('unresolvedThreads',0))" 2>/dev/null)
|
|
ci_label=$(python3 -c "import sys,json; print(json.load(open('$f')).get('ciSummary','none'))" 2>/dev/null)
|
|
echo " PR #$num: $unresolved unresolved threads, CI: $ci_label"
|
|
[ "$first_pr" = true ] && first_pr=false || pr_json+=","
|
|
pr_json+="\"$num\":$(cat "$f")"
|
|
done
|
|
pr_json+="}"
|
|
rm -rf "$pr_tmp"
|
|
|
|
# ─── Counts ────────────────────────────────────────────────────────────────────
|
|
|
|
total=$(echo "$sessions_json" | grep -o '"name"' | wc -l | tr -d ' ')
|
|
working=$(echo "$sessions_json" | grep -o '"activity":"working"' | wc -l | tr -d ' ')
|
|
open_prs=$(echo "$pr_json" | grep -o '"OPEN"' | wc -l | tr -d ' ')
|
|
needs_review=$(echo "$pr_json" | grep -o '"REVIEW_REQUIRED"' | wc -l | tr -d ' ')
|
|
|
|
# ─── Generate Markdown (machine-readable for orchestrator agent) ───────────────
|
|
|
|
MD_FILE="$HOME/.claude-dashboard.md"
|
|
|
|
python3 -c "
|
|
import json, sys
|
|
from datetime import datetime, timezone
|
|
|
|
sessions = json.loads('''$sessions_json''')
|
|
prs = json.loads('''$pr_json''')
|
|
|
|
now = datetime.now(timezone.utc)
|
|
lines = []
|
|
lines.append('# Claude Orchestrator Dashboard')
|
|
lines.append(f'Generated: {now.strftime(\"%Y-%m-%d %H:%M UTC\")}')
|
|
lines.append('')
|
|
lines.append(f'**{len(sessions)} sessions** | **{sum(1 for s in sessions if s[\"activity\"]==\"working\")} working** | **$open_prs open PRs** | **$needs_review needs review**')
|
|
lines.append('')
|
|
|
|
# Sessions grouped by activity
|
|
for activity in ['working', 'idle', 'exited']:
|
|
group = [s for s in sessions if s['activity'] == activity]
|
|
if not group:
|
|
continue
|
|
lines.append(f'## {activity.upper()} ({len(group)})')
|
|
lines.append('')
|
|
for s in sorted(group, key=lambda x: x['name']):
|
|
pr_num = s['pr'].split('/')[-1] if s['pr'] else None
|
|
pr = prs.get(pr_num, {}) if pr_num else {}
|
|
|
|
ci = pr.get('ciSummary', 'none')
|
|
unresolved = pr.get('unresolvedThreads', 0)
|
|
review = pr.get('reviewDecision', '')
|
|
additions = pr.get('additions', 0)
|
|
deletions = pr.get('deletions', 0)
|
|
|
|
# CI status summary
|
|
ci_short = 'none'
|
|
if 'failure' in ci: ci_short = 'FAILING'
|
|
elif 'pending' in ci or 'in_progress' in ci: ci_short = 'PENDING'
|
|
elif 'success' in ci: ci_short = 'PASSING'
|
|
|
|
# Review summary
|
|
review_short = ''
|
|
if review == 'APPROVED': review_short = 'approved'
|
|
elif review == 'CHANGES_REQUESTED': review_short = 'changes-requested'
|
|
elif review == 'REVIEW_REQUIRED': review_short = 'needs-review'
|
|
|
|
lines.append(f'- **{s[\"name\"]}** ({s[\"project\"]})')
|
|
lines.append(f' - Branch: \`{s[\"branch\"]}\`')
|
|
if s.get('summary'): lines.append(f' - Summary: {s[\"summary\"]}')
|
|
if pr_num:
|
|
pr_line = f' - PR: [#{pr_num}]({s[\"pr\"]}) (+{additions}/-{deletions})'
|
|
if review_short: pr_line += f' | {review_short}'
|
|
if ci_short != 'none': pr_line += f' | CI: {ci_short}'
|
|
if unresolved > 0: pr_line += f' | **{unresolved} unresolved comments**'
|
|
lines.append(pr_line)
|
|
else:
|
|
lines.append(' - PR: none')
|
|
lines.append('')
|
|
|
|
# PR summary table
|
|
open_pr_nums = [n for n, p in prs.items() if p.get('state') == 'OPEN']
|
|
if open_pr_nums:
|
|
lines.append('## Open PRs')
|
|
lines.append('')
|
|
lines.append('| PR | Title | Size | CI | Review | Unresolved |')
|
|
lines.append('|----|-------|------|----|--------|------------|')
|
|
for num in sorted(open_pr_nums, key=int, reverse=True):
|
|
pr = prs[num]
|
|
ci = pr.get('ciSummary', 'none')
|
|
ci_short = 'FAIL' if 'failure' in ci else 'PENDING' if 'pending' in ci else 'PASS' if 'success' in ci else '—'
|
|
review = pr.get('reviewDecision', '')
|
|
review_short = 'approved' if review == 'APPROVED' else 'changes' if review == 'CHANGES_REQUESTED' else 'needs review'
|
|
unresolved = pr.get('unresolvedThreads', 0)
|
|
size = pr.get('additions', 0) + pr.get('deletions', 0)
|
|
size_label = 'XL' if size > 1000 else 'L' if size > 500 else 'M' if size > 200 else 'S' if size > 50 else 'XS'
|
|
sess = next((s for s in sessions if s['pr'].endswith(f'/{num}')), None)
|
|
repo = 'ComposioHQ/integrator'
|
|
title = pr.get('title', '')[:60]
|
|
lines.append(f'| [#{num}](https://github.com/{repo}/pull/{num}) | {title} | +{pr.get(\"additions\",0)}/-{pr.get(\"deletions\",0)} ({size_label}) | {ci_short} | {review_short} | {unresolved} |')
|
|
lines.append('')
|
|
|
|
print('\n'.join(lines))
|
|
" > "$MD_FILE" 2>/dev/null
|
|
|
|
echo "Markdown dashboard: $MD_FILE"
|
|
|
|
# ─── Generate HTML ─────────────────────────────────────────────────────────────
|
|
|
|
echo "Generating HTML..."
|
|
|
|
cat > "$DASHBOARD" << 'HTMLEOF'
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Integrator Orchestrator Dashboard</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', system-ui, sans-serif; background: #0d1117; color: #e6edf3; padding: 32px; min-height: 100vh; }
|
|
a { color: #58a6ff; text-decoration: none; }
|
|
a:hover { text-decoration: underline; }
|
|
.container { max-width: 1100px; margin: 0 auto; }
|
|
|
|
.header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 28px; }
|
|
.header h1 { font-size: 22px; font-weight: 600; letter-spacing: -0.3px; }
|
|
.header h1 span { color: #7c8aff; }
|
|
.timestamp { color: #484f58; font-size: 12px; }
|
|
|
|
.stats { display: flex; gap: 32px; margin-bottom: 36px; padding: 0 4px; }
|
|
.stat { display: flex; align-items: baseline; gap: 8px; }
|
|
.stat .n { font-size: 28px; font-weight: 700; }
|
|
.stat .l { color: #484f58; font-size: 13px; }
|
|
.g { color: #3fb950; } .b { color: #58a6ff; } .y { color: #d29922; } .p { color: #bc8cff; } .r { color: #f85149; }
|
|
|
|
.section { margin-bottom: 36px; }
|
|
.section-head { font-size: 13px; font-weight: 600; color: #484f58; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 12px; padding-left: 4px; }
|
|
|
|
/* Session cards */
|
|
.sessions { display: flex; flex-direction: column; gap: 8px; }
|
|
.session-group { border: 1px solid #30363d; border-radius: 12px; overflow: hidden; }
|
|
.session-group > .card { border-radius: 0; border-left: none; border-right: none; border-top: none; border-bottom: 1px solid #21262d; }
|
|
.session-group > .card:last-child { border-bottom: none; }
|
|
.session-group > .card.working { border-left: 3px solid #3fb950; }
|
|
.session-group > .card.idle { border-left: 3px solid #30363d; }
|
|
.session-group > .card.exited { border-left: 3px solid #f85149; }
|
|
.card { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 14px 18px; cursor: pointer; transition: border-color 0.15s; }
|
|
.card:hover { border-color: #484f58; }
|
|
.card.working { border-left: 3px solid #3fb950; }
|
|
.card.idle { border-left: 3px solid #30363d; }
|
|
.card.exited { border-left: 3px solid #f85149; }
|
|
.card.has-alerts { border-color: rgba(210,153,34,0.4); }
|
|
.card.has-alerts-red { border-color: rgba(248,81,73,0.35); }
|
|
|
|
.card-top { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
|
|
.card-icon { font-size: 14px; flex-shrink: 0; width: 20px; text-align: center; }
|
|
.card-name { font-weight: 600; font-size: 13px; color: #7d8590; flex-shrink: 0; }
|
|
.card-purpose { font-size: 14px; font-weight: 500; color: #e6edf3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0; }
|
|
.card-age { color: #484f58; font-size: 11px; flex-shrink: 0; margin-right: 4px; }
|
|
|
|
.card-meta { display: flex; align-items: center; gap: 8px; padding-left: 30px; flex-wrap: wrap; }
|
|
.card-branch { font-family: 'SF Mono', 'Menlo', monospace; font-size: 11px; color: #484f58; }
|
|
.pill { display: inline-flex; align-items: center; padding: 1px 8px; border-radius: 10px; font-size: 10px; font-weight: 600; letter-spacing: 0.2px; }
|
|
.pill.ci-pass { background: rgba(63,185,80,0.1); color: #3fb950; }
|
|
.pill.ci-fail { background: rgba(248,81,73,0.15); color: #f85149; }
|
|
.pill.ci-pending { background: rgba(210,153,34,0.1); color: #d29922; }
|
|
.pill.approved { background: rgba(63,185,80,0.1); color: #3fb950; }
|
|
.pill.size { background: rgba(125,133,144,0.08); color: #484f58; }
|
|
.pill.pr-num { background: rgba(88,166,255,0.1); color: #58a6ff; }
|
|
.sep { color: #30363d; font-size: 10px; }
|
|
|
|
/* Alert tags — these are the big, prominent ones */
|
|
.card-alerts { display: flex; align-items: center; gap: 6px; padding-left: 30px; margin-top: 8px; flex-wrap: wrap; }
|
|
.alert-tag { display: inline-flex; align-items: center; gap: 5px; padding: 3px 12px; border-radius: 6px; font-size: 12px; font-weight: 700; letter-spacing: 0.1px; cursor: pointer; }
|
|
.alert-tag:hover { filter: brightness(1.3); }
|
|
.alert-tag.review { background: rgba(210,153,34,0.15); color: #d29922; border: 1px solid rgba(210,153,34,0.3); }
|
|
.alert-tag.conflict { background: rgba(248,81,73,0.15); color: #f85149; border: 1px solid rgba(248,81,73,0.3); }
|
|
.alert-tag.comments { background: rgba(248,81,73,0.15); color: #f85149; border: 1px solid rgba(248,81,73,0.3); }
|
|
.alert-tag.changes { background: rgba(248,81,73,0.15); color: #f85149; border: 1px solid rgba(248,81,73,0.3); }
|
|
.alert-tag.ci-fail { background: rgba(248,81,73,0.15); color: #f85149; border: 1px solid rgba(248,81,73,0.3); }
|
|
.alert-tag.merge { background: rgba(63,185,80,0.2); color: #3fb950; border: 1px solid rgba(63,185,80,0.4); font-size: 13px; padding: 4px 14px; }
|
|
.alert-tag.merged { background: rgba(163,113,247,0.15); color: #a371f7; border: 1px solid rgba(163,113,247,0.3); font-size: 13px; padding: 4px 14px; }
|
|
.alert-tag .alert-num { font-size: 14px; font-weight: 800; }
|
|
.card.ready-to-merge { border-color: rgba(63,185,80,0.5); }
|
|
.card.merged { border-color: rgba(163,113,247,0.4); opacity: 0.75; }
|
|
.action-btn { background: none; border: 1px solid rgba(88,166,255,0.3); color: #58a6ff; font-size: 11px; padding: 2px 10px; border-radius: 6px; cursor: pointer; font-family: inherit; transition: all 0.15s; flex-shrink: 0; white-space: nowrap; }
|
|
.action-btn:hover { background: rgba(88,166,255,0.1); border-color: #58a6ff; }
|
|
.action-btn:disabled { opacity: 0.5; cursor: default; pointer-events: none; }
|
|
|
|
.kill-btn { background: none; border: 1px solid rgba(248,81,73,0.4); color: #f85149; font-size: 11px; padding: 2px 10px; border-radius: 6px; cursor: pointer; font-family: inherit; transition: all 0.15s; flex-shrink: 0; }
|
|
.kill-btn:hover { background: rgba(248,81,73,0.15); border-color: #f85149; }
|
|
|
|
.open-btn { margin-left: auto; background: none; border: 1px solid #30363d; color: #7d8590; font-size: 11px; padding: 2px 10px; border-radius: 6px; cursor: pointer; font-family: inherit; transition: all 0.15s; flex-shrink: 0; }
|
|
.open-btn:hover { border-color: #58a6ff; color: #58a6ff; }
|
|
|
|
/* Expandable detail panel */
|
|
.card-detail { max-height: 0; overflow: hidden; transition: max-height 0.25s ease-out, padding 0.25s ease-out; padding: 0 18px 0 30px; }
|
|
.card.expanded .card-detail { max-height: 5000px; padding: 12px 18px 4px 30px; }
|
|
.detail-content { max-height: 150px; overflow: hidden; position: relative; }
|
|
.detail-content.expanded { max-height: none; }
|
|
.detail-content:not(.expanded)::after { content: ''; position: absolute; bottom: 0; left: 0; right: 0; height: 40px; background: linear-gradient(transparent, #161b22); pointer-events: none; }
|
|
.show-more-btn { background: none; border: none; color: #58a6ff; font-size: 12px; cursor: pointer; padding: 4px 0; font-family: inherit; }
|
|
.show-more-btn:hover { text-decoration: underline; }
|
|
.detail-footer { border-top: 1px solid #21262d; margin-top: 8px; padding-top: 8px; display: flex; gap: 8px; }
|
|
.card.expanded { border-color: #484f58; }
|
|
.detail-section { margin-bottom: 10px; }
|
|
.detail-label { font-size: 10px; color: #484f58; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; font-weight: 600; }
|
|
.detail-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 12px; }
|
|
.detail-row .check-icon { width: 14px; text-align: center; flex-shrink: 0; }
|
|
.detail-row .check-name { color: #7d8590; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.detail-row a { color: #58a6ff; font-size: 11px; flex-shrink: 0; }
|
|
.detail-summary { font-size: 12px; color: #7d8590; line-height: 1.5; }
|
|
|
|
/* PR table */
|
|
.tbl { width: 100%; border-collapse: collapse; }
|
|
.tbl th { text-align: left; color: #484f58; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; padding: 6px 12px; border-bottom: 1px solid #21262d; font-weight: 600; }
|
|
.tbl td { padding: 10px 12px; border-bottom: 1px solid #161b22; font-size: 13px; }
|
|
.tbl tr:hover td { background: rgba(88,166,255,0.03); }
|
|
.tbl tr.has-issues td { background: rgba(248,81,73,0.03); }
|
|
.tbl .title { font-weight: 500; max-width: 420px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.tbl .dim { color: #484f58; }
|
|
.tbl .comments-cell { font-size: 15px; font-weight: 800; text-align: center; }
|
|
.tbl .comments-cell.red { color: #f85149; }
|
|
.tbl .comments-cell.zero { color: #30363d; font-weight: 400; }
|
|
|
|
.notes-section { margin-top: 10px; }
|
|
.notes-area { width: 100%; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #e6edf3; font-family: inherit; font-size: 12px; padding: 8px 10px; resize: vertical; min-height: 60px; line-height: 1.5; }
|
|
.notes-area:focus { outline: none; border-color: #58a6ff; }
|
|
.notes-area::placeholder { color: #484f58; }
|
|
.notes-bar { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
|
|
.notes-save { background: none; border: 1px solid rgba(63,185,80,0.3); color: #3fb950; font-size: 11px; padding: 2px 10px; border-radius: 6px; cursor: pointer; font-family: inherit; transition: all 0.15s; }
|
|
.notes-save:hover { background: rgba(63,185,80,0.1); border-color: #3fb950; }
|
|
.notes-save:disabled { opacity: 0.4; cursor: default; }
|
|
.notes-status { font-size: 11px; color: #484f58; }
|
|
|
|
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.4; } }
|
|
.pulse { animation: pulse 2s ease-in-out infinite; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<h1><span>Integrator</span> Orchestrator</h1>
|
|
<div style="display:flex;align-items:center;gap:10px">
|
|
<button class="open-btn" onclick="openTerminal('orchestrator',event)" style="padding:4px 14px;font-size:12px">orchestrator terminal</button>
|
|
<div class="timestamp" id="timestamp"></div>
|
|
</div>
|
|
</div>
|
|
<div class="stats" id="stats"></div>
|
|
<div class="section">
|
|
<div class="section-head">Sessions</div>
|
|
<div class="sessions" id="sessions"></div>
|
|
</div>
|
|
<div class="section">
|
|
<div class="section-head">Pull Requests</div>
|
|
<table class="tbl"><thead><tr><th>PR</th><th>Title</th><th>Size</th><th>CI</th><th>Review</th><th>Unresolved</th><th>Age</th></tr></thead><tbody id="pr-tbody"></tbody></table>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
HTMLEOF
|
|
|
|
# Inject data
|
|
cat >> "$DASHBOARD" << DATAEOF
|
|
const sessions = $sessions_json;
|
|
const prDetails = $pr_json;
|
|
const counts = {total:$total, working:$working, openPrs:$open_prs, needsReview:$needs_review};
|
|
DATAEOF
|
|
|
|
cat >> "$DASHBOARD" << 'JSEOF'
|
|
function timeAgo(d) {
|
|
const h = Math.floor((Date.now() - new Date(d)) / 3600000);
|
|
return h < 1 ? 'now' : h < 24 ? h+'h' : Math.floor(h/24)+'d';
|
|
}
|
|
function mergePR(repo, num, ev) {
|
|
ev.stopPropagation();
|
|
ev.preventDefault();
|
|
if (!confirm('Merge PR #' + num + '?')) return;
|
|
const btn = ev.target.closest('.alert-tag');
|
|
btn.textContent = 'merging...';
|
|
btn.style.pointerEvents = 'none';
|
|
fetch('/api/merge/' + encodeURIComponent(repo) + '/' + num).then(r => r.json()).then(r => {
|
|
if (r.ok) {
|
|
btn.textContent = 'merged!';
|
|
btn.className = 'alert-tag merged';
|
|
const card = btn.closest('.card');
|
|
card.classList.remove('ready-to-merge');
|
|
card.classList.add('merged');
|
|
card.style.opacity = '0.75';
|
|
} else {
|
|
btn.textContent = 'failed: ' + (r.error || 'unknown');
|
|
btn.style.pointerEvents = '';
|
|
}
|
|
});
|
|
}
|
|
function prNum(url) { return url?.match(/\/pull\/(\d+)/)?.[1] || null; }
|
|
function ci(s) {
|
|
if (!s || s === 'none') return {l:'—', c:''};
|
|
let ok=0,fail=0,pend=0;
|
|
s.split(',').forEach(p => { const m=p.trim().match(/(\d+)\s+(\w+)/); if(!m)return; const[,n,t]=m;
|
|
if(t==='success')ok+=+n; else if(t==='failure')fail+=+n; else if(t==='pending'||t==='in_progress')pend+=+n; });
|
|
if(fail) return {l:fail+' failed',c:'ci-fail'}; if(pend) return {l:pend+' pending',c:'ci-pending'};
|
|
if(ok) return {l:'passing',c:'ci-pass'}; return {l:'—',c:''};
|
|
}
|
|
function openSession(name) {
|
|
fetch('/api/open/' + encodeURIComponent(name)).then(r => r.json()).then(r => {
|
|
const btn = event.target.closest('.open-btn');
|
|
if (r.ok) {
|
|
btn.textContent = 'opened';
|
|
btn.style.borderColor = '#3fb950'; btn.style.color = '#3fb950';
|
|
} else {
|
|
btn.textContent = 'error';
|
|
btn.style.borderColor = '#f85149'; btn.style.color = '#f85149';
|
|
}
|
|
setTimeout(() => { btn.textContent = 'open'; btn.style.borderColor = ''; btn.style.color = ''; }, 1500);
|
|
});
|
|
}
|
|
function openTerminal(name, ev) {
|
|
ev.stopPropagation();
|
|
const btn = ev.target.closest('.open-btn');
|
|
btn.textContent = 'connecting...';
|
|
btn.disabled = true;
|
|
fetch('/api/terminal/' + encodeURIComponent(name)).then(r => r.json()).then(r => {
|
|
if (r.ok) {
|
|
window.open(r.url, '_blank');
|
|
btn.textContent = 'terminal';
|
|
btn.style.borderColor = '#3fb950'; btn.style.color = '#3fb950';
|
|
} else {
|
|
btn.textContent = r.error || 'error';
|
|
btn.style.borderColor = '#f85149'; btn.style.color = '#f85149';
|
|
}
|
|
btn.disabled = false;
|
|
setTimeout(() => { btn.textContent = 'terminal'; btn.style.borderColor = ''; btn.style.color = ''; }, 1500);
|
|
});
|
|
}
|
|
function killSession(name, ev) {
|
|
ev.stopPropagation();
|
|
if (!confirm('Kill session ' + name + '?')) return;
|
|
const card = ev.target.closest('.card');
|
|
fetch('/api/kill/' + encodeURIComponent(name)).then(r => r.json()).then(r => {
|
|
if (r.ok) {
|
|
card.style.transition = 'opacity 0.3s, max-height 0.3s';
|
|
card.style.opacity = '0';
|
|
card.style.maxHeight = card.offsetHeight + 'px';
|
|
setTimeout(() => { card.style.maxHeight = '0'; card.style.padding = '0'; card.style.margin = '0'; card.style.overflow = 'hidden'; }, 300);
|
|
setTimeout(() => card.remove(), 600);
|
|
} else {
|
|
ev.target.closest('.kill-btn').textContent = 'error';
|
|
}
|
|
});
|
|
}
|
|
|
|
function saveNotes(session) {
|
|
const textarea = document.getElementById('notes-' + session);
|
|
const btn = document.getElementById('notes-save-' + session);
|
|
const status = document.getElementById('notes-status-' + session);
|
|
btn.disabled = true;
|
|
btn.textContent = 'saving...';
|
|
status.textContent = '';
|
|
fetch('/api/notes/' + encodeURIComponent(session), {method:'POST', body: textarea.value})
|
|
.then(r => r.json())
|
|
.then(r => {
|
|
if (r.ok) { btn.textContent = 'saved'; status.textContent = ''; btn.style.borderColor = 'rgba(63,185,80,0.5)'; }
|
|
else { btn.textContent = 'error'; status.textContent = r.error || ''; btn.style.borderColor = 'rgba(248,81,73,0.4)'; }
|
|
setTimeout(() => { btn.textContent = 'save'; btn.disabled = false; btn.style.borderColor = ''; }, 1500);
|
|
})
|
|
.catch(() => { btn.textContent = 'error'; btn.disabled = false; });
|
|
}
|
|
|
|
function removePR(session, ev) {
|
|
ev.stopPropagation();
|
|
if (!confirm('Remove PR association from ' + session + '?')) return;
|
|
const btn = ev.target.closest('.action-btn');
|
|
btn.textContent = 'removing...';
|
|
btn.disabled = true;
|
|
fetch('/api/remove-pr/' + encodeURIComponent(session)).then(r => r.json()).then(r => {
|
|
if (r.ok) {
|
|
btn.textContent = 'removed';
|
|
btn.style.color = '#3fb950';
|
|
// Remove PR pills and alerts from the card
|
|
const card = btn.closest('.card');
|
|
const meta = card.querySelector('.card-meta');
|
|
if (meta) { const pills = meta.querySelectorAll('.pill.pr-num, .pill.size'); pills.forEach(p => p.remove()); }
|
|
const alerts = card.querySelector('.card-alerts');
|
|
if (alerts) alerts.innerHTML = '';
|
|
} else {
|
|
btn.textContent = 'error';
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
function askAgent(session, message, ev) {
|
|
ev.stopPropagation();
|
|
const btn = ev.target.closest('.action-btn');
|
|
const origText = btn.textContent;
|
|
btn.textContent = 'sending...';
|
|
btn.disabled = true;
|
|
fetch('/api/send/' + encodeURIComponent(session), {method:'POST', body: message})
|
|
.then(r => r.json())
|
|
.then(r => {
|
|
if (r.ok) { btn.textContent = 'sent!'; btn.style.borderColor = '#3fb950'; btn.style.color = '#3fb950'; }
|
|
else { btn.textContent = 'failed'; btn.style.borderColor = '#f85149'; btn.style.color = '#f85149'; }
|
|
})
|
|
.catch(() => { btn.textContent = 'error'; btn.disabled = false; });
|
|
}
|
|
|
|
// Merge-readiness score (lower = easier to merge = sorted first)
|
|
function mergeScore(prList) {
|
|
if (!prList.length) return 100; // no PR = bottom
|
|
if (prList.every(pr => pr.state === 'MERGED')) return 200; // merged = very bottom
|
|
let score = 0;
|
|
for (const pr of prList) {
|
|
if (pr.mergeable === 'CONFLICTING') score += 40;
|
|
const ciR = ci(pr.ciSummary);
|
|
if (ciR.c === 'ci-fail') score += 30;
|
|
else if (ciR.c === 'ci-pending') score += 5;
|
|
if (pr.reviewDecision === 'CHANGES_REQUESTED') score += 20;
|
|
else if (pr.reviewDecision !== 'APPROVED') score += 10; // needs review
|
|
score += (pr.unresolvedThreads || 0) * 5;
|
|
}
|
|
return score;
|
|
}
|
|
|
|
// Build enriched session data for sorting
|
|
function enrichSession(s) {
|
|
const prUrls = s.pr ? s.pr.split(',').filter(Boolean) : [];
|
|
const prNums = prUrls.map(u => prNum(u)).filter(Boolean);
|
|
const prList = prNums.map(n => ({num: n, url: prUrls.find(u => u.includes('/'+n)), ...prDetails[n]})).filter(p => p.title);
|
|
return { ...s, prList, _score: mergeScore(prList) };
|
|
}
|
|
|
|
// Stats
|
|
const st = document.getElementById('stats');
|
|
st.innerHTML = [
|
|
{n:counts.total, l:'sessions', c:'b'}, {n:counts.working, l:'working', c:'g'},
|
|
{n:counts.openPrs, l:'open PRs', c:'p'}, {n:counts.needsReview, l:'need review', c:'y'},
|
|
].map(s => `<div class="stat"><span class="n ${s.c}">${s.n}</span><span class="l">${s.l}</span></div>`).join('');
|
|
|
|
// Sessions — sorted by ease of merging (lowest score first)
|
|
const el = document.getElementById('sessions');
|
|
const enriched = sessions.map(enrichSession);
|
|
enriched.sort((a, b) => {
|
|
// Working sessions first
|
|
if (a.activity === 'working' && b.activity !== 'working') return -1;
|
|
if (b.activity === 'working' && a.activity !== 'working') return 1;
|
|
// Exited last
|
|
if (a.activity === 'exited' && b.activity !== 'exited') return 1;
|
|
if (b.activity === 'exited' && a.activity !== 'exited') return -1;
|
|
// Then by merge readiness
|
|
return a._score - b._score;
|
|
});
|
|
|
|
// Expand sessions into one card per PR (sessions with no PR get one card)
|
|
const cards = [];
|
|
enriched.forEach(s => {
|
|
if (s.prList.length <= 1) {
|
|
cards.push({session: s, pr: s.prList[0] || null});
|
|
} else {
|
|
s.prList.forEach(pr => cards.push({session: s, pr}));
|
|
}
|
|
});
|
|
|
|
cards.forEach(({session: s, pr: currentPr}) => {
|
|
const ico = s.activity==='working' ? '<span class="pulse">⚡</span>' : s.activity==='exited' ? '💀' : '💤';
|
|
const purpose = currentPr?.title || s.summary || s.status || s.branch;
|
|
const isMerged = currentPr?.state === 'MERGED';
|
|
const repo = 'ComposioHQ/integrator';
|
|
|
|
// Pills
|
|
let pills = [];
|
|
let alerts = [];
|
|
let hasRedAlert = false;
|
|
|
|
if (currentPr) {
|
|
pills.push(`<a href="${currentPr.url}" target="_blank" class="pill pr-num">#${currentPr.num}</a>`);
|
|
const sz = (currentPr.additions||0)+(currentPr.deletions||0);
|
|
const sl = sz>1000?'XL':sz>500?'L':sz>200?'M':sz>50?'S':'XS';
|
|
pills.push(`<span class="pill size">+${currentPr.additions||0} -${currentPr.deletions||0} ${sl}</span>`);
|
|
|
|
if (isMerged) {
|
|
pills.push(`<span class="pill" style="background:rgba(163,113,247,0.1);color:#a371f7">merged</span>`);
|
|
} else {
|
|
// CI
|
|
const prCi = ci(currentPr.ciSummary);
|
|
if (prCi.c === 'ci-pass') pills.push(`<span class="pill ci-pass">${prCi.l}</span>`);
|
|
else if (prCi.c === 'ci-fail') {
|
|
const failCheck = (currentPr.ciChecks||[]).find(c => c.status === 'FAILURE');
|
|
const fUrl = failCheck?.url || currentPr.url + '/checks';
|
|
const failCount = (currentPr.ciChecks||[]).filter(c => c.status === 'FAILURE').length;
|
|
alerts.push(`<a href="${fUrl}" target="_blank" class="alert-tag ci-fail" onclick="event.stopPropagation()">${failCount} CI check${failCount > 1 ? 's' : ''} failing</a>`);
|
|
if (s.activity !== 'working') alerts.push(`<button class="action-btn" onclick="askAgent('${s.name}','Please fix the failing CI checks on ${currentPr.url}',event)">ask to fix CI</button>`);
|
|
hasRedAlert = true;
|
|
}
|
|
else if (prCi.c === 'ci-pending') pills.push(`<span class="pill ci-pending">${prCi.l}</span>`);
|
|
|
|
// Review
|
|
if (currentPr.reviewDecision==='APPROVED') pills.push('<span class="pill approved">approved</span>');
|
|
else if (currentPr.reviewDecision==='CHANGES_REQUESTED') {
|
|
alerts.push(`<a href="${currentPr.url}" target="_blank" class="alert-tag changes" onclick="event.stopPropagation()">changes requested</a>`);
|
|
hasRedAlert = true;
|
|
} else {
|
|
alerts.push(`<a href="${currentPr.url}" target="_blank" class="alert-tag review" onclick="event.stopPropagation()">needs review</a>`);
|
|
if (s.activity !== 'working') alerts.push(`<button class="action-btn" onclick="askAgent('${s.name}','Post ${currentPr.url} on slack asking for a review. Do not ask for permission, just post it.',event)">ask to post for review</button>`);
|
|
}
|
|
|
|
// Merge conflict
|
|
if (currentPr.mergeable==='CONFLICTING') {
|
|
alerts.push(`<a href="${currentPr.url}" target="_blank" class="alert-tag conflict" onclick="event.stopPropagation()">merge conflict</a>`);
|
|
if (s.activity !== 'working') alerts.push(`<button class="action-btn" onclick="askAgent('${s.name}','Please resolve the merge conflicts on ${currentPr.url} by checking out the branch and rebasing on the base branch',event)">ask to fix conflicts</button>`);
|
|
hasRedAlert = true;
|
|
}
|
|
|
|
// Unresolved comments
|
|
const unresolvedList = currentPr.unresolvedList || [];
|
|
if ((currentPr.unresolvedThreads||0) > 0) {
|
|
const firstCommentUrl = unresolvedList[0]?.url || currentPr.url + '/files';
|
|
alerts.push(`<a href="${firstCommentUrl}" target="_blank" class="alert-tag comments" onclick="event.stopPropagation()"><span class="alert-num">${currentPr.unresolvedThreads}</span> unresolved comments</a>`);
|
|
if (s.activity !== 'working') alerts.push(`<button class="action-btn" onclick="askAgent('${s.name}','Please address all unresolved review comments on ${currentPr.url}',event)">ask to resolve</button>`);
|
|
hasRedAlert = true;
|
|
}
|
|
|
|
// Ready to merge
|
|
const prCi2 = ci(currentPr.ciSummary);
|
|
if (currentPr.reviewDecision === 'APPROVED' && prCi2.c === 'ci-pass' && currentPr.mergeable !== 'CONFLICTING' && (currentPr.unresolvedThreads||0) === 0) {
|
|
alerts = [`<a href="#" class="alert-tag merge" onclick="mergePR('${repo}','${currentPr.num}',event)">merge PR #${currentPr.num}</a>`];
|
|
pills = pills.filter(p => !p.includes('approved'));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Slack pill
|
|
if (s.slack) pills.push(`<a href="${s.slack}" target="_blank" class="pill" style="background:rgba(88,166,255,0.08);color:#58a6ff" onclick="event.stopPropagation()">slack</a>`);
|
|
|
|
// Detail panel — scoped to this PR only
|
|
let detailHtml = '';
|
|
if (s.summary && currentPr?.title && s.summary !== currentPr.title) {
|
|
detailHtml += `<div class="detail-section"><div class="detail-label">Summary</div><div class="detail-summary">${s.summary}</div></div>`;
|
|
}
|
|
if (s.issue) detailHtml += `<div class="detail-section"><div class="detail-label">Issue</div><div class="detail-summary"><a href="${s.issue}" target="_blank">${s.issue}</a></div></div>`;
|
|
if (s.slack) detailHtml += `<div class="detail-section"><div class="detail-label">Slack</div><div class="detail-summary"><a href="${s.slack}" target="_blank">${s.slack}</a></div></div>`;
|
|
|
|
if (currentPr) {
|
|
const checks = currentPr.ciChecks || [];
|
|
if (checks.length) {
|
|
detailHtml += `<div class="detail-section"><div class="detail-label">CI Checks</div>`;
|
|
const order = {FAILURE: 0, IN_PROGRESS: 1, PENDING: 2, SUCCESS: 3, NEUTRAL: 4, SKIPPED: 5};
|
|
checks.sort((a,b) => (order[a.status]??9) - (order[b.status]??9));
|
|
checks.forEach(c => {
|
|
const icon = c.status === 'SUCCESS' ? '<span class="g">✓</span>' : c.status === 'FAILURE' ? '<span class="r">✗</span>' : c.status === 'PENDING' || c.status === 'IN_PROGRESS' ? '<span class="y">●</span>' : '<span class="dim">○</span>';
|
|
const link = c.url ? `<a href="${c.url}" target="_blank">view</a>` : '';
|
|
detailHtml += `<div class="detail-row">${icon} <span class="check-name">${c.name}</span> ${link}</div>`;
|
|
});
|
|
detailHtml += `</div>`;
|
|
}
|
|
const threads = currentPr.unresolvedList || [];
|
|
if (threads.length) {
|
|
detailHtml += `<div class="detail-section"><div class="detail-label">Unresolved Comments</div>`;
|
|
threads.forEach(t => {
|
|
const file = t.path || 'general';
|
|
const link = t.url ? `<a href="${t.url}" target="_blank">go to comment</a>` : '';
|
|
detailHtml += `<div class="detail-row"><span class="r">●</span> <span class="check-name">${file}</span> ${link}</div>`;
|
|
});
|
|
detailHtml += `</div>`;
|
|
}
|
|
detailHtml += `<div class="detail-section"><div class="detail-label">PR Details</div><div class="detail-summary"><a href="${currentPr.url}" target="_blank">${currentPr.title}</a><br><span class="g">+${currentPr.additions||0}</span> <span class="r">-${currentPr.deletions||0}</span> · mergeable: ${currentPr.mergeable || 'unknown'} · review: ${currentPr.reviewDecision || 'none'}${currentPr.createdAt ? ' · opened ' + timeAgo(currentPr.createdAt) : ''}</div></div>`;
|
|
}
|
|
|
|
if (!detailHtml && !currentPr) detailHtml = `<div class="detail-section"><div class="detail-summary dim">No PR associated with this session.</div></div>`;
|
|
|
|
// Notes (only on first card for this session to avoid duplicates)
|
|
const isFirstCard = cards.findIndex(c => c.session.name === s.name) === cards.indexOf(cards.find(c => c.session === s && c.pr === currentPr));
|
|
const cardIdx = cards.filter(c => c.session.name === s.name).indexOf(cards.find(c => c.session === s && c.pr === currentPr));
|
|
if (cardIdx === 0) {
|
|
const escapedNotes = (s.notes || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
detailHtml += `<div class="detail-section notes-section"><div class="detail-label">Notes</div><textarea class="notes-area" id="notes-${s.name}" placeholder="Add notes..." onclick="event.stopPropagation()" onkeydown="event.stopPropagation()">${escapedNotes}</textarea><div class="notes-bar"><button class="notes-save" onclick="event.stopPropagation();saveNotes('${s.name}')" id="notes-save-${s.name}">save</button><span class="notes-status" id="notes-status-${s.name}"></span></div></div>`;
|
|
}
|
|
|
|
const uid = currentPr ? s.name + '-pr' + currentPr.num : s.name;
|
|
detailHtml = `<div class="detail-content" id="detail-${uid}">${detailHtml}</div><button class="show-more-btn" onclick="event.stopPropagation();const c=document.getElementById('detail-${uid}');c.classList.toggle('expanded');this.textContent=c.classList.contains('expanded')?'Show less':'Show more'">Show more</button><div class="detail-footer">${currentPr ? `<button class="action-btn" onclick="removePR('${s.name}', event)" style="border-color:rgba(210,153,34,0.4);color:#d29922">remove PR association</button>` : ''}<button class="kill-btn" onclick="killSession('${s.name}', event)">terminate session</button></div>`;
|
|
|
|
const readyToMerge = currentPr && !isMerged && currentPr.reviewDecision === 'APPROVED' && ci(currentPr.ciSummary).c === 'ci-pass' && currentPr.mergeable !== 'CONFLICTING' && (currentPr.unresolvedThreads||0) === 0;
|
|
const cardClass = `card ${s.activity}${isMerged ? ' merged' : readyToMerge ? ' ready-to-merge' : hasRedAlert ? ' has-alerts-red' : alerts.length ? ' has-alerts' : ''}`;
|
|
const d = document.createElement('div');
|
|
d.className = cardClass;
|
|
d.onclick = (e) => { if (e.target.closest('a, button, textarea')) return; d.classList.toggle('expanded'); };
|
|
d.innerHTML = `<div class="card-top"><span class="card-icon">${ico}</span><span class="card-name">${s.name}</span><span class="card-purpose">${purpose}</span>${currentPr?.createdAt ? '<span class="card-age">' + timeAgo(currentPr.createdAt) + '</span>' : ''}${isMerged || s.activity === 'exited' ? `<button class="kill-btn" onclick="killSession('${s.name}', event)">kill session</button>` : ''}${currentPr ? `<button class="open-btn" onclick="event.stopPropagation();window.open('${currentPr.url}','_blank')">open PR</button>` : ''}<button class="open-btn" onclick="openTerminal('${s.name}',event)">terminal</button><button class="open-btn" onclick="event.stopPropagation();openSession('${s.name}')">open</button></div><div class="card-meta"><span class="card-branch">${s.branch}</span><span class="sep">·</span>${pills.join('')}</div>${alerts.length ? '<div class="card-alerts">' + alerts.join('') + '</div>' : ''}<div class="card-detail">${detailHtml}</div>`;
|
|
// Group cards from same session into a shared container
|
|
const sessionCount = cards.filter(c => c.session.name === s.name).length;
|
|
if (sessionCount > 1) {
|
|
let group = el.querySelector(`[data-session-group="${s.name}"]`);
|
|
if (!group) {
|
|
group = document.createElement('div');
|
|
group.className = 'session-group';
|
|
group.dataset.sessionGroup = s.name;
|
|
el.appendChild(group);
|
|
}
|
|
group.appendChild(d);
|
|
} else {
|
|
el.appendChild(d);
|
|
}
|
|
});
|
|
|
|
// PR table — sorted by ease of merge (same logic)
|
|
const tbody = document.getElementById('pr-tbody');
|
|
Object.keys(prDetails)
|
|
.filter(n => prDetails[n].state==='OPEN')
|
|
.sort((a,b) => {
|
|
// Score: lower = easier to merge = top
|
|
function prScore(n) {
|
|
const pr = prDetails[n];
|
|
let sc = 0;
|
|
if (pr.mergeable === 'CONFLICTING') sc += 40;
|
|
const c = ci(pr.ciSummary);
|
|
if (c.c === 'ci-fail') sc += 30;
|
|
if (pr.reviewDecision === 'CHANGES_REQUESTED') sc += 20;
|
|
else if (pr.reviewDecision !== 'APPROVED') sc += 10;
|
|
sc += (pr.unresolvedThreads || 0) * 5;
|
|
return sc;
|
|
}
|
|
return prScore(a) - prScore(b);
|
|
})
|
|
.forEach(num => {
|
|
const pr = prDetails[num];
|
|
const sz = pr.additions+pr.deletions;
|
|
const sl = sz>1000?'XL':sz>500?'L':sz>200?'M':sz>50?'S':'XS';
|
|
const ciR = ci(pr.ciSummary);
|
|
const failCheck = (pr.ciChecks||[]).find(c => c.status === 'FAILURE');
|
|
const rv = pr.reviewDecision==='APPROVED' ? '<span class="pill approved">approved</span>'
|
|
: pr.reviewDecision==='CHANGES_REQUESTED' ? '<span class="alert-tag changes" style="font-size:11px;padding:2px 8px">changes requested</span>'
|
|
: '<span class="alert-tag review" style="font-size:11px;padding:2px 8px">needs review</span>';
|
|
const ciTd = ciR.c === 'ci-fail'
|
|
? `<a href="${failCheck?.url || '#'}" target="_blank" class="alert-tag ci-fail" style="font-size:11px;padding:2px 8px">${ciR.l}</a>`
|
|
: ciR.c ? `<span class="pill ${ciR.c}">${ciR.l}</span>` : '<span class="dim">—</span>';
|
|
const hasIssues = (pr.unresolvedThreads||0) > 0 || ciR.c === 'ci-fail' || pr.mergeable === 'CONFLICTING';
|
|
const sess = sessions.find(s => s.pr && s.pr.includes('/pull/'+num));
|
|
const repo = 'ComposioHQ/integrator';
|
|
const firstComment = (pr.unresolvedList||[])[0]?.url || `https://github.com/${repo}/pull/${num}/files`;
|
|
const tr = document.createElement('tr');
|
|
if (hasIssues) tr.className = 'has-issues';
|
|
tr.innerHTML = `
|
|
<td><a href="https://github.com/${repo}/pull/${num}" target="_blank">#${num}</a></td>
|
|
<td class="title">${pr.title}</td>
|
|
<td><span class="g">+${pr.additions}</span> <span class="r">-${pr.deletions}</span> <span class="dim">${sl}</span></td>
|
|
<td>${ciTd}</td>
|
|
<td>${rv}</td>
|
|
<td class="comments-cell ${(pr.unresolvedThreads||0) > 0 ? 'red' : 'zero'}">${(pr.unresolvedThreads||0) > 0 ? `<a href="${firstComment}" target="_blank" style="color:inherit">${pr.unresolvedThreads}</a>` : '0'}</td>
|
|
<td class="dim">${timeAgo(pr.createdAt)}</td>`;
|
|
tbody.appendChild(tr);
|
|
});
|
|
|
|
document.getElementById('timestamp').textContent = new Date().toLocaleString();
|
|
|
|
// Live activity polling
|
|
setInterval(() => {
|
|
fetch('/api/sessions').then(r => r.json()).then(data => {
|
|
let workingCount = 0;
|
|
document.querySelectorAll('.card').forEach(card => {
|
|
const nameEl = card.querySelector('.card-name');
|
|
if (!nameEl) return;
|
|
const name = nameEl.textContent;
|
|
const newActivity = data[name];
|
|
if (!newActivity) return;
|
|
const oldActivity = card.classList.contains('working') ? 'working'
|
|
: card.classList.contains('exited') ? 'exited' : 'idle';
|
|
if (newActivity === oldActivity) { if (newActivity === 'working') workingCount++; return; }
|
|
// Update card class
|
|
card.classList.remove('working', 'idle', 'exited');
|
|
card.classList.add(newActivity);
|
|
// Update icon
|
|
const iconEl = card.querySelector('.card-icon');
|
|
if (iconEl) {
|
|
iconEl.innerHTML = newActivity === 'working' ? '<span class="pulse">⚡</span>'
|
|
: newActivity === 'exited' ? '💀' : '💤';
|
|
}
|
|
// Show/hide action buttons based on new activity
|
|
card.querySelectorAll('.action-btn').forEach(btn => {
|
|
btn.style.display = newActivity === 'working' ? 'none' : '';
|
|
});
|
|
if (newActivity === 'working') workingCount++;
|
|
});
|
|
// Update working count in stats
|
|
const statNums = document.querySelectorAll('.stat .n.g');
|
|
if (statNums.length) statNums[0].textContent = workingCount;
|
|
document.getElementById('timestamp').textContent = new Date().toLocaleString();
|
|
}).catch(() => {});
|
|
}, 5000);
|
|
</script>
|
|
</body>
|
|
</html>
|
|
JSEOF
|
|
|
|
echo "Dashboard generated: $DASHBOARD"
|
|
|
|
if [ "$REGEN_ONLY" = true ]; then
|
|
echo "Regeneration complete."
|
|
exit 0
|
|
fi
|
|
|
|
# Kill any previous dashboard server
|
|
pkill -f 'claude-dashboard-server' 2>/dev/null
|
|
sleep 0.3
|
|
|
|
# Start a tiny local server that serves the dashboard + handles /api/open/:session
|
|
PORT=9847
|
|
cat > /tmp/claude-dashboard-server.py << 'PYEOF'
|
|
import http.server, subprocess, json, os, urllib.parse, signal, sys, re, threading, time
|
|
|
|
PORT = 9847
|
|
DASHBOARD = "/tmp/claude-dashboard.html"
|
|
regen_lock = threading.Lock()
|
|
regen_proc = None
|
|
regen_started = 0
|
|
|
|
TTYD_BASE_PORT = 7682
|
|
ttyd_procs = {} # session_name -> {'port': int, 'proc': subprocess.Popen}
|
|
|
|
def get_ttyd_port():
|
|
used = {v['port'] for v in ttyd_procs.values()}
|
|
port = TTYD_BASE_PORT
|
|
while port in used:
|
|
port += 1
|
|
return port
|
|
|
|
def cleanup_ttyd():
|
|
for info in ttyd_procs.values():
|
|
try:
|
|
info['proc'].terminate()
|
|
except Exception:
|
|
pass
|
|
ttyd_procs.clear()
|
|
|
|
LOADER_HTML = b"""<!DOCTYPE html>
|
|
<html><head><meta charset="UTF-8"><title>Claude Orchestrator</title>
|
|
<style>
|
|
* { margin:0; padding:0; box-sizing:border-box; }
|
|
body { font-family:-apple-system,BlinkMacSystemFont,system-ui,sans-serif; background:#0d1117; color:#e6edf3;
|
|
display:flex; align-items:center; justify-content:center; min-height:100vh; flex-direction:column; gap:16px; }
|
|
.spinner { width:32px; height:32px; border:3px solid #30363d; border-top-color:#7c8aff; border-radius:50%;
|
|
animation:spin 0.8s linear infinite; }
|
|
@keyframes spin { to { transform:rotate(360deg); } }
|
|
.text { color:#7d8590; font-size:14px; }
|
|
.sub { color:#484f58; font-size:12px; }
|
|
</style></head><body>
|
|
<div class="spinner"></div>
|
|
<div class="text">Refreshing dashboard...</div>
|
|
<div class="sub" id="elapsed"></div>
|
|
<script>
|
|
const t0 = Date.now();
|
|
setInterval(() => {
|
|
document.getElementById('elapsed').textContent = ((Date.now()-t0)/1000).toFixed(0) + 's';
|
|
fetch('/api/regen-status').then(r=>r.json()).then(d => {
|
|
if (d.done) {
|
|
fetch('/dashboard').then(r=>r.text()).then(html => {
|
|
document.open(); document.write(html); document.close();
|
|
history.replaceState(null, '', '/');
|
|
});
|
|
}
|
|
}).catch(()=>{});
|
|
}, 1000);
|
|
</script></body></html>"""
|
|
|
|
def get_session_activities():
|
|
"""Fast scan of tmux sessions — returns {session_name: activity} using Claude session JSONL files"""
|
|
import time, glob
|
|
|
|
home = os.path.expanduser('~')
|
|
tmux = '/opt/homebrew/bin/tmux'
|
|
try:
|
|
result = subprocess.run(
|
|
[tmux, 'list-sessions', '-F', '#{session_name}'],
|
|
capture_output=True, text=True, timeout=5
|
|
)
|
|
sessions = [s for s in result.stdout.strip().split('\n')
|
|
if re.match(r'^integrator-', s)]
|
|
except Exception:
|
|
return {}
|
|
|
|
activities = {}
|
|
now = time.time()
|
|
for s in sessions:
|
|
# Get pane working directory directly from tmux (most reliable)
|
|
try:
|
|
cwd = subprocess.run(
|
|
[tmux, 'display-message', '-t', s, '-p', '#{pane_current_path}'],
|
|
capture_output=True, text=True, timeout=3
|
|
).stdout.strip()
|
|
except Exception:
|
|
cwd = ''
|
|
|
|
found_jsonl = False
|
|
if cwd:
|
|
# Convert CWD to Claude's project dir format: tr '/.' '--'
|
|
proj_key = cwd.translate(str.maketrans('/.', '--'))
|
|
session_dir = os.path.join(home, '.claude', 'projects', proj_key)
|
|
jsonls = sorted(glob.glob(os.path.join(session_dir, '*.jsonl')),
|
|
key=os.path.getmtime, reverse=True)
|
|
jsonls = [j for j in jsonls if 'agent-' not in os.path.basename(j)]
|
|
if jsonls:
|
|
found_jsonl = True
|
|
jsonl = jsonls[0]
|
|
mod_age = now - os.path.getmtime(jsonl)
|
|
last_type = ''
|
|
try:
|
|
with open(jsonl, 'rb') as f:
|
|
f.seek(0, 2)
|
|
f.seek(max(0, f.tell() - 4096))
|
|
lines = f.read().split(b'\n')
|
|
for line in reversed(lines):
|
|
line = line.strip()
|
|
if line:
|
|
last_type = json.loads(line).get('type', '')
|
|
break
|
|
except Exception:
|
|
pass
|
|
|
|
if mod_age < 30 and last_type not in ('assistant', 'system'):
|
|
activities[s] = 'working'
|
|
continue
|
|
|
|
# Fallback: check if claude process exists (pane itself or descendants)
|
|
try:
|
|
pane_pid = subprocess.run(
|
|
[tmux, 'list-panes', '-t', s, '-F', '#{pane_pid}'],
|
|
capture_output=True, text=True, timeout=3
|
|
).stdout.strip().split('\n')[0]
|
|
if pane_pid:
|
|
ps_out = subprocess.run(
|
|
['ps', '-o', 'pid,ppid,comm'],
|
|
capture_output=True, text=True, timeout=3
|
|
).stdout
|
|
has_claude = False
|
|
# Check if pane process itself is claude
|
|
for line in ps_out.strip().split('\n')[1:]:
|
|
parts = line.split()
|
|
if len(parts) >= 3 and parts[0] == pane_pid and 'claude' in parts[2]:
|
|
has_claude = True
|
|
break
|
|
# Walk descendants
|
|
if not has_claude:
|
|
pids_to_check = {pane_pid}
|
|
for _ in range(3):
|
|
next_pids = set()
|
|
for line in ps_out.strip().split('\n')[1:]:
|
|
parts = line.split()
|
|
if len(parts) >= 3 and parts[1] in pids_to_check:
|
|
next_pids.add(parts[0])
|
|
if 'claude' in parts[2]:
|
|
has_claude = True
|
|
if has_claude:
|
|
break
|
|
pids_to_check = next_pids
|
|
if not pids_to_check:
|
|
break
|
|
if not has_claude:
|
|
activities[s] = 'exited'
|
|
continue
|
|
except Exception:
|
|
pass
|
|
|
|
activities[s] = 'idle'
|
|
return activities
|
|
|
|
class Handler(http.server.SimpleHTTPRequestHandler):
|
|
def _json(self, code, data):
|
|
self.send_response(code)
|
|
self.send_header('Content-Type', 'application/json')
|
|
self.send_header('Access-Control-Allow-Origin', '*')
|
|
self.end_headers()
|
|
self.wfile.write(json.dumps(data).encode())
|
|
|
|
def do_GET(self):
|
|
if self.path == '/api/sessions':
|
|
try:
|
|
self._json(200, get_session_activities())
|
|
except Exception as e:
|
|
self._json(500, {'error': str(e)})
|
|
elif self.path.startswith('/api/open/'):
|
|
session = urllib.parse.unquote(self.path[len('/api/open/'):])
|
|
try:
|
|
result = subprocess.run(
|
|
[os.path.expanduser('~/open-tmux-session'), session],
|
|
capture_output=True, text=True, timeout=10
|
|
)
|
|
self._json(200, {'ok': result.returncode == 0, 'error': result.stderr.strip()})
|
|
except Exception as e:
|
|
self._json(500, {'ok': False, 'error': str(e)})
|
|
elif self.path.startswith('/api/terminal/'):
|
|
session = urllib.parse.unquote(self.path[len('/api/terminal/'):])
|
|
# Check if ttyd already running for this session
|
|
if session in ttyd_procs:
|
|
proc = ttyd_procs[session]['proc']
|
|
if proc.poll() is None:
|
|
self._json(200, {'ok': True, 'url': f'http://localhost:{ttyd_procs[session]["port"]}'})
|
|
return
|
|
else:
|
|
del ttyd_procs[session]
|
|
# Verify tmux session exists (auto-create for orchestrator)
|
|
check = subprocess.run(
|
|
['/opt/homebrew/bin/tmux', 'has-session', '-t', session],
|
|
capture_output=True, timeout=5
|
|
)
|
|
if check.returncode != 0:
|
|
if session == 'orchestrator':
|
|
env = {k: v for k, v in os.environ.items() if k != 'CLAUDECODE'}
|
|
subprocess.run(
|
|
['/opt/homebrew/bin/tmux', 'new-session', '-d', '-s', 'orchestrator',
|
|
'-c', os.path.expanduser('~'), '-e', 'CLAUDECODE='],
|
|
capture_output=True, timeout=5, env=env
|
|
)
|
|
# Verify it was created
|
|
verify = subprocess.run(
|
|
['/opt/homebrew/bin/tmux', 'has-session', '-t', 'orchestrator'],
|
|
capture_output=True, timeout=5
|
|
)
|
|
if verify.returncode != 0:
|
|
self._json(500, {'ok': False, 'error': 'failed to create orchestrator session'})
|
|
return
|
|
else:
|
|
self._json(404, {'ok': False, 'error': 'tmux session not found'})
|
|
return
|
|
port = get_ttyd_port()
|
|
try:
|
|
proc = subprocess.Popen(
|
|
['/opt/homebrew/bin/ttyd', '--writable', '--port', str(port),
|
|
'/opt/homebrew/bin/tmux', 'attach-session', '-t', session],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
|
)
|
|
ttyd_procs[session] = {'port': port, 'proc': proc}
|
|
time.sleep(0.3)
|
|
if proc.poll() is not None:
|
|
del ttyd_procs[session]
|
|
self._json(500, {'ok': False, 'error': 'ttyd failed to start'})
|
|
else:
|
|
self._json(200, {'ok': True, 'url': f'http://localhost:{port}'})
|
|
except Exception as e:
|
|
self._json(500, {'ok': False, 'error': str(e)})
|
|
elif self.path.startswith('/api/remove-pr/'):
|
|
session = urllib.parse.unquote(self.path[len('/api/remove-pr/'):])
|
|
try:
|
|
meta_file = None
|
|
meta_file = os.path.expanduser(f'~/.integrator-sessions/{session}')
|
|
if not os.path.exists(meta_file):
|
|
self._json(404, {'ok': False, 'error': 'session not found'})
|
|
return
|
|
with open(meta_file, 'r') as f:
|
|
lines = f.readlines()
|
|
new_lines = [l for l in lines if not l.startswith('pr=')]
|
|
with open(meta_file, 'w') as f:
|
|
f.writelines(new_lines)
|
|
self._json(200, {'ok': True})
|
|
except Exception as e:
|
|
self._json(500, {'ok': False, 'error': str(e)})
|
|
elif self.path.startswith('/api/kill/'):
|
|
session = urllib.parse.unquote(self.path[len('/api/kill/'):])
|
|
try:
|
|
# Kill any ttyd for this session
|
|
if session in ttyd_procs:
|
|
try:
|
|
ttyd_procs[session]['proc'].terminate()
|
|
except Exception:
|
|
pass
|
|
del ttyd_procs[session]
|
|
# Close iTerm2 tab (before killing tmux, so we can still find it)
|
|
# Get TTY of attached tmux client first
|
|
tty_result = subprocess.run(
|
|
['/opt/homebrew/bin/tmux', 'list-clients', '-t', session, '-F', '#{client_tty}'],
|
|
capture_output=True, text=True, timeout=5
|
|
)
|
|
target_tty = tty_result.stdout.strip().split('\n')[0] if tty_result.stdout.strip() else ''
|
|
if target_tty:
|
|
# Find iTerm2 tab by TTY
|
|
subprocess.run(['osascript', '-e', f'''
|
|
tell application "iTerm2"
|
|
repeat with aWindow in windows
|
|
repeat with aTab in tabs of aWindow
|
|
repeat with aSession in sessions of aTab
|
|
try
|
|
if tty of aSession is equal to "{target_tty}" then
|
|
tell aTab to close
|
|
return
|
|
end if
|
|
end try
|
|
end repeat
|
|
end repeat
|
|
end repeat
|
|
end tell
|
|
'''], capture_output=True, text=True, timeout=5)
|
|
else:
|
|
# Fallback: try matching by profile name
|
|
subprocess.run(['osascript', '-e', f'''
|
|
tell application "iTerm2"
|
|
repeat with aWindow in windows
|
|
repeat with aTab in tabs of aWindow
|
|
repeat with aSession in sessions of aTab
|
|
try
|
|
if profile name of aSession is equal to "{session}" then
|
|
tell aTab to close
|
|
return
|
|
end if
|
|
end try
|
|
end repeat
|
|
end repeat
|
|
end repeat
|
|
end tell
|
|
'''], capture_output=True, text=True, timeout=5)
|
|
# Kill tmux session
|
|
subprocess.run(['/opt/homebrew/bin/tmux', 'kill-session', '-t', session],
|
|
capture_output=True, text=True, timeout=5)
|
|
# Archive metadata file (instead of deleting)
|
|
meta = os.path.expanduser(f'~/.integrator-sessions/{session}')
|
|
if os.path.exists(meta):
|
|
archive_dir = os.path.expanduser(f'~/.integrator-sessions/archive')
|
|
os.makedirs(archive_dir, exist_ok=True)
|
|
ts = time.strftime('%Y%m%d-%H%M%S')
|
|
archive_path = os.path.join(archive_dir, f'{session}_{ts}')
|
|
import shutil
|
|
shutil.move(meta, archive_path)
|
|
self._json(200, {'ok': True})
|
|
# Regenerate HTML in background so next reload is fresh
|
|
subprocess.Popen([os.path.expanduser('~/claude-dashboard'), '--regen-only'],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
except Exception as e:
|
|
self._json(500, {'ok': False, 'error': str(e)})
|
|
elif self.path.startswith('/api/merge/'):
|
|
# /api/merge/owner/repo/num
|
|
parts = urllib.parse.unquote(self.path[len('/api/merge/'):]).split('/')
|
|
if len(parts) == 3:
|
|
repo = parts[0] + '/' + parts[1]
|
|
num = parts[2]
|
|
try:
|
|
result = subprocess.run(
|
|
['gh', 'pr', 'merge', num, '--repo', repo, '--squash', '--auto'],
|
|
capture_output=True, text=True, timeout=30,
|
|
env={**os.environ, 'PATH': '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin'}
|
|
)
|
|
if result.returncode == 0:
|
|
self._json(200, {'ok': True})
|
|
else:
|
|
self._json(200, {'ok': False, 'error': result.stderr.strip()})
|
|
except Exception as e:
|
|
self._json(500, {'ok': False, 'error': str(e)})
|
|
else:
|
|
self._json(400, {'ok': False, 'error': 'bad path, expected /api/merge/owner/repo/num'})
|
|
elif self.path == '/dashboard':
|
|
self.send_response(200)
|
|
self.send_header('Content-Type', 'text/html')
|
|
self.end_headers()
|
|
with open(DASHBOARD, 'rb') as f:
|
|
self.wfile.write(f.read())
|
|
elif self.path == '/api/regen-status':
|
|
global regen_proc, regen_started
|
|
done = True
|
|
if regen_proc is not None:
|
|
done = regen_proc.poll() is not None
|
|
elapsed = time.time() - regen_started if regen_started else 0
|
|
self._json(200, {'done': done, 'elapsed': round(elapsed, 1)})
|
|
elif self.path.split('?')[0] in ('/', '/index.html'):
|
|
# Always trigger regeneration and show loader
|
|
with regen_lock:
|
|
if regen_proc is None or regen_proc.poll() is not None:
|
|
regen_started = time.time()
|
|
regen_proc = subprocess.Popen(
|
|
[os.path.expanduser('~/claude-dashboard'), '--regen-only'],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
|
)
|
|
self.send_response(200)
|
|
self.send_header('Content-Type', 'text/html')
|
|
self.end_headers()
|
|
self.wfile.write(LOADER_HTML)
|
|
else:
|
|
self.send_error(404)
|
|
|
|
def do_POST(self):
|
|
if self.path.startswith('/api/notes/'):
|
|
session = urllib.parse.unquote(self.path[len('/api/notes/'):])
|
|
length = int(self.headers.get('Content-Length', 0))
|
|
notes = self.rfile.read(length).decode('utf-8') if length else ''
|
|
try:
|
|
# Find the metadata file
|
|
meta_file = None
|
|
meta_file = os.path.expanduser(f'~/.integrator-sessions/{session}')
|
|
if not os.path.exists(meta_file):
|
|
self._json(404, {'ok': False, 'error': 'session not found'})
|
|
return
|
|
# Read existing metadata, update notes line
|
|
with open(meta_file, 'r') as f:
|
|
lines = f.readlines()
|
|
new_lines = [l for l in lines if not l.startswith('notes=')]
|
|
if notes.strip():
|
|
new_lines.append(f'notes={notes.strip()}\n')
|
|
with open(meta_file, 'w') as f:
|
|
f.writelines(new_lines)
|
|
self._json(200, {'ok': True})
|
|
except Exception as e:
|
|
self._json(500, {'ok': False, 'error': str(e)})
|
|
elif self.path.startswith('/api/send/'):
|
|
session = urllib.parse.unquote(self.path[len('/api/send/'):])
|
|
length = int(self.headers.get('Content-Length', 0))
|
|
message = self.rfile.read(length).decode('utf-8') if length else ''
|
|
if not message:
|
|
self._json(400, {'ok': False, 'error': 'empty message'})
|
|
return
|
|
try:
|
|
result = subprocess.run(
|
|
[os.path.expanduser('~/send-to-session'), session, message],
|
|
capture_output=True, text=True, timeout=30
|
|
)
|
|
self._json(200, {'ok': result.returncode == 0, 'error': result.stderr.strip()})
|
|
except Exception as e:
|
|
self._json(500, {'ok': False, 'error': str(e)})
|
|
else:
|
|
self._json(404, {'ok': False, 'error': 'not found'})
|
|
|
|
def log_message(self, fmt, *args):
|
|
pass # quiet
|
|
|
|
def _shutdown(*a):
|
|
cleanup_ttyd()
|
|
sys.exit(0)
|
|
signal.signal(signal.SIGTERM, _shutdown)
|
|
signal.signal(signal.SIGINT, _shutdown)
|
|
import atexit
|
|
atexit.register(cleanup_ttyd)
|
|
print(f"Dashboard server on http://localhost:{PORT}")
|
|
http.server.HTTPServer(('127.0.0.1', PORT), Handler).serve_forever()
|
|
PYEOF
|
|
|
|
python3 /tmp/claude-dashboard-server.py &
|
|
SERVERPID=$!
|
|
echo "$SERVERPID" > /tmp/claude-dashboard-server.pid
|
|
sleep 0.5
|
|
|
|
if [ "$NO_OPEN" = false ]; then
|
|
open "http://localhost:$PORT"
|
|
echo "Opened http://localhost:$PORT"
|
|
fi
|
|
echo "Server PID: $SERVERPID (kill with: pkill -f claude-dashboard-server)"
|