Files
cloudly/ts_web/elements/views/baseos/index.ts
T

306 lines
11 KiB
TypeScript
Raw Normal View History

2026-05-07 17:44:31 +00:00
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;
2026-05-07 20:33:14 +00:00
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',
};
2026-05-07 17:44:31 +00:00
@customElement('cloudly-view-baseos')
export class CloudlyViewBaseOs extends DeesElement {
2026-05-08 13:56:20 +00:00
@state() private accessor builds: TBaseOsImageBuild[] = [];
@state() private accessor isLoading = false;
2026-05-07 17:44:31 +00:00
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">
2026-05-07 19:49:56 +00:00
<dees-panel .title=${'Create Image'} .subtitle=${'Build a Cloudly-bound BaseOS artifact'} .variant=${'outline'}>
2026-05-07 17:44:31 +00:00
<dees-form @formData=${(eventArg: CustomEvent) => this.createBuild((eventArg.detail as any).data)}>
2026-05-07 19:49:56 +00:00
<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>
2026-05-07 17:44:31 +00:00
<dees-input-dropdown
.key=${'architecture'}
.label=${'Architecture'}
.selectedOption=${'amd64'}
.options=${[
2026-05-07 19:49:56 +00:00
{ key: 'amd64', option: 'amd64', payload: null },
{ key: 'arm64', option: 'arm64', payload: null },
{ key: 'rpi', option: 'Raspberry Pi', payload: null },
2026-05-07 17:44:31 +00:00
]}
></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>
2026-05-07 20:33:14 +00:00
<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>
2026-05-07 17:44:31 +00:00
<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>
2026-05-07 20:33:14 +00:00
<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>
2026-05-07 17:44:31 +00:00
<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>
2026-05-07 19:49:56 +00:00
<div class="meta">${data.imageKind || 'ubuntu-iso'} · ${data.architecture} · ${data.cloudlyUrl}</div>
2026-05-07 20:33:14 +00:00
${data.sourceImagePreset ? html`<div class="meta">${data.sourceImagePreset} · balenaOS ${data.balenaOsVersion || 'latest'}</div>` : ''}
2026-05-07 17:44:31 +00:00
</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 {
2026-05-07 20:33:14 +00:00
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;
2026-05-07 17:44:31 +00:00
const response = await this.fireBaseOsRequest('createBaseOsImageBuild', {
build: {
2026-05-07 20:33:14 +00:00
architecture,
imageKind,
2026-05-07 17:44:31 +00:00
cloudlyUrl: formDataArg.cloudlyUrl || window.location.origin,
hostname: formDataArg.hostname || undefined,
2026-05-07 20:33:14 +00:00
sourceImageUrl,
sourceImagePreset,
balenaOsVersion,
2026-05-07 17:44:31 +00:00
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;
}
}
2026-05-07 20:33:14 +00:00
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.');
}
}
2026-05-07 17:44:31 +00:00
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;
}
}