feat: implement v1.2.1 fixes #95
@ -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 />} />
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
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