-
-
-
- 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 */}
+
+ >
);
}
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 */}
+
+
+ {/* ACME Dialog */}
+
);
}
diff --git a/src/pages/Proxmox/SubscriptionPage.tsx b/src/pages/Proxmox/SubscriptionPage.tsx
new file mode 100644
index 00000000..ba639de5
--- /dev/null
+++ b/src/pages/Proxmox/SubscriptionPage.tsx
@@ -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 (
+
+
+ Active
+
+ );
+ }
+ if (status === 'expired') {
+ return (
+
+
+ Expired
+
+ );
+ }
+ return (
+
+
+ None
+
+ );
+}
+
+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
([]);
+ const [subscriptions, setSubscriptions] = useState>({});
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [keyInput, setKeyInput] = useState('');
+ const [selectedClusterId, setSelectedClusterId] = useState('');
+ 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 = {};
+ 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 (
+
+
+
+
Subscriptions
+
Manage Proxmox subscription keys across clusters
+
+
+
+ Refresh
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {/* Left panel: Subscription Key input */}
+
+
+
+
+ Subscription Key
+
+
+
+ {/* Current key display */}
+ {selectedSub?.key && (
+
+
Current Key
+
{maskKey(selectedSub.key)}
+ {selectedSub.productname && (
+
{selectedSub.productname}
+ )}
+
+
+ )}
+
+ {clusters.length > 1 && (
+
+
+
+
+ )}
+
+
+
+
setKeyInput(e.target.value)}
+ onKeyDown={(e) => { if (e.key === 'Enter') void handleActivate(); }}
+ />
+
+ Keys can be obtained from the{' '}
+
+ Proxmox shop
+
+ .
+
+
+
+ {activationMessage && (
+
+ {activationMessage.text}
+
+ )}
+
+
+ {activating ? (
+
+ ) : (
+
+ )}
+ Activate Key
+
+
+
+
+ {/* Right panel: Per-cluster status */}
+
+
+ Cluster Subscription Status
+
+
+
+ {activeCount} Active
+
+
+
+ {expiredCount} Expired
+
+
+
+ {noneCount} None
+
+
+
+
+ {clusterSubscriptions.length === 0 ? (
+
+ {loading ? 'Loading...' : 'No clusters configured.'}
+
+ ) : (
+
+ {clusterSubscriptions.map(({ cluster, status }) => (
+
setSelectedClusterId(cluster.id)}
+ >
+
+
+
{cluster.name}
+
+ {cluster.url}:{cluster.port}
+
+ {status.productname && (
+
{status.productname}
+ )}
+
+ {status.regdate && (
+ Registered: {status.regdate}
+ )}
+ {status.nextduedate && (
+ Next due: {status.nextduedate}
+ )}
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ );
+}