313 lines
8.8 KiB
Bash
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
|