feat(hostedapp): add hosted app lifecycle protocol support

This commit is contained in:
2026-05-26 15:26:24 +00:00
parent 809bd015a0
commit fc182beb01
21 changed files with 689 additions and 17 deletions
+14
View File
@@ -2,6 +2,20 @@
## Pending
- add hosted app lifecycle protocol support (hostedapp)
- Injects generic hosted-app runtime identity environment variables into managed services.
- Adds token-scoped Hosted App TypedRequest handlers for lifecycle reporting, bootstrap setup, and managed self-upgrades.
- Injects a Docker host alias so hosted apps can reach the Onebox runtime URL from managed containers.
- Shows hosted-app bootstrap and upgrade state in the service detail view with a mark-done cleanup action.
### Features
- add hosted app lifecycle protocol support (hostedapp)
- Inject hosted app runtime identity environment variables into managed services and persist lifecycle records.
- Add token-scoped TypedRequest handlers for lifecycle reporting, bootstrap actions, and managed app upgrades.
- Show hosted app bootstrap and upgrade state in the service detail view with a mark-done cleanup action.
- Configure Docker host gateway aliases so managed containers can reach the Onebox runtime URL.
- Bump @serve.zone/interfaces to ^6.1.0.
## 2026-05-25 - 2.1.3
+1 -1
View File
@@ -28,7 +28,7 @@
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.2",
"@api.global/typedsocket": "npm:@api.global/typedsocket@^4.1.3",
"@serve.zone/containerarchive": "npm:@serve.zone/containerarchive@^0.1.3",
"@serve.zone/interfaces": "npm:@serve.zone/interfaces@^6.0.0",
"@serve.zone/interfaces": "npm:@serve.zone/interfaces@^6.1.0",
"@serve.zone/appstore": "npm:@serve.zone/appstore@^0.2.0"
},
"compilerOptions": {
+1 -1
View File
@@ -60,7 +60,7 @@
"@design.estate/dees-element": "^2.2.4",
"@serve.zone/appstore": "^0.2.0",
"@serve.zone/catalog": "^2.12.6",
"@serve.zone/interfaces": "^6.0.0"
"@serve.zone/interfaces": "^6.1.0"
},
"devDependencies": {
"@git.zone/tsbundle": "^2.10.4",
+6 -6
View File
@@ -27,8 +27,8 @@ importers:
specifier: ^2.12.6
version: 2.12.6(@tiptap/pm@2.27.2)
'@serve.zone/interfaces':
specifier: ^6.0.0
version: 6.0.0
specifier: ^6.1.0
version: 6.1.0
devDependencies:
'@git.zone/tsbundle':
specifier: ^2.10.4
@@ -986,8 +986,8 @@ packages:
'@serve.zone/catalog@2.12.6':
resolution: {integrity: sha512-FjieZNCHTCHufMre8OSP8bFP9L4DPL9yNtd7UMwD1yQ8wublgAq6eWrx6Tfb+3k8Hyof33BBt4rbFyrvIEBk+A==}
'@serve.zone/interfaces@6.0.0':
resolution: {integrity: sha512-nCidhOH0XlX+7e6xaJDq6fwnwaWasB/4w2LHkV7A96G+m+7EXZqbbaKSboUlaiGDly0dWNajk2FrYFo64ZucPA==}
'@serve.zone/interfaces@6.1.0':
resolution: {integrity: sha512-nhxMmMfemBaGM1xxFpbNM8/zPM4Y59mVsgz9XBNGZr6n7kn81QsY+Xcn5HnLywztuGHqgEZRWGmI4MPzORRktw==}
'@tempfix/lenis@1.3.20':
resolution: {integrity: sha512-ypeB0FuHLHOCQXW4d0RQ69txPJJH+1CHcpsZIUdcv2t1vR0IVyQr2vHihtde9UOXhjzqEnUphWon/UcJNsa0YA==}
@@ -3571,7 +3571,7 @@ snapshots:
'@serve.zone/appstore@0.2.0':
dependencies:
'@serve.zone/interfaces': 6.0.0
'@serve.zone/interfaces': 6.1.0
'@serve.zone/catalog@2.12.6(@tiptap/pm@2.27.2)':
dependencies:
@@ -3586,7 +3586,7 @@ snapshots:
- supports-color
- vue
'@serve.zone/interfaces@6.0.0':
'@serve.zone/interfaces@6.1.0':
dependencies:
'@api.global/typedrequest-interfaces': 3.0.19
'@push.rocks/smartlog-interfaces': 3.0.2
+2
View File
@@ -429,6 +429,7 @@ export class OneboxDockerManager {
},
PortBindings: portConfig.portBindings,
Binds: this.getStandaloneVolumeBinds(service),
ExtraHosts: ['host.docker.internal:host-gateway'],
},
});
@@ -489,6 +490,7 @@ export class OneboxDockerManager {
Image: fullImage,
Env: env,
Mounts: this.getSwarmVolumeMounts(service),
Hosts: ['host.docker.internal:host-gateway'],
Labels: {
'managed-by': 'onebox',
'onebox-service': service.name,
+52 -3
View File
@@ -72,6 +72,35 @@ export class OneboxServicesManager {
}
}
private createHostedAppControlToken(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes).map((byteArg) => byteArg.toString(16).padStart(2, '0')).join('');
}
private createHostedAppRuntimeEnvVars(): {
appInstanceId: string;
appControlToken: string;
envVars: Record<string, string>;
} {
const appInstanceId = crypto.randomUUID();
const appControlToken = this.createHostedAppControlToken();
const runtimeUrl =
Deno.env.get('SERVEZONE_RUNTIME_URL') ||
Deno.env.get('ONEBOX_RUNTIME_URL') ||
'http://host.docker.internal:3000';
return {
appInstanceId,
appControlToken,
envVars: {
SERVEZONE_RUNTIME_URL: runtimeUrl,
SERVEZONE_APP_INSTANCE_ID: appInstanceId,
SERVEZONE_APP_CONTROL_TOKEN: appControlToken,
SERVEZONE_APP_HOST_TYPE: 'onebox',
},
};
}
/**
* Deploy a new service (full workflow)
*/
@@ -109,12 +138,18 @@ export class OneboxServicesManager {
}
: undefined;
const hostedAppRuntime = this.createHostedAppRuntimeEnvVars();
const requestedEnvVars = {
...(options.envVars || {}),
...hostedAppRuntime.envVars,
};
// Create service record in database
const service = await this.database.createService({
name: options.name,
image: options.useOneboxRegistry ? imageToPull : options.image,
registry: options.registry,
envVars: options.envVars || {},
envVars: requestedEnvVars,
volumes: options.volumes || [],
publishedPorts: options.publishedPorts || [],
port: options.port,
@@ -135,6 +170,20 @@ export class OneboxServicesManager {
appTemplateVersion: options.appTemplateVersion,
});
this.database.upsertHostedAppLifecycle({
serviceId: service.id!,
appInstanceId: hostedAppRuntime.appInstanceId,
controlToken: hostedAppRuntime.appControlToken,
hostType: 'onebox',
state: {
appInstanceId: hostedAppRuntime.appInstanceId,
hostType: 'onebox',
appName: options.name,
publicUrl: options.domain ? `https://${options.domain}` : undefined,
runtimeStatus: 'unknown',
},
});
// Provision platform resources if needed
let platformEnvVars: Record<string, string> = {};
if (platformRequirements) {
@@ -151,8 +200,8 @@ export class OneboxServicesManager {
}
}
// Merge platform env vars with user-specified env vars (user vars take precedence)
const mergedEnvVars = { ...platformEnvVars, ...(options.envVars || {}) };
// Merge platform and app env vars, with host lifecycle values kept authoritative.
const mergedEnvVars = { ...platformEnvVars, ...requestedEnvVars };
this.resolveEnvVarTemplates(mergedEnvVars, {
...platformEnvVars,
SERVICE_NAME: options.name,
+95
View File
@@ -21,6 +21,8 @@ import type {
IBackup,
IBackupSchedule,
IBackupScheduleUpdate,
IHostedAppLifecycleRecord,
IHostedAppLifecycleState,
} from '../types.ts';
import type { TBindValue } from './types.ts';
import { logger } from '../logging.ts';
@@ -116,6 +118,20 @@ export class OneboxDatabase {
)
`);
this.query(`
CREATE TABLE IF NOT EXISTS hosted_app_lifecycle (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL UNIQUE,
app_instance_id TEXT NOT NULL UNIQUE,
control_token TEXT NOT NULL,
host_type TEXT NOT NULL,
state_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
// Registries table
this.query(`
CREATE TABLE IF NOT EXISTS registries (
@@ -316,6 +332,85 @@ export class OneboxDatabase {
this.serviceRepo.delete(id);
}
// ============ Hosted App Lifecycle ============
upsertHostedAppLifecycle(recordArg: Omit<IHostedAppLifecycleRecord, 'id' | 'createdAt' | 'updatedAt'>): IHostedAppLifecycleRecord {
const existing = this.getHostedAppLifecycleByServiceId(recordArg.serviceId);
const now = Date.now();
if (existing) {
this.query(
`UPDATE hosted_app_lifecycle
SET app_instance_id = ?, control_token = ?, host_type = ?, state_json = ?, updated_at = ?
WHERE service_id = ?`,
[
recordArg.appInstanceId,
recordArg.controlToken,
recordArg.hostType,
JSON.stringify(recordArg.state),
now,
recordArg.serviceId,
],
);
} else {
this.query(
`INSERT INTO hosted_app_lifecycle (
service_id, app_instance_id, control_token, host_type, state_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
recordArg.serviceId,
recordArg.appInstanceId,
recordArg.controlToken,
recordArg.hostType,
JSON.stringify(recordArg.state),
now,
now,
],
);
}
return this.getHostedAppLifecycleByServiceId(recordArg.serviceId)!;
}
getHostedAppLifecycleByServiceId(serviceIdArg: number): IHostedAppLifecycleRecord | null {
const rows = this.query('SELECT * FROM hosted_app_lifecycle WHERE service_id = ?', [serviceIdArg]);
return rows[0] ? this.rowToHostedAppLifecycle(rows[0]) : null;
}
getHostedAppLifecycleByInstanceId(appInstanceIdArg: string): IHostedAppLifecycleRecord | null {
const rows = this.query('SELECT * FROM hosted_app_lifecycle WHERE app_instance_id = ?', [appInstanceIdArg]);
return rows[0] ? this.rowToHostedAppLifecycle(rows[0]) : null;
}
updateHostedAppLifecycleState(serviceIdArg: number, stateArg: IHostedAppLifecycleState): IHostedAppLifecycleRecord {
this.query(
'UPDATE hosted_app_lifecycle SET state_json = ?, updated_at = ? WHERE service_id = ?',
[JSON.stringify(stateArg), Date.now(), serviceIdArg],
);
return this.getHostedAppLifecycleByServiceId(serviceIdArg)!;
}
private rowToHostedAppLifecycle(rowArg: Record<string, unknown>): IHostedAppLifecycleRecord {
let state: IHostedAppLifecycleState;
try {
state = JSON.parse(String(rowArg.state_json || '{}'));
} catch {
state = {
appInstanceId: String(rowArg.app_instance_id),
hostType: String(rowArg.host_type),
runtimeStatus: 'unknown',
};
}
return {
id: Number(rowArg.id),
serviceId: Number(rowArg.service_id),
appInstanceId: String(rowArg.app_instance_id),
controlToken: String(rowArg.control_token),
hostType: 'onebox',
state,
createdAt: Number(rowArg.created_at),
updatedAt: Number(rowArg.updated_at),
};
}
// ============ Registries CRUD (delegated to repository) ============
async createRegistry(registry: Omit<IRegistry, 'id'>): Promise<IRegistry> {
@@ -0,0 +1,23 @@
import { BaseMigration } from './base-migration.ts';
import type { TQueryFunction } from '../types.ts';
export class Migration018HostedAppLifecycle extends BaseMigration {
readonly version = 18;
readonly description = 'Add hosted app lifecycle state';
up(query: TQueryFunction): void {
query(`
CREATE TABLE IF NOT EXISTS hosted_app_lifecycle (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL UNIQUE,
app_instance_id TEXT NOT NULL UNIQUE,
control_token TEXT NOT NULL,
host_type TEXT NOT NULL,
state_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
}
}
@@ -24,6 +24,7 @@ import { Migration014ContainerArchive } from './migration-014-containerarchive.t
import { Migration015SmartProxyPlatformService } from './migration-015-smartproxy-platform-service.ts';
import { Migration016ServiceVolumes } from './migration-016-service-volumes.ts';
import { Migration017ServicePublishedPorts } from './migration-017-service-published-ports.ts';
import { Migration018HostedAppLifecycle } from './migration-018-hosted-app-lifecycle.ts';
import type { BaseMigration } from './base-migration.ts';
export class MigrationRunner {
@@ -52,6 +53,7 @@ export class MigrationRunner {
new Migration015SmartProxyPlatformService(),
new Migration016ServiceVolumes(),
new Migration017ServicePublishedPorts(),
new Migration018HostedAppLifecycle(),
].sort((a, b) => a.version - b.version);
}
+2
View File
@@ -27,6 +27,7 @@ export class OpsServer {
public logsHandler!: handlers.LogsHandler;
public workspaceHandler!: handlers.WorkspaceHandler;
public appStoreHandler!: handlers.AppStoreHandler;
public hostedAppHandler!: handlers.HostedAppHandler;
constructor(oneboxRef: Onebox) {
this.oneboxRef = oneboxRef;
@@ -71,6 +72,7 @@ export class OpsServer {
this.logsHandler = new handlers.LogsHandler(this);
this.workspaceHandler = new handlers.WorkspaceHandler(this);
this.appStoreHandler = new handlers.AppStoreHandler(this);
this.hostedAppHandler = new handlers.HostedAppHandler(this);
logger.success('OpsServer TypedRequest handlers initialized');
}
+10
View File
@@ -23,6 +23,16 @@ export class AppStoreHandler {
.slice(0, 25);
}
public getUpgradeOperationsForService(serviceNameArg: string): IAppStoreUpgradeOperation[] {
return this.getUpgradeOperations().filter((operationArg) => operationArg.serviceName === serviceNameArg);
}
public async startHostedAppUpgrade(serviceNameArg: string, targetVersionArg: string): Promise<IAppStoreUpgradeOperation> {
const operation = await this.createUpgradeOperation(serviceNameArg, targetVersionArg);
void this.performUpgrade(operation.id).catch(() => {});
return operation;
}
private getRunningUpgrade(serviceNameArg: string): IAppStoreUpgradeOperation | null {
for (const operation of this.upgradeOperations.values()) {
if (operation.serviceName === serviceNameArg && operation.status === 'running') {
+273
View File
@@ -0,0 +1,273 @@
import * as plugins from '../../plugins.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireAdminIdentity } from '../helpers/guards.ts';
type IHostedAppLifecycleRecord = import('../../types.ts').IHostedAppLifecycleRecord;
type IService = import('../../types.ts').IService;
type IHostedAppLifecycleState = interfaces.data.IHostedAppLifecycleState;
type IHostedAppUpgradeState = interfaces.data.IHostedAppUpgradeState;
export class HostedAppHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private async requireHostedAppIdentity(
identityArg: import('@serve.zone/interfaces').data.IHostedAppRuntimeIdentity,
): Promise<{ record: IHostedAppLifecycleRecord; service: IService }> {
const record = identityArg?.appInstanceId
? this.opsServerRef.oneboxRef.database.getHostedAppLifecycleByInstanceId(identityArg.appInstanceId)
: null;
if (!record || record.controlToken !== identityArg?.appControlToken) {
throw new plugins.typedrequest.TypedResponseError('Hosted app identity is invalid');
}
const service = this.opsServerRef.oneboxRef.database.getServiceByID(record.serviceId);
if (!service) {
throw new plugins.typedrequest.TypedResponseError('Hosted app service not found');
}
return { record, service };
}
private async getUpgradeState(serviceArg: IService): Promise<IHostedAppUpgradeState> {
const latestOperation = this.opsServerRef.appStoreHandler.getUpgradeOperationsForService(serviceArg.name)[0];
if (latestOperation) {
return {
status: latestOperation.status === 'running' ? 'running' : latestOperation.status,
appTemplateId: latestOperation.appTemplateId,
currentVersion: latestOperation.fromVersion,
targetVersion: latestOperation.targetVersion,
operationId: latestOperation.id,
warnings: latestOperation.warnings,
error: latestOperation.error,
startedAt: latestOperation.startedAt,
updatedAt: latestOperation.updatedAt,
completedAt: latestOperation.completedAt,
};
}
if (!serviceArg.appTemplateId || !serviceArg.appTemplateVersion) {
return { status: 'unknown' };
}
const upgradeableServices = await this.opsServerRef.oneboxRef.appStore.getUpgradeableAppStoreServices();
const upgradeable = upgradeableServices.find((serviceArg2) => serviceArg2.serviceName === serviceArg.name);
if (!upgradeable) {
return {
status: 'upToDate',
appTemplateId: serviceArg.appTemplateId,
currentVersion: serviceArg.appTemplateVersion,
latestVersion: serviceArg.appTemplateVersion,
};
}
return {
status: 'available',
appTemplateId: upgradeable.appTemplateId,
currentVersion: upgradeable.currentVersion,
latestVersion: upgradeable.latestVersion,
targetVersion: upgradeable.latestVersion,
};
}
private async getLifecycleState(recordArg: IHostedAppLifecycleRecord, serviceArg: IService): Promise<IHostedAppLifecycleState> {
const upgradeState = await this.getUpgradeState(serviceArg);
const state: IHostedAppLifecycleState = {
...recordArg.state,
appInstanceId: recordArg.appInstanceId,
hostType: 'onebox',
appName: recordArg.state.appName || serviceArg.name,
publicUrl: recordArg.state.publicUrl || (serviceArg.domain ? `https://${serviceArg.domain}` : undefined),
upgradeState,
};
this.opsServerRef.oneboxRef.database.updateHostedAppLifecycleState(recordArg.serviceId, state);
return state;
}
private async getLifecycleStateByServiceName(serviceNameArg: string): Promise<IHostedAppLifecycleState | undefined> {
const service = this.opsServerRef.oneboxRef.database.getServiceByName(serviceNameArg);
if (!service?.id) {
throw new plugins.typedrequest.TypedResponseError('Service not found');
}
const record = this.opsServerRef.oneboxRef.database.getHostedAppLifecycleByServiceId(service.id);
if (!record) {
return undefined;
}
return await this.getLifecycleState(record, service);
}
private async updateLifecycleState(
recordArg: IHostedAppLifecycleRecord,
serviceArg: IService,
stateArg: IHostedAppLifecycleState,
): Promise<IHostedAppLifecycleState> {
const record = this.opsServerRef.oneboxRef.database.updateHostedAppLifecycleState(recordArg.serviceId, stateArg);
return await this.getLifecycleState(record, serviceArg);
}
private registerHandlers(): void {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_HostedApp_ReportLifecycleState>(
'hostedAppReportLifecycleState',
async (dataArg) => {
const { record, service } = await this.requireHostedAppIdentity(dataArg.identity);
const state = await this.updateLifecycleState(record, service, {
...record.state,
...dataArg.report,
appInstanceId: record.appInstanceId,
hostType: 'onebox',
reportedAt: Date.now(),
});
return { state };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_HostedApp_GetLifecycleState>(
'hostedAppGetLifecycleState',
async (dataArg) => {
const { record, service } = await this.requireHostedAppIdentity(dataArg.identity);
return { state: await this.getLifecycleState(record, service) };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_HostedApp_RequestBootstrapAction>(
'hostedAppRequestBootstrapAction',
async (dataArg) => {
const { record, service } = await this.requireHostedAppIdentity(dataArg.identity);
const now = Date.now();
const action = {
...dataArg.action,
id: dataArg.action.id || crypto.randomUUID(),
status: 'ready' as const,
label: dataArg.action.label || 'Initial setup',
createdAt: now,
updatedAt: now,
};
const state = await this.updateLifecycleState(record, service, {
...record.state,
appInstanceId: record.appInstanceId,
hostType: 'onebox',
runtimeStatus: 'setupRequired',
bootstrapAction: action,
reportedAt: now,
});
return { action, state };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_HostedApp_CompleteBootstrapAction>(
'hostedAppCompleteBootstrapAction',
async (dataArg) => {
const { record, service } = await this.requireHostedAppIdentity(dataArg.identity);
const now = Date.now();
const existingAction = record.state.bootstrapAction;
const bootstrapAction = existingAction
? {
...existingAction,
id: dataArg.actionId || existingAction.id,
status: 'completed' as const,
message: dataArg.message || existingAction.message,
updatedAt: now,
completedAt: now,
}
: undefined;
const state = await this.updateLifecycleState(record, service, {
...record.state,
appInstanceId: record.appInstanceId,
hostType: 'onebox',
runtimeStatus: record.state.runtimeStatus === 'setupRequired' ? 'running' : record.state.runtimeStatus,
bootstrapAction,
reportedAt: now,
});
return { state };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_HostedApp_StartManagedUpgrade>(
'hostedAppStartManagedUpgrade',
async (dataArg) => {
const { record, service } = await this.requireHostedAppIdentity(dataArg.identity);
const upgradeState = await this.getUpgradeState(service);
const targetVersion = dataArg.targetVersion || upgradeState.targetVersion || upgradeState.latestVersion;
if (!targetVersion) {
throw new plugins.typedrequest.TypedResponseError('No managed upgrade target is available');
}
const operation = await this.opsServerRef.appStoreHandler.startHostedAppUpgrade(service.name, targetVersion);
const nextUpgradeState: IHostedAppUpgradeState = {
status: 'running',
appTemplateId: operation.appTemplateId,
currentVersion: operation.fromVersion,
targetVersion: operation.targetVersion,
operationId: operation.id,
warnings: operation.warnings,
startedAt: operation.startedAt,
updatedAt: operation.updatedAt,
};
const state = await this.updateLifecycleState(record, service, {
...record.state,
appInstanceId: record.appInstanceId,
hostType: 'onebox',
upgradeState: nextUpgradeState,
reportedAt: Date.now(),
});
return { upgradeState: nextUpgradeState, state };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_HostedApp_GetManagedUpgradeStatus>(
'hostedAppGetManagedUpgradeStatus',
async (dataArg) => {
const { service } = await this.requireHostedAppIdentity(dataArg.identity);
return { upgradeState: await this.getUpgradeState(service) };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetHostedAppLifecycleForService>(
'getHostedAppLifecycleForService',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
return { state: await this.getLifecycleStateByServiceName(dataArg.serviceName) };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DismissHostedAppBootstrapAction>(
'dismissHostedAppBootstrapAction',
async (dataArg) => {
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
const service = this.opsServerRef.oneboxRef.database.getServiceByName(dataArg.serviceName);
if (!service?.id) {
throw new plugins.typedrequest.TypedResponseError('Service not found');
}
const record = this.opsServerRef.oneboxRef.database.getHostedAppLifecycleByServiceId(service.id);
if (!record) {
return { state: undefined };
}
const nextState = {
...record.state,
appInstanceId: record.appInstanceId,
hostType: 'onebox',
bootstrapAction: undefined,
};
return { state: await this.updateLifecycleState(record, service, nextState) };
},
),
);
}
}
+1
View File
@@ -14,3 +14,4 @@ export * from './managed-dcrouter.handler.ts';
export * from './logs.handler.ts';
export * from './workspace.handler.ts';
export * from './appstore.handler.ts';
export * from './hostedapp.handler.ts';
+15
View File
@@ -2,6 +2,8 @@
* Type definitions for Onebox
*/
import type * as servezoneInterfaces from '@serve.zone/interfaces';
// Service types
export interface IService {
id?: number;
@@ -32,6 +34,19 @@ export interface IService {
appTemplateVersion?: string;
}
export type IHostedAppLifecycleState = servezoneInterfaces.data.IHostedAppLifecycleState;
export interface IHostedAppLifecycleRecord {
id?: number;
serviceId: number;
appInstanceId: string;
controlToken: string;
hostType: 'onebox';
state: IHostedAppLifecycleState;
createdAt: number;
updatedAt: number;
}
export interface IServiceVolume {
name?: string;
source?: string;
+1 -1
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -0,0 +1,5 @@
import type * as servezoneInterfaces from '@serve.zone/interfaces';
export type IHostedAppLifecycleState = servezoneInterfaces.data.IHostedAppLifecycleState;
export type IHostedAppBootstrapAction = servezoneInterfaces.data.IHostedAppBootstrapAction;
export type IHostedAppUpgradeState = servezoneInterfaces.data.IHostedAppUpgradeState;
+1
View File
@@ -3,6 +3,7 @@ export * from './service.ts';
export * from './platform.ts';
export * from './network.ts';
export * from './domain.ts';
export * from './hostedapp.ts';
export * from './registry.ts';
export * from './backup.ts';
export * from './settings.ts';
+38
View File
@@ -0,0 +1,38 @@
import type * as servezoneInterfaces from '@serve.zone/interfaces';
import * as plugins from '../plugins.ts';
import * as data from '../data/index.ts';
export type IReq_HostedApp_ReportLifecycleState = servezoneInterfaces.requests.hostedapp.IReq_HostedApp_ReportLifecycleState;
export type IReq_HostedApp_GetLifecycleState = servezoneInterfaces.requests.hostedapp.IReq_HostedApp_GetLifecycleState;
export type IReq_HostedApp_RequestBootstrapAction = servezoneInterfaces.requests.hostedapp.IReq_HostedApp_RequestBootstrapAction;
export type IReq_HostedApp_CompleteBootstrapAction = servezoneInterfaces.requests.hostedapp.IReq_HostedApp_CompleteBootstrapAction;
export type IReq_HostedApp_StartManagedUpgrade = servezoneInterfaces.requests.hostedapp.IReq_HostedApp_StartManagedUpgrade;
export type IReq_HostedApp_GetManagedUpgradeStatus = servezoneInterfaces.requests.hostedapp.IReq_HostedApp_GetManagedUpgradeStatus;
export interface IReq_GetHostedAppLifecycleForService extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetHostedAppLifecycleForService
> {
method: 'getHostedAppLifecycleForService';
request: {
identity: data.IIdentity;
serviceName: string;
};
response: {
state?: data.IHostedAppLifecycleState;
};
}
export interface IReq_DismissHostedAppBootstrapAction extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DismissHostedAppBootstrapAction
> {
method: 'dismissHostedAppBootstrapAction';
request: {
identity: data.IIdentity;
serviceName: string;
};
response: {
state?: data.IHostedAppLifecycleState;
};
}
+1
View File
@@ -13,3 +13,4 @@ export * from './settings.ts';
export * from './logs.ts';
export * from './workspace.ts';
export * from './appstore.ts';
export * from './hostedapp.ts';
+46 -5
View File
@@ -22,6 +22,7 @@ export interface ISystemState {
export interface IServicesState {
services: interfaces.data.IService[];
currentService: interfaces.data.IService | null;
currentHostedAppLifecycle: interfaces.data.IHostedAppLifecycleState | null;
currentServiceLogs: interfaces.data.ILogEntry[];
currentServiceStats: interfaces.data.IContainerStats | null;
platformServices: interfaces.data.IPlatformService[];
@@ -97,6 +98,7 @@ export const servicesStatePart = await appState.getStatePart<IServicesState>(
{
services: [],
currentService: null,
currentHostedAppLifecycle: null,
currentServiceLogs: [],
currentServiceStats: null,
platformServices: [],
@@ -268,11 +270,24 @@ export const fetchServiceAction = servicesStatePart.createAction<{ name: string
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetService
>('/typedrequest', 'getService');
const response = await typedRequest.fire({
identity: context.identity!,
serviceName: dataArg.name,
});
return { ...statePartArg.getState(), currentService: response.service };
const hostedAppRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetHostedAppLifecycleForService
>('/typedrequest', 'getHostedAppLifecycleForService');
const [response, hostedAppResponse] = await Promise.all([
typedRequest.fire({
identity: context.identity!,
serviceName: dataArg.name,
}),
hostedAppRequest.fire({
identity: context.identity!,
serviceName: dataArg.name,
}),
]);
return {
...statePartArg.getState(),
currentService: response.service,
currentHostedAppLifecycle: hostedAppResponse.state || null,
};
} catch (err) {
console.error('Failed to fetch service:', err);
return statePartArg.getState();
@@ -320,6 +335,7 @@ export const deleteServiceAction = servicesStatePart.createAction<{ name: string
...state,
services: state.services.filter((s) => s.name !== dataArg.name),
currentService: null,
currentHostedAppLifecycle: null,
};
} catch (err) {
console.error('Failed to delete service:', err);
@@ -328,6 +344,28 @@ export const deleteServiceAction = servicesStatePart.createAction<{ name: string
},
);
export const dismissHostedAppBootstrapAction = servicesStatePart.createAction<{ name: string }>(
async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DismissHostedAppBootstrapAction
>('/typedrequest', 'dismissHostedAppBootstrapAction');
const response = await typedRequest.fire({
identity: context.identity!,
serviceName: dataArg.name,
});
return {
...statePartArg.getState(),
currentHostedAppLifecycle: response.state || null,
};
} catch (err) {
console.error('Failed to dismiss hosted app bootstrap action:', err);
return statePartArg.getState();
}
},
);
export const startServiceAction = servicesStatePart.createAction<{ name: string }>(
async (statePartArg, dataArg) => {
const context = getActionContext();
@@ -1123,6 +1161,7 @@ socketRouter.addTypedHandler(
const state = servicesStatePart.getState();
let services = state.services;
let currentService = state.currentService;
let currentHostedAppLifecycle = state.currentHostedAppLifecycle;
let currentServiceLogs = state.currentServiceLogs;
let currentServiceStats = state.currentServiceStats;
@@ -1130,6 +1169,7 @@ socketRouter.addTypedHandler(
services = services.filter((service) => service.name !== dataArg.serviceName);
if (currentService?.name === dataArg.serviceName) {
currentService = null;
currentHostedAppLifecycle = null;
currentServiceLogs = [];
currentServiceStats = null;
}
@@ -1144,6 +1184,7 @@ socketRouter.addTypedHandler(
...state,
services,
currentService,
currentHostedAppLifecycle,
currentServiceLogs,
currentServiceStats,
});
+100
View File
@@ -113,6 +113,7 @@ export class ObViewServices extends DeesElement {
accessor servicesState: appstate.IServicesState = {
services: [],
currentService: null,
currentHostedAppLifecycle: null,
currentServiceLogs: [],
currentServiceStats: null,
platformServices: [],
@@ -488,6 +489,9 @@ export class ObViewServices extends DeesElement {
${!upgradeOperation && latestUpgradeOperation?.status === 'failed'
? this.renderUpgradeOperation(latestUpgradeOperation)
: ''}
${service && this.servicesState.currentHostedAppLifecycle
? this.renderHostedAppLifecycleCard(service, this.servicesState.currentHostedAppLifecycle)
: ''}
${upgradeInfo ? html`
<div style="
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1), rgba(139, 92, 246, 0.1));
@@ -601,6 +605,102 @@ export class ObViewServices extends DeesElement {
`;
}
private renderHostedAppLifecycleCard(
serviceArg: interfaces.data.IService,
lifecycleArg: interfaces.data.IHostedAppLifecycleState,
): TemplateResult | '' {
const action = lifecycleArg.bootstrapAction;
const upgrade = lifecycleArg.upgradeState;
const showUpgrade = upgrade?.status === 'available' || upgrade?.status === 'running' || upgrade?.status === 'failed';
if (!action && !showUpgrade) return '';
return html`
<div style="
background: var(--ci-shade-1, #09090b);
border: 1px solid ${action ? '#f59e0b' : '#60a5fa'};
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
">
<div style="display: flex; justify-content: space-between; gap: 16px; align-items: flex-start;">
<div>
<div style="font-size: 14px; font-weight: 600; color: var(--ci-shade-7, #e4e4e7);">
Hosted App Lifecycle
</div>
<div style="font-size: 12px; color: var(--ci-shade-4, #71717a); margin-top: 4px;">
Runtime reports from ${lifecycleArg.appName || serviceArg.name}${lifecycleArg.publicUrl ? html` at ${lifecycleArg.publicUrl}` : ''}.
</div>
</div>
<span style="font-size: 12px; color: var(--ci-shade-4, #71717a); text-transform: uppercase; letter-spacing: 0.04em;">
${lifecycleArg.runtimeStatus || 'unknown'}
</span>
</div>
${action ? html`
<div style="margin-top: 14px; padding: 12px; background: var(--ci-shade-0, #030305); border-radius: 6px;">
<div style="font-size: 13px; font-weight: 600; color: var(--ci-shade-7, #e4e4e7); margin-bottom: 8px;">
${action.label} ${action.status === 'completed' ? '(completed)' : ''}
</div>
${action.message ? html`<div style="font-size: 12px; color: var(--ci-shade-5, #a1a1aa); margin-bottom: 8px;">${action.message}</div>` : ''}
${action.url ? this.renderHostedAppValueRow('Setup URL', action.url) : ''}
${action.username ? this.renderHostedAppValueRow('Username', action.username) : ''}
${action.password ? this.renderHostedAppValueRow('Password', action.password) : ''}
${action.expiresAt ? html`
<div style="font-size: 12px; color: var(--ci-shade-4, #71717a); margin-top: 8px;">
Expires ${new Date(action.expiresAt).toLocaleString()}
</div>
` : ''}
<div style="font-size: 12px; color: #fbbf24; margin-top: 10px;">
Mark done only after you saved the information or completed the app setup. This deletes the displayed bootstrap data from Onebox.
</div>
<button
class="deploy-button"
style="margin-top: 12px; padding: 8px 14px; font-size: 12px; background: transparent; border: 1px solid #f59e0b; color: #fbbf24;"
@click=${() => this.handleDismissHostedAppBootstrap(serviceArg.name)}
>Mark done and delete</button>
</div>
` : ''}
${showUpgrade ? html`
<div style="margin-top: ${action ? '12px' : '14px'}; padding: 12px; background: var(--ci-shade-0, #030305); border-radius: 6px;">
<div style="font-size: 13px; font-weight: 600; color: var(--ci-shade-7, #e4e4e7);">
Managed upgrade: ${upgrade!.status}
</div>
<div style="font-size: 12px; color: var(--ci-shade-5, #a1a1aa); margin-top: 4px;">
${upgrade!.currentVersion || '-'} ${upgrade!.targetVersion || upgrade!.latestVersion ? html`&rarr; ${upgrade!.targetVersion || upgrade!.latestVersion}` : ''}
</div>
${upgrade!.error ? html`<div style="font-size: 12px; color: #f87171; margin-top: 6px;">${upgrade!.error}</div>` : ''}
</div>
` : ''}
</div>
`;
}
private renderHostedAppValueRow(labelArg: string, valueArg: string): TemplateResult {
return html`
<div style="display: grid; grid-template-columns: 110px 1fr auto; gap: 8px; align-items: center; margin-top: 6px;">
<span style="font-size: 12px; color: var(--ci-shade-4, #71717a);">${labelArg}</span>
<code style="font-size: 12px; color: var(--ci-shade-7, #e4e4e7); overflow-wrap: anywhere;">${valueArg}</code>
<button
class="deploy-button"
style="padding: 4px 8px; font-size: 11px; background: transparent; border: 1px solid var(--ci-shade-3, #3f3f46); color: var(--ci-shade-6, #d4d4d8);"
@click=${() => this.copyHostedAppValue(valueArg)}
>Copy</button>
</div>
`;
}
private async copyHostedAppValue(valueArg: string): Promise<void> {
await navigator.clipboard?.writeText(valueArg);
}
private async handleDismissHostedAppBootstrap(serviceNameArg: string): Promise<void> {
if (!confirm('Delete the hosted app bootstrap information from Onebox?')) return;
await appstate.servicesStatePart.dispatchAction(appstate.dismissHostedAppBootstrapAction, {
name: serviceNameArg,
});
}
private renderBackupsView(): TemplateResult {
return html`
<ob-sectionheading>Backups</ob-sectionheading>