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:
@@ -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, {
|
||||
|
||||
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);
|
||||
17
server/web/src/help/cloudSpreadMethods.test.ts
Normal file
17
server/web/src/help/cloudSpreadMethods.test.ts
Normal 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$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
142
server/web/src/help/cloudSpreadMethods.ts
Normal file
142
server/web/src/help/cloudSpreadMethods.ts
Normal 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 Reed–Solomon 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),
|
||||
}));
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user