Watch
1
0
Fork
You've already forked RedFlag
0

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:
Fimeg 2026-06-15 21:12:33 -04:00
commit 865d82ded0
2 changed files with 76 additions and 26 deletions

View file

@ -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

View file

@ -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">