headplane/src/pages/index.astro
Ryan Malloy 7c21720519 Complete the Astro rewrite
Drop the entire app/ Remix tree (144 deletions) and replace with the
Astro + Alpine.js architecture under src/. The Remix entrypoint, routes,
components, layouts, server bindings, and types are all gone; the Astro
pages (acls, dns, machines, settings, terminal, users, login, index)
plus their API endpoints under src/pages/api/ now own the surface.

Other surfaces touched:
- package.json: drop react-router, react-router-hono-server, remix-utils
  and the rest of the Remix stack; pull in Astro + integrations + Alpine
- pnpm-lock.yaml: regenerated against the new dependency set
- astro.config.mjs added; vite.config.ts, react-router.config.ts dropped
- New src/lib/auth/ (oidc-client, role-mapper, session-manager) and
  src/lib/config/authentik.ts for env-driven config
- biome.json: enable VCS-aware filtering, exclude .astro/dist/data/
  upstream/ and the React Router backup
- Extensive docs (HEADY_MANIFESTO, AUTHENTIK_*, BETTER_ROLE_MAPPING* etc.)
  and example role-mapping yamls added under examples/
- New remote-access/ tree for the Guacamole-Lite integration
- terminal.astro: prerender disabled (data is request-time only)

Committed with --no-verify; biome auto-fix was applied first but there
are still lint warnings in the new code worth a separate cleanup pass.
The legacy app/ tree was never re-pushed after the rewrite, which is
why the Gitea/Docker builds were trying to compile app/routes/ssh/
console.tsx.
2026-06-06 13:05:35 -06:00

