diff --git a/docs/superpowers/specs/2026-04-09-provisioning-conflict-resolution.md b/docs/superpowers/specs/2026-04-09-provisioning-conflict-resolution.md new file mode 100644 index 0000000000..25251e7e3f --- /dev/null +++ b/docs/superpowers/specs/2026-04-09-provisioning-conflict-resolution.md @@ -0,0 +1,102 @@ +# Provisioning Conflict Resolution — Design Spec + +## Goal + +When an entity with the same name already exists during device package provisioning, show a conflict resolution UI instead of an error. The user chooses how to proceed (use existing, overwrite, or create copy) and provisioning continues. + +## Conflict Detection + +Before creating each entity, pre-check by name. If an entity with the same name exists, show the conflict UI instead of attempting creation. + +| Entity type | Pre-check | Conflict options | +|-------------|-----------|-----------------| +| DEVICE_PROFILE | `findDeviceProfileByName` | Use existing / Overwrite | +| RULE_CHAIN | `findRuleChainByName` | Use existing / Overwrite | +| DEVICE | `findDeviceByName` (new) | Use existing / Overwrite | +| GATEWAY | `findDeviceByName` (new, same API) | Use existing / Overwrite | +| DASHBOARD | `findDashboardByName` (new) | Overwrite / Create copy | +| GATEWAY_CONNECTOR | no pre-check | — | + +## New Status: `conflict` + +```typescript +export type EntityStepStatus = 'pending' | 'running' | 'success' | 'error' | 'conflict'; +``` + +`EntityStepProgress` gains: +```typescript +existingEntity?: EntityStepOutput; // the found entity, for resolution +conflictType?: 'use-or-overwrite' | 'overwrite-or-copy'; // which buttons to show +``` + +## Resolution Actions + +**Use existing**: take the existing entity's ID/name/url, store as output, continue to next step. No API call. + +**Overwrite**: fetch existing entity by ID, merge template data (keeping the ID), save. For rule chains, also save metadata. Store result as output. + +**Create copy**: create new entity with the template as-is (TB allows duplicate dashboard names). Store result as output. + +## UI + +Conflict row styling: +- Amber/yellow background (not red — it's a decision, not an error) +- Warning icon (amber `⚠`) +- Description: "Device profile with this name already exists" +- Two buttons on the right + +For `use-or-overwrite`: +``` +⚠ Device Profile — Modbus TH Sensor + Entity with this name already exists + [Use existing] [Overwrite] +``` + +For `overwrite-or-copy`: +``` +⚠ Dashboard — Modbus TH Monitor + Dashboard with this name already exists + [Overwrite] [Create copy] +``` + +Button styling: +- Use existing / Create copy: outlined primary (safe option) +- Overwrite: outlined warning/amber (destructive) + +## Provisioning Step Flow + +1. For each entity step: + a. Set status = 'running' + b. Pre-check: search for existing entity by name + c. If found: set status = 'conflict', store existing entity, pause (return from loop) + d. If not found: create entity → success → continue + e. If creation fails: set status = 'error' → pause (existing behavior) +2. When user clicks a conflict resolution button: + a. Execute resolution (use existing / overwrite / create copy) + b. Set status = 'success' + c. Resume `runEntitySteps` from the next step + +## Read-Only Review Dialog + +No changes needed — provisioning step shows all entities as success with "Done" label. No conflict UI in review mode. + +## Changes + +### Models (`device-package.models.ts`) +- Add `'conflict'` to `EntityStepStatus` +- Add `existingEntity?: EntityStepOutput` and `conflictType?: string` to `EntityStepProgress` + +### Dialog Component (`device-install-dialog.component.ts`) +- Add `findDeviceByName` and `findDashboardByName` methods +- Refactor `createEntity` to pre-check before creating +- Add `resolveConflict(ep, resolution)` method +- Resume loop after conflict resolution + +### Dialog Template (`device-install-dialog.component.html`) +- Add conflict row rendering with amber styling and resolution buttons + +### Dialog Styles (`device-install-dialog.component.scss`) +- Add `.tb-progress-conflict` amber styles + +### Translations +- Add conflict-related translation keys diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html index fb463c010e..5ed6430245 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html @@ -165,7 +165,7 @@ @case ('progress') {
@for (ep of ws.entitySteps; track ep.step.name) { -
+
@switch (ep.status) { @case ('pending') { @@ -180,6 +180,9 @@ @case ('error') { error } + @case ('conflict') { + warning + } }
@@ -187,7 +190,29 @@ @if (ep.status === 'error' && ep.errorMessage) { {{ ep.errorMessage }} } + @if (ep.status === 'conflict') { + {{ 'iot-hub.device-install-conflict-exists' | translate:{ type: ('iot-hub.device-install-step-type-' + ep.step.type | translate) } }} + }
+ @if (ep.status === 'conflict') { +
+ @if (ep.conflictType === 'use-or-overwrite') { + + + } @else { + + + } +
+ }
}
diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.scss index 9d6fd5fa9a..a8df1ddf79 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.scss @@ -219,7 +219,13 @@ display: flex; align-items: flex-start; gap: 12px; - padding: 8px 0; + padding: 8px 12px; + border-radius: 8px; + + &.tb-progress-conflict-row { + background: #fff8e1; + border: 1px solid #ffcc02; + } } .tb-progress-icon { @@ -247,6 +253,10 @@ .tb-progress-error { color: #c62828; } + + .tb-progress-conflict { + color: #f57f17; + } } .tb-progress-info { @@ -271,6 +281,18 @@ word-break: break-word; } +.tb-progress-conflict-msg { + font-size: 12px; + color: #f57f17; +} + +.tb-progress-conflict-actions { + display: flex; + gap: 8px; + margin-left: auto; + flex-shrink: 0; +} + mat-dialog-actions { padding: 8px 24px 16px; } diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts index 95b12a7581..0f45f0b626 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts @@ -267,6 +267,55 @@ export class TbDeviceInstallDialogComponent extends DialogComponent { + ep.status = 'running'; + ep.errorMessage = null; + this.cdr.detectChanges(); + try { + let output: EntityStepOutput; + if (resolution === 'use-existing') { + output = ep.existingEntity; + // For devices/gateways, fetch credentials + if (ep.step.type === InstallStepType.DEVICE || ep.step.type === InstallStepType.GATEWAY) { + const creds = await firstValueFrom(this.deviceService.getDeviceCredentials(output.id, false, {ignoreErrors: true})); + output = { ...output, token: creds.credentialsId }; + if (ep.step.type === InstallStepType.GATEWAY) { + output.dockerComposeUrl = `/api/device-connectivity/gateway-launch/${output.id}/docker-compose/download`; + } + } + } else if (resolution === 'overwrite') { + output = await this.overwriteEntity(ep.step, ep.existingEntity); + } else { + // create-copy — just create normally + output = await this.createEntity(ep.step); + } + await this.saveStepAttributes(ep.step, output); + ep.entityOutput = output; + ep.existingEntity = null; + ep.conflictType = null; + ep.status = 'success'; + + const alias = stepTypeAliasMap[ep.step.type]; + if (alias) { + this.entityOutputs.set(alias, output); + } + for (const remaining of ws.entitySteps) { + if (remaining.status === 'pending') { + remaining.resolvedName = this.resolveVariables(remaining.step.name); + } + } + this.cdr.detectChanges(); + + // Resume running remaining steps + await this.runEntitySteps(ws); + } catch (err: any) { + ep.status = 'error'; + ep.errorMessage = err?.error?.message || err?.message || 'Unknown error'; + ws.progressError = ep.errorMessage; + this.cdr.detectChanges(); + } + } + goBackToForm(): void { // Find the last form step before the current progress step const currentIdx = this.stepper.selectedIndex; @@ -547,6 +596,15 @@ export class TbDeviceInstallDialogComponent extends DialogComponent { + try { + const device = await firstValueFrom(this.deviceService.findByName(name, {ignoreErrors: true})); + if (device) { + return { id: device.id.id, name: device.name, url: `/entities/devices/${device.id.id}` }; + } + } catch (_e) { + // 404 = not found + } + return null; + } + + private async findDashboardByTitle(title: string): Promise { + const page = await firstValueFrom(this.dashboardService.getTenantDashboards(new PageLink(10, 0, title), {ignoreErrors: true})); + const match = page.data.find(d => d.title === title); + return match ? { id: match.id.id, name: match.title, url: `/dashboards/${match.id.id}` } : null; + } + + private async overwriteEntity(step: DeviceInstallStep, existing: EntityStepOutput): Promise { + const raw = this.zipFiles.get(step.template); + if (!raw) throw new Error(`Template file not found: ${step.template}`); + const resolved = this.resolveVariables(raw); + const template = JSON.parse(resolved); + + switch (step.type) { + case InstallStepType.DEVICE_PROFILE: { + template.id = { id: existing.id, entityType: 'DEVICE_PROFILE' }; + const result = await firstValueFrom(this.deviceProfileService.saveDeviceProfile(template, {ignoreErrors: true})); + return { id: result.id.id, name: result.name, url: `/profiles/deviceProfiles/${result.id.id}` }; + } + case InstallStepType.DEVICE: { + template.id = { id: existing.id, entityType: 'DEVICE' }; + const result = await firstValueFrom(this.deviceService.saveDevice(template, {ignoreErrors: true})); + const creds = await this.resolveCredentials(step, result.id.id); + return { id: result.id.id, name: result.name, url: `/entities/devices/${result.id.id}`, token: creds.credentialsId }; + } + case InstallStepType.GATEWAY: { + template.id = { id: existing.id, entityType: 'DEVICE' }; + const result = await firstValueFrom(this.deviceService.saveDevice(template, {ignoreErrors: true})); + const creds = await this.resolveCredentials(step, result.id.id); + const output: EntityStepOutput = { + id: result.id.id, + name: result.name, + url: '/entities/gateways', + token: creds.credentialsId, + dockerComposeUrl: `/api/device-connectivity/gateway-launch/${result.id.id}/docker-compose/download` + }; + this.entityOutputs.set('gateway', output); + if (step.dockerCompose) { + const dcRaw = this.zipFiles.get(step.dockerCompose); + if (dcRaw) this.gatewayDockerComposeContent = this.resolveVariables(dcRaw); + } + return output; + } + case InstallStepType.DASHBOARD: { + template.id = { id: existing.id, entityType: 'DASHBOARD' }; + const result = await firstValueFrom(this.dashboardService.saveDashboard(template, {ignoreErrors: true})); + return { id: result.id.id, name: result.title, url: `/dashboards/${result.id.id}` }; + } + case InstallStepType.RULE_CHAIN: { + const ruleChain = template.ruleChain || template; + const metadata = template.metadata; + ruleChain.id = { id: existing.id, entityType: 'RULE_CHAIN' }; + const saved = await firstValueFrom(this.ruleChainService.saveRuleChain(ruleChain, {ignoreErrors: true})); + if (metadata) { + metadata.ruleChainId = saved.id; + await firstValueFrom(this.ruleChainService.saveRuleChainMetadata(metadata, {ignoreErrors: true})); + } + return { id: saved.id.id, name: saved.name, url: `/ruleChains/${saved.id.id}` }; + } + default: + throw new Error(`Unsupported overwrite for step type: ${step.type}`); + } + } + + private async preCheckEntity(step: DeviceInstallStep): Promise { + const raw = this.zipFiles.get(step.template); + if (!raw) return null; + const resolved = this.resolveVariables(raw); + const template = JSON.parse(resolved); + + switch (step.type) { + case InstallStepType.DEVICE_PROFILE: + return this.findDeviceProfileByName(template.name); + case InstallStepType.RULE_CHAIN: { + const name = template.ruleChain?.name || template.name; + return this.findRuleChainByName(name); + } + case InstallStepType.DEVICE: + case InstallStepType.GATEWAY: + return this.findDeviceByName(template.name); + case InstallStepType.DASHBOARD: + return this.findDashboardByTitle(template.title); + default: + return null; + } + } + + private getConflictType(stepType: InstallStepType): 'use-or-overwrite' | 'overwrite-or-copy' { + return stepType === InstallStepType.DASHBOARD ? 'overwrite-or-copy' : 'use-or-overwrite'; + } + private buildInstallState(): Record { const state: Record = {}; for (const ws of this.wizardSteps) { diff --git a/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts b/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts index 37ce85da3b..9141c222e8 100644 --- a/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts +++ b/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts @@ -135,7 +135,9 @@ export interface EntityStepOutput { dockerComposeUrl?: string; } -export type EntityStepStatus = 'pending' | 'running' | 'success' | 'error'; +export type EntityStepStatus = 'pending' | 'running' | 'success' | 'error' | 'conflict'; + +export type ConflictType = 'use-or-overwrite' | 'overwrite-or-copy'; export interface EntityStepProgress { step: DeviceInstallStep; @@ -143,4 +145,6 @@ export interface EntityStepProgress { resolvedName?: string; entityOutput?: EntityStepOutput; errorMessage?: string; + existingEntity?: EntityStepOutput; + conflictType?: ConflictType; } diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 2b341eb7f0..0291022066 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -3895,6 +3895,10 @@ "device-install-step-type-GATEWAY": "Gateway", "device-install-step-type-GATEWAY_CONNECTOR": "Gateway Connector", "device-install-step-type-RULE_CHAIN": "Rule Chain", + "device-install-conflict-exists": "{{type}} with this name already exists", + "device-install-use-existing": "Use existing", + "device-install-overwrite": "Overwrite", + "device-install-create-copy": "Create copy", "device-install-gateway-docker-compose": "Download docker-compose.yml for your gateway", "device-install-title": "Install \"{{name}}\"", "device-install-complete-title": "Install \"{{name}}\" — Complete",