Files
catalog/ts_web/elements/sz-service-create-view.ts

776 lines
24 KiB
TypeScript

import {
DeesElement,
customElement,
html,
css,
cssManager,
property,
state,
type TemplateResult,
} from '@design.estate/dees-element';
declare global {
interface HTMLElementTagNameMap {
'sz-service-create-view': SzServiceCreateView;
}
}
export interface IRegistry {
id: string;
name: string;
url: string;
}
export interface IPortMapping {
hostPort: string;
containerPort: string;
protocol: 'tcp' | 'udp';
}
export interface IEnvVar {
key: string;
value: string;
}
export interface IVolumeMount {
hostPath: string;
containerPath: string;
readOnly: boolean;
}
export interface IServiceConfig {
name: string;
image: string;
ports: IPortMapping[];
envVars: IEnvVar[];
volumes: IVolumeMount[];
cpuLimit: string;
memoryLimit: string;
restartPolicy: 'always' | 'on-failure' | 'never';
networkMode: string;
}
@customElement('sz-service-create-view')
export class SzServiceCreateView extends DeesElement {
public static demo = () => html`
<div style="padding: 24px; max-width: 800px;">
<sz-service-create-view
.registries=${[
{ id: '1', name: 'Onebox Registry', url: 'registry.onebox.local' },
{ id: '2', name: 'Docker Hub', url: 'docker.io' },
]}
></sz-service-create-view>
</div>
`;
public static demoGroups = ['Services'];
@property({ type: Array })
public accessor registries: IRegistry[] = [];
@property({ type: Boolean })
public accessor loading: boolean = false;
@state()
private accessor serviceName: string = '';
@state()
private accessor imageUrl: string = '';
@state()
private accessor selectedRegistry: string = '';
@state()
private accessor ports: IPortMapping[] = [{ hostPort: '', containerPort: '', protocol: 'tcp' }];
@state()
private accessor envVars: IEnvVar[] = [{ key: '', value: '' }];
@state()
private accessor volumes: IVolumeMount[] = [];
@state()
private accessor cpuLimit: string = '';
@state()
private accessor memoryLimit: string = '';
@state()
private accessor restartPolicy: 'always' | 'on-failure' | 'never' = 'always';
@state()
private accessor networkMode: string = 'bridge';
@state()
private accessor showAdvanced: boolean = false;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.header-title {
font-size: 20px;
font-weight: 600;
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
}
.header-subtitle {
font-size: 14px;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
margin-top: 4px;
}
.section {
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
border-radius: 8px;
padding: 20px;
margin-bottom: 16px;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.section-title svg {
width: 18px;
height: 18px;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.form-row.single {
grid-template-columns: 1fr;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-label {
font-size: 13px;
font-weight: 500;
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
}
.form-label .required {
color: ${cssManager.bdTheme('#ef4444', '#f87171')};
}
.form-hint {
font-size: 12px;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
}
.form-input,
.form-select {
width: 100%;
padding: 10px 12px;
background: ${cssManager.bdTheme('#ffffff', '#18181b')};
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
border-radius: 6px;
font-size: 14px;
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
outline: none;
transition: border-color 200ms ease;
box-sizing: border-box;
}
.form-input:focus,
.form-select:focus {
border-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
}
.form-input::placeholder {
color: ${cssManager.bdTheme('#a1a1aa', '#52525b')};
}
.form-select {
cursor: pointer;
}
.dynamic-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.dynamic-row {
display: flex;
gap: 8px;
align-items: flex-start;
}
.dynamic-row .form-input {
flex: 1;
}
.dynamic-row .form-select {
width: 80px;
flex-shrink: 0;
}
.remove-button {
padding: 10px;
background: transparent;
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
border-radius: 6px;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
cursor: pointer;
transition: all 200ms ease;
flex-shrink: 0;
}
.remove-button:hover {
background: ${cssManager.bdTheme('#fee2e2', 'rgba(239, 68, 68, 0.2)')};
border-color: ${cssManager.bdTheme('#fecaca', 'rgba(239, 68, 68, 0.3)')};
color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
}
.add-button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: transparent;
border: 1px dashed ${cssManager.bdTheme('#e4e4e7', '#27272a')};
border-radius: 6px;
font-size: 13px;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
cursor: pointer;
transition: all 200ms ease;
margin-top: 8px;
}
.add-button:hover {
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
border-color: ${cssManager.bdTheme('#a1a1aa', '#52525b')};
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
}
.add-button svg {
width: 14px;
height: 14px;
}
.toggle-advanced {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 0;
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
cursor: pointer;
background: none;
border: none;
}
.toggle-advanced svg {
width: 16px;
height: 16px;
transition: transform 200ms ease;
}
.toggle-advanced.open svg {
transform: rotate(180deg);
}
.checkbox-row {
display: flex;
align-items: center;
gap: 8px;
}
.checkbox {
width: 18px;
height: 18px;
accent-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
}
.actions {
display: flex;
justify-content: flex-end;
gap: 12px;
padding-top: 16px;
border-top: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
margin-top: 8px;
}
.button {
padding: 10px 20px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 200ms ease;
display: inline-flex;
align-items: center;
gap: 8px;
}
.button.secondary {
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
}
.button.secondary:hover {
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
}
.button.primary {
background: ${cssManager.bdTheme('#18181b', '#fafafa')};
border: none;
color: ${cssManager.bdTheme('#fafafa', '#18181b')};
}
.button.primary:hover:not(:disabled) {
opacity: 0.9;
}
.button.primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
`,
];
public render(): TemplateResult {
return html`
<div class="header">
<div>
<div class="header-title">Deploy New Service</div>
<div class="header-subtitle">Configure and deploy a new Docker container</div>
</div>
</div>
<!-- Basic Info Section -->
<div class="section">
<div class="section-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="9" y1="9" x2="15" y2="9"></line>
<line x1="9" y1="15" x2="15" y2="15"></line>
</svg>
Basic Information
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Service Name <span class="required">*</span></label>
<input
type="text"
class="form-input"
placeholder="my-service"
.value=${this.serviceName}
@input=${(e: Event) => this.serviceName = (e.target as HTMLInputElement).value}
>
<div class="form-hint">Unique name for the service (alphanumeric and hyphens)</div>
</div>
<div class="form-group">
<label class="form-label">Registry</label>
<select
class="form-select"
.value=${this.selectedRegistry}
@change=${(e: Event) => this.selectedRegistry = (e.target as HTMLSelectElement).value}
>
<option value="">Custom Image URL</option>
${this.registries.map(reg => html`
<option value=${reg.id}>${reg.name}</option>
`)}
</select>
</div>
</div>
<div class="form-row single">
<div class="form-group">
<label class="form-label">Image <span class="required">*</span></label>
<input
type="text"
class="form-input"
placeholder="nginx:latest or registry.example.com/image:tag"
.value=${this.imageUrl}
@input=${(e: Event) => this.imageUrl = (e.target as HTMLInputElement).value}
>
<div class="form-hint">Docker image to deploy (include tag)</div>
</div>
</div>
</div>
<!-- Port Configuration -->
<div class="section">
<div class="section-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="2" y1="12" x2="22" y2="12"></line>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
</svg>
Port Configuration
</div>
<div class="dynamic-list">
${this.ports.map((port, index) => html`
<div class="dynamic-row">
<input
type="text"
class="form-input"
placeholder="Host Port"
.value=${port.hostPort}
@input=${(e: Event) => this.updatePort(index, 'hostPort', (e.target as HTMLInputElement).value)}
>
<input
type="text"
class="form-input"
placeholder="Container Port"
.value=${port.containerPort}
@input=${(e: Event) => this.updatePort(index, 'containerPort', (e.target as HTMLInputElement).value)}
>
<select
class="form-select"
.value=${port.protocol}
@change=${(e: Event) => this.updatePort(index, 'protocol', (e.target as HTMLSelectElement).value)}
>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
${this.ports.length > 1 ? html`
<button class="remove-button" @click=${() => this.removePort(index)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
` : ''}
</div>
`)}
</div>
<button class="add-button" @click=${() => this.addPort()}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
Add Port Mapping
</button>
</div>
<!-- Environment Variables -->
<div class="section">
<div class="section-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="4 17 10 11 4 5"></polyline>
<line x1="12" y1="19" x2="20" y2="19"></line>
</svg>
Environment Variables
</div>
<div class="dynamic-list">
${this.envVars.map((env, index) => html`
<div class="dynamic-row">
<input
type="text"
class="form-input"
placeholder="KEY"
.value=${env.key}
@input=${(e: Event) => this.updateEnvVar(index, 'key', (e.target as HTMLInputElement).value)}
>
<input
type="text"
class="form-input"
placeholder="value"
.value=${env.value}
@input=${(e: Event) => this.updateEnvVar(index, 'value', (e.target as HTMLInputElement).value)}
>
${this.envVars.length > 1 ? html`
<button class="remove-button" @click=${() => this.removeEnvVar(index)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
` : ''}
</div>
`)}
</div>
<button class="add-button" @click=${() => this.addEnvVar()}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
Add Environment Variable
</button>
</div>
<!-- Advanced Options Toggle -->
<button
class="toggle-advanced ${this.showAdvanced ? 'open' : ''}"
@click=${() => this.showAdvanced = !this.showAdvanced}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
Advanced Options
</button>
${this.showAdvanced ? html`
<!-- Volumes -->
<div class="section">
<div class="section-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</svg>
Volume Mounts
</div>
<div class="dynamic-list">
${this.volumes.length === 0 ? html`
<div class="form-hint">No volumes configured</div>
` : this.volumes.map((vol, index) => html`
<div class="dynamic-row">
<input
type="text"
class="form-input"
placeholder="/host/path"
.value=${vol.hostPath}
@input=${(e: Event) => this.updateVolume(index, 'hostPath', (e.target as HTMLInputElement).value)}
>
<input
type="text"
class="form-input"
placeholder="/container/path"
.value=${vol.containerPath}
@input=${(e: Event) => this.updateVolume(index, 'containerPath', (e.target as HTMLInputElement).value)}
>
<div class="checkbox-row">
<input
type="checkbox"
class="checkbox"
?checked=${vol.readOnly}
@change=${(e: Event) => this.updateVolume(index, 'readOnly', (e.target as HTMLInputElement).checked)}
>
<span class="form-hint">RO</span>
</div>
<button class="remove-button" @click=${() => this.removeVolume(index)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
`)}
</div>
<button class="add-button" @click=${() => this.addVolume()}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
Add Volume Mount
</button>
</div>
<!-- Resource Limits -->
<div class="section">
<div class="section-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="1" y="4" width="22" height="16" rx="2" ry="2"></rect>
<line x1="1" y1="10" x2="23" y2="10"></line>
</svg>
Resource Limits
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">CPU Limit</label>
<input
type="text"
class="form-input"
placeholder="e.g., 1.0 or 0.5"
.value=${this.cpuLimit}
@input=${(e: Event) => this.cpuLimit = (e.target as HTMLInputElement).value}
>
<div class="form-hint">Number of CPUs (leave empty for unlimited)</div>
</div>
<div class="form-group">
<label class="form-label">Memory Limit</label>
<input
type="text"
class="form-input"
placeholder="e.g., 512m or 1g"
.value=${this.memoryLimit}
@input=${(e: Event) => this.memoryLimit = (e.target as HTMLInputElement).value}
>
<div class="form-hint">Memory limit (leave empty for unlimited)</div>
</div>
</div>
</div>
<!-- Restart Policy & Network -->
<div class="section">
<div class="section-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
Container Settings
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Restart Policy</label>
<select
class="form-select"
.value=${this.restartPolicy}
@change=${(e: Event) => this.restartPolicy = (e.target as HTMLSelectElement).value as any}
>
<option value="always">Always</option>
<option value="on-failure">On Failure</option>
<option value="never">Never</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Network Mode</label>
<select
class="form-select"
.value=${this.networkMode}
@change=${(e: Event) => this.networkMode = (e.target as HTMLSelectElement).value}
>
<option value="bridge">Bridge</option>
<option value="host">Host</option>
<option value="none">None</option>
</select>
</div>
</div>
</div>
` : ''}
<div class="actions">
<button class="button secondary" @click=${() => this.handleCancel()}>Cancel</button>
<button
class="button primary"
?disabled=${this.loading || !this.isValid()}
@click=${() => this.handleCreate()}
>
${this.loading ? html`<div class="spinner"></div>` : ''}
${this.loading ? 'Deploying...' : 'Deploy Service'}
</button>
</div>
`;
}
private isValid(): boolean {
return this.serviceName.trim() !== '' && this.imageUrl.trim() !== '';
}
private addPort() {
this.ports = [...this.ports, { hostPort: '', containerPort: '', protocol: 'tcp' }];
}
private removePort(index: number) {
this.ports = this.ports.filter((_, i) => i !== index);
}
private updatePort(index: number, field: keyof IPortMapping, value: string) {
const newPorts = [...this.ports];
(newPorts[index] as any)[field] = value;
this.ports = newPorts;
}
private addEnvVar() {
this.envVars = [...this.envVars, { key: '', value: '' }];
}
private removeEnvVar(index: number) {
this.envVars = this.envVars.filter((_, i) => i !== index);
}
private updateEnvVar(index: number, field: keyof IEnvVar, value: string) {
const newEnvVars = [...this.envVars];
newEnvVars[index][field] = value;
this.envVars = newEnvVars;
}
private addVolume() {
this.volumes = [...this.volumes, { hostPath: '', containerPath: '', readOnly: false }];
}
private removeVolume(index: number) {
this.volumes = this.volumes.filter((_, i) => i !== index);
}
private updateVolume(index: number, field: keyof IVolumeMount, value: string | boolean) {
const newVolumes = [...this.volumes];
(newVolumes[index] as any)[field] = value;
this.volumes = newVolumes;
}
private handleCancel() {
this.dispatchEvent(new CustomEvent('cancel', { bubbles: true, composed: true }));
}
private handleCreate() {
const config: IServiceConfig = {
name: this.serviceName.trim(),
image: this.imageUrl.trim(),
ports: this.ports.filter(p => p.hostPort && p.containerPort),
envVars: this.envVars.filter(e => e.key),
volumes: this.volumes.filter(v => v.hostPath && v.containerPath),
cpuLimit: this.cpuLimit,
memoryLimit: this.memoryLimit,
restartPolicy: this.restartPolicy,
networkMode: this.networkMode,
};
this.dispatchEvent(new CustomEvent('create-service', {
detail: config,
bubbles: true,
composed: true,
}));
}
public reset() {
this.serviceName = '';
this.imageUrl = '';
this.selectedRegistry = '';
this.ports = [{ hostPort: '', containerPort: '', protocol: 'tcp' }];
this.envVars = [{ key: '', value: '' }];
this.volumes = [];
this.cpuLimit = '';
this.memoryLimit = '';
this.restartPolicy = 'always';
this.networkMode = 'bridge';
this.showAdvanced = false;
}
}