audit notes work completed, test fixes work (95% done), new sprints, new data sources setup and configuration
This commit is contained in:
@@ -521,6 +521,19 @@ export const routes: Routes = [
|
||||
loadChildren: () =>
|
||||
import('./features/change-trace/change-trace.routes').then((m) => m.changeTraceRoutes),
|
||||
},
|
||||
// Setup Wizard (Sprint 4: UI Wizard Core)
|
||||
{
|
||||
path: 'setup',
|
||||
loadChildren: () =>
|
||||
import('./features/setup-wizard/setup-wizard.routes').then((m) => m.setupWizardRoutes),
|
||||
},
|
||||
// Configuration Pane (Sprint 6: Configuration Pane)
|
||||
{
|
||||
path: 'console/configuration',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/configuration-pane/configuration-pane.routes').then((m) => m.CONFIGURATION_PANE_ROUTES),
|
||||
},
|
||||
// Fallback for unknown routes
|
||||
{
|
||||
path: '**',
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* @file configuration-pane.component.spec.ts
|
||||
* @sprint Sprint 6: Configuration Pane
|
||||
* @description Tests for ConfigurationPaneComponent
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { ConfigurationPaneComponent } from './configuration-pane.component';
|
||||
import { ConfigurationPaneStateService } from '../services/configuration-pane-state.service';
|
||||
import { ConfigurationPaneApiService } from '../services/configuration-pane-api.service';
|
||||
import { ConfiguredIntegration, ConfigurationCheck } from '../models/configuration-pane.models';
|
||||
|
||||
describe('ConfigurationPaneComponent', () => {
|
||||
let component: ConfigurationPaneComponent;
|
||||
let fixture: ComponentFixture<ConfigurationPaneComponent>;
|
||||
let stateService: ConfigurationPaneStateService;
|
||||
let apiService: jasmine.SpyObj<ConfigurationPaneApiService>;
|
||||
let router: jasmine.SpyObj<Router>;
|
||||
|
||||
const mockIntegrations: ConfiguredIntegration[] = [
|
||||
{
|
||||
id: 'db-primary',
|
||||
type: 'database',
|
||||
name: 'Primary Database',
|
||||
provider: 'postgresql',
|
||||
status: 'connected',
|
||||
healthStatus: 'healthy',
|
||||
configuredAt: '2026-01-01T00:00:00Z',
|
||||
configValues: {
|
||||
'database.host': 'localhost',
|
||||
'database.port': '5432',
|
||||
},
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
id: 'cache-primary',
|
||||
type: 'cache',
|
||||
name: 'Redis Cache',
|
||||
provider: 'redis',
|
||||
status: 'connected',
|
||||
healthStatus: 'healthy',
|
||||
configuredAt: '2026-01-01T00:00:00Z',
|
||||
configValues: {
|
||||
'cache.host': 'localhost',
|
||||
'cache.port': '6379',
|
||||
},
|
||||
isPrimary: true,
|
||||
},
|
||||
];
|
||||
|
||||
const mockChecks: ConfigurationCheck[] = [
|
||||
{
|
||||
checkId: 'check.database.connectivity',
|
||||
integrationId: 'db-primary',
|
||||
name: 'Database Connectivity',
|
||||
status: 'passed',
|
||||
message: 'Connection established',
|
||||
severity: 'critical',
|
||||
lastRun: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
const apiSpy = jasmine.createSpyObj('ConfigurationPaneApiService', [
|
||||
'getIntegrations',
|
||||
'getChecks',
|
||||
'testConnection',
|
||||
'refreshStatus',
|
||||
'updateConfiguration',
|
||||
'removeIntegration',
|
||||
'runChecksForIntegration',
|
||||
'exportConfiguration',
|
||||
]);
|
||||
const routerSpy = jasmine.createSpyObj('Router', ['navigate']);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ConfigurationPaneComponent],
|
||||
providers: [
|
||||
ConfigurationPaneStateService,
|
||||
{ provide: ConfigurationPaneApiService, useValue: apiSpy },
|
||||
{ provide: Router, useValue: routerSpy },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
stateService = TestBed.inject(ConfigurationPaneStateService);
|
||||
apiService = TestBed.inject(ConfigurationPaneApiService) as jasmine.SpyObj<ConfigurationPaneApiService>;
|
||||
router = TestBed.inject(Router) as jasmine.SpyObj<Router>;
|
||||
|
||||
// Default mock implementations
|
||||
apiService.getIntegrations.and.returnValue(of(mockIntegrations));
|
||||
apiService.getChecks.and.returnValue(of(mockChecks));
|
||||
|
||||
fixture = TestBed.createComponent(ConfigurationPaneComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stateService.reset();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should load configuration on init', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(apiService.getIntegrations).toHaveBeenCalled();
|
||||
expect(apiService.getChecks).toHaveBeenCalled();
|
||||
expect(stateService.allIntegrations().length).toBe(2);
|
||||
}));
|
||||
|
||||
it('should show loading state while fetching data', fakeAsync(() => {
|
||||
stateService.loading.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.loading')).toBeTruthy();
|
||||
}));
|
||||
|
||||
it('should show error message on load failure', fakeAsync(() => {
|
||||
apiService.getIntegrations.and.returnValue(throwError(() => new Error('Network error')));
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(stateService.error()).toContain('Failed to load configuration');
|
||||
}));
|
||||
});
|
||||
|
||||
describe('summary cards', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
}));
|
||||
|
||||
it('should display total integrations count', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const cards = compiled.querySelectorAll('.summary-card');
|
||||
expect(cards.length).toBe(4);
|
||||
expect(cards[0].querySelector('.summary-value').textContent).toBe('2');
|
||||
});
|
||||
|
||||
it('should display healthy integrations count', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const healthyCard = compiled.querySelector('.summary-card.healthy');
|
||||
expect(healthyCard.querySelector('.summary-value').textContent).toBe('2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filters', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
}));
|
||||
|
||||
it('should show filter dropdowns', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('#type-filter')).toBeTruthy();
|
||||
expect(compiled.querySelector('#status-filter')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should filter by type', fakeAsync(() => {
|
||||
stateService.setFilterType('database');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(stateService.filteredSections().length).toBe(1);
|
||||
expect(stateService.filteredSections()[0].type).toBe('database');
|
||||
}));
|
||||
|
||||
it('should filter by status', fakeAsync(() => {
|
||||
stateService.setFilterStatus('connected');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
const filteredSections = stateService.filteredSections();
|
||||
const allConnected = filteredSections.every((s) =>
|
||||
s.integrations.every((i) => i.status === 'connected')
|
||||
);
|
||||
expect(allConnected).toBe(true);
|
||||
}));
|
||||
|
||||
it('should clear filters', fakeAsync(() => {
|
||||
stateService.setFilterType('database');
|
||||
stateService.setFilterStatus('connected');
|
||||
stateService.clearFilters();
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(stateService.filterType()).toBe('all');
|
||||
expect(stateService.filterStatus()).toBe('all');
|
||||
}));
|
||||
});
|
||||
|
||||
describe('integration selection', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
}));
|
||||
|
||||
it('should select integration', () => {
|
||||
component.onSelectIntegration(mockIntegrations[0]);
|
||||
expect(stateService.selectedIntegrationId()).toBe('db-primary');
|
||||
});
|
||||
|
||||
it('should show detail panel when integration is selected', fakeAsync(() => {
|
||||
component.onSelectIntegration(mockIntegrations[0]);
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.detail-panel')).toBeTruthy();
|
||||
}));
|
||||
|
||||
it('should close detail panel', () => {
|
||||
component.onSelectIntegration(mockIntegrations[0]);
|
||||
component.onCloseDetail();
|
||||
expect(stateService.selectedIntegrationId()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('connection testing', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
}));
|
||||
|
||||
it('should test connection for integration', fakeAsync(() => {
|
||||
apiService.testConnection.and.returnValue(
|
||||
of({ success: true, message: 'Connected', latencyMs: 20 })
|
||||
);
|
||||
|
||||
component.onTestConnection(mockIntegrations[0]);
|
||||
tick();
|
||||
|
||||
expect(apiService.testConnection).toHaveBeenCalled();
|
||||
expect(stateService.successMessage()).toContain('Connection successful');
|
||||
}));
|
||||
|
||||
it('should show error on connection failure', fakeAsync(() => {
|
||||
apiService.testConnection.and.returnValue(
|
||||
of({ success: false, message: 'Connection refused' })
|
||||
);
|
||||
|
||||
component.onTestConnection(mockIntegrations[0]);
|
||||
tick();
|
||||
|
||||
expect(stateService.error()).toContain('Connection refused');
|
||||
}));
|
||||
|
||||
it('should update status to checking while testing', fakeAsync(() => {
|
||||
apiService.testConnection.and.returnValue(
|
||||
of({ success: true, message: 'Connected', latencyMs: 20 })
|
||||
);
|
||||
|
||||
component.onTestConnection(mockIntegrations[0]);
|
||||
expect(
|
||||
stateService.allIntegrations().find((i) => i.id === 'db-primary')?.status
|
||||
).toBe('checking');
|
||||
|
||||
tick();
|
||||
}));
|
||||
});
|
||||
|
||||
describe('status refresh', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
}));
|
||||
|
||||
it('should refresh integration status', fakeAsync(() => {
|
||||
apiService.refreshStatus.and.returnValue(
|
||||
of({ status: 'connected', healthStatus: 'healthy' })
|
||||
);
|
||||
|
||||
component.onRefreshStatus(mockIntegrations[0]);
|
||||
tick();
|
||||
|
||||
expect(apiService.refreshStatus).toHaveBeenCalledWith('db-primary');
|
||||
const integration = stateService.allIntegrations().find((i) => i.id === 'db-primary');
|
||||
expect(integration?.status).toBe('connected');
|
||||
}));
|
||||
});
|
||||
|
||||
describe('configuration editing', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
component.onSelectIntegration(mockIntegrations[0]);
|
||||
}));
|
||||
|
||||
it('should enter edit mode', () => {
|
||||
stateService.enterEditMode();
|
||||
expect(stateService.editMode()).toBe(true);
|
||||
});
|
||||
|
||||
it('should update pending value', () => {
|
||||
stateService.enterEditMode();
|
||||
component.onUpdateValue('database.host', 'newhost.local');
|
||||
expect(stateService.pendingChanges()['database.host']).toBe('newhost.local');
|
||||
});
|
||||
|
||||
it('should save configuration changes', fakeAsync(() => {
|
||||
apiService.updateConfiguration.and.returnValue(
|
||||
of({ success: true, message: 'Saved', requiresRestart: false })
|
||||
);
|
||||
|
||||
stateService.enterEditMode();
|
||||
component.onUpdateValue('database.host', 'newhost.local');
|
||||
component.onSaveChanges();
|
||||
tick();
|
||||
|
||||
expect(apiService.updateConfiguration).toHaveBeenCalled();
|
||||
expect(stateService.editMode()).toBe(false);
|
||||
expect(stateService.successMessage()).toContain('saved successfully');
|
||||
}));
|
||||
|
||||
it('should show error on save failure', fakeAsync(() => {
|
||||
apiService.updateConfiguration.and.returnValue(
|
||||
of({ success: false, message: 'Validation failed', requiresRestart: false })
|
||||
);
|
||||
|
||||
stateService.enterEditMode();
|
||||
component.onSaveChanges();
|
||||
tick();
|
||||
|
||||
expect(stateService.error()).toContain('Validation failed');
|
||||
}));
|
||||
|
||||
it('should exit edit mode and discard changes', () => {
|
||||
stateService.enterEditMode();
|
||||
component.onUpdateValue('database.host', 'newhost.local');
|
||||
stateService.exitEditMode();
|
||||
|
||||
expect(stateService.editMode()).toBe(false);
|
||||
expect(Object.keys(stateService.pendingChanges()).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration removal', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
component.onSelectIntegration(mockIntegrations[0]);
|
||||
}));
|
||||
|
||||
it('should remove integration after confirmation', fakeAsync(() => {
|
||||
spyOn(window, 'confirm').and.returnValue(true);
|
||||
apiService.removeIntegration.and.returnValue(
|
||||
of({ success: true, message: 'Removed' })
|
||||
);
|
||||
|
||||
component.onRemoveIntegration();
|
||||
tick();
|
||||
|
||||
expect(apiService.removeIntegration).toHaveBeenCalledWith({ integrationId: 'db-primary' });
|
||||
expect(stateService.allIntegrations().find((i) => i.id === 'db-primary')).toBeUndefined();
|
||||
}));
|
||||
|
||||
it('should not remove integration if not confirmed', fakeAsync(() => {
|
||||
spyOn(window, 'confirm').and.returnValue(false);
|
||||
|
||||
component.onRemoveIntegration();
|
||||
tick();
|
||||
|
||||
expect(apiService.removeIntegration).not.toHaveBeenCalled();
|
||||
}));
|
||||
});
|
||||
|
||||
describe('health checks', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
}));
|
||||
|
||||
it('should run checks for selected integration', fakeAsync(() => {
|
||||
component.onSelectIntegration(mockIntegrations[0]);
|
||||
apiService.runChecksForIntegration.and.returnValue(of(mockChecks));
|
||||
|
||||
component.onRunChecksForSelected();
|
||||
tick();
|
||||
|
||||
expect(apiService.runChecksForIntegration).toHaveBeenCalledWith('db-primary');
|
||||
expect(stateService.successMessage()).toContain('Health checks completed');
|
||||
}));
|
||||
|
||||
it('should run all checks', fakeAsync(() => {
|
||||
apiService.runChecksForIntegration.and.returnValue(of(mockChecks));
|
||||
|
||||
component.runAllChecks();
|
||||
tick();
|
||||
|
||||
expect(apiService.runChecksForIntegration).toHaveBeenCalledTimes(2);
|
||||
expect(stateService.successMessage()).toContain('All health checks completed');
|
||||
}));
|
||||
});
|
||||
|
||||
describe('navigation', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
}));
|
||||
|
||||
it('should navigate to setup wizard', () => {
|
||||
component.navigateToSetupWizard();
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/setup']);
|
||||
});
|
||||
|
||||
it('should navigate to add integration with type', () => {
|
||||
component.onAddIntegration('vault');
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/setup'], { queryParams: { step: 'vault' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('export', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
}));
|
||||
|
||||
it('should export configuration', fakeAsync(() => {
|
||||
const mockBlob = new Blob(['{}'], { type: 'application/json' });
|
||||
apiService.exportConfiguration.and.returnValue(of(mockBlob));
|
||||
|
||||
spyOn(URL, 'createObjectURL').and.returnValue('blob:test');
|
||||
spyOn(URL, 'revokeObjectURL');
|
||||
|
||||
component.exportConfig();
|
||||
tick();
|
||||
|
||||
expect(apiService.exportConfiguration).toHaveBeenCalled();
|
||||
expect(stateService.successMessage()).toContain('exported successfully');
|
||||
}));
|
||||
|
||||
it('should show error on export failure', fakeAsync(() => {
|
||||
apiService.exportConfiguration.and.returnValue(throwError(() => new Error('Export failed')));
|
||||
|
||||
component.exportConfig();
|
||||
tick();
|
||||
|
||||
expect(stateService.error()).toContain('Failed to export');
|
||||
}));
|
||||
});
|
||||
|
||||
describe('missing required sections', () => {
|
||||
it('should show warning when required sections are missing', fakeAsync(() => {
|
||||
apiService.getIntegrations.and.returnValue(of([]));
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(stateService.missingRequiredSections().length).toBeGreaterThan(0);
|
||||
}));
|
||||
|
||||
it('should compute missing required section names', fakeAsync(() => {
|
||||
apiService.getIntegrations.and.returnValue(of([]));
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
const names = component.getMissingRequiredNames();
|
||||
expect(names.length).toBeGreaterThan(0);
|
||||
}));
|
||||
});
|
||||
|
||||
describe('empty state', () => {
|
||||
it('should show empty state when no integrations', fakeAsync(() => {
|
||||
apiService.getIntegrations.and.returnValue(of([]));
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(stateService.hasConfigurations()).toBe(false);
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,675 @@
|
||||
/**
|
||||
* @file configuration-pane.component.ts
|
||||
* @sprint Sprint 6: Configuration Pane
|
||||
* @description Main Configuration Pane component for managing integrations
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { ConfigurationPaneStateService } from '../services/configuration-pane-state.service';
|
||||
import { ConfigurationPaneApiService } from '../services/configuration-pane-api.service';
|
||||
import { IntegrationSectionComponent } from './integration-section.component';
|
||||
import { IntegrationDetailComponent } from './integration-detail.component';
|
||||
import {
|
||||
IntegrationType,
|
||||
ConnectionStatus,
|
||||
ConfiguredIntegration,
|
||||
} from '../models/configuration-pane.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-configuration-pane',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, IntegrationSectionComponent, IntegrationDetailComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="config-pane">
|
||||
<!-- Header -->
|
||||
<header class="config-header">
|
||||
<div class="header-title">
|
||||
<h1>Configuration</h1>
|
||||
<p class="subtitle">Manage platform integrations and settings</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn-secondary" (click)="runAllChecks()" [disabled]="state.loading()">
|
||||
Run Health Checks
|
||||
</button>
|
||||
<button class="btn-secondary" (click)="exportConfig()">
|
||||
Export
|
||||
</button>
|
||||
<button class="btn-primary" (click)="navigateToSetupWizard()">
|
||||
Setup Wizard
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Alerts -->
|
||||
@if (state.error()) {
|
||||
<div class="alert alert-error">
|
||||
<span>{{ state.error() }}</span>
|
||||
<button class="alert-dismiss" (click)="state.clearError()">Dismiss</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (state.successMessage()) {
|
||||
<div class="alert alert-success">
|
||||
<span>{{ state.successMessage() }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="summary-cards">
|
||||
<div class="summary-card">
|
||||
<div class="summary-value">{{ state.summary().totalIntegrations }}</div>
|
||||
<div class="summary-label">Total Integrations</div>
|
||||
</div>
|
||||
<div class="summary-card healthy">
|
||||
<div class="summary-value">{{ state.summary().healthyIntegrations }}</div>
|
||||
<div class="summary-label">Healthy</div>
|
||||
</div>
|
||||
<div class="summary-card degraded">
|
||||
<div class="summary-value">{{ state.summary().degradedIntegrations }}</div>
|
||||
<div class="summary-label">Degraded</div>
|
||||
</div>
|
||||
<div class="summary-card unhealthy">
|
||||
<div class="summary-value">{{ state.summary().unhealthyIntegrations }}</div>
|
||||
<div class="summary-label">Unhealthy</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Missing Required Sections Warning -->
|
||||
@if (state.missingRequiredSections().length > 0) {
|
||||
<div class="alert alert-warning">
|
||||
<strong>Missing Required Configuration:</strong>
|
||||
{{ getMissingRequiredNames() }}
|
||||
<button class="btn-link" (click)="navigateToSetupWizard()">
|
||||
Complete Setup
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<div class="filter-group">
|
||||
<label for="type-filter">Type:</label>
|
||||
<select
|
||||
id="type-filter"
|
||||
[ngModel]="state.filterType()"
|
||||
(ngModelChange)="state.setFilterType($event)">
|
||||
<option value="all">All Types</option>
|
||||
<option value="database">Database</option>
|
||||
<option value="cache">Cache</option>
|
||||
<option value="vault">Vault</option>
|
||||
<option value="settingsstore">Settings Store</option>
|
||||
<option value="registry">Registry</option>
|
||||
<option value="telemetry">Telemetry</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="status-filter">Status:</label>
|
||||
<select
|
||||
id="status-filter"
|
||||
[ngModel]="state.filterStatus()"
|
||||
(ngModelChange)="state.setFilterStatus($event)">
|
||||
<option value="all">All Status</option>
|
||||
<option value="connected">Connected</option>
|
||||
<option value="disconnected">Disconnected</option>
|
||||
<option value="error">Error</option>
|
||||
<option value="unknown">Unknown</option>
|
||||
</select>
|
||||
</div>
|
||||
@if (state.filterType() !== 'all' || state.filterStatus() !== 'all') {
|
||||
<button class="btn-link" (click)="state.clearFilters()">Clear Filters</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="config-content">
|
||||
<!-- Sections List -->
|
||||
<div class="sections-list">
|
||||
@if (state.loading()) {
|
||||
<div class="loading">Loading configuration...</div>
|
||||
} @else if (!state.hasConfigurations()) {
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">⚙</div>
|
||||
<h3>No Integrations Configured</h3>
|
||||
<p>Run the setup wizard to configure your platform integrations.</p>
|
||||
<button class="btn-primary" (click)="navigateToSetupWizard()">
|
||||
Start Setup Wizard
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
@for (section of state.filteredSections(); track section.type) {
|
||||
<app-integration-section
|
||||
[section]="section"
|
||||
[selectedIntegrationId]="state.selectedIntegrationId()"
|
||||
(selectIntegration)="onSelectIntegration($event)"
|
||||
(addIntegration)="onAddIntegration(section.type)"
|
||||
(testConnection)="onTestConnection($event)"
|
||||
(refreshStatus)="onRefreshStatus($event)" />
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Detail Panel -->
|
||||
@if (state.selectedIntegration()) {
|
||||
<div class="detail-panel">
|
||||
<app-integration-detail
|
||||
[integration]="state.selectedIntegration()!"
|
||||
[checks]="state.selectedIntegrationChecks()"
|
||||
[editMode]="state.editMode()"
|
||||
[pendingChanges]="state.pendingChanges()"
|
||||
[saving]="state.saving()"
|
||||
[testing]="state.testing()"
|
||||
(close)="onCloseDetail()"
|
||||
(enterEditMode)="state.enterEditMode()"
|
||||
(exitEditMode)="state.exitEditMode()"
|
||||
(updateValue)="onUpdateValue($event.key, $event.value)"
|
||||
(saveChanges)="onSaveChanges()"
|
||||
(testConnection)="onTestConnectionForSelected()"
|
||||
(runChecks)="onRunChecksForSelected()"
|
||||
(removeIntegration)="onRemoveIntegration()" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.config-pane {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.config-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header-title h1 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: var(--theme-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summary-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: var(--theme-bg-secondary);
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary-card.healthy {
|
||||
border-left: 4px solid var(--theme-status-success);
|
||||
}
|
||||
|
||||
.summary-card.degraded {
|
||||
border-left: 4px solid var(--theme-status-warning);
|
||||
}
|
||||
|
||||
.summary-card.unhealthy {
|
||||
border-left: 4px solid var(--theme-status-error);
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 13px;
|
||||
color: var(--theme-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
padding: 12px 16px;
|
||||
background: var(--theme-bg-secondary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--theme-text-secondary);
|
||||
}
|
||||
|
||||
.filter-group select {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
background: var(--theme-bg-primary);
|
||||
}
|
||||
|
||||
.config-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.sections-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
position: sticky;
|
||||
top: 24px;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty-state {
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
background: var(--theme-bg-secondary);
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0 0 24px 0;
|
||||
color: var(--theme-text-secondary);
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: var(--theme-status-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: var(--theme-status-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background: var(--theme-status-warning);
|
||||
color: var(--theme-text-primary);
|
||||
}
|
||||
|
||||
.alert-dismiss {
|
||||
background: transparent;
|
||||
border: 1px solid currentColor;
|
||||
color: inherit;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--theme-brand-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--theme-brand-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--theme-bg-tertiary);
|
||||
color: var(--theme-text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--theme-bg-hover);
|
||||
}
|
||||
|
||||
.btn-primary:disabled,
|
||||
.btn-secondary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--theme-brand-primary);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
color: var(--theme-brand-hover);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.config-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 400px;
|
||||
background: var(--theme-bg-primary);
|
||||
box-shadow: -4px 0 12px rgba(0,0,0,0.1);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.summary-cards {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class ConfigurationPaneComponent implements OnInit {
|
||||
readonly state = inject(ConfigurationPaneStateService);
|
||||
private readonly api = inject(ConfigurationPaneApiService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadConfiguration();
|
||||
}
|
||||
|
||||
loadConfiguration(): void {
|
||||
this.state.loading.set(true);
|
||||
this.state.clearError();
|
||||
|
||||
this.api.getIntegrations().subscribe({
|
||||
next: (integrations) => {
|
||||
this.state.initializeSections(integrations);
|
||||
this.loadChecks();
|
||||
this.state.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.state.showError('Failed to load configuration: ' + (err.message || 'Unknown error'));
|
||||
this.state.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
loadChecks(): void {
|
||||
this.api.getChecks().subscribe({
|
||||
next: (checks) => {
|
||||
this.state.setChecks(checks);
|
||||
},
|
||||
error: () => {
|
||||
// Non-critical, don't show error
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getMissingRequiredNames(): string {
|
||||
return this.state
|
||||
.missingRequiredSections()
|
||||
.map((s) => s.title)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
navigateToSetupWizard(): void {
|
||||
this.router.navigate(['/setup']);
|
||||
}
|
||||
|
||||
onSelectIntegration(integration: ConfiguredIntegration): void {
|
||||
this.state.selectIntegration(integration.id);
|
||||
}
|
||||
|
||||
onCloseDetail(): void {
|
||||
this.state.selectIntegration(null);
|
||||
}
|
||||
|
||||
onAddIntegration(type: IntegrationType): void {
|
||||
// Navigate to setup wizard with specific step
|
||||
this.router.navigate(['/setup'], { queryParams: { step: type } });
|
||||
}
|
||||
|
||||
onTestConnection(integration: ConfiguredIntegration): void {
|
||||
this.state.updateIntegrationStatus(integration.id, 'checking');
|
||||
|
||||
this.api
|
||||
.testConnection({
|
||||
integrationType: integration.type,
|
||||
provider: integration.provider,
|
||||
configValues: integration.configValues,
|
||||
})
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.state.updateIntegrationStatus(
|
||||
integration.id,
|
||||
result.success ? 'connected' : 'error'
|
||||
);
|
||||
if (result.success) {
|
||||
this.state.showSuccess(`${integration.name}: Connection successful`);
|
||||
} else {
|
||||
this.state.showError(`${integration.name}: ${result.message}`);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.state.updateIntegrationStatus(integration.id, 'error');
|
||||
this.state.showError(`${integration.name}: Connection test failed`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onRefreshStatus(integration: ConfiguredIntegration): void {
|
||||
this.state.updateIntegrationStatus(integration.id, 'checking');
|
||||
|
||||
this.api.refreshStatus(integration.id).subscribe({
|
||||
next: (result) => {
|
||||
this.state.updateIntegration(integration.id, {
|
||||
status: result.status as ConnectionStatus,
|
||||
healthStatus: result.healthStatus as any,
|
||||
lastChecked: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.state.updateIntegrationStatus(integration.id, 'unknown');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onUpdateValue(key: string, value: string): void {
|
||||
this.state.updatePendingValue(key, value);
|
||||
}
|
||||
|
||||
onSaveChanges(): void {
|
||||
const integration = this.state.selectedIntegration();
|
||||
if (!integration) return;
|
||||
|
||||
this.state.saving.set(true);
|
||||
|
||||
this.api
|
||||
.updateConfiguration({
|
||||
integrationId: integration.id,
|
||||
configValues: this.state.pendingChanges(),
|
||||
})
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
if (result.success) {
|
||||
this.state.updateIntegration(integration.id, {
|
||||
configValues: { ...this.state.pendingChanges() },
|
||||
});
|
||||
this.state.exitEditMode();
|
||||
this.state.showSuccess('Configuration saved successfully');
|
||||
} else {
|
||||
this.state.showError(result.message);
|
||||
}
|
||||
this.state.saving.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.state.showError('Failed to save configuration: ' + (err.message || 'Unknown error'));
|
||||
this.state.saving.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onTestConnectionForSelected(): void {
|
||||
const integration = this.state.selectedIntegration();
|
||||
if (integration) {
|
||||
this.state.testing.set(true);
|
||||
this.onTestConnection(integration);
|
||||
setTimeout(() => this.state.testing.set(false), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
onRunChecksForSelected(): void {
|
||||
const integration = this.state.selectedIntegration();
|
||||
if (!integration) return;
|
||||
|
||||
// Mark checks as running
|
||||
const checks = this.state.selectedIntegrationChecks();
|
||||
checks.forEach((check) => this.state.startCheck(check.checkId));
|
||||
|
||||
this.api.runChecksForIntegration(integration.id).subscribe({
|
||||
next: (results) => {
|
||||
results.forEach((check) => {
|
||||
this.state.completeCheck(check.checkId, check.status as any, check.message);
|
||||
});
|
||||
this.state.showSuccess('Health checks completed');
|
||||
},
|
||||
error: () => {
|
||||
checks.forEach((check) => {
|
||||
this.state.completeCheck(check.checkId, 'failed', 'Check execution failed');
|
||||
});
|
||||
this.state.showError('Failed to run health checks');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onRemoveIntegration(): void {
|
||||
const integration = this.state.selectedIntegration();
|
||||
if (!integration) return;
|
||||
|
||||
if (!confirm(`Are you sure you want to remove "${integration.name}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.api.removeIntegration({ integrationId: integration.id }).subscribe({
|
||||
next: (result) => {
|
||||
if (result.success) {
|
||||
this.state.removeIntegration(integration.id);
|
||||
this.state.showSuccess('Integration removed successfully');
|
||||
} else {
|
||||
this.state.showError(result.message);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.state.showError('Failed to remove integration: ' + (err.message || 'Unknown error'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
runAllChecks(): void {
|
||||
this.state.loading.set(true);
|
||||
|
||||
// Run checks for each integration
|
||||
const integrations = this.state.allIntegrations();
|
||||
let completed = 0;
|
||||
|
||||
integrations.forEach((integration) => {
|
||||
this.api.runChecksForIntegration(integration.id).subscribe({
|
||||
next: (results) => {
|
||||
results.forEach((check) => {
|
||||
this.state.updateCheck(check.checkId, check);
|
||||
});
|
||||
},
|
||||
complete: () => {
|
||||
completed++;
|
||||
if (completed === integrations.length) {
|
||||
this.state.loading.set(false);
|
||||
this.state.showSuccess('All health checks completed');
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
completed++;
|
||||
if (completed === integrations.length) {
|
||||
this.state.loading.set(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (integrations.length === 0) {
|
||||
this.state.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
exportConfig(): void {
|
||||
this.api.exportConfiguration().subscribe({
|
||||
next: (blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `stellaops-config-${new Date().toISOString().split('T')[0]}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
this.state.showSuccess('Configuration exported successfully');
|
||||
},
|
||||
error: () => {
|
||||
this.state.showError('Failed to export configuration');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
/**
|
||||
* @file integration-detail.component.spec.ts
|
||||
* @sprint Sprint 6: Configuration Pane
|
||||
* @description Tests for IntegrationDetailComponent
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { IntegrationDetailComponent } from './integration-detail.component';
|
||||
import { ConfiguredIntegration, ConfigurationCheck } from '../models/configuration-pane.models';
|
||||
|
||||
describe('IntegrationDetailComponent', () => {
|
||||
let component: IntegrationDetailComponent;
|
||||
let fixture: ComponentFixture<IntegrationDetailComponent>;
|
||||
|
||||
const mockIntegration: ConfiguredIntegration = {
|
||||
id: 'vault-hc',
|
||||
type: 'vault',
|
||||
name: 'HashiCorp Vault',
|
||||
provider: 'hashicorp',
|
||||
status: 'connected',
|
||||
healthStatus: 'healthy',
|
||||
configuredAt: '2026-01-01T12:00:00Z',
|
||||
configuredBy: 'admin@stellaops.local',
|
||||
lastChecked: '2026-01-13T10:00:00Z',
|
||||
configValues: {
|
||||
'vault.address': 'https://vault.example.com:8200',
|
||||
'vault.namespace': 'stellaops',
|
||||
'vault.token': 'secret-token',
|
||||
},
|
||||
isPrimary: true,
|
||||
};
|
||||
|
||||
const mockChecks: ConfigurationCheck[] = [
|
||||
{
|
||||
checkId: 'check.vault.connectivity',
|
||||
integrationId: 'vault-hc',
|
||||
name: 'Vault Connectivity',
|
||||
status: 'passed',
|
||||
message: 'Connection established',
|
||||
severity: 'critical',
|
||||
lastRun: '2026-01-13T10:00:00Z',
|
||||
},
|
||||
{
|
||||
checkId: 'check.vault.auth',
|
||||
integrationId: 'vault-hc',
|
||||
name: 'Vault Authentication',
|
||||
status: 'failed',
|
||||
message: 'Token expired',
|
||||
severity: 'critical',
|
||||
lastRun: '2026-01-13T10:00:00Z',
|
||||
},
|
||||
{
|
||||
checkId: 'check.vault.permissions',
|
||||
integrationId: 'vault-hc',
|
||||
name: 'Vault Permissions',
|
||||
status: 'warning',
|
||||
message: 'Limited read access',
|
||||
severity: 'warning',
|
||||
lastRun: '2026-01-13T10:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [IntegrationDetailComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(IntegrationDetailComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('integration', mockIntegration);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display integration name', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('h2').textContent).toContain('HashiCorp Vault');
|
||||
});
|
||||
|
||||
it('should display provider badge', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.provider-badge').textContent).toContain('hashicorp');
|
||||
});
|
||||
|
||||
it('should display close button', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.btn-close')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('status banner', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('integration', mockIntegration);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display status label', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.status-label').textContent).toContain('Connected');
|
||||
});
|
||||
|
||||
it('should display last checked time', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.last-checked')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should apply connected status class', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.status-banner.status-connected')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should apply error status class when status is error', () => {
|
||||
fixture.componentRef.setInput('integration', { ...mockIntegration, status: 'error' });
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.status-banner.status-error')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show test connection button', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const button = compiled.querySelector('.status-actions .btn-sm');
|
||||
expect(button.textContent).toContain('Test Connection');
|
||||
});
|
||||
|
||||
it('should show testing state when testing', () => {
|
||||
fixture.componentRef.setInput('testing', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
const button = compiled.querySelector('.status-actions .btn-sm');
|
||||
expect(button.textContent).toContain('Testing...');
|
||||
expect(button.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tabs', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('integration', mockIntegration);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display configuration and health tabs', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const tabs = compiled.querySelectorAll('.tab');
|
||||
expect(tabs.length).toBe(2);
|
||||
expect(tabs[0].textContent).toContain('Configuration');
|
||||
expect(tabs[1].textContent).toContain('Health Checks');
|
||||
});
|
||||
|
||||
it('should default to configuration tab', () => {
|
||||
expect(component.activeTab).toBe('config');
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.tab.active').textContent).toContain('Configuration');
|
||||
});
|
||||
|
||||
it('should switch to health tab on click', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const healthTab = compiled.querySelectorAll('.tab')[1];
|
||||
healthTab.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.activeTab).toBe('health');
|
||||
expect(compiled.querySelector('.tab.active').textContent).toContain('Health Checks');
|
||||
});
|
||||
|
||||
it('should show failed checks count badge', () => {
|
||||
fixture.componentRef.setInput('checks', mockChecks);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
const badge = compiled.querySelector('.badge-error');
|
||||
expect(badge).toBeTruthy();
|
||||
expect(badge.textContent).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('configuration tab', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('integration', mockIntegration);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display edit configuration button when not in edit mode', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.edit-toggle .btn-secondary').textContent).toContain(
|
||||
'Edit Configuration'
|
||||
);
|
||||
});
|
||||
|
||||
it('should display configuration fields', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const fields = compiled.querySelectorAll('.config-field');
|
||||
expect(fields.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should mask sensitive field values when not in edit mode', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.masked-value')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display metadata section', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.metadata-section')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display configured at date', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const metadataItems = compiled.querySelectorAll('.metadata-item');
|
||||
const configuredAt = Array.from(metadataItems).find((item: any) =>
|
||||
item.querySelector('.metadata-label')?.textContent?.includes('Configured At')
|
||||
);
|
||||
expect(configuredAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display configured by user', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const metadataItems = compiled.querySelectorAll('.metadata-item');
|
||||
const configuredBy = Array.from(metadataItems).find((item: any) =>
|
||||
item.querySelector('.metadata-label')?.textContent?.includes('Configured By')
|
||||
);
|
||||
expect(configuredBy).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display primary role for primary integrations', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const metadataItems = compiled.querySelectorAll('.metadata-item');
|
||||
const role = Array.from(metadataItems).find((item: any) =>
|
||||
item.querySelector('.metadata-label')?.textContent?.includes('Role')
|
||||
);
|
||||
expect(role).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit mode', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('integration', mockIntegration);
|
||||
fixture.componentRef.setInput('editMode', true);
|
||||
fixture.componentRef.setInput('pendingChanges', { ...mockIntegration.configValues });
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should show save and cancel buttons in edit mode', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.edit-actions .btn-primary').textContent).toContain(
|
||||
'Save Changes'
|
||||
);
|
||||
expect(compiled.querySelector('.edit-actions .btn-secondary').textContent).toContain('Cancel');
|
||||
});
|
||||
|
||||
it('should show input fields in edit mode', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.config-field input')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show password input for sensitive fields', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const passwordInput = compiled.querySelector('input[type="password"]');
|
||||
expect(passwordInput).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should disable save button when saving', () => {
|
||||
fixture.componentRef.setInput('saving', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
const saveButton = compiled.querySelector('.edit-actions .btn-primary');
|
||||
expect(saveButton.disabled).toBe(true);
|
||||
expect(saveButton.textContent).toContain('Saving...');
|
||||
});
|
||||
|
||||
it('should disable cancel button when saving', () => {
|
||||
fixture.componentRef.setInput('saving', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
const cancelButton = compiled.querySelector('.edit-actions .btn-secondary');
|
||||
expect(cancelButton.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('health checks tab', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('integration', mockIntegration);
|
||||
fixture.componentRef.setInput('checks', mockChecks);
|
||||
component.activeTab = 'health';
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display checks count', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.checks-header span').textContent).toContain('3 checks');
|
||||
});
|
||||
|
||||
it('should display run all checks button', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.checks-header .btn-secondary').textContent).toContain(
|
||||
'Run All Checks'
|
||||
);
|
||||
});
|
||||
|
||||
it('should display check items', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const checkItems = compiled.querySelectorAll('.check-item');
|
||||
expect(checkItems.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should display check name', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const checkNames = compiled.querySelectorAll('.check-name');
|
||||
expect(checkNames[0].textContent).toContain('Vault Connectivity');
|
||||
});
|
||||
|
||||
it('should display check message', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const checkMessages = compiled.querySelectorAll('.check-message');
|
||||
expect(checkMessages[0].textContent).toContain('Connection established');
|
||||
});
|
||||
|
||||
it('should display check severity', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const severities = compiled.querySelectorAll('.check-severity');
|
||||
expect(severities[0].textContent).toContain('critical');
|
||||
});
|
||||
|
||||
it('should show passed check icon', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.check-icon.passed')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show failed check icon', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.check-icon.failed')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show warning check icon', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.check-icon.warning')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should apply failed styling to failed checks', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.check-item.check-failed')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should apply warning styling to warning checks', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.check-item.check-warning')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show empty state when no checks', () => {
|
||||
fixture.componentRef.setInput('checks', []);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.empty-checks')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show spinner for running checks', () => {
|
||||
const runningChecks: ConfigurationCheck[] = [
|
||||
{ ...mockChecks[0], status: 'running' },
|
||||
];
|
||||
fixture.componentRef.setInput('checks', runningChecks);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.check-spinner')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('events', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('integration', mockIntegration);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should emit close when close button is clicked', () => {
|
||||
const closeSpy = spyOn(component.close, 'emit');
|
||||
const button = fixture.nativeElement.querySelector('.btn-close');
|
||||
button.click();
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit testConnection when test button is clicked', () => {
|
||||
const testSpy = spyOn(component.testConnection, 'emit');
|
||||
const button = fixture.nativeElement.querySelector('.status-actions .btn-sm');
|
||||
button.click();
|
||||
expect(testSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit enterEditMode when edit button is clicked', () => {
|
||||
const editSpy = spyOn(component.enterEditMode, 'emit');
|
||||
const button = fixture.nativeElement.querySelector('.edit-toggle .btn-secondary');
|
||||
button.click();
|
||||
expect(editSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit saveChanges when save button is clicked', () => {
|
||||
fixture.componentRef.setInput('editMode', true);
|
||||
fixture.componentRef.setInput('pendingChanges', { ...mockIntegration.configValues });
|
||||
fixture.detectChanges();
|
||||
|
||||
const saveSpy = spyOn(component.saveChanges, 'emit');
|
||||
const button = fixture.nativeElement.querySelector('.edit-actions .btn-primary');
|
||||
button.click();
|
||||
expect(saveSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit exitEditMode when cancel button is clicked', () => {
|
||||
fixture.componentRef.setInput('editMode', true);
|
||||
fixture.componentRef.setInput('pendingChanges', { ...mockIntegration.configValues });
|
||||
fixture.detectChanges();
|
||||
|
||||
const exitSpy = spyOn(component.exitEditMode, 'emit');
|
||||
const button = fixture.nativeElement.querySelector('.edit-actions .btn-secondary');
|
||||
button.click();
|
||||
expect(exitSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit runChecks when run all checks button is clicked', () => {
|
||||
fixture.componentRef.setInput('checks', mockChecks);
|
||||
component.activeTab = 'health';
|
||||
fixture.detectChanges();
|
||||
|
||||
const runSpy = spyOn(component.runChecks, 'emit');
|
||||
const button = fixture.nativeElement.querySelector('.checks-header .btn-secondary');
|
||||
button.click();
|
||||
expect(runSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit removeIntegration when remove button is clicked', () => {
|
||||
const removeSpy = spyOn(component.removeIntegration, 'emit');
|
||||
const button = fixture.nativeElement.querySelector('.detail-footer .btn-danger');
|
||||
button.click();
|
||||
expect(removeSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit updateValue when field is changed', () => {
|
||||
fixture.componentRef.setInput('editMode', true);
|
||||
fixture.componentRef.setInput('pendingChanges', { ...mockIntegration.configValues });
|
||||
fixture.detectChanges();
|
||||
|
||||
const updateSpy = spyOn(component.updateValue, 'emit');
|
||||
const input = fixture.nativeElement.querySelector('.config-field input:not([type="password"])');
|
||||
input.value = 'new-value';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
|
||||
expect(updateSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('helper methods', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('integration', mockIntegration);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should return correct status label for connected', () => {
|
||||
expect(component.getStatusLabel('connected')).toBe('Connected');
|
||||
});
|
||||
|
||||
it('should return correct status label for disconnected', () => {
|
||||
expect(component.getStatusLabel('disconnected')).toBe('Disconnected');
|
||||
});
|
||||
|
||||
it('should return correct status label for error', () => {
|
||||
expect(component.getStatusLabel('error')).toBe('Connection Error');
|
||||
});
|
||||
|
||||
it('should return correct status label for checking', () => {
|
||||
expect(component.getStatusLabel('checking')).toBe('Checking Connection...');
|
||||
});
|
||||
|
||||
it('should format date correctly', () => {
|
||||
const result = component.formatDate('2026-01-01T12:00:00Z');
|
||||
expect(result).toBeTruthy();
|
||||
expect(result).not.toBe('-');
|
||||
});
|
||||
|
||||
it('should return dash for undefined date', () => {
|
||||
expect(component.formatDate(undefined)).toBe('-');
|
||||
});
|
||||
|
||||
it('should count failed checks correctly', () => {
|
||||
fixture.componentRef.setInput('checks', mockChecks);
|
||||
fixture.detectChanges();
|
||||
expect(component.failedChecksCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('should format field label correctly', () => {
|
||||
expect(component.formatFieldLabel('vault.address')).toBe('Address');
|
||||
expect(component.formatFieldLabel('database.connectionString')).toBe('Connection String');
|
||||
});
|
||||
|
||||
it('should get field value from pending changes in edit mode', () => {
|
||||
fixture.componentRef.setInput('editMode', true);
|
||||
fixture.componentRef.setInput('pendingChanges', { 'vault.address': 'new-address' });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.getFieldValue('vault.address')).toBe('new-address');
|
||||
});
|
||||
|
||||
it('should get field value from integration when not in edit mode', () => {
|
||||
expect(component.getFieldValue('vault.address')).toBe('https://vault.example.com:8200');
|
||||
});
|
||||
|
||||
it('should return empty string for missing field', () => {
|
||||
expect(component.getFieldValue('nonexistent.field')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('display fields', () => {
|
||||
it('should use provider definition fields for vault', () => {
|
||||
const vaultIntegration: ConfiguredIntegration = {
|
||||
...mockIntegration,
|
||||
type: 'vault',
|
||||
provider: 'hashicorp',
|
||||
};
|
||||
fixture.componentRef.setInput('integration', vaultIntegration);
|
||||
fixture.detectChanges();
|
||||
|
||||
const fields = component.getDisplayFields();
|
||||
expect(fields.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should use provider definition fields for settings store', () => {
|
||||
const settingsIntegration: ConfiguredIntegration = {
|
||||
...mockIntegration,
|
||||
type: 'settingsstore',
|
||||
provider: 'consul',
|
||||
};
|
||||
fixture.componentRef.setInput('integration', settingsIntegration);
|
||||
fixture.detectChanges();
|
||||
|
||||
const fields = component.getDisplayFields();
|
||||
expect(fields.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should generate fields from config values for unknown types', () => {
|
||||
const unknownIntegration: ConfiguredIntegration = {
|
||||
...mockIntegration,
|
||||
type: 'database',
|
||||
provider: 'custom',
|
||||
configValues: {
|
||||
'custom.host': 'localhost',
|
||||
'custom.password': 'secret',
|
||||
},
|
||||
};
|
||||
fixture.componentRef.setInput('integration', unknownIntegration);
|
||||
fixture.detectChanges();
|
||||
|
||||
const fields = component.getDisplayFields();
|
||||
expect(fields.length).toBe(2);
|
||||
expect(fields.find((f) => f.key === 'custom.password')?.sensitive).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('integration', mockIntegration);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should have title on close button', () => {
|
||||
const button = fixture.nativeElement.querySelector('.btn-close');
|
||||
expect(button.getAttribute('title')).toBe('Close');
|
||||
});
|
||||
|
||||
it('should have labels on config fields', () => {
|
||||
const labels = fixture.nativeElement.querySelectorAll('.config-field label');
|
||||
expect(labels.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should associate labels with inputs in edit mode', () => {
|
||||
fixture.componentRef.setInput('editMode', true);
|
||||
fixture.componentRef.setInput('pendingChanges', { ...mockIntegration.configValues });
|
||||
fixture.detectChanges();
|
||||
|
||||
const labels = fixture.nativeElement.querySelectorAll('.config-field label');
|
||||
const inputs = fixture.nativeElement.querySelectorAll('.config-field input');
|
||||
|
||||
if (labels.length > 0 && inputs.length > 0) {
|
||||
const labelFor = labels[0].getAttribute('for');
|
||||
const inputId = inputs[0].getAttribute('id');
|
||||
expect(labelFor).toBe(inputId);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,737 @@
|
||||
/**
|
||||
* @file integration-detail.component.ts
|
||||
* @sprint Sprint 6: Configuration Pane
|
||||
* @description Component for viewing and editing integration details
|
||||
*/
|
||||
|
||||
import { Component, input, output, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
ConfiguredIntegration,
|
||||
ConfigurationCheck,
|
||||
VAULT_PROVIDER_DEFINITIONS,
|
||||
SETTINGS_STORE_PROVIDER_DEFINITIONS,
|
||||
} from '../models/configuration-pane.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-integration-detail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="detail-container">
|
||||
<!-- Header -->
|
||||
<div class="detail-header">
|
||||
<div class="header-info">
|
||||
<h2>{{ integration().name }}</h2>
|
||||
<span class="provider-badge">{{ integration().provider }}</span>
|
||||
</div>
|
||||
<button class="btn-close" (click)="close.emit()" title="Close">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Status Banner -->
|
||||
<div class="status-banner" [class]="'status-' + integration().status">
|
||||
<div class="status-info">
|
||||
<span class="status-label">{{ getStatusLabel(integration().status) }}</span>
|
||||
@if (integration().lastChecked) {
|
||||
<span class="last-checked">
|
||||
Last checked: {{ formatDate(integration().lastChecked) }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="status-actions">
|
||||
<button
|
||||
class="btn-sm"
|
||||
(click)="testConnection.emit()"
|
||||
[disabled]="testing()">
|
||||
{{ testing() ? 'Testing...' : 'Test Connection' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs">
|
||||
<button
|
||||
class="tab"
|
||||
[class.active]="activeTab === 'config'"
|
||||
(click)="activeTab = 'config'">
|
||||
Configuration
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
[class.active]="activeTab === 'health'"
|
||||
(click)="activeTab = 'health'">
|
||||
Health Checks
|
||||
@if (failedChecksCount() > 0) {
|
||||
<span class="badge-error">{{ failedChecksCount() }}</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Configuration Tab -->
|
||||
@if (activeTab === 'config') {
|
||||
<div class="tab-content">
|
||||
<!-- Edit Mode Toggle -->
|
||||
<div class="edit-toggle">
|
||||
@if (!editMode()) {
|
||||
<button class="btn-secondary" (click)="enterEditMode.emit()">
|
||||
Edit Configuration
|
||||
</button>
|
||||
} @else {
|
||||
<div class="edit-actions">
|
||||
<button
|
||||
class="btn-primary"
|
||||
(click)="saveChanges.emit()"
|
||||
[disabled]="saving()">
|
||||
{{ saving() ? 'Saving...' : 'Save Changes' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn-secondary"
|
||||
(click)="exitEditMode.emit()"
|
||||
[disabled]="saving()">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Configuration Fields -->
|
||||
<div class="config-fields">
|
||||
@for (field of getDisplayFields(); track field.key) {
|
||||
<div class="config-field">
|
||||
<label [for]="field.key">{{ field.label }}</label>
|
||||
@if (editMode()) {
|
||||
@if (field.sensitive) {
|
||||
<input
|
||||
[id]="field.key"
|
||||
type="password"
|
||||
[value]="getFieldValue(field.key)"
|
||||
(input)="onFieldChange(field.key, $event)"
|
||||
[placeholder]="field.placeholder || ''"
|
||||
autocomplete="off" />
|
||||
} @else if (field.type === 'textarea') {
|
||||
<textarea
|
||||
[id]="field.key"
|
||||
[value]="getFieldValue(field.key)"
|
||||
(input)="onFieldChange(field.key, $event)"
|
||||
[placeholder]="field.placeholder || ''"
|
||||
rows="3"></textarea>
|
||||
} @else {
|
||||
<input
|
||||
[id]="field.key"
|
||||
[type]="field.type"
|
||||
[value]="getFieldValue(field.key)"
|
||||
(input)="onFieldChange(field.key, $event)"
|
||||
[placeholder]="field.placeholder || ''" />
|
||||
}
|
||||
} @else {
|
||||
<div class="field-value">
|
||||
@if (field.sensitive) {
|
||||
<span class="masked-value">********</span>
|
||||
} @else {
|
||||
{{ getFieldValue(field.key) || '-' }}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (field.description) {
|
||||
<small class="field-hint">{{ field.description }}</small>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="metadata-section">
|
||||
<h4>Metadata</h4>
|
||||
<div class="metadata-grid">
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">Configured At</span>
|
||||
<span class="metadata-value">{{ formatDate(integration().configuredAt) }}</span>
|
||||
</div>
|
||||
@if (integration().configuredBy) {
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">Configured By</span>
|
||||
<span class="metadata-value">{{ integration().configuredBy }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (integration().isPrimary) {
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">Role</span>
|
||||
<span class="metadata-value">Primary</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Health Checks Tab -->
|
||||
@if (activeTab === 'health') {
|
||||
<div class="tab-content">
|
||||
<div class="checks-header">
|
||||
<span>{{ checks().length }} checks</span>
|
||||
<button class="btn-secondary btn-sm" (click)="runChecks.emit()">
|
||||
Run All Checks
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (checks().length === 0) {
|
||||
<div class="empty-checks">
|
||||
<p>No health checks configured for this integration.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="checks-list">
|
||||
@for (check of checks(); track check.checkId) {
|
||||
<div class="check-item" [class]="'check-' + check.status">
|
||||
<div class="check-status">
|
||||
@switch (check.status) {
|
||||
@case ('passed') {
|
||||
<span class="check-icon passed">✓</span>
|
||||
}
|
||||
@case ('failed') {
|
||||
<span class="check-icon failed">✗</span>
|
||||
}
|
||||
@case ('warning') {
|
||||
<span class="check-icon warning">⚠</span>
|
||||
}
|
||||
@case ('running') {
|
||||
<span class="check-spinner"></span>
|
||||
}
|
||||
@default {
|
||||
<span class="check-icon pending">◯</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="check-info">
|
||||
<div class="check-name">{{ check.name }}</div>
|
||||
@if (check.message) {
|
||||
<div class="check-message">{{ check.message }}</div>
|
||||
}
|
||||
@if (check.lastRun) {
|
||||
<div class="check-time">{{ formatDate(check.lastRun) }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="check-severity" [class]="check.severity">
|
||||
{{ check.severity }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Footer Actions -->
|
||||
<div class="detail-footer">
|
||||
<button class="btn-danger" (click)="removeIntegration.emit()">
|
||||
Remove Integration
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.detail-container {
|
||||
background: var(--theme-bg-secondary);
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 48px);
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--theme-border-primary);
|
||||
}
|
||||
|
||||
.header-info h2 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.provider-badge {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
background: var(--theme-bg-tertiary);
|
||||
border-radius: 4px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--theme-text-secondary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.btn-close:hover {
|
||||
background: var(--theme-bg-tertiary);
|
||||
color: var(--theme-text-primary);
|
||||
}
|
||||
|
||||
.status-banner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--theme-border-primary);
|
||||
}
|
||||
|
||||
.status-banner.status-connected {
|
||||
background: rgba(var(--theme-status-success-rgb, 40, 167, 69), 0.1);
|
||||
}
|
||||
|
||||
.status-banner.status-error {
|
||||
background: rgba(var(--theme-status-error-rgb, 220, 53, 69), 0.1);
|
||||
}
|
||||
|
||||
.status-banner.status-checking {
|
||||
background: rgba(var(--theme-brand-primary-rgb, 0, 123, 255), 0.1);
|
||||
}
|
||||
|
||||
.status-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.last-checked {
|
||||
font-size: 12px;
|
||||
color: var(--theme-text-secondary);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--theme-border-primary);
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
color: var(--theme-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--theme-brand-primary);
|
||||
border-bottom-color: var(--theme-brand-primary);
|
||||
}
|
||||
|
||||
.tab:hover:not(.active) {
|
||||
background: var(--theme-bg-tertiary);
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
background: var(--theme-status-error);
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.edit-toggle {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.config-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.config-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.config-field label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--theme-text-secondary);
|
||||
}
|
||||
|
||||
.config-field input,
|
||||
.config-field textarea {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background: var(--theme-bg-primary);
|
||||
}
|
||||
|
||||
.config-field textarea {
|
||||
resize: vertical;
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
padding: 8px 12px;
|
||||
background: var(--theme-bg-tertiary);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.masked-value {
|
||||
color: var(--theme-text-secondary);
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-size: 11px;
|
||||
color: var(--theme-text-secondary);
|
||||
}
|
||||
|
||||
.metadata-section {
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--theme-border-primary);
|
||||
}
|
||||
|
||||
.metadata-section h4 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.metadata-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metadata-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.metadata-label {
|
||||
font-size: 11px;
|
||||
color: var(--theme-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.metadata-value {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.checks-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.checks-header span {
|
||||
font-size: 13px;
|
||||
color: var(--theme-text-secondary);
|
||||
}
|
||||
|
||||
.checks-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.check-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--theme-bg-primary);
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.check-item.check-failed {
|
||||
border-left: 3px solid var(--theme-status-error);
|
||||
}
|
||||
|
||||
.check-item.check-warning {
|
||||
border-left: 3px solid var(--theme-status-warning);
|
||||
}
|
||||
|
||||
.check-status {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.check-icon.passed {
|
||||
color: var(--theme-status-success);
|
||||
}
|
||||
|
||||
.check-icon.failed {
|
||||
color: var(--theme-status-error);
|
||||
}
|
||||
|
||||
.check-icon.warning {
|
||||
color: var(--theme-status-warning);
|
||||
}
|
||||
|
||||
.check-icon.pending {
|
||||
color: var(--theme-text-secondary);
|
||||
}
|
||||
|
||||
.check-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--theme-border-primary);
|
||||
border-top-color: var(--theme-brand-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.check-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.check-name {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.check-message {
|
||||
font-size: 12px;
|
||||
color: var(--theme-text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.check-time {
|
||||
font-size: 11px;
|
||||
color: var(--theme-text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.check-severity {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.check-severity.critical {
|
||||
background: var(--theme-status-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.check-severity.warning {
|
||||
background: var(--theme-status-warning);
|
||||
color: var(--theme-text-primary);
|
||||
}
|
||||
|
||||
.check-severity.info {
|
||||
background: var(--theme-bg-tertiary);
|
||||
color: var(--theme-text-secondary);
|
||||
}
|
||||
|
||||
.empty-checks {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
color: var(--theme-text-secondary);
|
||||
}
|
||||
|
||||
.detail-footer {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--theme-border-primary);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-danger,
|
||||
.btn-sm {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--theme-brand-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--theme-bg-tertiary);
|
||||
color: var(--theme-text-primary);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--theme-status-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--theme-brand-hover);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--theme-bg-hover);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-primary:disabled,
|
||||
.btn-secondary:disabled,
|
||||
.btn-danger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class IntegrationDetailComponent {
|
||||
readonly integration = input.required<ConfiguredIntegration>();
|
||||
readonly checks = input<ConfigurationCheck[]>([]);
|
||||
readonly editMode = input(false);
|
||||
readonly pendingChanges = input<Record<string, string>>({});
|
||||
readonly saving = input(false);
|
||||
readonly testing = input(false);
|
||||
|
||||
readonly close = output<void>();
|
||||
readonly enterEditMode = output<void>();
|
||||
readonly exitEditMode = output<void>();
|
||||
readonly updateValue = output<{ key: string; value: string }>();
|
||||
readonly saveChanges = output<void>();
|
||||
readonly testConnection = output<void>();
|
||||
readonly runChecks = output<void>();
|
||||
readonly removeIntegration = output<void>();
|
||||
|
||||
activeTab: 'config' | 'health' = 'config';
|
||||
|
||||
getStatusLabel(status: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
connected: 'Connected',
|
||||
disconnected: 'Disconnected',
|
||||
error: 'Connection Error',
|
||||
unknown: 'Status Unknown',
|
||||
checking: 'Checking Connection...',
|
||||
};
|
||||
return labels[status] || status;
|
||||
}
|
||||
|
||||
formatDate(dateStr: string | undefined): string {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
failedChecksCount(): number {
|
||||
return this.checks().filter((c) => c.status === 'failed').length;
|
||||
}
|
||||
|
||||
getDisplayFields(): { key: string; label: string; type: string; sensitive: boolean; placeholder?: string; description?: string }[] {
|
||||
const integration = this.integration();
|
||||
|
||||
// Get provider definition if available
|
||||
let providerDef;
|
||||
if (integration.type === 'vault') {
|
||||
providerDef = VAULT_PROVIDER_DEFINITIONS.find((p) => p.id === integration.provider);
|
||||
} else if (integration.type === 'settingsstore') {
|
||||
providerDef = SETTINGS_STORE_PROVIDER_DEFINITIONS.find((p) => p.id === integration.provider);
|
||||
}
|
||||
|
||||
if (providerDef) {
|
||||
return providerDef.fields.map((f) => ({
|
||||
key: f.key,
|
||||
label: f.label,
|
||||
type: f.type,
|
||||
sensitive: f.sensitive,
|
||||
placeholder: f.placeholder,
|
||||
description: f.description,
|
||||
}));
|
||||
}
|
||||
|
||||
// Fallback: generate fields from config values
|
||||
return Object.keys(integration.configValues).map((key) => ({
|
||||
key,
|
||||
label: this.formatFieldLabel(key),
|
||||
type: key.includes('password') || key.includes('secret') || key.includes('token') ? 'password' : 'text',
|
||||
sensitive: key.includes('password') || key.includes('secret') || key.includes('token'),
|
||||
}));
|
||||
}
|
||||
|
||||
formatFieldLabel(key: string): string {
|
||||
return key
|
||||
.split('.')
|
||||
.pop()!
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/^./, (s) => s.toUpperCase())
|
||||
.trim();
|
||||
}
|
||||
|
||||
getFieldValue(key: string): string {
|
||||
if (this.editMode()) {
|
||||
return this.pendingChanges()[key] ?? '';
|
||||
}
|
||||
return this.integration().configValues[key] ?? '';
|
||||
}
|
||||
|
||||
onFieldChange(key: string, event: Event): void {
|
||||
const input = event.target as HTMLInputElement | HTMLTextAreaElement;
|
||||
this.updateValue.emit({ key, value: input.value });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* @file integration-section.component.spec.ts
|
||||
* @sprint Sprint 6: Configuration Pane
|
||||
* @description Tests for IntegrationSectionComponent
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { IntegrationSectionComponent } from './integration-section.component';
|
||||
import { ConfigurationSection, ConfiguredIntegration } from '../models/configuration-pane.models';
|
||||
|
||||
describe('IntegrationSectionComponent', () => {
|
||||
let component: IntegrationSectionComponent;
|
||||
let fixture: ComponentFixture<IntegrationSectionComponent>;
|
||||
|
||||
const mockIntegration: ConfiguredIntegration = {
|
||||
id: 'db-primary',
|
||||
type: 'database',
|
||||
name: 'Primary Database',
|
||||
provider: 'postgresql',
|
||||
status: 'connected',
|
||||
healthStatus: 'healthy',
|
||||
configuredAt: '2026-01-01T00:00:00Z',
|
||||
configValues: {},
|
||||
isPrimary: true,
|
||||
};
|
||||
|
||||
const mockSection: ConfigurationSection = {
|
||||
type: 'database',
|
||||
title: 'Database',
|
||||
description: 'Primary data store for application state',
|
||||
isRequired: true,
|
||||
supportsMultiple: false,
|
||||
integrations: [mockIntegration],
|
||||
isConfigured: true,
|
||||
};
|
||||
|
||||
const emptySection: ConfigurationSection = {
|
||||
type: 'vault',
|
||||
title: 'Secrets Vault',
|
||||
description: 'Secure secrets management',
|
||||
isRequired: false,
|
||||
supportsMultiple: true,
|
||||
integrations: [],
|
||||
isConfigured: false,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [IntegrationSectionComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(IntegrationSectionComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should create', () => {
|
||||
fixture.componentRef.setInput('section', mockSection);
|
||||
fixture.detectChanges();
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display section title', () => {
|
||||
fixture.componentRef.setInput('section', mockSection);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.section-title').textContent).toContain('Database');
|
||||
});
|
||||
|
||||
it('should display section description', () => {
|
||||
fixture.componentRef.setInput('section', mockSection);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.section-description').textContent).toContain(
|
||||
'Primary data store'
|
||||
);
|
||||
});
|
||||
|
||||
it('should show required badge for required sections', () => {
|
||||
fixture.componentRef.setInput('section', mockSection);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.required-badge')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not show required badge for optional sections', () => {
|
||||
fixture.componentRef.setInput('section', emptySection);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.required-badge')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should display section icon', () => {
|
||||
fixture.componentRef.setInput('section', mockSection);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
const icon = compiled.querySelector('.section-icon');
|
||||
expect(icon).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty state', () => {
|
||||
it('should show empty section styling when not configured', () => {
|
||||
fixture.componentRef.setInput('section', emptySection);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.section.empty')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show empty section message', () => {
|
||||
fixture.componentRef.setInput('section', emptySection);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.empty-section')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show empty hint for required unconfigured sections', () => {
|
||||
const requiredEmpty: ConfigurationSection = {
|
||||
...emptySection,
|
||||
isRequired: true,
|
||||
};
|
||||
fixture.componentRef.setInput('section', requiredEmpty);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.empty-hint')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration cards', () => {
|
||||
it('should display integration cards when configured', () => {
|
||||
fixture.componentRef.setInput('section', mockSection);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.integration-card')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display integration name', () => {
|
||||
fixture.componentRef.setInput('section', mockSection);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.integration-name').textContent).toContain('Primary Database');
|
||||
});
|
||||
|
||||
it('should display integration provider', () => {
|
||||
fixture.componentRef.setInput('section', mockSection);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.integration-provider').textContent).toContain('postgresql');
|
||||
});
|
||||
|
||||
it('should show primary badge for primary integrations', () => {
|
||||
fixture.componentRef.setInput('section', mockSection);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.primary-badge')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should highlight selected integration', () => {
|
||||
fixture.componentRef.setInput('section', mockSection);
|
||||
fixture.componentRef.setInput('selectedIntegrationId', 'db-primary');
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.integration-card.selected')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('status indicators', () => {
|
||||
it('should show connected status dot', () => {
|
||||
fixture.componentRef.setInput('section', mockSection);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.status-dot.connected')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show disconnected status dot', () => {
|
||||
const disconnectedSection: ConfigurationSection = {
|
||||
...mockSection,
|
||||
integrations: [{ ...mockIntegration, status: 'disconnected' }],
|
||||
};
|
||||
fixture.componentRef.setInput('section', disconnectedSection);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.status-dot.disconnected')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show error status dot', () => {
|
||||
const errorSection: ConfigurationSection = {
|
||||
...mockSection,
|
||||
integrations: [{ ...mockIntegration, status: 'error' }],
|
||||
};
|
||||
fixture.componentRef.setInput('section', errorSection);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.status-dot.error')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show spinner when checking', () => {
|
||||
const checkingSection: ConfigurationSection = {
|
||||
...mockSection,
|
||||
integrations: [{ ...mockIntegration, status: 'checking' }],
|
||||
};
|
||||
fixture.componentRef.setInput('section', checkingSection);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.status-spinner')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should apply error border to error status cards', () => {
|
||||
const errorSection: ConfigurationSection = {
|
||||
...mockSection,
|
||||
integrations: [{ ...mockIntegration, status: 'error' }],
|
||||
};
|
||||
fixture.componentRef.setInput('section', errorSection);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.integration-card.status-error')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('add button', () => {
|
||||
it('should show add button when section supports multiple', () => {
|
||||
const multiSection: ConfigurationSection = {
|
||||
...mockSection,
|
||||
supportsMultiple: true,
|
||||
};
|
||||
fixture.componentRef.setInput('section', multiSection);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.btn-add')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show add button when section is not configured', () => {
|
||||
fixture.componentRef.setInput('section', emptySection);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.btn-add')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should hide add button when configured and does not support multiple', () => {
|
||||
const singleSection: ConfigurationSection = {
|
||||
...mockSection,
|
||||
supportsMultiple: false,
|
||||
isConfigured: true,
|
||||
};
|
||||
fixture.componentRef.setInput('section', singleSection);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.btn-add')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('events', () => {
|
||||
it('should emit selectIntegration when card is clicked', () => {
|
||||
fixture.componentRef.setInput('section', mockSection);
|
||||
fixture.detectChanges();
|
||||
|
||||
const selectSpy = spyOn(component.selectIntegration, 'emit');
|
||||
const card = fixture.nativeElement.querySelector('.integration-card');
|
||||
card.click();
|
||||
|
||||
expect(selectSpy).toHaveBeenCalledWith(mockIntegration);
|
||||
});
|
||||
|
||||
it('should emit addIntegration when add button is clicked', () => {
|
||||
fixture.componentRef.setInput('section', emptySection);
|
||||
fixture.detectChanges();
|
||||
|
||||
const addSpy = spyOn(component.addIntegration, 'emit');
|
||||
const button = fixture.nativeElement.querySelector('.btn-add');
|
||||
button.click();
|
||||
|
||||
expect(addSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit testConnection when test button is clicked', () => {
|
||||
fixture.componentRef.setInput('section', mockSection);
|
||||
fixture.detectChanges();
|
||||
|
||||
const testSpy = spyOn(component.testConnection, 'emit');
|
||||
const buttons = fixture.nativeElement.querySelectorAll('.btn-icon');
|
||||
buttons[0].click();
|
||||
|
||||
expect(testSpy).toHaveBeenCalledWith(mockIntegration);
|
||||
});
|
||||
|
||||
it('should emit refreshStatus when refresh button is clicked', () => {
|
||||
fixture.componentRef.setInput('section', mockSection);
|
||||
fixture.detectChanges();
|
||||
|
||||
const refreshSpy = spyOn(component.refreshStatus, 'emit');
|
||||
const buttons = fixture.nativeElement.querySelectorAll('.btn-icon');
|
||||
buttons[1].click();
|
||||
|
||||
expect(refreshSpy).toHaveBeenCalledWith(mockIntegration);
|
||||
});
|
||||
|
||||
it('should not propagate click from action buttons to card', () => {
|
||||
fixture.componentRef.setInput('section', mockSection);
|
||||
fixture.detectChanges();
|
||||
|
||||
const selectSpy = spyOn(component.selectIntegration, 'emit');
|
||||
const testSpy = spyOn(component.testConnection, 'emit');
|
||||
|
||||
const buttons = fixture.nativeElement.querySelectorAll('.btn-icon');
|
||||
buttons[0].click();
|
||||
|
||||
expect(testSpy).toHaveBeenCalled();
|
||||
expect(selectSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('helper methods', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('section', mockSection);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should return correct icon for database type', () => {
|
||||
expect(component.getIcon('database')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return correct icon for cache type', () => {
|
||||
expect(component.getIcon('cache')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return correct icon for vault type', () => {
|
||||
expect(component.getIcon('vault')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return correct icon for settingsstore type', () => {
|
||||
expect(component.getIcon('settingsstore')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return fallback icon for unknown type', () => {
|
||||
expect(component.getIcon('unknown')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return correct status label for connected', () => {
|
||||
expect(component.getStatusLabel('connected')).toBe('Connected');
|
||||
});
|
||||
|
||||
it('should return correct status label for disconnected', () => {
|
||||
expect(component.getStatusLabel('disconnected')).toBe('Disconnected');
|
||||
});
|
||||
|
||||
it('should return correct status label for error', () => {
|
||||
expect(component.getStatusLabel('error')).toBe('Error');
|
||||
});
|
||||
|
||||
it('should return correct status label for checking', () => {
|
||||
expect(component.getStatusLabel('checking')).toBe('Checking...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('section', mockSection);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should have title attribute on status indicator', () => {
|
||||
const statusElement = fixture.nativeElement.querySelector('.integration-status');
|
||||
expect(statusElement.getAttribute('title')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have title attribute on action buttons', () => {
|
||||
const buttons = fixture.nativeElement.querySelectorAll('.btn-icon');
|
||||
expect(buttons[0].getAttribute('title')).toBe('Test Connection');
|
||||
expect(buttons[1].getAttribute('title')).toBe('Refresh Status');
|
||||
});
|
||||
|
||||
it('should disable test button when checking', () => {
|
||||
const checkingSection: ConfigurationSection = {
|
||||
...mockSection,
|
||||
integrations: [{ ...mockIntegration, status: 'checking' }],
|
||||
};
|
||||
fixture.componentRef.setInput('section', checkingSection);
|
||||
fixture.detectChanges();
|
||||
|
||||
const testButton = fixture.nativeElement.querySelector('.btn-icon');
|
||||
expect(testButton.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* @file integration-section.component.ts
|
||||
* @sprint Sprint 6: Configuration Pane
|
||||
* @description Component for displaying a section of integrations by type
|
||||
*/
|
||||
|
||||
import { Component, input, output, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ConfigurationSection, ConfiguredIntegration, ConnectionStatus } from '../models/configuration-pane.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-integration-section',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="section" [class.empty]="!section().isConfigured">
|
||||
<div class="section-header">
|
||||
<div class="section-info">
|
||||
<div class="section-icon">{{ getIcon(section().type) }}</div>
|
||||
<div>
|
||||
<h3 class="section-title">
|
||||
{{ section().title }}
|
||||
@if (section().isRequired) {
|
||||
<span class="required-badge">Required</span>
|
||||
}
|
||||
</h3>
|
||||
<p class="section-description">{{ section().description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-actions">
|
||||
@if (section().supportsMultiple || !section().isConfigured) {
|
||||
<button class="btn-add" (click)="addIntegration.emit()">
|
||||
+ Add
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (section().isConfigured) {
|
||||
<div class="integrations-list">
|
||||
@for (integration of section().integrations; track integration.id) {
|
||||
<div
|
||||
class="integration-card"
|
||||
[class.selected]="integration.id === selectedIntegrationId()"
|
||||
[class.status-connected]="integration.status === 'connected'"
|
||||
[class.status-disconnected]="integration.status === 'disconnected'"
|
||||
[class.status-error]="integration.status === 'error'"
|
||||
[class.status-checking]="integration.status === 'checking'"
|
||||
(click)="selectIntegration.emit(integration)">
|
||||
<div class="integration-main">
|
||||
<div class="integration-status" [title]="getStatusLabel(integration.status)">
|
||||
@switch (integration.status) {
|
||||
@case ('connected') {
|
||||
<span class="status-dot connected"></span>
|
||||
}
|
||||
@case ('disconnected') {
|
||||
<span class="status-dot disconnected"></span>
|
||||
}
|
||||
@case ('error') {
|
||||
<span class="status-dot error"></span>
|
||||
}
|
||||
@case ('checking') {
|
||||
<span class="status-spinner"></span>
|
||||
}
|
||||
@default {
|
||||
<span class="status-dot unknown"></span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="integration-info">
|
||||
<div class="integration-name">
|
||||
{{ integration.name }}
|
||||
@if (integration.isPrimary) {
|
||||
<span class="primary-badge">Primary</span>
|
||||
}
|
||||
</div>
|
||||
<div class="integration-provider">{{ integration.provider }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="integration-actions" (click)="$event.stopPropagation()">
|
||||
<button
|
||||
class="btn-icon"
|
||||
title="Test Connection"
|
||||
(click)="testConnection.emit(integration)"
|
||||
[disabled]="integration.status === 'checking'">
|
||||
↻
|
||||
</button>
|
||||
<button
|
||||
class="btn-icon"
|
||||
title="Refresh Status"
|
||||
(click)="refreshStatus.emit(integration)">
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="empty-section">
|
||||
<p>No {{ section().title.toLowerCase() }} configured</p>
|
||||
@if (section().isRequired) {
|
||||
<span class="empty-hint">This is a required integration</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.section {
|
||||
background: var(--theme-bg-secondary);
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section.empty {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--theme-border-primary);
|
||||
background: var(--theme-bg-tertiary);
|
||||
}
|
||||
|
||||
.section-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
font-size: 24px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--theme-text-secondary);
|
||||
}
|
||||
|
||||
.required-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
background: var(--theme-status-warning);
|
||||
color: var(--theme-text-primary);
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
padding: 6px 14px;
|
||||
background: var(--theme-brand-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-add:hover {
|
||||
background: var(--theme-brand-hover);
|
||||
}
|
||||
|
||||
.integrations-list {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.integration-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: var(--theme-bg-primary);
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.integration-card:hover {
|
||||
border-color: var(--theme-brand-primary);
|
||||
}
|
||||
|
||||
.integration-card.selected {
|
||||
border-color: var(--theme-brand-primary);
|
||||
background: var(--theme-bg-tertiary);
|
||||
box-shadow: 0 0 0 1px var(--theme-brand-primary);
|
||||
}
|
||||
|
||||
.integration-card.status-error {
|
||||
border-left: 3px solid var(--theme-status-error);
|
||||
}
|
||||
|
||||
.integration-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.integration-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background: var(--theme-status-success);
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background: var(--theme-text-secondary);
|
||||
}
|
||||
|
||||
.status-dot.error {
|
||||
background: var(--theme-status-error);
|
||||
}
|
||||
|
||||
.status-dot.unknown {
|
||||
background: var(--theme-text-secondary);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.status-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--theme-border-primary);
|
||||
border-top-color: var(--theme-brand-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.integration-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.integration-name {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.integration-provider {
|
||||
font-size: 12px;
|
||||
color: var(--theme-text-secondary);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.primary-badge {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
padding: 1px 5px;
|
||||
background: var(--theme-brand-primary);
|
||||
color: white;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.integration-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--theme-border-primary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--theme-text-secondary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-icon:hover:not(:disabled) {
|
||||
background: var(--theme-bg-tertiary);
|
||||
color: var(--theme-text-primary);
|
||||
}
|
||||
|
||||
.btn-icon:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.empty-section {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-section p {
|
||||
margin: 0;
|
||||
color: var(--theme-text-secondary);
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--theme-status-warning);
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class IntegrationSectionComponent {
|
||||
readonly section = input.required<ConfigurationSection>();
|
||||
readonly selectedIntegrationId = input<string | null>(null);
|
||||
|
||||
readonly selectIntegration = output<ConfiguredIntegration>();
|
||||
readonly addIntegration = output<void>();
|
||||
readonly testConnection = output<ConfiguredIntegration>();
|
||||
readonly refreshStatus = output<ConfiguredIntegration>();
|
||||
|
||||
getIcon(type: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
database: '\u{1F5C4}', // File Cabinet
|
||||
cache: '\u{26A1}', // Lightning
|
||||
vault: '\u{1F512}', // Lock
|
||||
settingsstore: '\u{2699}', // Gear
|
||||
registry: '\u{1F4E6}', // Package
|
||||
telemetry: '\u{1F4CA}', // Bar Chart
|
||||
};
|
||||
return icons[type] || '\u{2699}';
|
||||
}
|
||||
|
||||
getStatusLabel(status: ConnectionStatus): string {
|
||||
const labels: Record<ConnectionStatus, string> = {
|
||||
connected: 'Connected',
|
||||
disconnected: 'Disconnected',
|
||||
error: 'Error',
|
||||
unknown: 'Unknown',
|
||||
checking: 'Checking...',
|
||||
};
|
||||
return labels[status];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @file configuration-pane.routes.ts
|
||||
* @sprint Sprint 6: Configuration Pane
|
||||
* @description Routes for the Configuration Pane feature
|
||||
*/
|
||||
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const CONFIGURATION_PANE_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () =>
|
||||
import('./components/configuration-pane.component').then(
|
||||
(m) => m.ConfigurationPaneComponent
|
||||
),
|
||||
title: 'Configuration - Stella Ops',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* @sprint Sprint 6: Configuration Pane
|
||||
* @description Public API for the Configuration Pane feature
|
||||
*/
|
||||
|
||||
// Routes
|
||||
export { CONFIGURATION_PANE_ROUTES } from './configuration-pane.routes';
|
||||
|
||||
// Components
|
||||
export { ConfigurationPaneComponent } from './components/configuration-pane.component';
|
||||
export { IntegrationSectionComponent } from './components/integration-section.component';
|
||||
export { IntegrationDetailComponent } from './components/integration-detail.component';
|
||||
|
||||
// Services
|
||||
export { ConfigurationPaneStateService } from './services/configuration-pane-state.service';
|
||||
export { ConfigurationPaneApiService } from './services/configuration-pane-api.service';
|
||||
|
||||
// Models
|
||||
export * from './models/configuration-pane.models';
|
||||
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* @file configuration-pane.models.ts
|
||||
* @sprint Sprint 6: Configuration Pane
|
||||
* @description Models for the Configuration Pane feature
|
||||
*/
|
||||
|
||||
/**
|
||||
* Integration type identifiers matching backend IntegrationType enum
|
||||
*/
|
||||
export type IntegrationType =
|
||||
| 'database'
|
||||
| 'cache'
|
||||
| 'vault'
|
||||
| 'settingsstore'
|
||||
| 'registry'
|
||||
| 'telemetry';
|
||||
|
||||
/**
|
||||
* Connection status for an integration
|
||||
*/
|
||||
export type ConnectionStatus = 'connected' | 'disconnected' | 'error' | 'unknown' | 'checking';
|
||||
|
||||
/**
|
||||
* Health status returned by doctor checks
|
||||
*/
|
||||
export type HealthStatus = 'healthy' | 'degraded' | 'unhealthy' | 'unknown';
|
||||
|
||||
/**
|
||||
* Represents a configured integration instance
|
||||
*/
|
||||
export interface ConfiguredIntegration {
|
||||
id: string;
|
||||
type: IntegrationType;
|
||||
name: string;
|
||||
provider: string;
|
||||
description?: string;
|
||||
status: ConnectionStatus;
|
||||
healthStatus: HealthStatus;
|
||||
lastChecked?: string;
|
||||
configuredAt: string;
|
||||
configuredBy?: string;
|
||||
configValues: Record<string, string>;
|
||||
isDefault?: boolean;
|
||||
isPrimary?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration section grouping integrations by type
|
||||
*/
|
||||
export interface ConfigurationSection {
|
||||
type: IntegrationType;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
integrations: ConfiguredIntegration[];
|
||||
isRequired: boolean;
|
||||
isConfigured: boolean;
|
||||
supportsMultiple: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overall configuration summary
|
||||
*/
|
||||
export interface ConfigurationSummary {
|
||||
totalIntegrations: number;
|
||||
healthyIntegrations: number;
|
||||
degradedIntegrations: number;
|
||||
unhealthyIntegrations: number;
|
||||
lastFullCheck?: string;
|
||||
sections: ConfigurationSection[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration field definition for forms
|
||||
*/
|
||||
export interface ConfigurationField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'text' | 'password' | 'number' | 'checkbox' | 'select' | 'textarea';
|
||||
required: boolean;
|
||||
sensitive: boolean;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
options?: { value: string; label: string }[];
|
||||
validation?: {
|
||||
pattern?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider definition with available configuration fields
|
||||
*/
|
||||
export interface ProviderDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
fields: ConfigurationField[];
|
||||
supportsTest: boolean;
|
||||
supportsWatch?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection request
|
||||
*/
|
||||
export interface TestConnectionRequest {
|
||||
integrationType: IntegrationType;
|
||||
provider: string;
|
||||
configValues: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection result
|
||||
*/
|
||||
export interface TestConnectionResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
latencyMs?: number;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration request
|
||||
*/
|
||||
export interface UpdateConfigurationRequest {
|
||||
integrationId: string;
|
||||
configValues: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration result
|
||||
*/
|
||||
export interface UpdateConfigurationResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
requiresRestart?: boolean;
|
||||
affectedServices?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add integration request
|
||||
*/
|
||||
export interface AddIntegrationRequest {
|
||||
type: IntegrationType;
|
||||
provider: string;
|
||||
name: string;
|
||||
configValues: Record<string, string>;
|
||||
isPrimary?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove integration request
|
||||
*/
|
||||
export interface RemoveIntegrationRequest {
|
||||
integrationId: string;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Doctor check for configuration
|
||||
*/
|
||||
export interface ConfigurationCheck {
|
||||
checkId: string;
|
||||
integrationId: string;
|
||||
name: string;
|
||||
status: 'passed' | 'failed' | 'warning' | 'skipped' | 'running';
|
||||
message?: string;
|
||||
severity: 'critical' | 'warning' | 'info';
|
||||
lastRun?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration history entry
|
||||
*/
|
||||
export interface ConfigurationHistoryEntry {
|
||||
id: string;
|
||||
integrationId: string;
|
||||
action: 'created' | 'updated' | 'deleted' | 'tested';
|
||||
timestamp: string;
|
||||
performedBy?: string;
|
||||
previousValues?: Record<string, string>;
|
||||
newValues?: Record<string, string>;
|
||||
result?: 'success' | 'failure';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default section definitions
|
||||
*/
|
||||
export const DEFAULT_SECTIONS: Omit<ConfigurationSection, 'integrations' | 'isConfigured'>[] = [
|
||||
{
|
||||
type: 'database',
|
||||
title: 'PostgreSQL Database',
|
||||
description: 'Primary database for storing platform data',
|
||||
icon: 'database',
|
||||
isRequired: true,
|
||||
supportsMultiple: false,
|
||||
},
|
||||
{
|
||||
type: 'cache',
|
||||
title: 'Redis Cache',
|
||||
description: 'In-memory cache for performance optimization',
|
||||
icon: 'memory',
|
||||
isRequired: true,
|
||||
supportsMultiple: false,
|
||||
},
|
||||
{
|
||||
type: 'vault',
|
||||
title: 'Secrets Vault',
|
||||
description: 'Secure storage for secrets and credentials',
|
||||
icon: 'lock',
|
||||
isRequired: false,
|
||||
supportsMultiple: true,
|
||||
},
|
||||
{
|
||||
type: 'settingsstore',
|
||||
title: 'Settings Store',
|
||||
description: 'External configuration and feature flag management',
|
||||
icon: 'settings',
|
||||
isRequired: false,
|
||||
supportsMultiple: true,
|
||||
},
|
||||
{
|
||||
type: 'registry',
|
||||
title: 'Container Registry',
|
||||
description: 'OCI container registry for image storage',
|
||||
icon: 'archive',
|
||||
isRequired: false,
|
||||
supportsMultiple: true,
|
||||
},
|
||||
{
|
||||
type: 'telemetry',
|
||||
title: 'Telemetry',
|
||||
description: 'Observability and monitoring configuration',
|
||||
icon: 'chart-line',
|
||||
isRequired: false,
|
||||
supportsMultiple: false,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Provider definitions for vault integrations
|
||||
*/
|
||||
export const VAULT_PROVIDER_DEFINITIONS: ProviderDefinition[] = [
|
||||
{
|
||||
id: 'hashicorp',
|
||||
name: 'HashiCorp Vault',
|
||||
description: 'Enterprise secrets management',
|
||||
supportsTest: true,
|
||||
fields: [
|
||||
{ key: 'vault.address', label: 'Vault Address', type: 'text', required: true, sensitive: false, placeholder: 'https://vault.example.com:8200' },
|
||||
{ key: 'vault.token', label: 'Token', type: 'password', required: true, sensitive: true, placeholder: 'hvs.xxxxx' },
|
||||
{ key: 'vault.namespace', label: 'Namespace', type: 'text', required: false, sensitive: false, placeholder: 'admin' },
|
||||
{ key: 'vault.mountPath', label: 'Mount Path', type: 'text', required: false, sensitive: false, placeholder: 'secret' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'azure',
|
||||
name: 'Azure Key Vault',
|
||||
description: 'Azure-managed secrets and keys',
|
||||
supportsTest: true,
|
||||
fields: [
|
||||
{ key: 'azure.vaultUrl', label: 'Vault URL', type: 'text', required: true, sensitive: false, placeholder: 'https://myvault.vault.azure.net' },
|
||||
{ key: 'azure.tenantId', label: 'Tenant ID', type: 'text', required: true, sensitive: false },
|
||||
{ key: 'azure.clientId', label: 'Client ID', type: 'text', required: true, sensitive: false },
|
||||
{ key: 'azure.clientSecret', label: 'Client Secret', type: 'password', required: true, sensitive: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'aws',
|
||||
name: 'AWS Secrets Manager',
|
||||
description: 'AWS-managed secrets storage',
|
||||
supportsTest: true,
|
||||
fields: [
|
||||
{ key: 'aws.region', label: 'Region', type: 'text', required: true, sensitive: false, placeholder: 'us-east-1' },
|
||||
{ key: 'aws.accessKeyId', label: 'Access Key ID', type: 'text', required: false, sensitive: false },
|
||||
{ key: 'aws.secretAccessKey', label: 'Secret Access Key', type: 'password', required: false, sensitive: true },
|
||||
{ key: 'aws.roleArn', label: 'Role ARN (for assume role)', type: 'text', required: false, sensitive: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gcp',
|
||||
name: 'Google Secret Manager',
|
||||
description: 'GCP-managed secrets',
|
||||
supportsTest: true,
|
||||
fields: [
|
||||
{ key: 'gcp.projectId', label: 'Project ID', type: 'text', required: true, sensitive: false },
|
||||
{ key: 'gcp.credentialsJson', label: 'Service Account JSON', type: 'textarea', required: false, sensitive: true, description: 'Leave empty to use default credentials' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Provider definitions for settings store integrations
|
||||
*/
|
||||
export const SETTINGS_STORE_PROVIDER_DEFINITIONS: ProviderDefinition[] = [
|
||||
{
|
||||
id: 'consul',
|
||||
name: 'Consul KV',
|
||||
description: 'HashiCorp Consul key-value store',
|
||||
supportsTest: true,
|
||||
supportsWatch: true,
|
||||
fields: [
|
||||
{ key: 'consul.address', label: 'Consul Address', type: 'text', required: true, sensitive: false, placeholder: 'http://localhost:8500' },
|
||||
{ key: 'consul.token', label: 'ACL Token', type: 'password', required: false, sensitive: true },
|
||||
{ key: 'consul.prefix', label: 'Key Prefix', type: 'text', required: false, sensitive: false, placeholder: 'stellaops/' },
|
||||
{ key: 'consul.datacenter', label: 'Datacenter', type: 'text', required: false, sensitive: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'etcd',
|
||||
name: 'etcd',
|
||||
description: 'Distributed key-value store',
|
||||
supportsTest: true,
|
||||
supportsWatch: true,
|
||||
fields: [
|
||||
{ key: 'etcd.endpoints', label: 'Endpoints', type: 'text', required: true, sensitive: false, placeholder: 'http://localhost:2379' },
|
||||
{ key: 'etcd.username', label: 'Username', type: 'text', required: false, sensitive: false },
|
||||
{ key: 'etcd.password', label: 'Password', type: 'password', required: false, sensitive: true },
|
||||
{ key: 'etcd.prefix', label: 'Key Prefix', type: 'text', required: false, sensitive: false, placeholder: '/stellaops/' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'azure-appconfig',
|
||||
name: 'Azure App Configuration',
|
||||
description: 'Azure-managed configuration service',
|
||||
supportsTest: true,
|
||||
supportsWatch: true,
|
||||
fields: [
|
||||
{ key: 'azure.connectionString', label: 'Connection String', type: 'password', required: true, sensitive: true },
|
||||
{ key: 'azure.label', label: 'Label Filter', type: 'text', required: false, sensitive: false, placeholder: 'production' },
|
||||
{ key: 'azure.prefix', label: 'Key Prefix', type: 'text', required: false, sensitive: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'aws-parameterstore',
|
||||
name: 'AWS Parameter Store',
|
||||
description: 'AWS Systems Manager Parameter Store',
|
||||
supportsTest: true,
|
||||
supportsWatch: false,
|
||||
fields: [
|
||||
{ key: 'aws.region', label: 'Region', type: 'text', required: true, sensitive: false, placeholder: 'us-east-1' },
|
||||
{ key: 'aws.path', label: 'Parameter Path', type: 'text', required: true, sensitive: false, placeholder: '/stellaops/' },
|
||||
{ key: 'aws.accessKeyId', label: 'Access Key ID', type: 'text', required: false, sensitive: false },
|
||||
{ key: 'aws.secretAccessKey', label: 'Secret Access Key', type: 'password', required: false, sensitive: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'aws-appconfig',
|
||||
name: 'AWS AppConfig',
|
||||
description: 'AWS AppConfig for feature flags and configuration',
|
||||
supportsTest: true,
|
||||
supportsWatch: true,
|
||||
fields: [
|
||||
{ key: 'aws.region', label: 'Region', type: 'text', required: true, sensitive: false, placeholder: 'us-east-1' },
|
||||
{ key: 'aws.applicationId', label: 'Application ID', type: 'text', required: true, sensitive: false },
|
||||
{ key: 'aws.environmentId', label: 'Environment ID', type: 'text', required: true, sensitive: false },
|
||||
{ key: 'aws.configProfileId', label: 'Configuration Profile ID', type: 'text', required: true, sensitive: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* @file configuration-pane-api.service.spec.ts
|
||||
* @sprint Sprint 6: Configuration Pane
|
||||
* @description Tests for ConfigurationPaneApiService
|
||||
*/
|
||||
|
||||
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { ConfigurationPaneApiService } from './configuration-pane-api.service';
|
||||
|
||||
describe('ConfigurationPaneApiService', () => {
|
||||
let service: ConfigurationPaneApiService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [ConfigurationPaneApiService],
|
||||
});
|
||||
service = TestBed.inject(ConfigurationPaneApiService);
|
||||
});
|
||||
|
||||
describe('getIntegrations', () => {
|
||||
it('should return integrations', fakeAsync(() => {
|
||||
let result: any;
|
||||
service.getIntegrations().subscribe((data) => (result = data));
|
||||
tick(500);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
}));
|
||||
|
||||
it('should return integrations with required properties', fakeAsync(() => {
|
||||
let result: any;
|
||||
service.getIntegrations().subscribe((data) => (result = data));
|
||||
tick(500);
|
||||
|
||||
const integration = result[0];
|
||||
expect(integration.id).toBeTruthy();
|
||||
expect(integration.type).toBeTruthy();
|
||||
expect(integration.name).toBeTruthy();
|
||||
expect(integration.provider).toBeTruthy();
|
||||
expect(integration.status).toBeTruthy();
|
||||
}));
|
||||
});
|
||||
|
||||
describe('getIntegration', () => {
|
||||
it('should return specific integration by id', fakeAsync(() => {
|
||||
let result: any;
|
||||
service.getIntegration('db-primary').subscribe((data) => (result = data));
|
||||
tick(500);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.id).toBe('db-primary');
|
||||
}));
|
||||
|
||||
it('should return null for non-existent integration', fakeAsync(() => {
|
||||
let result: any;
|
||||
service.getIntegration('non-existent').subscribe((data) => (result = data));
|
||||
tick(500);
|
||||
|
||||
expect(result).toBeNull();
|
||||
}));
|
||||
});
|
||||
|
||||
describe('getIntegrationsByType', () => {
|
||||
it('should return integrations of specified type', fakeAsync(() => {
|
||||
let result: any;
|
||||
service.getIntegrationsByType('database').subscribe((data) => (result = data));
|
||||
tick(500);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.every((i: any) => i.type === 'database')).toBe(true);
|
||||
}));
|
||||
|
||||
it('should return empty array for type with no integrations', fakeAsync(() => {
|
||||
let result: any;
|
||||
service.getIntegrationsByType('telemetry').subscribe((data) => (result = data));
|
||||
tick(500);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
}));
|
||||
});
|
||||
|
||||
describe('getChecks', () => {
|
||||
it('should return checks', fakeAsync(() => {
|
||||
let result: any;
|
||||
service.getChecks().subscribe((data) => (result = data));
|
||||
tick(500);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
}));
|
||||
|
||||
it('should return checks with required properties', fakeAsync(() => {
|
||||
let result: any;
|
||||
service.getChecks().subscribe((data) => (result = data));
|
||||
tick(500);
|
||||
|
||||
const check = result[0];
|
||||
expect(check.checkId).toBeTruthy();
|
||||
expect(check.integrationId).toBeTruthy();
|
||||
expect(check.name).toBeTruthy();
|
||||
expect(check.status).toBeTruthy();
|
||||
expect(check.severity).toBeTruthy();
|
||||
}));
|
||||
});
|
||||
|
||||
describe('getChecksForIntegration', () => {
|
||||
it('should return checks for specific integration', fakeAsync(() => {
|
||||
let result: any;
|
||||
service.getChecksForIntegration('db-primary').subscribe((data) => (result = data));
|
||||
tick(500);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.every((c: any) => c.integrationId === 'db-primary')).toBe(true);
|
||||
}));
|
||||
});
|
||||
|
||||
describe('runCheck', () => {
|
||||
it('should run a check and return result', fakeAsync(() => {
|
||||
let result: any;
|
||||
service.runCheck('check.database.connectivity').subscribe((data) => (result = data));
|
||||
tick(2000);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.status).toBe('passed');
|
||||
expect(result.lastRun).toBeTruthy();
|
||||
}));
|
||||
|
||||
it('should error for non-existent check', fakeAsync(() => {
|
||||
let error: any;
|
||||
service.runCheck('non-existent').subscribe({
|
||||
error: (e) => (error = e),
|
||||
});
|
||||
tick(500);
|
||||
|
||||
expect(error).toBeTruthy();
|
||||
}));
|
||||
});
|
||||
|
||||
describe('runChecksForIntegration', () => {
|
||||
it('should run all checks for integration', fakeAsync(() => {
|
||||
let result: any;
|
||||
service.runChecksForIntegration('db-primary').subscribe((data) => (result = data));
|
||||
tick(2500);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.every((c: any) => c.status === 'passed')).toBe(true);
|
||||
}));
|
||||
});
|
||||
|
||||
describe('testConnection', () => {
|
||||
it('should test connection and return result', fakeAsync(() => {
|
||||
let result: any;
|
||||
service
|
||||
.testConnection({
|
||||
integrationType: 'database',
|
||||
provider: 'postgresql',
|
||||
configValues: { host: 'localhost' },
|
||||
})
|
||||
.subscribe((data) => (result = data));
|
||||
tick(2000);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(typeof result.success).toBe('boolean');
|
||||
expect(result.message).toBeTruthy();
|
||||
}));
|
||||
|
||||
it('should include latency on success', fakeAsync(() => {
|
||||
let result: any;
|
||||
service
|
||||
.testConnection({
|
||||
integrationType: 'database',
|
||||
provider: 'postgresql',
|
||||
configValues: { host: 'localhost' },
|
||||
})
|
||||
.subscribe((data) => (result = data));
|
||||
tick(2000);
|
||||
|
||||
if (result.success) {
|
||||
expect(typeof result.latencyMs).toBe('number');
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
describe('updateConfiguration', () => {
|
||||
it('should update configuration', fakeAsync(() => {
|
||||
let result: any;
|
||||
service
|
||||
.updateConfiguration({
|
||||
integrationId: 'db-primary',
|
||||
configValues: { 'database.host': 'newhost' },
|
||||
})
|
||||
.subscribe((data) => (result = data));
|
||||
tick(1000);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.success).toBe(true);
|
||||
}));
|
||||
|
||||
it('should error for non-existent integration', fakeAsync(() => {
|
||||
let error: any;
|
||||
service
|
||||
.updateConfiguration({
|
||||
integrationId: 'non-existent',
|
||||
configValues: {},
|
||||
})
|
||||
.subscribe({
|
||||
error: (e) => (error = e),
|
||||
});
|
||||
tick(500);
|
||||
|
||||
expect(error).toBeTruthy();
|
||||
}));
|
||||
});
|
||||
|
||||
describe('addIntegration', () => {
|
||||
it('should add new integration', fakeAsync(() => {
|
||||
let result: any;
|
||||
service
|
||||
.addIntegration({
|
||||
type: 'vault',
|
||||
name: 'New Vault',
|
||||
provider: 'hashicorp',
|
||||
configValues: { address: 'https://vault.local' },
|
||||
})
|
||||
.subscribe((data) => (result = data));
|
||||
tick(1000);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.id).toBeTruthy();
|
||||
expect(result.name).toBe('New Vault');
|
||||
expect(result.type).toBe('vault');
|
||||
}));
|
||||
|
||||
it('should set initial status to unknown', fakeAsync(() => {
|
||||
let result: any;
|
||||
service
|
||||
.addIntegration({
|
||||
type: 'vault',
|
||||
name: 'New Vault',
|
||||
provider: 'hashicorp',
|
||||
configValues: {},
|
||||
})
|
||||
.subscribe((data) => (result = data));
|
||||
tick(1000);
|
||||
|
||||
expect(result.status).toBe('unknown');
|
||||
expect(result.healthStatus).toBe('unknown');
|
||||
}));
|
||||
});
|
||||
|
||||
describe('removeIntegration', () => {
|
||||
it('should remove integration', fakeAsync(() => {
|
||||
let result: any;
|
||||
service
|
||||
.removeIntegration({
|
||||
integrationId: 'vault-hashicorp',
|
||||
})
|
||||
.subscribe((data) => (result = data));
|
||||
tick(1000);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.success).toBe(true);
|
||||
}));
|
||||
|
||||
it('should fail for required primary integration without force', fakeAsync(() => {
|
||||
let result: any;
|
||||
service
|
||||
.removeIntegration({
|
||||
integrationId: 'db-primary',
|
||||
})
|
||||
.subscribe((data) => (result = data));
|
||||
tick(500);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
}));
|
||||
|
||||
it('should succeed for required primary with force', fakeAsync(() => {
|
||||
let result: any;
|
||||
service
|
||||
.removeIntegration({
|
||||
integrationId: 'db-primary',
|
||||
force: true,
|
||||
})
|
||||
.subscribe((data) => (result = data));
|
||||
tick(1000);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
}));
|
||||
|
||||
it('should error for non-existent integration', fakeAsync(() => {
|
||||
let error: any;
|
||||
service
|
||||
.removeIntegration({
|
||||
integrationId: 'non-existent',
|
||||
})
|
||||
.subscribe({
|
||||
error: (e) => (error = e),
|
||||
});
|
||||
tick(500);
|
||||
|
||||
expect(error).toBeTruthy();
|
||||
}));
|
||||
});
|
||||
|
||||
describe('getHistory', () => {
|
||||
it('should return history entries', fakeAsync(() => {
|
||||
let result: any;
|
||||
service.getHistory().subscribe((data) => (result = data));
|
||||
tick(500);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
}));
|
||||
|
||||
it('should filter history by integration id', fakeAsync(() => {
|
||||
let result: any;
|
||||
service.getHistory('db-primary').subscribe((data) => (result = data));
|
||||
tick(500);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
if (result.length > 0) {
|
||||
expect(result.every((h: any) => h.integrationId === 'db-primary')).toBe(true);
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
describe('refreshStatus', () => {
|
||||
it('should refresh integration status', fakeAsync(() => {
|
||||
let result: any;
|
||||
service.refreshStatus('db-primary').subscribe((data) => (result = data));
|
||||
tick(1500);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.status).toBeTruthy();
|
||||
expect(result.healthStatus).toBeTruthy();
|
||||
}));
|
||||
});
|
||||
|
||||
describe('exportConfiguration', () => {
|
||||
it('should export configuration as blob', fakeAsync(() => {
|
||||
let result: any;
|
||||
service.exportConfiguration().subscribe((data) => (result = data));
|
||||
tick(500);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result instanceof Blob).toBe(true);
|
||||
expect(result.type).toBe('application/json');
|
||||
}));
|
||||
|
||||
it('should exclude sensitive values from export', fakeAsync(() => {
|
||||
let result: any;
|
||||
service.exportConfiguration().subscribe((data) => (result = data));
|
||||
tick(500);
|
||||
|
||||
const reader = new FileReader();
|
||||
let content: any;
|
||||
reader.onload = () => {
|
||||
content = JSON.parse(reader.result as string);
|
||||
};
|
||||
reader.readAsText(result);
|
||||
tick(100);
|
||||
|
||||
if (content) {
|
||||
const hasPassword = content.integrations?.some((i: any) =>
|
||||
Object.keys(i.configValues || {}).some(
|
||||
(k) => k.includes('password') || k.includes('secret') || k.includes('token')
|
||||
)
|
||||
);
|
||||
expect(hasPassword).toBeFalsy();
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
describe('validateConfiguration', () => {
|
||||
it('should validate database configuration', fakeAsync(() => {
|
||||
let result: any;
|
||||
service
|
||||
.validateConfiguration('database', 'postgresql', {
|
||||
'database.host': 'localhost',
|
||||
'database.port': '5432',
|
||||
'database.name': 'test',
|
||||
})
|
||||
.subscribe((data) => (result = data));
|
||||
tick(500);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
}));
|
||||
|
||||
it('should return errors for invalid database config', fakeAsync(() => {
|
||||
let result: any;
|
||||
service
|
||||
.validateConfiguration('database', 'postgresql', {})
|
||||
.subscribe((data) => (result = data));
|
||||
tick(500);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
}));
|
||||
|
||||
it('should validate cache configuration', fakeAsync(() => {
|
||||
let result: any;
|
||||
service
|
||||
.validateConfiguration('cache', 'redis', {
|
||||
'cache.host': 'localhost',
|
||||
'cache.port': '6379',
|
||||
})
|
||||
.subscribe((data) => (result = data));
|
||||
tick(500);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.valid).toBe(true);
|
||||
}));
|
||||
|
||||
it('should return errors for invalid cache config', fakeAsync(() => {
|
||||
let result: any;
|
||||
service
|
||||
.validateConfiguration('cache', 'redis', {})
|
||||
.subscribe((data) => (result = data));
|
||||
tick(500);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.valid).toBe(false);
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* @file configuration-pane-api.service.ts
|
||||
* @sprint Sprint 6: Configuration Pane
|
||||
* @description API service for Configuration Pane with mock implementations
|
||||
*/
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, of, delay, throwError } from 'rxjs';
|
||||
import {
|
||||
ConfiguredIntegration,
|
||||
ConfigurationCheck,
|
||||
ConfigurationHistoryEntry,
|
||||
TestConnectionRequest,
|
||||
TestConnectionResult,
|
||||
UpdateConfigurationRequest,
|
||||
UpdateConfigurationResult,
|
||||
AddIntegrationRequest,
|
||||
RemoveIntegrationRequest,
|
||||
IntegrationType,
|
||||
} from '../models/configuration-pane.models';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ConfigurationPaneApiService {
|
||||
// Mock data for development
|
||||
private readonly mockIntegrations: ConfiguredIntegration[] = [
|
||||
{
|
||||
id: 'db-primary',
|
||||
type: 'database',
|
||||
name: 'Primary Database',
|
||||
provider: 'postgresql',
|
||||
description: 'Main PostgreSQL database',
|
||||
status: 'connected',
|
||||
healthStatus: 'healthy',
|
||||
lastChecked: new Date().toISOString(),
|
||||
configuredAt: new Date(Date.now() - 86400000 * 7).toISOString(),
|
||||
configuredBy: 'admin@stellaops.local',
|
||||
configValues: {
|
||||
'database.host': 'localhost',
|
||||
'database.port': '5432',
|
||||
'database.name': 'stellaops',
|
||||
'database.user': 'stellaops',
|
||||
'database.ssl': 'true',
|
||||
},
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
id: 'cache-primary',
|
||||
type: 'cache',
|
||||
name: 'Redis Cache',
|
||||
provider: 'redis',
|
||||
description: 'Primary Redis cache',
|
||||
status: 'connected',
|
||||
healthStatus: 'healthy',
|
||||
lastChecked: new Date().toISOString(),
|
||||
configuredAt: new Date(Date.now() - 86400000 * 7).toISOString(),
|
||||
configuredBy: 'admin@stellaops.local',
|
||||
configValues: {
|
||||
'cache.host': 'localhost',
|
||||
'cache.port': '6379',
|
||||
'cache.database': '0',
|
||||
},
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
id: 'vault-hashicorp',
|
||||
type: 'vault',
|
||||
name: 'HashiCorp Vault',
|
||||
provider: 'hashicorp',
|
||||
description: 'Production secrets vault',
|
||||
status: 'connected',
|
||||
healthStatus: 'healthy',
|
||||
lastChecked: new Date().toISOString(),
|
||||
configuredAt: new Date(Date.now() - 86400000 * 3).toISOString(),
|
||||
configuredBy: 'admin@stellaops.local',
|
||||
configValues: {
|
||||
'vault.address': 'https://vault.example.com:8200',
|
||||
'vault.namespace': 'stellaops',
|
||||
'vault.mountPath': 'secret',
|
||||
},
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
id: 'settingsstore-consul',
|
||||
type: 'settingsstore',
|
||||
name: 'Consul KV',
|
||||
provider: 'consul',
|
||||
description: 'Feature flags and configuration',
|
||||
status: 'connected',
|
||||
healthStatus: 'healthy',
|
||||
lastChecked: new Date().toISOString(),
|
||||
configuredAt: new Date(Date.now() - 86400000 * 2).toISOString(),
|
||||
configuredBy: 'admin@stellaops.local',
|
||||
configValues: {
|
||||
'consul.address': 'http://localhost:8500',
|
||||
'consul.prefix': 'stellaops/',
|
||||
},
|
||||
isPrimary: true,
|
||||
},
|
||||
];
|
||||
|
||||
private readonly mockChecks: ConfigurationCheck[] = [
|
||||
{
|
||||
checkId: 'check.database.connectivity',
|
||||
integrationId: 'db-primary',
|
||||
name: 'Database Connectivity',
|
||||
status: 'passed',
|
||||
message: 'Connection established successfully',
|
||||
severity: 'critical',
|
||||
lastRun: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
checkId: 'check.database.migrations',
|
||||
integrationId: 'db-primary',
|
||||
name: 'Database Migrations',
|
||||
status: 'passed',
|
||||
message: 'All migrations applied',
|
||||
severity: 'warning',
|
||||
lastRun: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
checkId: 'check.cache.connectivity',
|
||||
integrationId: 'cache-primary',
|
||||
name: 'Cache Connectivity',
|
||||
status: 'passed',
|
||||
message: 'Redis connection active',
|
||||
severity: 'critical',
|
||||
lastRun: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
checkId: 'check.vault.connectivity',
|
||||
integrationId: 'vault-hashicorp',
|
||||
name: 'Vault Connectivity',
|
||||
status: 'passed',
|
||||
message: 'Vault connection established',
|
||||
severity: 'critical',
|
||||
lastRun: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
checkId: 'check.vault.auth',
|
||||
integrationId: 'vault-hashicorp',
|
||||
name: 'Vault Authentication',
|
||||
status: 'passed',
|
||||
message: 'Token valid',
|
||||
severity: 'critical',
|
||||
lastRun: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
checkId: 'check.consul.connectivity',
|
||||
integrationId: 'settingsstore-consul',
|
||||
name: 'Consul Connectivity',
|
||||
status: 'passed',
|
||||
message: 'Consul connection active',
|
||||
severity: 'critical',
|
||||
lastRun: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
private readonly mockHistory: ConfigurationHistoryEntry[] = [
|
||||
{
|
||||
id: 'hist-1',
|
||||
integrationId: 'db-primary',
|
||||
action: 'updated',
|
||||
timestamp: new Date(Date.now() - 3600000).toISOString(),
|
||||
performedBy: 'admin@stellaops.local',
|
||||
previousValues: { 'database.ssl': 'false' },
|
||||
newValues: { 'database.ssl': 'true' },
|
||||
result: 'success',
|
||||
message: 'Enabled SSL connection',
|
||||
},
|
||||
{
|
||||
id: 'hist-2',
|
||||
integrationId: 'vault-hashicorp',
|
||||
action: 'tested',
|
||||
timestamp: new Date(Date.now() - 7200000).toISOString(),
|
||||
performedBy: 'admin@stellaops.local',
|
||||
result: 'success',
|
||||
message: 'Connection test passed',
|
||||
},
|
||||
{
|
||||
id: 'hist-3',
|
||||
integrationId: 'settingsstore-consul',
|
||||
action: 'created',
|
||||
timestamp: new Date(Date.now() - 86400000 * 2).toISOString(),
|
||||
performedBy: 'admin@stellaops.local',
|
||||
newValues: {
|
||||
'consul.address': 'http://localhost:8500',
|
||||
'consul.prefix': 'stellaops/',
|
||||
},
|
||||
result: 'success',
|
||||
message: 'Initial configuration',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get all configured integrations
|
||||
*/
|
||||
getIntegrations(): Observable<ConfiguredIntegration[]> {
|
||||
return of([...this.mockIntegrations]).pipe(delay(300));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific integration by ID
|
||||
*/
|
||||
getIntegration(integrationId: string): Observable<ConfiguredIntegration | null> {
|
||||
const integration = this.mockIntegrations.find((i) => i.id === integrationId);
|
||||
return of(integration ?? null).pipe(delay(200));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get integrations by type
|
||||
*/
|
||||
getIntegrationsByType(type: IntegrationType): Observable<ConfiguredIntegration[]> {
|
||||
const integrations = this.mockIntegrations.filter((i) => i.type === type);
|
||||
return of(integrations).pipe(delay(200));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get doctor checks for all integrations
|
||||
*/
|
||||
getChecks(): Observable<ConfigurationCheck[]> {
|
||||
return of([...this.mockChecks]).pipe(delay(300));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get doctor checks for a specific integration
|
||||
*/
|
||||
getChecksForIntegration(integrationId: string): Observable<ConfigurationCheck[]> {
|
||||
const checks = this.mockChecks.filter((c) => c.integrationId === integrationId);
|
||||
return of(checks).pipe(delay(200));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a specific check
|
||||
*/
|
||||
runCheck(checkId: string): Observable<ConfigurationCheck> {
|
||||
const check = this.mockChecks.find((c) => c.checkId === checkId);
|
||||
if (!check) {
|
||||
return throwError(() => new Error('Check not found'));
|
||||
}
|
||||
|
||||
// Simulate check execution
|
||||
const result: ConfigurationCheck = {
|
||||
...check,
|
||||
status: 'passed',
|
||||
message: 'Check completed successfully',
|
||||
lastRun: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return of(result).pipe(delay(1500));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all checks for an integration
|
||||
*/
|
||||
runChecksForIntegration(integrationId: string): Observable<ConfigurationCheck[]> {
|
||||
const checks = this.mockChecks
|
||||
.filter((c) => c.integrationId === integrationId)
|
||||
.map((check) => ({
|
||||
...check,
|
||||
status: 'passed' as const,
|
||||
message: 'Check completed successfully',
|
||||
lastRun: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
return of(checks).pipe(delay(2000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection for an integration
|
||||
*/
|
||||
testConnection(request: TestConnectionRequest): Observable<TestConnectionResult> {
|
||||
// Simulate connection test
|
||||
const success = Math.random() > 0.1; // 90% success rate
|
||||
|
||||
const result: TestConnectionResult = success
|
||||
? {
|
||||
success: true,
|
||||
message: 'Connection established successfully',
|
||||
latencyMs: Math.floor(Math.random() * 50) + 10,
|
||||
}
|
||||
: {
|
||||
success: false,
|
||||
message: 'Connection failed: Unable to reach endpoint',
|
||||
};
|
||||
|
||||
return of(result).pipe(delay(1500));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update integration configuration
|
||||
*/
|
||||
updateConfiguration(request: UpdateConfigurationRequest): Observable<UpdateConfigurationResult> {
|
||||
// Simulate update
|
||||
const integration = this.mockIntegrations.find((i) => i.id === request.integrationId);
|
||||
if (!integration) {
|
||||
return throwError(() => new Error('Integration not found'));
|
||||
}
|
||||
|
||||
// Update mock data
|
||||
Object.assign(integration.configValues, request.configValues);
|
||||
|
||||
const result: UpdateConfigurationResult = {
|
||||
success: true,
|
||||
message: 'Configuration updated successfully',
|
||||
requiresRestart: false,
|
||||
};
|
||||
|
||||
return of(result).pipe(delay(800));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new integration
|
||||
*/
|
||||
addIntegration(request: AddIntegrationRequest): Observable<ConfiguredIntegration> {
|
||||
const newIntegration: ConfiguredIntegration = {
|
||||
id: `${request.type}-${Date.now()}`,
|
||||
type: request.type,
|
||||
name: request.name,
|
||||
provider: request.provider,
|
||||
status: 'unknown',
|
||||
healthStatus: 'unknown',
|
||||
configuredAt: new Date().toISOString(),
|
||||
configValues: request.configValues,
|
||||
isPrimary: request.isPrimary,
|
||||
};
|
||||
|
||||
this.mockIntegrations.push(newIntegration);
|
||||
return of(newIntegration).pipe(delay(500));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an integration
|
||||
*/
|
||||
removeIntegration(request: RemoveIntegrationRequest): Observable<{ success: boolean; message: string }> {
|
||||
const index = this.mockIntegrations.findIndex((i) => i.id === request.integrationId);
|
||||
if (index === -1) {
|
||||
return throwError(() => new Error('Integration not found'));
|
||||
}
|
||||
|
||||
const integration = this.mockIntegrations[index];
|
||||
|
||||
// Check if it's a required primary integration
|
||||
if (integration.isPrimary && (integration.type === 'database' || integration.type === 'cache')) {
|
||||
if (!request.force) {
|
||||
return of({
|
||||
success: false,
|
||||
message: 'Cannot remove required primary integration without force flag',
|
||||
}).pipe(delay(300));
|
||||
}
|
||||
}
|
||||
|
||||
this.mockIntegrations.splice(index, 1);
|
||||
return of({
|
||||
success: true,
|
||||
message: 'Integration removed successfully',
|
||||
}).pipe(delay(500));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration history
|
||||
*/
|
||||
getHistory(integrationId?: string): Observable<ConfigurationHistoryEntry[]> {
|
||||
let history = [...this.mockHistory];
|
||||
if (integrationId) {
|
||||
history = history.filter((h) => h.integrationId === integrationId);
|
||||
}
|
||||
return of(history).pipe(delay(300));
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh connection status for an integration
|
||||
*/
|
||||
refreshStatus(integrationId: string): Observable<{ status: string; healthStatus: string }> {
|
||||
return of({
|
||||
status: 'connected',
|
||||
healthStatus: 'healthy',
|
||||
}).pipe(delay(1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Export configuration as JSON
|
||||
*/
|
||||
exportConfiguration(): Observable<Blob> {
|
||||
const config = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
version: '1.0',
|
||||
integrations: this.mockIntegrations.map((i) => ({
|
||||
type: i.type,
|
||||
provider: i.provider,
|
||||
name: i.name,
|
||||
// Exclude sensitive values
|
||||
configValues: Object.fromEntries(
|
||||
Object.entries(i.configValues).filter(
|
||||
([key]) => !key.includes('password') && !key.includes('secret') && !key.includes('token')
|
||||
)
|
||||
),
|
||||
})),
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' });
|
||||
return of(blob).pipe(delay(200));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate configuration values
|
||||
*/
|
||||
validateConfiguration(
|
||||
type: IntegrationType,
|
||||
provider: string,
|
||||
configValues: Record<string, string>
|
||||
): Observable<{ valid: boolean; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Basic validation
|
||||
if (type === 'database') {
|
||||
if (!configValues['database.host']) errors.push('Database host is required');
|
||||
if (!configValues['database.port']) errors.push('Database port is required');
|
||||
if (!configValues['database.name']) errors.push('Database name is required');
|
||||
}
|
||||
|
||||
if (type === 'cache') {
|
||||
if (!configValues['cache.host']) errors.push('Cache host is required');
|
||||
if (!configValues['cache.port']) errors.push('Cache port is required');
|
||||
}
|
||||
|
||||
return of({
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
}).pipe(delay(200));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* @file configuration-pane-state.service.spec.ts
|
||||
* @sprint Sprint 6: Configuration Pane
|
||||
* @description Tests for ConfigurationPaneStateService
|
||||
*/
|
||||
|
||||
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { ConfigurationPaneStateService } from './configuration-pane-state.service';
|
||||
import { ConfiguredIntegration, ConfigurationCheck } from '../models/configuration-pane.models';
|
||||
|
||||
describe('ConfigurationPaneStateService', () => {
|
||||
let service: ConfigurationPaneStateService;
|
||||
|
||||
const mockIntegrations: ConfiguredIntegration[] = [
|
||||
{
|
||||
id: 'db-primary',
|
||||
type: 'database',
|
||||
name: 'Primary Database',
|
||||
provider: 'postgresql',
|
||||
status: 'connected',
|
||||
healthStatus: 'healthy',
|
||||
configuredAt: '2026-01-01T00:00:00Z',
|
||||
configValues: { 'database.host': 'localhost' },
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
id: 'cache-primary',
|
||||
type: 'cache',
|
||||
name: 'Redis Cache',
|
||||
provider: 'redis',
|
||||
status: 'disconnected',
|
||||
healthStatus: 'degraded',
|
||||
configuredAt: '2026-01-01T00:00:00Z',
|
||||
configValues: { 'cache.host': 'localhost' },
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
id: 'vault-hc',
|
||||
type: 'vault',
|
||||
name: 'HashiCorp Vault',
|
||||
provider: 'hashicorp',
|
||||
status: 'error',
|
||||
healthStatus: 'unhealthy',
|
||||
configuredAt: '2026-01-01T00:00:00Z',
|
||||
configValues: { 'vault.address': 'https://vault.local' },
|
||||
isPrimary: false,
|
||||
},
|
||||
];
|
||||
|
||||
const mockChecks: ConfigurationCheck[] = [
|
||||
{
|
||||
checkId: 'check.db.connectivity',
|
||||
integrationId: 'db-primary',
|
||||
name: 'Database Connectivity',
|
||||
status: 'passed',
|
||||
severity: 'critical',
|
||||
lastRun: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
checkId: 'check.db.migrations',
|
||||
integrationId: 'db-primary',
|
||||
name: 'Database Migrations',
|
||||
status: 'warning',
|
||||
severity: 'warning',
|
||||
lastRun: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [ConfigurationPaneStateService],
|
||||
});
|
||||
service = TestBed.inject(ConfigurationPaneStateService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service.reset();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have empty initial state', () => {
|
||||
expect(service.sections()).toEqual([]);
|
||||
expect(service.selectedIntegrationId()).toBeNull();
|
||||
expect(service.loading()).toBe(false);
|
||||
expect(service.error()).toBeNull();
|
||||
});
|
||||
|
||||
it('should initialize sections with integrations', () => {
|
||||
service.initializeSections(mockIntegrations);
|
||||
|
||||
expect(service.sections().length).toBeGreaterThan(0);
|
||||
const dbSection = service.sections().find((s) => s.type === 'database');
|
||||
expect(dbSection?.integrations.length).toBe(1);
|
||||
expect(dbSection?.isConfigured).toBe(true);
|
||||
});
|
||||
|
||||
it('should mark sections as not configured when no integrations', () => {
|
||||
service.initializeSections([]);
|
||||
|
||||
const dbSection = service.sections().find((s) => s.type === 'database');
|
||||
expect(dbSection?.isConfigured).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computed summary', () => {
|
||||
beforeEach(() => {
|
||||
service.initializeSections(mockIntegrations);
|
||||
});
|
||||
|
||||
it('should compute total integrations', () => {
|
||||
expect(service.summary().totalIntegrations).toBe(3);
|
||||
});
|
||||
|
||||
it('should compute healthy integrations', () => {
|
||||
expect(service.summary().healthyIntegrations).toBe(1);
|
||||
});
|
||||
|
||||
it('should compute degraded integrations', () => {
|
||||
expect(service.summary().degradedIntegrations).toBe(1);
|
||||
});
|
||||
|
||||
it('should compute unhealthy integrations', () => {
|
||||
expect(service.summary().unhealthyIntegrations).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selection', () => {
|
||||
beforeEach(() => {
|
||||
service.initializeSections(mockIntegrations);
|
||||
});
|
||||
|
||||
it('should select integration', () => {
|
||||
service.selectIntegration('db-primary');
|
||||
expect(service.selectedIntegrationId()).toBe('db-primary');
|
||||
});
|
||||
|
||||
it('should return selected integration details', () => {
|
||||
service.selectIntegration('db-primary');
|
||||
expect(service.selectedIntegration()?.name).toBe('Primary Database');
|
||||
});
|
||||
|
||||
it('should return selected section', () => {
|
||||
service.selectIntegration('db-primary');
|
||||
expect(service.selectedSection()?.type).toBe('database');
|
||||
});
|
||||
|
||||
it('should return null when no selection', () => {
|
||||
expect(service.selectedIntegration()).toBeNull();
|
||||
expect(service.selectedSection()).toBeNull();
|
||||
});
|
||||
|
||||
it('should deselect integration', () => {
|
||||
service.selectIntegration('db-primary');
|
||||
service.selectIntegration(null);
|
||||
expect(service.selectedIntegrationId()).toBeNull();
|
||||
});
|
||||
|
||||
it('should not switch selection when editing with pending changes', () => {
|
||||
service.selectIntegration('db-primary');
|
||||
service.enterEditMode();
|
||||
service.updatePendingValue('database.host', 'newhost');
|
||||
service.selectIntegration('cache-primary');
|
||||
|
||||
expect(service.selectedIntegrationId()).toBe('db-primary');
|
||||
});
|
||||
|
||||
it('should exit edit mode on selection change', () => {
|
||||
service.selectIntegration('db-primary');
|
||||
service.enterEditMode();
|
||||
service.selectIntegration('db-primary');
|
||||
|
||||
expect(service.editMode()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filtering', () => {
|
||||
beforeEach(() => {
|
||||
service.initializeSections(mockIntegrations);
|
||||
});
|
||||
|
||||
it('should filter by type', () => {
|
||||
service.setFilterType('database');
|
||||
expect(service.filteredSections().length).toBe(1);
|
||||
expect(service.filteredSections()[0].type).toBe('database');
|
||||
});
|
||||
|
||||
it('should filter by status', () => {
|
||||
service.setFilterStatus('connected');
|
||||
const sections = service.filteredSections();
|
||||
const allConnected = sections.every((s) =>
|
||||
s.integrations.every((i) => i.status === 'connected')
|
||||
);
|
||||
expect(allConnected).toBe(true);
|
||||
});
|
||||
|
||||
it('should combine type and status filters', () => {
|
||||
service.setFilterType('database');
|
||||
service.setFilterStatus('connected');
|
||||
|
||||
const sections = service.filteredSections();
|
||||
expect(sections.length).toBe(1);
|
||||
expect(sections[0].integrations[0].status).toBe('connected');
|
||||
});
|
||||
|
||||
it('should clear filters', () => {
|
||||
service.setFilterType('database');
|
||||
service.setFilterStatus('connected');
|
||||
service.clearFilters();
|
||||
|
||||
expect(service.filterType()).toBe('all');
|
||||
expect(service.filterStatus()).toBe('all');
|
||||
});
|
||||
|
||||
it('should return all sections when filter is "all"', () => {
|
||||
service.setFilterType('all');
|
||||
service.setFilterStatus('all');
|
||||
|
||||
expect(service.filteredSections().length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit mode', () => {
|
||||
beforeEach(() => {
|
||||
service.initializeSections(mockIntegrations);
|
||||
service.selectIntegration('db-primary');
|
||||
});
|
||||
|
||||
it('should enter edit mode', () => {
|
||||
service.enterEditMode();
|
||||
expect(service.editMode()).toBe(true);
|
||||
});
|
||||
|
||||
it('should populate pending changes on enter', () => {
|
||||
service.enterEditMode();
|
||||
expect(service.pendingChanges()['database.host']).toBe('localhost');
|
||||
});
|
||||
|
||||
it('should exit edit mode', () => {
|
||||
service.enterEditMode();
|
||||
service.exitEditMode();
|
||||
expect(service.editMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('should clear pending changes on exit', () => {
|
||||
service.enterEditMode();
|
||||
service.updatePendingValue('database.host', 'newhost');
|
||||
service.exitEditMode();
|
||||
|
||||
expect(Object.keys(service.pendingChanges()).length).toBe(0);
|
||||
});
|
||||
|
||||
it('should update pending value', () => {
|
||||
service.enterEditMode();
|
||||
service.updatePendingValue('database.host', 'newhost');
|
||||
expect(service.pendingChanges()['database.host']).toBe('newhost');
|
||||
});
|
||||
|
||||
it('should detect pending changes', () => {
|
||||
service.enterEditMode();
|
||||
expect(service.hasPendingChanges()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not enter edit mode without selection', () => {
|
||||
service.selectIntegration(null);
|
||||
service.enterEditMode();
|
||||
expect(service.editMode()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration updates', () => {
|
||||
beforeEach(() => {
|
||||
service.initializeSections(mockIntegrations);
|
||||
});
|
||||
|
||||
it('should update integration status', () => {
|
||||
service.updateIntegrationStatus('db-primary', 'checking');
|
||||
const integration = service.allIntegrations().find((i) => i.id === 'db-primary');
|
||||
expect(integration?.status).toBe('checking');
|
||||
});
|
||||
|
||||
it('should update integration properties', () => {
|
||||
service.updateIntegration('db-primary', {
|
||||
name: 'Updated Database',
|
||||
healthStatus: 'degraded',
|
||||
});
|
||||
|
||||
const integration = service.allIntegrations().find((i) => i.id === 'db-primary');
|
||||
expect(integration?.name).toBe('Updated Database');
|
||||
expect(integration?.healthStatus).toBe('degraded');
|
||||
});
|
||||
|
||||
it('should add new integration', () => {
|
||||
const newIntegration: ConfiguredIntegration = {
|
||||
id: 'vault-new',
|
||||
type: 'vault',
|
||||
name: 'New Vault',
|
||||
provider: 'hashicorp',
|
||||
status: 'unknown',
|
||||
healthStatus: 'unknown',
|
||||
configuredAt: new Date().toISOString(),
|
||||
configValues: {},
|
||||
isPrimary: false,
|
||||
};
|
||||
|
||||
service.addIntegration(newIntegration);
|
||||
const vaultSection = service.sections().find((s) => s.type === 'vault');
|
||||
expect(vaultSection?.integrations.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should remove integration', () => {
|
||||
service.removeIntegration('db-primary');
|
||||
const dbSection = service.sections().find((s) => s.type === 'database');
|
||||
expect(dbSection?.integrations.length).toBe(0);
|
||||
expect(dbSection?.isConfigured).toBe(false);
|
||||
});
|
||||
|
||||
it('should clear selection when removing selected integration', () => {
|
||||
service.selectIntegration('db-primary');
|
||||
service.removeIntegration('db-primary');
|
||||
expect(service.selectedIntegrationId()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('checks', () => {
|
||||
beforeEach(() => {
|
||||
service.initializeSections(mockIntegrations);
|
||||
service.setChecks(mockChecks);
|
||||
});
|
||||
|
||||
it('should set checks', () => {
|
||||
expect(service.checks().length).toBe(2);
|
||||
});
|
||||
|
||||
it('should get checks for selected integration', () => {
|
||||
service.selectIntegration('db-primary');
|
||||
expect(service.selectedIntegrationChecks().length).toBe(2);
|
||||
});
|
||||
|
||||
it('should return empty checks when no selection', () => {
|
||||
expect(service.selectedIntegrationChecks().length).toBe(0);
|
||||
});
|
||||
|
||||
it('should update check', () => {
|
||||
service.updateCheck('check.db.connectivity', { status: 'failed', message: 'Connection lost' });
|
||||
|
||||
const check = service.checks().find((c) => c.checkId === 'check.db.connectivity');
|
||||
expect(check?.status).toBe('failed');
|
||||
expect(check?.message).toBe('Connection lost');
|
||||
});
|
||||
|
||||
it('should start check', () => {
|
||||
service.startCheck('check.db.connectivity');
|
||||
|
||||
expect(service.runningCheckIds().has('check.db.connectivity')).toBe(true);
|
||||
const check = service.checks().find((c) => c.checkId === 'check.db.connectivity');
|
||||
expect(check?.status).toBe('running');
|
||||
});
|
||||
|
||||
it('should complete check', () => {
|
||||
service.startCheck('check.db.connectivity');
|
||||
service.completeCheck('check.db.connectivity', 'passed', 'Success');
|
||||
|
||||
expect(service.runningCheckIds().has('check.db.connectivity')).toBe(false);
|
||||
const check = service.checks().find((c) => c.checkId === 'check.db.connectivity');
|
||||
expect(check?.status).toBe('passed');
|
||||
expect(check?.message).toBe('Success');
|
||||
expect(check?.lastRun).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('history', () => {
|
||||
it('should set history', () => {
|
||||
const history = [
|
||||
{ id: 'h1', integrationId: 'db-primary', action: 'updated' as const, timestamp: '', result: 'success' as const },
|
||||
];
|
||||
service.setHistory(history);
|
||||
expect(service.history().length).toBe(1);
|
||||
});
|
||||
|
||||
it('should add history entry', () => {
|
||||
const entry = { id: 'h1', integrationId: 'db-primary', action: 'updated' as const, timestamp: '', result: 'success' as const };
|
||||
service.addHistoryEntry(entry);
|
||||
expect(service.history()[0].id).toBe('h1');
|
||||
});
|
||||
|
||||
it('should prepend new history entries', () => {
|
||||
service.addHistoryEntry({ id: 'h1', integrationId: 'db-primary', action: 'updated' as const, timestamp: '', result: 'success' as const });
|
||||
service.addHistoryEntry({ id: 'h2', integrationId: 'db-primary', action: 'tested' as const, timestamp: '', result: 'success' as const });
|
||||
|
||||
expect(service.history()[0].id).toBe('h2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('messages', () => {
|
||||
it('should show success message', fakeAsync(() => {
|
||||
service.showSuccess('Test success');
|
||||
expect(service.successMessage()).toBe('Test success');
|
||||
|
||||
tick(5000);
|
||||
expect(service.successMessage()).toBeNull();
|
||||
}));
|
||||
|
||||
it('should show error message', () => {
|
||||
service.showError('Test error');
|
||||
expect(service.error()).toBe('Test error');
|
||||
});
|
||||
|
||||
it('should clear error message', () => {
|
||||
service.showError('Test error');
|
||||
service.clearError();
|
||||
expect(service.error()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('computed properties', () => {
|
||||
it('should detect hasConfigurations', () => {
|
||||
service.initializeSections([]);
|
||||
expect(service.hasConfigurations()).toBe(false);
|
||||
|
||||
service.initializeSections(mockIntegrations);
|
||||
expect(service.hasConfigurations()).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect missing required sections', () => {
|
||||
service.initializeSections([]);
|
||||
expect(service.missingRequiredSections().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return all integrations flat list', () => {
|
||||
service.initializeSections(mockIntegrations);
|
||||
expect(service.allIntegrations().length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should reset all state', () => {
|
||||
service.initializeSections(mockIntegrations);
|
||||
service.selectIntegration('db-primary');
|
||||
service.enterEditMode();
|
||||
service.setChecks(mockChecks);
|
||||
service.showError('error');
|
||||
service.loading.set(true);
|
||||
|
||||
service.reset();
|
||||
|
||||
expect(service.sections()).toEqual([]);
|
||||
expect(service.selectedIntegrationId()).toBeNull();
|
||||
expect(service.loading()).toBe(false);
|
||||
expect(service.editMode()).toBe(false);
|
||||
expect(service.checks()).toEqual([]);
|
||||
expect(service.error()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* @file configuration-pane-state.service.ts
|
||||
* @sprint Sprint 6: Configuration Pane
|
||||
* @description Signal-based state management for Configuration Pane
|
||||
*/
|
||||
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
import {
|
||||
ConfigurationSection,
|
||||
ConfigurationSummary,
|
||||
ConfiguredIntegration,
|
||||
ConfigurationCheck,
|
||||
ConfigurationHistoryEntry,
|
||||
IntegrationType,
|
||||
ConnectionStatus,
|
||||
DEFAULT_SECTIONS,
|
||||
} from '../models/configuration-pane.models';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ConfigurationPaneStateService {
|
||||
// Core state signals
|
||||
readonly sections = signal<ConfigurationSection[]>([]);
|
||||
readonly selectedIntegrationId = signal<string | null>(null);
|
||||
readonly loading = signal(false);
|
||||
readonly saving = signal(false);
|
||||
readonly testing = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly successMessage = signal<string | null>(null);
|
||||
|
||||
// Doctor checks state
|
||||
readonly checks = signal<ConfigurationCheck[]>([]);
|
||||
readonly runningCheckIds = signal<Set<string>>(new Set());
|
||||
|
||||
// History state
|
||||
readonly history = signal<ConfigurationHistoryEntry[]>([]);
|
||||
readonly historyLoading = signal(false);
|
||||
|
||||
// Edit mode state
|
||||
readonly editMode = signal(false);
|
||||
readonly pendingChanges = signal<Record<string, string>>({});
|
||||
|
||||
// Filter state
|
||||
readonly filterType = signal<IntegrationType | 'all'>('all');
|
||||
readonly filterStatus = signal<ConnectionStatus | 'all'>('all');
|
||||
|
||||
// Computed: Summary statistics
|
||||
readonly summary = computed<ConfigurationSummary>(() => {
|
||||
const allSections = this.sections();
|
||||
const allIntegrations = allSections.flatMap((s) => s.integrations);
|
||||
|
||||
return {
|
||||
totalIntegrations: allIntegrations.length,
|
||||
healthyIntegrations: allIntegrations.filter((i) => i.healthStatus === 'healthy').length,
|
||||
degradedIntegrations: allIntegrations.filter((i) => i.healthStatus === 'degraded').length,
|
||||
unhealthyIntegrations: allIntegrations.filter((i) => i.healthStatus === 'unhealthy').length,
|
||||
sections: allSections,
|
||||
};
|
||||
});
|
||||
|
||||
// Computed: Selected integration details
|
||||
readonly selectedIntegration = computed<ConfiguredIntegration | null>(() => {
|
||||
const id = this.selectedIntegrationId();
|
||||
if (!id) return null;
|
||||
|
||||
for (const section of this.sections()) {
|
||||
const integration = section.integrations.find((i) => i.id === id);
|
||||
if (integration) return integration;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Computed: Selected section (based on selected integration)
|
||||
readonly selectedSection = computed<ConfigurationSection | null>(() => {
|
||||
const integration = this.selectedIntegration();
|
||||
if (!integration) return null;
|
||||
|
||||
return this.sections().find((s) => s.type === integration.type) ?? null;
|
||||
});
|
||||
|
||||
// Computed: Filtered sections
|
||||
readonly filteredSections = computed<ConfigurationSection[]>(() => {
|
||||
let sections = this.sections();
|
||||
const typeFilter = this.filterType();
|
||||
const statusFilter = this.filterStatus();
|
||||
|
||||
if (typeFilter !== 'all') {
|
||||
sections = sections.filter((s) => s.type === typeFilter);
|
||||
}
|
||||
|
||||
if (statusFilter !== 'all') {
|
||||
sections = sections
|
||||
.map((s) => ({
|
||||
...s,
|
||||
integrations: s.integrations.filter((i) => i.status === statusFilter),
|
||||
}))
|
||||
.filter((s) => s.integrations.length > 0);
|
||||
}
|
||||
|
||||
return sections;
|
||||
});
|
||||
|
||||
// Computed: Has any configured integrations
|
||||
readonly hasConfigurations = computed(() => {
|
||||
return this.sections().some((s) => s.integrations.length > 0);
|
||||
});
|
||||
|
||||
// Computed: Required sections that are not configured
|
||||
readonly missingRequiredSections = computed(() => {
|
||||
return this.sections().filter((s) => s.isRequired && !s.isConfigured);
|
||||
});
|
||||
|
||||
// Computed: All configured integrations flat list
|
||||
readonly allIntegrations = computed<ConfiguredIntegration[]>(() => {
|
||||
return this.sections().flatMap((s) => s.integrations);
|
||||
});
|
||||
|
||||
// Computed: Checks for selected integration
|
||||
readonly selectedIntegrationChecks = computed<ConfigurationCheck[]>(() => {
|
||||
const integrationId = this.selectedIntegrationId();
|
||||
if (!integrationId) return [];
|
||||
return this.checks().filter((c) => c.integrationId === integrationId);
|
||||
});
|
||||
|
||||
// Computed: Has pending changes
|
||||
readonly hasPendingChanges = computed(() => {
|
||||
return Object.keys(this.pendingChanges()).length > 0;
|
||||
});
|
||||
|
||||
/**
|
||||
* Initialize sections with default definitions
|
||||
*/
|
||||
initializeSections(integrations: ConfiguredIntegration[]): void {
|
||||
const sections: ConfigurationSection[] = DEFAULT_SECTIONS.map((def) => {
|
||||
const sectionIntegrations = integrations.filter((i) => i.type === def.type);
|
||||
return {
|
||||
...def,
|
||||
integrations: sectionIntegrations,
|
||||
isConfigured: sectionIntegrations.length > 0,
|
||||
};
|
||||
});
|
||||
|
||||
this.sections.set(sections);
|
||||
this.error.set(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select an integration for viewing/editing
|
||||
*/
|
||||
selectIntegration(integrationId: string | null): void {
|
||||
if (this.editMode() && this.hasPendingChanges()) {
|
||||
// Don't allow switching while editing with pending changes
|
||||
return;
|
||||
}
|
||||
this.selectedIntegrationId.set(integrationId);
|
||||
this.editMode.set(false);
|
||||
this.pendingChanges.set({});
|
||||
this.error.set(null);
|
||||
this.successMessage.set(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter edit mode for the selected integration
|
||||
*/
|
||||
enterEditMode(): void {
|
||||
const integration = this.selectedIntegration();
|
||||
if (!integration) return;
|
||||
|
||||
this.editMode.set(true);
|
||||
this.pendingChanges.set({ ...integration.configValues });
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit edit mode and discard changes
|
||||
*/
|
||||
exitEditMode(): void {
|
||||
this.editMode.set(false);
|
||||
this.pendingChanges.set({});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a pending change value
|
||||
*/
|
||||
updatePendingValue(key: string, value: string): void {
|
||||
this.pendingChanges.update((changes) => ({
|
||||
...changes,
|
||||
[key]: value,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update integration status
|
||||
*/
|
||||
updateIntegrationStatus(integrationId: string, status: ConnectionStatus): void {
|
||||
this.sections.update((sections) =>
|
||||
sections.map((section) => ({
|
||||
...section,
|
||||
integrations: section.integrations.map((integration) =>
|
||||
integration.id === integrationId ? { ...integration, status } : integration
|
||||
),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update integration after successful save
|
||||
*/
|
||||
updateIntegration(integrationId: string, updates: Partial<ConfiguredIntegration>): void {
|
||||
this.sections.update((sections) =>
|
||||
sections.map((section) => ({
|
||||
...section,
|
||||
integrations: section.integrations.map((integration) =>
|
||||
integration.id === integrationId ? { ...integration, ...updates } : integration
|
||||
),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new integration
|
||||
*/
|
||||
addIntegration(integration: ConfiguredIntegration): void {
|
||||
this.sections.update((sections) =>
|
||||
sections.map((section) => {
|
||||
if (section.type !== integration.type) return section;
|
||||
return {
|
||||
...section,
|
||||
integrations: [...section.integrations, integration],
|
||||
isConfigured: true,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an integration
|
||||
*/
|
||||
removeIntegration(integrationId: string): void {
|
||||
this.sections.update((sections) =>
|
||||
sections.map((section) => {
|
||||
const filteredIntegrations = section.integrations.filter((i) => i.id !== integrationId);
|
||||
return {
|
||||
...section,
|
||||
integrations: filteredIntegrations,
|
||||
isConfigured: filteredIntegrations.length > 0,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Clear selection if removed integration was selected
|
||||
if (this.selectedIntegrationId() === integrationId) {
|
||||
this.selectIntegration(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set doctor checks for integrations
|
||||
*/
|
||||
setChecks(checks: ConfigurationCheck[]): void {
|
||||
this.checks.set(checks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific check
|
||||
*/
|
||||
updateCheck(checkId: string, updates: Partial<ConfigurationCheck>): void {
|
||||
this.checks.update((checks) =>
|
||||
checks.map((check) => (check.checkId === checkId ? { ...check, ...updates } : check))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a check as running
|
||||
*/
|
||||
startCheck(checkId: string): void {
|
||||
this.runningCheckIds.update((ids) => {
|
||||
const newIds = new Set(ids);
|
||||
newIds.add(checkId);
|
||||
return newIds;
|
||||
});
|
||||
this.updateCheck(checkId, { status: 'running' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a check as complete
|
||||
*/
|
||||
completeCheck(checkId: string, status: 'passed' | 'failed' | 'warning', message?: string): void {
|
||||
this.runningCheckIds.update((ids) => {
|
||||
const newIds = new Set(ids);
|
||||
newIds.delete(checkId);
|
||||
return newIds;
|
||||
});
|
||||
this.updateCheck(checkId, {
|
||||
status,
|
||||
message,
|
||||
lastRun: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set history entries
|
||||
*/
|
||||
setHistory(history: ConfigurationHistoryEntry[]): void {
|
||||
this.history.set(history);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a history entry
|
||||
*/
|
||||
addHistoryEntry(entry: ConfigurationHistoryEntry): void {
|
||||
this.history.update((entries) => [entry, ...entries]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set filter by type
|
||||
*/
|
||||
setFilterType(type: IntegrationType | 'all'): void {
|
||||
this.filterType.set(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set filter by status
|
||||
*/
|
||||
setFilterStatus(status: ConnectionStatus | 'all'): void {
|
||||
this.filterStatus.set(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all filters
|
||||
*/
|
||||
clearFilters(): void {
|
||||
this.filterType.set('all');
|
||||
this.filterStatus.set('all');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show success message temporarily
|
||||
*/
|
||||
showSuccess(message: string): void {
|
||||
this.successMessage.set(message);
|
||||
setTimeout(() => {
|
||||
this.successMessage.set(null);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error message
|
||||
*/
|
||||
showError(message: string): void {
|
||||
this.error.set(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear error message
|
||||
*/
|
||||
clearError(): void {
|
||||
this.error.set(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all state
|
||||
*/
|
||||
reset(): void {
|
||||
this.sections.set([]);
|
||||
this.selectedIntegrationId.set(null);
|
||||
this.loading.set(false);
|
||||
this.saving.set(false);
|
||||
this.testing.set(false);
|
||||
this.error.set(null);
|
||||
this.successMessage.set(null);
|
||||
this.checks.set([]);
|
||||
this.runningCheckIds.set(new Set());
|
||||
this.history.set([]);
|
||||
this.historyLoading.set(false);
|
||||
this.editMode.set(false);
|
||||
this.pendingChanges.set({});
|
||||
this.filterType.set('all');
|
||||
this.filterStatus.set('all');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* @file setup-wizard.component.spec.ts
|
||||
* @sprint Sprint 5: UI Integrations + Settings Store
|
||||
* @description Unit tests for SetupWizardComponent
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { SetupWizardComponent } from './setup-wizard.component';
|
||||
import { SetupWizardStateService } from '../services/setup-wizard-state.service';
|
||||
import { SetupWizardApiService } from '../services/setup-wizard-api.service';
|
||||
import { SetupSession } from '../models/setup-wizard.models';
|
||||
|
||||
describe('SetupWizardComponent', () => {
|
||||
let component: SetupWizardComponent;
|
||||
let fixture: ComponentFixture<SetupWizardComponent>;
|
||||
let stateService: SetupWizardStateService;
|
||||
let apiService: jasmine.SpyObj<SetupWizardApiService>;
|
||||
let router: Router;
|
||||
|
||||
const mockSession: SetupSession = {
|
||||
sessionId: 'test-session-123',
|
||||
startedAt: new Date().toISOString(),
|
||||
completedSteps: [],
|
||||
skippedSteps: [],
|
||||
configValues: {},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const apiSpy = jasmine.createSpyObj('SetupWizardApiService', [
|
||||
'createSession',
|
||||
'executeStep',
|
||||
'skipStep',
|
||||
'runValidationChecks',
|
||||
'testConnection',
|
||||
'finalizeSetup',
|
||||
]);
|
||||
|
||||
apiSpy.createSession.and.returnValue(of(mockSession));
|
||||
apiSpy.runValidationChecks.and.returnValue(of([]));
|
||||
apiSpy.executeStep.and.returnValue(of({
|
||||
stepId: 'database',
|
||||
status: 'completed',
|
||||
message: 'Success',
|
||||
canRetry: true,
|
||||
}));
|
||||
apiSpy.skipStep.and.returnValue(of({
|
||||
stepId: 'vault',
|
||||
status: 'skipped',
|
||||
message: 'Skipped',
|
||||
canRetry: false,
|
||||
}));
|
||||
apiSpy.testConnection.and.returnValue(of({ success: true, message: 'OK' }));
|
||||
apiSpy.finalizeSetup.and.returnValue(of({ success: true, message: 'Done' }));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SetupWizardComponent],
|
||||
providers: [
|
||||
SetupWizardStateService,
|
||||
{ provide: SetupWizardApiService, useValue: apiSpy },
|
||||
provideRouter([]),
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SetupWizardComponent);
|
||||
component = fixture.componentInstance;
|
||||
stateService = TestBed.inject(SetupWizardStateService);
|
||||
apiService = TestBed.inject(SetupWizardApiService) as jasmine.SpyObj<SetupWizardApiService>;
|
||||
router = TestBed.inject(Router);
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize wizard on ngOnInit', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(apiService.createSession).toHaveBeenCalled();
|
||||
expect(stateService.session()).toEqual(mockSession);
|
||||
expect(stateService.loading()).toBeFalse();
|
||||
}));
|
||||
|
||||
it('should set error on initialization failure', fakeAsync(() => {
|
||||
apiService.createSession.and.returnValue(throwError(() => new Error('Failed')));
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(stateService.error()).toBe('Failed to initialize setup wizard');
|
||||
}));
|
||||
|
||||
it('should set current step to first step', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(stateService.currentStepId()).toBe('database');
|
||||
}));
|
||||
});
|
||||
|
||||
describe('navigation', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
}));
|
||||
|
||||
it('should go to previous step', () => {
|
||||
stateService.currentStepId.set('cache');
|
||||
component.onPrevious();
|
||||
expect(stateService.currentStepId()).toBe('database');
|
||||
});
|
||||
|
||||
it('should go to next step when current is completed', () => {
|
||||
stateService.updateStepStatus('database', 'completed');
|
||||
component.onNext();
|
||||
expect(stateService.currentStepId()).toBe('cache');
|
||||
});
|
||||
|
||||
it('should navigate to selected step', () => {
|
||||
stateService.updateStepStatus('database', 'completed');
|
||||
component.onStepSelected('cache');
|
||||
expect(stateService.currentStepId()).toBe('cache');
|
||||
});
|
||||
});
|
||||
|
||||
describe('step execution', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
}));
|
||||
|
||||
it('should execute current step', fakeAsync(() => {
|
||||
component.onExecuteStep();
|
||||
tick();
|
||||
|
||||
expect(apiService.executeStep).toHaveBeenCalled();
|
||||
expect(stateService.executing()).toBeFalse();
|
||||
}));
|
||||
|
||||
it('should mark step as completed on success', fakeAsync(() => {
|
||||
component.onExecuteStep();
|
||||
tick();
|
||||
|
||||
const step = stateService.steps().find(s => s.id === 'database');
|
||||
expect(step?.status).toBe('completed');
|
||||
}));
|
||||
|
||||
it('should mark step as failed on error', fakeAsync(() => {
|
||||
apiService.executeStep.and.returnValue(of({
|
||||
stepId: 'database',
|
||||
status: 'failed',
|
||||
message: 'Failed',
|
||||
error: 'Connection error',
|
||||
canRetry: true,
|
||||
}));
|
||||
|
||||
component.onExecuteStep();
|
||||
tick();
|
||||
|
||||
const step = stateService.steps().find(s => s.id === 'database');
|
||||
expect(step?.status).toBe('failed');
|
||||
}));
|
||||
});
|
||||
|
||||
describe('step skip', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
stateService.currentStepId.set('vault');
|
||||
}));
|
||||
|
||||
it('should skip current step', fakeAsync(() => {
|
||||
component.onSkipStep();
|
||||
tick();
|
||||
|
||||
expect(apiService.skipStep).toHaveBeenCalled();
|
||||
const step = stateService.steps().find(s => s.id === 'vault');
|
||||
expect(step?.status).toBe('skipped');
|
||||
}));
|
||||
});
|
||||
|
||||
describe('test connection', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
}));
|
||||
|
||||
it('should test connection', fakeAsync(() => {
|
||||
component.onTestConnection();
|
||||
tick();
|
||||
|
||||
expect(apiService.testConnection).toHaveBeenCalled();
|
||||
expect(stateService.executing()).toBeFalse();
|
||||
}));
|
||||
|
||||
it('should set error on failed connection', fakeAsync(() => {
|
||||
apiService.testConnection.and.returnValue(of({ success: false, message: 'Failed' }));
|
||||
component.onTestConnection();
|
||||
tick();
|
||||
|
||||
expect(stateService.error()).toBe('Failed');
|
||||
}));
|
||||
});
|
||||
|
||||
describe('config changes', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
}));
|
||||
|
||||
it('should update config value', () => {
|
||||
component.onConfigChange({ key: 'database.host', value: 'localhost' });
|
||||
expect(stateService.configValues()['database.host']).toBe('localhost');
|
||||
});
|
||||
});
|
||||
|
||||
describe('completion', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
// Complete required steps
|
||||
stateService.updateStepStatus('database', 'completed');
|
||||
stateService.updateStepStatus('cache', 'completed');
|
||||
}));
|
||||
|
||||
it('should finalize setup on complete', fakeAsync(() => {
|
||||
spyOn(router, 'navigate');
|
||||
component.onComplete();
|
||||
tick();
|
||||
|
||||
expect(apiService.finalizeSetup).toHaveBeenCalled();
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/']);
|
||||
}));
|
||||
|
||||
it('should show error on finalize failure', fakeAsync(() => {
|
||||
apiService.finalizeSetup.and.returnValue(of({ success: false, message: 'Failed' }));
|
||||
component.onComplete();
|
||||
tick();
|
||||
|
||||
expect(stateService.error()).toBe('Failed');
|
||||
}));
|
||||
});
|
||||
|
||||
describe('cancel', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
}));
|
||||
|
||||
it('should reset and navigate on cancel', () => {
|
||||
spyOn(window, 'confirm').and.returnValue(true);
|
||||
spyOn(router, 'navigate');
|
||||
|
||||
component.onCancel();
|
||||
|
||||
expect(stateService.session()).toBeNull();
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/']);
|
||||
});
|
||||
|
||||
it('should not cancel if user declines', () => {
|
||||
spyOn(window, 'confirm').and.returnValue(false);
|
||||
spyOn(router, 'navigate');
|
||||
|
||||
component.onCancel();
|
||||
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dry run toggle', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
}));
|
||||
|
||||
it('should toggle dry run mode', () => {
|
||||
expect(stateService.dryRunMode()).toBeTrue();
|
||||
component.toggleDryRun();
|
||||
expect(stateService.dryRunMode()).toBeFalse();
|
||||
component.toggleDryRun();
|
||||
expect(stateService.dryRunMode()).toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
}));
|
||||
|
||||
it('should dismiss error', () => {
|
||||
stateService.error.set('Some error');
|
||||
component.dismissError();
|
||||
expect(stateService.error()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('computed values', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
}));
|
||||
|
||||
it('should compute completedStepIds', () => {
|
||||
stateService.updateStepStatus('database', 'completed');
|
||||
expect(component.completedStepIds()).toContain('database');
|
||||
});
|
||||
|
||||
it('should compute skippedStepIds', () => {
|
||||
stateService.updateStepStatus('vault', 'skipped');
|
||||
expect(component.skippedStepIds()).toContain('vault');
|
||||
});
|
||||
|
||||
it('should compute isLastStep', () => {
|
||||
expect(component.isLastStep()).toBeFalse();
|
||||
stateService.currentStepId.set('telemetry');
|
||||
expect(component.isLastStep()).toBeTrue();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,585 @@
|
||||
/**
|
||||
* @file setup-wizard.component.ts
|
||||
* @sprint Sprint 4: UI Wizard Core
|
||||
* @description Main setup wizard container component
|
||||
*/
|
||||
|
||||
import {
|
||||
Component,
|
||||
OnInit,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
import { SetupWizardStateService } from '../services/setup-wizard-state.service';
|
||||
import { SetupWizardApiService } from '../services/setup-wizard-api.service';
|
||||
import { StepIndicatorComponent } from './step-indicator.component';
|
||||
import { StepContentComponent } from './step-content.component';
|
||||
import {
|
||||
SetupStep,
|
||||
SetupStepId,
|
||||
ExecuteStepRequest,
|
||||
} from '../models/setup-wizard.models';
|
||||
|
||||
/**
|
||||
* Main setup wizard component.
|
||||
* Orchestrates the multi-step setup process.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-setup-wizard',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
StepIndicatorComponent,
|
||||
StepContentComponent,
|
||||
],
|
||||
providers: [SetupWizardStateService, SetupWizardApiService],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="wizard-container">
|
||||
<!-- Header -->
|
||||
<header class="wizard-header">
|
||||
<h1>{{ isReconfigureMode() ? 'Reconfigure' : 'Setup' }} StellaOps</h1>
|
||||
<p class="subtitle">
|
||||
Configure your StellaOps installation step by step.
|
||||
Required steps are marked with an asterisk (*).
|
||||
</p>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar" [style.width.%]="state.progressPercent()"></div>
|
||||
<span class="progress-text">{{ state.progressPercent() }}% complete</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="wizard-body">
|
||||
<!-- Step Navigation (Left Sidebar) -->
|
||||
<nav class="step-sidebar" aria-label="Setup steps">
|
||||
<app-step-indicator
|
||||
[steps]="state.orderedSteps()"
|
||||
[currentStepId]="state.currentStepId()"
|
||||
[completedStepIds]="completedStepIds()"
|
||||
[skippedStepIds]="skippedStepIds()"
|
||||
(stepSelected)="onStepSelected($event)"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<!-- Step Content (Main Area) -->
|
||||
<main class="step-main">
|
||||
@if (state.loading()) {
|
||||
<div class="loading-overlay">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
} @else if (state.currentStep()) {
|
||||
<app-step-content
|
||||
[step]="state.currentStep()!"
|
||||
[configValues]="state.configValues()"
|
||||
[validationChecks]="state.validationChecks()"
|
||||
[executing]="state.executing()"
|
||||
[dryRunMode]="state.dryRunMode()"
|
||||
(configChange)="onConfigChange($event)"
|
||||
(executeStep)="onExecuteStep()"
|
||||
(skipStep)="onSkipStep()"
|
||||
(testConnection)="onTestConnection()"
|
||||
/>
|
||||
} @else {
|
||||
<div class="no-step-selected">
|
||||
<p>Select a step from the sidebar to begin configuration.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Error Display -->
|
||||
@if (state.error()) {
|
||||
<div class="error-banner" role="alert">
|
||||
<span class="error-icon">!</span>
|
||||
<span class="error-text">{{ state.error() }}</span>
|
||||
<button class="dismiss-btn" (click)="dismissError()">Dismiss</button>
|
||||
</div>
|
||||
}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Footer Actions -->
|
||||
<footer class="wizard-footer">
|
||||
<div class="footer-left">
|
||||
<button
|
||||
class="btn btn-outline"
|
||||
(click)="onCancel()"
|
||||
[disabled]="state.executing()">
|
||||
Cancel
|
||||
</button>
|
||||
<label class="dry-run-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="state.dryRunMode()"
|
||||
(change)="toggleDryRun()"
|
||||
[disabled]="state.executing()"
|
||||
/>
|
||||
<span>Dry Run Mode</span>
|
||||
<span class="help-icon" title="When enabled, steps will validate without making changes">?</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="footer-right">
|
||||
<button
|
||||
class="btn"
|
||||
(click)="onPrevious()"
|
||||
[disabled]="!state.navigation().canGoBack || state.executing()">
|
||||
Previous
|
||||
</button>
|
||||
|
||||
@if (isLastStep()) {
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
(click)="onComplete()"
|
||||
[disabled]="!state.navigation().canComplete || state.executing()">
|
||||
{{ state.executing() ? 'Completing...' : 'Complete Setup' }}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
(click)="onNext()"
|
||||
[disabled]="!state.navigation().canGoNext || state.executing()">
|
||||
Next
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.wizard-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.wizard-header {
|
||||
padding: 24px 32px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.wizard-header h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0 0 16px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
position: relative;
|
||||
height: 8px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #1976d2, #42a5f5);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 12px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.wizard-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.step-sidebar {
|
||||
width: 280px;
|
||||
background: white;
|
||||
border-right: 1px solid #e0e0e0;
|
||||
padding: 24px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.step-main {
|
||||
flex: 1;
|
||||
padding: 24px 32px;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #e0e0e0;
|
||||
border-top-color: #1976d2;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.no-step-selected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: #ffebee;
|
||||
border: 1px solid #ef9a9a;
|
||||
border-radius: 4px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #f44336;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
flex: 1;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.dismiss-btn {
|
||||
padding: 4px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid #c62828;
|
||||
border-radius: 4px;
|
||||
color: #c62828;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dismiss-btn:hover {
|
||||
background: #ffcdd2;
|
||||
}
|
||||
|
||||
.wizard-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 32px;
|
||||
background: white;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.footer-left,
|
||||
.footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dry-run-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dry-run-toggle input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.help-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 50%;
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #1976d2;
|
||||
border-color: #1976d2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #1565c0;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border-color: #999;
|
||||
color: #666;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class SetupWizardComponent implements OnInit {
|
||||
readonly state = inject(SetupWizardStateService);
|
||||
private readonly api = inject(SetupWizardApiService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
readonly isReconfigureMode = signal(false);
|
||||
|
||||
readonly completedStepIds = computed(() =>
|
||||
this.state.steps()
|
||||
.filter(s => s.status === 'completed')
|
||||
.map(s => s.id)
|
||||
);
|
||||
|
||||
readonly skippedStepIds = computed(() =>
|
||||
this.state.steps()
|
||||
.filter(s => s.status === 'skipped')
|
||||
.map(s => s.id)
|
||||
);
|
||||
|
||||
readonly isLastStep = computed(() => {
|
||||
const index = this.state.currentStepIndex();
|
||||
const total = this.state.orderedSteps().length;
|
||||
return index === total - 1;
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.initializeWizard();
|
||||
}
|
||||
|
||||
private initializeWizard(): void {
|
||||
this.state.loading.set(true);
|
||||
|
||||
// Create a new session
|
||||
this.api.createSession().subscribe({
|
||||
next: (session) => {
|
||||
this.state.initializeSession(session);
|
||||
|
||||
// Set initial step to first one
|
||||
const firstStep = this.state.orderedSteps()[0];
|
||||
if (firstStep) {
|
||||
this.state.currentStepId.set(firstStep.id);
|
||||
this.loadValidationChecks(firstStep.id);
|
||||
}
|
||||
|
||||
this.state.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.state.error.set('Failed to initialize setup wizard');
|
||||
this.state.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private loadValidationChecks(stepId: SetupStepId): void {
|
||||
const session = this.state.session();
|
||||
if (!session) return;
|
||||
|
||||
this.api.runValidationChecks(session.sessionId, stepId).subscribe({
|
||||
next: (checks) => this.state.setValidationChecks(checks),
|
||||
error: () => this.state.setValidationChecks([]),
|
||||
});
|
||||
}
|
||||
|
||||
onStepSelected(stepId: SetupStepId): void {
|
||||
this.state.goToStep(stepId);
|
||||
this.loadValidationChecks(stepId);
|
||||
}
|
||||
|
||||
onConfigChange(event: { key: string; value: string }): void {
|
||||
this.state.setConfigValue(event.key, event.value);
|
||||
}
|
||||
|
||||
onExecuteStep(): void {
|
||||
const session = this.state.session();
|
||||
const step = this.state.currentStep();
|
||||
if (!session || !step) return;
|
||||
|
||||
this.state.executing.set(true);
|
||||
this.state.markCurrentStepInProgress();
|
||||
|
||||
const request: ExecuteStepRequest = {
|
||||
sessionId: session.sessionId,
|
||||
stepId: step.id,
|
||||
configValues: this.state.configValues(),
|
||||
dryRun: this.state.dryRunMode(),
|
||||
};
|
||||
|
||||
this.api.executeStep(request).subscribe({
|
||||
next: (result) => {
|
||||
if (result.status === 'completed') {
|
||||
this.state.markCurrentStepCompleted(result.appliedConfig);
|
||||
} else if (result.status === 'failed') {
|
||||
this.state.markCurrentStepFailed(result.error ?? 'Step execution failed');
|
||||
}
|
||||
this.state.executing.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.state.markCurrentStepFailed(err.message ?? 'Step execution failed');
|
||||
this.state.executing.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onSkipStep(): void {
|
||||
const session = this.state.session();
|
||||
const step = this.state.currentStep();
|
||||
if (!session || !step || !step.isSkippable) return;
|
||||
|
||||
this.api.skipStep({
|
||||
sessionId: session.sessionId,
|
||||
stepId: step.id,
|
||||
reason: 'User skipped',
|
||||
}).subscribe({
|
||||
next: () => {
|
||||
this.state.markCurrentStepSkipped();
|
||||
},
|
||||
error: (err) => {
|
||||
this.state.error.set('Failed to skip step');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onTestConnection(): void {
|
||||
const step = this.state.currentStep();
|
||||
if (!step) return;
|
||||
|
||||
this.state.executing.set(true);
|
||||
|
||||
this.api.testConnection(step.id, this.state.configValues()).subscribe({
|
||||
next: (result) => {
|
||||
if (result.success) {
|
||||
// Update validation check to passed
|
||||
this.state.setValidationChecks(
|
||||
this.state.validationChecks().map(c =>
|
||||
c.checkId.includes('connectivity')
|
||||
? { ...c, status: 'passed', message: result.message }
|
||||
: c
|
||||
)
|
||||
);
|
||||
} else {
|
||||
this.state.error.set(result.message);
|
||||
}
|
||||
this.state.executing.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.state.error.set(err.message ?? 'Connection test failed');
|
||||
this.state.executing.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onPrevious(): void {
|
||||
this.state.goToPreviousStep();
|
||||
const stepId = this.state.currentStepId();
|
||||
if (stepId) {
|
||||
this.loadValidationChecks(stepId);
|
||||
}
|
||||
}
|
||||
|
||||
onNext(): void {
|
||||
this.state.goToNextStep();
|
||||
const stepId = this.state.currentStepId();
|
||||
if (stepId) {
|
||||
this.loadValidationChecks(stepId);
|
||||
}
|
||||
}
|
||||
|
||||
onComplete(): void {
|
||||
const session = this.state.session();
|
||||
if (!session) return;
|
||||
|
||||
this.state.executing.set(true);
|
||||
|
||||
this.api.finalizeSetup(session.sessionId).subscribe({
|
||||
next: (result) => {
|
||||
this.state.executing.set(false);
|
||||
if (result.success) {
|
||||
// Navigate to success page or dashboard
|
||||
this.router.navigate(['/']);
|
||||
} else {
|
||||
this.state.error.set(result.message);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.state.executing.set(false);
|
||||
this.state.error.set(err.message ?? 'Failed to complete setup');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
if (confirm('Are you sure you want to cancel setup? Your progress will be lost.')) {
|
||||
this.state.reset();
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
}
|
||||
|
||||
dismissError(): void {
|
||||
this.state.error.set(null);
|
||||
}
|
||||
|
||||
toggleDryRun(): void {
|
||||
this.state.dryRunMode.update(v => !v);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* @file step-content.component.spec.ts
|
||||
* @sprint Sprint 5: UI Integrations + Settings Store
|
||||
* @description Unit tests for StepContentComponent
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { StepContentComponent } from './step-content.component';
|
||||
import { SetupStep, ValidationCheck } from '../models/setup-wizard.models';
|
||||
|
||||
describe('StepContentComponent', () => {
|
||||
let component: StepContentComponent;
|
||||
let fixture: ComponentFixture<StepContentComponent>;
|
||||
|
||||
const mockDatabaseStep: SetupStep = {
|
||||
id: 'database',
|
||||
name: 'PostgreSQL Database',
|
||||
description: 'Configure the PostgreSQL database connection.',
|
||||
category: 'Infrastructure',
|
||||
order: 10,
|
||||
isRequired: true,
|
||||
isSkippable: false,
|
||||
dependencies: [],
|
||||
validationChecks: ['check.database.connectivity'],
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
const mockVaultStep: SetupStep = {
|
||||
id: 'vault',
|
||||
name: 'Secrets Vault',
|
||||
description: 'Configure a secrets vault.',
|
||||
category: 'Security',
|
||||
order: 30,
|
||||
isRequired: false,
|
||||
isSkippable: true,
|
||||
dependencies: [],
|
||||
validationChecks: ['check.integration.vault.connectivity'],
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [StepContentComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(StepContentComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
fixture.componentRef.setInput('step', mockDatabaseStep);
|
||||
fixture.detectChanges();
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('step header', () => {
|
||||
it('should display step name', () => {
|
||||
fixture.componentRef.setInput('step', mockDatabaseStep);
|
||||
fixture.detectChanges();
|
||||
const header = fixture.nativeElement.querySelector('.step-header h2');
|
||||
expect(header.textContent).toContain('PostgreSQL Database');
|
||||
});
|
||||
|
||||
it('should display step description', () => {
|
||||
fixture.componentRef.setInput('step', mockDatabaseStep);
|
||||
fixture.detectChanges();
|
||||
const desc = fixture.nativeElement.querySelector('.step-description');
|
||||
expect(desc.textContent).toContain('Configure the PostgreSQL database');
|
||||
});
|
||||
|
||||
it('should show required badge for required steps', () => {
|
||||
fixture.componentRef.setInput('step', mockDatabaseStep);
|
||||
fixture.detectChanges();
|
||||
const badge = fixture.nativeElement.querySelector('.required-badge');
|
||||
expect(badge).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show skip button for skippable steps', () => {
|
||||
fixture.componentRef.setInput('step', mockVaultStep);
|
||||
fixture.detectChanges();
|
||||
const skipBtn = fixture.nativeElement.querySelector('.btn-skip');
|
||||
expect(skipBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not show skip button for required steps', () => {
|
||||
fixture.componentRef.setInput('step', mockDatabaseStep);
|
||||
fixture.detectChanges();
|
||||
const skipBtn = fixture.nativeElement.querySelector('.btn-skip');
|
||||
expect(skipBtn).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('status banners', () => {
|
||||
it('should show success banner for completed steps', () => {
|
||||
const completedStep = { ...mockDatabaseStep, status: 'completed' as const };
|
||||
fixture.componentRef.setInput('step', completedStep);
|
||||
fixture.detectChanges();
|
||||
const banner = fixture.nativeElement.querySelector('.status-banner.success');
|
||||
expect(banner).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show skipped banner for skipped steps', () => {
|
||||
const skippedStep = { ...mockVaultStep, status: 'skipped' as const };
|
||||
fixture.componentRef.setInput('step', skippedStep);
|
||||
fixture.detectChanges();
|
||||
const banner = fixture.nativeElement.querySelector('.status-banner.skipped');
|
||||
expect(banner).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show error banner for failed steps', () => {
|
||||
const failedStep = { ...mockDatabaseStep, status: 'failed' as const, error: 'Connection failed' };
|
||||
fixture.componentRef.setInput('step', failedStep);
|
||||
fixture.detectChanges();
|
||||
const banner = fixture.nativeElement.querySelector('.status-banner.error');
|
||||
expect(banner).toBeTruthy();
|
||||
expect(banner.textContent).toContain('Connection failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('database form', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('step', mockDatabaseStep);
|
||||
fixture.componentRef.setInput('configValues', {});
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should render database form fields', () => {
|
||||
const hostInput = fixture.nativeElement.querySelector('#db-host');
|
||||
const portInput = fixture.nativeElement.querySelector('#db-port');
|
||||
const databaseInput = fixture.nativeElement.querySelector('#db-database');
|
||||
expect(hostInput).toBeTruthy();
|
||||
expect(portInput).toBeTruthy();
|
||||
expect(databaseInput).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should emit config change on input', () => {
|
||||
spyOn(component.configChange, 'emit');
|
||||
const hostInput = fixture.nativeElement.querySelector('#db-host');
|
||||
hostInput.value = 'localhost';
|
||||
hostInput.dispatchEvent(new Event('input'));
|
||||
expect(component.configChange.emit).toHaveBeenCalledWith({
|
||||
key: 'database.host',
|
||||
value: 'localhost',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation checks', () => {
|
||||
const mockChecks: ValidationCheck[] = [
|
||||
{ checkId: 'check.database.connectivity', name: 'Connectivity', description: 'Test', status: 'passed', severity: 'info', message: 'OK' },
|
||||
{ checkId: 'check.database.migrations', name: 'Migrations', description: 'Test', status: 'pending', severity: 'info' },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('step', mockDatabaseStep);
|
||||
fixture.componentRef.setInput('validationChecks', mockChecks);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display validation checks', () => {
|
||||
const checkItems = fixture.nativeElement.querySelectorAll('.check-item');
|
||||
expect(checkItems.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should show pass icon for passed checks', () => {
|
||||
const passIcon = fixture.nativeElement.querySelector('.icon-pass');
|
||||
expect(passIcon).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('action buttons', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('step', mockDatabaseStep);
|
||||
fixture.componentRef.setInput('executing', false);
|
||||
fixture.componentRef.setInput('dryRunMode', true);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should show test connection button', () => {
|
||||
const testBtn = fixture.nativeElement.querySelector('.btn-secondary');
|
||||
expect(testBtn.textContent).toContain('Test Connection');
|
||||
});
|
||||
|
||||
it('should show validate button in dry run mode', () => {
|
||||
const executeBtn = fixture.nativeElement.querySelector('.btn-primary');
|
||||
expect(executeBtn.textContent).toContain('Validate Configuration');
|
||||
});
|
||||
|
||||
it('should show apply button when not in dry run mode', () => {
|
||||
fixture.componentRef.setInput('dryRunMode', false);
|
||||
fixture.detectChanges();
|
||||
const executeBtn = fixture.nativeElement.querySelector('.btn-primary');
|
||||
expect(executeBtn.textContent).toContain('Apply Configuration');
|
||||
});
|
||||
|
||||
it('should emit executeStep on execute click', () => {
|
||||
spyOn(component.executeStep, 'emit');
|
||||
component.onExecute();
|
||||
expect(component.executeStep.emit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit skipStep on skip click', () => {
|
||||
fixture.componentRef.setInput('step', mockVaultStep);
|
||||
fixture.detectChanges();
|
||||
spyOn(component.skipStep, 'emit');
|
||||
component.onSkip();
|
||||
expect(component.skipStep.emit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit testConnection on test click', () => {
|
||||
spyOn(component.testConnection, 'emit');
|
||||
component.onTest();
|
||||
expect(component.testConnection.emit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should disable buttons when executing', () => {
|
||||
fixture.componentRef.setInput('executing', true);
|
||||
fixture.detectChanges();
|
||||
const buttons = fixture.nativeElement.querySelectorAll('.step-actions button');
|
||||
buttons.forEach((btn: HTMLButtonElement) => {
|
||||
expect(btn.disabled).toBeTrue();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('vault provider selection', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('step', mockVaultStep);
|
||||
fixture.componentRef.setInput('configValues', {});
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display provider cards', () => {
|
||||
const providerCards = fixture.nativeElement.querySelectorAll('.provider-card');
|
||||
expect(providerCards.length).toBe(4); // hashicorp, azure, aws, gcp
|
||||
});
|
||||
|
||||
it('should select provider on click', () => {
|
||||
spyOn(component.configChange, 'emit');
|
||||
component.selectVaultProvider('hashicorp');
|
||||
expect(component.selectedVaultProvider()).toBe('hashicorp');
|
||||
expect(component.configChange.emit).toHaveBeenCalledWith({
|
||||
key: 'vault.provider',
|
||||
value: 'hashicorp',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConfigValue', () => {
|
||||
it('should return config value if exists', () => {
|
||||
fixture.componentRef.setInput('step', mockDatabaseStep);
|
||||
fixture.componentRef.setInput('configValues', { 'database.host': 'localhost' });
|
||||
fixture.detectChanges();
|
||||
expect(component.getConfigValue('database.host')).toBe('localhost');
|
||||
});
|
||||
|
||||
it('should return empty string if value not exists', () => {
|
||||
fixture.componentRef.setInput('step', mockDatabaseStep);
|
||||
fixture.componentRef.setInput('configValues', {});
|
||||
fixture.detectChanges();
|
||||
expect(component.getConfigValue('database.host')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkbox handling', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('step', mockDatabaseStep);
|
||||
fixture.componentRef.setInput('configValues', {});
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should emit true for checked checkbox', () => {
|
||||
spyOn(component.configChange, 'emit');
|
||||
const event = { target: { checked: true } } as unknown as Event;
|
||||
component.onCheckboxChange('database.ssl', event);
|
||||
expect(component.configChange.emit).toHaveBeenCalledWith({
|
||||
key: 'database.ssl',
|
||||
value: 'true',
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit false for unchecked checkbox', () => {
|
||||
spyOn(component.configChange, 'emit');
|
||||
const event = { target: { checked: false } } as unknown as Event;
|
||||
component.onCheckboxChange('database.ssl', event);
|
||||
expect(component.configChange.emit).toHaveBeenCalledWith({
|
||||
key: 'database.ssl',
|
||||
value: 'false',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* @file step-indicator.component.spec.ts
|
||||
* @sprint Sprint 5: UI Integrations + Settings Store
|
||||
* @description Unit tests for StepIndicatorComponent
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { StepIndicatorComponent } from './step-indicator.component';
|
||||
import { SetupStep, DEFAULT_SETUP_STEPS } from '../models/setup-wizard.models';
|
||||
|
||||
describe('StepIndicatorComponent', () => {
|
||||
let component: StepIndicatorComponent;
|
||||
let fixture: ComponentFixture<StepIndicatorComponent>;
|
||||
|
||||
const mockSteps: SetupStep[] = [...DEFAULT_SETUP_STEPS];
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [StepIndicatorComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(StepIndicatorComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
fixture.componentRef.setInput('steps', mockSteps);
|
||||
fixture.detectChanges();
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('step display', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('steps', mockSteps);
|
||||
fixture.componentRef.setInput('currentStepId', 'database');
|
||||
fixture.componentRef.setInput('completedStepIds', []);
|
||||
fixture.componentRef.setInput('skippedStepIds', []);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display all steps', () => {
|
||||
const stepItems = fixture.nativeElement.querySelectorAll('.step-item');
|
||||
expect(stepItems.length).toBe(mockSteps.length);
|
||||
});
|
||||
|
||||
it('should mark current step as active', () => {
|
||||
const activeStep = fixture.nativeElement.querySelector('.step-item.active');
|
||||
expect(activeStep).toBeTruthy();
|
||||
expect(activeStep.textContent).toContain('PostgreSQL Database');
|
||||
});
|
||||
|
||||
it('should show required marker for required steps', () => {
|
||||
const requiredMarkers = fixture.nativeElement.querySelectorAll('.required-marker');
|
||||
expect(requiredMarkers.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('categories', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('steps', mockSteps);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should compute categories in order', () => {
|
||||
const categories = component.categories();
|
||||
expect(categories[0]).toBe('Infrastructure');
|
||||
expect(categories[1]).toBe('Security');
|
||||
});
|
||||
|
||||
it('should group steps by category', () => {
|
||||
const byCategory = component.stepsByCategory();
|
||||
expect(byCategory.get('Infrastructure')?.length).toBe(2);
|
||||
expect(byCategory.get('Security')?.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('step status icons', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('steps', mockSteps);
|
||||
fixture.componentRef.setInput('currentStepId', 'cache');
|
||||
fixture.componentRef.setInput('completedStepIds', ['database']);
|
||||
fixture.componentRef.setInput('skippedStepIds', ['vault']);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should show check icon for completed steps', () => {
|
||||
const step = mockSteps.find(s => s.id === 'database')!;
|
||||
step.status = 'completed';
|
||||
expect(component.getStepStatusIcon(step)).toBe('check');
|
||||
});
|
||||
|
||||
it('should show skip icon for skipped steps', () => {
|
||||
const step = mockSteps.find(s => s.id === 'vault')!;
|
||||
step.status = 'skipped';
|
||||
expect(component.getStepStatusIcon(step)).toBe('skip');
|
||||
});
|
||||
|
||||
it('should show error icon for failed steps', () => {
|
||||
const step = { ...mockSteps[0], status: 'failed' as const };
|
||||
expect(component.getStepStatusIcon(step)).toBe('error');
|
||||
});
|
||||
|
||||
it('should show progress spinner for in-progress steps', () => {
|
||||
const step = { ...mockSteps[0], status: 'in_progress' as const };
|
||||
expect(component.getStepStatusIcon(step)).toBe('progress');
|
||||
});
|
||||
|
||||
it('should show number for pending steps', () => {
|
||||
const step = mockSteps.find(s => s.id === 'database')!;
|
||||
step.status = 'pending';
|
||||
expect(component.getStepStatusIcon(step)).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('step navigation', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('steps', mockSteps);
|
||||
fixture.componentRef.setInput('currentStepId', 'cache');
|
||||
fixture.componentRef.setInput('completedStepIds', ['database']);
|
||||
fixture.componentRef.setInput('skippedStepIds', []);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should allow navigation to completed steps', () => {
|
||||
const dbStep = mockSteps.find(s => s.id === 'database')!;
|
||||
dbStep.status = 'completed';
|
||||
expect(component.canNavigateToStep(dbStep)).toBeTrue();
|
||||
});
|
||||
|
||||
it('should allow navigation to current step', () => {
|
||||
const cacheStep = mockSteps.find(s => s.id === 'cache')!;
|
||||
expect(component.canNavigateToStep(cacheStep)).toBeTrue();
|
||||
});
|
||||
|
||||
it('should not allow forward navigation to steps after current unless previous are completed', () => {
|
||||
const vaultStep = mockSteps.find(s => s.id === 'vault')!;
|
||||
expect(component.canNavigateToStep(vaultStep)).toBeFalse();
|
||||
});
|
||||
|
||||
it('should emit stepSelected when step is clicked', () => {
|
||||
spyOn(component.stepSelected, 'emit');
|
||||
component.onStepClick('database');
|
||||
expect(component.stepSelected.emit).toHaveBeenCalledWith('database');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStepNumber', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('steps', mockSteps);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should return correct step number', () => {
|
||||
const dbStep = mockSteps.find(s => s.id === 'database')!;
|
||||
expect(component.getStepNumber(dbStep)).toBe(1);
|
||||
|
||||
const cacheStep = mockSteps.find(s => s.id === 'cache')!;
|
||||
expect(component.getStepNumber(cacheStep)).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* @file step-indicator.component.ts
|
||||
* @sprint Sprint 4: UI Wizard Core
|
||||
* @description Step navigation indicator component for the setup wizard
|
||||
*/
|
||||
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
computed,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { SetupStep, SetupStepId, SetupCategory } from '../models/setup-wizard.models';
|
||||
|
||||
/**
|
||||
* Step indicator component for wizard navigation.
|
||||
* Displays steps grouped by category with status indicators.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-step-indicator',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="step-indicator">
|
||||
@for (category of categories(); track category) {
|
||||
<div class="category-group">
|
||||
<h3 class="category-title">{{ category }}</h3>
|
||||
<ul class="step-list">
|
||||
@for (step of stepsByCategory().get(category); track step.id) {
|
||||
<li>
|
||||
<button
|
||||
class="step-item"
|
||||
[class.active]="step.id === currentStepId()"
|
||||
[class.completed]="completedStepIds().includes(step.id)"
|
||||
[class.skipped]="skippedStepIds().includes(step.id)"
|
||||
[class.failed]="step.status === 'failed'"
|
||||
[class.disabled]="!canNavigateToStep(step)"
|
||||
[disabled]="!canNavigateToStep(step)"
|
||||
(click)="onStepClick(step.id)"
|
||||
[attr.aria-current]="step.id === currentStepId() ? 'step' : null">
|
||||
<span class="step-status-icon">
|
||||
@switch (getStepStatusIcon(step)) {
|
||||
@case ('check') {
|
||||
<svg viewBox="0 0 24 24" class="icon-check">
|
||||
<path fill="currentColor" d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
</svg>
|
||||
}
|
||||
@case ('skip') {
|
||||
<svg viewBox="0 0 24 24" class="icon-skip">
|
||||
<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/>
|
||||
<path fill="currentColor" d="M8 12h8"/>
|
||||
</svg>
|
||||
}
|
||||
@case ('error') {
|
||||
<svg viewBox="0 0 24 24" class="icon-error">
|
||||
<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
|
||||
</svg>
|
||||
}
|
||||
@case ('progress') {
|
||||
<span class="spinner-small"></span>
|
||||
}
|
||||
@default {
|
||||
<span class="step-number">{{ getStepNumber(step) }}</span>
|
||||
}
|
||||
}
|
||||
</span>
|
||||
<span class="step-info">
|
||||
<span class="step-name">
|
||||
{{ step.name }}
|
||||
@if (step.isRequired) {
|
||||
<span class="required-marker" title="Required">*</span>
|
||||
}
|
||||
</span>
|
||||
<span class="step-desc">{{ step.description | slice:0:50 }}{{ step.description.length > 50 ? '...' : '' }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.step-indicator {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.category-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.category-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #999;
|
||||
margin: 0 0 12px 8px;
|
||||
}
|
||||
|
||||
.step-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.step-item:hover:not(.disabled) {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.step-item.active {
|
||||
background: #e3f2fd;
|
||||
}
|
||||
|
||||
.step-item.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.step-status-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: #e0e0e0;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-item.active .step-status-icon {
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.step-item.completed .step-status-icon {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.step-item.skipped .step-status-icon {
|
||||
background: #9e9e9e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.step-item.failed .step-status-icon {
|
||||
background: #f44336;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.icon-check,
|
||||
.icon-skip,
|
||||
.icon-error {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.spinner-small {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.step-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.step-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1a1a1a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.required-marker {
|
||||
color: #f44336;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.step-desc {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.step-item.active .step-name {
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.step-item.completed .step-name {
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.step-item.failed .step-name {
|
||||
color: #c62828;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class StepIndicatorComponent {
|
||||
/** All steps to display */
|
||||
readonly steps = input.required<SetupStep[]>();
|
||||
|
||||
/** Currently active step ID */
|
||||
readonly currentStepId = input<SetupStepId | null>(null);
|
||||
|
||||
/** IDs of completed steps */
|
||||
readonly completedStepIds = input<SetupStepId[]>([]);
|
||||
|
||||
/** IDs of skipped steps */
|
||||
readonly skippedStepIds = input<SetupStepId[]>([]);
|
||||
|
||||
/** Emits when a step is selected */
|
||||
readonly stepSelected = output<SetupStepId>();
|
||||
|
||||
/** Categories in order */
|
||||
readonly categories = computed((): SetupCategory[] => {
|
||||
const categoryOrder: SetupCategory[] = [
|
||||
'Infrastructure',
|
||||
'Security',
|
||||
'Configuration',
|
||||
'Integration',
|
||||
'Observability',
|
||||
];
|
||||
const presentCategories = new Set(this.steps().map(s => s.category));
|
||||
return categoryOrder.filter(c => presentCategories.has(c));
|
||||
});
|
||||
|
||||
/** Steps grouped by category */
|
||||
readonly stepsByCategory = computed(() => {
|
||||
const grouped = new Map<SetupCategory, SetupStep[]>();
|
||||
for (const step of this.steps()) {
|
||||
const existing = grouped.get(step.category) ?? [];
|
||||
grouped.set(step.category, [...existing, step]);
|
||||
}
|
||||
return grouped;
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the step number for display
|
||||
*/
|
||||
getStepNumber(step: SetupStep): number {
|
||||
return this.steps().findIndex(s => s.id === step.id) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the status icon type for a step
|
||||
*/
|
||||
getStepStatusIcon(step: SetupStep): 'check' | 'skip' | 'error' | 'progress' | 'number' {
|
||||
if (step.status === 'completed') return 'check';
|
||||
if (step.status === 'skipped') return 'skip';
|
||||
if (step.status === 'failed') return 'error';
|
||||
if (step.status === 'in_progress') return 'progress';
|
||||
return 'number';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if navigation to a step is allowed
|
||||
*/
|
||||
canNavigateToStep(step: SetupStep): boolean {
|
||||
const currentId = this.currentStepId();
|
||||
const steps = this.steps();
|
||||
const stepIndex = steps.findIndex(s => s.id === step.id);
|
||||
const currentIndex = steps.findIndex(s => s.id === currentId);
|
||||
|
||||
// Can always go back
|
||||
if (stepIndex < currentIndex) return true;
|
||||
|
||||
// Current step is always accessible
|
||||
if (step.id === currentId) return true;
|
||||
|
||||
// Can only go forward if all previous steps are completed/skipped
|
||||
const previousSteps = steps.slice(0, stepIndex);
|
||||
return previousSteps.every(
|
||||
s => s.status === 'completed' || s.status === 'skipped'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle step click
|
||||
*/
|
||||
onStepClick(stepId: SetupStepId): void {
|
||||
this.stepSelected.emit(stepId);
|
||||
}
|
||||
}
|
||||
20
src/Web/StellaOps.Web/src/app/features/setup-wizard/index.ts
Normal file
20
src/Web/StellaOps.Web/src/app/features/setup-wizard/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* @sprint Sprint 4: UI Wizard Core
|
||||
* @description Barrel export for setup wizard feature
|
||||
*/
|
||||
|
||||
// Components
|
||||
export { SetupWizardComponent } from './components/setup-wizard.component';
|
||||
export { StepIndicatorComponent } from './components/step-indicator.component';
|
||||
export { StepContentComponent } from './components/step-content.component';
|
||||
|
||||
// Services
|
||||
export { SetupWizardStateService } from './services/setup-wizard-state.service';
|
||||
export { SetupWizardApiService } from './services/setup-wizard-api.service';
|
||||
|
||||
// Models
|
||||
export * from './models/setup-wizard.models';
|
||||
|
||||
// Routes
|
||||
export { setupWizardRoutes } from './setup-wizard.routes';
|
||||
@@ -0,0 +1,609 @@
|
||||
/**
|
||||
* @file setup-wizard.models.ts
|
||||
* @sprint Sprint 4: UI Wizard Core
|
||||
* @description TypeScript models for the setup wizard feature
|
||||
*/
|
||||
|
||||
/** Setup wizard step identifiers */
|
||||
export type SetupStepId =
|
||||
| 'authority'
|
||||
| 'users'
|
||||
| 'database'
|
||||
| 'cache'
|
||||
| 'vault'
|
||||
| 'settingsstore'
|
||||
| 'registry'
|
||||
| 'telemetry'
|
||||
| 'notify'
|
||||
| 'llm';
|
||||
|
||||
/** Setup step categories */
|
||||
export type SetupCategory =
|
||||
| 'Infrastructure'
|
||||
| 'Security'
|
||||
| 'Configuration'
|
||||
| 'Integration'
|
||||
| 'Observability';
|
||||
|
||||
/** Status of an individual setup step */
|
||||
export type SetupStepStatus =
|
||||
| 'pending'
|
||||
| 'in_progress'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'skipped';
|
||||
|
||||
/** Doctor check severity levels */
|
||||
export type CheckSeverity = 'info' | 'warning' | 'error' | 'critical';
|
||||
|
||||
/** Doctor check status */
|
||||
export type CheckStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped';
|
||||
|
||||
/** A validation check from the Doctor module */
|
||||
export interface ValidationCheck {
|
||||
checkId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: CheckStatus;
|
||||
severity: CheckSeverity;
|
||||
message?: string;
|
||||
remediation?: string;
|
||||
}
|
||||
|
||||
/** Step definition for the setup wizard */
|
||||
export interface SetupStep {
|
||||
id: SetupStepId;
|
||||
name: string;
|
||||
description: string;
|
||||
category: SetupCategory;
|
||||
order: number;
|
||||
isRequired: boolean;
|
||||
isSkippable: boolean;
|
||||
dependencies: SetupStepId[];
|
||||
validationChecks: string[];
|
||||
status: SetupStepStatus;
|
||||
appliedConfig?: Record<string, string>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Prerequisites check result */
|
||||
export interface PrerequisiteResult {
|
||||
met: boolean;
|
||||
missingPrerequisites: string[];
|
||||
}
|
||||
|
||||
/** Step execution result from backend */
|
||||
export interface SetupStepResult {
|
||||
stepId: SetupStepId;
|
||||
status: SetupStepStatus;
|
||||
message: string;
|
||||
appliedConfig?: Record<string, string>;
|
||||
outputValues?: Record<string, string>;
|
||||
error?: string;
|
||||
canRetry: boolean;
|
||||
}
|
||||
|
||||
/** Wizard session state */
|
||||
export interface SetupSession {
|
||||
sessionId: string;
|
||||
startedAt: string;
|
||||
completedSteps: SetupStepId[];
|
||||
skippedSteps: SetupStepId[];
|
||||
configValues: Record<string, string>;
|
||||
currentStep?: SetupStepId;
|
||||
}
|
||||
|
||||
/** Request to execute a setup step */
|
||||
export interface ExecuteStepRequest {
|
||||
sessionId: string;
|
||||
stepId: SetupStepId;
|
||||
configValues: Record<string, string>;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
/** Request to skip a setup step */
|
||||
export interface SkipStepRequest {
|
||||
sessionId: string;
|
||||
stepId: SetupStepId;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** Wizard mode (first-time setup vs reconfiguration) */
|
||||
export type WizardMode = 'setup' | 'reconfigure';
|
||||
|
||||
/** Provider definitions for multi-provider steps */
|
||||
export interface ProviderInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
fields: ProviderField[];
|
||||
}
|
||||
|
||||
/** Field definition for provider configuration */
|
||||
export interface ProviderField {
|
||||
id: string;
|
||||
label: string;
|
||||
type: 'text' | 'password' | 'number' | 'boolean' | 'select' | 'textarea';
|
||||
required: boolean;
|
||||
placeholder?: string;
|
||||
helpText?: string;
|
||||
defaultValue?: string | number | boolean;
|
||||
options?: { value: string; label: string }[];
|
||||
validation?: FieldValidation;
|
||||
}
|
||||
|
||||
/** Field validation rules */
|
||||
export interface FieldValidation {
|
||||
pattern?: string;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
/** Vault provider types */
|
||||
export type VaultProvider = 'hashicorp' | 'azure' | 'aws' | 'gcp';
|
||||
|
||||
/** Settings store provider types */
|
||||
export type SettingsStoreProvider =
|
||||
| 'consul'
|
||||
| 'etcd'
|
||||
| 'azure'
|
||||
| 'aws-parameter-store'
|
||||
| 'aws-appconfig';
|
||||
|
||||
/** Authority provider types */
|
||||
export type AuthorityProvider = 'standard' | 'ldap';
|
||||
|
||||
/** Authority provider configurations */
|
||||
export const AUTHORITY_PROVIDERS: ProviderInfo[] = [
|
||||
{
|
||||
id: 'standard',
|
||||
name: 'Standard Authentication',
|
||||
description: 'Username/password authentication with Argon2id hashing',
|
||||
icon: 'key',
|
||||
fields: [
|
||||
{ id: 'minLength', label: 'Minimum Password Length', type: 'number', required: false, defaultValue: 12, validation: { min: 8, max: 128 } },
|
||||
{ id: 'requireUppercase', label: 'Require Uppercase Letter', type: 'boolean', required: false, defaultValue: true },
|
||||
{ id: 'requireLowercase', label: 'Require Lowercase Letter', type: 'boolean', required: false, defaultValue: true },
|
||||
{ id: 'requireDigit', label: 'Require Digit', type: 'boolean', required: false, defaultValue: true },
|
||||
{ id: 'requireSpecialChar', label: 'Require Special Character', type: 'boolean', required: false, defaultValue: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ldap',
|
||||
name: 'LDAP/Active Directory',
|
||||
description: 'LDAP bind authentication with claims enrichment',
|
||||
icon: 'directory',
|
||||
fields: [
|
||||
{ id: 'server', label: 'LDAP Server URL', type: 'text', required: true, placeholder: 'ldap://ldap.example.com or ldaps://ldap.example.com' },
|
||||
{ id: 'port', label: 'Port', type: 'number', required: false, defaultValue: 389, helpText: 'Default: 389 (LDAP), 636 (LDAPS)' },
|
||||
{ id: 'useSsl', label: 'Use SSL/TLS', type: 'boolean', required: false, defaultValue: false },
|
||||
{ id: 'bindDn', label: 'Bind DN', type: 'text', required: true, placeholder: 'cn=admin,dc=example,dc=com' },
|
||||
{ id: 'bindPassword', label: 'Bind Password', type: 'password', required: true },
|
||||
{ id: 'searchBase', label: 'User Search Base', type: 'text', required: true, placeholder: 'ou=users,dc=example,dc=com' },
|
||||
{ id: 'userFilter', label: 'User Filter', type: 'text', required: false, defaultValue: '(uid={0})', helpText: 'Use {0} for username placeholder' },
|
||||
{ id: 'groupSearchBase', label: 'Group Search Base', type: 'text', required: false, placeholder: 'ou=groups,dc=example,dc=com', helpText: 'Optional: for group membership' },
|
||||
{ id: 'adminGroup', label: 'Admin Group DN', type: 'text', required: false, placeholder: 'cn=admins,ou=groups,dc=example,dc=com', helpText: 'Members get admin privileges' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** User role options */
|
||||
export const USER_ROLES: { value: string; label: string }[] = [
|
||||
{ value: 'user', label: 'User - Basic access' },
|
||||
{ value: 'operator', label: 'Operator - Operations access' },
|
||||
{ value: 'admin', label: 'Administrator - Full access' },
|
||||
];
|
||||
|
||||
/** Vault provider configurations */
|
||||
export const VAULT_PROVIDERS: ProviderInfo[] = [
|
||||
{
|
||||
id: 'hashicorp',
|
||||
name: 'HashiCorp Vault',
|
||||
description: 'HashiCorp Vault secrets management',
|
||||
icon: 'lock',
|
||||
fields: [
|
||||
{ id: 'address', label: 'Vault Address', type: 'text', required: true, placeholder: 'https://vault.example.com:8200' },
|
||||
{ id: 'token', label: 'Vault Token', type: 'password', required: false, helpText: 'Optional if using other auth methods' },
|
||||
{ id: 'namespace', label: 'Namespace', type: 'text', required: false, placeholder: 'admin' },
|
||||
{ id: 'mountPath', label: 'Secret Mount Path', type: 'text', required: false, defaultValue: 'secret' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'azure',
|
||||
name: 'Azure Key Vault',
|
||||
description: 'Azure Key Vault for secrets and keys',
|
||||
icon: 'cloud',
|
||||
fields: [
|
||||
{ id: 'vaultUri', label: 'Vault URI', type: 'text', required: true, placeholder: 'https://myvault.vault.azure.net/' },
|
||||
{ id: 'tenantId', label: 'Tenant ID', type: 'text', required: false, helpText: 'Optional if using Managed Identity' },
|
||||
{ id: 'clientId', label: 'Client ID', type: 'text', required: false },
|
||||
{ id: 'clientSecret', label: 'Client Secret', type: 'password', required: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'aws',
|
||||
name: 'AWS Secrets Manager',
|
||||
description: 'AWS Secrets Manager for secrets storage',
|
||||
icon: 'cloud',
|
||||
fields: [
|
||||
{ id: 'region', label: 'AWS Region', type: 'text', required: true, defaultValue: 'us-east-1' },
|
||||
{ id: 'accessKeyId', label: 'Access Key ID', type: 'text', required: false, helpText: 'Optional if using IAM roles' },
|
||||
{ id: 'secretAccessKey', label: 'Secret Access Key', type: 'password', required: false },
|
||||
{ id: 'secretPrefix', label: 'Secret Prefix', type: 'text', required: false, defaultValue: 'stellaops/' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gcp',
|
||||
name: 'GCP Secret Manager',
|
||||
description: 'Google Cloud Secret Manager',
|
||||
icon: 'cloud',
|
||||
fields: [
|
||||
{ id: 'projectId', label: 'Project ID', type: 'text', required: true },
|
||||
{ id: 'credentialsPath', label: 'Credentials Path', type: 'text', required: false, helpText: 'Optional if using default credentials' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Settings store provider configurations */
|
||||
export const SETTINGS_STORE_PROVIDERS: ProviderInfo[] = [
|
||||
{
|
||||
id: 'consul',
|
||||
name: 'Consul KV',
|
||||
description: 'HashiCorp Consul Key-Value store',
|
||||
icon: 'database',
|
||||
fields: [
|
||||
{ id: 'address', label: 'Consul Address', type: 'text', required: true, defaultValue: 'http://localhost:8500' },
|
||||
{ id: 'token', label: 'ACL Token', type: 'password', required: false },
|
||||
{ id: 'prefix', label: 'Key Prefix', type: 'text', required: false, defaultValue: 'stellaops/config/' },
|
||||
{ id: 'reloadOnChange', label: 'Reload on Change', type: 'boolean', required: false, defaultValue: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'etcd',
|
||||
name: 'etcd',
|
||||
description: 'etcd distributed key-value store',
|
||||
icon: 'database',
|
||||
fields: [
|
||||
{ id: 'endpoints', label: 'Endpoints', type: 'textarea', required: true, placeholder: 'http://localhost:2379', helpText: 'One endpoint per line' },
|
||||
{ id: 'prefix', label: 'Key Prefix', type: 'text', required: false, defaultValue: '/stellaops/config/' },
|
||||
{ id: 'reloadOnChange', label: 'Reload on Change', type: 'boolean', required: false, defaultValue: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'azure',
|
||||
name: 'Azure App Configuration',
|
||||
description: 'Azure App Configuration service',
|
||||
icon: 'cloud',
|
||||
fields: [
|
||||
{ id: 'endpoint', label: 'Endpoint', type: 'text', required: false, placeholder: 'https://myconfig.azconfig.io', helpText: 'Use endpoint OR connection string' },
|
||||
{ id: 'connectionString', label: 'Connection String', type: 'password', required: false },
|
||||
{ id: 'label', label: 'Label', type: 'text', required: false, placeholder: 'dev, staging, prod' },
|
||||
{ id: 'reloadOnChange', label: 'Reload on Change', type: 'boolean', required: false, defaultValue: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'aws-parameter-store',
|
||||
name: 'AWS Parameter Store',
|
||||
description: 'AWS Systems Manager Parameter Store',
|
||||
icon: 'cloud',
|
||||
fields: [
|
||||
{ id: 'region', label: 'AWS Region', type: 'text', required: true, defaultValue: 'us-east-1' },
|
||||
{ id: 'prefix', label: 'Parameter Path Prefix', type: 'text', required: false, defaultValue: '/stellaops/' },
|
||||
{ id: 'reloadOnChange', label: 'Reload on Change', type: 'boolean', required: false, defaultValue: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'aws-appconfig',
|
||||
name: 'AWS AppConfig',
|
||||
description: 'AWS AppConfig for feature flags',
|
||||
icon: 'cloud',
|
||||
fields: [
|
||||
{ id: 'region', label: 'AWS Region', type: 'text', required: true, defaultValue: 'us-east-1' },
|
||||
{ id: 'application', label: 'Application ID', type: 'text', required: true },
|
||||
{ id: 'environment', label: 'Environment ID', type: 'text', required: true },
|
||||
{ id: 'configuration', label: 'Configuration Profile ID', type: 'text', required: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Notify channel provider types */
|
||||
export type NotifyProvider = 'email' | 'slack' | 'teams' | 'webhook';
|
||||
|
||||
/** LLM provider types */
|
||||
export type LlmProvider = 'openai' | 'claude' | 'gemini' | 'ollama' | 'none';
|
||||
|
||||
/** Notify channel provider configurations */
|
||||
export const NOTIFY_PROVIDERS: ProviderInfo[] = [
|
||||
{
|
||||
id: 'email',
|
||||
name: 'Email (SMTP)',
|
||||
description: 'Send notifications via email using SMTP',
|
||||
icon: 'mail',
|
||||
fields: [
|
||||
{ id: 'smtpHost', label: 'SMTP Server', type: 'text', required: true, placeholder: 'smtp.example.com' },
|
||||
{ id: 'smtpPort', label: 'SMTP Port', type: 'number', required: false, defaultValue: 587, validation: { min: 1, max: 65535 } },
|
||||
{ id: 'useTls', label: 'Use TLS', type: 'boolean', required: false, defaultValue: true },
|
||||
{ id: 'fromAddress', label: 'From Address', type: 'text', required: true, placeholder: 'alerts@example.com' },
|
||||
{ id: 'username', label: 'SMTP Username', type: 'text', required: false, helpText: 'Leave empty for anonymous SMTP' },
|
||||
{ id: 'password', label: 'SMTP Password', type: 'password', required: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'slack',
|
||||
name: 'Slack',
|
||||
description: 'Send notifications to Slack channels via webhook',
|
||||
icon: 'chat',
|
||||
fields: [
|
||||
{ id: 'webhookUrl', label: 'Webhook URL', type: 'password', required: true, placeholder: 'https://hooks.slack.com/services/...' },
|
||||
{ id: 'defaultChannel', label: 'Default Channel', type: 'text', required: false, placeholder: '#alerts', helpText: 'Override channel in webhook if needed' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'teams',
|
||||
name: 'Microsoft Teams',
|
||||
description: 'Send notifications to Microsoft Teams channels via webhook',
|
||||
icon: 'chat',
|
||||
fields: [
|
||||
{ id: 'webhookUrl', label: 'Webhook URL', type: 'password', required: true, placeholder: 'https://outlook.office.com/webhook/...' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'webhook',
|
||||
name: 'Custom Webhook',
|
||||
description: 'Send notifications to a custom HTTP endpoint',
|
||||
icon: 'webhook',
|
||||
fields: [
|
||||
{ id: 'endpoint', label: 'Webhook Endpoint', type: 'text', required: true, placeholder: 'https://api.example.com/webhook' },
|
||||
{ id: 'secret', label: 'Webhook Secret', type: 'password', required: false, helpText: 'Used for HMAC signature verification' },
|
||||
{ id: 'headers', label: 'Custom Headers', type: 'textarea', required: false, placeholder: 'Authorization: Bearer token\nX-Custom: value', helpText: 'One header per line (Key: Value)' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Notify event rule suggestions */
|
||||
export interface NotifyEventRule {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
eventKinds: string[];
|
||||
condition?: string;
|
||||
severity: 'info' | 'warning' | 'critical';
|
||||
digest?: 'immediate' | 'hourly' | 'daily';
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** Default notify event rules */
|
||||
export const DEFAULT_NOTIFY_RULES: NotifyEventRule[] = [
|
||||
{
|
||||
id: 'scan-failure',
|
||||
name: 'Scan Failure Alert',
|
||||
description: 'Notify immediately when scans fail',
|
||||
eventKinds: ['scanner.report.ready'],
|
||||
condition: "status == 'failed'",
|
||||
severity: 'critical',
|
||||
digest: 'immediate',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'scan-success',
|
||||
name: 'Scan Success Summary',
|
||||
description: 'Daily digest of successful scans',
|
||||
eventKinds: ['scanner.report.ready'],
|
||||
condition: "status == 'passed'",
|
||||
severity: 'info',
|
||||
digest: 'daily',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'deploy-prod',
|
||||
name: 'Production Deploy Alert',
|
||||
description: 'Alert on production deployments',
|
||||
eventKinds: ['workflow.step.completed'],
|
||||
condition: "environment == 'production'",
|
||||
severity: 'warning',
|
||||
digest: 'immediate',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'deploy-failure',
|
||||
name: 'Deploy Failure Alert',
|
||||
description: 'Critical alert on deployment failures',
|
||||
eventKinds: ['workflow.step.failed'],
|
||||
severity: 'critical',
|
||||
digest: 'immediate',
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
/** LLM provider configurations for AdvisoryAI */
|
||||
export const LLM_PROVIDERS: ProviderInfo[] = [
|
||||
{
|
||||
id: 'openai',
|
||||
name: 'OpenAI',
|
||||
description: 'OpenAI GPT models (GPT-4o, GPT-4, GPT-3.5)',
|
||||
icon: 'brain',
|
||||
fields: [
|
||||
{ id: 'apiKey', label: 'API Key', type: 'password', required: true, placeholder: 'sk-...', helpText: 'Or set OPENAI_API_KEY environment variable' },
|
||||
{ id: 'model', label: 'Model', type: 'select', required: false, defaultValue: 'gpt-4o', options: [
|
||||
{ value: 'gpt-4o', label: 'GPT-4o (Recommended)' },
|
||||
{ value: 'gpt-4-turbo', label: 'GPT-4 Turbo' },
|
||||
{ value: 'gpt-4', label: 'GPT-4' },
|
||||
{ value: 'gpt-3.5-turbo', label: 'GPT-3.5 Turbo' },
|
||||
]},
|
||||
{ id: 'organizationId', label: 'Organization ID', type: 'text', required: false, placeholder: 'org-...', helpText: 'Optional: for organization billing' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'claude',
|
||||
name: 'Anthropic Claude',
|
||||
description: 'Anthropic Claude models (Claude 3.5, Claude 3)',
|
||||
icon: 'sparkle',
|
||||
fields: [
|
||||
{ id: 'apiKey', label: 'API Key', type: 'password', required: true, placeholder: 'sk-ant-...', helpText: 'Or set ANTHROPIC_API_KEY environment variable' },
|
||||
{ id: 'model', label: 'Model', type: 'select', required: false, defaultValue: 'claude-sonnet-4-20250514', options: [
|
||||
{ value: 'claude-sonnet-4-20250514', label: 'Claude 4 Sonnet (Recommended)' },
|
||||
{ value: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet' },
|
||||
{ value: 'claude-3-opus-20240229', label: 'Claude 3 Opus' },
|
||||
{ value: 'claude-3-haiku-20240307', label: 'Claude 3 Haiku' },
|
||||
]},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gemini',
|
||||
name: 'Google Gemini',
|
||||
description: 'Google Gemini models (Gemini 1.5 Pro, Flash)',
|
||||
icon: 'stars',
|
||||
fields: [
|
||||
{ id: 'apiKey', label: 'API Key', type: 'password', required: true, placeholder: 'AI...', helpText: 'Or set GEMINI_API_KEY or GOOGLE_API_KEY environment variable' },
|
||||
{ id: 'model', label: 'Model', type: 'select', required: false, defaultValue: 'gemini-1.5-flash', options: [
|
||||
{ value: 'gemini-1.5-flash', label: 'Gemini 1.5 Flash (Recommended)' },
|
||||
{ value: 'gemini-1.5-pro', label: 'Gemini 1.5 Pro' },
|
||||
{ value: 'gemini-1.0-pro', label: 'Gemini 1.0 Pro' },
|
||||
]},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ollama',
|
||||
name: 'Ollama (Local)',
|
||||
description: 'Run LLMs locally with Ollama',
|
||||
icon: 'server',
|
||||
fields: [
|
||||
{ id: 'endpoint', label: 'Ollama Endpoint', type: 'text', required: false, defaultValue: 'http://localhost:11434', helpText: 'URL of your local Ollama instance' },
|
||||
{ id: 'model', label: 'Model', type: 'text', required: false, defaultValue: 'llama3:8b', placeholder: 'llama3:8b', helpText: 'Model name as shown by ollama list' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'none',
|
||||
name: 'Skip LLM Configuration',
|
||||
description: 'Skip AI/LLM setup. AdvisoryAI features will be unavailable.',
|
||||
icon: 'skip',
|
||||
fields: [],
|
||||
},
|
||||
];
|
||||
|
||||
/** Default step definitions */
|
||||
export const DEFAULT_SETUP_STEPS: SetupStep[] = [
|
||||
{
|
||||
id: 'authority',
|
||||
name: 'Authentication Provider',
|
||||
description: 'Configure authentication provider (Standard password auth or LDAP/Active Directory).',
|
||||
category: 'Security',
|
||||
order: 5,
|
||||
isRequired: true,
|
||||
isSkippable: false,
|
||||
dependencies: [],
|
||||
validationChecks: ['check.authority.plugin.configured', 'check.authority.plugin.connectivity'],
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
name: 'User Management',
|
||||
description: 'Create the initial super user (administrator) and optional additional users.',
|
||||
category: 'Security',
|
||||
order: 6,
|
||||
isRequired: true,
|
||||
isSkippable: false,
|
||||
dependencies: ['authority'],
|
||||
validationChecks: ['check.users.superuser.exists', 'check.authority.bootstrap.exists'],
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'database',
|
||||
name: 'PostgreSQL Database',
|
||||
description: 'Configure the PostgreSQL database connection for persistent storage.',
|
||||
category: 'Infrastructure',
|
||||
order: 10,
|
||||
isRequired: true,
|
||||
isSkippable: false,
|
||||
dependencies: [],
|
||||
validationChecks: ['check.database.connectivity', 'check.database.migrations'],
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'cache',
|
||||
name: 'Valkey/Redis Cache',
|
||||
description: 'Configure Valkey or Redis for caching and session storage.',
|
||||
category: 'Infrastructure',
|
||||
order: 20,
|
||||
isRequired: true,
|
||||
isSkippable: false,
|
||||
dependencies: ['database'],
|
||||
validationChecks: ['check.cache.connectivity', 'check.cache.persistence'],
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'vault',
|
||||
name: 'Secrets Vault',
|
||||
description: 'Configure a secrets vault for secure credential storage (HashiCorp Vault, Azure Key Vault, AWS Secrets Manager, or GCP Secret Manager).',
|
||||
category: 'Security',
|
||||
order: 30,
|
||||
isRequired: false,
|
||||
isSkippable: true,
|
||||
dependencies: [],
|
||||
validationChecks: ['check.integration.vault.connectivity', 'check.integration.vault.auth'],
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'settingsstore',
|
||||
name: 'Settings Store',
|
||||
description: 'Configure a settings store for application configuration and feature flags (Consul, etcd, Azure App Configuration, or AWS Parameter Store).',
|
||||
category: 'Configuration',
|
||||
order: 40,
|
||||
isRequired: false,
|
||||
isSkippable: true,
|
||||
dependencies: [],
|
||||
validationChecks: ['check.integration.settingsstore.connectivity', 'check.integration.settingsstore.auth'],
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'registry',
|
||||
name: 'Container Registry',
|
||||
description: 'Configure the container registry for storing and retrieving container images.',
|
||||
category: 'Integration',
|
||||
order: 50,
|
||||
isRequired: false,
|
||||
isSkippable: true,
|
||||
dependencies: [],
|
||||
validationChecks: ['check.integration.registry.connectivity', 'check.integration.registry.auth'],
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'telemetry',
|
||||
name: 'OpenTelemetry',
|
||||
description: 'Configure OpenTelemetry for distributed tracing, metrics, and logging.',
|
||||
category: 'Observability',
|
||||
order: 60,
|
||||
isRequired: false,
|
||||
isSkippable: true,
|
||||
dependencies: [],
|
||||
validationChecks: ['check.telemetry.otlp.connectivity'],
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'notify',
|
||||
name: 'Notifications',
|
||||
description: 'Configure notification channels (Email, Slack, Teams, Webhook) for alerts and events.',
|
||||
category: 'Integration',
|
||||
order: 70,
|
||||
isRequired: false,
|
||||
isSkippable: true,
|
||||
dependencies: [],
|
||||
validationChecks: ['check.notify.channel.configured', 'check.notify.channel.connectivity'],
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'llm',
|
||||
name: 'AI/LLM Provider',
|
||||
description: 'Configure AI/LLM provider for AdvisoryAI features (OpenAI, Claude, Gemini, Ollama).',
|
||||
category: 'Integration',
|
||||
order: 80,
|
||||
isRequired: false,
|
||||
isSkippable: true,
|
||||
dependencies: [],
|
||||
validationChecks: ['check.ai.llm.config', 'check.ai.provider.openai', 'check.ai.provider.claude', 'check.ai.provider.gemini'],
|
||||
status: 'pending',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* @file setup-wizard-api.service.spec.ts
|
||||
* @sprint Sprint 5: UI Integrations + Settings Store
|
||||
* @description Unit tests for SetupWizardApiService
|
||||
*/
|
||||
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { SetupWizardApiService } from './setup-wizard-api.service';
|
||||
import { ExecuteStepRequest, SkipStepRequest } from '../models/setup-wizard.models';
|
||||
|
||||
describe('SetupWizardApiService', () => {
|
||||
let service: SetupWizardApiService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
SetupWizardApiService,
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(SetupWizardApiService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('createSession', () => {
|
||||
it('should create a new session', (done) => {
|
||||
service.createSession().subscribe((session) => {
|
||||
expect(session).toBeTruthy();
|
||||
expect(session.sessionId).toBeTruthy();
|
||||
expect(session.startedAt).toBeTruthy();
|
||||
expect(session.completedSteps).toEqual([]);
|
||||
expect(session.skippedSteps).toEqual([]);
|
||||
expect(session.configValues).toEqual({});
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resumeSession', () => {
|
||||
it('should return null for non-existent session', (done) => {
|
||||
service.resumeSession('non-existent-id').subscribe((session) => {
|
||||
expect(session).toBeNull();
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSteps', () => {
|
||||
it('should return default steps', (done) => {
|
||||
service.getSteps().subscribe((steps) => {
|
||||
expect(steps.length).toBe(6);
|
||||
expect(steps[0].id).toBe('database');
|
||||
expect(steps[1].id).toBe('cache');
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStep', () => {
|
||||
it('should return specific step', (done) => {
|
||||
service.getStep('database').subscribe((step) => {
|
||||
expect(step).toBeTruthy();
|
||||
expect(step?.id).toBe('database');
|
||||
expect(step?.name).toBe('PostgreSQL Database');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for non-existent step', (done) => {
|
||||
service.getStep('nonexistent' as any).subscribe((step) => {
|
||||
expect(step).toBeNull();
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkPrerequisites', () => {
|
||||
it('should return met=true for valid config', (done) => {
|
||||
service.checkPrerequisites('session-id', 'database', {}).subscribe((result) => {
|
||||
expect(result.met).toBeTrue();
|
||||
expect(result.missingPrerequisites).toEqual([]);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeStep', () => {
|
||||
it('should return completed status for successful execution', (done) => {
|
||||
const request: ExecuteStepRequest = {
|
||||
sessionId: 'session-id',
|
||||
stepId: 'database',
|
||||
configValues: { 'database.host': 'localhost' },
|
||||
dryRun: false,
|
||||
};
|
||||
|
||||
service.executeStep(request).subscribe((result) => {
|
||||
expect(result.stepId).toBe('database');
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.appliedConfig).toEqual(request.configValues);
|
||||
expect(result.canRetry).toBeTrue();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should indicate dry run in message', (done) => {
|
||||
const request: ExecuteStepRequest = {
|
||||
sessionId: 'session-id',
|
||||
stepId: 'database',
|
||||
configValues: {},
|
||||
dryRun: true,
|
||||
};
|
||||
|
||||
service.executeStep(request).subscribe((result) => {
|
||||
expect(result.message).toContain('DRY RUN');
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('skipStep', () => {
|
||||
it('should return skipped status', (done) => {
|
||||
const request: SkipStepRequest = {
|
||||
sessionId: 'session-id',
|
||||
stepId: 'vault',
|
||||
reason: 'Not needed',
|
||||
};
|
||||
|
||||
service.skipStep(request).subscribe((result) => {
|
||||
expect(result.stepId).toBe('vault');
|
||||
expect(result.status).toBe('skipped');
|
||||
expect(result.message).toContain('Not needed');
|
||||
expect(result.canRetry).toBeFalse();
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('runValidationChecks', () => {
|
||||
it('should return validation checks for step', (done) => {
|
||||
service.runValidationChecks('session-id', 'database').subscribe((checks) => {
|
||||
expect(checks.length).toBeGreaterThan(0);
|
||||
expect(checks[0].checkId).toContain('database');
|
||||
expect(checks[0].status).toBe('pending');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty for non-existent step', (done) => {
|
||||
service.runValidationChecks('session-id', 'nonexistent' as any).subscribe((checks) => {
|
||||
expect(checks).toEqual([]);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('runValidationCheck', () => {
|
||||
it('should return passed check', (done) => {
|
||||
service.runValidationCheck('session-id', 'check.database.connectivity', {}).subscribe((check) => {
|
||||
expect(check.checkId).toBe('check.database.connectivity');
|
||||
expect(check.status).toBe('passed');
|
||||
expect(check.message).toBe('Check passed successfully');
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('testConnection', () => {
|
||||
it('should return success for valid config', (done) => {
|
||||
service.testConnection('database', { 'database.host': 'localhost' }).subscribe((result) => {
|
||||
expect(result.success).toBeTrue();
|
||||
expect(result.message).toBe('Connection successful');
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveConfiguration', () => {
|
||||
it('should return success', (done) => {
|
||||
service.saveConfiguration('session-id', { key: 'value' }).subscribe((result) => {
|
||||
expect(result.success).toBeTrue();
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('finalizeSetup', () => {
|
||||
it('should return success with restart message', (done) => {
|
||||
service.finalizeSetup('session-id').subscribe((result) => {
|
||||
expect(result.success).toBeTrue();
|
||||
expect(result.message).toContain('restart');
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* @file setup-wizard-api.service.ts
|
||||
* @sprint Sprint 4: UI Wizard Core
|
||||
* @description API service for setup wizard backend communication
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
import {
|
||||
SetupStep,
|
||||
SetupStepId,
|
||||
SetupSession,
|
||||
SetupStepResult,
|
||||
ExecuteStepRequest,
|
||||
SkipStepRequest,
|
||||
ValidationCheck,
|
||||
PrerequisiteResult,
|
||||
DEFAULT_SETUP_STEPS,
|
||||
} from '../models/setup-wizard.models';
|
||||
|
||||
/** API response wrapper */
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* API service for setup wizard operations.
|
||||
* Communicates with the CLI/Platform backend for setup operations.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SetupWizardApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = '/api/v1/setup';
|
||||
|
||||
/**
|
||||
* Create a new setup session
|
||||
*/
|
||||
createSession(): Observable<SetupSession> {
|
||||
// For now, return a mock session
|
||||
// TODO: Replace with actual API call when backend is ready
|
||||
const session: SetupSession = {
|
||||
sessionId: crypto.randomUUID(),
|
||||
startedAt: new Date().toISOString(),
|
||||
completedSteps: [],
|
||||
skippedSteps: [],
|
||||
configValues: {},
|
||||
};
|
||||
return of(session).pipe(delay(300));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume an existing setup session
|
||||
*/
|
||||
resumeSession(sessionId: string): Observable<SetupSession | null> {
|
||||
// TODO: Replace with actual API call
|
||||
return of(null).pipe(delay(300));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available setup steps
|
||||
*/
|
||||
getSteps(): Observable<SetupStep[]> {
|
||||
// TODO: Replace with actual API call
|
||||
return of([...DEFAULT_SETUP_STEPS]).pipe(delay(200));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific setup step
|
||||
*/
|
||||
getStep(stepId: SetupStepId): Observable<SetupStep | null> {
|
||||
const step = DEFAULT_SETUP_STEPS.find(s => s.id === stepId);
|
||||
return of(step ?? null).pipe(delay(100));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check prerequisites for a step
|
||||
*/
|
||||
checkPrerequisites(
|
||||
sessionId: string,
|
||||
stepId: SetupStepId,
|
||||
configValues: Record<string, string>
|
||||
): Observable<PrerequisiteResult> {
|
||||
// TODO: Replace with actual API call
|
||||
// Mock: always return met for now
|
||||
return of({ met: true, missingPrerequisites: [] }).pipe(delay(500));
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a setup step
|
||||
*/
|
||||
executeStep(request: ExecuteStepRequest): Observable<SetupStepResult> {
|
||||
// TODO: Replace with actual API call
|
||||
// Mock successful execution
|
||||
const result: SetupStepResult = {
|
||||
stepId: request.stepId,
|
||||
status: 'completed',
|
||||
message: request.dryRun
|
||||
? `[DRY RUN] Step ${request.stepId} would be configured`
|
||||
: `Step ${request.stepId} configured successfully`,
|
||||
appliedConfig: request.configValues,
|
||||
canRetry: true,
|
||||
};
|
||||
return of(result).pipe(delay(1500));
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip a setup step
|
||||
*/
|
||||
skipStep(request: SkipStepRequest): Observable<SetupStepResult> {
|
||||
const result: SetupStepResult = {
|
||||
stepId: request.stepId,
|
||||
status: 'skipped',
|
||||
message: `Step ${request.stepId} skipped: ${request.reason}`,
|
||||
canRetry: false,
|
||||
};
|
||||
return of(result).pipe(delay(300));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run validation checks for a step
|
||||
*/
|
||||
runValidationChecks(
|
||||
sessionId: string,
|
||||
stepId: SetupStepId
|
||||
): Observable<ValidationCheck[]> {
|
||||
// TODO: Replace with actual API call
|
||||
// Mock validation checks based on step
|
||||
const step = DEFAULT_SETUP_STEPS.find(s => s.id === stepId);
|
||||
if (!step) return of([]);
|
||||
|
||||
const checks: ValidationCheck[] = step.validationChecks.map(checkId => ({
|
||||
checkId,
|
||||
name: this.getCheckName(checkId),
|
||||
description: this.getCheckDescription(checkId),
|
||||
status: 'pending',
|
||||
severity: 'info',
|
||||
}));
|
||||
|
||||
return of(checks).pipe(delay(200));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a specific validation check
|
||||
*/
|
||||
runValidationCheck(
|
||||
sessionId: string,
|
||||
checkId: string,
|
||||
configValues: Record<string, string>
|
||||
): Observable<ValidationCheck> {
|
||||
// TODO: Replace with actual API call
|
||||
// Mock: simulate check running and passing
|
||||
const check: ValidationCheck = {
|
||||
checkId,
|
||||
name: this.getCheckName(checkId),
|
||||
description: this.getCheckDescription(checkId),
|
||||
status: 'passed',
|
||||
severity: 'info',
|
||||
message: 'Check passed successfully',
|
||||
};
|
||||
return of(check).pipe(delay(800 + Math.random() * 400));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection for a step configuration
|
||||
*/
|
||||
testConnection(
|
||||
stepId: SetupStepId,
|
||||
configValues: Record<string, string>
|
||||
): Observable<{ success: boolean; message: string }> {
|
||||
// TODO: Replace with actual API call
|
||||
// Mock successful connection
|
||||
return of({
|
||||
success: true,
|
||||
message: 'Connection successful',
|
||||
}).pipe(delay(1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the completed setup configuration
|
||||
*/
|
||||
saveConfiguration(
|
||||
sessionId: string,
|
||||
configValues: Record<string, string>
|
||||
): Observable<{ success: boolean }> {
|
||||
// TODO: Replace with actual API call
|
||||
return of({ success: true }).pipe(delay(500));
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize the setup wizard
|
||||
*/
|
||||
finalizeSetup(sessionId: string): Observable<{ success: boolean; message: string }> {
|
||||
// TODO: Replace with actual API call
|
||||
return of({
|
||||
success: true,
|
||||
message: 'Setup completed successfully. Please restart the services to apply configuration.',
|
||||
}).pipe(delay(1000));
|
||||
}
|
||||
|
||||
// === Helper Methods ===
|
||||
|
||||
private getCheckName(checkId: string): string {
|
||||
const names: Record<string, string> = {
|
||||
'check.database.connectivity': 'Database Connectivity',
|
||||
'check.database.migrations': 'Database Migrations',
|
||||
'check.cache.connectivity': 'Cache Connectivity',
|
||||
'check.cache.persistence': 'Cache Persistence',
|
||||
'check.integration.vault.connectivity': 'Vault Connectivity',
|
||||
'check.integration.vault.auth': 'Vault Authentication',
|
||||
'check.integration.settingsstore.connectivity': 'Settings Store Connectivity',
|
||||
'check.integration.settingsstore.auth': 'Settings Store Authentication',
|
||||
'check.integration.registry.connectivity': 'Registry Connectivity',
|
||||
'check.integration.registry.auth': 'Registry Authentication',
|
||||
'check.telemetry.otlp.connectivity': 'OTLP Endpoint Connectivity',
|
||||
};
|
||||
return names[checkId] ?? checkId;
|
||||
}
|
||||
|
||||
private getCheckDescription(checkId: string): string {
|
||||
const descriptions: Record<string, string> = {
|
||||
'check.database.connectivity': 'Verify connection to the PostgreSQL database',
|
||||
'check.database.migrations': 'Check that database migrations are up to date',
|
||||
'check.cache.connectivity': 'Verify connection to the cache server',
|
||||
'check.cache.persistence': 'Check cache persistence configuration',
|
||||
'check.integration.vault.connectivity': 'Verify connection to the secrets vault',
|
||||
'check.integration.vault.auth': 'Verify vault authentication credentials',
|
||||
'check.integration.settingsstore.connectivity': 'Verify connection to the settings store',
|
||||
'check.integration.settingsstore.auth': 'Verify settings store authentication',
|
||||
'check.integration.registry.connectivity': 'Verify connection to the container registry',
|
||||
'check.integration.registry.auth': 'Verify registry authentication credentials',
|
||||
'check.telemetry.otlp.connectivity': 'Verify connection to the OTLP endpoint',
|
||||
};
|
||||
return descriptions[checkId] ?? 'Validation check';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* @file setup-wizard-state.service.spec.ts
|
||||
* @sprint Sprint 5: UI Integrations + Settings Store
|
||||
* @description Unit tests for SetupWizardStateService
|
||||
*/
|
||||
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { SetupWizardStateService } from './setup-wizard-state.service';
|
||||
import {
|
||||
SetupSession,
|
||||
SetupStep,
|
||||
DEFAULT_SETUP_STEPS,
|
||||
} from '../models/setup-wizard.models';
|
||||
|
||||
describe('SetupWizardStateService', () => {
|
||||
let service: SetupWizardStateService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [SetupWizardStateService],
|
||||
});
|
||||
service = TestBed.inject(SetupWizardStateService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should have default steps', () => {
|
||||
expect(service.steps().length).toBe(DEFAULT_SETUP_STEPS.length);
|
||||
});
|
||||
|
||||
it('should have no current step initially', () => {
|
||||
expect(service.currentStepId()).toBeNull();
|
||||
});
|
||||
|
||||
it('should have empty config values', () => {
|
||||
expect(service.configValues()).toEqual({});
|
||||
});
|
||||
|
||||
it('should not be loading initially', () => {
|
||||
expect(service.loading()).toBeFalse();
|
||||
});
|
||||
|
||||
it('should not be executing initially', () => {
|
||||
expect(service.executing()).toBeFalse();
|
||||
});
|
||||
|
||||
it('should have dry run mode enabled by default', () => {
|
||||
expect(service.dryRunMode()).toBeTrue();
|
||||
});
|
||||
|
||||
it('should have 0% progress initially', () => {
|
||||
expect(service.progressPercent()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeSession', () => {
|
||||
const mockSession: SetupSession = {
|
||||
sessionId: 'test-session-123',
|
||||
startedAt: new Date().toISOString(),
|
||||
completedSteps: ['database'],
|
||||
skippedSteps: ['vault'],
|
||||
configValues: { 'database.host': 'localhost' },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
service.initializeSession(mockSession);
|
||||
});
|
||||
|
||||
it('should set the session', () => {
|
||||
expect(service.session()).toEqual(mockSession);
|
||||
});
|
||||
|
||||
it('should set config values from session', () => {
|
||||
expect(service.configValues()['database.host']).toBe('localhost');
|
||||
});
|
||||
|
||||
it('should mark completed steps', () => {
|
||||
const dbStep = service.steps().find(s => s.id === 'database');
|
||||
expect(dbStep?.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('should mark skipped steps', () => {
|
||||
const vaultStep = service.steps().find(s => s.id === 'vault');
|
||||
expect(vaultStep?.status).toBe('skipped');
|
||||
});
|
||||
|
||||
it('should set current step to first pending step', () => {
|
||||
// After database (completed) and vault (skipped), cache should be next
|
||||
expect(service.currentStepId()).toBe('cache');
|
||||
});
|
||||
});
|
||||
|
||||
describe('navigation', () => {
|
||||
beforeEach(() => {
|
||||
const session: SetupSession = {
|
||||
sessionId: 'test',
|
||||
startedAt: new Date().toISOString(),
|
||||
completedSteps: [],
|
||||
skippedSteps: [],
|
||||
configValues: {},
|
||||
};
|
||||
service.initializeSession(session);
|
||||
});
|
||||
|
||||
it('should navigate to a step', () => {
|
||||
service.goToStep('cache');
|
||||
expect(service.currentStepId()).toBe('cache');
|
||||
});
|
||||
|
||||
it('should navigate to next step', () => {
|
||||
service.currentStepId.set('database');
|
||||
service.updateStepStatus('database', 'completed');
|
||||
service.goToNextStep();
|
||||
expect(service.currentStepId()).toBe('cache');
|
||||
});
|
||||
|
||||
it('should navigate to previous step', () => {
|
||||
service.currentStepId.set('cache');
|
||||
service.goToPreviousStep();
|
||||
expect(service.currentStepId()).toBe('database');
|
||||
});
|
||||
|
||||
it('should not navigate past first step', () => {
|
||||
service.currentStepId.set('database');
|
||||
service.goToPreviousStep();
|
||||
expect(service.currentStepId()).toBe('database');
|
||||
});
|
||||
});
|
||||
|
||||
describe('config values', () => {
|
||||
it('should set a single config value', () => {
|
||||
service.setConfigValue('database.host', 'db.example.com');
|
||||
expect(service.configValues()['database.host']).toBe('db.example.com');
|
||||
});
|
||||
|
||||
it('should set multiple config values', () => {
|
||||
service.setConfigValues({
|
||||
'database.host': 'localhost',
|
||||
'database.port': '5432',
|
||||
});
|
||||
expect(service.configValues()['database.host']).toBe('localhost');
|
||||
expect(service.configValues()['database.port']).toBe('5432');
|
||||
});
|
||||
|
||||
it('should merge config values', () => {
|
||||
service.setConfigValue('database.host', 'localhost');
|
||||
service.setConfigValue('database.port', '5432');
|
||||
expect(service.configValues()).toEqual({
|
||||
'database.host': 'localhost',
|
||||
'database.port': '5432',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('step status', () => {
|
||||
beforeEach(() => {
|
||||
service.currentStepId.set('database');
|
||||
});
|
||||
|
||||
it('should mark step as in progress', () => {
|
||||
service.markCurrentStepInProgress();
|
||||
const step = service.steps().find(s => s.id === 'database');
|
||||
expect(step?.status).toBe('in_progress');
|
||||
});
|
||||
|
||||
it('should mark step as completed', () => {
|
||||
const appliedConfig = { 'database.host': 'localhost' };
|
||||
service.markCurrentStepCompleted(appliedConfig);
|
||||
const step = service.steps().find(s => s.id === 'database');
|
||||
expect(step?.status).toBe('completed');
|
||||
expect(step?.appliedConfig).toEqual(appliedConfig);
|
||||
});
|
||||
|
||||
it('should mark step as failed with error', () => {
|
||||
service.markCurrentStepFailed('Connection failed');
|
||||
const step = service.steps().find(s => s.id === 'database');
|
||||
expect(step?.status).toBe('failed');
|
||||
expect(step?.error).toBe('Connection failed');
|
||||
});
|
||||
|
||||
it('should mark step as skipped', () => {
|
||||
service.currentStepId.set('vault');
|
||||
service.markCurrentStepSkipped();
|
||||
const step = service.steps().find(s => s.id === 'vault');
|
||||
expect(step?.status).toBe('skipped');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computed values', () => {
|
||||
it('should compute ordered steps', () => {
|
||||
const ordered = service.orderedSteps();
|
||||
expect(ordered[0].id).toBe('database');
|
||||
expect(ordered[1].id).toBe('cache');
|
||||
});
|
||||
|
||||
it('should compute steps by category', () => {
|
||||
const byCategory = service.stepsByCategory();
|
||||
expect(byCategory.get('Infrastructure')?.length).toBe(2);
|
||||
expect(byCategory.get('Security')?.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should compute pending required steps', () => {
|
||||
const pending = service.pendingRequiredSteps();
|
||||
expect(pending.length).toBe(2); // database and cache
|
||||
});
|
||||
|
||||
it('should compute allRequiredComplete', () => {
|
||||
expect(service.allRequiredComplete()).toBeFalse();
|
||||
|
||||
// Complete required steps
|
||||
service.updateStepStatus('database', 'completed');
|
||||
service.updateStepStatus('cache', 'completed');
|
||||
|
||||
expect(service.allRequiredComplete()).toBeTrue();
|
||||
});
|
||||
|
||||
it('should compute progress percentage', () => {
|
||||
expect(service.progressPercent()).toBe(0);
|
||||
|
||||
// Complete 2 of 6 steps
|
||||
service.updateStepStatus('database', 'completed');
|
||||
service.updateStepStatus('cache', 'completed');
|
||||
|
||||
expect(service.progressPercent()).toBe(33); // 2/6 = 33%
|
||||
});
|
||||
|
||||
it('should compute current step', () => {
|
||||
service.currentStepId.set('database');
|
||||
expect(service.currentStep()?.id).toBe('database');
|
||||
});
|
||||
|
||||
it('should compute canSkipCurrentStep', () => {
|
||||
service.currentStepId.set('database');
|
||||
expect(service.canSkipCurrentStep()).toBeFalse();
|
||||
|
||||
service.currentStepId.set('vault');
|
||||
expect(service.canSkipCurrentStep()).toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation checks', () => {
|
||||
it('should set validation checks', () => {
|
||||
const checks = [
|
||||
{ checkId: 'check.database.connectivity', name: 'Connectivity', description: 'Test', status: 'pending' as const, severity: 'info' as const },
|
||||
];
|
||||
service.setValidationChecks(checks);
|
||||
expect(service.validationChecks()).toEqual(checks);
|
||||
});
|
||||
|
||||
it('should update a validation check', () => {
|
||||
const checks = [
|
||||
{ checkId: 'check.database.connectivity', name: 'Connectivity', description: 'Test', status: 'pending' as const, severity: 'info' as const },
|
||||
];
|
||||
service.setValidationChecks(checks);
|
||||
service.updateValidationCheck('check.database.connectivity', { status: 'passed', message: 'OK' });
|
||||
|
||||
expect(service.validationChecks()[0].status).toBe('passed');
|
||||
expect(service.validationChecks()[0].message).toBe('OK');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should reset all state', () => {
|
||||
// Set some state
|
||||
service.currentStepId.set('database');
|
||||
service.setConfigValue('key', 'value');
|
||||
service.loading.set(true);
|
||||
service.error.set('Some error');
|
||||
|
||||
// Reset
|
||||
service.reset();
|
||||
|
||||
expect(service.session()).toBeNull();
|
||||
expect(service.currentStepId()).toBeNull();
|
||||
expect(service.configValues()).toEqual({});
|
||||
expect(service.loading()).toBeFalse();
|
||||
expect(service.error()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* @file setup-wizard-state.service.ts
|
||||
* @sprint Sprint 4: UI Wizard Core
|
||||
* @description State management service for the setup wizard using Angular signals
|
||||
*/
|
||||
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
import {
|
||||
SetupStep,
|
||||
SetupStepId,
|
||||
SetupStepStatus,
|
||||
SetupSession,
|
||||
WizardMode,
|
||||
ValidationCheck,
|
||||
DEFAULT_SETUP_STEPS,
|
||||
} from '../models/setup-wizard.models';
|
||||
|
||||
/** Wizard navigation state */
|
||||
interface WizardNavigation {
|
||||
currentStepIndex: number;
|
||||
canGoBack: boolean;
|
||||
canGoNext: boolean;
|
||||
canComplete: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* State service for the setup wizard.
|
||||
* Uses Angular signals for reactive state management.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SetupWizardStateService {
|
||||
// === Core State Signals ===
|
||||
|
||||
/** Current wizard mode */
|
||||
readonly mode = signal<WizardMode>('setup');
|
||||
|
||||
/** Current wizard session */
|
||||
readonly session = signal<SetupSession | null>(null);
|
||||
|
||||
/** All setup steps */
|
||||
readonly steps = signal<SetupStep[]>([...DEFAULT_SETUP_STEPS]);
|
||||
|
||||
/** Current step ID */
|
||||
readonly currentStepId = signal<SetupStepId | null>(null);
|
||||
|
||||
/** Configuration values entered by user */
|
||||
readonly configValues = signal<Record<string, string>>({});
|
||||
|
||||
/** Validation checks for current step */
|
||||
readonly validationChecks = signal<ValidationCheck[]>([]);
|
||||
|
||||
/** Whether the wizard is loading */
|
||||
readonly loading = signal(false);
|
||||
|
||||
/** Whether a step is executing */
|
||||
readonly executing = signal(false);
|
||||
|
||||
/** Global error message */
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
/** Whether dry-run mode is enabled */
|
||||
readonly dryRunMode = signal(true);
|
||||
|
||||
// === Computed Signals ===
|
||||
|
||||
/** Current step object */
|
||||
readonly currentStep = computed(() => {
|
||||
const stepId = this.currentStepId();
|
||||
return this.steps().find(s => s.id === stepId) ?? null;
|
||||
});
|
||||
|
||||
/** Ordered steps for display */
|
||||
readonly orderedSteps = computed(() => {
|
||||
return [...this.steps()].sort((a, b) => a.order - b.order);
|
||||
});
|
||||
|
||||
/** Current step index in ordered list */
|
||||
readonly currentStepIndex = computed(() => {
|
||||
const stepId = this.currentStepId();
|
||||
return this.orderedSteps().findIndex(s => s.id === stepId);
|
||||
});
|
||||
|
||||
/** Steps grouped by category */
|
||||
readonly stepsByCategory = computed(() => {
|
||||
const grouped = new Map<string, SetupStep[]>();
|
||||
for (const step of this.orderedSteps()) {
|
||||
const existing = grouped.get(step.category) ?? [];
|
||||
grouped.set(step.category, [...existing, step]);
|
||||
}
|
||||
return grouped;
|
||||
});
|
||||
|
||||
/** Required steps that are not yet completed */
|
||||
readonly pendingRequiredSteps = computed(() => {
|
||||
return this.steps().filter(
|
||||
s => s.isRequired && s.status !== 'completed' && s.status !== 'skipped'
|
||||
);
|
||||
});
|
||||
|
||||
/** All required steps completed */
|
||||
readonly allRequiredComplete = computed(() => {
|
||||
return this.pendingRequiredSteps().length === 0;
|
||||
});
|
||||
|
||||
/** Progress percentage (0-100) */
|
||||
readonly progressPercent = computed(() => {
|
||||
const allSteps = this.steps();
|
||||
if (allSteps.length === 0) return 0;
|
||||
|
||||
const completedOrSkipped = allSteps.filter(
|
||||
s => s.status === 'completed' || s.status === 'skipped'
|
||||
).length;
|
||||
|
||||
return Math.round((completedOrSkipped / allSteps.length) * 100);
|
||||
});
|
||||
|
||||
/** Navigation state */
|
||||
readonly navigation = computed<WizardNavigation>(() => {
|
||||
const index = this.currentStepIndex();
|
||||
const ordered = this.orderedSteps();
|
||||
const current = this.currentStep();
|
||||
|
||||
return {
|
||||
currentStepIndex: index,
|
||||
canGoBack: index > 0,
|
||||
canGoNext: index < ordered.length - 1 && this.canProceedFromCurrentStep(),
|
||||
canComplete: this.allRequiredComplete(),
|
||||
};
|
||||
});
|
||||
|
||||
/** Whether current step can be skipped */
|
||||
readonly canSkipCurrentStep = computed(() => {
|
||||
const step = this.currentStep();
|
||||
return step?.isSkippable ?? false;
|
||||
});
|
||||
|
||||
/** Whether current step's dependencies are met */
|
||||
readonly dependenciesMet = computed(() => {
|
||||
const step = this.currentStep();
|
||||
if (!step) return false;
|
||||
|
||||
const completedIds = new Set(
|
||||
this.steps()
|
||||
.filter(s => s.status === 'completed')
|
||||
.map(s => s.id)
|
||||
);
|
||||
|
||||
return step.dependencies.every(depId => completedIds.has(depId));
|
||||
});
|
||||
|
||||
// === State Mutation Methods ===
|
||||
|
||||
/**
|
||||
* Initialize the wizard with a session
|
||||
*/
|
||||
initializeSession(session: SetupSession): void {
|
||||
this.session.set(session);
|
||||
this.configValues.set({ ...session.configValues });
|
||||
|
||||
// Update step statuses based on session
|
||||
this.steps.update(steps =>
|
||||
steps.map(step => ({
|
||||
...step,
|
||||
status: session.completedSteps.includes(step.id)
|
||||
? 'completed'
|
||||
: session.skippedSteps.includes(step.id)
|
||||
? 'skipped'
|
||||
: 'pending',
|
||||
}))
|
||||
);
|
||||
|
||||
// Set current step
|
||||
if (session.currentStep) {
|
||||
this.currentStepId.set(session.currentStep);
|
||||
} else {
|
||||
// Find first pending step
|
||||
const firstPending = this.orderedSteps().find(s => s.status === 'pending');
|
||||
if (firstPending) {
|
||||
this.currentStepId.set(firstPending.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a specific step
|
||||
*/
|
||||
goToStep(stepId: SetupStepId): void {
|
||||
const step = this.steps().find(s => s.id === stepId);
|
||||
if (!step) return;
|
||||
|
||||
// Check if we can navigate to this step
|
||||
const stepIndex = this.orderedSteps().findIndex(s => s.id === stepId);
|
||||
const currentIndex = this.currentStepIndex();
|
||||
|
||||
// Can always go back
|
||||
if (stepIndex < currentIndex) {
|
||||
this.currentStepId.set(stepId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Can only go forward if all previous steps are completed/skipped
|
||||
const canNavigate = this.orderedSteps()
|
||||
.slice(0, stepIndex)
|
||||
.every(s => s.status === 'completed' || s.status === 'skipped');
|
||||
|
||||
if (canNavigate) {
|
||||
this.currentStepId.set(stepId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to next step
|
||||
*/
|
||||
goToNextStep(): void {
|
||||
const ordered = this.orderedSteps();
|
||||
const currentIndex = this.currentStepIndex();
|
||||
|
||||
if (currentIndex < ordered.length - 1) {
|
||||
this.currentStepId.set(ordered[currentIndex + 1].id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to previous step
|
||||
*/
|
||||
goToPreviousStep(): void {
|
||||
const ordered = this.orderedSteps();
|
||||
const currentIndex = this.currentStepIndex();
|
||||
|
||||
if (currentIndex > 0) {
|
||||
this.currentStepId.set(ordered[currentIndex - 1].id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a configuration value
|
||||
*/
|
||||
setConfigValue(key: string, value: string): void {
|
||||
this.configValues.update(values => ({
|
||||
...values,
|
||||
[key]: value,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update multiple configuration values
|
||||
*/
|
||||
setConfigValues(values: Record<string, string>): void {
|
||||
this.configValues.update(current => ({
|
||||
...current,
|
||||
...values,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update step status
|
||||
*/
|
||||
updateStepStatus(stepId: SetupStepId, status: SetupStepStatus, error?: string): void {
|
||||
this.steps.update(steps =>
|
||||
steps.map(step =>
|
||||
step.id === stepId
|
||||
? { ...step, status, error: error ?? undefined }
|
||||
: step
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark current step as in progress
|
||||
*/
|
||||
markCurrentStepInProgress(): void {
|
||||
const stepId = this.currentStepId();
|
||||
if (stepId) {
|
||||
this.updateStepStatus(stepId, 'in_progress');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark current step as completed with applied config
|
||||
*/
|
||||
markCurrentStepCompleted(appliedConfig?: Record<string, string>): void {
|
||||
const stepId = this.currentStepId();
|
||||
if (stepId) {
|
||||
this.steps.update(steps =>
|
||||
steps.map(step =>
|
||||
step.id === stepId
|
||||
? { ...step, status: 'completed', appliedConfig, error: undefined }
|
||||
: step
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark current step as failed
|
||||
*/
|
||||
markCurrentStepFailed(error: string): void {
|
||||
const stepId = this.currentStepId();
|
||||
if (stepId) {
|
||||
this.updateStepStatus(stepId, 'failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark current step as skipped
|
||||
*/
|
||||
markCurrentStepSkipped(): void {
|
||||
const stepId = this.currentStepId();
|
||||
if (stepId) {
|
||||
this.updateStepStatus(stepId, 'skipped');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set validation checks for current step
|
||||
*/
|
||||
setValidationChecks(checks: ValidationCheck[]): void {
|
||||
this.validationChecks.set(checks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific validation check
|
||||
*/
|
||||
updateValidationCheck(
|
||||
checkId: string,
|
||||
update: Partial<ValidationCheck>
|
||||
): void {
|
||||
this.validationChecks.update(checks =>
|
||||
checks.map(check =>
|
||||
check.checkId === checkId ? { ...check, ...update } : check
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the wizard state
|
||||
*/
|
||||
reset(): void {
|
||||
this.session.set(null);
|
||||
this.steps.set([...DEFAULT_SETUP_STEPS]);
|
||||
this.currentStepId.set(null);
|
||||
this.configValues.set({});
|
||||
this.validationChecks.set([]);
|
||||
this.loading.set(false);
|
||||
this.executing.set(false);
|
||||
this.error.set(null);
|
||||
}
|
||||
|
||||
// === Private Helper Methods ===
|
||||
|
||||
private canProceedFromCurrentStep(): boolean {
|
||||
const step = this.currentStep();
|
||||
if (!step) return false;
|
||||
|
||||
// Can proceed if step is completed or skipped
|
||||
return step.status === 'completed' || step.status === 'skipped';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* @file setup-wizard.routes.ts
|
||||
* @sprint Sprint 4: UI Wizard Core
|
||||
* @description Routes for the setup wizard feature
|
||||
*/
|
||||
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const setupWizardRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () =>
|
||||
import('./components/setup-wizard.component').then(
|
||||
(m) => m.SetupWizardComponent
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -15,7 +15,10 @@ export { AiVexDraftChipComponent, type VexDraftState } from './ai-vex-draft-chip
|
||||
export { AiNeedsEvidenceChipComponent, type EvidenceType } from './ai-needs-evidence-chip.component';
|
||||
export { AiExploitabilityChipComponent, type ExploitabilityLevel } from './ai-exploitability-chip.component';
|
||||
|
||||
// Panels (to be created)
|
||||
// Panels
|
||||
export { AiAssistPanelComponent } from './ai-assist-panel.component';
|
||||
export { AskStellaButtonComponent } from './ask-stella-button.component';
|
||||
export { AskStellaPanelComponent } from './ask-stella-panel.component';
|
||||
|
||||
// Status components
|
||||
export { LlmUnavailableComponent } from './llm-unavailable.component';
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* LLM Unavailable Component.
|
||||
* Sprint: Sprint 9: LLM Provider Setup
|
||||
*
|
||||
* Displays when AdvisoryAI features are unavailable because
|
||||
* no LLM provider has been configured. Provides guidance on
|
||||
* how to configure an LLM provider.
|
||||
*/
|
||||
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'stella-llm-unavailable',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="llm-unavailable" [class]="'llm-unavailable--' + variant()">
|
||||
<div class="llm-unavailable__icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2Z" opacity="0.2"/>
|
||||
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2Z"/>
|
||||
<path d="M12 8v4M12 16h.01"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="llm-unavailable__content">
|
||||
<h3 class="llm-unavailable__title">{{ title() }}</h3>
|
||||
<p class="llm-unavailable__message">{{ message() }}</p>
|
||||
|
||||
@if (showProviderList()) {
|
||||
<div class="llm-unavailable__providers">
|
||||
<p class="llm-unavailable__providers-label">Supported providers:</p>
|
||||
<ul class="llm-unavailable__providers-list">
|
||||
<li>OpenAI (GPT-4o, GPT-4, GPT-3.5)</li>
|
||||
<li>Anthropic Claude (Claude 4, Claude 3.5)</li>
|
||||
<li>Google Gemini (Gemini 1.5 Pro, Flash)</li>
|
||||
<li>Ollama (Local LLM)</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showCliHint()) {
|
||||
<div class="llm-unavailable__cli-hint">
|
||||
<code>stella setup --step llm</code>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="llm-unavailable__actions">
|
||||
@if (showConfigureButton()) {
|
||||
<button
|
||||
type="button"
|
||||
class="llm-unavailable__btn llm-unavailable__btn--primary"
|
||||
(click)="onConfigureClick()"
|
||||
>
|
||||
Configure AI Provider
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (showDismiss()) {
|
||||
<button
|
||||
type="button"
|
||||
class="llm-unavailable__btn llm-unavailable__btn--secondary"
|
||||
(click)="onDismissClick()"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.llm-unavailable {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
background: #fafafa;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.llm-unavailable--inline {
|
||||
padding: 1rem 1.5rem;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
text-align: left;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.llm-unavailable--inline .llm-unavailable__icon {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.llm-unavailable--inline .llm-unavailable__icon svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.llm-unavailable--inline .llm-unavailable__content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.llm-unavailable--inline .llm-unavailable__actions {
|
||||
margin-top: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.llm-unavailable--warning {
|
||||
background: #fff3e0;
|
||||
border-color: #ff9800;
|
||||
}
|
||||
|
||||
.llm-unavailable--warning .llm-unavailable__icon {
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.llm-unavailable--info {
|
||||
background: #e3f2fd;
|
||||
border-color: #90caf9;
|
||||
}
|
||||
|
||||
.llm-unavailable--info .llm-unavailable__icon {
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.llm-unavailable__icon {
|
||||
color: #666;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.llm-unavailable__icon svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.llm-unavailable__content {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.llm-unavailable--inline .llm-unavailable__content {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.llm-unavailable__title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.llm-unavailable__message {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.llm-unavailable__providers {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.llm-unavailable__providers-label {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #999;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.llm-unavailable__providers-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.llm-unavailable__providers-list li {
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 16px;
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.llm-unavailable__cli-hint {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.llm-unavailable__cli-hint code {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #1a1a1a;
|
||||
color: #4ade80;
|
||||
border-radius: 6px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.llm-unavailable__actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.llm-unavailable__btn {
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.llm-unavailable__btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.llm-unavailable__btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.llm-unavailable__btn--primary {
|
||||
background: #4f46e5;
|
||||
color: white;
|
||||
border-color: #4f46e5;
|
||||
}
|
||||
|
||||
.llm-unavailable__btn--primary:hover {
|
||||
background: #4338ca;
|
||||
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
|
||||
}
|
||||
|
||||
.llm-unavailable__btn--secondary {
|
||||
background: white;
|
||||
color: #666;
|
||||
border-color: #ddd;
|
||||
}
|
||||
|
||||
.llm-unavailable__btn--secondary:hover {
|
||||
background: #f5f5f5;
|
||||
border-color: #ccc;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class LlmUnavailableComponent {
|
||||
/**
|
||||
* Component variant: 'card' (default), 'inline', 'warning', 'info'
|
||||
*/
|
||||
readonly variant = input<'card' | 'inline' | 'warning' | 'info'>('card');
|
||||
|
||||
/**
|
||||
* Title text
|
||||
*/
|
||||
readonly title = input<string>('AI Assistant Unavailable');
|
||||
|
||||
/**
|
||||
* Message text
|
||||
*/
|
||||
readonly message = input<string>('An LLM provider must be configured to use AdvisoryAI features.');
|
||||
|
||||
/**
|
||||
* Show list of supported providers
|
||||
*/
|
||||
readonly showProviderList = input<boolean>(true);
|
||||
|
||||
/**
|
||||
* Show CLI hint command
|
||||
*/
|
||||
readonly showCliHint = input<boolean>(true);
|
||||
|
||||
/**
|
||||
* Show configure button
|
||||
*/
|
||||
readonly showConfigureButton = input<boolean>(true);
|
||||
|
||||
/**
|
||||
* Show dismiss button
|
||||
*/
|
||||
readonly showDismiss = input<boolean>(false);
|
||||
|
||||
/**
|
||||
* Route to navigate when configure is clicked
|
||||
*/
|
||||
readonly configureRoute = input<string>('/setup?step=llm');
|
||||
|
||||
/**
|
||||
* Emits when configure button is clicked
|
||||
*/
|
||||
readonly configure = output<void>();
|
||||
|
||||
/**
|
||||
* Emits when dismiss button is clicked
|
||||
*/
|
||||
readonly dismiss = output<void>();
|
||||
|
||||
constructor(private readonly router: Router) {}
|
||||
|
||||
/**
|
||||
* Handle configure button click
|
||||
*/
|
||||
onConfigureClick(): void {
|
||||
this.configure.emit();
|
||||
const route = this.configureRoute();
|
||||
if (route) {
|
||||
this.router.navigateByUrl(route);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle dismiss button click
|
||||
*/
|
||||
onDismissClick(): void {
|
||||
this.dismiss.emit();
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
/c/dev/New folder/git.stella-ops.org/src/Web/StellaOps.Web
|
||||
@@ -1 +0,0 @@
|
||||
/c/dev/New folder/git.stella-ops.org/src/Web/StellaOps.Web
|
||||
@@ -1 +0,0 @@
|
||||
/c/dev/New folder/git.stella-ops.org/src/Web/StellaOps.Web
|
||||
@@ -1,6 +1,6 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": [
|
||||
@@ -11,4 +11,4 @@
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user