audit notes work completed, test fixes work (95% done), new sprints, new data sources setup and configuration

This commit is contained in:
master
2026-01-14 10:48:00 +02:00
parent d7be6ba34b
commit 95d5898650
379 changed files with 40695 additions and 19041 deletions

View File

@@ -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: '**',

View File

@@ -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);
}));
});
});

View File

@@ -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">&#9881;</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');
},
});
}
}

View File

@@ -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);
}
});
});
});

View File

@@ -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">
&times;
</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">&#10003;</span>
}
@case ('failed') {
<span class="check-icon failed">&#10007;</span>
}
@case ('warning') {
<span class="check-icon warning">&#9888;</span>
}
@case ('running') {
<span class="check-spinner"></span>
}
@default {
<span class="check-icon pending">&#9711;</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 });
}
}

View File

@@ -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);
});
});
});

View File

@@ -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'">
&#8635;
</button>
<button
class="btn-icon"
title="Refresh Status"
(click)="refreshStatus.emit(integration)">
&#x21bb;
</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];
}
}

View File

@@ -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',
},
];

View File

@@ -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';

View File

@@ -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 },
],
},
];

View File

@@ -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);
}));
});
});

View File

@@ -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));
}
}

View File

@@ -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();
});
});
});

View File

@@ -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');
}
}

View File

@@ -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();
});
});
});

View File

@@ -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);
}
}

View File

@@ -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',
});
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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);
}
}

View 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';

View File

@@ -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',
},
];

View File

@@ -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();
});
});
});
});

View File

@@ -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';
}
}

View File

@@ -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();
});
});
});

View File

@@ -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';
}
}

View File

@@ -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
),
},
];

View File

@@ -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';

View File

@@ -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();
}
}

View File

@@ -1 +0,0 @@
/c/dev/New folder/git.stella-ops.org/src/Web/StellaOps.Web

View File

@@ -1 +0,0 @@
/c/dev/New folder/git.stella-ops.org/src/Web/StellaOps.Web

View File

@@ -1 +0,0 @@
/c/dev/New folder/git.stella-ops.org/src/Web/StellaOps.Web

View File

@@ -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"
]
}
}