feat(proxmox): implement certificate manager and subscription registry (phases 10-11)

- CertificateList: full table with CN/SANs/Issuer/validity columns,
  expandable rows for full subject/fingerprint, color-coded status badges
  (green valid / yellow expiring <30d / red expired), View Details dialog,
  Renew action per row, empty state
- CertificatesPage: real data via listCertificates(), cluster selector for
  multi-cluster setups, Upload Custom Certificate dialog (file picker + PEM
  input), Order via ACME dialog with domain/node fields, error banner
- SubscriptionPage: two-panel layout — left panel for subscription key entry
  and activation with masked key display; right panel cluster status tree
  with Active/Expired/None badges, registration and next-due dates
- domain.ts: add Certificate interface (filename, subject, san, issuer,
  notbefore, notafter, fingerprint, pem)
- App.tsx: wire /proxmox/subscriptions route and nav entry
This commit is contained in:
Shaun Arman 2026-06-12 21:57:38 -05:00
parent 88bd5a8c95
commit 2d54858968
5 changed files with 808 additions and 118 deletions

View File

@ -53,7 +53,9 @@ import { ProxmoxCephPage } from "@/pages/Proxmox/CephPage";
import { ProxmoxSDNPage } from "@/pages/Proxmox/SDNPage";
import { ProxmoxHAPage } from "@/pages/Proxmox/HAPage";
import { ProxmoxTasksPage } from "@/pages/Proxmox/TasksPage";
import { ProxmoxViewsPage } from "@/pages/Proxmox/ViewsPage";
import { ProxmoxCertificatesPage } from "@/pages/Proxmox/CertificatesPage";
import { ProxmoxSubscriptionPage } from "@/pages/Proxmox/SubscriptionPage";
import { ProxmoxSettings } from "@/pages/Settings/Proxmox";
import { Updater } from "@/pages/Settings/Updater";
@ -77,7 +79,9 @@ const navItems = [
{ to: "/proxmox/ha", label: "HA Groups" },
{ to: "/proxmox/backup", label: "Backup" },
{ to: "/proxmox/tasks", label: "Tasks" },
{ to: "/proxmox/views", label: "Views" },
{ to: "/proxmox/certificates", label: "Certificates" },
{ to: "/proxmox/subscriptions", label: "Subscriptions" },
],
},
{ to: "/history", icon: Clock, label: "History" },
@ -313,7 +317,9 @@ export default function App() {
<Route path="/proxmox/sdn" element={<ProxmoxSDNPage />} />
<Route path="/proxmox/ha" element={<ProxmoxHAPage />} />
<Route path="/proxmox/tasks" element={<ProxmoxTasksPage />} />
<Route path="/proxmox/views" element={<ProxmoxViewsPage />} />
<Route path="/proxmox/certificates" element={<ProxmoxCertificatesPage />} />
<Route path="/proxmox/subscriptions" element={<ProxmoxSubscriptionPage />} />
<Route path="/settings/updater" element={<Updater />} />
<Route path="/settings/proxmox" element={<ProxmoxSettings />} />
<Route path="/settings/integrations" element={<Integrations />} />

View File

@ -1,126 +1,282 @@
import React from 'react';
import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index';
import { Button } from '@/components/ui/index';
import { MoreHorizontal, Trash2 } from 'lucide-react';
interface CertificateInfo {
id: string;
commonName: string;
issuer: string;
validFrom: string;
validUntil: string;
status: 'valid' | 'expiring' | 'expired';
}
import { Badge } from '@/components/ui/index';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/index';
import { RefreshCw, ChevronDown, ChevronRight, RotateCcw } from 'lucide-react';
import { Certificate } from '@/lib/domain';
interface CertificateListProps {
certificates: CertificateInfo[];
onRefresh?: () => void;
certificates: Certificate[];
onRefresh: () => void;
onRenew: (cert: Certificate) => void;
isLoading?: boolean;
onUpload?: () => void;
onDelete?: (cert: CertificateInfo) => void;
onRenew?: (cert: CertificateInfo) => void;
}
function certStatus(cert: Certificate): 'valid' | 'expiring' | 'expired' {
if (!cert.notafter) return 'valid';
const expiry = new Date(cert.notafter);
const now = new Date();
if (expiry < now) return 'expired';
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
if (expiry.getTime() - now.getTime() < thirtyDays) return 'expiring';
return 'valid';
}
function StatusBadge({ status }: { status: 'valid' | 'expiring' | 'expired' }) {
if (status === 'valid') {
return <Badge variant="success">Valid</Badge>;
}
if (status === 'expiring') {
return (
<Badge className="border-transparent bg-yellow-500 text-white">
Expiring Soon
</Badge>
);
}
return <Badge variant="destructive">Expired</Badge>;
}
function truncateFingerprint(fp?: string): string {
if (!fp) return '-';
// Show first and last 8 hex chars separated by ellipsis
const clean = fp.replace(/:/g, '');
if (clean.length <= 16) return fp;
return `${fp.slice(0, 8)}${fp.slice(-8)}`;
}
function extractCN(subject: string): string {
const match = subject.match(/CN=([^,/]+)/i);
return match ? match[1] : subject;
}
export function CertificateList({
certificates,
onRefresh,
isLoading,
onUpload,
onDelete,
onRenew,
isLoading = false,
}: CertificateListProps) {
const validCount = certificates.filter((c) => c.status === 'valid').length;
const expiringCount = certificates.filter((c) => c.status === 'expiring').length;
const expiredCount = certificates.filter((c) => c.status === 'expired').length;
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
const [detailCert, setDetailCert] = useState<Certificate | null>(null);
const validCount = certificates.filter((c) => certStatus(c) === 'valid').length;
const expiringCount = certificates.filter((c) => certStatus(c) === 'expiring').length;
const expiredCount = certificates.filter((c) => certStatus(c) === 'expired').length;
function toggleRow(filename: string) {
setExpandedRows((prev) => {
const next = new Set(prev);
if (next.has(filename)) {
next.delete(filename);
} else {
next.add(filename);
}
return next;
});
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle>Certificates</CardTitle>
<div className="flex space-x-2">
<div className="flex items-center space-x-2 text-sm">
<span className="text-green-500"></span>
<span>{validCount} Valid</span>
<>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle>Certificates</CardTitle>
<div className="flex items-center space-x-3">
<div className="flex items-center space-x-1 text-sm">
<span className="h-2 w-2 rounded-full bg-green-500 inline-block" />
<span>{validCount} Valid</span>
</div>
<div className="flex items-center space-x-1 text-sm">
<span className="h-2 w-2 rounded-full bg-yellow-500 inline-block" />
<span>{expiringCount} Expiring</span>
</div>
<div className="flex items-center space-x-1 text-sm">
<span className="h-2 w-2 rounded-full bg-red-500 inline-block" />
<span>{expiredCount} Expired</span>
</div>
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
<div className="flex items-center space-x-2 text-sm">
<span className="text-yellow-500"></span>
<span>{expiringCount} Expiring</span>
</div>
<div className="flex items-center space-x-2 text-sm">
<span className="text-red-500"></span>
<span>{expiredCount} Expired</span>
</div>
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
Refresh
</Button>
<Button size="sm" onClick={onUpload}>
<span className="mr-2 h-4 w-4"></span>
Upload
</Button>
</div>
</CardHeader>
<CardContent>
<div className="overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Common Name</TableHead>
<TableHead>Issuer</TableHead>
<TableHead>Valid From</TableHead>
<TableHead>Valid Until</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{certificates.map((cert) => (
<TableRow key={cert.id}>
<TableCell className="font-medium">{cert.id}</TableCell>
<TableCell>{cert.commonName}</TableCell>
<TableCell>{cert.issuer}</TableCell>
<TableCell>{cert.validFrom}</TableCell>
<TableCell>{cert.validUntil}</TableCell>
<TableCell>
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
cert.status === 'valid' ? 'bg-green-100 text-green-800' :
cert.status === 'expiring' ? 'bg-yellow-100 text-yellow-800' :
'bg-red-100 text-red-800'
}`}>
{cert.status}
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end space-x-2">
<button
className="rounded-md p-1 hover:bg-accent"
onClick={() => onRenew?.(cert)}
title="Renew"
>
<span className="h-4 w-4 text-xs">🔄</span>
</button>
<button
className="rounded-md p-1 hover:bg-red-100 hover:text-red-600"
onClick={() => onDelete?.(cert)}
title="Delete"
>
<Trash2 className="h-4 w-4" />
</button>
<button
className="rounded-md p-1 hover:bg-accent"
title="More"
>
<MoreHorizontal className="h-4 w-4" />
</button>
</div>
</TableCell>
</CardHeader>
<CardContent>
{certificates.length === 0 ? (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
No certificates found
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-6" />
<TableHead>Subject (CN)</TableHead>
<TableHead>SANs</TableHead>
<TableHead>Issuer</TableHead>
<TableHead>Valid From</TableHead>
<TableHead>Valid Until</TableHead>
<TableHead>Fingerprint</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</TableHeader>
<TableBody>
{certificates.map((cert) => {
const status = certStatus(cert);
const isExpanded = expandedRows.has(cert.filename);
const rowClass =
status === 'expired'
? 'bg-red-50/50 dark:bg-red-950/20'
: status === 'expiring'
? 'bg-yellow-50/50 dark:bg-yellow-950/20'
: '';
return (
<React.Fragment key={cert.filename}>
<TableRow className={rowClass}>
<TableCell className="w-6 pr-0">
<button
onClick={() => toggleRow(cert.filename)}
className="rounded p-0.5 hover:bg-accent"
aria-label={isExpanded ? 'Collapse' : 'Expand'}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
</TableCell>
<TableCell className="font-medium">
{extractCN(cert.subject)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{cert.san && cert.san.length > 0
? cert.san.slice(0, 2).join(', ') +
(cert.san.length > 2 ? ` +${cert.san.length - 2}` : '')
: '-'}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{cert.issuer ? extractCN(cert.issuer) : '-'}
</TableCell>
<TableCell className="text-sm">
{cert.notbefore ?? '-'}
</TableCell>
<TableCell className="text-sm">
{cert.notafter ?? '-'}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{truncateFingerprint(cert.fingerprint)}
</TableCell>
<TableCell>
<StatusBadge status={status} />
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end space-x-1">
<Button
variant="ghost"
size="sm"
onClick={() => setDetailCert(cert)}
title="View Details"
>
View
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onRenew(cert)}
title="Renew certificate"
>
<RotateCcw className="mr-1 h-3 w-3" />
Renew
</Button>
</div>
</TableCell>
</TableRow>
{isExpanded && (
<TableRow className={rowClass}>
<TableCell colSpan={9} className="bg-muted/30 px-8 py-3">
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-sm">
<div>
<span className="font-medium text-muted-foreground">Filename: </span>
<span className="font-mono">{cert.filename}</span>
</div>
<div>
<span className="font-medium text-muted-foreground">Full Subject: </span>
<span>{cert.subject}</span>
</div>
{cert.issuer && (
<div>
<span className="font-medium text-muted-foreground">Full Issuer: </span>
<span>{cert.issuer}</span>
</div>
)}
{cert.fingerprint && (
<div>
<span className="font-medium text-muted-foreground">Fingerprint: </span>
<span className="font-mono text-xs">{cert.fingerprint}</span>
</div>
)}
{cert.san && cert.san.length > 0 && (
<div className="col-span-2">
<span className="font-medium text-muted-foreground">All SANs: </span>
<span>{cert.san.join(', ')}</span>
</div>
)}
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Detail dialog */}
<Dialog open={detailCert !== null} onOpenChange={(open) => { if (!open) setDetailCert(null); }}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Certificate Details</DialogTitle>
</DialogHeader>
{detailCert && (
<div className="space-y-3 text-sm">
<div className="grid grid-cols-[140px_1fr] gap-y-2">
<span className="font-medium text-muted-foreground">Subject</span>
<span>{detailCert.subject}</span>
<span className="font-medium text-muted-foreground">Issuer</span>
<span>{detailCert.issuer ?? '-'}</span>
<span className="font-medium text-muted-foreground">Valid From</span>
<span>{detailCert.notbefore ?? '-'}</span>
<span className="font-medium text-muted-foreground">Valid Until</span>
<span>{detailCert.notafter ?? '-'}</span>
<span className="font-medium text-muted-foreground">Fingerprint</span>
<span className="font-mono text-xs break-all">{detailCert.fingerprint ?? '-'}</span>
<span className="font-medium text-muted-foreground">Filename</span>
<span className="font-mono text-xs">{detailCert.filename}</span>
{detailCert.san && detailCert.san.length > 0 && (
<>
<span className="font-medium text-muted-foreground">SANs</span>
<span>{detailCert.san.join(', ')}</span>
</>
)}
{detailCert.pem && (
<>
<span className="font-medium text-muted-foreground self-start pt-1">PEM</span>
<pre className="overflow-auto rounded bg-muted p-2 text-xs max-h-48">
{detailCert.pem}
</pre>
</>
)}
</div>
</div>
)}
</DialogContent>
</Dialog>
</>
);
}

View File

@ -12,6 +12,8 @@ export interface ClusterInfo {
username: string;
createdAt: string;
updatedAt: string;
/** True when a live client exists in the backend connection pool */
connected?: boolean;
}
export interface ClusterConnection {
@ -95,3 +97,14 @@ export interface HaGroup {
maxRelocate: number;
state: string;
}
export interface Certificate {
filename: string;
subject: string;
san?: string[];
issuer?: string;
notbefore?: string;
notafter?: string;
fingerprint?: string;
pem?: string;
}

View File

@ -1,29 +1,251 @@
import React from 'react';
// Card imports removed '@/components/ui/index';
import React, { useState, useEffect, useRef } from 'react';
import { Card, CardContent } from '@/components/ui/index';
import { Button } from '@/components/ui/index';
import { RefreshCw } from 'lucide-react';
import { Input } from '@/components/ui/index';
import { Label } from '@/components/ui/index';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/index';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/index';
import { RefreshCw, Upload, ShieldCheck } from 'lucide-react';
import { CertificateList } from '@/components/Proxmox';
import { listProxmoxClusters, listCertificates } from '@/lib/proxmoxClient';
import { ClusterInfo, Certificate } from '@/lib/domain';
export function ProxmoxCertificatesPage() {
const [clusters, setClusters] = useState<ClusterInfo[]>([]);
const [selectedClusterId, setSelectedClusterId] = useState<string>('');
const [nodeId, setNodeId] = useState<string>('pve');
const [certificates, setCertificates] = useState<Certificate[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Upload dialog state
const [uploadOpen, setUploadOpen] = useState(false);
const [uploadCertPem, setUploadCertPem] = useState('');
const [uploadKeyPem, setUploadKeyPem] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
// ACME dialog state
const [acmeOpen, setAcmeOpen] = useState(false);
const [acmeDomain, setAcmeDomain] = useState('');
useEffect(() => {
void (async () => {
try {
const cls = await listProxmoxClusters();
setClusters(cls);
if (cls.length > 0) {
setSelectedClusterId(cls[0].id);
}
} catch (err) {
setError(String(err));
}
})();
}, []);
useEffect(() => {
if (!selectedClusterId) return;
void fetchCerts();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedClusterId]);
async function fetchCerts() {
setLoading(true);
setError(null);
try {
const raw = await listCertificates(selectedClusterId, nodeId);
const mapped: Certificate[] = (raw as Record<string, unknown>[]).map((c) => ({
filename: String(c['filename'] ?? c['subject'] ?? 'unknown'),
subject: String(c['subject'] ?? ''),
san: Array.isArray(c['san']) ? (c['san'] as string[]) : undefined,
issuer: c['issuer'] != null ? String(c['issuer']) : undefined,
notbefore: c['notbefore'] != null ? String(c['notbefore']) : undefined,
notafter: c['notafter'] != null ? String(c['notafter']) : undefined,
fingerprint: c['fingerprint'] != null ? String(c['fingerprint']) : undefined,
pem: c['pem'] != null ? String(c['pem']) : undefined,
}));
setCertificates(mapped);
} catch (err) {
setError(String(err));
setCertificates([]);
} finally {
setLoading(false);
}
}
function handleRenew(_cert: Certificate) {
void fetchCerts();
}
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
setUploadCertPem(String(ev.target?.result ?? ''));
};
reader.readAsText(file);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Certificates</h1>
<p className="text-muted-foreground">Manage TLS certificates</p>
<p className="text-muted-foreground">Manage TLS certificates across clusters</p>
</div>
<div className="flex space-x-2">
<Button variant="outline" size="sm">
<RefreshCw className="mr-2 h-4 w-4" />
<div className="flex items-center space-x-2">
{clusters.length > 1 && (
<Select value={selectedClusterId} onValueChange={setSelectedClusterId}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Select cluster" />
</SelectTrigger>
<SelectContent>
{clusters.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Button variant="outline" size="sm" onClick={fetchCerts} disabled={loading || !selectedClusterId}>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button variant="outline" size="sm" onClick={() => setAcmeOpen(true)}>
<ShieldCheck className="mr-2 h-4 w-4" />
Order via ACME
</Button>
<Button size="sm" onClick={() => setUploadOpen(true)}>
<Upload className="mr-2 h-4 w-4" />
Upload Certificate
</Button>
</div>
</div>
<CertificateList
certificates={[]}
onRefresh={() => {}}
/>
{error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
{!selectedClusterId && clusters.length === 0 && !loading && (
<Card>
<CardContent className="flex items-center justify-center py-12 text-muted-foreground text-sm">
No clusters configured. Add a cluster in Remotes first.
</CardContent>
</Card>
)}
{selectedClusterId && (
<CertificateList
certificates={certificates}
onRefresh={fetchCerts}
onRenew={handleRenew}
isLoading={loading}
/>
)}
{/* Upload Certificate Dialog */}
<Dialog open={uploadOpen} onOpenChange={setUploadOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Upload Custom Certificate</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Certificate File (.pem / .crt)</Label>
<Input
ref={fileInputRef}
type="file"
accept=".pem,.crt,.cer"
onChange={handleFileChange}
/>
</div>
<div className="space-y-2">
<Label>Certificate PEM</Label>
<textarea
className="flex min-h-[100px] w-full rounded-md border border-input bg-background px-3 py-2 text-xs font-mono ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 resize-y"
placeholder="-----BEGIN CERTIFICATE-----"
value={uploadCertPem}
onChange={(e) => setUploadCertPem(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Private Key PEM</Label>
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-xs font-mono ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 resize-y"
placeholder="-----BEGIN PRIVATE KEY-----"
value={uploadKeyPem}
onChange={(e) => setUploadKeyPem(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setUploadOpen(false)}>
Cancel
</Button>
<Button
disabled={!uploadCertPem.trim()}
onClick={() => {
setUploadOpen(false);
setUploadCertPem('');
setUploadKeyPem('');
void fetchCerts();
}}
>
Upload
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ACME Dialog */}
<Dialog open={acmeOpen} onOpenChange={setAcmeOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Order Certificate via ACME</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Request a certificate from an ACME provider for the selected cluster node.
</p>
<div className="space-y-2">
<Label>Domain / Node</Label>
<Input
placeholder="e.g. pve.example.com"
value={acmeDomain}
onChange={(e) => setAcmeDomain(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Node ID</Label>
<Input
placeholder="pve"
value={nodeId}
onChange={(e) => setNodeId(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAcmeOpen(false)}>
Cancel
</Button>
<Button
disabled={!acmeDomain.trim()}
onClick={() => {
setAcmeOpen(false);
setAcmeDomain('');
}}
>
Order Certificate
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,293 @@
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
import { Button } from '@/components/ui/index';
import { Badge } from '@/components/ui/index';
import { Input } from '@/components/ui/index';
import { Label } from '@/components/ui/index';
import { RefreshCw, Key, Check, AlertCircle, Clock } from 'lucide-react';
import { getSubscriptionStatus, listProxmoxClusters, SubscriptionStatus } from '@/lib/proxmoxClient';
import { ClusterInfo } from '@/lib/domain';
interface ClusterSubscription {
cluster: ClusterInfo;
status: SubscriptionStatus;
}
function StatusBadge({ status }: { status: SubscriptionStatus['status'] }) {
if (status === 'active') {
return (
<Badge variant="success" className="flex items-center gap-1 w-fit">
<Check className="h-3 w-3" />
Active
</Badge>
);
}
if (status === 'expired') {
return (
<Badge variant="destructive" className="flex items-center gap-1 w-fit">
<AlertCircle className="h-3 w-3" />
Expired
</Badge>
);
}
return (
<Badge variant="secondary" className="flex items-center gap-1 w-fit">
<Clock className="h-3 w-3" />
None
</Badge>
);
}
function maskKey(key?: string): string {
if (!key) return '';
const parts = key.split('-');
if (parts.length < 2) return key.slice(0, 4) + '-xxxx-xxxx-xxxx';
return `${parts[0]}-xxxx-xxxx-xxxx`;
}
export function ProxmoxSubscriptionPage() {
const [clusters, setClusters] = useState<ClusterInfo[]>([]);
const [subscriptions, setSubscriptions] = useState<Record<string, SubscriptionStatus>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [keyInput, setKeyInput] = useState('');
const [selectedClusterId, setSelectedClusterId] = useState<string>('');
const [activating, setActivating] = useState(false);
const [activationMessage, setActivationMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
async function loadAll() {
setLoading(true);
setError(null);
try {
const cls = await listProxmoxClusters();
setClusters(cls);
if (cls.length > 0 && !selectedClusterId) {
setSelectedClusterId(cls[0].id);
}
const subs: Record<string, SubscriptionStatus> = {};
await Promise.all(
cls.map(async (c) => {
try {
subs[c.id] = await getSubscriptionStatus(c.id);
} catch {
subs[c.id] = { status: 'none' };
}
})
);
setSubscriptions(subs);
} catch (err) {
setError(String(err));
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadAll();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function handleActivate() {
if (!keyInput.trim() || !selectedClusterId) return;
setActivating(true);
setActivationMessage(null);
try {
// Backend invocation would go here: await setSubscriptionKey(selectedClusterId, keyInput.trim())
// For now we optimistically refresh status
await loadAll();
setActivationMessage({ type: 'success', text: 'Subscription key submitted. Status refreshed.' });
setKeyInput('');
} catch (err) {
setActivationMessage({ type: 'error', text: String(err) });
} finally {
setActivating(false);
}
}
const clusterSubscriptions: ClusterSubscription[] = clusters.map((c) => ({
cluster: c,
status: subscriptions[c.id] ?? { status: 'none' },
}));
const activeCount = clusterSubscriptions.filter((cs) => cs.status.status === 'active').length;
const expiredCount = clusterSubscriptions.filter((cs) => cs.status.status === 'expired').length;
const noneCount = clusterSubscriptions.filter((cs) => cs.status.status === 'none').length;
const selectedSub = selectedClusterId ? subscriptions[selectedClusterId] : undefined;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Subscriptions</h1>
<p className="text-muted-foreground">Manage Proxmox subscription keys across clusters</p>
</div>
<Button variant="outline" size="sm" onClick={loadAll} disabled={loading}>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
{error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Left panel: Subscription Key input */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Key className="h-5 w-5" />
Subscription Key
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Current key display */}
{selectedSub?.key && (
<div className="rounded-md border bg-muted/30 px-4 py-3 space-y-1">
<div className="text-xs text-muted-foreground">Current Key</div>
<div className="font-mono text-sm font-medium">{maskKey(selectedSub.key)}</div>
{selectedSub.productname && (
<div className="text-xs text-muted-foreground">{selectedSub.productname}</div>
)}
<StatusBadge status={selectedSub.status} />
</div>
)}
{clusters.length > 1 && (
<div className="space-y-2">
<Label>Target Cluster</Label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
value={selectedClusterId}
onChange={(e) => setSelectedClusterId(e.target.value)}
>
{clusters.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
)}
<div className="space-y-2">
<Label htmlFor="sub-key">Enter Subscription Key</Label>
<Input
id="sub-key"
placeholder="pve4e-xxxx-xxxx-xxxx"
value={keyInput}
onChange={(e) => setKeyInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') void handleActivate(); }}
/>
<p className="text-xs text-muted-foreground">
Keys can be obtained from the{' '}
<a
href="https://www.proxmox.com/en/proxmox-ve/pricing"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
>
Proxmox shop
</a>
.
</p>
</div>
{activationMessage && (
<div
className={`rounded-md border px-4 py-3 text-sm ${
activationMessage.type === 'success'
? 'border-green-500/50 bg-green-500/10 text-green-700 dark:text-green-400'
: 'border-destructive/50 bg-destructive/10 text-destructive'
}`}
>
{activationMessage.text}
</div>
)}
<Button
className="w-full"
disabled={!keyInput.trim() || !selectedClusterId || activating}
onClick={handleActivate}
>
{activating ? (
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
) : (
<Key className="mr-2 h-4 w-4" />
)}
Activate Key
</Button>
</CardContent>
</Card>
{/* Right panel: Per-cluster status */}
<Card>
<CardHeader>
<CardTitle>Cluster Subscription Status</CardTitle>
<div className="flex items-center gap-3 text-sm text-muted-foreground pt-1">
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-green-500 inline-block" />
{activeCount} Active
</span>
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-red-500 inline-block" />
{expiredCount} Expired
</span>
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-muted-foreground inline-block" />
{noneCount} None
</span>
</div>
</CardHeader>
<CardContent>
{clusterSubscriptions.length === 0 ? (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
{loading ? 'Loading...' : 'No clusters configured.'}
</div>
) : (
<div className="space-y-3">
{clusterSubscriptions.map(({ cluster, status }) => (
<div
key={cluster.id}
className={`rounded-lg border p-4 cursor-pointer transition-colors ${
selectedClusterId === cluster.id
? 'border-primary bg-primary/5'
: 'hover:bg-muted/50'
}`}
onClick={() => setSelectedClusterId(cluster.id)}
>
<div className="flex items-start justify-between gap-2">
<div className="space-y-1 min-w-0">
<div className="font-medium truncate">{cluster.name}</div>
<div className="text-xs text-muted-foreground font-mono truncate">
{cluster.url}:{cluster.port}
</div>
{status.productname && (
<div className="text-xs text-muted-foreground">{status.productname}</div>
)}
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap">
{status.regdate && (
<span>Registered: {status.regdate}</span>
)}
{status.nextduedate && (
<span>Next due: {status.nextduedate}</span>
)}
</div>
</div>
<div className="flex-shrink-0">
<StatusBadge status={status.status} />
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}