Add fleet groups, agent screenshots, deploy guards, and Crucible polish.
This commit is contained in:
40
server/web/src/help/fleetGroups.test.ts
Normal file
40
server/web/src/help/fleetGroups.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
createFleetGroup,
|
||||
groupsForAgent,
|
||||
loadFleetGroups,
|
||||
normalizeGroupColor,
|
||||
primaryGroupForAgent,
|
||||
saveFleetGroups,
|
||||
} from './fleetGroups';
|
||||
|
||||
describe('fleetGroups', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('normalizeGroupColor accepts hex', () => {
|
||||
expect(normalizeGroupColor('#abc')).toBe('#aabbcc');
|
||||
expect(normalizeGroupColor('#aabbcc')).toBe('#aabbcc');
|
||||
});
|
||||
|
||||
it('persists and loads groups', () => {
|
||||
const g = createFleetGroup('Rack A', '#ff00ff', ['a1', 'a2']);
|
||||
saveFleetGroups([g]);
|
||||
const loaded = loadFleetGroups();
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0].name).toBe('Rack A');
|
||||
expect(loaded[0].agentIds).toEqual(['a1', 'a2']);
|
||||
});
|
||||
|
||||
it('groupsForAgent and primaryGroupForAgent', () => {
|
||||
const groups = [
|
||||
createFleetGroup('G1', '#00f5ff', ['x']),
|
||||
createFleetGroup('G2', '#ff0000', ['x', 'y']),
|
||||
];
|
||||
expect(groupsForAgent(groups, 'x').map((g) => g.name)).toEqual(['G1', 'G2']);
|
||||
expect(primaryGroupForAgent(groups, 'x')?.name).toBe('G1');
|
||||
saveFleetGroups(groups);
|
||||
expect(loadFleetGroups()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
97
server/web/src/help/fleetGroups.ts
Normal file
97
server/web/src/help/fleetGroups.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/** Fleet node groups — persisted in localStorage, shared across Roster + Crucible. */
|
||||
|
||||
export interface FleetGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
agentIds: string[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const FLEET_GROUP_COLORS = [
|
||||
'#00f5ff',
|
||||
'#39ff14',
|
||||
'#ff2da6',
|
||||
'#b24bf3',
|
||||
'#ffb020',
|
||||
'#ff6b35',
|
||||
'#00d4aa',
|
||||
'#3a86ff',
|
||||
'#f72585',
|
||||
'#ffd60a',
|
||||
'#06d6a0',
|
||||
'#e63946',
|
||||
] as const;
|
||||
|
||||
export const FLEET_GROUPS_STORAGE_KEY = 'aetherforge_fleet_groups';
|
||||
export const FLEET_GROUPS_CHANGED_EVENT = 'aetherforge-fleet-groups-changed';
|
||||
|
||||
export function normalizeGroupColor(color: string): string {
|
||||
const c = color.trim();
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(c)) return c;
|
||||
if (/^#[0-9A-Fa-f]{3}$/.test(c)) {
|
||||
const r = c[1];
|
||||
const g = c[2];
|
||||
const b = c[3];
|
||||
return `#${r}${r}${g}${g}${b}${b}`;
|
||||
}
|
||||
return FLEET_GROUP_COLORS[0];
|
||||
}
|
||||
|
||||
export function loadFleetGroups(): FleetGroup[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(FLEET_GROUPS_STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed
|
||||
.map((g) => {
|
||||
if (!g || typeof g !== 'object') return null;
|
||||
const o = g as Record<string, unknown>;
|
||||
const name = typeof o.name === 'string' ? o.name.trim() : '';
|
||||
if (!name) return null;
|
||||
const agentIds = Array.isArray(o.agentIds)
|
||||
? [...new Set(o.agentIds.filter((id): id is string => typeof id === 'string' && id.length > 0))]
|
||||
: [];
|
||||
return {
|
||||
id: typeof o.id === 'string' && o.id ? o.id : `fg-${Date.now()}`,
|
||||
name,
|
||||
color: normalizeGroupColor(typeof o.color === 'string' ? o.color : FLEET_GROUP_COLORS[0]),
|
||||
agentIds,
|
||||
createdAt: typeof o.createdAt === 'string' ? o.createdAt : new Date().toISOString(),
|
||||
} satisfies FleetGroup;
|
||||
})
|
||||
.filter((g): g is FleetGroup => g !== null);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveFleetGroups(groups: FleetGroup[]): void {
|
||||
localStorage.setItem(FLEET_GROUPS_STORAGE_KEY, JSON.stringify(groups));
|
||||
window.dispatchEvent(new Event(FLEET_GROUPS_CHANGED_EVENT));
|
||||
}
|
||||
|
||||
export function createFleetGroup(name: string, color: string, agentIds: string[]): FleetGroup {
|
||||
return {
|
||||
id: `fg-${crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`}`,
|
||||
name: name.trim(),
|
||||
color: normalizeGroupColor(color),
|
||||
agentIds: [...new Set(agentIds)],
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Groups that contain this agent (preserves group list order). */
|
||||
export function groupsForAgent(groups: FleetGroup[], agentId: string): FleetGroup[] {
|
||||
return groups.filter((g) => g.agentIds.includes(agentId));
|
||||
}
|
||||
|
||||
/** First group color for an agent (roster stripe / Crucible accent). */
|
||||
export function primaryGroupForAgent(groups: FleetGroup[], agentId: string): FleetGroup | undefined {
|
||||
return groups.find((g) => g.agentIds.includes(agentId));
|
||||
}
|
||||
|
||||
export function notifyFleetGroupsChanged(): void {
|
||||
window.dispatchEvent(new Event(FLEET_GROUPS_CHANGED_EVENT));
|
||||
}
|
||||
@@ -92,9 +92,16 @@ export function runForgePreflight(form: BuildRequest, fusionPrepSelected: boolea
|
||||
|
||||
if (form.fusion_enabled) {
|
||||
if (!fusionPrepSelected) {
|
||||
checks.push({ id: 'fusion', level: 'error', message: 'Fusion is on — upload your prep.exe.' });
|
||||
checks.push({ id: 'fusion', level: 'error', message: 'Fusion is on — choose a file to fuse (PDF, PNG, video, doc, or .exe).' });
|
||||
} else {
|
||||
checks.push({ id: 'fusion', level: 'ok', message: `Fusion ready → output ${form.fusion_output_name || 'prep.exe'}.` });
|
||||
checks.push({ id: 'fusion', level: 'ok', message: `Fusion ready → universal ZIP with runners for ${form.fusion_output_name || 'each OS'}.` });
|
||||
}
|
||||
if (fusionPrepSelected && form.fusion_media_mode === 'embedded') {
|
||||
checks.push({
|
||||
id: 'fusion_embedded',
|
||||
level: 'warn',
|
||||
message: 'Embedded mode bakes the file into one .exe — use ZIP bundle (paired) for large images/videos or if the build fails.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
14
server/web/src/help/matrixRainEffects.test.ts
Normal file
14
server/web/src/help/matrixRainEffects.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { pickMysticWord, wordColumnSpan, MYSTIC_WORD_DROPS } from './matrixRainEffects';
|
||||
|
||||
describe('matrixRainEffects', () => {
|
||||
it('pickMysticWord returns known words', () => {
|
||||
const w = pickMysticWord();
|
||||
expect(MYSTIC_WORD_DROPS).toContain(w);
|
||||
});
|
||||
|
||||
it('wordColumnSpan matches string length', () => {
|
||||
expect(wordColumnSpan('DESTROY')).toBe(7);
|
||||
expect(wordColumnSpan('BLACK MAGIC')).toBe(11);
|
||||
});
|
||||
});
|
||||
30
server/web/src/help/matrixRainEffects.ts
Normal file
30
server/web/src/help/matrixRainEffects.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/** Words that fall as intact columns through the sidebar matrix rain. */
|
||||
export const MYSTIC_WORD_DROPS = [
|
||||
'DESTROY',
|
||||
'WITCHCRAFT',
|
||||
'BLACKMAGIC',
|
||||
'BLACK MAGIC',
|
||||
'VOID',
|
||||
'CURSE',
|
||||
'BINDING',
|
||||
'SIGNAL',
|
||||
'EXECUTE',
|
||||
'POSSESS',
|
||||
'SUMMON',
|
||||
'AETHER',
|
||||
] as const;
|
||||
|
||||
export const FORGE_RAIN_STRINGS = [
|
||||
'COMPILING', 'LINKING', 'GARBLE', 'GO BUILD', 'INJECT',
|
||||
'STEALTH', 'PERSIST', 'ENCRYPT', 'OBFUSC', 'PACKAGE',
|
||||
'WORKER', 'FORGE', 'SIGN', 'BUNDLE', 'AGENT',
|
||||
'RANDOMX', 'STRATUM', 'C2CONN', 'DEPLOY',
|
||||
] as const;
|
||||
|
||||
export function pickMysticWord(): string {
|
||||
return MYSTIC_WORD_DROPS[Math.floor(Math.random() * MYSTIC_WORD_DROPS.length)];
|
||||
}
|
||||
|
||||
export function wordColumnSpan(text: string): number {
|
||||
return text.length;
|
||||
}
|
||||
14
server/web/src/help/screenshotDownload.test.ts
Normal file
14
server/web/src/help/screenshotDownload.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { sanitizeScreenshotBase64 } from './screenshotDownload';
|
||||
|
||||
describe('screenshotDownload', () => {
|
||||
it('sanitizeScreenshotBase64 picks the longest base64 chunk', () => {
|
||||
const junk = "warning\n/9j/QUJD\n";
|
||||
const b64 = 'A'.repeat(120);
|
||||
expect(sanitizeScreenshotBase64(`${junk}${b64}`)).toBe(b64);
|
||||
});
|
||||
|
||||
it('returns empty when no valid base64', () => {
|
||||
expect(sanitizeScreenshotBase64('not an image')).toBe('');
|
||||
});
|
||||
});
|
||||
42
server/web/src/help/screenshotDownload.ts
Normal file
42
server/web/src/help/screenshotDownload.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/** Strip whitespace and download a remote desktop capture as a JPEG file. */
|
||||
export function sanitizeScreenshotBase64(raw: string): string {
|
||||
const trimmed = raw.trim().replace(/^\uFEFF/, '');
|
||||
let best = '';
|
||||
for (const part of trimmed.split(/\s+/)) {
|
||||
const cleaned = part.replace(/[^A-Za-z0-9+/=]/g, '');
|
||||
if (cleaned.length > best.length && cleaned.length >= 100) {
|
||||
best = cleaned;
|
||||
}
|
||||
}
|
||||
if (best.length >= 100) return best;
|
||||
return trimmed.replace(/[^A-Za-z0-9+/=]/g, '');
|
||||
}
|
||||
|
||||
export function downloadScreenshotFromBase64(
|
||||
base64: string,
|
||||
agentLabel: string
|
||||
): boolean {
|
||||
const clean = sanitizeScreenshotBase64(base64);
|
||||
if (clean.length < 100) return false;
|
||||
|
||||
const safeName = agentLabel.replace(/[^\w.-]+/g, '_').slice(0, 64) || 'agent';
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const blob = base64ToBlob(clean, 'image/jpeg');
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `screenshot-${safeName}-${stamp}.jpg`;
|
||||
a.rel = 'noopener';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
return true;
|
||||
}
|
||||
|
||||
function base64ToBlob(b64: string, mime: string): Blob {
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return new Blob([bytes], { type: mime });
|
||||
}
|
||||
Reference in New Issue
Block a user