audit work, doctors work
This commit is contained in:
@@ -151,6 +151,11 @@ import {
|
||||
ReleaseEvidenceHttpClient,
|
||||
MockReleaseEvidenceClient,
|
||||
} from './core/api/release-evidence.client';
|
||||
import {
|
||||
DOCTOR_API,
|
||||
HttpDoctorClient,
|
||||
MockDoctorClient,
|
||||
} from './features/doctor/services/doctor.client';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
@@ -679,5 +684,17 @@ export const appConfig: ApplicationConfig = {
|
||||
mock: MockReleaseEvidenceClient
|
||||
) => (config.config.quickstartMode ? mock : http),
|
||||
},
|
||||
// Doctor API (Sprint 20260112_001_008)
|
||||
HttpDoctorClient,
|
||||
MockDoctorClient,
|
||||
{
|
||||
provide: DOCTOR_API,
|
||||
deps: [AppConfigService, HttpDoctorClient, MockDoctorClient],
|
||||
useFactory: (
|
||||
config: AppConfigService,
|
||||
http: HttpDoctorClient,
|
||||
mock: MockDoctorClient
|
||||
) => (config.config.quickstartMode ? mock : http),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -457,6 +457,13 @@ export const routes: Routes = [
|
||||
loadChildren: () =>
|
||||
import('./features/platform-health/platform-health.routes').then((m) => m.platformHealthRoutes),
|
||||
},
|
||||
// Ops - Doctor Diagnostics (SPRINT_20260112_001_008)
|
||||
{
|
||||
path: 'ops/doctor',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/doctor/doctor.routes').then((m) => m.DOCTOR_ROUTES),
|
||||
},
|
||||
// Analyze - Unknowns Tracking (SPRINT_20251229_033)
|
||||
{
|
||||
path: 'analyze/unknowns',
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<div class="check-result" [class]="severityClass" [class.expanded]="expanded">
|
||||
<div class="result-header">
|
||||
<div class="result-icon" [innerHTML]="severityIcon"></div>
|
||||
<div class="result-info">
|
||||
<div class="result-title">
|
||||
<span class="check-id">{{ result.checkId }}</span>
|
||||
<span class="severity-badge" [class]="severityClass">{{ severityLabel }}</span>
|
||||
</div>
|
||||
<div class="result-diagnosis">{{ result.diagnosis }}</div>
|
||||
</div>
|
||||
<div class="result-meta">
|
||||
<span class="category-badge">{{ categoryLabel }}</span>
|
||||
<span class="duration">{{ formatDuration(result.durationMs) }}</span>
|
||||
</div>
|
||||
<div class="result-actions">
|
||||
<button class="btn-icon-small" title="Re-run this check" (click)="onRerun($event)">
|
||||
↻
|
||||
</button>
|
||||
<span class="expand-indicator">{{ expanded ? '▲' : '▼' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (expanded) {
|
||||
<div class="result-details">
|
||||
<!-- Evidence Section -->
|
||||
@if (result.evidence) {
|
||||
<st-evidence-viewer [evidence]="result.evidence" />
|
||||
}
|
||||
|
||||
<!-- Likely Causes -->
|
||||
@if (result.likelyCauses?.length) {
|
||||
<div class="likely-causes">
|
||||
<h4>Likely Causes</h4>
|
||||
<ol>
|
||||
@for (cause of result.likelyCauses; track $index) {
|
||||
<li>{{ cause }}</li>
|
||||
}
|
||||
</ol>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Remediation Section -->
|
||||
@if (result.remediation) {
|
||||
<st-remediation-panel
|
||||
[remediation]="result.remediation"
|
||||
[verificationCommand]="result.verificationCommand" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,251 @@
|
||||
.check-result {
|
||||
border: 1px solid var(--border, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
// Severity border indicator
|
||||
&.severity-pass { border-left: 4px solid var(--success, #22c55e); }
|
||||
&.severity-info { border-left: 4px solid var(--info, #3b82f6); }
|
||||
&.severity-warn { border-left: 4px solid var(--warning, #f59e0b); }
|
||||
&.severity-fail { border-left: 4px solid var(--error, #ef4444); }
|
||||
&.severity-skip { border-left: 4px solid var(--text-tertiary, #94a3b8); }
|
||||
}
|
||||
|
||||
.result-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover, #f8fafc);
|
||||
}
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
font-size: 1.25rem;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
|
||||
.severity-pass & {
|
||||
background: var(--success-bg, #dcfce7);
|
||||
color: var(--success, #22c55e);
|
||||
}
|
||||
|
||||
.severity-info & {
|
||||
background: var(--info-bg, #dbeafe);
|
||||
color: var(--info, #3b82f6);
|
||||
}
|
||||
|
||||
.severity-warn & {
|
||||
background: var(--warning-bg, #fef3c7);
|
||||
color: var(--warning, #f59e0b);
|
||||
}
|
||||
|
||||
.severity-fail & {
|
||||
background: var(--error-bg, #fee2e2);
|
||||
color: var(--error, #ef4444);
|
||||
}
|
||||
|
||||
.severity-skip & {
|
||||
background: var(--bg-tertiary, #f1f5f9);
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
}
|
||||
}
|
||||
|
||||
.result-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
.check-id {
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
}
|
||||
}
|
||||
|
||||
.severity-badge {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.025em;
|
||||
|
||||
&.severity-pass {
|
||||
background: var(--success-bg, #dcfce7);
|
||||
color: var(--success-dark, #15803d);
|
||||
}
|
||||
|
||||
&.severity-info {
|
||||
background: var(--info-bg, #dbeafe);
|
||||
color: var(--info-dark, #1d4ed8);
|
||||
}
|
||||
|
||||
&.severity-warn {
|
||||
background: var(--warning-bg, #fef3c7);
|
||||
color: var(--warning-dark, #b45309);
|
||||
}
|
||||
|
||||
&.severity-fail {
|
||||
background: var(--error-bg, #fee2e2);
|
||||
color: var(--error-dark, #b91c1c);
|
||||
}
|
||||
|
||||
&.severity-skip {
|
||||
background: var(--bg-tertiary, #f1f5f9);
|
||||
color: var(--text-tertiary, #64748b);
|
||||
}
|
||||
}
|
||||
|
||||
.result-diagnosis {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
.expanded & {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.category-badge {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #64748b);
|
||||
background: var(--bg-tertiary, #f1f5f9);
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.duration {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
}
|
||||
|
||||
.result-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-icon-small {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover, #f1f5f9);
|
||||
color: var(--primary, #3b82f6);
|
||||
}
|
||||
}
|
||||
|
||||
.expand-indicator {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
}
|
||||
|
||||
// Details
|
||||
.result-details {
|
||||
padding: 1rem 1rem 1rem 3.5rem;
|
||||
border-top: 1px solid var(--border, #e2e8f0);
|
||||
background: var(--bg-secondary, #fafafa);
|
||||
}
|
||||
|
||||
.likely-causes {
|
||||
margin: 1rem 0;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
}
|
||||
|
||||
ol {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
|
||||
li {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive
|
||||
@media (max-width: 640px) {
|
||||
.result-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.result-info {
|
||||
width: 100%;
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
flex-direction: row;
|
||||
order: 2;
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.result-actions {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
.result-details {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
|
||||
import { CheckResult } from '../../models/doctor.models';
|
||||
import { RemediationPanelComponent } from '../remediation-panel/remediation-panel.component';
|
||||
import { EvidenceViewerComponent } from '../evidence-viewer/evidence-viewer.component';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'st-check-result',
|
||||
imports: [CommonModule, RemediationPanelComponent, EvidenceViewerComponent],
|
||||
templateUrl: './check-result.component.html',
|
||||
styleUrl: './check-result.component.scss',
|
||||
})
|
||||
export class CheckResultComponent {
|
||||
@Input({ required: true }) result!: CheckResult;
|
||||
@Input() expanded = false;
|
||||
@Output() rerun = new EventEmitter<void>();
|
||||
|
||||
get severityClass(): string {
|
||||
return `severity-${this.result.severity}`;
|
||||
}
|
||||
|
||||
get severityIcon(): string {
|
||||
switch (this.result.severity) {
|
||||
case 'pass':
|
||||
return '✔'; // checkmark
|
||||
case 'info':
|
||||
return 'ℹ'; // info
|
||||
case 'warn':
|
||||
return '⚠'; // warning triangle
|
||||
case 'fail':
|
||||
return '✘'; // x mark
|
||||
case 'skip':
|
||||
return '→'; // arrow right
|
||||
default:
|
||||
return '?'; // question mark
|
||||
}
|
||||
}
|
||||
|
||||
get severityLabel(): string {
|
||||
switch (this.result.severity) {
|
||||
case 'pass':
|
||||
return 'Passed';
|
||||
case 'info':
|
||||
return 'Info';
|
||||
case 'warn':
|
||||
return 'Warning';
|
||||
case 'fail':
|
||||
return 'Failed';
|
||||
case 'skip':
|
||||
return 'Skipped';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
get categoryLabel(): string {
|
||||
return this.result.category.charAt(0).toUpperCase() + this.result.category.slice(1);
|
||||
}
|
||||
|
||||
formatDuration(ms: number): string {
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`;
|
||||
}
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
onRerun(event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.rerun.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { CommonModule, KeyValuePipe } from '@angular/common';
|
||||
import { Component, Input, signal } from '@angular/core';
|
||||
|
||||
import { Evidence } from '../../models/doctor.models';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'st-evidence-viewer',
|
||||
imports: [CommonModule, KeyValuePipe],
|
||||
template: `
|
||||
<div class="evidence-viewer">
|
||||
<div class="evidence-header" (click)="toggleExpanded()">
|
||||
<h4>Evidence</h4>
|
||||
<span class="toggle-icon">{{ expanded() ? '▼' : '▶' }}</span>
|
||||
</div>
|
||||
|
||||
@if (expanded()) {
|
||||
<div class="evidence-content">
|
||||
<p class="evidence-description">{{ evidence.description }}</p>
|
||||
|
||||
@if (hasData()) {
|
||||
<div class="evidence-data">
|
||||
<table>
|
||||
<tbody>
|
||||
@for (item of evidence.data | keyvalue; track item.key) {
|
||||
<tr>
|
||||
<td class="data-key">{{ item.key }}</td>
|
||||
<td class="data-value">
|
||||
<code>{{ formatValue(item.value) }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.evidence-viewer {
|
||||
background: var(--bg-tertiary, #f8fafc);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.evidence-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover, #f1f5f9);
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
}
|
||||
}
|
||||
|
||||
.evidence-content {
|
||||
padding: 0 0.75rem 0.75rem;
|
||||
}
|
||||
|
||||
.evidence-description {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.evidence-data {
|
||||
background: white;
|
||||
border: 1px solid var(--border, #e2e8f0);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
tr:not(:last-child) {
|
||||
border-bottom: 1px solid var(--border, #e2e8f0);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.data-key {
|
||||
width: 30%;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
background: var(--bg-secondary, #f8fafc);
|
||||
border-right: 1px solid var(--border, #e2e8f0);
|
||||
}
|
||||
|
||||
.data-value {
|
||||
code {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class EvidenceViewerComponent {
|
||||
@Input({ required: true }) evidence!: Evidence;
|
||||
|
||||
readonly expanded = signal(false);
|
||||
|
||||
toggleExpanded(): void {
|
||||
this.expanded.update((v) => !v);
|
||||
}
|
||||
|
||||
hasData(): boolean {
|
||||
return Object.keys(this.evidence.data).length > 0;
|
||||
}
|
||||
|
||||
formatValue(value: string): string {
|
||||
// Truncate very long values
|
||||
if (value.length > 200) {
|
||||
return value.substring(0, 200) + '...';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, EventEmitter, Input, Output, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
import { DoctorReport } from '../../models/doctor.models';
|
||||
|
||||
type ExportFormat = 'json' | 'markdown' | 'text';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'st-export-dialog',
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div class="dialog-backdrop" (click)="onBackdropClick($event)">
|
||||
<div class="dialog" (click)="$event.stopPropagation()">
|
||||
<div class="dialog-header">
|
||||
<h2>Export Report</h2>
|
||||
<button class="close-btn" (click)="onClose()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="dialog-body">
|
||||
<div class="format-options">
|
||||
<label class="format-option">
|
||||
<input type="radio" name="format" value="json" [(ngModel)]="selectedFormat">
|
||||
<span class="format-label">
|
||||
<strong>JSON</strong>
|
||||
<small>Machine-readable format for CI/CD integration</small>
|
||||
</span>
|
||||
</label>
|
||||
<label class="format-option">
|
||||
<input type="radio" name="format" value="markdown" [(ngModel)]="selectedFormat">
|
||||
<span class="format-label">
|
||||
<strong>Markdown</strong>
|
||||
<small>Human-readable format for documentation</small>
|
||||
</span>
|
||||
</label>
|
||||
<label class="format-option">
|
||||
<input type="radio" name="format" value="text" [(ngModel)]="selectedFormat">
|
||||
<span class="format-label">
|
||||
<strong>Plain Text</strong>
|
||||
<small>Simple text format for logs</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="export-options">
|
||||
<label class="checkbox-option">
|
||||
<input type="checkbox" [(ngModel)]="includeEvidence">
|
||||
<span>Include Evidence Data</span>
|
||||
</label>
|
||||
<label class="checkbox-option">
|
||||
<input type="checkbox" [(ngModel)]="includeRemediation">
|
||||
<span>Include Remediation Commands</span>
|
||||
</label>
|
||||
<label class="checkbox-option">
|
||||
<input type="checkbox" [(ngModel)]="failedOnly">
|
||||
<span>Failed Checks Only</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="preview-section">
|
||||
<h4>Preview</h4>
|
||||
<pre class="preview-content">{{ generatePreview() }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dialog-footer">
|
||||
<button class="btn btn-outline" (click)="copyToClipboard()">
|
||||
{{ copyLabel() }}
|
||||
</button>
|
||||
<button class="btn btn-primary" (click)="download()">
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.dialog-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border, #e2e8f0);
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover, #f1f5f9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.format-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.format-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover, #f8fafc);
|
||||
}
|
||||
|
||||
&:has(input:checked) {
|
||||
border-color: var(--primary, #3b82f6);
|
||||
background: var(--primary-light, #eff6ff);
|
||||
}
|
||||
|
||||
input {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.format-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
strong {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.export-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.checkbox-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
}
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
background: var(--bg-code, #1e293b);
|
||||
border-radius: 8px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-code, #e2e8f0);
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--border, #e2e8f0);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border, #e2e8f0);
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover, #f1f5f9);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary, #3b82f6);
|
||||
border: 1px solid var(--primary, #3b82f6);
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: var(--primary-dark, #2563eb);
|
||||
}
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class ExportDialogComponent {
|
||||
@Input({ required: true }) report!: DoctorReport;
|
||||
@Output() closeDialog = new EventEmitter<void>();
|
||||
|
||||
selectedFormat: ExportFormat = 'markdown';
|
||||
includeEvidence = true;
|
||||
includeRemediation = true;
|
||||
failedOnly = false;
|
||||
|
||||
private copied = signal(false);
|
||||
|
||||
onClose(): void {
|
||||
this.closeDialog.emit();
|
||||
}
|
||||
|
||||
onBackdropClick(event: MouseEvent): void {
|
||||
if (event.target === event.currentTarget) {
|
||||
this.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
copyLabel(): string {
|
||||
return this.copied() ? 'Copied!' : 'Copy to Clipboard';
|
||||
}
|
||||
|
||||
generatePreview(): string {
|
||||
const content = this.generateContent();
|
||||
// Truncate for preview
|
||||
if (content.length > 1000) {
|
||||
return content.substring(0, 1000) + '\n... (truncated)';
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
generateContent(): string {
|
||||
const results = this.failedOnly
|
||||
? this.report.results.filter((r) => r.severity === 'fail' || r.severity === 'warn')
|
||||
: this.report.results;
|
||||
|
||||
switch (this.selectedFormat) {
|
||||
case 'json':
|
||||
return this.generateJson(results);
|
||||
case 'markdown':
|
||||
return this.generateMarkdown(results);
|
||||
case 'text':
|
||||
return this.generateText(results);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private generateJson(results: typeof this.report.results): string {
|
||||
const exportData = {
|
||||
runId: this.report.runId,
|
||||
status: this.report.status,
|
||||
startedAt: this.report.startedAt,
|
||||
completedAt: this.report.completedAt,
|
||||
durationMs: this.report.durationMs,
|
||||
summary: this.report.summary,
|
||||
overallSeverity: this.report.overallSeverity,
|
||||
results: results.map((r) => ({
|
||||
checkId: r.checkId,
|
||||
severity: r.severity,
|
||||
diagnosis: r.diagnosis,
|
||||
category: r.category,
|
||||
...(this.includeEvidence && r.evidence ? { evidence: r.evidence } : {}),
|
||||
...(this.includeRemediation && r.remediation ? { remediation: r.remediation } : {}),
|
||||
})),
|
||||
};
|
||||
return JSON.stringify(exportData, null, 2);
|
||||
}
|
||||
|
||||
private generateMarkdown(results: typeof this.report.results): string {
|
||||
let md = `# Doctor Report\n\n`;
|
||||
md += `- **Run ID:** ${this.report.runId}\n`;
|
||||
md += `- **Status:** ${this.report.status}\n`;
|
||||
md += `- **Started:** ${this.report.startedAt}\n`;
|
||||
md += `- **Duration:** ${this.report.durationMs}ms\n\n`;
|
||||
|
||||
md += `## Summary\n\n`;
|
||||
md += `| Status | Count |\n|--------|-------|\n`;
|
||||
md += `| Passed | ${this.report.summary.passed} |\n`;
|
||||
md += `| Warnings | ${this.report.summary.warnings} |\n`;
|
||||
md += `| Failed | ${this.report.summary.failed} |\n`;
|
||||
md += `| Total | ${this.report.summary.total} |\n\n`;
|
||||
|
||||
md += `## Results\n\n`;
|
||||
for (const result of results) {
|
||||
const icon = result.severity === 'pass' ? '!' : result.severity === 'fail' ? '!!' : '!';
|
||||
md += `### [${result.severity.toUpperCase()}] ${result.checkId}\n\n`;
|
||||
md += `${result.diagnosis}\n\n`;
|
||||
|
||||
if (this.includeRemediation && result.remediation) {
|
||||
md += `**Remediation:**\n\n`;
|
||||
for (const step of result.remediation.steps) {
|
||||
md += `${step.order}. ${step.description}\n`;
|
||||
md += `\`\`\`${step.commandType}\n${step.command}\n\`\`\`\n\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return md;
|
||||
}
|
||||
|
||||
private generateText(results: typeof this.report.results): string {
|
||||
let text = `DOCTOR REPORT\n${'='.repeat(50)}\n\n`;
|
||||
text += `Run ID: ${this.report.runId}\n`;
|
||||
text += `Status: ${this.report.status}\n`;
|
||||
text += `Started: ${this.report.startedAt}\n`;
|
||||
text += `Duration: ${this.report.durationMs}ms\n\n`;
|
||||
|
||||
text += `SUMMARY\n${'-'.repeat(20)}\n`;
|
||||
text += `Passed: ${this.report.summary.passed}\n`;
|
||||
text += `Warnings: ${this.report.summary.warnings}\n`;
|
||||
text += `Failed: ${this.report.summary.failed}\n`;
|
||||
text += `Total: ${this.report.summary.total}\n\n`;
|
||||
|
||||
text += `RESULTS\n${'-'.repeat(20)}\n\n`;
|
||||
for (const result of results) {
|
||||
text += `[${result.severity.toUpperCase()}] ${result.checkId}\n`;
|
||||
text += ` ${result.diagnosis}\n`;
|
||||
|
||||
if (this.includeRemediation && result.remediation) {
|
||||
text += ` Fix:\n`;
|
||||
for (const step of result.remediation.steps) {
|
||||
text += ` ${step.order}. ${step.description}\n`;
|
||||
text += ` $ ${step.command}\n`;
|
||||
}
|
||||
}
|
||||
text += `\n`;
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
copyToClipboard(): void {
|
||||
const content = this.generateContent();
|
||||
navigator.clipboard.writeText(content).then(() => {
|
||||
this.copied.set(true);
|
||||
setTimeout(() => this.copied.set(false), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
download(): void {
|
||||
const content = this.generateContent();
|
||||
const extension = this.selectedFormat === 'markdown' ? 'md' : this.selectedFormat;
|
||||
const filename = `doctor-report-${this.report.runId}.${extension}`;
|
||||
const mimeType =
|
||||
this.selectedFormat === 'json'
|
||||
? 'application/json'
|
||||
: this.selectedFormat === 'markdown'
|
||||
? 'text/markdown'
|
||||
: 'text/plain';
|
||||
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, Input, signal } from '@angular/core';
|
||||
|
||||
import { Remediation, RemediationStep } from '../../models/doctor.models';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'st-remediation-panel',
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="remediation-panel">
|
||||
<div class="panel-header">
|
||||
<h4>Remediation</h4>
|
||||
<button class="copy-all-btn" (click)="copyAllCommands()">
|
||||
{{ copyAllLabel() }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (remediation.requiresBackup) {
|
||||
<div class="backup-warning">
|
||||
<span class="warning-icon">⚠</span>
|
||||
<span>Backup recommended before proceeding</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (remediation.safetyNote) {
|
||||
<div class="safety-note">
|
||||
<span class="note-icon">ℹ</span>
|
||||
<span>{{ remediation.safetyNote }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="fix-steps">
|
||||
@for (step of remediation.steps; track step.order) {
|
||||
<div class="step">
|
||||
<div class="step-header">
|
||||
<span class="step-number">{{ step.order }}.</span>
|
||||
<span class="step-description">{{ step.description }}</span>
|
||||
<button class="copy-btn" (click)="copyCommand(step)">
|
||||
{{ getCopyLabel(step) }}
|
||||
</button>
|
||||
</div>
|
||||
<pre class="step-command"><code>{{ step.command }}</code></pre>
|
||||
<span class="command-type">{{ step.commandType }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (verificationCommand) {
|
||||
<div class="verification-section">
|
||||
<div class="verification-header">
|
||||
<h5>Verification</h5>
|
||||
<button class="copy-btn" (click)="copyVerification()">
|
||||
{{ copyVerificationLabel() }}
|
||||
</button>
|
||||
</div>
|
||||
<pre class="verification-command"><code>{{ verificationCommand }}</code></pre>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.remediation-panel {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border: 1px solid var(--border, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
}
|
||||
}
|
||||
|
||||
.copy-all-btn,
|
||||
.copy-btn {
|
||||
padding: 0.25rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
background: var(--bg-secondary, #f1f5f9);
|
||||
border: 1px solid var(--border, #e2e8f0);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary, #64748b);
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover, #e2e8f0);
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
}
|
||||
}
|
||||
|
||||
.backup-warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--warning-bg, #fffbeb);
|
||||
border: 1px solid var(--warning-border, #fcd34d);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--warning-dark, #92400e);
|
||||
|
||||
.warning-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.safety-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--info-bg, #eff6ff);
|
||||
border: 1px solid var(--info-border, #bfdbfe);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--info-dark, #1e40af);
|
||||
|
||||
.note-icon {
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fix-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.step {
|
||||
.step-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
font-weight: 600;
|
||||
color: var(--primary, #3b82f6);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.step-description {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
}
|
||||
}
|
||||
|
||||
.step-command,
|
||||
.verification-command {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-code, #1e293b);
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
|
||||
code {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-code, #e2e8f0);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.command-type {
|
||||
display: inline-block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.verification-section {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border, #e2e8f0);
|
||||
|
||||
.verification-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
h5 {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
}
|
||||
}
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class RemediationPanelComponent {
|
||||
@Input({ required: true }) remediation!: Remediation;
|
||||
@Input() verificationCommand?: string;
|
||||
|
||||
private copiedSteps = signal<Set<number>>(new Set());
|
||||
private copiedAll = signal(false);
|
||||
private copiedVerification = signal(false);
|
||||
|
||||
copyCommand(step: RemediationStep): void {
|
||||
navigator.clipboard.writeText(step.command).then(() => {
|
||||
const newSet = new Set(this.copiedSteps());
|
||||
newSet.add(step.order);
|
||||
this.copiedSteps.set(newSet);
|
||||
|
||||
setTimeout(() => {
|
||||
const updated = new Set(this.copiedSteps());
|
||||
updated.delete(step.order);
|
||||
this.copiedSteps.set(updated);
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
copyAllCommands(): void {
|
||||
const allCommands = this.remediation.steps
|
||||
.map((s) => `# ${s.order}. ${s.description}\n${s.command}`)
|
||||
.join('\n\n');
|
||||
|
||||
navigator.clipboard.writeText(allCommands).then(() => {
|
||||
this.copiedAll.set(true);
|
||||
setTimeout(() => this.copiedAll.set(false), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
copyVerification(): void {
|
||||
if (this.verificationCommand) {
|
||||
navigator.clipboard.writeText(this.verificationCommand).then(() => {
|
||||
this.copiedVerification.set(true);
|
||||
setTimeout(() => this.copiedVerification.set(false), 2000);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getCopyLabel(step: RemediationStep): string {
|
||||
return this.copiedSteps().has(step.order) ? 'Copied!' : 'Copy';
|
||||
}
|
||||
|
||||
copyAllLabel(): string {
|
||||
return this.copiedAll() ? 'Copied!' : 'Copy All';
|
||||
}
|
||||
|
||||
copyVerificationLabel(): string {
|
||||
return this.copiedVerification() ? 'Copied!' : 'Copy';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { SummaryStripComponent } from './summary-strip.component';
|
||||
import { DoctorSummary } from '../../models/doctor.models';
|
||||
|
||||
describe('SummaryStripComponent', () => {
|
||||
let component: SummaryStripComponent;
|
||||
let fixture: ComponentFixture<SummaryStripComponent>;
|
||||
|
||||
const mockSummary: DoctorSummary = {
|
||||
passed: 5,
|
||||
info: 2,
|
||||
warnings: 3,
|
||||
failed: 1,
|
||||
skipped: 0,
|
||||
total: 11,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SummaryStripComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SummaryStripComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.summary = mockSummary;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display summary counts', () => {
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.textContent).toContain('5');
|
||||
expect(compiled.textContent).toContain('Passed');
|
||||
expect(compiled.textContent).toContain('3');
|
||||
expect(compiled.textContent).toContain('Warnings');
|
||||
expect(compiled.textContent).toContain('1');
|
||||
expect(compiled.textContent).toContain('Failed');
|
||||
expect(compiled.textContent).toContain('11');
|
||||
expect(compiled.textContent).toContain('Total');
|
||||
});
|
||||
|
||||
it('should apply overall-fail class when severity is fail', () => {
|
||||
component.overallSeverity = 'fail';
|
||||
fixture.detectChanges();
|
||||
const strip = fixture.nativeElement.querySelector('.summary-strip');
|
||||
expect(strip.classList.contains('overall-fail')).toBeTrue();
|
||||
});
|
||||
|
||||
it('should apply overall-warn class when severity is warn', () => {
|
||||
component.overallSeverity = 'warn';
|
||||
fixture.detectChanges();
|
||||
const strip = fixture.nativeElement.querySelector('.summary-strip');
|
||||
expect(strip.classList.contains('overall-warn')).toBeTrue();
|
||||
});
|
||||
|
||||
describe('formatDuration', () => {
|
||||
it('should format milliseconds', () => {
|
||||
expect(component.formatDuration(500)).toBe('500ms');
|
||||
});
|
||||
|
||||
it('should format seconds', () => {
|
||||
expect(component.formatDuration(2500)).toBe('2.5s');
|
||||
});
|
||||
|
||||
it('should format minutes and seconds', () => {
|
||||
expect(component.formatDuration(125000)).toBe('2m 5s');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, Input } from '@angular/core';
|
||||
|
||||
import { DoctorSeverity, DoctorSummary } from '../../models/doctor.models';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'st-summary-strip',
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="summary-strip" [class]="'overall-' + (overallSeverity || 'pass')">
|
||||
<div class="summary-item passed">
|
||||
<span class="count">{{ summary.passed }}</span>
|
||||
<span class="label">Passed</span>
|
||||
</div>
|
||||
<div class="summary-item info">
|
||||
<span class="count">{{ summary.info }}</span>
|
||||
<span class="label">Info</span>
|
||||
</div>
|
||||
<div class="summary-item warnings">
|
||||
<span class="count">{{ summary.warnings }}</span>
|
||||
<span class="label">Warnings</span>
|
||||
</div>
|
||||
<div class="summary-item failed">
|
||||
<span class="count">{{ summary.failed }}</span>
|
||||
<span class="label">Failed</span>
|
||||
</div>
|
||||
<div class="summary-item skipped">
|
||||
<span class="count">{{ summary.skipped }}</span>
|
||||
<span class="label">Skipped</span>
|
||||
</div>
|
||||
<div class="summary-divider"></div>
|
||||
<div class="summary-item total">
|
||||
<span class="count">{{ summary.total }}</span>
|
||||
<span class="label">Total</span>
|
||||
</div>
|
||||
@if (duration !== undefined && duration !== null) {
|
||||
<div class="summary-item duration">
|
||||
<span class="count">{{ formatDuration(duration) }}</span>
|
||||
<span class="label">Duration</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.summary-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background: var(--bg-secondary, #f8fafc);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
border-left: 4px solid var(--success, #22c55e);
|
||||
|
||||
&.overall-fail {
|
||||
border-left-color: var(--error, #ef4444);
|
||||
background: var(--error-bg, #fef2f2);
|
||||
}
|
||||
|
||||
&.overall-warn {
|
||||
border-left-color: var(--warning, #f59e0b);
|
||||
background: var(--warning-bg, #fffbeb);
|
||||
}
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 60px;
|
||||
|
||||
.count {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
margin-top: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
&.passed .count { color: var(--success, #22c55e); }
|
||||
&.info .count { color: var(--info, #3b82f6); }
|
||||
&.warnings .count { color: var(--warning, #f59e0b); }
|
||||
&.failed .count { color: var(--error, #ef4444); }
|
||||
&.skipped .count { color: var(--text-tertiary, #94a3b8); }
|
||||
&.total .count { color: var(--text-primary, #1a1a2e); }
|
||||
&.duration .count {
|
||||
color: var(--text-secondary, #64748b);
|
||||
font-family: monospace;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.summary-divider {
|
||||
width: 1px;
|
||||
height: 40px;
|
||||
background: var(--border, #e2e8f0);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.summary-strip {
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
min-width: 50px;
|
||||
|
||||
.count {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.summary-divider {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class SummaryStripComponent {
|
||||
@Input({ required: true }) summary!: DoctorSummary;
|
||||
@Input() duration?: number;
|
||||
@Input() overallSeverity?: DoctorSeverity;
|
||||
|
||||
formatDuration(ms: number): string {
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`;
|
||||
}
|
||||
const seconds = ms / 1000;
|
||||
if (seconds < 60) {
|
||||
return `${seconds.toFixed(1)}s`;
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.round(seconds % 60);
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<div class="doctor-dashboard">
|
||||
<header class="dashboard-header">
|
||||
<div class="header-content">
|
||||
<h1>Doctor Diagnostics</h1>
|
||||
<p class="subtitle">Run diagnostic checks on your Stella Ops deployment</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
(click)="runQuickCheck()"
|
||||
[disabled]="store.isRunning()">
|
||||
<span class="btn-icon">⚡</span>
|
||||
Quick Check
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
(click)="runNormalCheck()"
|
||||
[disabled]="store.isRunning()">
|
||||
<span class="btn-icon">⚙</span>
|
||||
Normal Check
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline"
|
||||
(click)="runFullCheck()"
|
||||
[disabled]="store.isRunning()">
|
||||
<span class="btn-icon">🔍</span>
|
||||
Full Check
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost"
|
||||
(click)="openExportDialog()"
|
||||
[disabled]="!store.hasReport()">
|
||||
<span class="btn-icon">💾</span>
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
@if (store.isRunning()) {
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress-fill"
|
||||
[style.width.%]="store.progressPercent()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-info">
|
||||
<span class="progress-text">
|
||||
{{ store.progress().completed }} / {{ store.progress().total }} checks completed
|
||||
</span>
|
||||
@if (store.progress().checkId) {
|
||||
<span class="progress-current">
|
||||
Running: {{ store.progress().checkId }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Error Display -->
|
||||
@if (store.error()) {
|
||||
<div class="error-banner">
|
||||
<span class="error-icon">⚠</span>
|
||||
<span class="error-message">{{ store.error() }}</span>
|
||||
<button class="error-dismiss" (click)="store.reset()">Dismiss</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Summary Strip -->
|
||||
@if (store.summary(); as summary) {
|
||||
<st-summary-strip
|
||||
[summary]="summary"
|
||||
[duration]="store.report()?.durationMs"
|
||||
[overallSeverity]="store.report()?.overallSeverity" />
|
||||
}
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters-container">
|
||||
<div class="filter-group">
|
||||
<label for="category-filter">Category</label>
|
||||
<select
|
||||
id="category-filter"
|
||||
class="filter-select"
|
||||
[value]="store.categoryFilter() || ''"
|
||||
(change)="onCategoryChange($event)">
|
||||
@for (cat of categories; track cat.value) {
|
||||
<option [value]="cat.value || ''">{{ cat.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group severity-filters">
|
||||
<label>Severity</label>
|
||||
<div class="severity-checkboxes">
|
||||
@for (sev of severities; track sev.value) {
|
||||
<label class="severity-checkbox" [class]="sev.class">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="isSeveritySelected(sev.value)"
|
||||
(change)="toggleSeverity(sev.value)">
|
||||
<span>{{ sev.label }}</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group search-group">
|
||||
<label for="search-filter">Search</label>
|
||||
<input
|
||||
id="search-filter"
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Search checks..."
|
||||
[value]="store.searchQuery()"
|
||||
(input)="onSearchChange($event)">
|
||||
</div>
|
||||
|
||||
<button class="btn btn-ghost clear-filters" (click)="clearFilters()">
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Results List -->
|
||||
<div class="results-container">
|
||||
@if (store.state() === 'idle' && !store.hasReport()) {
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🔍</div>
|
||||
<h3>No Diagnostics Run Yet</h3>
|
||||
<p>Click "Quick Check" to run a fast diagnostic, or "Full Check" for comprehensive analysis.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (store.hasReport()) {
|
||||
<div class="results-header">
|
||||
<span class="results-count">
|
||||
Showing {{ store.filteredResults().length }} of {{ store.report()?.results?.length || 0 }} checks
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="results-list">
|
||||
@for (result of store.filteredResults(); track trackResult($index, result)) {
|
||||
<st-check-result
|
||||
[result]="result"
|
||||
[expanded]="isResultSelected(result)"
|
||||
(click)="selectResult(result)"
|
||||
(rerun)="rerunCheck(result.checkId)" />
|
||||
}
|
||||
|
||||
@if (store.filteredResults().length === 0) {
|
||||
<div class="no-results">
|
||||
<p>No checks match your current filters.</p>
|
||||
<button class="btn btn-link" (click)="clearFilters()">Clear filters</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Export Dialog -->
|
||||
@if (showExportDialog()) {
|
||||
<st-export-dialog
|
||||
[report]="store.report()!"
|
||||
(closeDialog)="closeExportDialog()" />
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,377 @@
|
||||
.doctor-dashboard {
|
||||
padding: 1.5rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
// Header
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
|
||||
.header-content {
|
||||
h1 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: var(--text-secondary, #64748b);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
// Buttons
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border: 1px solid transparent;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary, #3b82f6);
|
||||
color: white;
|
||||
border-color: var(--primary, #3b82f6);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--primary-dark, #2563eb);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--secondary, #6366f1);
|
||||
color: white;
|
||||
border-color: var(--secondary, #6366f1);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--secondary-dark, #4f46e5);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
border-color: var(--border, #e2e8f0);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--bg-hover, #f1f5f9);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary, #64748b);
|
||||
border: none;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--bg-hover, #f1f5f9);
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--primary, #3b82f6);
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
// Progress
|
||||
.progress-container {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-secondary, #f8fafc);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background: var(--bg-tertiary, #e2e8f0);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--primary, #3b82f6);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
|
||||
.progress-text {
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.progress-current {
|
||||
color: var(--text-secondary, #64748b);
|
||||
font-family: monospace;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Error
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: var(--error-bg, #fef2f2);
|
||||
border: 1px solid var(--error-border, #fecaca);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
.error-icon {
|
||||
font-size: 1.25rem;
|
||||
color: var(--error, #ef4444);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
flex: 1;
|
||||
color: var(--error-text, #991b1b);
|
||||
}
|
||||
|
||||
.error-dismiss {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--error, #ef4444);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filters
|
||||
.filters-container {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-secondary, #f8fafc);
|
||||
border-radius: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
|
||||
label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #64748b);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 0.5rem 2rem 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border, #e2e8f0);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
min-width: 150px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary, #3b82f6);
|
||||
box-shadow: 0 0 0 3px var(--primary-light, rgba(59, 130, 246, 0.1));
|
||||
}
|
||||
}
|
||||
|
||||
.severity-filters {
|
||||
.severity-checkboxes {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.severity-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover, #f1f5f9);
|
||||
}
|
||||
|
||||
input {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&.severity-fail span { color: var(--error, #ef4444); }
|
||||
&.severity-warn span { color: var(--warning, #f59e0b); }
|
||||
&.severity-pass span { color: var(--success, #22c55e); }
|
||||
&.severity-info span { color: var(--info, #3b82f6); }
|
||||
}
|
||||
|
||||
.search-group {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border, #e2e8f0);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary, #3b82f6);
|
||||
box-shadow: 0 0 0 3px var(--primary-light, rgba(59, 130, 246, 0.1));
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
}
|
||||
}
|
||||
|
||||
.clear-filters {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
// Results
|
||||
.results-container {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.results-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.results-count {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
}
|
||||
|
||||
.results-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
// Empty state
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary, #64748b);
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.no-results {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
|
||||
p {
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
.header-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.filters-container {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
.filter-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-select,
|
||||
.search-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.severity-checkboxes {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { signal } from '@angular/core';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { DoctorDashboardComponent } from './doctor-dashboard.component';
|
||||
import { DoctorStore } from './services/doctor.store';
|
||||
import { DOCTOR_API, MockDoctorClient } from './services/doctor.client';
|
||||
import { DoctorReport, DoctorSummary, CheckResult, DoctorProgress } from './models/doctor.models';
|
||||
|
||||
describe('DoctorDashboardComponent', () => {
|
||||
let component: DoctorDashboardComponent;
|
||||
let fixture: ComponentFixture<DoctorDashboardComponent>;
|
||||
let mockStore: jasmine.SpyObj<DoctorStore>;
|
||||
|
||||
const mockSummary: DoctorSummary = {
|
||||
passed: 3,
|
||||
info: 0,
|
||||
warnings: 1,
|
||||
failed: 1,
|
||||
skipped: 0,
|
||||
total: 5,
|
||||
};
|
||||
|
||||
const mockResults: CheckResult[] = [
|
||||
{
|
||||
checkId: 'check.config.required',
|
||||
pluginId: 'stellaops.doctor.core',
|
||||
category: 'core',
|
||||
severity: 'pass',
|
||||
diagnosis: 'All configuration present',
|
||||
evidence: { description: 'Config OK', data: {} },
|
||||
durationMs: 100,
|
||||
executedAt: '2026-01-12T10:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const mockReport: DoctorReport = {
|
||||
runId: 'dr_test_123',
|
||||
status: 'completed',
|
||||
startedAt: '2026-01-12T10:00:00Z',
|
||||
completedAt: '2026-01-12T10:00:05Z',
|
||||
durationMs: 5000,
|
||||
summary: mockSummary,
|
||||
overallSeverity: 'warn',
|
||||
results: mockResults,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create mock store with signals
|
||||
mockStore = jasmine.createSpyObj('DoctorStore', [
|
||||
'fetchPlugins',
|
||||
'fetchChecks',
|
||||
'startRun',
|
||||
'setCategoryFilter',
|
||||
'toggleSeverityFilter',
|
||||
'setSearchQuery',
|
||||
'clearFilters',
|
||||
], {
|
||||
state: signal('idle'),
|
||||
currentRunId: signal(null),
|
||||
report: signal(null),
|
||||
progress: signal({ completed: 0, total: 0 } as DoctorProgress),
|
||||
error: signal(null),
|
||||
loading: signal(false),
|
||||
checks: signal(null),
|
||||
plugins: signal(null),
|
||||
categoryFilter: signal(null),
|
||||
severityFilter: signal([]),
|
||||
searchQuery: signal(''),
|
||||
summary: signal(null),
|
||||
hasReport: signal(false),
|
||||
isRunning: signal(false),
|
||||
progressPercent: signal(0),
|
||||
filteredResults: signal([]),
|
||||
failedResults: signal([]),
|
||||
warningResults: signal([]),
|
||||
passedResults: signal([]),
|
||||
});
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DoctorDashboardComponent],
|
||||
providers: [
|
||||
{ provide: DoctorStore, useValue: mockStore },
|
||||
{ provide: DOCTOR_API, useClass: MockDoctorClient },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(DoctorDashboardComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should fetch plugins and checks on init', () => {
|
||||
expect(mockStore.fetchPlugins).toHaveBeenCalled();
|
||||
expect(mockStore.fetchChecks).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('run actions', () => {
|
||||
it('should start quick check', () => {
|
||||
component.runQuickCheck();
|
||||
expect(mockStore.startRun).toHaveBeenCalledWith(jasmine.objectContaining({ mode: 'quick' }));
|
||||
});
|
||||
|
||||
it('should start normal check', () => {
|
||||
component.runNormalCheck();
|
||||
expect(mockStore.startRun).toHaveBeenCalledWith(jasmine.objectContaining({ mode: 'normal' }));
|
||||
});
|
||||
|
||||
it('should start full check', () => {
|
||||
component.runFullCheck();
|
||||
expect(mockStore.startRun).toHaveBeenCalledWith(jasmine.objectContaining({ mode: 'full' }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('filters', () => {
|
||||
it('should toggle severity filter', () => {
|
||||
component.toggleSeverity('fail');
|
||||
expect(mockStore.toggleSeverityFilter).toHaveBeenCalledWith('fail');
|
||||
});
|
||||
|
||||
it('should clear filters', () => {
|
||||
component.clearFilters();
|
||||
expect(mockStore.clearFilters).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('result selection', () => {
|
||||
it('should select result on click', () => {
|
||||
const result = mockResults[0];
|
||||
component.selectResult(result);
|
||||
expect(component.selectedResult()).toEqual(result);
|
||||
});
|
||||
|
||||
it('should deselect result on second click', () => {
|
||||
const result = mockResults[0];
|
||||
component.selectResult(result);
|
||||
component.selectResult(result);
|
||||
expect(component.selectedResult()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('export dialog', () => {
|
||||
it('should open export dialog', () => {
|
||||
component.openExportDialog();
|
||||
expect(component.showExportDialog()).toBeTrue();
|
||||
});
|
||||
|
||||
it('should close export dialog', () => {
|
||||
component.openExportDialog();
|
||||
component.closeExportDialog();
|
||||
expect(component.showExportDialog()).toBeFalse();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
import { DoctorStore } from './services/doctor.store';
|
||||
import { CheckResult, DoctorCategory, DoctorSeverity, RunDoctorRequest } from './models/doctor.models';
|
||||
import { SummaryStripComponent } from './components/summary-strip/summary-strip.component';
|
||||
import { CheckResultComponent } from './components/check-result/check-result.component';
|
||||
import { ExportDialogComponent } from './components/export-dialog/export-dialog.component';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'st-doctor-dashboard',
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
SummaryStripComponent,
|
||||
CheckResultComponent,
|
||||
ExportDialogComponent,
|
||||
],
|
||||
templateUrl: './doctor-dashboard.component.html',
|
||||
styleUrl: './doctor-dashboard.component.scss',
|
||||
})
|
||||
export class DoctorDashboardComponent implements OnInit {
|
||||
readonly store = inject(DoctorStore);
|
||||
|
||||
readonly showExportDialog = signal(false);
|
||||
readonly selectedResult = signal<CheckResult | null>(null);
|
||||
|
||||
readonly categories: { value: DoctorCategory | null; label: string }[] = [
|
||||
{ value: null, label: 'All Categories' },
|
||||
{ value: 'core', label: 'Core' },
|
||||
{ value: 'database', label: 'Database' },
|
||||
{ value: 'servicegraph', label: 'Service Graph' },
|
||||
{ value: 'integration', label: 'Integration' },
|
||||
{ value: 'security', label: 'Security' },
|
||||
{ value: 'observability', label: 'Observability' },
|
||||
];
|
||||
|
||||
readonly severities: { value: DoctorSeverity; label: string; class: string }[] = [
|
||||
{ value: 'fail', label: 'Failed', class: 'severity-fail' },
|
||||
{ value: 'warn', label: 'Warnings', class: 'severity-warn' },
|
||||
{ value: 'pass', label: 'Passed', class: 'severity-pass' },
|
||||
{ value: 'info', label: 'Info', class: 'severity-info' },
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
// Load metadata on init
|
||||
this.store.fetchPlugins();
|
||||
this.store.fetchChecks();
|
||||
}
|
||||
|
||||
runQuickCheck(): void {
|
||||
this.runDoctor({ mode: 'quick', includeRemediation: true });
|
||||
}
|
||||
|
||||
runNormalCheck(): void {
|
||||
this.runDoctor({ mode: 'normal', includeRemediation: true });
|
||||
}
|
||||
|
||||
runFullCheck(): void {
|
||||
this.runDoctor({ mode: 'full', includeRemediation: true });
|
||||
}
|
||||
|
||||
private runDoctor(request: RunDoctorRequest): void {
|
||||
// Apply current category filter if set
|
||||
const category = this.store.categoryFilter();
|
||||
if (category) {
|
||||
request.categories = [category];
|
||||
}
|
||||
this.store.startRun(request);
|
||||
}
|
||||
|
||||
onCategoryChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
const value = select.value as DoctorCategory | '';
|
||||
this.store.setCategoryFilter(value || null);
|
||||
}
|
||||
|
||||
toggleSeverity(severity: DoctorSeverity): void {
|
||||
this.store.toggleSeverityFilter(severity);
|
||||
}
|
||||
|
||||
isSeveritySelected(severity: DoctorSeverity): boolean {
|
||||
return this.store.severityFilter().includes(severity);
|
||||
}
|
||||
|
||||
onSearchChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.store.setSearchQuery(input.value);
|
||||
}
|
||||
|
||||
selectResult(result: CheckResult): void {
|
||||
const current = this.selectedResult();
|
||||
if (current?.checkId === result.checkId) {
|
||||
this.selectedResult.set(null);
|
||||
} else {
|
||||
this.selectedResult.set(result);
|
||||
}
|
||||
}
|
||||
|
||||
isResultSelected(result: CheckResult): boolean {
|
||||
return this.selectedResult()?.checkId === result.checkId;
|
||||
}
|
||||
|
||||
rerunCheck(checkId: string): void {
|
||||
this.store.startRun({ mode: 'normal', checkIds: [checkId], includeRemediation: true });
|
||||
}
|
||||
|
||||
openExportDialog(): void {
|
||||
this.showExportDialog.set(true);
|
||||
}
|
||||
|
||||
closeExportDialog(): void {
|
||||
this.showExportDialog.set(false);
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.store.clearFilters();
|
||||
}
|
||||
|
||||
trackResult(_index: number, result: CheckResult): string {
|
||||
return result.checkId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const DOCTOR_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () =>
|
||||
import('./doctor-dashboard.component').then(
|
||||
(m) => m.DoctorDashboardComponent
|
||||
),
|
||||
title: 'Doctor Diagnostics',
|
||||
},
|
||||
];
|
||||
17
src/Web/StellaOps.Web/src/app/features/doctor/index.ts
Normal file
17
src/Web/StellaOps.Web/src/app/features/doctor/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Models
|
||||
export * from './models/doctor.models';
|
||||
|
||||
// Services
|
||||
export * from './services/doctor.client';
|
||||
export * from './services/doctor.store';
|
||||
|
||||
// Components
|
||||
export * from './doctor-dashboard.component';
|
||||
export * from './components/summary-strip/summary-strip.component';
|
||||
export * from './components/check-result/check-result.component';
|
||||
export * from './components/remediation-panel/remediation-panel.component';
|
||||
export * from './components/evidence-viewer/evidence-viewer.component';
|
||||
export * from './components/export-dialog/export-dialog.component';
|
||||
|
||||
// Routes
|
||||
export * from './doctor.routes';
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Doctor diagnostics data models.
|
||||
* Aligned with Doctor.WebService API contracts.
|
||||
*/
|
||||
|
||||
export type DoctorSeverity = 'pass' | 'info' | 'warn' | 'fail' | 'skip';
|
||||
export type DoctorState = 'idle' | 'running' | 'completed' | 'error';
|
||||
export type DoctorRunMode = 'quick' | 'normal' | 'full';
|
||||
export type DoctorCategory = 'core' | 'database' | 'servicegraph' | 'integration' | 'security' | 'observability';
|
||||
|
||||
export interface CheckMetadata {
|
||||
checkId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
pluginId: string;
|
||||
category: string;
|
||||
defaultSeverity: DoctorSeverity;
|
||||
tags: string[];
|
||||
estimatedDurationMs: number;
|
||||
}
|
||||
|
||||
export interface PluginMetadata {
|
||||
pluginId: string;
|
||||
displayName: string;
|
||||
category: string;
|
||||
version: string;
|
||||
checkCount: number;
|
||||
}
|
||||
|
||||
export interface RunDoctorRequest {
|
||||
mode: DoctorRunMode;
|
||||
categories?: string[];
|
||||
plugins?: string[];
|
||||
checkIds?: string[];
|
||||
timeoutMs?: number;
|
||||
parallelism?: number;
|
||||
includeRemediation?: boolean;
|
||||
}
|
||||
|
||||
export interface DoctorReport {
|
||||
runId: string;
|
||||
status: string;
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
durationMs?: number;
|
||||
summary: DoctorSummary;
|
||||
overallSeverity: DoctorSeverity;
|
||||
results: CheckResult[];
|
||||
}
|
||||
|
||||
export interface DoctorSummary {
|
||||
passed: number;
|
||||
info: number;
|
||||
warnings: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface CheckResult {
|
||||
checkId: string;
|
||||
pluginId: string;
|
||||
category: string;
|
||||
severity: DoctorSeverity;
|
||||
diagnosis: string;
|
||||
evidence: Evidence;
|
||||
likelyCauses?: string[];
|
||||
remediation?: Remediation;
|
||||
verificationCommand?: string;
|
||||
durationMs: number;
|
||||
executedAt: string;
|
||||
}
|
||||
|
||||
export interface Evidence {
|
||||
description: string;
|
||||
data: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface Remediation {
|
||||
requiresBackup: boolean;
|
||||
safetyNote?: string;
|
||||
steps: RemediationStep[];
|
||||
}
|
||||
|
||||
export interface RemediationStep {
|
||||
order: number;
|
||||
description: string;
|
||||
command: string;
|
||||
commandType: string;
|
||||
}
|
||||
|
||||
export interface DoctorProgress {
|
||||
completed: number;
|
||||
total: number;
|
||||
checkId?: string;
|
||||
}
|
||||
|
||||
export interface CheckListResponse {
|
||||
checks: CheckMetadata[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface PluginListResponse {
|
||||
plugins: PluginMetadata[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ReportListResponse {
|
||||
reports: DoctorReport[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface StartRunResponse {
|
||||
runId: string;
|
||||
}
|
||||
|
||||
export interface SseProgressEvent {
|
||||
eventType: 'check-started' | 'check-completed' | 'run-completed' | 'error';
|
||||
completed?: number;
|
||||
total?: number;
|
||||
checkId?: string;
|
||||
message?: string;
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { Injectable, InjectionToken, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import {
|
||||
CheckListResponse,
|
||||
CheckMetadata,
|
||||
CheckResult,
|
||||
DoctorReport,
|
||||
DoctorSummary,
|
||||
PluginListResponse,
|
||||
PluginMetadata,
|
||||
ReportListResponse,
|
||||
RunDoctorRequest,
|
||||
SseProgressEvent,
|
||||
StartRunResponse,
|
||||
} from '../models/doctor.models';
|
||||
|
||||
/**
|
||||
* Doctor API interface.
|
||||
* Aligned with Doctor.WebService endpoints.
|
||||
*/
|
||||
export interface DoctorApi {
|
||||
/** List available checks with optional filtering. */
|
||||
listChecks(category?: string, plugin?: string): Observable<CheckListResponse>;
|
||||
|
||||
/** List available plugins. */
|
||||
listPlugins(): Observable<PluginListResponse>;
|
||||
|
||||
/** Start a new doctor run. */
|
||||
startRun(request: RunDoctorRequest): Observable<StartRunResponse>;
|
||||
|
||||
/** Get run result by ID. */
|
||||
getRunResult(runId: string): Observable<DoctorReport>;
|
||||
|
||||
/** Stream run progress via SSE. */
|
||||
streamRunProgress(runId: string): Observable<MessageEvent>;
|
||||
|
||||
/** List historical reports. */
|
||||
listReports(limit?: number, offset?: number): Observable<ReportListResponse>;
|
||||
|
||||
/** Delete a report by ID. */
|
||||
deleteReport(reportId: string): Observable<void>;
|
||||
}
|
||||
|
||||
export const DOCTOR_API = new InjectionToken<DoctorApi>('DOCTOR_API');
|
||||
|
||||
/**
|
||||
* HTTP client implementation for Doctor API.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class HttpDoctorClient implements DoctorApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = `${environment.apiUrl}/api/v1/doctor`;
|
||||
|
||||
listChecks(category?: string, plugin?: string): Observable<CheckListResponse> {
|
||||
const params: Record<string, string> = {};
|
||||
if (category) params['category'] = category;
|
||||
if (plugin) params['plugin'] = plugin;
|
||||
return this.http.get<CheckListResponse>(`${this.baseUrl}/checks`, { params });
|
||||
}
|
||||
|
||||
listPlugins(): Observable<PluginListResponse> {
|
||||
return this.http.get<PluginListResponse>(`${this.baseUrl}/plugins`);
|
||||
}
|
||||
|
||||
startRun(request: RunDoctorRequest): Observable<StartRunResponse> {
|
||||
return this.http.post<StartRunResponse>(`${this.baseUrl}/run`, request);
|
||||
}
|
||||
|
||||
getRunResult(runId: string): Observable<DoctorReport> {
|
||||
return this.http.get<DoctorReport>(`${this.baseUrl}/run/${runId}`);
|
||||
}
|
||||
|
||||
streamRunProgress(runId: string): Observable<MessageEvent> {
|
||||
return new Observable((observer) => {
|
||||
const eventSource = new EventSource(`${this.baseUrl}/run/${runId}/stream`);
|
||||
|
||||
eventSource.onmessage = (event) => observer.next(event);
|
||||
eventSource.onerror = (error) => {
|
||||
observer.error(error);
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
return () => eventSource.close();
|
||||
});
|
||||
}
|
||||
|
||||
listReports(limit = 20, offset = 0): Observable<ReportListResponse> {
|
||||
return this.http.get<ReportListResponse>(`${this.baseUrl}/reports`, {
|
||||
params: { limit: limit.toString(), offset: offset.toString() },
|
||||
});
|
||||
}
|
||||
|
||||
deleteReport(reportId: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseUrl}/reports/${reportId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Doctor API for development and testing.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockDoctorClient implements DoctorApi {
|
||||
private readonly mockChecks: CheckMetadata[] = [
|
||||
{
|
||||
checkId: 'check.config.required',
|
||||
name: 'Required Configuration',
|
||||
description: 'Verifies all required configuration keys are present',
|
||||
pluginId: 'stellaops.doctor.core',
|
||||
category: 'core',
|
||||
defaultSeverity: 'fail',
|
||||
tags: ['quick', 'core'],
|
||||
estimatedDurationMs: 100,
|
||||
},
|
||||
{
|
||||
checkId: 'check.database.connectivity',
|
||||
name: 'Database Connectivity',
|
||||
description: 'Tests connectivity to the primary database',
|
||||
pluginId: 'stellaops.doctor.database',
|
||||
category: 'database',
|
||||
defaultSeverity: 'fail',
|
||||
tags: ['quick', 'database'],
|
||||
estimatedDurationMs: 500,
|
||||
},
|
||||
{
|
||||
checkId: 'check.database.migrations.pending',
|
||||
name: 'Pending Migrations',
|
||||
description: 'Checks for pending database migrations',
|
||||
pluginId: 'stellaops.doctor.database',
|
||||
category: 'database',
|
||||
defaultSeverity: 'warn',
|
||||
tags: ['database'],
|
||||
estimatedDurationMs: 300,
|
||||
},
|
||||
{
|
||||
checkId: 'check.services.gateway.routing',
|
||||
name: 'Gateway Routing',
|
||||
description: 'Verifies gateway service routing configuration',
|
||||
pluginId: 'stellaops.doctor.servicegraph',
|
||||
category: 'servicegraph',
|
||||
defaultSeverity: 'fail',
|
||||
tags: ['services'],
|
||||
estimatedDurationMs: 1000,
|
||||
},
|
||||
{
|
||||
checkId: 'check.security.tls.certificates',
|
||||
name: 'TLS Certificates',
|
||||
description: 'Validates TLS certificate configuration and expiry',
|
||||
pluginId: 'stellaops.doctor.security',
|
||||
category: 'security',
|
||||
defaultSeverity: 'warn',
|
||||
tags: ['security', 'tls'],
|
||||
estimatedDurationMs: 200,
|
||||
},
|
||||
];
|
||||
|
||||
private readonly mockPlugins: PluginMetadata[] = [
|
||||
{
|
||||
pluginId: 'stellaops.doctor.core',
|
||||
displayName: 'Core Platform',
|
||||
category: 'core',
|
||||
version: '1.0.0',
|
||||
checkCount: 9,
|
||||
},
|
||||
{
|
||||
pluginId: 'stellaops.doctor.database',
|
||||
displayName: 'Database',
|
||||
category: 'database',
|
||||
version: '1.0.0',
|
||||
checkCount: 8,
|
||||
},
|
||||
{
|
||||
pluginId: 'stellaops.doctor.servicegraph',
|
||||
displayName: 'Service Graph',
|
||||
category: 'servicegraph',
|
||||
version: '1.0.0',
|
||||
checkCount: 6,
|
||||
},
|
||||
{
|
||||
pluginId: 'stellaops.doctor.security',
|
||||
displayName: 'Security',
|
||||
category: 'security',
|
||||
version: '1.0.0',
|
||||
checkCount: 9,
|
||||
},
|
||||
];
|
||||
|
||||
private runCounter = 0;
|
||||
|
||||
listChecks(category?: string, plugin?: string): Observable<CheckListResponse> {
|
||||
let checks = [...this.mockChecks];
|
||||
if (category) {
|
||||
checks = checks.filter((c) => c.category === category);
|
||||
}
|
||||
if (plugin) {
|
||||
checks = checks.filter((c) => c.pluginId === plugin);
|
||||
}
|
||||
return of({ checks, total: checks.length }).pipe(delay(100));
|
||||
}
|
||||
|
||||
listPlugins(): Observable<PluginListResponse> {
|
||||
return of({ plugins: this.mockPlugins, total: this.mockPlugins.length }).pipe(delay(50));
|
||||
}
|
||||
|
||||
startRun(request: RunDoctorRequest): Observable<StartRunResponse> {
|
||||
this.runCounter++;
|
||||
const runId = `dr_mock_${Date.now()}_${this.runCounter}`;
|
||||
return of({ runId }).pipe(delay(100));
|
||||
}
|
||||
|
||||
getRunResult(runId: string): Observable<DoctorReport> {
|
||||
const mockResults: CheckResult[] = this.mockChecks.map((check, index) => ({
|
||||
checkId: check.checkId,
|
||||
pluginId: check.pluginId,
|
||||
category: check.category,
|
||||
severity: index === 0 ? 'pass' : index === 1 ? 'warn' : index === 2 ? 'fail' : 'pass',
|
||||
diagnosis:
|
||||
index === 2
|
||||
? 'Database migrations are pending'
|
||||
: index === 1
|
||||
? 'TLS certificate expires in 14 days'
|
||||
: 'Check passed successfully',
|
||||
evidence: {
|
||||
description: 'Evidence collected during check execution',
|
||||
data: {
|
||||
timestamp: new Date().toISOString(),
|
||||
checkId: check.checkId,
|
||||
},
|
||||
},
|
||||
likelyCauses: index === 2 ? ['Database schema out of sync', 'Missing migration files'] : undefined,
|
||||
remediation:
|
||||
index === 2
|
||||
? {
|
||||
requiresBackup: true,
|
||||
safetyNote: 'Create a database backup before running migrations',
|
||||
steps: [
|
||||
{
|
||||
order: 1,
|
||||
description: 'Backup the database',
|
||||
command: 'pg_dump -h localhost -U postgres stellaops > backup.sql',
|
||||
commandType: 'shell',
|
||||
},
|
||||
{
|
||||
order: 2,
|
||||
description: 'Run pending migrations',
|
||||
command: 'dotnet ef database update',
|
||||
commandType: 'shell',
|
||||
},
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
verificationCommand: index === 2 ? 'dotnet ef migrations list' : undefined,
|
||||
durationMs: check.estimatedDurationMs,
|
||||
executedAt: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
const summary: DoctorSummary = {
|
||||
passed: mockResults.filter((r) => r.severity === 'pass').length,
|
||||
info: mockResults.filter((r) => r.severity === 'info').length,
|
||||
warnings: mockResults.filter((r) => r.severity === 'warn').length,
|
||||
failed: mockResults.filter((r) => r.severity === 'fail').length,
|
||||
skipped: mockResults.filter((r) => r.severity === 'skip').length,
|
||||
total: mockResults.length,
|
||||
};
|
||||
|
||||
const report: DoctorReport = {
|
||||
runId,
|
||||
status: 'completed',
|
||||
startedAt: new Date(Date.now() - 5000).toISOString(),
|
||||
completedAt: new Date().toISOString(),
|
||||
durationMs: 5000,
|
||||
summary,
|
||||
overallSeverity: summary.failed > 0 ? 'fail' : summary.warnings > 0 ? 'warn' : 'pass',
|
||||
results: mockResults,
|
||||
};
|
||||
|
||||
return of(report).pipe(delay(500));
|
||||
}
|
||||
|
||||
streamRunProgress(runId: string): Observable<MessageEvent> {
|
||||
return new Observable((observer) => {
|
||||
let completed = 0;
|
||||
const total = this.mockChecks.length;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (completed < total) {
|
||||
const event = new MessageEvent('message', {
|
||||
data: JSON.stringify({
|
||||
eventType: 'check-completed',
|
||||
completed: completed + 1,
|
||||
total,
|
||||
checkId: this.mockChecks[completed].checkId,
|
||||
} as SseProgressEvent),
|
||||
});
|
||||
observer.next(event);
|
||||
completed++;
|
||||
} else {
|
||||
const event = new MessageEvent('message', {
|
||||
data: JSON.stringify({
|
||||
eventType: 'run-completed',
|
||||
completed: total,
|
||||
total,
|
||||
} as SseProgressEvent),
|
||||
});
|
||||
observer.next(event);
|
||||
clearInterval(interval);
|
||||
observer.complete();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
}
|
||||
|
||||
listReports(limit = 20, offset = 0): Observable<ReportListResponse> {
|
||||
return of({ reports: [], total: 0 }).pipe(delay(50));
|
||||
}
|
||||
|
||||
deleteReport(reportId: string): Observable<void> {
|
||||
return of(undefined).pipe(delay(50));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of, throwError } from 'rxjs';
|
||||
|
||||
import { DoctorStore } from './doctor.store';
|
||||
import { DOCTOR_API, DoctorApi, MockDoctorClient } from './doctor.client';
|
||||
import { DoctorReport, DoctorSummary } from '../models/doctor.models';
|
||||
|
||||
describe('DoctorStore', () => {
|
||||
let store: DoctorStore;
|
||||
let mockApi: jasmine.SpyObj<DoctorApi>;
|
||||
|
||||
const mockSummary: DoctorSummary = {
|
||||
passed: 3,
|
||||
info: 1,
|
||||
warnings: 1,
|
||||
failed: 1,
|
||||
skipped: 0,
|
||||
total: 6,
|
||||
};
|
||||
|
||||
const mockReport: DoctorReport = {
|
||||
runId: 'dr_test_123',
|
||||
status: 'completed',
|
||||
startedAt: '2026-01-12T10:00:00Z',
|
||||
completedAt: '2026-01-12T10:00:05Z',
|
||||
durationMs: 5000,
|
||||
summary: mockSummary,
|
||||
overallSeverity: 'fail',
|
||||
results: [
|
||||
{
|
||||
checkId: 'check.config.required',
|
||||
pluginId: 'stellaops.doctor.core',
|
||||
category: 'core',
|
||||
severity: 'pass',
|
||||
diagnosis: 'All required configuration present',
|
||||
evidence: { description: 'Config check', data: {} },
|
||||
durationMs: 100,
|
||||
executedAt: '2026-01-12T10:00:01Z',
|
||||
},
|
||||
{
|
||||
checkId: 'check.database.connectivity',
|
||||
pluginId: 'stellaops.doctor.database',
|
||||
category: 'database',
|
||||
severity: 'fail',
|
||||
diagnosis: 'Cannot connect to database',
|
||||
evidence: { description: 'Connection failed', data: { error: 'timeout' } },
|
||||
likelyCauses: ['Database not running', 'Wrong credentials'],
|
||||
remediation: {
|
||||
requiresBackup: false,
|
||||
steps: [
|
||||
{ order: 1, description: 'Check database service', command: 'systemctl status postgres', commandType: 'shell' },
|
||||
],
|
||||
},
|
||||
durationMs: 500,
|
||||
executedAt: '2026-01-12T10:00:02Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockApi = jasmine.createSpyObj('DoctorApi', [
|
||||
'listChecks',
|
||||
'listPlugins',
|
||||
'startRun',
|
||||
'getRunResult',
|
||||
'streamRunProgress',
|
||||
'listReports',
|
||||
'deleteReport',
|
||||
]);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
DoctorStore,
|
||||
{ provide: DOCTOR_API, useValue: mockApi },
|
||||
],
|
||||
});
|
||||
|
||||
store = TestBed.inject(DoctorStore);
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should have idle state', () => {
|
||||
expect(store.state()).toBe('idle');
|
||||
});
|
||||
|
||||
it('should have no report', () => {
|
||||
expect(store.report()).toBeNull();
|
||||
});
|
||||
|
||||
it('should have zero progress', () => {
|
||||
expect(store.progress()).toEqual({ completed: 0, total: 0 });
|
||||
});
|
||||
|
||||
it('should have no error', () => {
|
||||
expect(store.error()).toBeNull();
|
||||
});
|
||||
|
||||
it('should not be running', () => {
|
||||
expect(store.isRunning()).toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchChecks', () => {
|
||||
it('should fetch checks and update store', () => {
|
||||
const mockResponse = {
|
||||
checks: [{ checkId: 'check.config.required', name: 'Config', description: 'Test', pluginId: 'core', category: 'core', defaultSeverity: 'fail' as const, tags: [], estimatedDurationMs: 100 }],
|
||||
total: 1,
|
||||
};
|
||||
mockApi.listChecks.and.returnValue(of(mockResponse));
|
||||
|
||||
store.fetchChecks();
|
||||
|
||||
expect(mockApi.listChecks).toHaveBeenCalled();
|
||||
expect(store.checks()).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should set error on failure', () => {
|
||||
mockApi.listChecks.and.returnValue(throwError(() => new Error('Network error')));
|
||||
|
||||
store.fetchChecks();
|
||||
|
||||
expect(store.error()).toBe('Network error');
|
||||
expect(store.state()).toBe('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchPlugins', () => {
|
||||
it('should fetch plugins and update store', () => {
|
||||
const mockResponse = {
|
||||
plugins: [{ pluginId: 'stellaops.doctor.core', displayName: 'Core', category: 'core', version: '1.0.0', checkCount: 9 }],
|
||||
total: 1,
|
||||
};
|
||||
mockApi.listPlugins.and.returnValue(of(mockResponse));
|
||||
|
||||
store.fetchPlugins();
|
||||
|
||||
expect(mockApi.listPlugins).toHaveBeenCalled();
|
||||
expect(store.plugins()).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filters', () => {
|
||||
it('should filter results by category', () => {
|
||||
// First set a report
|
||||
(store as any).reportSignal.set(mockReport);
|
||||
|
||||
store.setCategoryFilter('core');
|
||||
|
||||
const filtered = store.filteredResults();
|
||||
expect(filtered.length).toBe(1);
|
||||
expect(filtered[0].category).toBe('core');
|
||||
});
|
||||
|
||||
it('should filter results by severity', () => {
|
||||
(store as any).reportSignal.set(mockReport);
|
||||
|
||||
store.toggleSeverityFilter('fail');
|
||||
|
||||
const filtered = store.filteredResults();
|
||||
expect(filtered.length).toBe(1);
|
||||
expect(filtered[0].severity).toBe('fail');
|
||||
});
|
||||
|
||||
it('should filter results by search query', () => {
|
||||
(store as any).reportSignal.set(mockReport);
|
||||
|
||||
store.setSearchQuery('database');
|
||||
|
||||
const filtered = store.filteredResults();
|
||||
expect(filtered.length).toBe(1);
|
||||
expect(filtered[0].checkId).toContain('database');
|
||||
});
|
||||
|
||||
it('should clear all filters', () => {
|
||||
(store as any).reportSignal.set(mockReport);
|
||||
store.setCategoryFilter('core');
|
||||
store.toggleSeverityFilter('fail');
|
||||
store.setSearchQuery('test');
|
||||
|
||||
store.clearFilters();
|
||||
|
||||
expect(store.categoryFilter()).toBeNull();
|
||||
expect(store.severityFilter()).toEqual([]);
|
||||
expect(store.searchQuery()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computed values', () => {
|
||||
beforeEach(() => {
|
||||
(store as any).reportSignal.set(mockReport);
|
||||
});
|
||||
|
||||
it('should compute summary', () => {
|
||||
expect(store.summary()).toEqual(mockSummary);
|
||||
});
|
||||
|
||||
it('should compute hasReport', () => {
|
||||
expect(store.hasReport()).toBeTrue();
|
||||
});
|
||||
|
||||
it('should compute failedResults', () => {
|
||||
const failed = store.failedResults();
|
||||
expect(failed.length).toBe(1);
|
||||
expect(failed[0].severity).toBe('fail');
|
||||
});
|
||||
|
||||
it('should compute progressPercent', () => {
|
||||
(store as any).progressSignal.set({ completed: 5, total: 10 });
|
||||
expect(store.progressPercent()).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should reset to initial state', () => {
|
||||
(store as any).stateSignal.set('completed');
|
||||
(store as any).reportSignal.set(mockReport);
|
||||
(store as any).errorSignal.set('Some error');
|
||||
|
||||
store.reset();
|
||||
|
||||
expect(store.state()).toBe('idle');
|
||||
expect(store.report()).toBeNull();
|
||||
expect(store.error()).toBeNull();
|
||||
expect(store.progress()).toEqual({ completed: 0, total: 0 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
import { Injectable, Signal, computed, inject, signal } from '@angular/core';
|
||||
import { finalize } from 'rxjs/operators';
|
||||
|
||||
import { DOCTOR_API, DoctorApi } from './doctor.client';
|
||||
import {
|
||||
CheckListResponse,
|
||||
CheckResult,
|
||||
DoctorCategory,
|
||||
DoctorProgress,
|
||||
DoctorReport,
|
||||
DoctorSeverity,
|
||||
DoctorState,
|
||||
PluginListResponse,
|
||||
RunDoctorRequest,
|
||||
SseProgressEvent,
|
||||
} from '../models/doctor.models';
|
||||
|
||||
/**
|
||||
* Signal-based state store for Doctor diagnostics.
|
||||
* Manages doctor run state, results, and filtering.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DoctorStore {
|
||||
private readonly api = inject<DoctorApi>(DOCTOR_API);
|
||||
|
||||
// Core state signals
|
||||
private readonly stateSignal = signal<DoctorState>('idle');
|
||||
private readonly currentRunIdSignal = signal<string | null>(null);
|
||||
private readonly reportSignal = signal<DoctorReport | null>(null);
|
||||
private readonly progressSignal = signal<DoctorProgress>({ completed: 0, total: 0 });
|
||||
private readonly errorSignal = signal<string | null>(null);
|
||||
private readonly loadingSignal = signal(false);
|
||||
|
||||
// Metadata signals
|
||||
private readonly checksSignal = signal<CheckListResponse | null>(null);
|
||||
private readonly pluginsSignal = signal<PluginListResponse | null>(null);
|
||||
|
||||
// Filter signals
|
||||
private readonly categoryFilterSignal = signal<DoctorCategory | null>(null);
|
||||
private readonly severityFilterSignal = signal<DoctorSeverity[]>([]);
|
||||
private readonly searchQuerySignal = signal('');
|
||||
|
||||
// Public readonly signals
|
||||
readonly state: Signal<DoctorState> = this.stateSignal.asReadonly();
|
||||
readonly currentRunId: Signal<string | null> = this.currentRunIdSignal.asReadonly();
|
||||
readonly report: Signal<DoctorReport | null> = this.reportSignal.asReadonly();
|
||||
readonly progress: Signal<DoctorProgress> = this.progressSignal.asReadonly();
|
||||
readonly error: Signal<string | null> = this.errorSignal.asReadonly();
|
||||
readonly loading: Signal<boolean> = this.loadingSignal.asReadonly();
|
||||
readonly checks: Signal<CheckListResponse | null> = this.checksSignal.asReadonly();
|
||||
readonly plugins: Signal<PluginListResponse | null> = this.pluginsSignal.asReadonly();
|
||||
readonly categoryFilter: Signal<DoctorCategory | null> = this.categoryFilterSignal.asReadonly();
|
||||
readonly severityFilter: Signal<DoctorSeverity[]> = this.severityFilterSignal.asReadonly();
|
||||
readonly searchQuery: Signal<string> = this.searchQuerySignal.asReadonly();
|
||||
|
||||
// Computed values
|
||||
readonly summary = computed(() => this.reportSignal()?.summary ?? null);
|
||||
|
||||
readonly hasReport = computed(() => this.reportSignal() !== null);
|
||||
|
||||
readonly isRunning = computed(() => this.stateSignal() === 'running');
|
||||
|
||||
readonly progressPercent = computed(() => {
|
||||
const p = this.progressSignal();
|
||||
if (p.total === 0) return 0;
|
||||
return Math.round((p.completed / p.total) * 100);
|
||||
});
|
||||
|
||||
readonly filteredResults = computed<CheckResult[]>(() => {
|
||||
const report = this.reportSignal();
|
||||
if (!report) return [];
|
||||
|
||||
let results = report.results;
|
||||
|
||||
// Filter by category
|
||||
const category = this.categoryFilterSignal();
|
||||
if (category) {
|
||||
results = results.filter((r) => r.category === category);
|
||||
}
|
||||
|
||||
// Filter by severity
|
||||
const severities = this.severityFilterSignal();
|
||||
if (severities.length > 0) {
|
||||
results = results.filter((r) => severities.includes(r.severity));
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
const query = this.searchQuerySignal().toLowerCase().trim();
|
||||
if (query) {
|
||||
results = results.filter(
|
||||
(r) =>
|
||||
r.checkId.toLowerCase().includes(query) ||
|
||||
r.diagnosis.toLowerCase().includes(query) ||
|
||||
r.category.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return results;
|
||||
});
|
||||
|
||||
readonly failedResults = computed(() =>
|
||||
this.reportSignal()?.results.filter((r) => r.severity === 'fail') ?? []
|
||||
);
|
||||
|
||||
readonly warningResults = computed(() =>
|
||||
this.reportSignal()?.results.filter((r) => r.severity === 'warn') ?? []
|
||||
);
|
||||
|
||||
readonly passedResults = computed(() =>
|
||||
this.reportSignal()?.results.filter((r) => r.severity === 'pass') ?? []
|
||||
);
|
||||
|
||||
// Actions
|
||||
|
||||
/** Fetch available checks from API. */
|
||||
fetchChecks(category?: string, plugin?: string): void {
|
||||
this.loadingSignal.set(true);
|
||||
this.api
|
||||
.listChecks(category, plugin)
|
||||
.pipe(finalize(() => this.loadingSignal.set(false)))
|
||||
.subscribe({
|
||||
next: (response) => this.checksSignal.set(response),
|
||||
error: (err) => this.setError(this.normalizeError(err)),
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch available plugins from API. */
|
||||
fetchPlugins(): void {
|
||||
this.loadingSignal.set(true);
|
||||
this.api
|
||||
.listPlugins()
|
||||
.pipe(finalize(() => this.loadingSignal.set(false)))
|
||||
.subscribe({
|
||||
next: (response) => this.pluginsSignal.set(response),
|
||||
error: (err) => this.setError(this.normalizeError(err)),
|
||||
});
|
||||
}
|
||||
|
||||
/** Start a doctor run with the given options. */
|
||||
startRun(request: RunDoctorRequest): void {
|
||||
this.stateSignal.set('running');
|
||||
this.errorSignal.set(null);
|
||||
this.progressSignal.set({ completed: 0, total: 0 });
|
||||
|
||||
this.api.startRun(request).subscribe({
|
||||
next: ({ runId }) => {
|
||||
this.currentRunIdSignal.set(runId);
|
||||
this.streamProgress(runId);
|
||||
},
|
||||
error: (err) => {
|
||||
this.stateSignal.set('error');
|
||||
this.errorSignal.set(this.normalizeError(err));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Stream progress updates via SSE. */
|
||||
private streamProgress(runId: string): void {
|
||||
this.api.streamRunProgress(runId).subscribe({
|
||||
next: (event) => {
|
||||
try {
|
||||
const data: SseProgressEvent = JSON.parse(event.data);
|
||||
|
||||
if (data.eventType === 'check-completed' || data.eventType === 'check-started') {
|
||||
this.progressSignal.set({
|
||||
completed: data.completed ?? 0,
|
||||
total: data.total ?? 0,
|
||||
checkId: data.checkId,
|
||||
});
|
||||
} else if (data.eventType === 'run-completed') {
|
||||
this.loadFinalResult(runId);
|
||||
} else if (data.eventType === 'error') {
|
||||
this.stateSignal.set('error');
|
||||
this.errorSignal.set(data.message ?? 'Unknown error during run');
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
// Fallback to polling if SSE fails
|
||||
this.pollForResult(runId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Poll for result when SSE is not available. */
|
||||
private pollForResult(runId: string): void {
|
||||
const interval = setInterval(() => {
|
||||
this.api.getRunResult(runId).subscribe({
|
||||
next: (result) => {
|
||||
if (result.status === 'completed' || result.status === 'failed') {
|
||||
clearInterval(interval);
|
||||
this.completeRun(result);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
clearInterval(interval);
|
||||
this.stateSignal.set('error');
|
||||
this.errorSignal.set('Failed to get run result');
|
||||
},
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// Timeout after 5 minutes
|
||||
setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
if (this.stateSignal() === 'running') {
|
||||
this.stateSignal.set('error');
|
||||
this.errorSignal.set('Run timed out');
|
||||
}
|
||||
}, 300000);
|
||||
}
|
||||
|
||||
/** Load final result after run completion. */
|
||||
private loadFinalResult(runId: string): void {
|
||||
this.api.getRunResult(runId).subscribe({
|
||||
next: (result) => this.completeRun(result),
|
||||
error: (err) => {
|
||||
this.stateSignal.set('error');
|
||||
this.errorSignal.set(this.normalizeError(err));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Complete a run with the final report. */
|
||||
private completeRun(report: DoctorReport): void {
|
||||
this.stateSignal.set('completed');
|
||||
this.reportSignal.set(report);
|
||||
this.progressSignal.set({
|
||||
completed: report.summary.total,
|
||||
total: report.summary.total,
|
||||
});
|
||||
}
|
||||
|
||||
/** Set error state. */
|
||||
private setError(message: string): void {
|
||||
this.stateSignal.set('error');
|
||||
this.errorSignal.set(message);
|
||||
}
|
||||
|
||||
/** Set category filter. */
|
||||
setCategoryFilter(category: DoctorCategory | null): void {
|
||||
this.categoryFilterSignal.set(category);
|
||||
}
|
||||
|
||||
/** Toggle severity filter. */
|
||||
toggleSeverityFilter(severity: DoctorSeverity): void {
|
||||
const current = this.severityFilterSignal();
|
||||
if (current.includes(severity)) {
|
||||
this.severityFilterSignal.set(current.filter((s) => s !== severity));
|
||||
} else {
|
||||
this.severityFilterSignal.set([...current, severity]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Set search query. */
|
||||
setSearchQuery(query: string): void {
|
||||
this.searchQuerySignal.set(query);
|
||||
}
|
||||
|
||||
/** Clear all filters. */
|
||||
clearFilters(): void {
|
||||
this.categoryFilterSignal.set(null);
|
||||
this.severityFilterSignal.set([]);
|
||||
this.searchQuerySignal.set('');
|
||||
}
|
||||
|
||||
/** Reset store to initial state. */
|
||||
reset(): void {
|
||||
this.stateSignal.set('idle');
|
||||
this.currentRunIdSignal.set(null);
|
||||
this.reportSignal.set(null);
|
||||
this.progressSignal.set({ completed: 0, total: 0 });
|
||||
this.errorSignal.set(null);
|
||||
this.clearFilters();
|
||||
}
|
||||
|
||||
/** Normalize error to string. */
|
||||
private normalizeError(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === 'string') return err;
|
||||
return 'An unknown error occurred';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user