From 01d877f7ed07f78913f3c934acc1b90ca92a822b Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Wed, 10 Sep 2025 08:24:55 +0000 Subject: [PATCH] feat(external-registry): Implement CRUD operations and connection verification for external registries --- ts/classes.cloudly.ts | 2 + .../classes.externalregistry.ts | 155 ++++++- .../classes.externalregistrymanager.ts | 89 +++- ts_interfaces/data/externalregistry.ts | 98 +++++ ts_interfaces/requests/externalregistry.ts | 17 + ts_web/appstate.ts | 104 +++++ .../cloudly-view-externalregistries.ts | 385 ++++++++++++++++-- 7 files changed, 805 insertions(+), 45 deletions(-) diff --git a/ts/classes.cloudly.ts b/ts/classes.cloudly.ts index 299bf02..9ab2718 100644 --- a/ts/classes.cloudly.ts +++ b/ts/classes.cloudly.ts @@ -137,6 +137,7 @@ export class Cloudly { // start the managers this.imageManager.start(); + this.externalRegistryManager.start(); } /** @@ -149,5 +150,6 @@ export class Cloudly { await this.secretManager.stop(); await this.serviceManager.stop(); await this.deploymentManager.stop(); + await this.externalRegistryManager.stop(); } } diff --git a/ts/manager.externalregistry/classes.externalregistry.ts b/ts/manager.externalregistry/classes.externalregistry.ts index 0b17e02..52c4f8c 100644 --- a/ts/manager.externalregistry/classes.externalregistry.ts +++ b/ts/manager.externalregistry/classes.externalregistry.ts @@ -17,14 +17,92 @@ export class ExternalRegistry extends plugins.smartdata.SmartDataDbDoc) { const externalRegistry = new ExternalRegistry(); externalRegistry.id = await ExternalRegistry.getNewId(); - Object.assign(externalRegistry, registryDataArg); + externalRegistry.data = { + type: registryDataArg.type || 'docker', + name: registryDataArg.name || '', + url: registryDataArg.url || '', + username: registryDataArg.username || '', + password: registryDataArg.password || '', + description: registryDataArg.description, + isDefault: registryDataArg.isDefault || false, + authType: registryDataArg.authType || 'basic', + insecure: registryDataArg.insecure || false, + namespace: registryDataArg.namespace, + proxy: registryDataArg.proxy, + config: registryDataArg.config, + status: 'unverified', + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + // If this is set as default, unset other defaults of the same type + if (externalRegistry.data.isDefault) { + const existingDefaults = await ExternalRegistry.getInstances({ + 'data.type': externalRegistry.data.type, + 'data.isDefault': true, + }); + for (const existingDefault of existingDefaults) { + existingDefault.data.isDefault = false; + await existingDefault.save(); + } + } + await externalRegistry.save(); return externalRegistry; } + public static async updateExternalRegistry( + registryIdArg: string, + registryDataArg: Partial + ) { + const externalRegistry = await this.getRegistryById(registryIdArg); + if (!externalRegistry) { + throw new Error(`Registry with id ${registryIdArg} not found`); + } + + // If setting as default, unset other defaults of the same type + if (registryDataArg.isDefault && !externalRegistry.data.isDefault) { + const existingDefaults = await ExternalRegistry.getInstances({ + 'data.type': externalRegistry.data.type, + 'data.isDefault': true, + }); + for (const existingDefault of existingDefaults) { + if (existingDefault.id !== registryIdArg) { + existingDefault.data.isDefault = false; + await existingDefault.save(); + } + } + } + + // Update fields + Object.assign(externalRegistry.data, registryDataArg, { + updatedAt: Date.now(), + }); + + await externalRegistry.save(); + return externalRegistry; + } + + public static async deleteExternalRegistry(registryIdArg: string) { + const externalRegistry = await this.getRegistryById(registryIdArg); + if (!externalRegistry) { + return false; + } + await externalRegistry.delete(); + return true; + } + // INSTANCE @plugins.smartdata.svDb() @@ -37,4 +115,79 @@ export class ExternalRegistry extends plugins.smartdata.SmartDataDbDoc { + try { + // For Docker registries, try to access the v2 API + if (this.data.type === 'docker') { + const registryUrl = this.data.url.replace(/\/$/, ''); // Remove trailing slash + const authHeader = 'Basic ' + Buffer.from(`${this.data.username}:${this.data.password}`).toString('base64'); + + // Try to access the Docker Registry v2 API + const response = await fetch(`${registryUrl}/v2/`, { + headers: { + 'Authorization': authHeader, + }, + // Allow insecure if configured + ...(this.data.insecure ? { rejectUnauthorized: false } : {}), + }).catch(err => { + throw new Error(`Failed to connect: ${err.message}`); + }); + + if (response.status === 200 || response.status === 401) { + // 200 means successful auth, 401 means registry exists but needs auth + this.data.status = 'active'; + this.data.lastVerified = Date.now(); + this.data.lastError = undefined; + await this.save(); + return { success: true, message: 'Registry connection successful' }; + } else { + throw new Error(`Registry returned status ${response.status}`); + } + } + + // For npm registries, implement npm-specific verification + if (this.data.type === 'npm') { + // TODO: Implement npm registry verification + this.data.status = 'unverified'; + return { success: false, message: 'NPM registry verification not yet implemented' }; + } + + return { success: false, message: 'Unknown registry type' }; + } catch (error) { + this.data.status = 'error'; + this.data.lastError = error.message; + await this.save(); + return { success: false, message: error.message }; + } + } + + /** + * Get the full registry URL with namespace if applicable + */ + public getFullRegistryUrl(): string { + let url = this.data.url.replace(/\/$/, ''); // Remove trailing slash + if (this.data.namespace) { + url = `${url}/${this.data.namespace}`; + } + return url; + } + + /** + * Get Docker auth config for this registry + */ + public getDockerAuthConfig() { + if (this.data.type !== 'docker') { + return null; + } + + return { + username: this.data.username, + password: this.data.password, + email: this.data.config?.dockerConfig?.email, + serveraddress: this.data.config?.dockerConfig?.serverAddress || this.data.url, + }; + } } diff --git a/ts/manager.externalregistry/classes.externalregistrymanager.ts b/ts/manager.externalregistry/classes.externalregistrymanager.ts index 9c60244..0f34cef 100644 --- a/ts/manager.externalregistry/classes.externalregistrymanager.ts +++ b/ts/manager.externalregistry/classes.externalregistrymanager.ts @@ -13,23 +13,34 @@ export class ExternalRegistryManager { constructor(cloudlyRef: Cloudly) { this.cloudlyRef = cloudlyRef; - } - - public async start() { - // lets set up a typedrouter - this.typedrouter.addTypedRouter(this.typedrouter); + + // Add typedrouter to cloudly's main router + this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter); + // Get registry by ID this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler('getExternalRegistryById', async (dataArg) => { + await plugins.smartguard.passGuardsOrReject(dataArg, [ + this.cloudlyRef.authManager.validIdentityGuard, + ]); + const registry = await ExternalRegistry.getRegistryById(dataArg.id); + if (!registry) { + throw new Error(`Registry with id ${dataArg.id} not found`); + } return { registry: await registry.createSavableObject(), }; }) ); + // Get all registries this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler('getExternalRegistries', async (dataArg) => { + await plugins.smartguard.passGuardsOrReject(dataArg, [ + this.cloudlyRef.authManager.validIdentityGuard, + ]); + const registries = await ExternalRegistry.getRegistries(); return { registries: await Promise.all( @@ -39,13 +50,81 @@ export class ExternalRegistryManager { }) ); + // Create registry this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler('createExternalRegistry', async (dataArg) => { + await plugins.smartguard.passGuardsOrReject(dataArg, [ + this.cloudlyRef.authManager.validIdentityGuard, + ]); + const registry = await ExternalRegistry.createExternalRegistry(dataArg.registryData); return { registry: await registry.createSavableObject(), }; }) ); + + // Update registry + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler('updateExternalRegistry', async (dataArg) => { + await plugins.smartguard.passGuardsOrReject(dataArg, [ + this.cloudlyRef.authManager.validIdentityGuard, + ]); + + const registry = await ExternalRegistry.updateExternalRegistry( + dataArg.registryId, + dataArg.registryData + ); + return { + resultRegistry: await registry.createSavableObject(), + }; + }) + ); + + // Delete registry + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler('deleteExternalRegistryById', async (dataArg) => { + await plugins.smartguard.passGuardsOrReject(dataArg, [ + this.cloudlyRef.authManager.validIdentityGuard, + ]); + + const success = await ExternalRegistry.deleteExternalRegistry(dataArg.registryId); + return { + ok: success, + }; + }) + ); + + // Verify registry connection + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler('verifyExternalRegistry', async (dataArg) => { + await plugins.smartguard.passGuardsOrReject(dataArg, [ + this.cloudlyRef.authManager.validIdentityGuard, + ]); + + const registry = await ExternalRegistry.getRegistryById(dataArg.registryId); + if (!registry) { + return { + success: false, + message: `Registry with id ${dataArg.registryId} not found`, + }; + } + + const result = await registry.verifyConnection(); + return { + success: result.success, + message: result.message, + registry: await registry.createSavableObject(), + }; + }) + ); + } + + public async start() { + console.log('External Registry Manager started'); + } + + public async stop() { + console.log('External Registry Manager stopped'); } } diff --git a/ts_interfaces/data/externalregistry.ts b/ts_interfaces/data/externalregistry.ts index c7d0542..84eac68 100644 --- a/ts_interfaces/data/externalregistry.ts +++ b/ts_interfaces/data/externalregistry.ts @@ -3,10 +3,108 @@ import * as plugins from '../plugins.js'; export interface IExternalRegistry { id: string; data: { + /** + * Registry type + */ type: 'docker' | 'npm'; + + /** + * Human-readable name for the registry + */ name: string; + + /** + * Registry URL (e.g., https://registry.gitlab.com, docker.io) + */ url: string; + + /** + * Username for authentication + */ username: string; + + /** + * Password or access token for authentication + */ password: string; + + /** + * Optional description + */ + description?: string; + + /** + * Whether this is the default registry for its type + */ + isDefault?: boolean; + + /** + * Authentication type + */ + authType?: 'basic' | 'token' | 'oauth2'; + + /** + * Allow insecure registry connections (HTTP or self-signed certs) + */ + insecure?: boolean; + + /** + * Optional namespace/organization for the registry + */ + namespace?: string; + + /** + * Proxy configuration + */ + proxy?: { + http?: string; + https?: string; + noProxy?: string; + }; + + /** + * Registry-specific configuration + */ + config?: { + /** + * For Docker registries + */ + dockerConfig?: { + email?: string; + serverAddress?: string; + }; + /** + * For npm registries + */ + npmConfig?: { + scope?: string; + alwaysAuth?: boolean; + }; + }; + + /** + * Status of the registry connection + */ + status?: 'active' | 'inactive' | 'error' | 'unverified'; + + /** + * Last error message if status is 'error' + */ + lastError?: string; + + /** + * Timestamp when the registry was last successfully verified + */ + lastVerified?: number; + + /** + * Timestamp when the registry was created + */ + createdAt?: number; + + /** + * Timestamp when the registry was last updated + */ + updatedAt?: number; }; } \ No newline at end of file diff --git a/ts_interfaces/requests/externalregistry.ts b/ts_interfaces/requests/externalregistry.ts index e8c9648..12bb49f 100644 --- a/ts_interfaces/requests/externalregistry.ts +++ b/ts_interfaces/requests/externalregistry.ts @@ -50,6 +50,7 @@ export interface IReq_UpdateRegistry extends plugins.typedrequestInterfaces.impl method: 'updateExternalRegistry'; request: { identity: userInterfaces.IIdentity; + registryId: string; registryData: data.IExternalRegistry['data']; }; response: { @@ -69,4 +70,20 @@ export interface IReq_DeleteRegistryById extends plugins.typedrequestInterfaces. response: { ok: boolean; }; +} + +export interface IReq_VerifyRegistry extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_VerifyRegistry +> { + method: 'verifyExternalRegistry'; + request: { + identity: userInterfaces.IIdentity; + registryId: string; + }; + response: { + success: boolean; + message?: string; + registry?: data.IExternalRegistry; + }; } \ No newline at end of file diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index 749fc27..3d216b7 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -47,6 +47,7 @@ export interface IDataState { secretGroups?: plugins.interfaces.data.ISecretGroup[]; secretBundles?: plugins.interfaces.data.ISecretBundle[]; clusters?: plugins.interfaces.data.ICluster[]; + externalRegistries?: plugins.interfaces.data.IExternalRegistry[]; images?: any[]; services?: plugins.interfaces.data.IService[]; deployments?: plugins.interfaces.data.IDeployment[]; @@ -64,6 +65,7 @@ export const dataState = await appstate.getStatePart( secretGroups: [], secretBundles: [], clusters: [], + externalRegistries: [], images: [], services: [], deployments: [], @@ -138,6 +140,28 @@ export const getAllDataAction = dataState.createAction(async (statePartArg) => { clusters: responseClusters.clusters, } + // External Registries + const trGetExternalRegistries = + new domtools.plugins.typedrequest.TypedRequest( + '/typedrequest', + 'getExternalRegistries' + ); + try { + const responseExternalRegistries = await trGetExternalRegistries.fire({ + identity: loginStatePart.getState().identity, + }); + currentState = { + ...currentState, + externalRegistries: responseExternalRegistries?.registries || [], + }; + } catch (error) { + console.error('Failed to fetch external registries:', error); + currentState = { + ...currentState, + externalRegistries: [], + }; + } + // Services const trGetServices = new domtools.plugins.typedrequest.TypedRequest( @@ -559,6 +583,86 @@ export const verifyDomainAction = dataState.createAction( } ); +// External Registry Actions +export const createExternalRegistryAction = dataState.createAction( + async (statePartArg, payloadArg: { registryData: plugins.interfaces.data.IExternalRegistry['data'] }) => { + let currentState = statePartArg.getState(); + const trCreateRegistry = + new domtools.plugins.typedrequest.TypedRequest( + '/typedrequest', + 'createExternalRegistry' + ); + const response = await trCreateRegistry.fire({ + identity: loginStatePart.getState().identity, + registryData: payloadArg.registryData, + }); + currentState = await dataState.dispatchAction(getAllDataAction, null); + return currentState; + } +); + +export const updateExternalRegistryAction = dataState.createAction( + async (statePartArg, payloadArg: { registryId: string; registryData: plugins.interfaces.data.IExternalRegistry['data'] }) => { + let currentState = statePartArg.getState(); + const trUpdateRegistry = + new domtools.plugins.typedrequest.TypedRequest( + '/typedrequest', + 'updateExternalRegistry' + ); + const response = await trUpdateRegistry.fire({ + identity: loginStatePart.getState().identity, + registryId: payloadArg.registryId, + registryData: payloadArg.registryData, + }); + currentState = await dataState.dispatchAction(getAllDataAction, null); + return currentState; + } +); + +export const deleteExternalRegistryAction = dataState.createAction( + async (statePartArg, payloadArg: { registryId: string }) => { + let currentState = statePartArg.getState(); + const trDeleteRegistry = + new domtools.plugins.typedrequest.TypedRequest( + '/typedrequest', + 'deleteExternalRegistryById' + ); + const response = await trDeleteRegistry.fire({ + identity: loginStatePart.getState().identity, + registryId: payloadArg.registryId, + }); + currentState = await dataState.dispatchAction(getAllDataAction, null); + return currentState; + } +); + +export const verifyExternalRegistryAction = dataState.createAction( + async (statePartArg, payloadArg: { registryId: string }) => { + let currentState = statePartArg.getState(); + const trVerifyRegistry = + new domtools.plugins.typedrequest.TypedRequest( + '/typedrequest', + 'verifyExternalRegistry' + ); + const response = await trVerifyRegistry.fire({ + identity: loginStatePart.getState().identity, + registryId: payloadArg.registryId, + }); + + if (response.success && response.registry) { + // Update the registry in the state with the verified status + currentState = { + ...currentState, + externalRegistries: currentState.externalRegistries?.map(reg => + reg.id === payloadArg.registryId ? response.registry : reg + ) || [], + }; + } + + return currentState; + } +); + // cluster export const addClusterAction = dataState.createAction( async ( diff --git a/ts_web/elements/cloudly-view-externalregistries.ts b/ts_web/elements/cloudly-view-externalregistries.ts index 53299c6..aced428 100644 --- a/ts_web/elements/cloudly-view-externalregistries.ts +++ b/ts_web/elements/cloudly-view-externalregistries.ts @@ -18,22 +18,63 @@ export class CloudlyViewExternalRegistries extends DeesElement { private data: appstate.IDataState = { secretGroups: [], secretBundles: [], + externalRegistries: [], }; constructor() { super(); - const subecription = appstate.dataState + const subscription = appstate.dataState .select((stateArg) => stateArg) .subscribe((dataArg) => { this.data = dataArg; }); - this.rxSubscriptions.push(subecription); + this.rxSubscriptions.push(subscription); + } + + async connectedCallback() { + super.connectedCallback(); + // Load external registries + await appstate.dataState.dispatchAction(appstate.getAllDataAction, {}); } public static styles = [ cssManager.defaultStyles, shared.viewHostCss, - css` + css` + .status-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.85em; + font-weight: 500; + color: white; + } + .status-active { background: #4CAF50; } + .status-inactive { background: #9E9E9E; } + .status-error { background: #f44336; } + .status-unverified { background: #FF9800; } + + .type-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.85em; + font-weight: 500; + color: white; + } + .type-docker { background: #2196F3; } + .type-npm { background: #CB3837; } + + .default-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.85em; + font-weight: 500; + background: #673AB7; + color: white; + margin-left: 8px; + } `, ]; @@ -42,43 +83,125 @@ export class CloudlyViewExternalRegistries extends DeesElement { External Registries { + .heading2=${'Configure external Docker and NPM registries'} + .data=${this.data.externalRegistries || []} + .displayFunction=${(registry: plugins.interfaces.data.IExternalRegistry) => { return { - id: itemArg.id, - serverAmount: itemArg.data.servers.length, + Name: html`${registry.data.name}${registry.data.isDefault ? html`DEFAULT` : ''}`, + Type: html`${registry.data.type.toUpperCase()}`, + URL: registry.data.url, + Username: registry.data.username, + Namespace: registry.data.namespace || '-', + Status: html`${(registry.data.status || 'unverified').toUpperCase()}`, + 'Last Verified': registry.data.lastVerified ? new Date(registry.data.lastVerified).toLocaleString() : 'Never', }; }} .dataActions=${[ { - name: 'add configBundle', + name: 'Add Registry', iconName: 'plus', type: ['header', 'footer'], actionFunc: async (dataActionArg) => { const modal = await plugins.deesCatalog.DeesModal.createAndShow({ - heading: 'Add ConfigBundle', + heading: 'Add External Registry', content: html` - - - + + + + + + + + + + + + + + + + + + + + `, menuOptions: [ - { name: 'create', action: async (modalArg) => {} }, { - name: 'cancel', + name: 'Create Registry', action: async (modalArg) => { - modalArg.destroy(); + const form = modalArg.shadowRoot.querySelector('dees-form') as any; + const formData = await form.gatherData(); + + await appstate.dataState.dispatchAction(appstate.createExternalRegistryAction, { + registryData: { + type: formData.type, + name: formData.name, + url: formData.url, + username: formData.username, + password: formData.password, + namespace: formData.namespace || undefined, + description: formData.description || undefined, + authType: formData.authType, + isDefault: formData.isDefault, + insecure: formData.insecure, + }, + }); + + await modalArg.destroy(); + }, + }, + { + name: 'Cancel', + action: async (modalArg) => { + await modalArg.destroy(); }, }, ], @@ -86,34 +209,218 @@ export class CloudlyViewExternalRegistries extends DeesElement { }, }, { - name: 'delete', - iconName: 'trash', + name: 'Edit', + iconName: 'edit', type: ['contextmenu', 'inRow'], actionFunc: async (actionDataArg) => { - plugins.deesCatalog.DeesModal.createAndShow({ - heading: `Delete ConfigBundle ${actionDataArg.item.id}`, + const registry = actionDataArg.item as plugins.interfaces.data.IExternalRegistry; + const modal = await plugins.deesCatalog.DeesModal.createAndShow({ + heading: `Edit Registry: ${registry.data.name}`, content: html` -
- Do you really want to delete the ConfigBundle? + + + + + + + + + + + + + + + + + + + + + + + `, + menuOptions: [ + { + name: 'Update Registry', + action: async (modalArg) => { + const form = modalArg.shadowRoot.querySelector('dees-form') as any; + const formData = await form.gatherData(); + + const updateData: any = { + type: formData.type, + name: formData.name, + url: formData.url, + username: formData.username, + namespace: formData.namespace || undefined, + description: formData.description || undefined, + authType: formData.authType, + isDefault: formData.isDefault, + insecure: formData.insecure, + }; + + // Only include password if it was changed + if (formData.password) { + updateData.password = formData.password; + } else { + updateData.password = registry.data.password; + } + + await appstate.dataState.dispatchAction(appstate.updateExternalRegistryAction, { + registryId: registry.id, + registryData: updateData, + }); + + await modalArg.destroy(); + }, + }, + { + name: 'Cancel', + action: async (modalArg) => { + await modalArg.destroy(); + }, + }, + ], + }); + }, + }, + { + name: 'Test Connection', + iconName: 'check-circle', + type: ['contextmenu'], + actionFunc: async (actionDataArg) => { + const registry = actionDataArg.item as plugins.interfaces.data.IExternalRegistry; + + // Show loading modal + const loadingModal = await plugins.deesCatalog.DeesModal.createAndShow({ + heading: 'Testing Registry Connection', + content: html` +
+ +

Testing connection to ${registry.data.name}...

-
- ${actionDataArg.item.id} + `, + menuOptions: [], + }); + + // Test the connection + await appstate.dataState.dispatchAction(appstate.verifyExternalRegistryAction, { + registryId: registry.id, + }); + + // Close loading modal + await loadingModal.destroy(); + + // Get updated registry + const updatedRegistry = this.data.externalRegistries?.find(r => r.id === registry.id); + + // Show result modal + const resultModal = await plugins.deesCatalog.DeesModal.createAndShow({ + heading: 'Connection Test Result', + content: html` +
+ ${updatedRegistry?.data.status === 'active' ? html` +
+

Connection successful!

+ ` : html` +
+

Connection failed!

+ ${updatedRegistry?.data.lastError ? html` +

+ Error: ${updatedRegistry.data.lastError} +

+ ` : ''} + `}
`, menuOptions: [ { - name: 'cancel', + name: 'OK', + action: async (modalArg) => { + await modalArg.destroy(); + }, + }, + ], + }); + }, + }, + { + name: 'Delete', + iconName: 'trash', + type: ['contextmenu'], + actionFunc: async (actionDataArg) => { + const registry = actionDataArg.item as plugins.interfaces.data.IExternalRegistry; + plugins.deesCatalog.DeesModal.createAndShow({ + heading: `Delete Registry: ${registry.data.name}`, + content: html` +
+

Do you really want to delete this external registry?

+

+ This will remove all stored credentials and configuration. +

+
+
+ ${registry.data.name} (${registry.data.url}) +
+ `, + menuOptions: [ + { + name: 'Cancel', action: async (modalArg) => { await modalArg.destroy(); }, }, { - name: 'delete', + name: 'Delete', action: async (modalArg) => { - appstate.dataState.dispatchAction(appstate.deleteSecretBundleAction, { - configBundleId: actionDataArg.item.id, + await appstate.dataState.dispatchAction(appstate.deleteExternalRegistryAction, { + registryId: registry.id, }); await modalArg.destroy(); }, @@ -126,4 +433,4 @@ export class CloudlyViewExternalRegistries extends DeesElement { > `; } -} +} \ No newline at end of file