157 lines
5.5 KiB
TypeScript
157 lines
5.5 KiB
TypeScript
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/tenantApi';
|
||
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>
|
||
)}
|
||
</>
|
||
);
|
||
}
|