diff --git a/src/App.tsx b/src/App.tsx index e7404019..697ab0b9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> + } /> } /> } /> } /> diff --git a/src/components/Proxmox/CertificateList.tsx b/src/components/Proxmox/CertificateList.tsx index 107a10fe..e9ebaeb0 100644 --- a/src/components/Proxmox/CertificateList.tsx +++ b/src/components/Proxmox/CertificateList.tsx @@ -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 Valid; + } + if (status === 'expiring') { + return ( + + Expiring Soon + + ); + } + return Expired; +} + +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>(new Set()); + const [detailCert, setDetailCert] = useState(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 ( - - - Certificates -
-
- - {validCount} Valid + <> + + + Certificates +
+
+ + {validCount} Valid +
+
+ + {expiringCount} Expiring +
+
+ + {expiredCount} Expired +
+
-
- - {expiringCount} Expiring -
-
- - {expiredCount} Expired -
- - -
- - -
- - - - ID - Common Name - Issuer - Valid From - Valid Until - Status - Actions - - - - {certificates.map((cert) => ( - - {cert.id} - {cert.commonName} - {cert.issuer} - {cert.validFrom} - {cert.validUntil} - - - {cert.status} - - - -
- - - -
-
+ + + {certificates.length === 0 ? ( +
+ No certificates found +
+ ) : ( +
+ + + + Subject (CN) + SANs + Issuer + Valid From + Valid Until + Fingerprint + Status + Actions - ))} - -
-
-
- + + + {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 ( + + + + + + + {extractCN(cert.subject)} + + + {cert.san && cert.san.length > 0 + ? cert.san.slice(0, 2).join(', ') + + (cert.san.length > 2 ? ` +${cert.san.length - 2}` : '') + : '-'} + + + {cert.issuer ? extractCN(cert.issuer) : '-'} + + + {cert.notbefore ?? '-'} + + + {cert.notafter ?? '-'} + + + {truncateFingerprint(cert.fingerprint)} + + + + + +
+ + +
+
+
+ + {isExpanded && ( + + +
+
+ Filename: + {cert.filename} +
+
+ Full Subject: + {cert.subject} +
+ {cert.issuer && ( +
+ Full Issuer: + {cert.issuer} +
+ )} + {cert.fingerprint && ( +
+ Fingerprint: + {cert.fingerprint} +
+ )} + {cert.san && cert.san.length > 0 && ( +
+ All SANs: + {cert.san.join(', ')} +
+ )} +
+
+
+ )} +
+ ); + })} +
+ + )} + + + + {/* Detail dialog */} + { if (!open) setDetailCert(null); }}> + + + Certificate Details + + {detailCert && ( +
+
+ Subject + {detailCert.subject} + Issuer + {detailCert.issuer ?? '-'} + Valid From + {detailCert.notbefore ?? '-'} + Valid Until + {detailCert.notafter ?? '-'} + Fingerprint + {detailCert.fingerprint ?? '-'} + Filename + {detailCert.filename} + {detailCert.san && detailCert.san.length > 0 && ( + <> + SANs + {detailCert.san.join(', ')} + + )} + {detailCert.pem && ( + <> + PEM +
+                      {detailCert.pem}
+                    
+ + )} +
+
+ )} +
+
+ ); } diff --git a/src/lib/domain.ts b/src/lib/domain.ts index a542c231..a8757c35 100644 --- a/src/lib/domain.ts +++ b/src/lib/domain.ts @@ -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; +} diff --git a/src/pages/Proxmox/CertificatesPage.tsx b/src/pages/Proxmox/CertificatesPage.tsx index 0d428a88..ffa5f772 100644 --- a/src/pages/Proxmox/CertificatesPage.tsx +++ b/src/pages/Proxmox/CertificatesPage.tsx @@ -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([]); + const [selectedClusterId, setSelectedClusterId] = useState(''); + const [nodeId, setNodeId] = useState('pve'); + const [certificates, setCertificates] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Upload dialog state + const [uploadOpen, setUploadOpen] = useState(false); + const [uploadCertPem, setUploadCertPem] = useState(''); + const [uploadKeyPem, setUploadKeyPem] = useState(''); + const fileInputRef = useRef(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[]).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) { + 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 (

Certificates

-

Manage TLS certificates

+

Manage TLS certificates across clusters

-
- + +
- {}} - /> + {error && ( +
+ {error} +
+ )} + + {!selectedClusterId && clusters.length === 0 && !loading && ( + + + No clusters configured. Add a cluster in Remotes first. + + + )} + + {selectedClusterId && ( + + )} + + {/* Upload Certificate Dialog */} + + + + Upload Custom Certificate + +
+
+ + +
+
+ +