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:
parent
88bd5a8c95
commit
2d54858968
@ -53,7 +53,9 @@ import { ProxmoxCephPage } from "@/pages/Proxmox/CephPage";
|
|||||||
import { ProxmoxSDNPage } from "@/pages/Proxmox/SDNPage";
|
import { ProxmoxSDNPage } from "@/pages/Proxmox/SDNPage";
|
||||||
import { ProxmoxHAPage } from "@/pages/Proxmox/HAPage";
|
import { ProxmoxHAPage } from "@/pages/Proxmox/HAPage";
|
||||||
import { ProxmoxTasksPage } from "@/pages/Proxmox/TasksPage";
|
import { ProxmoxTasksPage } from "@/pages/Proxmox/TasksPage";
|
||||||
|
import { ProxmoxViewsPage } from "@/pages/Proxmox/ViewsPage";
|
||||||
import { ProxmoxCertificatesPage } from "@/pages/Proxmox/CertificatesPage";
|
import { ProxmoxCertificatesPage } from "@/pages/Proxmox/CertificatesPage";
|
||||||
|
import { ProxmoxSubscriptionPage } from "@/pages/Proxmox/SubscriptionPage";
|
||||||
import { ProxmoxSettings } from "@/pages/Settings/Proxmox";
|
import { ProxmoxSettings } from "@/pages/Settings/Proxmox";
|
||||||
import { Updater } from "@/pages/Settings/Updater";
|
import { Updater } from "@/pages/Settings/Updater";
|
||||||
|
|
||||||
@ -77,7 +79,9 @@ const navItems = [
|
|||||||
{ to: "/proxmox/ha", label: "HA Groups" },
|
{ to: "/proxmox/ha", label: "HA Groups" },
|
||||||
{ to: "/proxmox/backup", label: "Backup" },
|
{ to: "/proxmox/backup", label: "Backup" },
|
||||||
{ to: "/proxmox/tasks", label: "Tasks" },
|
{ to: "/proxmox/tasks", label: "Tasks" },
|
||||||
|
{ to: "/proxmox/views", label: "Views" },
|
||||||
{ to: "/proxmox/certificates", label: "Certificates" },
|
{ to: "/proxmox/certificates", label: "Certificates" },
|
||||||
|
{ to: "/proxmox/subscriptions", label: "Subscriptions" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ to: "/history", icon: Clock, label: "History" },
|
{ to: "/history", icon: Clock, label: "History" },
|
||||||
@ -313,7 +317,9 @@ export default function App() {
|
|||||||
<Route path="/proxmox/sdn" element={<ProxmoxSDNPage />} />
|
<Route path="/proxmox/sdn" element={<ProxmoxSDNPage />} />
|
||||||
<Route path="/proxmox/ha" element={<ProxmoxHAPage />} />
|
<Route path="/proxmox/ha" element={<ProxmoxHAPage />} />
|
||||||
<Route path="/proxmox/tasks" element={<ProxmoxTasksPage />} />
|
<Route path="/proxmox/tasks" element={<ProxmoxTasksPage />} />
|
||||||
|
<Route path="/proxmox/views" element={<ProxmoxViewsPage />} />
|
||||||
<Route path="/proxmox/certificates" element={<ProxmoxCertificatesPage />} />
|
<Route path="/proxmox/certificates" element={<ProxmoxCertificatesPage />} />
|
||||||
|
<Route path="/proxmox/subscriptions" element={<ProxmoxSubscriptionPage />} />
|
||||||
<Route path="/settings/updater" element={<Updater />} />
|
<Route path="/settings/updater" element={<Updater />} />
|
||||||
<Route path="/settings/proxmox" element={<ProxmoxSettings />} />
|
<Route path="/settings/proxmox" element={<ProxmoxSettings />} />
|
||||||
<Route path="/settings/integrations" element={<Integrations />} />
|
<Route path="/settings/integrations" element={<Integrations />} />
|
||||||
|
|||||||
@ -1,126 +1,282 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index';
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index';
|
||||||
import { Button } from '@/components/ui/index';
|
import { Button } from '@/components/ui/index';
|
||||||
import { MoreHorizontal, Trash2 } from 'lucide-react';
|
import { Badge } from '@/components/ui/index';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/index';
|
||||||
interface CertificateInfo {
|
import { RefreshCw, ChevronDown, ChevronRight, RotateCcw } from 'lucide-react';
|
||||||
id: string;
|
import { Certificate } from '@/lib/domain';
|
||||||
commonName: string;
|
|
||||||
issuer: string;
|
|
||||||
validFrom: string;
|
|
||||||
validUntil: string;
|
|
||||||
status: 'valid' | 'expiring' | 'expired';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CertificateListProps {
|
interface CertificateListProps {
|
||||||
certificates: CertificateInfo[];
|
certificates: Certificate[];
|
||||||
onRefresh?: () => void;
|
onRefresh: () => void;
|
||||||
|
onRenew: (cert: Certificate) => void;
|
||||||
isLoading?: boolean;
|
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({
|
export function CertificateList({
|
||||||
certificates,
|
certificates,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
isLoading,
|
|
||||||
onUpload,
|
|
||||||
onDelete,
|
|
||||||
onRenew,
|
onRenew,
|
||||||
|
isLoading = false,
|
||||||
}: CertificateListProps) {
|
}: CertificateListProps) {
|
||||||
const validCount = certificates.filter((c) => c.status === 'valid').length;
|
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
||||||
const expiringCount = certificates.filter((c) => c.status === 'expiring').length;
|
const [detailCert, setDetailCert] = useState<Certificate | null>(null);
|
||||||
const expiredCount = certificates.filter((c) => c.status === 'expired').length;
|
|
||||||
|
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 (
|
return (
|
||||||
|
<>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle>Certificates</CardTitle>
|
<CardTitle>Certificates</CardTitle>
|
||||||
<div className="flex space-x-2">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="flex items-center space-x-2 text-sm">
|
<div className="flex items-center space-x-1 text-sm">
|
||||||
<span className="text-green-500">●</span>
|
<span className="h-2 w-2 rounded-full bg-green-500 inline-block" />
|
||||||
<span>{validCount} Valid</span>
|
<span>{validCount} Valid</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2 text-sm">
|
<div className="flex items-center space-x-1 text-sm">
|
||||||
<span className="text-yellow-500">●</span>
|
<span className="h-2 w-2 rounded-full bg-yellow-500 inline-block" />
|
||||||
<span>{expiringCount} Expiring</span>
|
<span>{expiringCount} Expiring</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2 text-sm">
|
<div className="flex items-center space-x-1 text-sm">
|
||||||
<span className="text-red-500">●</span>
|
<span className="h-2 w-2 rounded-full bg-red-500 inline-block" />
|
||||||
<span>{expiredCount} Expired</span>
|
<span>{expiredCount} Expired</span>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
|
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
|
||||||
|
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={onUpload}>
|
|
||||||
<span className="mr-2 h-4 w-4">⬆️</span>
|
|
||||||
Upload
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="overflow-auto">
|
{certificates.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||||
|
No certificates found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>ID</TableHead>
|
<TableHead className="w-6" />
|
||||||
<TableHead>Common Name</TableHead>
|
<TableHead>Subject (CN)</TableHead>
|
||||||
|
<TableHead>SANs</TableHead>
|
||||||
<TableHead>Issuer</TableHead>
|
<TableHead>Issuer</TableHead>
|
||||||
<TableHead>Valid From</TableHead>
|
<TableHead>Valid From</TableHead>
|
||||||
<TableHead>Valid Until</TableHead>
|
<TableHead>Valid Until</TableHead>
|
||||||
|
<TableHead>Fingerprint</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{certificates.map((cert) => (
|
{certificates.map((cert) => {
|
||||||
<TableRow key={cert.id}>
|
const status = certStatus(cert);
|
||||||
<TableCell className="font-medium">{cert.id}</TableCell>
|
const isExpanded = expandedRows.has(cert.filename);
|
||||||
<TableCell>{cert.commonName}</TableCell>
|
const rowClass =
|
||||||
<TableCell>{cert.issuer}</TableCell>
|
status === 'expired'
|
||||||
<TableCell>{cert.validFrom}</TableCell>
|
? 'bg-red-50/50 dark:bg-red-950/20'
|
||||||
<TableCell>{cert.validUntil}</TableCell>
|
: 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>
|
<TableCell>
|
||||||
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
|
<StatusBadge status={status} />
|
||||||
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>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex items-center justify-end space-x-2">
|
<div className="flex items-center justify-end space-x-1">
|
||||||
<button
|
<Button
|
||||||
className="rounded-md p-1 hover:bg-accent"
|
variant="ghost"
|
||||||
onClick={() => onRenew?.(cert)}
|
size="sm"
|
||||||
title="Renew"
|
onClick={() => setDetailCert(cert)}
|
||||||
|
title="View Details"
|
||||||
>
|
>
|
||||||
<span className="h-4 w-4 text-xs">🔄</span>
|
View
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
className="rounded-md p-1 hover:bg-red-100 hover:text-red-600"
|
variant="outline"
|
||||||
onClick={() => onDelete?.(cert)}
|
size="sm"
|
||||||
title="Delete"
|
onClick={() => onRenew(cert)}
|
||||||
|
title="Renew certificate"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<RotateCcw className="mr-1 h-3 w-3" />
|
||||||
</button>
|
Renew
|
||||||
<button
|
</Button>
|
||||||
className="rounded-md p-1 hover:bg-accent"
|
|
||||||
title="More"
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</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>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,8 @@ export interface ClusterInfo {
|
|||||||
username: string;
|
username: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
/** True when a live client exists in the backend connection pool */
|
||||||
|
connected?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClusterConnection {
|
export interface ClusterConnection {
|
||||||
@ -95,3 +97,14 @@ export interface HaGroup {
|
|||||||
maxRelocate: number;
|
maxRelocate: number;
|
||||||
state: string;
|
state: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Certificate {
|
||||||
|
filename: string;
|
||||||
|
subject: string;
|
||||||
|
san?: string[];
|
||||||
|
issuer?: string;
|
||||||
|
notbefore?: string;
|
||||||
|
notafter?: string;
|
||||||
|
fingerprint?: string;
|
||||||
|
pem?: string;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,29 +1,251 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
// Card imports removed '@/components/ui/index';
|
import { Card, CardContent } from '@/components/ui/index';
|
||||||
import { Button } 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 { CertificateList } from '@/components/Proxmox';
|
||||||
|
import { listProxmoxClusters, listCertificates } from '@/lib/proxmoxClient';
|
||||||
|
import { ClusterInfo, Certificate } from '@/lib/domain';
|
||||||
|
|
||||||
export function ProxmoxCertificatesPage() {
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Certificates</h1>
|
<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>
|
||||||
<div className="flex space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Button variant="outline" size="sm">
|
{clusters.length > 1 && (
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
<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
|
Refresh
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{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
|
<CertificateList
|
||||||
certificates={[]}
|
certificates={certificates}
|
||||||
onRefresh={() => {}}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
293
src/pages/Proxmox/SubscriptionPage.tsx
Normal file
293
src/pages/Proxmox/SubscriptionPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user