Browse Source

feat(iot-hub): add conflict resolution for entity provisioning steps

When an entity with the same name already exists, show amber conflict
UI with resolution options instead of a red error:

- Device Profile / Rule Chain / Device / Gateway:
  "Use existing" (reuse as-is) or "Overwrite" (update with template)
- Dashboard: "Overwrite" or "Create copy" (duplicate names allowed)

Pre-checks by name before each entity creation. If conflict found,
pauses provisioning and shows resolution buttons. After user chooses,
resumes with remaining steps.

New status: 'conflict' with amber styling, warning icon, and action
buttons on the progress row.
pull/15508/head
Andrii Shvaika 6 months ago
parent
commit
17c05fdc63
  1. 102
      docs/superpowers/specs/2026-04-09-provisioning-conflict-resolution.md
  2. 27
      ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html
  3. 24
      ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.scss
  4. 160
      ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts
  5. 6
      ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts
  6. 4
      ui-ngx/src/assets/locale/locale.constant-en_US.json

102
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

27
ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html

@ -165,7 +165,7 @@
@case ('progress') {
<div class="tb-device-install-progress">
@for (ep of ws.entitySteps; track ep.step.name) {
<div class="tb-progress-row">
<div class="tb-progress-row" [class.tb-progress-conflict-row]="ep.status === 'conflict'">
<div class="tb-progress-icon">
@switch (ep.status) {
@case ('pending') {
@ -180,6 +180,9 @@
@case ('error') {
<mat-icon class="tb-progress-error">error</mat-icon>
}
@case ('conflict') {
<mat-icon class="tb-progress-conflict">warning</mat-icon>
}
}
</div>
<div class="tb-progress-info">
@ -187,7 +190,29 @@
@if (ep.status === 'error' && ep.errorMessage) {
<span class="tb-progress-error-msg">{{ ep.errorMessage }}</span>
}
@if (ep.status === 'conflict') {
<span class="tb-progress-conflict-msg">{{ 'iot-hub.device-install-conflict-exists' | translate:{ type: ('iot-hub.device-install-step-type-' + ep.step.type | translate) } }}</span>
}
</div>
@if (ep.status === 'conflict') {
<div class="tb-progress-conflict-actions">
@if (ep.conflictType === 'use-or-overwrite') {
<button mat-stroked-button color="primary" (click)="resolveConflict(ws, ep, 'use-existing')">
{{ 'iot-hub.device-install-use-existing' | translate }}
</button>
<button mat-stroked-button color="warn" (click)="resolveConflict(ws, ep, 'overwrite')">
{{ 'iot-hub.device-install-overwrite' | translate }}
</button>
} @else {
<button mat-stroked-button color="warn" (click)="resolveConflict(ws, ep, 'overwrite')">
{{ 'iot-hub.device-install-overwrite' | translate }}
</button>
<button mat-stroked-button color="primary" (click)="resolveConflict(ws, ep, 'create-copy')">
{{ 'iot-hub.device-install-create-copy' | translate }}
</button>
}
</div>
}
</div>
}
</div>

24
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;
}

160
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<TbDeviceInst
this.runEntitySteps(step);
}
async resolveConflict(ws: WizardStep, ep: EntityStepProgress, resolution: string): Promise<void> {
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<TbDeviceInst
ep.errorMessage = null;
this.cdr.detectChanges();
try {
// Pre-check for existing entity
const existing = await this.preCheckEntity(ep.step);
if (existing) {
ep.status = 'conflict';
ep.existingEntity = existing;
ep.conflictType = this.getConflictType(ep.step.type);
this.cdr.detectChanges();
return; // Pause — user must choose a resolution
}
const startTime = Date.now();
const output = await this.createEntity(ep.step);
// Save optional attributes after entity creation
@ -744,6 +802,108 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
return match ? { id: match.id.id, name: match.name, url: `/ruleChains/${match.id.id}` } : null;
}
private async findDeviceByName(name: string): Promise<EntityStepOutput | null> {
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<EntityStepOutput | null> {
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<EntityStepOutput> {
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<EntityStepOutput | null> {
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<string, any> {
const state: Record<string, any> = {};
for (const ws of this.wizardSteps) {

6
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;
}

4
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",

Loading…
Cancel
Save