OPC # 0001: Extract OPC into standalone repo
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { AnchorButton, Button, Callout, Intent, NonIdealState, Spinner, Tab, Tabs, Tag } from '@blueprintjs/core';
|
||||
import DeployWizard from '../components/wizard/DeployWizard';
|
||||
import { tenantUrl, CLARITY_DOMAIN } from '../config';
|
||||
import { getTenants, subscribeToTenantLogs } from '../api/provisioningApi';
|
||||
import type { TenantRecord } from '../types/provisioning';
|
||||
|
||||
const ENV_INTENT: Record<string, Intent> = {
|
||||
prod: Intent.DANGER,
|
||||
uat: Intent.WARNING,
|
||||
fdev: Intent.PRIMARY,
|
||||
};
|
||||
|
||||
function TenantCard({ t }: { t: TenantRecord }) {
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
const logRef = useRef<HTMLDivElement>(null);
|
||||
const sourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!logsOpen) return;
|
||||
const src = subscribeToTenantLogs(
|
||||
t.subdomain,
|
||||
(line) => setLogs((prev) => [...prev.slice(-500), line]), // cap at 500 lines
|
||||
() => {}
|
||||
);
|
||||
sourceRef.current = src;
|
||||
return () => { src.close(); sourceRef.current = null; };
|
||||
}, [logsOpen, t.subdomain]);
|
||||
|
||||
// Auto-scroll to bottom when new lines arrive
|
||||
useEffect(() => {
|
||||
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
|
||||
}, [logs]);
|
||||
|
||||
return (
|
||||
<div className="job-card">
|
||||
<div className="job-card-header">
|
||||
<div>
|
||||
<strong>{t.clientName}</strong>
|
||||
<span className="job-card-subdomain">{t.subdomain}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center' }}>
|
||||
<Tag intent={ENV_INTENT[t.environment] ?? Intent.NONE} round minimal>{t.environment}</Tag>
|
||||
<Tag intent={t.status === 'Provisioned' ? Intent.SUCCESS : Intent.WARNING} round>{t.status}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs onChange={(id) => setLogsOpen(id === 'logs')}>
|
||||
<Tab id="info" title="Info" panel={
|
||||
<div className="job-card-meta" style={{ paddingTop: '0.5rem' }}>
|
||||
<span>Site: <code>{t.siteCode}</code></span>
|
||||
<span>Container: <code>{t.containerName ?? '—'}{t.containerPort ? `:${t.containerPort}` : ''}</code></span>
|
||||
<span>{new Date(t.provisionedAt).toLocaleString()}</span>
|
||||
{t.status === 'Provisioned' && (
|
||||
<AnchorButton
|
||||
icon="share"
|
||||
minimal
|
||||
small
|
||||
href={tenantUrl(t.subdomain)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ marginTop: '0.25rem' }}
|
||||
>
|
||||
{t.subdomain}.{CLARITY_DOMAIN}
|
||||
</AnchorButton>
|
||||
)}
|
||||
</div>
|
||||
} />
|
||||
<Tab id="logs" title="Server" panel={
|
||||
<div
|
||||
ref={logRef}
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.75rem',
|
||||
background: '#1a1a1a',
|
||||
color: '#d4d4d4',
|
||||
padding: '0.5rem',
|
||||
borderRadius: '4px',
|
||||
height: '260px',
|
||||
overflowY: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
marginTop: '0.5rem',
|
||||
}}
|
||||
>
|
||||
{logs.length === 0
|
||||
? <span style={{ color: '#666' }}>Connecting to container logs…</span>
|
||||
: logs.map((l, i) => <div key={i}>{l}</div>)
|
||||
}
|
||||
</div>
|
||||
} />
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
const [tenants, setTenants] = useState<TenantRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = () => {
|
||||
getTenants()
|
||||
.then((data) => { setTenants(data); setError(null); })
|
||||
.catch((e: Error) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleWizardClose = () => { setWizardOpen(false); setLoading(true); load(); };
|
||||
|
||||
if (wizardOpen) return <DeployWizard onClose={handleWizardClose} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>Provisioned Tenants</h1>
|
||||
<p>Manage and monitor client deployments.</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<Button icon="refresh" minimal onClick={load} loading={loading} title="Refresh" />
|
||||
<Button intent={Intent.PRIMARY} text="Deploy New Client" icon="plus" large onClick={() => setWizardOpen(true)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && <NonIdealState icon={<Spinner />} title="Loading tenants..." />}
|
||||
|
||||
{error && (
|
||||
<Callout intent={Intent.DANGER} title="Failed to load tenants" style={{ marginBottom: '1rem' }}>
|
||||
{error}
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{!loading && !error && tenants.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">🏗️</div>
|
||||
<h3>No tenants provisioned yet</h3>
|
||||
<p>Deploy your first client to get started.</p>
|
||||
<Button intent={Intent.PRIMARY} text="Deploy New Client" icon="plus" onClick={() => setWizardOpen(true)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && tenants.length > 0 && (
|
||||
<div className="job-list">
|
||||
{tenants.map((t) => (
|
||||
<TenantCard key={t.subdomain} t={t} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user