feat(opsserver): add container workspace API and backend execution environment for services
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/onebox',
|
||||
version: '1.20.0',
|
||||
version: '1.21.0',
|
||||
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
|
||||
}
|
||||
|
||||
@@ -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 { BackendExecutionEnvironment } from '../environments/backend-environment.js';
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
@@ -135,6 +136,9 @@ export class ObViewServices extends DeesElement {
|
||||
@state()
|
||||
accessor selectedPlatformType: string = '';
|
||||
|
||||
@state()
|
||||
accessor workspaceOpen: boolean = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
@@ -186,6 +190,18 @@ export class ObViewServices extends DeesElement {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
:host(.workspace-mode) {
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:host(.workspace-mode) ob-sectionheading {
|
||||
display: none;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -347,6 +363,28 @@ export class ObViewServices extends DeesElement {
|
||||
this.currentView = 'list';
|
||||
}}
|
||||
@service-action=${(e: CustomEvent) => this.handleServiceAction(e)}
|
||||
@request-workspace=${async (e: CustomEvent) => {
|
||||
const name = e.detail?.service?.name || this.selectedServiceName;
|
||||
const identity = appstate.loginStatePart.getState().identity;
|
||||
if (!name || !identity) return;
|
||||
try {
|
||||
const env = new BackendExecutionEnvironment(name, identity);
|
||||
await env.init();
|
||||
const detailView = this.shadowRoot?.querySelector('sz-service-detail-view') as any;
|
||||
if (detailView) {
|
||||
detailView.workspaceEnvironment = env;
|
||||
}
|
||||
this.workspaceOpen = true;
|
||||
this.classList.add('workspace-mode');
|
||||
} catch (err) {
|
||||
console.error('Failed to open workspace:', err);
|
||||
}
|
||||
}}
|
||||
@back=${() => {
|
||||
this.workspaceOpen = false;
|
||||
this.classList.remove('workspace-mode');
|
||||
this.currentView = 'list';
|
||||
}}
|
||||
></sz-service-detail-view>
|
||||
`;
|
||||
}
|
||||
|
||||
155
ts_web/environments/backend-environment.ts
Normal file
155
ts_web/environments/backend-environment.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* BackendExecutionEnvironment — implements IExecutionEnvironment
|
||||
* by routing all filesystem and process operations through the onebox API
|
||||
* to Docker exec on the target container.
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as interfaces from '../../ts_interfaces/index.js';
|
||||
|
||||
// Import IExecutionEnvironment type from dees-catalog
|
||||
type IExecutionEnvironment = import('@design.estate/dees-catalog').IExecutionEnvironment;
|
||||
type IFileEntry = import('@design.estate/dees-catalog').IFileEntry;
|
||||
type IFileWatcher = import('@design.estate/dees-catalog').IFileWatcher;
|
||||
type IProcessHandle = import('@design.estate/dees-catalog').IProcessHandle;
|
||||
|
||||
const domtools = plugins.deesElement.domtools;
|
||||
|
||||
export class BackendExecutionEnvironment implements IExecutionEnvironment {
|
||||
readonly type = 'backend' as const;
|
||||
private _ready = false;
|
||||
private identity: interfaces.data.IIdentity;
|
||||
|
||||
constructor(
|
||||
private serviceName: string,
|
||||
identity: interfaces.data.IIdentity,
|
||||
) {
|
||||
this.identity = identity;
|
||||
}
|
||||
|
||||
get ready(): boolean {
|
||||
return this._ready;
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
// Verify the container is accessible by checking if root exists
|
||||
const result = await this.fireRequest<interfaces.requests.IReq_WorkspaceExists>(
|
||||
'workspaceExists',
|
||||
{ path: '/' },
|
||||
);
|
||||
if (!result.exists) {
|
||||
throw new Error(`Cannot access container filesystem for service: ${this.serviceName}`);
|
||||
}
|
||||
this._ready = true;
|
||||
}
|
||||
|
||||
async destroy(): Promise<void> {
|
||||
this._ready = false;
|
||||
}
|
||||
|
||||
async readFile(path: string): Promise<string> {
|
||||
const result = await this.fireRequest<interfaces.requests.IReq_WorkspaceReadFile>(
|
||||
'workspaceReadFile',
|
||||
{ path },
|
||||
);
|
||||
return result.content;
|
||||
}
|
||||
|
||||
async writeFile(path: string, contents: string): Promise<void> {
|
||||
await this.fireRequest<interfaces.requests.IReq_WorkspaceWriteFile>(
|
||||
'workspaceWriteFile',
|
||||
{ path, content: contents },
|
||||
);
|
||||
}
|
||||
|
||||
async readDir(path: string): Promise<IFileEntry[]> {
|
||||
const result = await this.fireRequest<interfaces.requests.IReq_WorkspaceReadDir>(
|
||||
'workspaceReadDir',
|
||||
{ path },
|
||||
);
|
||||
return result.entries;
|
||||
}
|
||||
|
||||
async mkdir(path: string): Promise<void> {
|
||||
await this.fireRequest<interfaces.requests.IReq_WorkspaceMkdir>(
|
||||
'workspaceMkdir',
|
||||
{ path },
|
||||
);
|
||||
}
|
||||
|
||||
async rm(path: string, options?: { recursive?: boolean }): Promise<void> {
|
||||
await this.fireRequest<interfaces.requests.IReq_WorkspaceRm>(
|
||||
'workspaceRm',
|
||||
{ path, recursive: options?.recursive },
|
||||
);
|
||||
}
|
||||
|
||||
async exists(path: string): Promise<boolean> {
|
||||
const result = await this.fireRequest<interfaces.requests.IReq_WorkspaceExists>(
|
||||
'workspaceExists',
|
||||
{ path },
|
||||
);
|
||||
return result.exists;
|
||||
}
|
||||
|
||||
watch(
|
||||
_path: string,
|
||||
_callback: (event: 'rename' | 'change', filename: string | null) => void,
|
||||
_options?: { recursive?: boolean },
|
||||
): IFileWatcher {
|
||||
// Polling-based file watching — check for changes periodically
|
||||
// For now, return a no-op watcher. Full implementation would poll readDir.
|
||||
return { stop: () => {} };
|
||||
}
|
||||
|
||||
async spawn(command: string, args?: string[]): Promise<IProcessHandle> {
|
||||
// For interactive shell: execute the command via the workspace exec API
|
||||
// and return a process handle that bridges stdin/stdout
|
||||
const cmd = args ? [command, ...args] : [command];
|
||||
const fullCommand = cmd.join(' ');
|
||||
|
||||
// Use a non-interactive exec for now — full interactive shell would need
|
||||
// TypedSocket bidirectional streaming (to be implemented)
|
||||
const result = await this.fireRequest<interfaces.requests.IReq_WorkspaceExec>(
|
||||
'workspaceExec',
|
||||
{ command: cmd[0], args: cmd.slice(1) },
|
||||
);
|
||||
|
||||
// Create a ReadableStream from the exec output
|
||||
const output = new ReadableStream<string>({
|
||||
start(controller) {
|
||||
if (result.stdout) controller.enqueue(result.stdout);
|
||||
if (result.stderr) controller.enqueue(result.stderr);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
// Create a writable stream (no-op for non-interactive)
|
||||
const inputStream = new WritableStream<string>();
|
||||
|
||||
return {
|
||||
output,
|
||||
input: inputStream,
|
||||
exit: Promise.resolve(result.exitCode),
|
||||
kill: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to fire TypedRequests to the workspace API
|
||||
*/
|
||||
private async fireRequest<T extends { method: string; request: any; response: any }>(
|
||||
method: string,
|
||||
data: Omit<T['request'], 'identity' | 'serviceName'>,
|
||||
): Promise<T['response']> {
|
||||
const typedRequest = new domtools.plugins.typedrequest.TypedRequest<T>(
|
||||
'/typedrequest',
|
||||
method,
|
||||
);
|
||||
return await typedRequest.fire({
|
||||
identity: this.identity,
|
||||
serviceName: this.serviceName,
|
||||
...data,
|
||||
} as T['request']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user