613 lines
18 KiB
TypeScript
613 lines
18 KiB
TypeScript
import * as plugins from '../plugins.js';
|
|
import * as shared from './shared/index.js';
|
|
import * as appstate from '../appstate.js';
|
|
import * as interfaces from '../../ts_interfaces/index.js';
|
|
import { appRouter } from '../router.js';
|
|
import {
|
|
DeesElement,
|
|
customElement,
|
|
html,
|
|
state,
|
|
css,
|
|
cssManager,
|
|
type TemplateResult,
|
|
} from '@design.estate/dees-element';
|
|
|
|
@customElement('ob-view-appstore')
|
|
export class ObViewAppStore extends DeesElement {
|
|
@state()
|
|
accessor appStoreState: appstate.IAppStoreState = {
|
|
apps: [],
|
|
upgradeableServices: [],
|
|
};
|
|
|
|
@state()
|
|
accessor currentView: 'grid' | 'detail' = 'grid';
|
|
|
|
@state()
|
|
accessor selectedApp: interfaces.requests.ICatalogApp | null = null;
|
|
|
|
@state()
|
|
accessor selectedAppMeta: interfaces.requests.IAppMeta | null = null;
|
|
|
|
@state()
|
|
accessor selectedAppConfig: interfaces.requests.IAppVersionConfig | null = null;
|
|
|
|
@state()
|
|
accessor selectedVersion: string = '';
|
|
|
|
@state()
|
|
accessor editableEnvVars: Array<{ key: string; value: string; description: string; required?: boolean; platformInjected?: boolean }> = [];
|
|
|
|
@state()
|
|
accessor serviceName: string = '';
|
|
|
|
@state()
|
|
accessor loading: boolean = false;
|
|
|
|
@state()
|
|
accessor deployMode: boolean = false;
|
|
|
|
public static styles = [
|
|
cssManager.defaultStyles,
|
|
shared.viewHostCss,
|
|
css`
|
|
.detail-card {
|
|
background: var(--ci-shade-1, #09090b);
|
|
border: 1px solid var(--ci-shade-2, #27272a);
|
|
border-radius: 8px;
|
|
padding: 24px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.detail-header {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 16px;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.detail-icon {
|
|
width: 64px;
|
|
height: 64px;
|
|
border-radius: 12px;
|
|
background: var(--ci-shade-2, #27272a);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 28px;
|
|
font-weight: 700;
|
|
color: var(--ci-shade-5, #a1a1aa);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.detail-title {
|
|
font-size: 24px;
|
|
font-weight: 700;
|
|
color: var(--ci-shade-7, #e4e4e7);
|
|
margin: 0 0 4px 0;
|
|
}
|
|
|
|
.detail-category {
|
|
display: inline-block;
|
|
padding: 2px 10px;
|
|
border-radius: 9999px;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
background: var(--ci-shade-2, #27272a);
|
|
color: var(--ci-shade-5, #a1a1aa);
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.detail-description {
|
|
font-size: 14px;
|
|
color: var(--ci-shade-5, #a1a1aa);
|
|
line-height: 1.6;
|
|
margin: 0;
|
|
}
|
|
|
|
.detail-meta {
|
|
display: flex;
|
|
gap: 16px;
|
|
margin-top: 8px;
|
|
font-size: 13px;
|
|
color: var(--ci-shade-4, #71717a);
|
|
}
|
|
|
|
.detail-meta a {
|
|
color: var(--ci-shade-5, #a1a1aa);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.detail-meta a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.section-label {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: var(--ci-shade-5, #a1a1aa);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 4px 10px;
|
|
border-radius: 6px;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
background: rgba(59, 130, 246, 0.15);
|
|
color: #60a5fa;
|
|
margin-right: 6px;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.version-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
}
|
|
|
|
.version-select {
|
|
background: var(--ci-shade-2, #27272a);
|
|
border: 1px solid var(--ci-shade-3, #3f3f46);
|
|
border-radius: 6px;
|
|
padding: 8px 12px;
|
|
color: var(--ci-shade-7, #e4e4e7);
|
|
font-size: 14px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.image-tag {
|
|
font-family: monospace;
|
|
font-size: 13px;
|
|
color: var(--ci-shade-5, #a1a1aa);
|
|
background: var(--ci-shade-2, #27272a);
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.env-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
.env-table th {
|
|
text-align: left;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
color: var(--ci-shade-4, #71717a);
|
|
padding: 8px 8px 8px 0;
|
|
border-bottom: 1px solid var(--ci-shade-2, #27272a);
|
|
}
|
|
|
|
.env-table td {
|
|
padding: 6px 8px 6px 0;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.env-input {
|
|
width: 100%;
|
|
background: var(--ci-shade-2, #27272a);
|
|
border: 1px solid var(--ci-shade-3, #3f3f46);
|
|
border-radius: 4px;
|
|
padding: 6px 8px;
|
|
color: var(--ci-shade-7, #e4e4e7);
|
|
font-size: 13px;
|
|
font-family: monospace;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.env-input:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.env-key {
|
|
font-family: monospace;
|
|
font-size: 13px;
|
|
color: var(--ci-shade-6, #d4d4d8);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.env-desc {
|
|
font-size: 12px;
|
|
color: var(--ci-shade-4, #71717a);
|
|
}
|
|
|
|
.env-badge {
|
|
font-size: 10px;
|
|
padding: 1px 6px;
|
|
border-radius: 3px;
|
|
margin-left: 6px;
|
|
}
|
|
|
|
.env-badge.required {
|
|
background: rgba(239, 68, 68, 0.15);
|
|
color: #f87171;
|
|
}
|
|
|
|
.env-badge.auto {
|
|
background: rgba(34, 197, 94, 0.15);
|
|
color: #4ade80;
|
|
}
|
|
|
|
.name-input {
|
|
background: var(--ci-shade-2, #27272a);
|
|
border: 1px solid var(--ci-shade-3, #3f3f46);
|
|
border-radius: 6px;
|
|
padding: 10px 14px;
|
|
color: var(--ci-shade-7, #e4e4e7);
|
|
font-size: 14px;
|
|
width: 300px;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.actions-row {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 12px;
|
|
margin-top: 24px;
|
|
}
|
|
|
|
.btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 10px 20px;
|
|
border: none;
|
|
border-radius: 6px;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: opacity 200ms ease;
|
|
}
|
|
|
|
.btn:hover { opacity: 0.9; }
|
|
|
|
.btn-primary {
|
|
background: var(--ci-shade-7, #e4e4e7);
|
|
color: var(--ci-shade-0, #09090b);
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: transparent;
|
|
border: 1px solid var(--ci-shade-2, #27272a);
|
|
color: var(--ci-shade-6, #d4d4d8);
|
|
}
|
|
|
|
.loading-spinner {
|
|
padding: 32px;
|
|
text-align: center;
|
|
color: var(--ci-shade-4, #71717a);
|
|
}
|
|
`,
|
|
];
|
|
|
|
constructor() {
|
|
super();
|
|
const sub = appstate.appStoreStatePart
|
|
.select((s) => s)
|
|
.subscribe((newState) => {
|
|
this.appStoreState = newState;
|
|
});
|
|
this.rxSubscriptions.push(sub);
|
|
}
|
|
|
|
async connectedCallback() {
|
|
super.connectedCallback();
|
|
await appstate.appStoreStatePart.dispatchAction(appstate.fetchAppTemplatesAction, null);
|
|
}
|
|
|
|
public render(): TemplateResult {
|
|
switch (this.currentView) {
|
|
case 'detail':
|
|
return this.renderDetailView();
|
|
default:
|
|
return this.renderGridView();
|
|
}
|
|
}
|
|
|
|
private renderGridView(): TemplateResult {
|
|
const appTemplates = this.appStoreState.apps.map((app) => ({
|
|
id: app.id,
|
|
name: app.name,
|
|
description: app.description,
|
|
category: app.category,
|
|
iconName: app.iconName,
|
|
iconUrl: app.iconUrl,
|
|
image: '',
|
|
port: 0,
|
|
}));
|
|
|
|
return html`
|
|
<ob-sectionheading>App Store</ob-sectionheading>
|
|
${appTemplates.length === 0
|
|
? html`<div class="loading-spinner">Loading app templates...</div>`
|
|
: html`
|
|
<sz-app-store-view
|
|
.apps=${appTemplates}
|
|
@view-app=${(e: CustomEvent) => this.handleViewDetails(e)}
|
|
@deploy-app=${(e: CustomEvent) => this.handleAppClick(e)}
|
|
></sz-app-store-view>
|
|
`}
|
|
`;
|
|
}
|
|
|
|
private renderDetailView(): TemplateResult {
|
|
if (this.loading) {
|
|
return html`
|
|
<ob-sectionheading>App Store</ob-sectionheading>
|
|
<div class="loading-spinner">Loading app details...</div>
|
|
`;
|
|
}
|
|
|
|
const app = this.selectedApp;
|
|
const meta = this.selectedAppMeta;
|
|
const config = this.selectedAppConfig;
|
|
|
|
if (!app || !config) {
|
|
return html`
|
|
<ob-sectionheading>App Store</ob-sectionheading>
|
|
<div class="loading-spinner">App not found.</div>
|
|
`;
|
|
}
|
|
|
|
const platformReqs = config.platformRequirements || {};
|
|
const hasPlatformReqs = Object.values(platformReqs).some(Boolean);
|
|
const platformLabels: Record<string, string> = {
|
|
mongodb: 'MongoDB',
|
|
s3: 'S3 (MinIO)',
|
|
clickhouse: 'ClickHouse',
|
|
redis: 'Redis',
|
|
mariadb: 'MariaDB',
|
|
};
|
|
|
|
return html`
|
|
<ob-sectionheading>App Store</ob-sectionheading>
|
|
<button class="btn btn-secondary" style="margin-bottom: 16px;" @click=${() => { this.currentView = 'grid'; }}>
|
|
← Back to App Store
|
|
</button>
|
|
|
|
<!-- Header -->
|
|
<div class="detail-card">
|
|
<div class="detail-header">
|
|
<div class="detail-icon">${(app.name || '?')[0].toUpperCase()}</div>
|
|
<div style="flex: 1;">
|
|
<h2 class="detail-title">${app.name}</h2>
|
|
<span class="detail-category">${app.category}</span>
|
|
<p class="detail-description">${app.description}</p>
|
|
<div class="detail-meta">
|
|
${meta?.maintainer ? html`<span>Maintainer: <strong>${meta.maintainer}</strong></span>` : ''}
|
|
${meta?.links ? Object.entries(meta.links).map(([label, url]) =>
|
|
html`<a href="${url}" target="_blank" rel="noopener">${label}</a>`
|
|
) : ''}
|
|
${app.tags?.length ? html`<span>Tags: ${app.tags.join(', ')}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Platform Services -->
|
|
${hasPlatformReqs ? html`
|
|
<div class="detail-card">
|
|
<div class="section-label">Platform Services</div>
|
|
<div>
|
|
${Object.entries(platformReqs)
|
|
.filter(([_, enabled]) => enabled)
|
|
.map(([key]) => html`<span class="badge">${platformLabels[key] || key}</span>`)}
|
|
</div>
|
|
<div style="font-size: 12px; color: var(--ci-shade-4, #71717a); margin-top: 8px;">
|
|
These platform services will be automatically provisioned when you deploy.
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
|
|
<!-- Version & Image -->
|
|
<div class="detail-card">
|
|
<div class="section-label">Version</div>
|
|
<div class="version-row">
|
|
<select class="version-select" @change=${(e: Event) => this.handleVersionChange((e.target as HTMLSelectElement).value)}>
|
|
${(meta?.versions || [this.selectedVersion]).map((v) =>
|
|
html`<option value="${v}" ?selected=${v === this.selectedVersion}>${v}${v === app.latestVersion ? ' (latest)' : ''}</option>`
|
|
)}
|
|
</select>
|
|
<span class="image-tag">${config.image}</span>
|
|
${config.minOneboxVersion ? html`<span style="font-size: 12px; color: var(--ci-shade-4, #71717a);">Requires onebox ≥ ${config.minOneboxVersion}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Environment Variables -->
|
|
${this.editableEnvVars.length > 0 ? html`
|
|
<div class="detail-card">
|
|
<div class="section-label">Environment Variables</div>
|
|
<table class="env-table">
|
|
<thead>
|
|
<tr>
|
|
<th style="width: 30%;">Variable</th>
|
|
<th style="width: 40%;">Value</th>
|
|
<th>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${this.editableEnvVars.map((ev, index) => html`
|
|
<tr>
|
|
<td>
|
|
<span class="env-key">${ev.key}</span>
|
|
${ev.required ? html`<span class="env-badge required">required</span>` : ''}
|
|
${ev.platformInjected ? html`<span class="env-badge auto">auto</span>` : ''}
|
|
</td>
|
|
<td>
|
|
<input
|
|
class="env-input"
|
|
type="text"
|
|
.value=${ev.value}
|
|
?disabled=${ev.platformInjected || !this.deployMode}
|
|
placeholder=${ev.platformInjected ? 'Auto-injected by platform' : 'Enter value...'}
|
|
@input=${(e: Event) => this.handleEnvVarChange(index, (e.target as HTMLInputElement).value)}
|
|
/>
|
|
</td>
|
|
<td><span class="env-desc">${ev.description || ''}</span></td>
|
|
</tr>
|
|
`)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
` : ''}
|
|
|
|
<!-- Deploy section (only in deploy mode) or action button (view mode) -->
|
|
${this.deployMode ? html`
|
|
<div class="detail-card">
|
|
<div class="section-label">Service Name</div>
|
|
<input
|
|
class="name-input"
|
|
type="text"
|
|
.value=${this.serviceName}
|
|
placeholder="e.g. my-ghost-blog"
|
|
@input=${(e: Event) => { this.serviceName = (e.target as HTMLInputElement).value; }}
|
|
/>
|
|
<div style="font-size: 12px; color: var(--ci-shade-4, #71717a); margin-top: 6px;">
|
|
Lowercase letters, numbers, and hyphens only.
|
|
</div>
|
|
|
|
<div class="actions-row">
|
|
<button class="btn btn-secondary" @click=${() => { this.currentView = 'grid'; }}>Cancel</button>
|
|
<button class="btn btn-primary" @click=${() => this.handleDeploy()}>
|
|
Deploy v${this.selectedVersion}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
` : html`
|
|
<div class="actions-row" style="margin-top: 8px;">
|
|
<button class="btn btn-secondary" @click=${() => { this.currentView = 'grid'; }}>
|
|
← Back
|
|
</button>
|
|
<button class="btn btn-primary" @click=${() => { this.deployMode = true; }}>
|
|
Deploy this App
|
|
</button>
|
|
</div>
|
|
`}
|
|
`;
|
|
}
|
|
|
|
private async handleViewDetails(e: CustomEvent) {
|
|
const app = e.detail?.app;
|
|
if (!app) return;
|
|
|
|
const catalogApp = this.appStoreState.apps.find((a) => a.id === app.id);
|
|
if (!catalogApp) return;
|
|
|
|
this.deployMode = false;
|
|
this.selectedApp = catalogApp;
|
|
this.selectedVersion = catalogApp.latestVersion;
|
|
this.serviceName = catalogApp.id;
|
|
this.loading = true;
|
|
this.currentView = 'detail';
|
|
|
|
await this.fetchVersionConfig(catalogApp.id, catalogApp.latestVersion);
|
|
this.loading = false;
|
|
}
|
|
|
|
private async handleAppClick(e: CustomEvent) {
|
|
const app = e.detail?.app;
|
|
if (!app) return;
|
|
|
|
const catalogApp = this.appStoreState.apps.find((a) => a.id === app.id);
|
|
if (!catalogApp) return;
|
|
|
|
this.deployMode = true;
|
|
this.selectedApp = catalogApp;
|
|
this.selectedVersion = catalogApp.latestVersion;
|
|
this.serviceName = catalogApp.id;
|
|
this.loading = true;
|
|
this.currentView = 'detail';
|
|
|
|
await this.fetchVersionConfig(catalogApp.id, catalogApp.latestVersion);
|
|
this.loading = false;
|
|
}
|
|
|
|
private async handleVersionChange(version: string) {
|
|
if (!this.selectedApp || version === this.selectedVersion) return;
|
|
this.selectedVersion = version;
|
|
this.loading = true;
|
|
await this.fetchVersionConfig(this.selectedApp.id, version);
|
|
this.loading = false;
|
|
}
|
|
|
|
private async fetchVersionConfig(appId: string, version: string) {
|
|
try {
|
|
const identity = appstate.loginStatePart.getState().identity;
|
|
if (!identity) return;
|
|
|
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
|
interfaces.requests.IReq_GetAppConfig
|
|
>('/typedrequest', 'getAppConfig');
|
|
|
|
const response = await typedRequest.fire({ identity, appId, version });
|
|
|
|
this.selectedAppMeta = response.appMeta;
|
|
this.selectedAppConfig = response.config;
|
|
|
|
// Build editable env vars
|
|
this.editableEnvVars = (response.config.envVars || []).map((ev) => ({
|
|
key: ev.key,
|
|
value: ev.value || '',
|
|
description: ev.description || '',
|
|
required: ev.required,
|
|
platformInjected: ev.value?.includes('${') || false,
|
|
}));
|
|
} catch (err) {
|
|
console.error('Failed to fetch app config:', err);
|
|
}
|
|
}
|
|
|
|
private handleEnvVarChange(index: number, value: string) {
|
|
const updated = [...this.editableEnvVars];
|
|
updated[index] = { ...updated[index], value };
|
|
this.editableEnvVars = updated;
|
|
}
|
|
|
|
private async handleDeploy() {
|
|
const app = this.selectedApp;
|
|
const config = this.selectedAppConfig;
|
|
if (!app || !config) return;
|
|
|
|
const envVars: Record<string, string> = {};
|
|
for (const ev of this.editableEnvVars) {
|
|
if (ev.key && ev.value && !ev.platformInjected) {
|
|
envVars[ev.key] = ev.value;
|
|
}
|
|
}
|
|
|
|
const platformReqs = config.platformRequirements || {};
|
|
const serviceConfig: interfaces.data.IServiceCreate = {
|
|
name: this.serviceName || app.id,
|
|
image: config.image,
|
|
port: config.port || 80,
|
|
envVars,
|
|
enableMongoDB: platformReqs.mongodb || false,
|
|
enableS3: platformReqs.s3 || false,
|
|
enableClickHouse: platformReqs.clickhouse || false,
|
|
enableRedis: platformReqs.redis || false,
|
|
enableMariaDB: platformReqs.mariadb || false,
|
|
appTemplateId: app.id,
|
|
appTemplateVersion: this.selectedVersion,
|
|
};
|
|
|
|
try {
|
|
await appstate.servicesStatePart.dispatchAction(appstate.createServiceAction, {
|
|
config: serviceConfig,
|
|
});
|
|
setTimeout(() => {
|
|
appRouter.navigateToView('services');
|
|
}, 0);
|
|
} catch (err) {
|
|
console.error('Failed to deploy from App Store:', err);
|
|
}
|
|
}
|
|
}
|