373 lines
No EOL
14 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
import { getCollection } from 'astro:content';
import AuthenticatedLayout from '../components/auth/AuthenticatedLayout.astro';
// Heady Dashboard - Alpine.js/Astro Homepage 🤠
import Layout from '../layouts/Layout.astro';
// Fetch live data using Astro content collections
const allMachines = await getCollection('machines');
const activeSessions = await getCollection(
'sessions',
({ data }) => data.status === 'active',
);
const recentActivity = await getCollection('activity').then((items) =>
items
.slice(0, 10)
.sort(
(a, b) =>
new Date(b.data.timestamp).getTime() -
new Date(a.data.timestamp).getTime(),
),
);
// Calculate dashboard stats
const onlineMachines = allMachines.filter((m) => m.data.online).length;
const totalMachines = allMachines.length;
const activeSessionCount = activeSessions.length;
// System health indicators
const systemHealth = {
headscale: true, // Would be determined by actual health checks
database: true,
oidc: true,
remoteAccess: true,
};
---
<Layout title="Dashboard">
<AuthenticatedLayout title="Dashboard" requiredRole="member">
<div class="min-h-screen bg-gray-900" x-data="dashboard()" x-init="init()">
<!-- Header -->
<div class="bg-gray-800 border-b border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-white flex items-center">
<span class="mr-3">🤠</span>
Welcome to Heady
</h1>
<p class="mt-1 text-sm text-gray-400">
Strategic VPN management that's actually awesome to use
</p>
</div>
<!-- Quick Actions -->
<div class="flex space-x-3">
<a
href="/terminal"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center"
>
💻 Quick Terminal
</a>
<a
href="/machines"
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center"
>
🖥️ Manage Machines
</a>
</div>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- System Status Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<!-- Online Machines -->
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-green-500 rounded-lg flex items-center justify-center">
<span class="text-white text-sm font-medium">🖥️</span>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-400 truncate">Online Machines</dt>
<dd class="flex items-baseline">
<div class="text-2xl font-semibold text-white">{onlineMachines}</div>
<div class="ml-2 text-sm text-gray-400">/ {totalMachines}</div>
</dd>
</dl>
</div>
</div>
</div>
<!-- Active Sessions -->
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center">
<span class="text-white text-sm font-medium">⚡</span>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-400 truncate">Active Sessions</dt>
<dd class="text-2xl font-semibold text-white">{activeSessionCount}</dd>
</dl>
</div>
</div>
</div>
<!-- System Health -->
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class={`w-8 h-8 rounded-lg flex items-center justify-center ${
Object.values(systemHealth).every(h => h) ? 'bg-green-500' : 'bg-yellow-500'
}`}>
<span class="text-white text-sm font-medium">🛡️</span>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-400 truncate">System Health</dt>
<dd class="text-2xl font-semibold text-white">
{Object.values(systemHealth).every(h => h) ? 'Excellent' : 'Warning'}
</dd>
</dl>
</div>
</div>
</div>
<!-- Network Status -->
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-purple-500 rounded-lg flex items-center justify-center">
<span class="text-white text-sm font-medium">🌐</span>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-400 truncate">Network</dt>
<dd class="text-2xl font-semibold text-white">Healthy</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Recent Machines -->
<div class="lg:col-span-2">
<div class="bg-gray-800 rounded-lg border border-gray-700">
<div class="px-6 py-4 border-b border-gray-700">
<h3 class="text-lg font-medium text-white flex items-center">
<span class="mr-2">🖥️</span>
Machines Overview
</h3>
</div>
<div class="p-6">
<div class="space-y-4">
{allMachines.slice(0, 5).map(machine => (
<div class="flex items-center justify-between p-4 bg-gray-900 rounded-lg">
<div class="flex items-center space-x-3">
<div class={`w-3 h-3 rounded-full ${machine.data.online ? 'bg-green-500' : 'bg-gray-500'}`}></div>
<div>
<div class="text-sm font-medium text-white">{machine.data.name}</div>
<div class="text-xs text-gray-400">{machine.data.ip_address}</div>
</div>
</div>
<div class="flex items-center space-x-2">
{machine.data.os && (
<span class="text-xs text-gray-400 capitalize">
{machine.data.os === 'linux' ? '🐧' :
machine.data.os === 'windows' ? '🪟' :
machine.data.os === 'macos' ? '🍎' : '❓'}
{machine.data.os}
</span>
)}
<span class={`text-xs px-2 py-1 rounded ${
machine.data.online ? 'bg-green-900 text-green-200' : 'bg-gray-700 text-gray-400'
}`}>
{machine.data.online ? 'Online' : 'Offline'}
</span>
</div>
</div>
))}
</div>
{allMachines.length > 5 && (
<div class="mt-4 text-center">
<a
href="/machines"
class="text-blue-400 hover:text-blue-300 text-sm font-medium"
>
View all {totalMachines} machines →
</a>
</div>
)}
</div>
</div>
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- Active Sessions -->
{activeSessions.length > 0 && (
<div class="bg-gray-800 rounded-lg border border-gray-700">
<div class="px-6 py-4 border-b border-gray-700">
<h3 class="text-lg font-medium text-white flex items-center">
<span class="mr-2">⚡</span>
Active Sessions
</h3>
</div>
<div class="p-6">
<div class="space-y-3">
{activeSessions.slice(0, 3).map(session => (
<div class="flex items-center justify-between p-3 bg-gray-900 rounded">
<div class="flex items-center space-x-2">
<span class={`heady-protocol-badge protocol-${session.data.protocol}`}>
{session.data.protocol.toUpperCase()}
</span>
<span class="text-sm text-white font-mono">{session.data.node_name}</span>
</div>
<div class="text-xs text-gray-400">
{session.data.user_email.split('@')[0]}
</div>
</div>
))}
</div>
{activeSessions.length > 3 && (
<div class="mt-4 text-center">
<a
href="/terminal"
class="text-blue-400 hover:text-blue-300 text-sm font-medium"
>
View all sessions →
</a>
</div>
)}
</div>
</div>
)}
<!-- System Health Details -->
<div class="bg-gray-800 rounded-lg border border-gray-700">
<div class="px-6 py-4 border-b border-gray-700">
<h3 class="text-lg font-medium text-white flex items-center">
<span class="mr-2">🛡️</span>
System Health
</h3>
</div>
<div class="p-6">
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-400">Headscale</span>
<span class={`text-sm ${systemHealth.headscale ? 'text-green-400' : 'text-red-400'}`}>
{systemHealth.headscale ? '✅ Healthy' : '❌ Unhealthy'}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-400">Database</span>
<span class={`text-sm ${systemHealth.database ? 'text-green-400' : 'text-red-400'}`}>
{systemHealth.database ? '✅ Connected' : '❌ Disconnected'}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-400">OIDC Auth</span>
<span class={`text-sm ${systemHealth.oidc ? 'text-green-400' : 'text-red-400'}`}>
{systemHealth.oidc ? '✅ Active' : '❌ Error'}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-400">Remote Access</span>
<span class={`text-sm ${systemHealth.remoteAccess ? 'text-green-400' : 'text-red-400'}`}>
{systemHealth.remoteAccess ? '✅ Ready' : '❌ Unavailable'}
</span>
</div>
</div>
</div>
</div>
<!-- Quick Links -->
<div class="bg-gray-800 rounded-lg border border-gray-700">
<div class="px-6 py-4 border-b border-gray-700">
<h3 class="text-lg font-medium text-white flex items-center">
<span class="mr-2">🚀</span>
Quick Actions
</h3>
</div>
<div class="p-6">
<div class="space-y-3">
<a
href="/machines/add"
class="w-full bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center justify-center"
>
Add Machine
</a>
<a
href="/acls"
class="w-full bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center justify-center"
>
🛡️ Configure ACLs
</a>
<a
href="/dns"
class="w-full bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center justify-center"
>
🌐 Manage DNS
</a>
<a
href="/users"
class="w-full bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center justify-center"
>
👥 User Management
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
function dashboard() {
return {
refreshInterval: null,
init() {
// Set up auto-refresh
this.refreshInterval = setInterval(() => {
this.refreshDashboard();
}, 30000); // Refresh every 30 seconds
// Listen for data refresh events
this.$el.addEventListener('data-refresh', () => {
this.refreshDashboard();
});
},
async refreshDashboard() {
try {
// Refresh dashboard data
const response = await fetch('/api/dashboard/stats');
if (response.ok) {
const data = await response.json();
// Update dashboard stats in real-time
// This would update the displayed numbers
console.log('Dashboard refreshed:', data);
}
} catch (error) {
console.error('Failed to refresh dashboard:', error);
}
},
destroy() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
}
};
}
// Make globally available
window.dashboard = dashboard;
</script>
</AuthenticatedLayout>
</Layout>