web: wire Agents <-> Enrollment, back on the steel-blue theme
deep-link a chosen key into the install panel, drop the emoji.
This commit is contained in:
parent
99d97a07ee
commit
865d82ded0
2 changed files with 76 additions and 26 deletions
|
|
@ -21,6 +21,7 @@ import {
|
|||
Power,
|
||||
MonitorPlay,
|
||||
Upload,
|
||||
UserPlus,
|
||||
} from 'lucide-react';
|
||||
import { useFilterUrl, buildFilterPills } from '@/hooks/useFilterUrl';
|
||||
import {
|
||||
|
|
@ -1133,11 +1134,21 @@ const Agents: React.FC = () => {
|
|||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Agents</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Monitor and manage your connected agents
|
||||
</p>
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Agents</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Monitor and manage your connected agents
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/settings/agents?install=1')}
|
||||
className="btn btn-primary shrink-0"
|
||||
title="Enroll a new agent — pick a registration key and copy the install one-liner"
|
||||
>
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
Enroll agent
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<FilterBar
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Shield,
|
||||
Plus,
|
||||
|
|
@ -84,7 +84,7 @@ type StatusFilter = 'all' | 'active' | 'used' | 'expired' | 'revoked';
|
|||
|
||||
const STATUS_DOT: Record<string, string> = {
|
||||
active: 'bg-green-500',
|
||||
used: 'bg-blue-500',
|
||||
used: 'bg-primary-500',
|
||||
expired: 'bg-amber-500',
|
||||
revoked: 'bg-gray-400',
|
||||
};
|
||||
|
|
@ -98,6 +98,7 @@ const tokenLabel = (t: RegistrationToken): string => t.label || `token ${t.id.sl
|
|||
|
||||
const AgentsEnrollment: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const confirm = useConfirm();
|
||||
|
||||
// Key list — fetch all (incl. revoked/expired) so the operator sees the full
|
||||
|
|
@ -182,6 +183,23 @@ const AgentsEnrollment: React.FC = () => {
|
|||
}
|
||||
}, [availableTokens, installTokenId]);
|
||||
|
||||
// Deep-link from elsewhere (e.g. the Agents page): `?install=1` opens the
|
||||
// install panel; `?install=<tokenId>` opens it pre-seeded with that key so
|
||||
// the operator lands on the exact key they came to enroll with. The guard
|
||||
// effect above drops the seed if the key turns out unavailable. Strip the
|
||||
// param afterward so a refresh or back-nav doesn't re-trigger it.
|
||||
const deepLinkHandled = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
if (deepLinkHandled.current) return;
|
||||
const inst = searchParams.get('install');
|
||||
if (!inst) return;
|
||||
deepLinkHandled.current = true;
|
||||
setShowInstall(true);
|
||||
if (inst !== '1') setInstallTokenId(inst);
|
||||
searchParams.delete('install');
|
||||
setSearchParams(searchParams, { replace: true });
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
const { data: boundAgentsData, isLoading: boundAgentsLoading } = useBoundAgents(selectedTokenId || '');
|
||||
const revokeAgentMutation = useRevokeAgent(selectedTokenId || '');
|
||||
|
||||
|
|
@ -251,6 +269,14 @@ const AgentsEnrollment: React.FC = () => {
|
|||
cleanupTokens.mutate(undefined, { onSuccess: () => refetch() });
|
||||
};
|
||||
|
||||
// Jump straight from a selected key into the install flow, pre-seeded with
|
||||
// that key — no re-picking it in the install dropdown.
|
||||
const installWithKey = (tokenId: string) => {
|
||||
setInstallTokenId(tokenId);
|
||||
setShowInstall(true);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string, id: string) => {
|
||||
if (!text || !text.trim()) {
|
||||
toast.error('Nothing to copy');
|
||||
|
|
@ -310,7 +336,7 @@ const AgentsEnrollment: React.FC = () => {
|
|||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreate((v) => !v)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New key
|
||||
|
|
@ -331,7 +357,7 @@ const AgentsEnrollment: React.FC = () => {
|
|||
{availableTokens.length === 0 ? (
|
||||
<div className="text-sm text-gray-600 bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
No registration keys with available seats.{' '}
|
||||
<button onClick={() => setShowCreate(true)} className="text-blue-600 hover:text-blue-800 underline">
|
||||
<button onClick={() => setShowCreate(true)} className="text-primary-600 hover:text-primary-800 underline">
|
||||
Create a key
|
||||
</button>{' '}
|
||||
first — existing agents are unaffected.
|
||||
|
|
@ -343,7 +369,7 @@ const AgentsEnrollment: React.FC = () => {
|
|||
<select
|
||||
value={installTokenId}
|
||||
onChange={(e) => setInstallTokenId(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[320px]"
|
||||
className="px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-primary-500 min-w-[320px]"
|
||||
>
|
||||
<option value="">— Select a key ({availableTokens.length} available) —</option>
|
||||
{availableTokens.map((t) => (
|
||||
|
|
@ -365,12 +391,12 @@ const AgentsEnrollment: React.FC = () => {
|
|||
disabled={!p.available}
|
||||
className={cn(
|
||||
'p-4 border-2 rounded-lg text-left transition-all disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
selected ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300',
|
||||
selected ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Icon className={cn('w-6 h-6', p.id === 'linux' ? 'text-orange-600' : p.id === 'windows' ? 'text-blue-600' : 'text-gray-600')} />
|
||||
{selected && <CheckCircle className="w-4 h-4 text-blue-600" />}
|
||||
<Icon className={cn('w-6 h-6', p.id === 'linux' ? 'text-orange-600' : p.id === 'windows' ? 'text-primary-600' : 'text-gray-600')} />
|
||||
{selected && <CheckCircle className="w-4 h-4 text-primary-600" />}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900">{p.name}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">{p.description}</div>
|
||||
|
|
@ -383,7 +409,7 @@ const AgentsEnrollment: React.FC = () => {
|
|||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Installation command
|
||||
{installPlatform === 'windows' && <span className="text-blue-600"> (Run in PowerShell as Administrator)</span>}
|
||||
{installPlatform === 'windows' && <span className="text-primary-600"> (Run in PowerShell as Administrator)</span>}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
|
||||
|
|
@ -422,7 +448,7 @@ const AgentsEnrollment: React.FC = () => {
|
|||
value={formData.label}
|
||||
onChange={(e) => setFormData({ ...formData, label: e.target.value })}
|
||||
placeholder="e.g., Production Servers"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -430,7 +456,7 @@ const AgentsEnrollment: React.FC = () => {
|
|||
<select
|
||||
value={formData.expires_in}
|
||||
onChange={(e) => setFormData({ ...formData, expires_in: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="24h">24 hours</option>
|
||||
<option value="72h">3 days</option>
|
||||
|
|
@ -446,7 +472,7 @@ const AgentsEnrollment: React.FC = () => {
|
|||
max="100"
|
||||
value={formData.max_seats || 1}
|
||||
onChange={(e) => setFormData({ ...formData, max_seats: parseInt(e.target.value) || 1 })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Number of agents that can enroll with this key</p>
|
||||
</div>
|
||||
|
|
@ -455,7 +481,7 @@ const AgentsEnrollment: React.FC = () => {
|
|||
<button
|
||||
type="submit"
|
||||
disabled={createToken.isPending}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{createToken.isPending ? 'Creating...' : 'Create key'}
|
||||
</button>
|
||||
|
|
@ -526,7 +552,7 @@ const AgentsEnrollment: React.FC = () => {
|
|||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 mb-6">
|
||||
<StatCard label="Total keys" value={stats.total_tokens} icon={Shield} />
|
||||
<StatCard label="Active" value={stats.active_tokens} valueClass="text-green-600" icon={CheckCircle} />
|
||||
<StatCard label="Used" value={stats.used_tokens} valueClass="text-blue-600" icon={Users} />
|
||||
<StatCard label="Used" value={stats.used_tokens} valueClass="text-primary-600" icon={Users} />
|
||||
<StatCard label="Expired" value={stats.expired_tokens} valueClass="text-gray-600" icon={Clock} />
|
||||
<StatCard
|
||||
label="Seats used"
|
||||
|
|
@ -576,7 +602,7 @@ const AgentsEnrollment: React.FC = () => {
|
|||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||
<div className="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
|
||||
<p className="mt-2 text-sm text-gray-500">Loading keys...</p>
|
||||
</div>
|
||||
) : filteredTokens.length === 0 ? (
|
||||
|
|
@ -598,7 +624,7 @@ const AgentsEnrollment: React.FC = () => {
|
|||
}}
|
||||
className={cn(
|
||||
'w-full text-left px-4 py-3 border-b border-gray-100 transition-colors',
|
||||
selected ? 'bg-blue-50 border-l-2 border-l-blue-500' : 'hover:bg-gray-50 border-l-2 border-l-transparent',
|
||||
selected ? 'bg-primary-50 border-l-2 border-l-primary-500' : 'hover:bg-gray-50 border-l-2 border-l-transparent',
|
||||
)}
|
||||
>
|
||||
<div className={cn('font-medium truncate', t.status === 'revoked' ? 'text-gray-400 line-through' : 'text-gray-900')}>
|
||||
|
|
@ -648,6 +674,16 @@ const AgentsEnrollment: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{selectedToken.status === 'active' && selectedToken.seats_used < selectedToken.max_seats && (
|
||||
<button
|
||||
onClick={() => installWithKey(selectedToken.id)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm text-primary-700 bg-primary-50 border border-primary-200 rounded-md hover:bg-primary-100"
|
||||
title="Open the install panel pre-seeded with this key"
|
||||
>
|
||||
<Terminal className="w-4 h-4" />
|
||||
Install with this key
|
||||
</button>
|
||||
)}
|
||||
{selectedToken.status === 'active' && (
|
||||
<button
|
||||
onClick={() => handleRevokeToken(selectedToken.id, tokenLabel(selectedToken))}
|
||||
|
|
@ -680,7 +716,7 @@ const AgentsEnrollment: React.FC = () => {
|
|||
<code>{revealToken ? selectedToken.token : `rk_${'•'.repeat(20)}${selectedToken.token.slice(-4)}`}</code>
|
||||
<button
|
||||
onClick={() => setRevealToken((v) => !v)}
|
||||
className="text-xs text-blue-600 hover:text-blue-800"
|
||||
className="text-xs text-primary-600 hover:text-primary-800"
|
||||
>
|
||||
{revealToken ? 'hide' : 'reveal'}
|
||||
</button>
|
||||
|
|
@ -751,7 +787,7 @@ const AgentsEnrollment: React.FC = () => {
|
|||
|
||||
{boundAgentsLoading ? (
|
||||
<div className="py-6 text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
|
||||
<div className="inline-block animate-spin rounded-full h-5 w-5 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
) : boundAgents.length === 0 ? (
|
||||
<div className="py-8 text-center bg-gray-50 rounded-lg border border-gray-200">
|
||||
|
|
@ -777,7 +813,7 @@ const AgentsEnrollment: React.FC = () => {
|
|||
return (
|
||||
<tr key={ba.agent_id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-2.5">
|
||||
<Link to={`/agents/${ba.agent_id}`} className="font-mono text-sm text-blue-600 hover:text-blue-800">
|
||||
<Link to={`/agents/${ba.agent_id}`} className="font-mono text-sm text-primary-600 hover:text-primary-800">
|
||||
{ba.hostname}
|
||||
</Link>
|
||||
</td>
|
||||
|
|
@ -812,10 +848,13 @@ const AgentsEnrollment: React.FC = () => {
|
|||
|
||||
{/* Signing keys — preserved capability from the old Agent Management page */}
|
||||
<div className="border-t border-gray-200 pt-5">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">🔑 Server signing key</h3>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3 inline-flex items-center gap-1.5">
|
||||
<Key className="w-4 h-4 text-gray-500" />
|
||||
Server signing key
|
||||
</h3>
|
||||
{isLoadingServerKeySecurity ? (
|
||||
<div className="py-3 text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
|
||||
<div className="inline-block animate-spin rounded-full h-5 w-5 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
) : serverKeySecurity?.has_private_key ? (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
|
|
|
|||
Loading…
Reference in a new issue