ui: shadcn-ui + React island shells for pages, Redis session store
Rewrite the dashboard, machines, users, dns, and settings pages as thin
Astro shells that mount a matching React island (Dashboard, MachinesPage,
UsersPage, DnsPage, SettingsPage) built on the shadcn-ui component
library. Alpine.js templates in those pages are replaced wholesale;
Alpine still ships as the runtime for the ACL editor's dependencies.
- src/components/ui/*: shadcn primitives (button, card, tabs, select,
dropdown-menu, avatar, badge, input, switch, separator)
- src/components/{dashboard,dns,machines,settings,users}/*: page-level
React shells that consume server props from the Astro parent
- src/components/shell/AppShell.tsx: shared chrome (nav, user menu)
- src/lib/utils.ts: shadcn's cn() helper
- src/lib/auth/session-manager.ts: pluggable SessionStore interface with
Redis (via REDIS_URL) or in-memory backends; both honor absolute
expiration via TTL. In-memory logs a warning that sessions vanish on
restart.
- src/lib/auth/oidc-client.ts, oidc-state.ts: shrink OIDC handlers now
that PKCE + nonce state travels in a signed JWT cookie
- src/pages/api/auth/*: match the simplified handlers
- src/styles/global.css, tailwind.config.mjs, tsconfig.json: shadcn
design tokens and the '@/*' path alias
- docker-compose.local.yml: local dev tweaks
- package.json, pnpm-lock.yaml: shadcn + Radix + ioredis + zod
This commit is contained in:
parent
36c1b18724
commit
09b1bae157
38 changed files with 4966 additions and 3972 deletions
|
|
@ -1,686 +1,25 @@
|
|||
---
|
||||
// 🤠 Heady Machines — Astro shell, React island.
|
||||
|
||||
import { getCollection } from 'astro:content';
|
||||
// Heady Machines Management - Alpine.js/Astro Implementation 🤠
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import Layout from '@/layouts/Layout.astro';
|
||||
import { MachinesPage } from '@/components/machines/MachinesPage';
|
||||
|
||||
// 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',
|
||||
},
|
||||
];
|
||||
const machines = allMachines.map((m) => ({
|
||||
id: m.data.id,
|
||||
name: m.data.name,
|
||||
hostname: m.data.hostname,
|
||||
ip_address: m.data.ip_address,
|
||||
online: m.data.online,
|
||||
expired: m.data.expired,
|
||||
os: m.data.os ?? 'unknown',
|
||||
user: m.data.user,
|
||||
tags: m.data.tags,
|
||||
last_seen: m.data.last_seen,
|
||||
}));
|
||||
---
|
||||
|
||||
<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>
|
||||
<Layout title="Machines" hideChrome={true}>
|
||||
<MachinesPage client:load machines={machines} />
|
||||
</Layout>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue