384 lines
21 KiB
TypeScript
384 lines
21 KiB
TypeScript
import * as plugins from '../../../plugins.js';
|
|
import * as shared from '../../shared/index.js';
|
|
|
|
import {
|
|
DeesElement,
|
|
customElement,
|
|
html,
|
|
state,
|
|
css,
|
|
cssManager,
|
|
} from '@design.estate/dees-element';
|
|
|
|
import * as appstate from '../../../appstate.js';
|
|
|
|
@customElement('cloudly-view-settings')
|
|
export class CloudlyViewSettings extends DeesElement {
|
|
@state()
|
|
private accessor settings: plugins.interfaces.data.ICloudlySettingsMasked = {} as any;
|
|
|
|
@state()
|
|
private accessor isLoading = false;
|
|
|
|
@state()
|
|
private accessor testResults: {[key: string]: {success: boolean; message: string}} = {};
|
|
|
|
@state()
|
|
private accessor hostedRuntime: appstate.IHostedRuntimeState = {
|
|
isHosted: false,
|
|
loading: false,
|
|
upgradeState: null,
|
|
};
|
|
|
|
constructor() {
|
|
super();
|
|
const hostedRuntimeSubscription = appstate.hostedRuntimeStatePart
|
|
.select((stateArg) => stateArg)
|
|
.subscribe((stateArg) => {
|
|
this.hostedRuntime = stateArg;
|
|
});
|
|
this.rxSubscriptions.push(hostedRuntimeSubscription);
|
|
const loginSubscription = appstate.loginStatePart
|
|
.select((stateArg) => stateArg.identity)
|
|
.subscribe((identityArg) => {
|
|
if (identityArg) {
|
|
void this.refreshHostedRuntimeStatus();
|
|
}
|
|
});
|
|
this.rxSubscriptions.push(loginSubscription);
|
|
this.loadSettings();
|
|
}
|
|
|
|
public static styles = [
|
|
cssManager.defaultStyles,
|
|
shared.viewHostCss,
|
|
css`
|
|
.settings-container { padding: 24px 0; display: flex; flex-direction: column; gap: 16px; }
|
|
.provider-icon { margin-right: 8px; font-size: 20px; }
|
|
.test-status { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
|
|
.test-status dees-button { margin-left: auto; }
|
|
.loading-container { display: flex; justify-content: center; padding: 48px; }
|
|
.actions-container { display: flex; justify-content: center; margin-top: 24px; }
|
|
dees-panel { margin-bottom: 16px; }
|
|
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
.form-grid.single { grid-template-columns: 1fr; }
|
|
.runtime-panel { display: grid; gap: 16px; }
|
|
.runtime-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
|
.runtime-card { border: 1px solid var(--ci-shade-2, #27272a); border-radius: 8px; padding: 12px; background: var(--ci-shade-1, #09090b); }
|
|
.runtime-label { color: var(--ci-shade-4, #71717a); font-size: 12px; margin-bottom: 6px; }
|
|
.runtime-value { color: var(--ci-shade-7, #e4e4e7); font-size: 14px; font-weight: 600; overflow-wrap: anywhere; }
|
|
.runtime-message { color: var(--ci-shade-5, #a1a1aa); font-size: 13px; line-height: 1.5; }
|
|
.runtime-message.error { color: #ef4444; }
|
|
.runtime-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
|
@media (max-width: 768px) { .form-grid { grid-template-columns: 1fr; } }
|
|
@media (max-width: 768px) { .runtime-grid { grid-template-columns: 1fr; } }
|
|
`,
|
|
];
|
|
|
|
public async connectedCallback() {
|
|
super.connectedCallback();
|
|
await this.refreshHostedRuntimeStatus();
|
|
}
|
|
|
|
private async loadSettings() {
|
|
this.isLoading = true;
|
|
try {
|
|
const response = await appstate.apiClient.settings.getSettings();
|
|
this.settings = response.settings;
|
|
} catch (error: any) {
|
|
console.error('Failed to load settings:', error);
|
|
plugins.deesCatalog.DeesToast.createAndShow({ message: `Failed to load settings: ${error.message}`, type: 'error' });
|
|
} finally {
|
|
this.isLoading = false;
|
|
}
|
|
}
|
|
|
|
private async saveSettings(formData: any) {
|
|
this.isLoading = true;
|
|
try {
|
|
const updates: Partial<plugins.interfaces.data.ICloudlySettings> = {};
|
|
for (const [key, value] of Object.entries(formData)) {
|
|
if (value !== undefined && value !== '****' && !value?.toString().startsWith('****')) {
|
|
updates[key as keyof plugins.interfaces.data.ICloudlySettings] = value as string;
|
|
}
|
|
}
|
|
const response = await appstate.apiClient.settings.updateSettings(updates);
|
|
if (response.success) {
|
|
plugins.deesCatalog.DeesToast.createAndShow({ message: 'Settings saved successfully', type: 'success' });
|
|
await this.loadSettings();
|
|
} else {
|
|
throw new Error(response.message);
|
|
}
|
|
} catch (error: any) {
|
|
console.error('Failed to save settings:', error);
|
|
plugins.deesCatalog.DeesToast.createAndShow({ message: `Failed to save settings: ${error.message}`, type: 'error' });
|
|
} finally {
|
|
this.isLoading = false;
|
|
}
|
|
}
|
|
|
|
private async testConnection(provider: string) {
|
|
this.isLoading = true;
|
|
try {
|
|
const response = await appstate.apiClient.settings.testProviderConnection(provider);
|
|
this.testResults = { ...this.testResults, [provider]: { success: response.connectionValid, message: response.message } };
|
|
plugins.deesCatalog.DeesToast.createAndShow({ message: response.message, type: response.connectionValid ? 'success' : 'error' });
|
|
} catch (error: any) {
|
|
this.testResults = { ...this.testResults, [provider]: { success: false, message: `Test failed: ${error.message}` } };
|
|
plugins.deesCatalog.DeesToast.createAndShow({ message: `Connection test failed: ${error.message}`, type: 'error' });
|
|
} finally {
|
|
this.isLoading = false;
|
|
}
|
|
}
|
|
|
|
private renderProviderStatus(provider: string) {
|
|
const result = this.testResults[provider];
|
|
if (!result) return '' as any;
|
|
return html`<dees-badge .type=${result.success ? 'success' : 'error'} .text=${result.success ? 'Connected' : 'Failed'}></dees-badge>`;
|
|
}
|
|
|
|
private async refreshHostedRuntimeStatus() {
|
|
if (!appstate.loginStatePart.getState()?.identity) {
|
|
return;
|
|
}
|
|
await appstate.hostedRuntimeStatePart.dispatchAction(appstate.fetchHostedRuntimeUpgradeStatusAction, null);
|
|
}
|
|
|
|
private getHostedRuntimeBadgeType() {
|
|
const status = this.hostedRuntime.upgradeState?.status;
|
|
if (!this.hostedRuntime.isHosted) return 'info';
|
|
if (status === 'failed') return 'error';
|
|
if (status === 'upToDate' || status === 'success') return 'success';
|
|
return 'info';
|
|
}
|
|
|
|
private getHostedRuntimeStatusText() {
|
|
if (!this.hostedRuntime.isHosted) return 'Not hosted';
|
|
const status = this.hostedRuntime.upgradeState?.status || 'unknown';
|
|
switch (status) {
|
|
case 'upToDate': return 'Up to date';
|
|
case 'available': return 'Update available';
|
|
case 'running': return 'Upgrade running';
|
|
case 'success': return 'Upgrade complete';
|
|
case 'failed': return 'Upgrade failed';
|
|
default: return 'Unknown';
|
|
}
|
|
}
|
|
|
|
private getHostedRuntimeMessage() {
|
|
if (!this.hostedRuntime.isHosted) {
|
|
return this.hostedRuntime.unavailableReason || 'This Cloudly instance is not running as a managed hosted app.';
|
|
}
|
|
if (this.hostedRuntime.unavailableReason) {
|
|
return this.hostedRuntime.unavailableReason;
|
|
}
|
|
const upgradeState = this.hostedRuntime.upgradeState;
|
|
if (upgradeState?.status === 'available') {
|
|
return `Parent host can upgrade Cloudly from ${upgradeState.currentVersion || 'current'} to ${upgradeState.targetVersion || upgradeState.latestVersion}.`;
|
|
}
|
|
if (upgradeState?.status === 'running') {
|
|
return 'The parent host is upgrading this Cloudly service. Status refreshes automatically.';
|
|
}
|
|
if (upgradeState?.status === 'failed') {
|
|
return upgradeState.error || 'The last parent-hosted upgrade failed.';
|
|
}
|
|
return 'Parent hosted runtime status is available. No upgrade action is currently required.';
|
|
}
|
|
|
|
private async startHostedRuntimeUpgrade() {
|
|
const upgradeState = this.hostedRuntime.upgradeState;
|
|
const targetVersion = upgradeState?.targetVersion || upgradeState?.latestVersion;
|
|
if (!targetVersion) {
|
|
plugins.deesCatalog.DeesToast.createAndShow({ message: 'No hosted runtime upgrade target is available.', type: 'error' });
|
|
return;
|
|
}
|
|
|
|
let upgradeStarting = false;
|
|
await plugins.deesCatalog.DeesModal.createAndShow({
|
|
heading: 'Upgrade Hosted Cloudly',
|
|
content: html`
|
|
<div style="width: min(560px, calc(100vw - 48px)); max-width: 100%; color: var(--ci-shade-5, #a1a1aa); line-height: 1.5;">
|
|
The parent host will upgrade this Cloudly app from ${upgradeState?.currentVersion || 'current'} to ${targetVersion} using its hosted app lifecycle controls.
|
|
</div>
|
|
`,
|
|
menuOptions: [
|
|
{
|
|
name: 'Start Upgrade',
|
|
action: async (modalArg: any) => {
|
|
if (upgradeStarting) return;
|
|
upgradeStarting = true;
|
|
try {
|
|
await appstate.hostedRuntimeStatePart.dispatchAction(appstate.startHostedRuntimeParentUpgradeAction, { targetVersion });
|
|
plugins.deesCatalog.DeesToast.createAndShow({ message: 'Hosted runtime upgrade started.', type: 'success' });
|
|
await modalArg.destroy();
|
|
} catch (error) {
|
|
upgradeStarting = false;
|
|
plugins.deesCatalog.DeesToast.createAndShow({ message: `Upgrade failed: ${(error as Error).message}`, type: 'error' });
|
|
}
|
|
},
|
|
},
|
|
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
|
],
|
|
});
|
|
}
|
|
|
|
private renderHostedRuntimePanel() {
|
|
const upgradeState = this.hostedRuntime.upgradeState;
|
|
const canStartUpgrade = this.hostedRuntime.isHosted && upgradeState?.status === 'available' && !this.hostedRuntime.loading;
|
|
return html`
|
|
<dees-panel .title=${'Hosted Runtime'} .subtitle=${'Manage this Cloudly instance through its parent serve.zone host'} .variant=${'outline'}>
|
|
<div class="runtime-panel">
|
|
<div class="runtime-grid">
|
|
<div class="runtime-card">
|
|
<div class="runtime-label">Runtime</div>
|
|
<div class="runtime-value">${this.hostedRuntime.isHosted ? 'Managed hosted app' : 'Standalone'}</div>
|
|
</div>
|
|
<div class="runtime-card">
|
|
<div class="runtime-label">Upgrade Status</div>
|
|
<div class="runtime-value"><dees-badge .type=${this.getHostedRuntimeBadgeType()} .text=${this.getHostedRuntimeStatusText()}></dees-badge></div>
|
|
</div>
|
|
<div class="runtime-card">
|
|
<div class="runtime-label">Version</div>
|
|
<div class="runtime-value">${upgradeState?.currentVersion || '-'}${upgradeState?.latestVersion ? ` / ${upgradeState.latestVersion}` : ''}</div>
|
|
</div>
|
|
</div>
|
|
<div class=${`runtime-message ${this.hostedRuntime.unavailableReason || upgradeState?.status === 'failed' ? 'error' : ''}`}>
|
|
${this.getHostedRuntimeMessage()}
|
|
</div>
|
|
${upgradeState?.warnings?.length ? html`<div class="runtime-message">${upgradeState.warnings.join(' | ')}</div>` : ''}
|
|
<div class="runtime-actions">
|
|
<dees-button .text=${this.hostedRuntime.loading ? 'Refreshing...' : 'Refresh Status'} .type=${'secondary'} .disabled=${this.hostedRuntime.loading} @click=${() => this.refreshHostedRuntimeStatus()}></dees-button>
|
|
<dees-button .text=${upgradeState?.status === 'running' ? 'Upgrade Running' : 'Start Parent Upgrade'} .type=${'primary'} .disabled=${!canStartUpgrade} @click=${() => this.startHostedRuntimeUpgrade()}></dees-button>
|
|
</div>
|
|
</div>
|
|
</dees-panel>
|
|
`;
|
|
}
|
|
|
|
public render() {
|
|
if (this.isLoading && Object.keys(this.settings).length === 0) {
|
|
return html`<div class="loading-container"><dees-spinner></dees-spinner></div>`;
|
|
}
|
|
return html`
|
|
<cloudly-sectionheading>Settings</cloudly-sectionheading>
|
|
<div class="settings-container">
|
|
${this.renderHostedRuntimePanel()}
|
|
<dees-form @formData=${(e: CustomEvent) => { this.saveSettings((e.detail as any).data); }}>
|
|
<dees-panel .title=${'Hetzner Cloud'} .subtitle=${'Configure Hetzner Cloud API access'} .variant=${'outline'}>
|
|
<div class="test-status">
|
|
${this.renderProviderStatus('hetzner')}
|
|
<dees-button .text=${'Test Connection'} .type=${'secondary'} @click=${(e: Event) => { e.preventDefault(); e.stopPropagation(); this.testConnection('hetzner'); }}></dees-button>
|
|
</div>
|
|
<div class="form-grid single">
|
|
<dees-input-text .key=${'hetznerToken'} .label=${'API Token'} .value=${this.settings.hetznerToken || ''} .isPasswordBool=${true} .description=${'Your Hetzner Cloud API token for managing infrastructure'} .required=${false}></dees-input-text>
|
|
</div>
|
|
</dees-panel>
|
|
|
|
<dees-panel .title=${'Cloudflare'} .subtitle=${'Configure Cloudflare API access'} .variant=${'outline'}>
|
|
<div class="test-status">
|
|
${this.renderProviderStatus('cloudflare')}
|
|
<dees-button .text=${'Test Connection'} .type=${'secondary'} @click=${(e: Event) => { e.preventDefault(); e.stopPropagation(); this.testConnection('cloudflare'); }}></dees-button>
|
|
</div>
|
|
<div class="form-grid single">
|
|
<dees-input-text .key=${'cloudflareToken'} .label=${'API Token'} .value=${this.settings.cloudflareToken || ''} .isPasswordBool=${true} .description=${'Cloudflare API token with DNS and Zone permissions'} .required=${false}></dees-input-text>
|
|
</div>
|
|
</dees-panel>
|
|
|
|
<dees-panel .title=${'External dcrouter Gateway'} .subtitle=${'Route WorkApps through a dcrouter edge authority'} .variant=${'outline'}>
|
|
<div class="form-grid">
|
|
<dees-input-text .key=${'dcrouterGatewayUrl'} .label=${'Gateway URL'} .value=${this.settings.dcrouterGatewayUrl || ''} .description=${'Base URL of the dcrouter OpsServer, for example https://edge.example.com'} .required=${false}></dees-input-text>
|
|
<dees-input-text .key=${'dcrouterGatewayApiToken'} .label=${'API Token'} .value=${this.settings.dcrouterGatewayApiToken || ''} .isPasswordBool=${true} .description=${'dcrouter API token with workhosters and certificates scopes'} .required=${false}></dees-input-text>
|
|
</div>
|
|
<div class="form-grid">
|
|
<dees-input-text .key=${'dcrouterWorkHosterId'} .label=${'WorkHoster ID'} .value=${this.settings.dcrouterWorkHosterId || ''} .description=${'Optional stable owner ID; defaults to the cluster ID'} .required=${false}></dees-input-text>
|
|
<dees-input-text .key=${'dcrouterTargetHost'} .label=${'Target Host'} .value=${this.settings.dcrouterTargetHost || ''} .description=${'Host or IP dcrouter forwards workload traffic to'} .required=${false}></dees-input-text>
|
|
</div>
|
|
<div class="form-grid single">
|
|
<dees-input-text .key=${'dcrouterTargetPort'} .label=${'Target Port'} .value=${this.settings.dcrouterTargetPort || '80'} .description=${'Internal HTTP port dcrouter should forward to'} .required=${false}></dees-input-text>
|
|
</div>
|
|
</dees-panel>
|
|
|
|
<dees-panel .title=${'CoreBuild Worker'} .subtitle=${'Build BaseOS images on a capable worker node'} .variant=${'outline'}>
|
|
<div class="form-grid">
|
|
<dees-input-text .key=${'corebuildWorkerUrl'} .label=${'Worker URL'} .value=${this.settings.corebuildWorkerUrl || ''} .description=${'Base URL of the corebuild worker, for example http://10.0.0.20:3060'} .required=${false}></dees-input-text>
|
|
<dees-input-text .key=${'corebuildWorkerToken'} .label=${'Worker Token'} .value=${this.settings.corebuildWorkerToken || ''} .isPasswordBool=${true} .description=${'Shared token accepted by the corebuild worker'} .required=${false}></dees-input-text>
|
|
</div>
|
|
<div class="form-grid single">
|
|
<dees-input-textarea .key=${'corebuildWorkersJson'} .label=${'Worker Registry JSON'} .value=${this.settings.corebuildWorkersJson || ''} .isPasswordBool=${true} .description=${'Optional JSON array of workers: [{"id":"builder-1","url":"http://10.0.0.20:3060","token":"secret"}]'} .required=${false}></dees-input-textarea>
|
|
</div>
|
|
</dees-panel>
|
|
|
|
<dees-panel .title=${'Amazon Web Services'} .subtitle=${'Configure AWS credentials'} .variant=${'outline'}>
|
|
<div class="test-status">
|
|
${this.renderProviderStatus('aws')}
|
|
<dees-button .text=${'Test Connection'} .type=${'secondary'} @click=${(e: Event) => { e.preventDefault(); e.stopPropagation(); this.testConnection('aws'); }}></dees-button>
|
|
</div>
|
|
<div class="form-grid">
|
|
<dees-input-text .key=${'awsAccessKey'} .label=${'Access Key ID'} .value=${this.settings.awsAccessKey || ''} .isPasswordBool=${true} .description=${'AWS IAM access key identifier'} .required=${false}></dees-input-text>
|
|
<dees-input-text .key=${'awsSecretKey'} .label=${'Secret Access Key'} .value=${this.settings.awsSecretKey || ''} .isPasswordBool=${true} .description=${'AWS IAM secret access key'} .required=${false}></dees-input-text>
|
|
</div>
|
|
<div class="form-grid single">
|
|
<dees-input-dropdown .key=${'awsRegion'} .label=${'Default Region'} .selectedOption=${this.settings.awsRegion || 'us-east-1'} .options=${[
|
|
{ key: 'us-east-1', option: 'US East (N. Virginia)', payload: null },
|
|
{ key: 'us-west-2', option: 'US West (Oregon)', payload: null },
|
|
{ key: 'eu-west-1', option: 'EU (Ireland)', payload: null },
|
|
{ key: 'eu-central-1', option: 'EU (Frankfurt)', payload: null },
|
|
{ key: 'ap-southeast-1', option: 'Asia Pacific (Singapore)', payload: null },
|
|
{ key: 'ap-northeast-1', option: 'Asia Pacific (Tokyo)', payload: null },
|
|
]} .description=${'Default AWS region for resource provisioning'}></dees-input-dropdown>
|
|
</div>
|
|
</dees-panel>
|
|
|
|
<dees-panel .title=${'DigitalOcean'} .subtitle=${'Configure DigitalOcean API access'} .variant=${'outline'}>
|
|
<div class="test-status">
|
|
${this.renderProviderStatus('digitalocean')}
|
|
<dees-button .text=${'Test Connection'} .type=${'secondary'} @click=${(e: Event) => { e.preventDefault(); e.stopPropagation(); this.testConnection('digitalocean'); }}></dees-button>
|
|
</div>
|
|
<div class="form-grid single">
|
|
<dees-input-text .key=${'digitalOceanToken'} .label=${'Personal Access Token'} .value=${this.settings.digitalOceanToken || ''} .isPasswordBool=${true} .description=${'DigitalOcean personal access token with read/write scope'} .required=${false}></dees-input-text>
|
|
</div>
|
|
</dees-panel>
|
|
|
|
<dees-panel .title=${'Microsoft Azure'} .subtitle=${'Configure Azure service principal'} .variant=${'outline'}>
|
|
<div class="test-status">
|
|
${this.renderProviderStatus('azure')}
|
|
<dees-button .text=${'Test Connection'} .type=${'secondary'} @click=${(e: Event) => { e.preventDefault(); e.stopPropagation(); this.testConnection('azure'); }}></dees-button>
|
|
</div>
|
|
<div class="form-grid">
|
|
<dees-input-text .key=${'azureClientId'} .label=${'Application (Client) ID'} .value=${this.settings.azureClientId || ''} .isPasswordBool=${true} .description=${'Azure AD application client ID'} .required=${false}></dees-input-text>
|
|
<dees-input-text .key=${'azureClientSecret'} .label=${'Client Secret'} .value=${this.settings.azureClientSecret || ''} .isPasswordBool=${true} .description=${'Azure AD application client secret'} .required=${false}></dees-input-text>
|
|
</div>
|
|
<div class="form-grid">
|
|
<dees-input-text .key=${'azureTenantId'} .label=${'Directory (Tenant) ID'} .value=${this.settings.azureTenantId || ''} .description=${'Azure AD tenant identifier'} .required=${false}></dees-input-text>
|
|
<dees-input-text .key=${'azureSubscriptionId'} .label=${'Subscription ID'} .value=${this.settings.azureSubscriptionId || ''} .description=${'Azure subscription for resource management'} .required=${false}></dees-input-text>
|
|
</div>
|
|
</dees-panel>
|
|
|
|
<dees-panel .title=${'Google Cloud Platform'} .subtitle=${'Configure GCP service account'} .variant=${'outline'}>
|
|
<div class="test-status">
|
|
${this.renderProviderStatus('google')}
|
|
<dees-button .text=${'Test Connection'} .type=${'secondary'} @click=${(e: Event) => { e.preventDefault(); e.stopPropagation(); this.testConnection('google'); }}></dees-button>
|
|
</div>
|
|
<div class="form-grid single">
|
|
<dees-input-textarea .key=${'googleCloudKeyJson'} .label=${'Service Account Key (JSON)'} .value=${this.settings.googleCloudKeyJson || ''} .isPasswordBool=${true} .description=${'Complete JSON key file for service account authentication'} .required=${false}></dees-input-textarea>
|
|
</div>
|
|
<div class="form-grid single">
|
|
<dees-input-text .key=${'googleCloudProjectId'} .label=${'Project ID'} .value=${this.settings.googleCloudProjectId || ''} .description=${'Google Cloud project identifier'} .required=${false}></dees-input-text>
|
|
</div>
|
|
</dees-panel>
|
|
|
|
<div class="actions-container">
|
|
<dees-form-submit .text=${'Save All Settings'} .disabled=${this.isLoading}></dees-form-submit>
|
|
</div>
|
|
</dees-form>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'cloudly-view-settings': CloudlyViewSettings;
|
|
}
|
|
}
|