fix: terminal.astro must be SSR, not prerendered
Some checks are pending
Build / native (push) Waiting to run
Build / nix (push) Waiting to run

Page reads from astro:content collections (machines, sessions, users)
that are populated at request time from headscale. Pre-rendering at
build time hits empty/undefined data and throws 'Cannot read
properties of undefined (reading length)'.
This commit is contained in:
Ryan Malloy 2026-06-06 12:58:29 -06:00
parent 72f6277b6f
commit 6e2679ac3a

553
src/pages/terminal.astro Normal file
View File

@ -0,0 +1,553 @@
---
// Heady Terminal Page - Alpine.js/Astro Remote Access 🤠
// SSR-only: the page reads from `astro:content` collections that are
// populated at request time from headscale, so pre-rendering at build
// time hits empty/undefined data and throws.
export const prerender = false;
import Layout from '../layouts/Layout.astro';
import { getCollection } from 'astro:content';
import GuacamoleLiteClient from '../components/GuacamoleLiteClient.astro';
import NodeGrid from '../components/NodeGrid.astro';
import SessionManager from '../components/SessionManager.astro';
// Fetch live data for terminal page with error handling
let allMachines = [];
let activeSessions = [];
let currentUser = null;
try {
allMachines = await getCollection('machines');
activeSessions = await getCollection('sessions', ({ data }) => data.status === 'active');
const users = await getCollection('users');
currentUser = users[0] || null; // In real app, get from auth
} catch (error) {
console.error('Error loading terminal page data:', error);
// Use fallback data for development
}
// Filter online machines for terminal access
const onlineMachines = allMachines.filter(machine => machine.data?.online);
// User permissions for protocol access
const userPermissions = {
ssh: true,
rdp: currentUser?.data.role === 'owner' || currentUser?.data.role === 'admin' || currentUser?.data.role === 'it_admin',
vnc: currentUser?.data.role === 'owner' || currentUser?.data.role === 'admin',
telnet: currentUser?.data.role !== 'member' && currentUser?.data.role !== 'auditor',
kubernetes: currentUser?.data.role === 'owner' || currentUser?.data.role === 'admin' || currentUser?.data.role === 'network_admin',
file_transfer: currentUser?.data.role !== 'member' && currentUser?.data.role !== 'auditor',
session_recording: currentUser?.data.role !== 'owner'
};
// Available protocols based on permissions
const availableProtocols = Object.entries(userPermissions)
.filter(([protocol, allowed]) => allowed && ['ssh', 'rdp', 'vnc', 'telnet', 'kubernetes'].includes(protocol))
.map(([protocol]) => protocol);
---
<Layout title="Remote Access Terminal">
<div
class="min-h-screen bg-gray-900"
x-data="terminalPage()"
x-init="init()"
>
<!-- Terminal 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 flex-col lg:flex-row lg:items-center lg:justify-between">
<div class="mb-4 lg:mb-0">
<h1 class="text-2xl font-bold text-white flex items-center">
<span class="mr-3">💻</span>
Heady Remote Access
</h1>
<p class="mt-1 text-sm text-gray-400">
Secure multi-protocol terminal access to your VPN infrastructure
</p>
</div>
<!-- Quick Connection -->
<div class="flex items-center space-x-4">
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-400">Role:</span>
<span
class="px-2 py-1 text-xs rounded font-medium"
:class="`heady-role-${currentUser?.role?.replace('_', '-')}`"
x-text="currentUser?.role"
>
{currentUser?.data.role}
</span>
</div>
<button
@click="showQuickConnect = true"
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 Connect
</button>
</div>
</div>
</div>
</div>
<!-- Search and Filters -->
<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-4">
<div class="flex flex-col md:flex-row gap-4 items-center justify-between">
<!-- Search -->
<div class="flex-1 max-w-md">
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
x-model="searchTerm"
type="text"
placeholder="Search machines..."
class="w-full pl-10 pr-4 py-2 bg-gray-900 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:border-blue-500 focus:outline-none"
/>
</div>
</div>
<!-- Protocol Filter -->
<div class="flex gap-2 flex-wrap">
{availableProtocols.map(protocol => (
<button
@click={`selectedProtocol = '${protocol}'`}
:class={`selectedProtocol === '${protocol}' ? 'bg-blue-500 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'`}
class="px-3 py-2 text-sm rounded font-semibold transition-colors"
>
{protocol.toUpperCase()}
</button>
))}
</div>
<!-- View Toggle -->
<div class="flex gap-2">
<button
@click="viewMode = 'grid'"
:class="viewMode === 'grid' ? 'bg-blue-500 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'"
class="px-3 py-2 text-sm rounded transition-colors"
>
🔲 Grid
</button>
<button
@click="viewMode = 'list'"
:class="viewMode === 'list' ? 'bg-blue-500 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'"
class="px-3 py-2 text-sm rounded transition-colors"
>
📋 List
</button>
</div>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Active Sessions Section -->
<div x-show="activeSessions && activeSessions.length > 0" x-transition class="mb-8">
<SessionManager
sessions={activeSessions}
userId={currentUser?.data.id}
compact={false}
/>
</div>
<!-- Machines Grid/List -->
<div class="space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-white flex items-center">
<span class="mr-2">🖥️</span>
Available Machines
<span class="ml-2 text-sm text-gray-400" x-text="`(${filteredMachines ? filteredMachines.length : 0})`"></span>
</h2>
<!-- Sort Options -->
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-400">Sort by:</span>
<select
x-model="sortBy"
@change="sortMachines()"
class="bg-gray-800 border border-gray-600 text-white text-sm rounded px-3 py-1"
>
<option value="name">Name</option>
<option value="status">Status</option>
<option value="last_seen">Last Seen</option>
<option value="os">OS</option>
</select>
</div>
</div>
<!-- Grid View -->
<div x-show="viewMode === 'grid'" x-transition>
<NodeGrid
nodes={onlineMachines}
userId={currentUser?.data.id}
showOffline={false}
gridCols={4}
/>
</div>
<!-- List View -->
<div x-show="viewMode === 'list'" x-transition>
<div class="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
<div class="px-6 py-4 bg-gray-800 border-b border-gray-700">
<h3 class="text-lg font-medium text-white">Machine List</h3>
</div>
<div class="divide-y divide-gray-700">
<template x-for="machine in filteredMachines" :key="machine.id">
<div
class="px-6 py-4 hover:bg-gray-700 cursor-pointer transition-colors"
@click="connectToMachine(machine)"
>
<div class="flex items-center justify-between">
<!-- Machine Info -->
<div class="flex items-center space-x-4">
<div
class="w-3 h-3 rounded-full"
:class="machine.online ? 'bg-green-500 animate-pulse' : 'bg-gray-500'"
></div>
<div>
<div class="text-sm font-medium text-white" x-text="machine.name"></div>
<div class="text-xs text-gray-400" x-text="machine.ip_address"></div>
</div>
<!-- OS Icon -->
<div class="text-lg">
<span x-show="machine.os === 'linux'">🐧</span>
<span x-show="machine.os === 'windows'">🪟</span>
<span x-show="machine.os === 'macos'">🍎</span>
<span x-show="!machine.os || machine.os === 'unknown'">❓</span>
</div>
</div>
<!-- Protocols and Actions -->
<div class="flex items-center space-x-4">
<!-- Available Protocols -->
<div class="flex gap-1">
<template x-for="protocol in getAvailableProtocols(machine)" :key="protocol">
<span
class="heady-protocol-badge text-xs"
:class="`protocol-${protocol}`"
x-text="protocol"
></span>
</template>
</div>
<!-- Quick Actions -->
<div class="flex gap-2">
<button
@click.stop="quickConnect(machine, 'ssh')"
class="bg-green-600 hover:bg-green-700 text-white px-2 py-1 text-xs rounded transition-colors"
>
SSH
</button>
<template x-if="machine.os === 'windows' && hasPermission('rdp')">
<button
@click.stop="quickConnect(machine, 'rdp')"
class="bg-blue-600 hover:bg-blue-700 text-white px-2 py-1 text-xs rounded transition-colors"
>
RDP
</button>
</template>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- Empty State -->
<div x-show="filteredMachines && filteredMachines.length === 0" x-transition class="text-center py-12">
<span class="text-6xl mb-4 block">🤠</span>
<h3 class="text-xl font-semibold mb-2 text-white">No machines available</h3>
<p class="text-gray-400">
<span x-show="searchTerm">No machines match your search criteria.</span>
<span x-show="!searchTerm">No online machines support the selected protocol.</span>
</p>
<button
@click="clearFilters()"
class="mt-4 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
Clear Filters
</button>
</div>
</div>
</div>
<!-- Terminal Modal -->
<div
x-show="isTerminalOpen"
x-transition
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75"
@click.self="closeTerminal()"
>
<div class="w-full h-full md:w-5/6 md:h-5/6 md:max-w-6xl">
<div class="bg-gray-900 rounded-lg overflow-hidden h-full flex flex-col">
<!-- Terminal Header -->
<div class="bg-gray-800 border-b border-gray-600 px-4 py-3 flex items-center justify-between">
<div class="flex items-center space-x-3">
<span
class="heady-protocol-badge"
:class="`protocol-${currentConnection?.protocol}`"
x-text="currentConnection?.protocol?.toUpperCase()"
></span>
<span class="font-mono text-white" x-text="currentConnection?.node"></span>
<!-- Connection Status -->
<div class="flex items-center space-x-2">
<div
class="w-2 h-2 rounded-full"
:class="connectionStatus === 'connected' ? 'bg-green-500 animate-pulse' :
connectionStatus === 'connecting' ? 'bg-yellow-500' :
'bg-red-500'"
></div>
<span class="text-xs text-gray-300" x-text="connectionStatus"></span>
</div>
</div>
<div class="flex items-center space-x-2">
<span class="text-xs text-gray-300" x-text="`${currentUser?.email} • ${currentUser?.role}`"></span>
<button
@click="closeTerminal()"
class="text-red-400 hover:text-red-300 font-bold text-lg"
>
</button>
</div>
</div>
<!-- Guacamole Client Container -->
<div class="flex-1" x-show="currentConnection">
<GuacamoleLiteClient
x-bind:nodeId="currentConnection?.node"
x-bind:protocol="currentConnection?.protocol"
autoConnect={true}
showToolbar={true}
/>
</div>
</div>
</div>
</div>
<!-- Quick Connect Modal -->
<div
x-show="showQuickConnect"
x-transition
class="fixed inset-0 z-40 flex items-center justify-center bg-black bg-opacity-50"
@click.self="showQuickConnect = false"
>
<div class="bg-gray-800 rounded-lg p-6 w-full max-w-md mx-4">
<h3 class="text-lg font-semibold text-white mb-4">Quick Connect</h3>
<div class="space-y-4">
<!-- Machine Selection -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Machine</label>
<select
x-model="quickConnectMachine"
class="w-full bg-gray-900 border border-gray-600 text-white rounded px-3 py-2"
>
<option value="">Select a machine...</option>
<template x-for="machine in onlineMachines" :key="machine.id">
<option :value="machine.name" x-text="`${machine.name} (${machine.ip_address})`"></option>
</template>
</select>
</div>
<!-- Protocol Selection -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Protocol</label>
<div class="grid grid-cols-2 gap-2">
{availableProtocols.map(protocol => (
<button
@click={`quickConnectProtocol = '${protocol}'`}
:class={`quickConnectProtocol === '${protocol}' ? 'bg-blue-500 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'`}
class="px-3 py-2 text-sm rounded font-medium transition-colors"
>
{protocol.toUpperCase()}
</button>
))}
</div>
</div>
<!-- Actions -->
<div class="flex gap-3 pt-4">
<button
@click="executeQuickConnect()"
:disabled="!quickConnectMachine || !quickConnectProtocol"
class="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white px-4 py-2 rounded font-medium transition-colors"
>
Connect
</button>
<button
@click="showQuickConnect = false"
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded font-medium transition-colors"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
<script define:vars={{ onlineMachines, activeSessions, userPermissions, currentUser }}>
function terminalPage() {
return {
// Current user and permissions
currentUser: currentUser?.data || null,
permissions: userPermissions,
// UI State
searchTerm: '',
selectedProtocol: 'ssh',
viewMode: 'grid',
sortBy: 'name',
// Machine data
machines: onlineMachines.map(m => m.data),
activeSessions: activeSessions.map(s => s.data),
// Terminal state
isTerminalOpen: false,
currentConnection: null,
connectionStatus: 'disconnected',
// Quick connect
showQuickConnect: false,
quickConnectMachine: '',
quickConnectProtocol: 'ssh',
get filteredMachines() {
let filtered = this.machines.filter(machine => {
const matchesSearch = machine.name.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
machine.ip_address.includes(this.searchTerm);
const hasProtocol = this.getAvailableProtocols(machine).includes(this.selectedProtocol);
return matchesSearch && hasProtocol;
});
return this.sortMachines(filtered);
},
get onlineMachines() {
return this.machines.filter(m => m.online);
},
init() {
// Parse JSON data
this.currentUser = JSON.parse(this.currentUser || 'null');
this.permissions = JSON.parse(this.permissions);
this.machines = JSON.parse(this.machines);
this.activeSessions = JSON.parse(this.activeSessions);
// Set up periodic refresh
setInterval(() => this.refreshData(), 30000);
// Listen for terminal events
this.$el.addEventListener('open-terminal', (e) => {
this.connectToNode(e.detail.node, e.detail.protocol);
});
},
getAvailableProtocols(machine) {
const protocols = [];
if (machine.ssh_enabled && this.permissions.ssh) protocols.push('ssh');
if (machine.rdp_enabled && this.permissions.rdp) protocols.push('rdp');
if (machine.vnc_enabled && this.permissions.vnc) protocols.push('vnc');
if (machine.telnet_enabled && this.permissions.telnet) protocols.push('telnet');
if (machine.kubernetes_enabled && this.permissions.kubernetes) protocols.push('kubernetes');
return protocols;
},
hasPermission(protocol) {
return this.permissions[protocol] || false;
},
sortMachines(machines = this.machines) {
return machines.sort((a, b) => {
switch (this.sortBy) {
case 'name':
return a.name.localeCompare(b.name);
case 'status':
return b.online - a.online;
case 'last_seen':
return new Date(b.last_seen || 0) - new Date(a.last_seen || 0);
case 'os':
return (a.os || 'unknown').localeCompare(b.os || 'unknown');
default:
return 0;
}
});
},
connectToMachine(machine) {
const protocols = this.getAvailableProtocols(machine);
const protocol = protocols.includes(this.selectedProtocol) ? this.selectedProtocol : protocols[0];
this.connectToNode(machine.name, protocol);
},
connectToNode(nodeName, protocol) {
this.currentConnection = { node: nodeName, protocol };
this.connectionStatus = 'connecting';
this.isTerminalOpen = true;
},
quickConnect(machine, protocol) {
this.connectToNode(machine.name, protocol);
},
executeQuickConnect() {
if (this.quickConnectMachine && this.quickConnectProtocol) {
this.connectToNode(this.quickConnectMachine, this.quickConnectProtocol);
this.showQuickConnect = false;
this.quickConnectMachine = '';
this.quickConnectProtocol = 'ssh';
}
},
closeTerminal() {
this.isTerminalOpen = false;
this.currentConnection = null;
this.connectionStatus = 'disconnected';
},
clearFilters() {
this.searchTerm = '';
this.selectedProtocol = 'ssh';
},
async refreshData() {
try {
const [machinesResponse, sessionsResponse] = await Promise.all([
fetch('/api/machines'),
fetch('/api/sessions?status=active')
]);
if (machinesResponse.ok) {
this.machines = await machinesResponse.json();
}
if (sessionsResponse.ok) {
this.activeSessions = await sessionsResponse.json();
}
} catch (error) {
console.error('Failed to refresh terminal data:', error);
}
}
};
}
// Make globally available
window.terminalPage = terminalPage;
</script>
</Layout>