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

@@ -48,4 +48,12 @@ test.describe('Page smoke', () => {
await expect(page.getByRole('heading', { name: 'Quick Forge' })).toBeVisible();
await expect(page.locator('.forge-mode-toggle').getByRole('button', { name: 'Simple', exact: true }).first()).toBeVisible();
});
test('Emberwake Cloud Ecosystem hub loads', async ({ page }) => {
await page.getByRole('link', { name: /Emberwake/i }).click();
await expect(page.getByText('Cloud Ecosystem')).toBeVisible({ timeout: 10_000 });
await page.getByText('Cloud Ecosystem').click();
await expect(page.getByTestId('cloud-spread-panel')).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId('cloud-method-s3-cloudfront')).toBeVisible();
});
});

View File

@@ -623,6 +623,17 @@ export const api = {
method: 'DELETE',
}),
testErasureSwarm: (body: { s3_bucket?: string; cloudfront_domain?: string }) =>
fetchJSON<{ ok: boolean; error?: string }>('/erasure-swarm/test', {
method: 'POST',
body: JSON.stringify(body),
}),
getErasureSwarmPolicyJSON: (bucket?: string) =>
fetchJSON<{ iam_policy: string; bucket_policy: string; env_keys: string[] }>(
`/erasure-swarm/policy-json${bucket ? `?bucket=${encodeURIComponent(bucket)}` : ''}`,
),
// Full deck backup — downloads a zip containing config.json, users.json, miner.db.
downloadBackup: async (): Promise<void> => {
const res = await fetchAuthedWithTimeout(`${API_BASE}/backup`, BACKUP_DOWNLOAD_TIMEOUT_MS, {

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);

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { CLOUD_SPREAD_METHODS, cloudMethodsByGroup } from './cloudSpreadMethods';
describe('cloudSpreadMethods', () => {
it('defines eight cloud deploy methods', () => {
expect(CLOUD_SPREAD_METHODS).toHaveLength(8);
expect(cloudMethodsByGroup('aws')).toHaveLength(6);
expect(cloudMethodsByGroup('generic')).toHaveLength(2);
});
it('each method has mermaid flow and zip name', () => {
for (const m of CLOUD_SPREAD_METHODS) {
expect(m.mermaid).toMatch(/flowchart/);
expect(m.zipName).toMatch(/^aetherforge-.*\.zip$/);
}
});
});

View File

@@ -0,0 +1,142 @@
/** Cloud deploy hub — AWS ecosystem + generic S3-compatible methods. */
import { SPREAD_TEMPLATES } from './spreadTemplateExport';
import { spreadTechniqueDocUrl } from './spreadTechniques';
export type CloudMethodGroup = 'aws' | 'generic';
export type CloudMethodId =
| 's3-cloudfront'
| 'ssm-document'
| 'launch-template'
| 'fargate'
| 'eventbridge'
| 'cloud-map'
| 'minio'
| 'curl-manifest';
export interface CloudSpreadMethod {
id: CloudMethodId;
group: CloudMethodGroup;
label: string;
hint: string;
docAnchor: string;
mermaid: string;
zipName: string;
connectionTest?: { kind: string; endpointKey: 'serverUrl' | 'minioEndpoint' | 'cloudfrontDomain' | 'bucketRegion' };
}
export const CLOUD_SPREAD_METHODS: CloudSpreadMethod[] = [
{
id: 's3-cloudfront',
group: 'aws',
label: 'S3 + CloudFront erasure swarm',
hint: 'Sync ReedSolomon shards to S3; agents fetch via CloudFront OAC.',
docAnchor: 'aws-s3-cloudfront',
zipName: 'aetherforge-s3-cloudfront.zip',
connectionTest: { kind: 's3', endpointKey: 'bucketRegion' },
mermaid: `flowchart LR
Deck[Command deck] -->|aws s3 sync| S3[(S3 bucket)]
S3 --> CF[CloudFront]
CF --> Agent[Agent shard fetch]`,
},
{
id: 'ssm-document',
group: 'aws',
label: 'SSM Run Command document',
hint: 'Owned EC2 — document curls install.sh from your deck.',
docAnchor: 'ssm-document',
zipName: 'aetherforge-ssm-document.zip',
mermaid: `flowchart LR
Deck[Command deck] --> Doc[SSM document]
Doc --> EC2[Managed instance]
EC2 -->|curl install.sh| Deck`,
},
{
id: 'launch-template',
group: 'aws',
label: 'EC2 Launch Template',
hint: 'ASG user-data bootstraps agents with genesis snapshot markers.',
docAnchor: 'aws-launch-template',
zipName: 'aetherforge-launch-template.zip',
mermaid: `flowchart LR
LT[Launch template] --> ASG[Auto scaling group]
ASG --> EC2[New instance]
EC2 -->|user-data curl| Deck[Command deck]`,
},
{
id: 'fargate',
group: 'aws',
label: 'Fargate burst seeder',
hint: 'Short TTL ECS tasks seed erasure shards inside VPC.',
docAnchor: 'aws-fargate-burst',
zipName: 'aetherforge-fargate.zip',
mermaid: `flowchart LR
Deck[Command deck] --> ECS[ECS RunTask]
ECS --> Task[Fargate seeder]
Task -->|shard fanout| VPC[VPC agents]`,
},
{
id: 'eventbridge',
group: 'aws',
label: 'EventBridge policy fan-out',
hint: 'Scheduled Lambda polls policy snapshot for degraded mode.',
docAnchor: 'aws-eventbridge',
zipName: 'aetherforge-eventbridge.zip',
mermaid: `flowchart LR
EB[EventBridge rule] --> Lambda[Policy relay]
Lambda -->|poll| Deck[Policy snapshot]
Lambda --> Webhook[Agent webhook]`,
},
{
id: 'cloud-map',
group: 'aws',
label: 'Cloud Map service registry',
hint: 'Register seeder DNS names for lattice shard discovery.',
docAnchor: 'aws-cloud-map',
zipName: 'aetherforge-cloud-map.zip',
mermaid: `flowchart LR
Seeder[Primary seeder] --> CM[Cloud Map]
CM --> DNS[seeder.svc.local]
DNS --> Agent[Agent manifest fetch]`,
},
{
id: 'minio',
group: 'generic',
label: 'MinIO / S3-compatible upload',
hint: 'mc mirror spread-kit to any on-prem object store.',
docAnchor: 'minio-spread-kit',
zipName: 'aetherforge-minio.zip',
connectionTest: { kind: 'minio', endpointKey: 'minioEndpoint' },
mermaid: `flowchart LR
Kit[Spread kit ZIP] --> MC[mc cp]
MC --> MinIO[(MinIO bucket)]
MinIO --> Browser[Waterhole index.html]`,
},
{
id: 'curl-manifest',
group: 'generic',
label: 'curl manifest.json',
hint: 'Portable manifest for any VPS — no cloud SDK required.',
docAnchor: 'curl-manifest',
zipName: 'aetherforge-curl-manifest.zip',
connectionTest: { kind: 'http', endpointKey: 'serverUrl' },
mermaid: `flowchart LR
VPS[Any VPS] -->|curl manifest.json| Deck[Command deck]
VPS -->|curl install.sh| Agent[Agent bootstrap]`,
},
];
export function cloudMethodsByGroup(group: CloudMethodGroup): CloudSpreadMethod[] {
return CLOUD_SPREAD_METHODS.filter((m) => m.group === group);
}
export function cloudMethodDocUrl(anchor: string): string {
return spreadTechniqueDocUrl(anchor);
}
export const RELATED_SPREAD_TEMPLATE_LINKS = SPREAD_TEMPLATES.map((t) => ({
id: t.id,
label: t.label,
docUrl: spreadTechniqueDocUrl(t.docAnchor),
}));

View File

@@ -64,9 +64,11 @@ describe('UI_HELP', () => {
'fm_encrypt_path',
'pt_path_tracer',
'pt_agent_chain',
'pt_subnet_autopsy',
'fleet_runtime_policy',
'fleet_runtime_modules',
'spread_funnel_widget',
'subnet_immune_autopsy',
'md_overview',
'md_operation_chip',
'md_spread_profile',
@@ -82,6 +84,10 @@ describe('UI_HELP', () => {
'ew_install_links',
'ew_spread_kit',
'ew_war_room',
'ew_cloud_ecosystem',
'ew_cloud_aws',
'ew_cloud_generic',
'ew_ssm_document',
'ew_supply_chain',
'ew_public_urls',
'ew_techniques',
@@ -104,6 +110,7 @@ describe('UI_HELP', () => {
'set_webhook',
'ui_color_scheme',
'crucible_section_spread_templates',
'crucible_section_launch_template',
] as const;
it('defines help for every documented UI key', () => {