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
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:
131
server/web/src/components/Spread/CloudMethodPanel.tsx
Normal file
131
server/web/src/components/Spread/CloudMethodPanel.tsx
Normal 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);
|
||||
12
server/web/src/components/Spread/CloudSpreadPanel.css
Normal file
12
server/web/src/components/Spread/CloudSpreadPanel.css
Normal 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)}
|
||||
19
server/web/src/components/Spread/CloudSpreadPanel.test.tsx
Normal file
19
server/web/src/components/Spread/CloudSpreadPanel.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
84
server/web/src/components/Spread/CloudSpreadPanel.tsx
Normal file
84
server/web/src/components/Spread/CloudSpreadPanel.tsx
Normal 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);
|
||||
Reference in New Issue
Block a user