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.
This commit is contained in:
Ryan Malloy 2026-06-06 13:05:35 -06:00
parent 6e2679ac3a
commit 7c21720519
236 changed files with 22894 additions and 17736 deletions

686
src/pages/machines.astro Normal file
View file

@ -0,0 +1,686 @@
---
import { getCollection } from 'astro:content';
// Heady Machines Management - Alpine.js/Astro Implementation 🤠
import Layout from '../layouts/Layout.astro';
// Fetch live machine data
const allMachines = await getCollection('machines');
const currentUser = await getCollection('users').then((users) => users[0]);
// Machine statistics
const totalMachines = allMachines.length;
const onlineMachines = allMachines.filter((m) => m.data.online).length;
const offlineMachines = totalMachines - onlineMachines;
const expiredMachines = allMachines.filter((m) => m.data.expired).length;
// Group machines by OS
const machinesByOS = allMachines.reduce(
(acc, machine) => {
const os = machine.data.os || 'unknown';
acc[os] = (acc[os] || 0) + 1;
return acc;
},
{} as Record<string, number>,
);
// Recent activity (would come from activity collection)
const recentActivity = [
{
action: 'Machine connected',
machine: 'dev-laptop',
timestamp: '2 minutes ago',
},
{
action: 'Machine disconnected',
machine: 'server-01',
timestamp: '1 hour ago',
},
{
action: 'Machine added',
machine: 'mobile-phone',
timestamp: '3 hours ago',
},
];
---
<Layout title="Machine Management">
<div
class="min-h-screen bg-gray-900"
x-data="machinesPage()"
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 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>
Machine Management
</h1>
<p class="mt-1 text-sm text-gray-400">
Manage and monitor your Tailscale network devices
</p>
</div>
<!-- Actions -->
<div class="flex items-center space-x-4">
<button
@click="refreshMachines()"
:disabled="refreshing"
class="bg-gray-600 hover:bg-gray-700 disabled:bg-gray-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center"
>
<svg class="h-4 w-4 mr-2" :class="refreshing ? 'animate-spin' : ''" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</button>
<button
@click="showAddMachine = 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"
>
Add Machine
</button>
</div>
</div>
</div>
</div>
<!-- Statistics Cards -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<!-- Total 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-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">Total Machines</dt>
<dd class="text-2xl font-semibold text-white">{totalMachines}</dd>
</dl>
</div>
</div>
</div>
<!-- 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</dt>
<dd class="text-2xl font-semibold text-white">{onlineMachines}</dd>
</dl>
</div>
</div>
</div>
<!-- Offline 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-gray-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">Offline</dt>
<dd class="text-2xl font-semibold text-white">{offlineMachines}</dd>
</dl>
</div>
</div>
</div>
<!-- Expired 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-red-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">Expired</dt>
<dd class="text-2xl font-semibold text-white">{expiredMachines}</dd>
</dl>
</div>
</div>
</div>
</div>
<!-- Filters and Search -->
<div class="bg-gray-800 rounded-lg border border-gray-700 mb-6">
<div class="px-6 py-4">
<div class="flex flex-col lg: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 by name, IP, or user..."
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>
<!-- Filters -->
<div class="flex gap-4 items-center">
<!-- Status Filter -->
<div>
<select
x-model="statusFilter"
class="bg-gray-900 border border-gray-600 text-white text-sm rounded px-3 py-2"
>
<option value="all">All Status</option>
<option value="online">Online</option>
<option value="offline">Offline</option>
<option value="expired">Expired</option>
</select>
</div>
<!-- OS Filter -->
<div>
<select
x-model="osFilter"
class="bg-gray-900 border border-gray-600 text-white text-sm rounded px-3 py-2"
>
<option value="all">All OS</option>
<option value="linux">🐧 Linux</option>
<option value="windows">🪟 Windows</option>
<option value="macos">🍎 macOS</option>
<option value="android">🤖 Android</option>
<option value="ios">📱 iOS</option>
<option value="unknown">❓ Unknown</option>
</select>
</div>
<!-- Sort -->
<div>
<select
x-model="sortBy"
@change="sortMachines()"
class="bg-gray-900 border border-gray-600 text-white text-sm rounded px-3 py-2"
>
<option value="name">Name</option>
<option value="status">Status</option>
<option value="last_seen">Last Seen</option>
<option value="created_at">Created</option>
<option value="os">OS</option>
</select>
</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"
>
🔲
</button>
<button
@click="viewMode = 'table'"
:class="viewMode === 'table' ? '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"
>
📋
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Machines Display -->
<div class="space-y-6">
<!-- Grid View -->
<div x-show="viewMode === 'grid'" x-transition>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<template x-for="machine in filteredMachines" :key="machine.id">
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700 hover:border-gray-500 transition-all duration-200">
<!-- Machine Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center space-x-2">
<div
class="w-3 h-3 rounded-full"
:class="machine.online ? 'bg-green-500 animate-pulse' : machine.expired ? 'bg-red-500' : 'bg-gray-500'"
></div>
<h3 class="font-semibold text-white truncate" x-text="machine.name"></h3>
</div>
<!-- Machine Menu -->
<div class="relative" x-data="{ menuOpen: false }">
<button
@click="menuOpen = !menuOpen"
class="text-gray-400 hover:text-white p-1"
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</button>
<div
x-show="menuOpen"
@click.away="menuOpen = false"
x-transition
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg z-10"
>
<div class="py-1">
<button @click="editMachine(machine)" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">✏️ Edit</button>
<button @click="renameMachine(machine)" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">📝 Rename</button>
<button @click="expireMachine(machine)" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">⏰ Expire</button>
<button @click="deleteMachine(machine)" class="block w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-gray-100">🗑️ Delete</button>
</div>
</div>
</div>
</div>
<!-- Machine Details -->
<div class="space-y-3">
<!-- IP Address -->
<div class="flex justify-between items-center">
<span class="text-sm text-gray-400">IP:</span>
<code class="text-xs text-blue-300 bg-gray-900 px-2 py-1 rounded" x-text="machine.ip_address"></code>
</div>
<!-- Operating System -->
<div class="flex justify-between items-center">
<span class="text-sm text-gray-400">OS:</span>
<div class="flex items-center space-x-1">
<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 === 'android'">🤖</span>
<span x-show="machine.os === 'ios'">📱</span>
<span x-show="!machine.os || machine.os === 'unknown'">❓</span>
<span class="text-xs text-gray-300 capitalize" x-text="machine.os || 'Unknown'"></span>
</div>
</div>
<!-- User -->
<div x-show="machine.user" class="flex justify-between items-center">
<span class="text-sm text-gray-400">User:</span>
<span class="text-xs text-purple-300" x-text="machine.user"></span>
</div>
<!-- Last Seen -->
<div x-show="machine.last_seen" class="flex justify-between items-center">
<span class="text-sm text-gray-400">Last Seen:</span>
<span class="text-xs text-gray-300" x-text="formatRelativeTime(machine.last_seen)"></span>
</div>
<!-- Tags -->
<div x-show="machine.tags && machine.tags.length > 0" class="mt-3">
<div class="text-sm text-gray-400 mb-1">Tags:</div>
<div class="flex flex-wrap gap-1">
<template x-for="tag in machine.tags" :key="tag">
<span class="text-xs bg-gray-700 text-gray-300 px-2 py-1 rounded" x-text="tag"></span>
</template>
</div>
</div>
</div>
<!-- Machine Actions -->
<div class="mt-4 flex gap-2">
<button
@click="connectToMachine(machine)"
:disabled="!machine.online"
class="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white text-xs py-2 px-3 rounded font-medium transition-colors"
>
💻 Connect
</button>
<button
@click="showMachineDetails(machine)"
class="bg-gray-600 hover:bg-gray-700 text-white text-xs py-2 px-3 rounded font-medium transition-colors"
>
📊 Details
</button>
</div>
</div>
</template>
</div>
</div>
<!-- Table View -->
<div x-show="viewMode === 'table'" x-transition>
<div class="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-700">
<thead class="bg-gray-800">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Machine</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">IP Address</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">OS</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">User</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Last Seen</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-700">
<template x-for="machine in filteredMachines" :key="machine.id">
<tr class="hover:bg-gray-700 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center space-x-3">
<div
class="w-3 h-3 rounded-full"
:class="machine.online ? 'bg-green-500 animate-pulse' : machine.expired ? 'bg-red-500' : '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.id?.slice(0, 8)"></div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
:class="machine.online ? 'bg-green-900 text-green-200' :
machine.expired ? 'bg-red-900 text-red-200' :
'bg-gray-700 text-gray-400'"
x-text="machine.online ? 'Online' : machine.expired ? 'Expired' : 'Offline'"
></span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<code class="text-sm text-blue-300" x-text="machine.ip_address"></code>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center space-x-2">
<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 === 'android'">🤖</span>
<span x-show="machine.os === 'ios'">📱</span>
<span x-show="!machine.os || machine.os === 'unknown'">❓</span>
<span class="text-sm text-gray-300 capitalize" x-text="machine.os || 'Unknown'"></span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm text-gray-300" x-text="machine.user || '-'"></span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm text-gray-300" x-text="machine.last_seen ? formatRelativeTime(machine.last_seen) : 'Never'"></span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex space-x-2">
<button
@click="connectToMachine(machine)"
:disabled="!machine.online"
class="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white px-3 py-1 text-xs rounded transition-colors"
>
Connect
</button>
<button
@click="editMachine(machine)"
class="bg-gray-600 hover:bg-gray-700 text-white px-3 py-1 text-xs rounded transition-colors"
>
Edit
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
<!-- Empty State -->
<div x-show="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 found</h3>
<p class="text-gray-400 mb-4">
<span x-show="searchTerm || statusFilter !== 'all' || osFilter !== 'all'">
No machines match your current filters.
</span>
<span x-show="!searchTerm && statusFilter === 'all' && osFilter === 'all'">
No machines have been added to your network yet.
</span>
</p>
<div class="space-x-4">
<button
@click="clearFilters()"
x-show="searchTerm || statusFilter !== 'all' || osFilter !== 'all'"
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
Clear Filters
</button>
<button
@click="showAddMachine = true"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
Add First Machine
</button>
</div>
</div>
</div>
</div>
<!-- Add Machine Modal -->
<div
x-show="showAddMachine"
x-transition
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
@click.self="showAddMachine = 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">Add New Machine</h3>
<div class="space-y-4">
<p class="text-sm text-gray-400">
To add a new machine to your Tailscale network, install Tailscale on the device and authenticate it.
</p>
<div class="bg-gray-900 rounded p-4">
<h4 class="text-sm font-medium text-white mb-2">Installation Command:</h4>
<code class="text-xs text-green-400 block">
curl -fsSL https://tailscale.com/install.sh | sh
</code>
</div>
<div class="bg-gray-900 rounded p-4">
<h4 class="text-sm font-medium text-white mb-2">Authentication:</h4>
<code class="text-xs text-blue-400 block">
sudo tailscale up --login-server=<span x-text="headscaleUrl"></span>
</code>
</div>
<div class="flex gap-3 pt-4">
<button
@click="showAddMachine = false"
class="flex-1 bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded font-medium transition-colors"
>
Got it
</button>
</div>
</div>
</div>
</div>
</div>
<script>
function machinesPage() {
return {
// Machine data
machines: JSON.parse(JSON.stringify(allMachines.map(m => m.data))),
// UI state
searchTerm: '',
statusFilter: 'all',
osFilter: 'all',
sortBy: 'name',
viewMode: 'grid',
refreshing: false,
// Modals
showAddMachine: false,
// Configuration
headscaleUrl: window.location.origin,
get filteredMachines() {
let filtered = this.machines.filter(machine => {
// Search filter
const searchMatch = !this.searchTerm ||
machine.name.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
machine.ip_address.includes(this.searchTerm) ||
(machine.user && machine.user.toLowerCase().includes(this.searchTerm.toLowerCase()));
// Status filter
const statusMatch = this.statusFilter === 'all' ||
(this.statusFilter === 'online' && machine.online) ||
(this.statusFilter === 'offline' && !machine.online && !machine.expired) ||
(this.statusFilter === 'expired' && machine.expired);
// OS filter
const osMatch = this.osFilter === 'all' ||
(machine.os || 'unknown') === this.osFilter;
return searchMatch && statusMatch && osMatch;
});
return this.sortMachines(filtered);
},
init() {
// Parse JSON data
this.machines = JSON.parse(this.machines);
// Set up periodic refresh
setInterval(() => this.refreshMachines(), 60000); // Every minute
},
sortMachines(machines = this.machines) {
return machines.sort((a, b) => {
switch (this.sortBy) {
case 'name':
return a.name.localeCompare(b.name);
case 'status':
// Online first, then offline, then expired
if (a.online !== b.online) return b.online - a.online;
if (a.expired !== b.expired) return a.expired - b.expired;
return 0;
case 'last_seen':
return new Date(b.last_seen || 0) - new Date(a.last_seen || 0);
case 'created_at':
return new Date(b.created_at || 0) - new Date(a.created_at || 0);
case 'os':
return (a.os || 'unknown').localeCompare(b.os || 'unknown');
default:
return 0;
}
});
},
async refreshMachines() {
this.refreshing = true;
try {
const response = await fetch('/api/machines');
if (response.ok) {
this.machines = await response.json();
}
} catch (error) {
console.error('Failed to refresh machines:', error);
} finally {
this.refreshing = false;
}
},
connectToMachine(machine) {
// Navigate to terminal with this machine
window.location.href = `/terminal?machine=${machine.name}&protocol=ssh`;
},
editMachine(machine) {
// Show edit modal (would be implemented)
console.log('Edit machine:', machine);
},
renameMachine(machine) {
const newName = prompt('Enter new name for machine:', machine.name);
if (newName && newName !== machine.name) {
this.updateMachine(machine.id, { name: newName });
}
},
async expireMachine(machine) {
if (confirm(`Expire machine "${machine.name}"? This will disconnect it from the network.`)) {
try {
const response = await fetch(`/api/machines/${machine.id}/expire`, {
method: 'POST'
});
if (response.ok) {
await this.refreshMachines();
}
} catch (error) {
console.error('Failed to expire machine:', error);
}
}
},
async deleteMachine(machine) {
if (confirm(`Delete machine "${machine.name}"? This action cannot be undone.`)) {
try {
const response = await fetch(`/api/machines/${machine.id}`, {
method: 'DELETE'
});
if (response.ok) {
await this.refreshMachines();
}
} catch (error) {
console.error('Failed to delete machine:', error);
}
}
},
showMachineDetails(machine) {
// Show detailed machine information modal
console.log('Show details for:', machine);
},
clearFilters() {
this.searchTerm = '';
this.statusFilter = 'all';
this.osFilter = 'all';
},
formatRelativeTime(timestamp) {
const now = new Date();
const time = new Date(timestamp);
const diffMs = now.getTime() - time.getTime();
const diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) return `${diffDays}d ago`;
if (diffHours > 0) return `${diffHours}h ago`;
if (diffMinutes > 0) return `${diffMinutes}m ago`;
return 'Just now';
}
};
}
// Make globally available
window.machinesPage = machinesPage;
</script>
</Layout>