feat(appstore): add remote app store templates with service upgrades and Redis/MariaDB platform support
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/onebox',
|
||||
version: '1.22.2',
|
||||
version: '1.23.0',
|
||||
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
|
||||
}
|
||||
|
||||
@@ -54,6 +54,11 @@ export interface ISettingsState {
|
||||
backupPasswordConfigured: boolean;
|
||||
}
|
||||
|
||||
export interface IAppStoreState {
|
||||
apps: interfaces.requests.ICatalogApp[];
|
||||
upgradeableServices: interfaces.requests.IUpgradeableService[];
|
||||
}
|
||||
|
||||
export interface IUiState {
|
||||
activeView: string;
|
||||
autoRefresh: boolean;
|
||||
@@ -137,6 +142,15 @@ export const settingsStatePart = await appState.getStatePart<ISettingsState>(
|
||||
'soft',
|
||||
);
|
||||
|
||||
export const appStoreStatePart = await appState.getStatePart<IAppStoreState>(
|
||||
'appStore',
|
||||
{
|
||||
apps: [],
|
||||
upgradeableServices: [],
|
||||
},
|
||||
'soft',
|
||||
);
|
||||
|
||||
export const uiStatePart = await appState.getStatePart<IUiState>(
|
||||
'ui',
|
||||
{
|
||||
@@ -914,7 +928,8 @@ export const setBackupPasswordAction = settingsStatePart.createAction<{ password
|
||||
|
||||
export const setActiveViewAction = uiStatePart.createAction<{ view: string }>(
|
||||
async (statePartArg, dataArg) => {
|
||||
return { ...statePartArg.getState(), activeView: dataArg.view };
|
||||
const normalizedView = dataArg.view.toLowerCase().replace(/\s+/g, '-');
|
||||
return { ...statePartArg.getState(), activeView: normalizedView };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1055,6 +1070,68 @@ async function disconnectSocket() {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// App Store Actions
|
||||
// ============================================================================
|
||||
|
||||
export const fetchAppTemplatesAction = appStoreStatePart.createAction(
|
||||
async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetAppTemplates
|
||||
>('/typedrequest', 'getAppTemplates');
|
||||
const response = await typedRequest.fire({ identity: context.identity! });
|
||||
return { ...statePartArg.getState(), apps: response.apps };
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch app templates:', err);
|
||||
return statePartArg.getState();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchUpgradeableServicesAction = appStoreStatePart.createAction(
|
||||
async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetUpgradeableServices
|
||||
>('/typedrequest', 'getUpgradeableServices');
|
||||
const response = await typedRequest.fire({ identity: context.identity! });
|
||||
return { ...statePartArg.getState(), upgradeableServices: response.services };
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch upgradeable services:', err);
|
||||
return statePartArg.getState();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const upgradeServiceAction = appStoreStatePart.createAction<{
|
||||
serviceName: string;
|
||||
targetVersion: string;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_UpgradeService
|
||||
>('/typedrequest', 'upgradeService');
|
||||
await typedRequest.fire({
|
||||
identity: context.identity!,
|
||||
serviceName: dataArg.serviceName,
|
||||
targetVersion: dataArg.targetVersion,
|
||||
});
|
||||
// Re-fetch upgradeable services and services list
|
||||
const upgradeReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetUpgradeableServices
|
||||
>('/typedrequest', 'getUpgradeableServices');
|
||||
const upgradeResp = await upgradeReq.fire({ identity: context.identity! });
|
||||
return { ...statePartArg.getState(), upgradeableServices: upgradeResp.services };
|
||||
} catch (err) {
|
||||
console.error('Failed to upgrade service:', err);
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
// Connect socket when logged in, disconnect when logged out
|
||||
loginStatePart.select((s) => s).subscribe((loginState) => {
|
||||
if (loginState.isLoggedIn) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as appstate from '../appstate.js';
|
||||
import * as interfaces from '../../ts_interfaces/index.js';
|
||||
import { appRouter } from '../router.js';
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
@@ -93,6 +94,9 @@ export class ObAppShell extends DeesElement {
|
||||
<dees-simple-appdash
|
||||
name="Onebox"
|
||||
.viewTabs=${this.resolvedViewTabs}
|
||||
.selectedView=${this.resolvedViewTabs.find(
|
||||
(t) => t.name.toLowerCase().replace(/\s+/g, '-') === this.uiState.activeView
|
||||
) || this.resolvedViewTabs[0]}
|
||||
>
|
||||
</dees-simple-appdash>
|
||||
</dees-simple-login>
|
||||
@@ -122,8 +126,8 @@ export class ObAppShell extends DeesElement {
|
||||
const appDash = this.shadowRoot!.querySelector('dees-simple-appdash') as any;
|
||||
if (appDash) {
|
||||
appDash.addEventListener('view-select', (e: CustomEvent) => {
|
||||
const viewName = e.detail.view.name.toLowerCase();
|
||||
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: viewName });
|
||||
const viewName = e.detail.view.name.toLowerCase().replace(/\s+/g, '-');
|
||||
appRouter.navigateToView(viewName);
|
||||
});
|
||||
appDash.addEventListener('logout', async () => {
|
||||
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
|
||||
@@ -131,10 +135,11 @@ export class ObAppShell extends DeesElement {
|
||||
}
|
||||
|
||||
// Load the initial view on the appdash now that tabs are resolved
|
||||
// (appdash's own firstUpdated already fired when viewTabs was still empty)
|
||||
// Read activeView directly from state (not this.uiState which may be stale)
|
||||
if (appDash && this.resolvedViewTabs.length > 0) {
|
||||
const currentActiveView = appstate.uiStatePart.getState().activeView;
|
||||
const initialView = this.resolvedViewTabs.find(
|
||||
(t) => t.name.toLowerCase() === this.uiState.activeView,
|
||||
(t) => t.name.toLowerCase().replace(/\s+/g, '-') === currentActiveView,
|
||||
) || this.resolvedViewTabs[0];
|
||||
await appDash.loadView(initialView);
|
||||
}
|
||||
@@ -143,23 +148,26 @@ export class ObAppShell extends DeesElement {
|
||||
const loginState = appstate.loginStatePart.getState();
|
||||
if (loginState.identity?.jwt) {
|
||||
if (loginState.identity.expiresAt > Date.now()) {
|
||||
// Validate token with server before switching to dashboard
|
||||
// (server may have restarted with a new JWT secret)
|
||||
// Switch to dashboard immediately (no flash of login form)
|
||||
this.loginState = loginState;
|
||||
if (simpleLogin) {
|
||||
await simpleLogin.switchToSlottedContent();
|
||||
}
|
||||
// Validate token with server in the background
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetSystemStatus
|
||||
>('/typedrequest', 'getSystemStatus');
|
||||
const response = await typedRequest.fire({ identity: loginState.identity });
|
||||
// Token is valid - switch to dashboard
|
||||
appstate.systemStatePart.setState({ status: response.status });
|
||||
this.loginState = loginState;
|
||||
if (simpleLogin) {
|
||||
await simpleLogin.switchToSlottedContent();
|
||||
}
|
||||
} catch (err) {
|
||||
// Token rejected by server - clear session
|
||||
// Token rejected by server - switch back to login
|
||||
console.warn('Stored session invalid, returning to login:', err);
|
||||
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
|
||||
if (simpleLogin) {
|
||||
// Force page reload to show login properly
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
|
||||
@@ -201,9 +209,11 @@ export class ObAppShell extends DeesElement {
|
||||
private syncAppdashView(viewName: string): void {
|
||||
const appDash = this.shadowRoot?.querySelector('dees-simple-appdash') as any;
|
||||
if (!appDash || this.resolvedViewTabs.length === 0) return;
|
||||
const targetTab = this.resolvedViewTabs.find((t) => t.name.toLowerCase() === viewName);
|
||||
// Match kebab-case view name (e.g., 'app-store') to tab name (e.g., 'App Store')
|
||||
const targetTab = this.resolvedViewTabs.find(
|
||||
(t) => t.name.toLowerCase().replace(/\s+/g, '-') === viewName
|
||||
);
|
||||
if (!targetTab) return;
|
||||
// Use appdash's own loadView method for proper view management
|
||||
appDash.loadView(targetTab);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ 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,
|
||||
@@ -12,213 +13,600 @@ import {
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
// App template definitions — curated Docker apps
|
||||
const appTemplates = [
|
||||
{
|
||||
id: 'nginx',
|
||||
name: 'Nginx',
|
||||
description: 'High-performance web server and reverse proxy. Lightweight, fast, and battle-tested.',
|
||||
category: 'Web Server',
|
||||
iconName: 'globe',
|
||||
image: 'nginx:alpine',
|
||||
port: 80,
|
||||
},
|
||||
{
|
||||
id: 'wordpress',
|
||||
name: 'WordPress',
|
||||
description: 'The world\'s most popular content management system. Powers over 40% of the web.',
|
||||
category: 'CMS',
|
||||
iconName: 'file-text',
|
||||
image: 'wordpress:latest',
|
||||
port: 80,
|
||||
enableMongoDB: false,
|
||||
envVars: [
|
||||
{ key: 'WORDPRESS_DB_HOST', value: '', description: 'Database host', required: true },
|
||||
{ key: 'WORDPRESS_DB_USER', value: 'wordpress', description: 'Database user' },
|
||||
{ key: 'WORDPRESS_DB_PASSWORD', value: '', description: 'Database password', required: true },
|
||||
{ key: 'WORDPRESS_DB_NAME', value: 'wordpress', description: 'Database name' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ghost',
|
||||
name: 'Ghost',
|
||||
description: 'Modern publishing platform for creating professional blogs and newsletters.',
|
||||
category: 'CMS',
|
||||
iconName: 'book-open',
|
||||
image: 'ghost:latest',
|
||||
port: 2368,
|
||||
envVars: [
|
||||
{ key: 'database__client', value: 'sqlite3', description: 'Database client (sqlite3 for standalone)' },
|
||||
{ key: 'database__connection__filename', value: '/var/lib/ghost/content/data/ghost.db', description: 'SQLite database path' },
|
||||
{ key: 'url', value: 'http://localhost:2368', description: 'Public URL of the blog' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gitea',
|
||||
name: 'Gitea',
|
||||
description: 'Lightweight self-hosted Git service. Easy to install and maintain.',
|
||||
category: 'Dev Tools',
|
||||
iconName: 'git-branch',
|
||||
image: 'gitea/gitea:latest',
|
||||
port: 3000,
|
||||
},
|
||||
{
|
||||
id: 'nextcloud',
|
||||
name: 'Nextcloud',
|
||||
description: 'Self-hosted file sync and share platform. Your own private cloud.',
|
||||
category: 'Storage',
|
||||
iconName: 'package',
|
||||
image: 'nextcloud:latest',
|
||||
port: 80,
|
||||
},
|
||||
{
|
||||
id: 'grafana',
|
||||
name: 'Grafana',
|
||||
description: 'Open-source observability platform for metrics, logs, and traces visualization.',
|
||||
category: 'Monitoring',
|
||||
iconName: 'monitor',
|
||||
image: 'grafana/grafana:latest',
|
||||
port: 3000,
|
||||
envVars: [
|
||||
{ key: 'GF_SECURITY_ADMIN_PASSWORD', value: 'admin', description: 'Admin password' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'uptime-kuma',
|
||||
name: 'Uptime Kuma',
|
||||
description: 'Self-hosted monitoring tool. Beautiful UI for tracking uptime of services.',
|
||||
category: 'Monitoring',
|
||||
iconName: 'monitor',
|
||||
image: 'louislam/uptime-kuma:latest',
|
||||
port: 3001,
|
||||
},
|
||||
{
|
||||
id: 'plausible',
|
||||
name: 'Plausible Analytics',
|
||||
description: 'Privacy-friendly web analytics. No cookies, GDPR compliant by design.',
|
||||
category: 'Analytics',
|
||||
iconName: 'monitor',
|
||||
image: 'plausible/analytics:latest',
|
||||
port: 8000,
|
||||
enableClickHouse: true,
|
||||
},
|
||||
{
|
||||
id: 'vaultwarden',
|
||||
name: 'Vaultwarden',
|
||||
description: 'Lightweight Bitwarden-compatible password manager server.',
|
||||
category: 'Security',
|
||||
iconName: 'shield',
|
||||
image: 'vaultwarden/server:latest',
|
||||
port: 80,
|
||||
},
|
||||
{
|
||||
id: 'n8n',
|
||||
name: 'N8N',
|
||||
description: 'Workflow automation tool. Connect anything to everything with a visual editor.',
|
||||
category: 'Automation',
|
||||
iconName: 'server',
|
||||
image: 'n8nio/n8n:latest',
|
||||
port: 5678,
|
||||
},
|
||||
{
|
||||
id: 'mattermost',
|
||||
name: 'Mattermost',
|
||||
description: 'Open-source Slack alternative for team communication and collaboration.',
|
||||
category: 'Communication',
|
||||
iconName: 'mail',
|
||||
image: 'mattermost/mattermost-team-edition:latest',
|
||||
port: 8065,
|
||||
},
|
||||
{
|
||||
id: 'portainer',
|
||||
name: 'Portainer',
|
||||
description: 'Docker management UI. Monitor and manage containers from a web interface.',
|
||||
category: 'Dev Tools',
|
||||
iconName: 'package',
|
||||
image: 'portainer/portainer-ce:latest',
|
||||
port: 9000,
|
||||
},
|
||||
{
|
||||
id: 'redis',
|
||||
name: 'Redis',
|
||||
description: 'In-memory data store used as database, cache, and message broker.',
|
||||
category: 'Database',
|
||||
iconName: 'database',
|
||||
image: 'redis:alpine',
|
||||
port: 6379,
|
||||
},
|
||||
{
|
||||
id: 'postgres',
|
||||
name: 'PostgreSQL',
|
||||
description: 'Advanced open-source relational database. Reliable and feature-rich.',
|
||||
category: 'Database',
|
||||
iconName: 'database',
|
||||
image: 'postgres:16-alpine',
|
||||
port: 5432,
|
||||
envVars: [
|
||||
{ key: 'POSTGRES_PASSWORD', value: '', description: 'Superuser password', required: true },
|
||||
{ key: 'POSTGRES_USER', value: 'postgres', description: 'Superuser name' },
|
||||
{ key: 'POSTGRES_DB', value: 'postgres', description: 'Default database name' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mariadb',
|
||||
name: 'MariaDB',
|
||||
description: 'Community-developed fork of MySQL. Drop-in replacement with enhanced features.',
|
||||
category: 'Database',
|
||||
iconName: 'database',
|
||||
image: 'mariadb:latest',
|
||||
port: 3306,
|
||||
envVars: [
|
||||
{ key: 'MARIADB_ROOT_PASSWORD', value: '', description: 'Root password', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'adminer',
|
||||
name: 'Adminer',
|
||||
description: 'Database management tool in a single PHP file. Supports MySQL, PostgreSQL, SQLite.',
|
||||
category: 'Dev Tools',
|
||||
iconName: 'database',
|
||||
image: 'adminer:latest',
|
||||
port: 8080,
|
||||
},
|
||||
];
|
||||
|
||||
@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``,
|
||||
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>
|
||||
<sz-app-store-view
|
||||
.apps=${appTemplates}
|
||||
@deploy-app=${(e: CustomEvent) => this.handleDeployApp(e)}
|
||||
></sz-app-store-view>
|
||||
${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 handleDeployApp(e: CustomEvent) {
|
||||
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;
|
||||
|
||||
// Store the template and navigate on next microtask to avoid
|
||||
// destroying the current view while the event handler is still on the call stack
|
||||
setTimeout(() => {
|
||||
// Set both pendingAppTemplate and activeView atomically
|
||||
appstate.uiStatePart.setState({
|
||||
...appstate.uiStatePart.getState(),
|
||||
pendingAppTemplate: app,
|
||||
activeView: 'services',
|
||||
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,
|
||||
});
|
||||
}, 0);
|
||||
setTimeout(() => {
|
||||
appRouter.navigateToView('services');
|
||||
}, 0);
|
||||
} catch (err) {
|
||||
console.error('Failed to deploy from App Store:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as shared from './shared/index.js';
|
||||
import * as appstate from '../appstate.js';
|
||||
import { appRouter } from '../router.js';
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
@@ -114,11 +115,13 @@ export class ObViewDashboard extends DeesElement {
|
||||
networkOut: status?.docker?.networkOut || 0,
|
||||
topConsumers: [],
|
||||
},
|
||||
platformServices: platformServices.map((ps) => ({
|
||||
name: ps.displayName,
|
||||
status: ps.status === 'running' ? 'running' : 'stopped',
|
||||
running: ps.status === 'running',
|
||||
})),
|
||||
platformServices: platformServices
|
||||
.filter((ps) => ps.status === 'running' || ps.status === 'starting' || ps.status === 'stopping' || ps.isCore)
|
||||
.map((ps) => ({
|
||||
name: ps.displayName,
|
||||
status: ps.status === 'running' ? 'Running' : ps.status === 'starting' ? 'Starting...' : ps.status === 'stopping' ? 'Stopping...' : 'Stopped',
|
||||
running: ps.status === 'running',
|
||||
})),
|
||||
traffic: {
|
||||
requests: 0,
|
||||
errors: 0,
|
||||
@@ -159,9 +162,9 @@ export class ObViewDashboard extends DeesElement {
|
||||
private handleQuickAction(e: CustomEvent) {
|
||||
const action = e.detail?.action || e.detail?.label;
|
||||
if (action === 'Deploy Service') {
|
||||
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: 'services' });
|
||||
appRouter.navigateToView('services');
|
||||
} else if (action === 'Add Domain') {
|
||||
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: 'network' });
|
||||
appRouter.navigateToView('network');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +181,7 @@ export class ObViewDashboard extends DeesElement {
|
||||
...appstate.servicesStatePart.getState(),
|
||||
currentPlatformService: ps,
|
||||
});
|
||||
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: 'services' });
|
||||
appRouter.navigateToView('services');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as shared from './shared/index.js';
|
||||
import * as appstate from '../appstate.js';
|
||||
import { appRouter } from '../router.js';
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
@@ -64,7 +65,7 @@ export class ObViewRegistries extends DeesElement {
|
||||
.registryUrl=${'localhost:5000'}
|
||||
@manage-tokens=${() => {
|
||||
// tokens are managed via the tokens view
|
||||
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: 'tokens' });
|
||||
appRouter.navigateToView('tokens');
|
||||
}}
|
||||
></sz-registry-advertisement>
|
||||
`;
|
||||
|
||||
@@ -142,6 +142,12 @@ export class ObViewServices extends DeesElement {
|
||||
@state()
|
||||
accessor pendingTemplate: any = null;
|
||||
|
||||
@state()
|
||||
accessor appStoreState: appstate.IAppStoreState = {
|
||||
apps: [],
|
||||
upgradeableServices: [],
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
@@ -159,7 +165,12 @@ export class ObViewServices extends DeesElement {
|
||||
});
|
||||
this.rxSubscriptions.push(backupsSub);
|
||||
|
||||
// No subscription needed — pendingAppTemplate is checked in render()
|
||||
const appStoreSub = appstate.appStoreStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((newState) => {
|
||||
this.appStoreState = newState;
|
||||
});
|
||||
this.rxSubscriptions.push(appStoreSub);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
@@ -215,6 +226,7 @@ export class ObViewServices extends DeesElement {
|
||||
await Promise.all([
|
||||
appstate.servicesStatePart.dispatchAction(appstate.fetchServicesAction, null),
|
||||
appstate.servicesStatePart.dispatchAction(appstate.fetchPlatformServicesAction, null),
|
||||
appstate.appStoreStatePart.dispatchAction(appstate.fetchUpgradeableServicesAction, null),
|
||||
]);
|
||||
|
||||
// If a platform service was selected from the dashboard, navigate to its detail
|
||||
@@ -230,20 +242,6 @@ export class ObViewServices extends DeesElement {
|
||||
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string, any>) {
|
||||
super.updated(changedProperties);
|
||||
// Check for pending app template from the App Store after each update
|
||||
const uiState = appstate.uiStatePart.getState();
|
||||
if (uiState.pendingAppTemplate && !this.pendingTemplate) {
|
||||
this.pendingTemplate = uiState.pendingAppTemplate;
|
||||
appstate.uiStatePart.setState({
|
||||
...appstate.uiStatePart.getState(),
|
||||
pendingAppTemplate: undefined,
|
||||
});
|
||||
this.currentView = 'create';
|
||||
}
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
switch (this.currentView) {
|
||||
case 'create':
|
||||
@@ -277,7 +275,14 @@ export class ObViewServices extends DeesElement {
|
||||
default: return status;
|
||||
}
|
||||
};
|
||||
const mappedPlatformServices = this.servicesState.platformServices.map((ps) => ({
|
||||
// Split platform services into active (running or core) and inactive (not in use)
|
||||
const activePlatformServices = this.servicesState.platformServices.filter(
|
||||
(ps) => ps.status === 'running' || ps.status === 'starting' || ps.status === 'stopping' || ps.isCore,
|
||||
);
|
||||
const inactivePlatformServices = this.servicesState.platformServices.filter(
|
||||
(ps) => !ps.isCore && (ps.status === 'not-deployed' || ps.status === 'stopped' || ps.status === 'failed'),
|
||||
);
|
||||
const mappedActivePlatformServices = activePlatformServices.map((ps) => ({
|
||||
name: ps.displayName,
|
||||
status: displayStatus(ps.status),
|
||||
running: ps.status === 'running',
|
||||
@@ -313,17 +318,45 @@ export class ObViewServices extends DeesElement {
|
||||
></sz-services-list-view>
|
||||
<ob-sectionheading style="margin-top: 32px;">Platform Services</ob-sectionheading>
|
||||
<div style="max-width: 500px;">
|
||||
<sz-platform-services-card
|
||||
.services=${mappedPlatformServices}
|
||||
@service-click=${(e: CustomEvent) => {
|
||||
const type = e.detail.type || this.servicesState.platformServices.find(
|
||||
(ps) => ps.displayName === e.detail.name,
|
||||
)?.type;
|
||||
if (type) {
|
||||
this.navigateToPlatformDetail(type);
|
||||
}
|
||||
}}
|
||||
></sz-platform-services-card>
|
||||
${mappedActivePlatformServices.length > 0 ? html`
|
||||
<sz-platform-services-card
|
||||
.services=${mappedActivePlatformServices}
|
||||
@service-click=${(e: CustomEvent) => {
|
||||
const type = e.detail.type || this.servicesState.platformServices.find(
|
||||
(ps) => ps.displayName === e.detail.name,
|
||||
)?.type;
|
||||
if (type) {
|
||||
this.navigateToPlatformDetail(type);
|
||||
}
|
||||
}}
|
||||
></sz-platform-services-card>
|
||||
` : ''}
|
||||
${inactivePlatformServices.length > 0 ? html`
|
||||
<div style="
|
||||
background: var(--ci-shade-1, #09090b);
|
||||
border: 1px solid var(--ci-shade-2, #27272a);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-top: ${mappedActivePlatformServices.length > 0 ? '12px' : '0'};
|
||||
opacity: 0.5;
|
||||
">
|
||||
<div style="font-size: 13px; color: var(--ci-shade-4, #71717a); margin-bottom: 12px;">Available — not in use</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 12px;">
|
||||
${inactivePlatformServices.map((ps) => html`
|
||||
<div
|
||||
style="display: flex; justify-content: space-between; align-items: center; padding: 8px 0; cursor: pointer; transition: opacity 200ms ease;"
|
||||
@click=${() => this.navigateToPlatformDetail(ps.type)}
|
||||
>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<div style="width: 8px; height: 8px; border-radius: 50%; background: var(--ci-shade-3, #3f3f46); flex-shrink: 0;"></div>
|
||||
<span style="font-size: 14px; font-weight: 500; color: var(--ci-shade-4, #71717a);">${ps.displayName}</span>
|
||||
</div>
|
||||
<span style="font-size: 13px; color: var(--ci-shade-3, #3f3f46);">${displayStatus(ps.status)}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -344,6 +377,8 @@ export class ObViewServices extends DeesElement {
|
||||
enableMongoDB: template.enableMongoDB || false,
|
||||
enableS3: template.enableS3 || false,
|
||||
enableClickHouse: template.enableClickHouse || false,
|
||||
enableRedis: template.enableRedis || false,
|
||||
enableMariaDB: template.enableMariaDB || false,
|
||||
};
|
||||
await appstate.servicesStatePart.dispatchAction(appstate.createServiceAction, {
|
||||
config: serviceConfig,
|
||||
@@ -368,12 +403,14 @@ export class ObViewServices extends DeesElement {
|
||||
<div><span style="color: var(--ci-shade-5, #a1a1aa);">Service Name:</span> <strong>${t.id}</strong></div>
|
||||
<div><span style="color: var(--ci-shade-5, #a1a1aa);">Category:</span> <strong>${t.category}</strong></div>
|
||||
</div>
|
||||
${t.enableMongoDB || t.enableS3 || t.enableClickHouse ? html`
|
||||
${t.enableMongoDB || t.enableS3 || t.enableClickHouse || t.enableRedis || t.enableMariaDB ? html`
|
||||
<div style="margin-top: 12px; font-size: 13px; color: var(--ci-shade-5, #a1a1aa);">
|
||||
Platform Services:
|
||||
${t.enableMongoDB ? html`<span style="margin-right: 8px;">MongoDB</span>` : ''}
|
||||
${t.enableS3 ? html`<span style="margin-right: 8px;">S3</span>` : ''}
|
||||
${t.enableClickHouse ? html`<span>ClickHouse</span>` : ''}
|
||||
${t.enableClickHouse ? html`<span style="margin-right: 8px;">ClickHouse</span>` : ''}
|
||||
${t.enableRedis ? html`<span style="margin-right: 8px;">Redis</span>` : ''}
|
||||
${t.enableMariaDB ? html`<span style="margin-right: 8px;">MariaDB</span>` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
@@ -407,6 +444,8 @@ export class ObViewServices extends DeesElement {
|
||||
enableMongoDB: formConfig.enableMongoDB || false,
|
||||
enableS3: formConfig.enableS3 || false,
|
||||
enableClickHouse: formConfig.enableClickHouse || false,
|
||||
enableRedis: formConfig.enableRedis || false,
|
||||
enableMariaDB: formConfig.enableMariaDB || false,
|
||||
};
|
||||
await appstate.servicesStatePart.dispatchAction(appstate.createServiceAction, {
|
||||
config: serviceConfig,
|
||||
@@ -428,8 +467,49 @@ export class ObViewServices extends DeesElement {
|
||||
: defaultStats;
|
||||
const transformedLogs = parseLogs(this.servicesState.currentServiceLogs);
|
||||
|
||||
// Check if this service has an available upgrade
|
||||
const upgradeInfo = service
|
||||
? this.appStoreState.upgradeableServices.find((u) => u.serviceName === service.name)
|
||||
: null;
|
||||
|
||||
return html`
|
||||
<ob-sectionheading>Service Details</ob-sectionheading>
|
||||
${upgradeInfo ? html`
|
||||
<div style="
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1), rgba(139, 92, 246, 0.1));
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
">
|
||||
<div>
|
||||
<div style="font-size: 14px; font-weight: 600; color: var(--ci-shade-7, #e4e4e7);">
|
||||
Update available: v${upgradeInfo.currentVersion} → v${upgradeInfo.latestVersion}
|
||||
</div>
|
||||
<div style="font-size: 12px; color: var(--ci-shade-4, #71717a); margin-top: 4px;">
|
||||
${upgradeInfo.hasMigration ? 'Migration script available' : 'Config-only upgrade'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="deploy-button"
|
||||
style="padding: 8px 16px; font-size: 13px;"
|
||||
@click=${async () => {
|
||||
await appstate.appStoreStatePart.dispatchAction(appstate.upgradeServiceAction, {
|
||||
serviceName: upgradeInfo.serviceName,
|
||||
targetVersion: upgradeInfo.latestVersion,
|
||||
});
|
||||
// Refresh service data
|
||||
appstate.servicesStatePart.dispatchAction(appstate.fetchServiceAction, {
|
||||
name: upgradeInfo.serviceName,
|
||||
});
|
||||
appstate.servicesStatePart.dispatchAction(appstate.fetchServicesAction, null);
|
||||
}}
|
||||
>Upgrade</button>
|
||||
</div>
|
||||
` : ''}
|
||||
<sz-service-detail-view
|
||||
.service=${transformedService}
|
||||
.logs=${transformedLogs}
|
||||
@@ -530,6 +610,8 @@ export class ObViewServices extends DeesElement {
|
||||
minio: { host: 'onebox-minio', port: 9000, version: 'latest', config: { consolePort: 9001, region: 'us-east-1' } },
|
||||
clickhouse: { host: 'onebox-clickhouse', port: 8123, version: 'latest', config: { nativePort: 9000, httpPort: 8123 } },
|
||||
caddy: { host: 'onebox-caddy', port: 80, version: '2-alpine', config: { httpsPort: 443, adminApi: 2019 } },
|
||||
mariadb: { host: 'onebox-mariadb', port: 3306, version: '11', config: { engine: 'InnoDB', authEnabled: true } },
|
||||
redis: { host: 'onebox-redis', port: 6379, version: '7-alpine', config: { appendonly: true, maxDatabases: 16 } },
|
||||
};
|
||||
const info = platformService
|
||||
? serviceInfo[platformService.type] || { host: 'unknown', port: 0, version: '', config: {} }
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { html } from '@design.estate/dees-element';
|
||||
import './elements/index.js';
|
||||
import { appRouter } from './router.js';
|
||||
|
||||
// Initialize router before rendering (handles initial URL → state)
|
||||
appRouter.init();
|
||||
|
||||
plugins.deesElement.render(html`
|
||||
<ob-app-shell></ob-app-shell>
|
||||
|
||||
110
ts_web/router.ts
Normal file
110
ts_web/router.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as appstate from './appstate.js';
|
||||
|
||||
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
|
||||
|
||||
export const validViews = [
|
||||
'dashboard', 'app-store', 'services', 'network',
|
||||
'registries', 'tokens', 'settings',
|
||||
] as const;
|
||||
|
||||
export type TValidView = typeof validViews[number];
|
||||
|
||||
class AppRouter {
|
||||
private router: InstanceType<typeof SmartRouter>;
|
||||
private initialized = false;
|
||||
private suppressStateUpdate = false;
|
||||
|
||||
constructor() {
|
||||
this.router = new SmartRouter({ debug: false });
|
||||
}
|
||||
|
||||
public init(): void {
|
||||
if (this.initialized) return;
|
||||
this.setupRoutes();
|
||||
this.setupStateSync();
|
||||
this.handleInitialRoute();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
private setupRoutes(): void {
|
||||
for (const view of validViews) {
|
||||
this.router.on(`/${view}`, async () => {
|
||||
this.updateViewState(view);
|
||||
});
|
||||
}
|
||||
|
||||
// Root redirect
|
||||
this.router.on('/', async () => {
|
||||
this.navigateTo('/dashboard');
|
||||
});
|
||||
}
|
||||
|
||||
private setupStateSync(): void {
|
||||
appstate.uiStatePart.select((s) => s.activeView).subscribe((activeView) => {
|
||||
if (this.suppressStateUpdate) return;
|
||||
|
||||
const currentPath = window.location.pathname;
|
||||
const expectedPath = `/${activeView}`;
|
||||
|
||||
if (currentPath !== expectedPath) {
|
||||
this.suppressStateUpdate = true;
|
||||
this.router.pushUrl(expectedPath);
|
||||
this.suppressStateUpdate = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private handleInitialRoute(): void {
|
||||
const path = window.location.pathname;
|
||||
|
||||
if (!path || path === '/') {
|
||||
this.router.pushUrl('/dashboard');
|
||||
} else {
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
const view = segments[0];
|
||||
|
||||
if (validViews.includes(view as TValidView)) {
|
||||
this.updateViewState(view as TValidView);
|
||||
} else {
|
||||
this.router.pushUrl('/dashboard');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateViewState(view: string): void {
|
||||
this.suppressStateUpdate = true;
|
||||
const currentState = appstate.uiStatePart.getState();
|
||||
if (currentState.activeView !== view) {
|
||||
appstate.uiStatePart.setState({
|
||||
...currentState,
|
||||
activeView: view,
|
||||
});
|
||||
}
|
||||
this.suppressStateUpdate = false;
|
||||
}
|
||||
|
||||
public navigateTo(path: string): void {
|
||||
this.router.pushUrl(path);
|
||||
}
|
||||
|
||||
public navigateToView(view: string): void {
|
||||
const normalized = view.toLowerCase().replace(/\s+/g, '-');
|
||||
if (validViews.includes(normalized as TValidView)) {
|
||||
this.navigateTo(`/${normalized}`);
|
||||
} else {
|
||||
this.navigateTo('/dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
public getCurrentView(): string {
|
||||
return appstate.uiStatePart.getState().activeView;
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.router.destroy();
|
||||
this.initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
export const appRouter = new AppRouter();
|
||||
Reference in New Issue
Block a user