Files
homepage/scripts/run.sh
2026-02-02 22:57:25 +01:00

313 lines
8.8 KiB
Bash

#!/bin/bash
set -e
HOMEPAGE_PORT="${HOMEPAGE_PORT:-3000}"
HOMEPAGE_TITLE="${HOMEPAGE_TITLE:-VibeStack}"
SKILLS_DIR="${SKILLS_DIR:-/skills}"
# Known ports for skills
declare -A KNOWN_PORTS=(
["caddy"]="80"
["ttyd"]="7681"
["metrics"]="9090"
["loki"]="3100"
["duckdb"]="8432"
["claude"]="8888"
["openclaw"]="18789"
["supervisor"]="9001"
["homepage"]="3000"
)
declare -A HEALTH_PORTS=(
["caddy"]="2019"
["ttyd"]="7681"
["metrics"]="9090"
["loki"]="3100"
["duckdb"]="8432"
["claude"]="8888"
["openclaw"]="18789"
["supervisor"]="9001"
)
# Create handler script
create_handler() {
cat > /tmp/homepage_handler.sh << 'HANDLER'
#!/bin/bash
HOMEPAGE_TITLE="${HOMEPAGE_TITLE:-VibeStack}"
SKILLS_DIR="${SKILLS_DIR:-/skills}"
# Read request
read -r request_line
path=$(echo "$request_line" | cut -d' ' -f2)
while read -r header; do
header=$(echo "$header" | tr -d '\r')
[ -z "$header" ] && break
done
send_response() {
local status="$1"
local content_type="$2"
local body="$3"
local body_length=${#body}
printf "HTTP/1.1 %s\r\n" "$status"
printf "Content-Type: %s\r\n" "$content_type"
printf "Content-Length: %d\r\n" "$body_length"
printf "Connection: close\r\n"
printf "\r\n"
printf "%s" "$body"
}
# Get skill info
get_skills_json() {
local skills="[]"
for skill_dir in "$SKILLS_DIR"/*/; do
local name=$(basename "$skill_dir")
local skill_md="$skill_dir/SKILL.md"
[ ! -f "$skill_md" ] && continue
# Parse SKILL.md
local frontmatter=$(sed -n '/^---$/,/^---$/p' "$skill_md" | sed '1d;$d')
local desc=$(echo "$frontmatter" | yq -r '.description // "No description"' 2>/dev/null || echo "")
local metrics_port=$(echo "$frontmatter" | yq -r '.metadata.vibestack."metrics-port" // empty' 2>/dev/null || echo "")
local is_main=$(echo "$frontmatter" | yq -r '.metadata.vibestack.main // false' 2>/dev/null || echo "false")
# Get port
local port=""
case "$name" in
caddy) port="80" ;;
ttyd) port="7681" ;;
metrics) port="9090" ;;
loki) port="3100" ;;
duckdb) port="8432" ;;
claude) port="8888" ;;
openclaw) port="18789" ;;
supervisor) port="9001" ;;
homepage) port="3000" ;;
esac
# Check health
local health_port="$port"
[ "$name" = "caddy" ] && health_port="2019"
local status="unknown"
if [ -n "$health_port" ]; then
if curl -sf --max-time 1 "http://localhost:$health_port/health" >/dev/null 2>&1 || \
curl -sf --max-time 1 "http://localhost:$health_port/" >/dev/null 2>&1; then
status="up"
else
status="down"
fi
fi
skills=$(echo "$skills" | jq \
--arg name "$name" \
--arg desc "$desc" \
--arg port "$port" \
--arg status "$status" \
--arg main "$is_main" \
--arg metrics "$metrics_port" \
'. + [{name:$name, description:$desc, port:$port, status:$status, main:($main=="true"), metrics_port:$metrics}]')
done
echo "$skills"
}
# Generate HTML
generate_html() {
local skills=$(get_skills_json)
cat << HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>$HOMEPAGE_TITLE</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f172a;
color: #e2e8f0;
min-height: 100vh;
padding: 2rem;
}
.container { max-width: 1200px; margin: 0 auto; }
h1 {
font-size: 2rem;
margin-bottom: 2rem;
color: #f8fafc;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.card {
background: #1e293b;
border-radius: 0.5rem;
padding: 1.25rem;
border: 1px solid #334155;
transition: border-color 0.2s;
}
.card:hover { border-color: #3b82f6; }
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.card-title {
font-size: 1.125rem;
font-weight: 600;
color: #f8fafc;
}
.status {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
font-weight: 500;
padding: 0.25rem 0.5rem;
border-radius: 9999px;
}
.status-up { background: #064e3b; color: #34d399; }
.status-down { background: #7f1d1d; color: #f87171; }
.status-unknown { background: #374151; color: #9ca3af; }
.status::before {
content: '';
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: currentColor;
}
.description {
color: #94a3b8;
font-size: 0.875rem;
margin-bottom: 1rem;
line-height: 1.5;
}
.meta {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.port {
font-family: monospace;
font-size: 0.75rem;
background: #334155;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
color: #cbd5e1;
}
.link {
font-size: 0.75rem;
color: #3b82f6;
text-decoration: none;
}
.link:hover { text-decoration: underline; }
.main-badge {
font-size: 0.625rem;
background: #3b82f6;
color: white;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
margin-left: 0.5rem;
}
.refresh {
position: fixed;
bottom: 1rem;
right: 1rem;
background: #334155;
color: #e2e8f0;
border: none;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
cursor: pointer;
font-size: 0.875rem;
}
.refresh:hover { background: #475569; }
</style>
</head>
<body>
<div class="container">
<h1>$HOMEPAGE_TITLE</h1>
<div class="grid" id="skills"></div>
</div>
<button class="refresh" onclick="location.reload()">↻ Refresh</button>
<script>
const skills = $skills;
const container = document.getElementById('skills');
skills.sort((a, b) => {
if (a.main && !b.main) return -1;
if (!a.main && b.main) return 1;
return a.name.localeCompare(b.name);
});
skills.forEach(skill => {
const card = document.createElement('div');
card.className = 'card';
const statusClass = skill.status === 'up' ? 'status-up' :
skill.status === 'down' ? 'status-down' : 'status-unknown';
let meta = '';
if (skill.port) {
meta += '<span class="port">:' + skill.port + '</span>';
if (skill.status === 'up') {
meta += ' <a class="link" href="http://localhost:' + skill.port + '" target="_blank">Open →</a>';
}
}
card.innerHTML =
'<div class="card-header">' +
'<span class="card-title">' + skill.name + (skill.main ? '<span class="main-badge">MAIN</span>' : '') + '</span>' +
'<span class="status ' + statusClass + '">' + skill.status + '</span>' +
'</div>' +
'<p class="description">' + (skill.description || 'No description') + '</p>' +
'<div class="meta">' + meta + '</div>';
container.appendChild(card);
});
</script>
</body>
</html>
HTML
}
# Route requests
case "$path" in
/health)
send_response "200 OK" "application/json" '{"status":"ok"}'
;;
/api/skills)
skills=$(get_skills_json)
send_response "200 OK" "application/json" "$skills"
;;
*)
html=$(generate_html)
send_response "200 OK" "text/html; charset=utf-8" "$html"
;;
esac
HANDLER
chmod +x /tmp/homepage_handler.sh
export HOMEPAGE_TITLE
export SKILLS_DIR
}
# Serve
serve() {
echo "Starting Homepage on port $HOMEPAGE_PORT..."
exec socat TCP-LISTEN:$HOMEPAGE_PORT,reuseaddr,fork EXEC:"/tmp/homepage_handler.sh"
}
create_handler
serve