audit work, doctors work

This commit is contained in:
master
2026-01-12 23:39:07 +02:00
parent 9330c64349
commit b8868a5f13
80 changed files with 12659 additions and 87 deletions

View File

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

View File

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

View File

@@ -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)">
&#8635;
</button>
<span class="expand-indicator">{{ expanded ? '&#9650;' : '&#9660;' }}</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>

View File

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

View File

@@ -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 '&#10004;'; // checkmark
case 'info':
return '&#8505;'; // info
case 'warn':
return '&#9888;'; // warning triangle
case 'fail':
return '&#10008;'; // x mark
case 'skip':
return '&#8594;'; // arrow right
default:
return '&#63;'; // 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();
}
}

View File

@@ -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() ? '&#9660;' : '&#9654;' }}</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;
}
}

View File

@@ -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()">&times;</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);
}
}

View File

@@ -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">&#9888;</span>
<span>Backup recommended before proceeding</span>
</div>
}
@if (remediation.safetyNote) {
<div class="safety-note">
<span class="note-icon">&#8505;</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';
}
}

View File

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

View File

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

View File

@@ -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">&#9889;</span>
Quick Check
</button>
<button
class="btn btn-secondary"
(click)="runNormalCheck()"
[disabled]="store.isRunning()">
<span class="btn-icon">&#9881;</span>
Normal Check
</button>
<button
class="btn btn-outline"
(click)="runFullCheck()"
[disabled]="store.isRunning()">
<span class="btn-icon">&#128269;</span>
Full Check
</button>
<button
class="btn btn-ghost"
(click)="openExportDialog()"
[disabled]="!store.hasReport()">
<span class="btn-icon">&#128190;</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">&#9888;</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">&#128269;</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>

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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