306 lines
11 KiB
TypeScript
306 lines
11 KiB
TypeScript
import * as plugins from '../../../plugins.js';
|
|
import * as appstate from '../../../appstate.js';
|
|
import * as shared from '../../shared/index.js';
|
|
|
|
import {
|
|
DeesElement,
|
|
css,
|
|
cssManager,
|
|
customElement,
|
|
html,
|
|
state,
|
|
} from '@design.estate/dees-element';
|
|
|
|
type TBaseOsImageBuild = any;
|
|
type TBaseOsImageSourcePreset =
|
|
| 'balena-generic-amd64'
|
|
| 'balena-generic-aarch64'
|
|
| 'balena-raspberrypi4-64';
|
|
|
|
const sourcePresetArchitectures: Record<TBaseOsImageSourcePreset, string> = {
|
|
'balena-generic-amd64': 'amd64',
|
|
'balena-generic-aarch64': 'arm64',
|
|
'balena-raspberrypi4-64': 'rpi',
|
|
};
|
|
|
|
@customElement('cloudly-view-baseos')
|
|
export class CloudlyViewBaseOs extends DeesElement {
|
|
@state() private builds: TBaseOsImageBuild[] = [];
|
|
@state() private isLoading = false;
|
|
|
|
private refreshTimer?: number;
|
|
|
|
public static styles = [
|
|
cssManager.defaultStyles,
|
|
shared.viewHostCss,
|
|
css`
|
|
.layout {
|
|
display: grid;
|
|
grid-template-columns: 420px 1fr;
|
|
gap: 16px;
|
|
padding: 24px 0;
|
|
}
|
|
|
|
.builds {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
.build {
|
|
border: 1px solid #2a2f3a;
|
|
border-radius: 12px;
|
|
padding: 16px;
|
|
background: #10151f;
|
|
}
|
|
|
|
.build-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.meta {
|
|
color: #9aa4b2;
|
|
font-size: 13px;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.logs {
|
|
margin-top: 12px;
|
|
max-height: 120px;
|
|
overflow: auto;
|
|
font-family: monospace;
|
|
font-size: 12px;
|
|
color: #bac4d1;
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.layout {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
`,
|
|
];
|
|
|
|
public async connectedCallback() {
|
|
await super.connectedCallback();
|
|
await this.loadBuilds();
|
|
this.refreshTimer = window.setInterval(() => this.loadBuilds(), 5000);
|
|
}
|
|
|
|
public async disconnectedCallback() {
|
|
await super.disconnectedCallback();
|
|
if (this.refreshTimer) {
|
|
window.clearInterval(this.refreshTimer);
|
|
}
|
|
}
|
|
|
|
public render() {
|
|
return html`
|
|
<cloudly-sectionheading>BaseOS Images</cloudly-sectionheading>
|
|
<div class="layout">
|
|
<dees-panel .title=${'Create Image'} .subtitle=${'Build a Cloudly-bound BaseOS artifact'} .variant=${'outline'}>
|
|
<dees-form @formData=${(eventArg: CustomEvent) => this.createBuild((eventArg.detail as any).data)}>
|
|
<dees-input-dropdown
|
|
.key=${'imageKind'}
|
|
.label=${'Image Type'}
|
|
.selectedOption=${'balena-raw'}
|
|
.options=${[
|
|
{ key: 'balena-raw', option: 'balenaOS raw image', payload: null },
|
|
{ key: 'ubuntu-iso', option: 'Ubuntu bootstrap ISO', payload: null },
|
|
]}
|
|
></dees-input-dropdown>
|
|
<dees-input-dropdown
|
|
.key=${'architecture'}
|
|
.label=${'Architecture'}
|
|
.selectedOption=${'amd64'}
|
|
.options=${[
|
|
{ key: 'amd64', option: 'amd64', payload: null },
|
|
{ key: 'arm64', option: 'arm64', payload: null },
|
|
{ key: 'rpi', option: 'Raspberry Pi', payload: null },
|
|
]}
|
|
></dees-input-dropdown>
|
|
<dees-input-text .key=${'cloudlyUrl'} .label=${'Cloudly URL'} .value=${window.location.origin} .required=${true}></dees-input-text>
|
|
<dees-input-text .key=${'hostname'} .label=${'Hostname'} .value=${'baseos-node'} .required=${false}></dees-input-text>
|
|
<dees-input-dropdown
|
|
.key=${'sourceImagePreset'}
|
|
.label=${'balenaOS Source Preset'}
|
|
.selectedOption=${'auto'}
|
|
.options=${[
|
|
{ key: 'auto', option: 'Auto by architecture', payload: null },
|
|
{ key: 'balena-generic-amd64', option: 'Generic x86_64 (GPT)', payload: null },
|
|
{ key: 'balena-generic-aarch64', option: 'Generic AARCH64', payload: null },
|
|
{ key: 'balena-raspberrypi4-64', option: 'Raspberry Pi 4 64-bit', payload: null },
|
|
{ key: 'custom-url', option: 'Custom source URL', payload: null },
|
|
]}
|
|
.description=${'Used for balenaOS raw images when no custom source URL is provided.'}
|
|
></dees-input-dropdown>
|
|
<dees-input-text .key=${'balenaOsVersion'} .label=${'balenaOS Version'} .value=${'latest'} .description=${'Use latest, or an explicit balenaOS raw_version such as 2026.1.0.'} .required=${false}></dees-input-text>
|
|
<dees-input-text .key=${'wifiSsid'} .label=${'WiFi SSID'} .required=${false}></dees-input-text>
|
|
<dees-input-text .key=${'wifiPassword'} .label=${'WiFi Password'} .isPasswordBool=${true} .required=${false}></dees-input-text>
|
|
<dees-input-textarea .key=${'sshPublicKey'} .label=${'SSH Public Key'} .required=${false}></dees-input-textarea>
|
|
<dees-input-text .key=${'sourceImageUrl'} .label=${'Custom Source Image URL'} .description=${'Optional override for balenaOS raw images (.img, .img.xz, .zip, or balena download URL with fileType=.zip).'} .required=${false}></dees-input-text>
|
|
<dees-form-submit .text=${this.isLoading ? 'Creating...' : 'Create BaseOS Image'} .disabled=${this.isLoading}></dees-form-submit>
|
|
</dees-form>
|
|
</dees-panel>
|
|
<div class="builds">
|
|
${this.builds.length === 0
|
|
? html`<dees-panel .title=${'No image builds yet'} .subtitle=${'Create an image to start a corebuild job.'}></dees-panel>`
|
|
: this.builds.map((buildArg) => this.renderBuild(buildArg))}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderBuild(buildArg: TBaseOsImageBuild) {
|
|
const data = buildArg.data;
|
|
return html`
|
|
<div class="build">
|
|
<div class="build-head">
|
|
<div>
|
|
<strong>${data.hostname || buildArg.id}</strong>
|
|
<div class="meta">${data.imageKind || 'ubuntu-iso'} · ${data.architecture} · ${data.cloudlyUrl}</div>
|
|
${data.sourceImagePreset ? html`<div class="meta">${data.sourceImagePreset} · balenaOS ${data.balenaOsVersion || 'latest'}</div>` : ''}
|
|
</div>
|
|
<dees-badge .text=${data.status} .type=${data.status === 'ready' ? 'success' : data.status === 'failed' ? 'error' : 'info'}></dees-badge>
|
|
</div>
|
|
<div class="meta">
|
|
${data.artifact ? `${data.artifact.filename} · ${Math.round(data.artifact.size / 1024 / 1024)} MB · ${data.artifact.sha256.slice(0, 12)}...` : data.errorText || 'Waiting for artifact'}
|
|
</div>
|
|
${data.status === 'ready'
|
|
? html`<dees-button .text=${'Download'} .type=${'primary'} @click=${() => this.downloadBuild(buildArg.id)}></dees-button>`
|
|
: ''}
|
|
${data.logs?.length ? html`<div class="logs">${data.logs.slice(-8).join('\n')}</div>` : ''}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private async loadBuilds() {
|
|
try {
|
|
const response = await this.fireBaseOsRequest('getBaseOsImageBuilds', {});
|
|
this.builds = response.builds || [];
|
|
} catch (error) {
|
|
console.error('Failed to load BaseOS image builds:', error);
|
|
}
|
|
}
|
|
|
|
private async createBuild(formDataArg: any) {
|
|
try {
|
|
const architecture = formDataArg.architecture || 'amd64';
|
|
const imageKind = formDataArg.imageKind || 'balena-raw';
|
|
const sourceImageUrl = formDataArg.sourceImageUrl?.trim() || undefined;
|
|
const selectedSourceImagePreset = formDataArg.sourceImagePreset || 'auto';
|
|
const sourceImagePreset = this.getSourceImagePreset(selectedSourceImagePreset, imageKind, sourceImageUrl);
|
|
const balenaOsVersion = imageKind === 'balena-raw' && !sourceImageUrl
|
|
? formDataArg.balenaOsVersion?.trim() || 'latest'
|
|
: undefined;
|
|
this.validateBuildForm({
|
|
architecture,
|
|
imageKind,
|
|
selectedSourceImagePreset,
|
|
sourceImagePreset,
|
|
sourceImageUrl,
|
|
wifiSsid: formDataArg.wifiSsid,
|
|
wifiPassword: formDataArg.wifiPassword,
|
|
});
|
|
this.isLoading = true;
|
|
const response = await this.fireBaseOsRequest('createBaseOsImageBuild', {
|
|
build: {
|
|
architecture,
|
|
imageKind,
|
|
cloudlyUrl: formDataArg.cloudlyUrl || window.location.origin,
|
|
hostname: formDataArg.hostname || undefined,
|
|
sourceImageUrl,
|
|
sourceImagePreset,
|
|
balenaOsVersion,
|
|
wifi: formDataArg.wifiSsid
|
|
? {
|
|
ssid: formDataArg.wifiSsid,
|
|
password: formDataArg.wifiPassword || undefined,
|
|
}
|
|
: undefined,
|
|
sshPublicKey: formDataArg.sshPublicKey || undefined,
|
|
},
|
|
});
|
|
this.builds = [response.build, ...this.builds];
|
|
plugins.deesCatalog.DeesToast.createAndShow({ message: 'BaseOS image build queued', type: 'success' });
|
|
} catch (error: any) {
|
|
plugins.deesCatalog.DeesToast.createAndShow({ message: `Failed to create image build: ${error.message}`, type: 'error' });
|
|
} finally {
|
|
this.isLoading = false;
|
|
}
|
|
}
|
|
|
|
private getSourceImagePreset(
|
|
sourceImagePresetArg: string | undefined,
|
|
imageKindArg: string,
|
|
sourceImageUrlArg?: string,
|
|
) {
|
|
if (imageKindArg !== 'balena-raw' || sourceImageUrlArg || sourceImagePresetArg === 'auto') {
|
|
return undefined;
|
|
}
|
|
if (sourceImagePresetArg === 'custom-url') {
|
|
return undefined;
|
|
}
|
|
return sourceImagePresetArg as TBaseOsImageSourcePreset | undefined;
|
|
}
|
|
|
|
private validateBuildForm(optionsArg: {
|
|
architecture: string;
|
|
imageKind: string;
|
|
selectedSourceImagePreset?: string;
|
|
sourceImagePreset?: TBaseOsImageSourcePreset;
|
|
sourceImageUrl?: string;
|
|
wifiSsid?: string;
|
|
wifiPassword?: string;
|
|
}) {
|
|
if (optionsArg.architecture === 'rpi' && optionsArg.imageKind === 'ubuntu-iso') {
|
|
throw new Error('Raspberry Pi BaseOS images require the balenaOS raw image type.');
|
|
}
|
|
if (
|
|
optionsArg.imageKind === 'balena-raw'
|
|
&& optionsArg.selectedSourceImagePreset === 'custom-url'
|
|
&& !optionsArg.sourceImageUrl
|
|
) {
|
|
throw new Error('A custom source image URL is required when the custom source preset is selected.');
|
|
}
|
|
if (optionsArg.imageKind === 'balena-raw' && optionsArg.sourceImagePreset) {
|
|
const expectedArchitecture = sourcePresetArchitectures[optionsArg.sourceImagePreset];
|
|
if (expectedArchitecture !== optionsArg.architecture) {
|
|
throw new Error(`${optionsArg.sourceImagePreset} is only valid for ${expectedArchitecture} images.`);
|
|
}
|
|
}
|
|
if (optionsArg.wifiPassword && !optionsArg.wifiSsid) {
|
|
throw new Error('A WiFi SSID is required when a WiFi password is set.');
|
|
}
|
|
}
|
|
|
|
private async downloadBuild(buildIdArg: string) {
|
|
const response = await this.fireBaseOsRequest('createBaseOsImageDownloadUrl', {
|
|
buildId: buildIdArg,
|
|
});
|
|
window.location.href = response.url;
|
|
}
|
|
|
|
private async fireBaseOsRequest(methodArg: string, payloadArg: Record<string, unknown>) {
|
|
appstate.apiClient.identity = appstate.loginStatePart.getState()?.identity || null as any;
|
|
if (!appstate.apiClient.typedsocketClient) {
|
|
await appstate.apiClient.start();
|
|
}
|
|
const request = appstate.apiClient.typedsocketClient.createTypedRequest<any>(methodArg);
|
|
return await request.fire({
|
|
identity: appstate.apiClient.identity,
|
|
...payloadArg,
|
|
});
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'cloudly-view-baseos': CloudlyViewBaseOs;
|
|
}
|
|
}
|