Add Emberwake Cloud Ecosystem deploy hub with AWS and generic spread templates.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

Unified expandable panels with mermaid flows, ZIP export, connection tests, and Playwright smoke coverage.
This commit is contained in:
AetherForge
2026-06-07 10:03:22 -07:00
parent 20f083b111
commit 5b5fc7c01c
36 changed files with 1305 additions and 83 deletions

View File

@@ -0,0 +1,131 @@
import { memo, useCallback, useMemo, useState } from 'react';
import { api } from '../../api/client';
import { cloudMethodDocUrl, type CloudSpreadMethod } from '../../help/cloudSpreadMethods';
export interface CloudMethodConfig {
serverUrl: string;
buildId: string;
campaign: string;
bucket: string;
cloudfrontDomain: string;
minioEndpoint: string;
region: string;
cluster: string;
namespaceName: string;
}
export interface CloudMethodPanelProps {
method: CloudSpreadMethod;
config: CloudMethodConfig;
}
function CloudMethodPanelInner({ method, config }: CloudMethodPanelProps) {
const [busy, setBusy] = useState(false);
const [testMsg, setTestMsg] = useState('');
const [copyOk, setCopyOk] = useState(false);
const [err, setErr] = useState('');
const installCmd = useMemo(() => {
const q: string[] = [];
if (config.buildId.trim()) q.push(`pin=${config.buildId.trim()}`);
if (config.campaign.trim()) q.push(`c=${config.campaign.trim()}`);
const suffix = q.length ? `?${q.join('&')}` : '';
return `curl -fsSL ${config.serverUrl}/install.sh${suffix} | bash`;
}, [config.serverUrl, config.buildId, config.campaign]);
const testEndpoint = useMemo(() => {
if (!method.connectionTest) return '';
switch (method.connectionTest.endpointKey) {
case 'serverUrl':
return `${config.serverUrl.replace(/\/$/, '')}/api/v1/public/download`;
case 'minioEndpoint':
return config.minioEndpoint;
case 'cloudfrontDomain':
return config.cloudfrontDomain ? `https://${config.cloudfrontDomain}` : '';
case 'bucketRegion':
return `https://s3.${config.region}.amazonaws.com`;
default:
return '';
}
}, [method.connectionTest, config]);
const download = useCallback(async () => {
setErr('');
setBusy(true);
try {
await api.exportCloudTemplate({
template: method.id,
server_url: config.serverUrl,
build_id: config.buildId.trim(),
campaign: config.campaign.trim(),
bucket: config.bucket.trim(),
cloudfront_domain: config.cloudfrontDomain.trim(),
minio_endpoint: config.minioEndpoint.trim(),
region: config.region.trim(),
cluster: config.cluster.trim(),
namespace_name: config.namespaceName.trim(),
});
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}, [method.id, config]);
const copyInstall = useCallback(() => {
void navigator.clipboard?.writeText(installCmd).then(() => {
setCopyOk(true);
setTimeout(() => setCopyOk(false), 1500);
});
}, [installCmd]);
const testConnection = useCallback(async () => {
if (!method.connectionTest || !testEndpoint) return;
setTestMsg('');
setBusy(true);
try {
const res = await api.testCloudConnection({
kind: method.connectionTest.kind,
endpoint: testEndpoint,
bucket: config.bucket.trim(),
});
setTestMsg(res.reachable ? `Reachable (${res.status ?? 'ok'})` : res.error || 'Unreachable');
} catch (e) {
setTestMsg(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}, [method.connectionTest, testEndpoint, config.bucket]);
return (
<details className={`cloud-method-panel cloud-method-panel--${method.group}`} data-testid={`cloud-method-${method.id}`}>
<summary className="cloud-method-summary font-tech">
{method.label}
<span className="form-hint"> {method.hint}</span>
</summary>
<div className="cloud-method-body">
<pre className="cloud-method-mermaid">{method.mermaid}</pre>
<div className="cloud-method-actions">
<button type="button" className="btn btn-primary btn-sm" disabled={busy || !config.serverUrl.trim()} onClick={() => void download()}>
{busy ? 'Working…' : `Download ${method.zipName}`}
</button>
<button type="button" className="btn btn-outline btn-sm" onClick={copyInstall}>
{copyOk ? 'Copied' : 'Copy install curl'}
</button>
{method.connectionTest && testEndpoint ? (
<button type="button" className="btn btn-outline btn-sm" disabled={busy} onClick={() => void testConnection()}>
Test connection
</button>
) : null}
<a className="btn btn-outline btn-sm" href={cloudMethodDocUrl(method.docAnchor)} target="_blank" rel="noreferrer">
Playbook
</a>
</div>
{testMsg ? <p className="form-hint">{testMsg}</p> : null}
{err ? <p className="form-error">{err}</p> : null}
</div>
</details>
);
}
export default memo(CloudMethodPanelInner);

View File

@@ -0,0 +1,12 @@
.cloud-spread-config-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(12rem,1fr));gap:.5rem;margin-bottom:1rem}
.cloud-method-panel{border:1px solid rgba(255,255,255,.08);border-radius:6px;margin-bottom:.4rem;background:rgba(0,0,0,.2)}
.cloud-method-panel--aws{border-left:3px solid #ff9900}
.cloud-method-panel--generic{border-left:3px solid #3dd6c6}
.cloud-method-summary{cursor:pointer;padding:.5rem .65rem;list-style:none}
.cloud-method-summary::-webkit-details-marker{display:none}
.cloud-method-mermaid{font-family:monospace;font-size:.68rem;padding:.45rem;background:rgba(0,0,0,.35);white-space:pre}
.cloud-method-body{padding:.45rem .65rem .65rem}
.cloud-method-actions{display:flex;flex-wrap:wrap;gap:.4rem}
.cloud-spread-related{margin-top:.75rem;font-size:.82rem}
.cloud-spread-field .label{font-size:.72rem}
.cloud-spread-group-title{font-size:.82rem;color:var(--neon-cyan,#3dd6c6)}

View File

@@ -0,0 +1,19 @@
/** @vitest-environment happy-dom */
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import CloudSpreadPanel from './CloudSpreadPanel';
import { api } from '../../api/client';
describe('CloudSpreadPanel', () => {
beforeEach(() => {
vi.spyOn(api, 'exportCloudTemplate').mockResolvedValue(undefined);
vi.spyOn(api, 'testCloudConnection').mockResolvedValue({ ok: true, reachable: true });
});
it('renders cloud ecosystem hub', () => {
render(<CloudSpreadPanel serverUrl="https://deck.example" />);
expect(screen.getByTestId('cloud-spread-panel')).toBeInTheDocument();
expect(screen.getByTestId('cloud-method-s3-cloudfront')).toBeInTheDocument();
expect(screen.getByTestId('cloud-method-minio')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,84 @@
import { memo, useMemo, useState } from 'react';
import { HelpTip } from '../HelpTip';
import { cloudMethodsByGroup, RELATED_SPREAD_TEMPLATE_LINKS } from '../../help/cloudSpreadMethods';
import CloudMethodPanel, { type CloudMethodConfig } from './CloudMethodPanel';
import './CloudSpreadPanel.css';
export interface CloudSpreadPanelProps {
serverUrl: string;
buildId?: string;
campaign?: string;
}
function CloudSpreadPanelInner({ serverUrl, buildId = '', campaign = '' }: CloudSpreadPanelProps) {
const [bucket, setBucket] = useState('aetherforge-shards');
const [cloudfrontDomain, setCloudfrontDomain] = useState('');
const [minioEndpoint, setMinioEndpoint] = useState('https://minio.example:9000');
const [region, setRegion] = useState('us-east-1');
const config: CloudMethodConfig = useMemo(
() => ({
serverUrl,
buildId,
campaign,
bucket,
cloudfrontDomain,
minioEndpoint,
region,
cluster: 'aetherforge-cluster',
namespaceName: 'prod.local',
}),
[serverUrl, buildId, campaign, bucket, cloudfrontDomain, minioEndpoint, region],
);
return (
<div className="cloud-spread-panel" data-testid="cloud-spread-panel">
<p className="form-hint">Deploy spread kits into AWS, MinIO, or any VPS standalone template ZIPs.</p>
<div className="cloud-spread-config-grid">
<label className="cloud-spread-field">
<span className="label">Bucket</span>
<input className="input mono" value={bucket} onChange={(e) => setBucket(e.target.value)} />
</label>
<label className="cloud-spread-field">
<span className="label">CloudFront</span>
<input className="input mono" value={cloudfrontDomain} onChange={(e) => setCloudfrontDomain(e.target.value)} />
</label>
<label className="cloud-spread-field">
<span className="label">MinIO</span>
<input className="input mono" value={minioEndpoint} onChange={(e) => setMinioEndpoint(e.target.value)} />
</label>
<label className="cloud-spread-field">
<span className="label">Region</span>
<input className="input mono" value={region} onChange={(e) => setRegion(e.target.value)} />
</label>
</div>
<h4 className="font-tech cloud-spread-group-title">
AWS ecosystem <HelpTip field="ew_cloud_aws" />
</h4>
{cloudMethodsByGroup('aws').map((m) => (
<CloudMethodPanel key={m.id} method={m} config={config} />
))}
<h4 className="font-tech cloud-spread-group-title">
Generic cloud <HelpTip field="ew_cloud_generic" />
</h4>
{cloudMethodsByGroup('generic').map((m) => (
<CloudMethodPanel key={m.id} method={m} config={config} />
))}
<details className="cloud-spread-related">
<summary className="font-tech">Related lateral templates</summary>
<ul>
{RELATED_SPREAD_TEMPLATE_LINKS.map((t) => (
<li key={t.id}>
<strong>{t.label}</strong> {' '}
<a href={t.docUrl} target="_blank" rel="noreferrer">
Playbook
</a>
</li>
))}
</ul>
</details>
</div>
);
}
export default memo(CloudSpreadPanelInner);