audit, advisories and doctors/setup work

This commit is contained in:
master
2026-01-13 18:53:39 +02:00
parent 9ca7cb183e
commit d7be6ba34b
811 changed files with 54242 additions and 4056 deletions

View File

@@ -54,6 +54,10 @@ export interface WelcomeConfig {
readonly docsUrl?: string;
}
export interface DoctorConfig {
readonly fixEnabled?: boolean;
}
export interface AppConfig {
readonly authority: AuthorityConfig;
readonly apiBaseUrls: ApiBaseUrlConfig;
@@ -63,9 +67,10 @@ export interface AppConfig {
*/
readonly quickstartMode?: boolean;
/**
* Optional welcome metadata surfaced at /welcome for config discovery.
* Optional welcome metadata surfaced at /welcome for config discovery.
*/
readonly welcome?: WelcomeConfig;
readonly doctor?: DoctorConfig;
}
export const APP_CONFIG = new InjectionToken<AppConfig>('STELLAOPS_APP_CONFIG');

View File

@@ -20,6 +20,7 @@ const DEFAULT_DPOP_ALG: DPoPAlgorithm = 'ES256';
const DEFAULT_REFRESH_LEEWAY_SECONDS = 60;
const DEFAULT_QUICKSTART = false;
const DEFAULT_TELEMETRY_SAMPLE_RATE = 0;
const DEFAULT_DOCTOR_FIX_ENABLED = false;
@Injectable({
providedIn: 'root',
@@ -105,11 +106,19 @@ export class AppConfigService {
}
: undefined;
const doctor = config.doctor
? {
...config.doctor,
fixEnabled: config.doctor.fixEnabled ?? DEFAULT_DOCTOR_FIX_ENABLED,
}
: { fixEnabled: DEFAULT_DOCTOR_FIX_ENABLED };
return {
...config,
authority,
telemetry,
quickstartMode: config.quickstartMode ?? DEFAULT_QUICKSTART,
doctor,
};
}
}

View File

@@ -43,6 +43,7 @@
@if (result.remediation) {
<st-remediation-panel
[remediation]="result.remediation"
[fixEnabled]="fixEnabled"
[verificationCommand]="result.verificationCommand" />
}
</div>

View File

@@ -15,6 +15,7 @@ import { EvidenceViewerComponent } from '../evidence-viewer/evidence-viewer.comp
export class CheckResultComponent {
@Input({ required: true }) result!: CheckResult;
@Input() expanded = false;
@Input() fixEnabled = false;
@Output() rerun = new EventEmitter<void>();
get severityClass(): string {

View File

@@ -1,10 +1,11 @@
import { CommonModule } from '@angular/common';
import { Component, EventEmitter, Input, Output, signal } from '@angular/core';
import { Component, EventEmitter, Input, Output, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { DoctorReport } from '../../models/doctor.models';
import { DoctorExportService } from '../../services/doctor-export.service';
type ExportFormat = 'json' | 'markdown' | 'text';
type ExportFormat = 'json' | 'markdown' | 'text' | 'dsse';
@Component({
standalone: true,
@@ -24,7 +25,14 @@ type ExportFormat = 'json' | 'markdown' | 'text';
<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>
<small>Machine-readable format for CI/CD integration</small>
</span>
</label>
<label class="format-option">
<input type="radio" name="format" value="dsse" [(ngModel)]="selectedFormat">
<span class="format-label">
<strong>DSSE Summary</strong>
<small>Summary envelope for audits (no signatures by default)</small>
</span>
</label>
<label class="format-option">
@@ -43,20 +51,27 @@ type ExportFormat = 'json' | 'markdown' | 'text';
</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>
@if (selectedFormat !== 'dsse') {
<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>
} @else {
<div class="dsse-note">
DSSE summaries include the doctor command and evidence log hash.
Use JSON for full report detail.
</div>
}
<div class="preview-section">
<h4>Preview</h4>
@@ -189,6 +204,15 @@ type ExportFormat = 'json' | 'markdown' | 'text';
margin-bottom: 1.5rem;
}
.dsse-note {
padding: 0.75rem;
border: 1px dashed var(--border, #e2e8f0);
border-radius: 8px;
margin-bottom: 1.5rem;
font-size: 0.8125rem;
color: var(--text-secondary, #64748b);
}
.checkbox-option {
display: flex;
align-items: center;
@@ -267,6 +291,7 @@ type ExportFormat = 'json' | 'markdown' | 'text';
export class ExportDialogComponent {
@Input({ required: true }) report!: DoctorReport;
@Output() closeDialog = new EventEmitter<void>();
private readonly exportService = inject(DoctorExportService);
selectedFormat: ExportFormat = 'markdown';
includeEvidence = true;
@@ -290,6 +315,10 @@ export class ExportDialogComponent {
}
generatePreview(): string {
if (this.selectedFormat === 'dsse') {
return 'DSSE summary preview is generated on download.';
}
const content = this.generateContent();
// Truncate for preview
if (content.length > 1000) {
@@ -399,20 +428,31 @@ export class ExportDialogComponent {
return text;
}
copyToClipboard(): void {
const content = this.generateContent();
async copyToClipboard(): Promise<void> {
const content =
this.selectedFormat === 'dsse'
? await this.exportService.buildDsseSummary(this.report)
: 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;
async download(): Promise<void> {
const isDsse = this.selectedFormat === 'dsse';
const content = isDsse
? await this.exportService.buildDsseSummary(this.report)
: this.generateContent();
const extension = isDsse
? 'dsse.json'
: this.selectedFormat === 'markdown'
? 'md'
: this.selectedFormat;
const filename = `doctor-report-${this.report.runId}.${extension}`;
const mimeType =
this.selectedFormat === 'json'
const mimeType = isDsse
? 'application/json'
: this.selectedFormat === 'json'
? 'application/json'
: this.selectedFormat === 'markdown'
? 'text/markdown'

View File

@@ -11,9 +11,18 @@ import { Remediation, RemediationStep } from '../../models/doctor.models';
<div class="remediation-panel">
<div class="panel-header">
<h4>Remediation</h4>
<button class="copy-all-btn" (click)="copyAllCommands()">
{{ copyAllLabel() }}
</button>
<div class="panel-actions">
<button class="copy-all-btn" (click)="copyAllCommands()">
{{ copyAllLabel() }}
</button>
<button
class="run-fix-btn"
[disabled]="!canRunFix"
[title]="runFixTitle"
(click)="runFix()">
{{ runFixLabel() }}
</button>
</div>
</div>
@if (remediation.requiresBackup) {
@@ -83,7 +92,8 @@ import { Remediation, RemediationStep } from '../../models/doctor.models';
}
.copy-all-btn,
.copy-btn {
.copy-btn,
.run-fix-btn {
padding: 0.25rem 0.625rem;
font-size: 0.75rem;
background: var(--bg-secondary, #f1f5f9);
@@ -99,6 +109,28 @@ import { Remediation, RemediationStep } from '../../models/doctor.models';
}
}
.panel-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.run-fix-btn {
background: var(--primary, #3b82f6);
border-color: var(--primary, #3b82f6);
color: white;
&:hover:not(:disabled) {
background: var(--primary-dark, #2563eb);
}
&:disabled {
background: var(--bg-secondary, #f1f5f9);
border-color: var(--border, #e2e8f0);
color: var(--text-secondary, #64748b);
}
}
.backup-warning {
display: flex;
align-items: center;
@@ -210,11 +242,27 @@ import { Remediation, RemediationStep } from '../../models/doctor.models';
})
export class RemediationPanelComponent {
@Input({ required: true }) remediation!: Remediation;
@Input() fixEnabled = false;
@Input() verificationCommand?: string;
private copiedSteps = signal<Set<number>>(new Set());
private copiedAll = signal(false);
private copiedVerification = signal(false);
private ranFix = signal(false);
get canRunFix(): boolean {
return this.fixEnabled && this.remediation?.steps?.length > 0;
}
get runFixTitle(): string {
if (!this.fixEnabled) {
return 'Enable doctor.fix.enabled to allow fix actions.';
}
if (!this.remediation?.steps?.length) {
return 'No fix commands available.';
}
return 'Run safe fix commands.';
}
copyCommand(step: RemediationStep): void {
navigator.clipboard.writeText(step.command).then(() => {
@@ -241,6 +289,15 @@ export class RemediationPanelComponent {
});
}
runFix(): void {
if (!this.canRunFix) {
return;
}
this.copyAllCommands();
this.ranFix.set(true);
setTimeout(() => this.ranFix.set(false), 2000);
}
copyVerification(): void {
if (this.verificationCommand) {
navigator.clipboard.writeText(this.verificationCommand).then(() => {
@@ -255,10 +312,14 @@ export class RemediationPanelComponent {
}
copyAllLabel(): string {
return this.copiedAll() ? 'Copied!' : 'Copy All';
return this.copiedAll() ? 'Copied!' : 'Copy Fix Commands';
}
copyVerificationLabel(): string {
return this.copiedVerification() ? 'Copied!' : 'Copy';
}
runFixLabel(): string {
return this.ranFix() ? 'Fix Commands Copied' : 'Run Fix';
}
}

View File

@@ -75,6 +75,54 @@
[overallSeverity]="store.report()?.overallSeverity" />
}
@if (store.packGroups().length) {
<section class="pack-section">
<div class="section-header">
<h2>Doctor Packs</h2>
<p>Discovered integrations and checks available to run.</p>
</div>
<div class="pack-grid">
@for (pack of store.packGroups(); track pack.category) {
<article class="pack-card">
<div class="pack-header">
<div>
<h3>{{ pack.label }}</h3>
<span class="pack-meta">{{ pack.plugins.length }} plugins</span>
</div>
</div>
<div class="plugin-list">
@for (plugin of pack.plugins; track plugin.pluginId) {
<div class="plugin-card">
<div class="plugin-header">
<div>
<div class="plugin-name">{{ plugin.displayName }}</div>
<div class="plugin-meta">{{ plugin.pluginId }}</div>
</div>
<div class="plugin-stats">
<span>{{ plugin.checks.length > 0 ? plugin.checks.length : plugin.checkCount }} checks</span>
@if (plugin.version && plugin.version !== 'unknown') {
<span class="plugin-version">v{{ plugin.version }}</span>
}
</div>
</div>
@if (plugin.checks.length > 0) {
<ul class="plugin-checks">
@for (check of plugin.checks; track check.checkId) {
<li>{{ check.checkId }}</li>
}
</ul>
} @else {
<div class="plugin-empty">Checks not discovered yet.</div>
}
</div>
}
</div>
</article>
}
</div>
</section>
}
<!-- Filters -->
<div class="filters-container">
<div class="filter-group">
@@ -143,6 +191,7 @@
<st-check-result
[result]="result"
[expanded]="isResultSelected(result)"
[fixEnabled]="fixEnabled"
(click)="selectResult(result)"
(rerun)="rerunCheck(result.checkId)" />
}

View File

@@ -185,6 +185,133 @@
}
}
// Packs
.pack-section {
margin-bottom: 1.5rem;
padding: 1rem;
background: var(--bg-secondary, #f8fafc);
border-radius: 8px;
.section-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 1rem;
gap: 1rem;
h2 {
margin: 0;
font-size: 1.125rem;
color: var(--text-primary, #1a1a2e);
}
p {
margin: 0;
font-size: 0.8125rem;
color: var(--text-secondary, #64748b);
}
}
}
.pack-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
}
.pack-card {
background: white;
border: 1px solid var(--border, #e2e8f0);
border-radius: 8px;
padding: 0.75rem;
}
.pack-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
h3 {
margin: 0;
font-size: 0.9375rem;
color: var(--text-primary, #1a1a2e);
}
.pack-meta {
font-size: 0.75rem;
color: var(--text-secondary, #64748b);
}
}
.plugin-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.plugin-card {
border: 1px solid var(--border, #e2e8f0);
border-radius: 6px;
padding: 0.75rem;
background: var(--bg-surface, #ffffff);
}
.plugin-header {
display: flex;
justify-content: space-between;
gap: 0.75rem;
.plugin-name {
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary, #1a1a2e);
}
.plugin-meta {
font-size: 0.75rem;
color: var(--text-secondary, #64748b);
word-break: break-all;
}
.plugin-stats {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.125rem;
font-size: 0.75rem;
color: var(--text-secondary, #64748b);
}
.plugin-version {
font-size: 0.6875rem;
color: var(--text-tertiary, #94a3b8);
}
}
.plugin-checks {
list-style: none;
padding: 0;
margin: 0.5rem 0 0 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
li {
padding: 0.25rem 0.5rem;
background: var(--bg-hover, #f1f5f9);
border-radius: 4px;
font-family: monospace;
font-size: 0.8125rem;
}
}
.plugin-empty {
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--text-tertiary, #94a3b8);
}
// Filters
.filters-container {
display: flex;

View File

@@ -6,6 +6,8 @@ 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';
import { AppConfigService } from '../../core/config/app-config.service';
import { AppConfig } from '../../core/config/app-config.model';
describe('DoctorDashboardComponent', () => {
let component: DoctorDashboardComponent;
@@ -45,6 +47,28 @@ describe('DoctorDashboardComponent', () => {
results: mockResults,
};
const mockConfig: AppConfig = {
authority: {
issuer: 'https://authority.local',
clientId: 'stellaops-ui',
authorizeEndpoint: 'https://authority.local/connect/authorize',
tokenEndpoint: 'https://authority.local/connect/token',
redirectUri: 'http://localhost:4400/auth/callback',
scope: 'openid profile email',
audience: 'https://scanner.local',
},
apiBaseUrls: {
authority: 'https://authority.local',
scanner: 'https://scanner.local',
policy: 'https://scanner.local',
concelier: 'https://concelier.local',
attestor: 'https://attestor.local',
},
doctor: {
fixEnabled: false,
},
};
beforeEach(async () => {
// Create mock store with signals
mockStore = jasmine.createSpyObj('DoctorStore', [
@@ -75,6 +99,7 @@ describe('DoctorDashboardComponent', () => {
failedResults: signal([]),
warningResults: signal([]),
passedResults: signal([]),
packGroups: signal([]),
});
await TestBed.configureTestingModule({
@@ -82,6 +107,7 @@ describe('DoctorDashboardComponent', () => {
providers: [
{ provide: DoctorStore, useValue: mockStore },
{ provide: DOCTOR_API, useClass: MockDoctorClient },
{ provide: AppConfigService, useValue: { config: mockConfig } },
],
}).compileComponents();

View File

@@ -7,6 +7,7 @@ import { CheckResult, DoctorCategory, DoctorSeverity, RunDoctorRequest } from '.
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';
import { AppConfigService } from '../../core/config/app-config.service';
@Component({
standalone: true,
@@ -23,6 +24,8 @@ import { ExportDialogComponent } from './components/export-dialog/export-dialog.
})
export class DoctorDashboardComponent implements OnInit {
readonly store = inject(DoctorStore);
private readonly configService = inject(AppConfigService);
readonly fixEnabled = this.configService.config.doctor?.fixEnabled ?? false;
readonly showExportDialog = signal(false);
readonly selectedResult = signal<CheckResult | null>(null);

View File

@@ -3,6 +3,7 @@ export * from './models/doctor.models';
// Services
export * from './services/doctor.client';
export * from './services/doctor-export.service';
export * from './services/doctor.store';
// Components

View File

@@ -27,6 +27,21 @@ export interface PluginMetadata {
checkCount: number;
}
export interface DoctorPackGroup {
category: string;
label: string;
plugins: DoctorPluginGroup[];
}
export interface DoctorPluginGroup {
pluginId: string;
displayName: string;
category: string;
version: string;
checkCount: number;
checks: CheckMetadata[];
}
export interface RunDoctorRequest {
mode: DoctorRunMode;
categories?: string[];

View File

@@ -0,0 +1,70 @@
import { DoctorExportService } from './doctor-export.service';
import { DoctorReport } from '../models/doctor.models';
describe('DoctorExportService', () => {
let service: DoctorExportService;
beforeEach(() => {
service = new DoctorExportService();
});
it('builds a DSSE summary envelope', async () => {
const report: DoctorReport = {
runId: 'dr_test_001',
status: 'completed',
startedAt: '2026-01-12T10:00:00Z',
completedAt: '2026-01-12T10:00:05Z',
durationMs: 5000,
summary: {
passed: 1,
info: 0,
warnings: 0,
failed: 0,
skipped: 0,
total: 1,
},
overallSeverity: 'pass',
results: [
{
checkId: 'check.scm.webhook',
pluginId: 'stellaops.doctor.gitlab',
category: 'integration',
severity: 'pass',
diagnosis: 'Webhook reachable',
evidence: {
description: 'Evidence collected',
data: { status: 'ok' },
},
remediation: {
requiresBackup: false,
steps: [
{
order: 1,
description: 'Recreate webhook',
command: 'stella orchestrator scm create-webhook',
commandType: 'shell',
},
],
},
durationMs: 120,
executedAt: '2026-01-12T10:00:01Z',
},
],
};
const output = await service.buildDsseSummary(report);
const envelope = JSON.parse(output);
expect(envelope.payloadType).toBe('application/vnd.stellaops.doctor.summary+json');
expect(envelope.signatures.length).toBe(0);
const payloadJson = atob(envelope.payload);
const payload = JSON.parse(payloadJson);
expect(payload.runId).toBe(report.runId);
expect(payload.doctor_command).toBe('stella doctor run');
expect(payload.evidenceLog.records).toBe(report.results.length);
expect(payload.evidenceLog.jsonlPath).toContain(report.runId);
expect(payload.evidenceLog.sha256).toMatch(/^[0-9a-f]{64}$/);
});
});

View File

@@ -0,0 +1,204 @@
import { Injectable } from '@angular/core';
import { CheckResult, DoctorReport } from '../models/doctor.models';
interface DoctorEvidenceSummary {
runId: string;
doctor_command: string;
startedAt: string;
completedAt?: string;
durationMs: number;
overallSeverity: string;
summary: {
passed: number;
info: number;
warnings: number;
failed: number;
skipped: number;
total: number;
};
evidenceLog: {
jsonlPath: string;
sha256: string;
records: number;
};
}
interface DoctorDsseEnvelope {
payloadType: string;
payload: string;
signatures: Array<{ keyid?: string; sig?: string }>;
}
@Injectable({ providedIn: 'root' })
export class DoctorExportService {
private readonly payloadType = 'application/vnd.stellaops.doctor.summary+json';
private readonly defaultDoctorCommand = 'stella doctor run';
private readonly defaultJsonlTemplate = 'artifacts/doctor/doctor-run-{runId}.ndjson';
async buildDsseSummary(report: DoctorReport, doctorCommand?: string): Promise<string> {
const command = doctorCommand?.trim() || this.defaultDoctorCommand;
const jsonlPath = this.defaultJsonlTemplate.replace('{runId}', report.runId);
const jsonl = this.buildEvidenceJsonl(report, command);
const sha256 = await this.computeSha256Hex(jsonl);
const durationMs = this.resolveDurationMs(report);
const summary: DoctorEvidenceSummary = {
runId: report.runId,
doctor_command: command,
startedAt: report.startedAt,
completedAt: report.completedAt,
durationMs,
overallSeverity: report.overallSeverity,
summary: report.summary,
evidenceLog: {
jsonlPath,
sha256,
records: report.results.length,
},
};
const payloadJson = JSON.stringify(summary);
const envelope: DoctorDsseEnvelope = {
payloadType: this.payloadType,
payload: this.toBase64(payloadJson),
signatures: [],
};
return JSON.stringify(envelope, null, 2);
}
private buildEvidenceJsonl(report: DoctorReport, doctorCommand: string): string {
const results = [...report.results].sort((a, b) => {
const order = this.severityOrder(a.severity) - this.severityOrder(b.severity);
if (order !== 0) {
return order;
}
return DoctorExportService.compareStrings(a.checkId, b.checkId);
});
return results
.map((result) => JSON.stringify(this.buildResultRecord(report, result, doctorCommand)))
.join('\n');
}
private buildResultRecord(
report: DoctorReport,
result: CheckResult,
doctorCommand: string
): Record<string, unknown> {
const record: Record<string, unknown> = {
runId: report.runId,
doctor_command: doctorCommand,
checkId: result.checkId,
pluginId: result.pluginId,
category: result.category,
severity: result.severity,
diagnosis: result.diagnosis,
executedAt: this.formatTimestamp(result.executedAt),
durationMs: result.durationMs ?? 0,
how_to_fix: {
commands: this.extractFixCommands(result),
},
};
const evidenceData = result.evidence?.data ?? {};
const evidenceKeys = Object.keys(evidenceData);
if (evidenceKeys.length > 0) {
const sortedData: Record<string, string> = {};
evidenceKeys.sort(DoctorExportService.compareStrings).forEach((key) => {
sortedData[key] = evidenceData[key];
});
record['evidence'] = {
description: result.evidence?.description ?? 'Evidence',
data: sortedData,
};
}
return record;
}
private extractFixCommands(result: CheckResult): string[] {
if (!result.remediation?.steps?.length) {
return [];
}
return [...result.remediation.steps]
.sort((a, b) => a.order - b.order)
.map((step) => step.command)
.filter((command) => command && command.trim().length > 0);
}
private formatTimestamp(value: string): string {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return value;
}
return parsed.toISOString();
}
private resolveDurationMs(report: DoctorReport): number {
if (typeof report.durationMs === 'number') {
return report.durationMs;
}
if (!report.completedAt) {
return 0;
}
const started = new Date(report.startedAt).getTime();
const completed = new Date(report.completedAt).getTime();
if (Number.isNaN(started) || Number.isNaN(completed)) {
return 0;
}
return Math.max(0, completed - started);
}
private async computeSha256Hex(text: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(text);
const cryptoRef = globalThis.crypto;
if (!cryptoRef?.subtle?.digest) {
throw new Error('WebCrypto is not available.');
}
const digest = await cryptoRef.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
}
private toBase64(value: string): string {
const data = new TextEncoder().encode(value);
let binary = '';
for (const byte of data) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
private severityOrder(severity: string): number {
switch (severity) {
case 'fail':
return 0;
case 'warn':
return 1;
case 'info':
return 2;
case 'pass':
return 3;
case 'skip':
return 4;
default:
return 5;
}
}
private static compareStrings(a: string, b: string): number {
if (a === b) {
return 0;
}
return a < b ? -1 : 1;
}
}

View File

@@ -210,6 +210,60 @@ describe('DoctorStore', () => {
});
});
describe('packGroups', () => {
it('should group plugins and checks by category', () => {
const mockChecks = {
checks: [
{
checkId: 'check.scm.webhook',
name: 'Webhook',
description: 'Test webhook',
pluginId: 'stellaops.doctor.gitlab',
category: 'integration',
defaultSeverity: 'fail' as const,
tags: ['scm'],
estimatedDurationMs: 100,
},
{
checkId: 'check.scm.branch-policy',
name: 'Branch Policy',
description: 'Test branch policy',
pluginId: 'stellaops.doctor.gitlab',
category: 'integration',
defaultSeverity: 'warn' as const,
tags: ['scm'],
estimatedDurationMs: 100,
},
],
total: 2,
};
const mockPlugins = {
plugins: [
{
pluginId: 'stellaops.doctor.gitlab',
displayName: 'GitLab',
category: 'integration',
version: '1.0.0',
checkCount: 2,
},
],
total: 1,
};
mockApi.listChecks.and.returnValue(of(mockChecks));
mockApi.listPlugins.and.returnValue(of(mockPlugins));
store.fetchChecks();
store.fetchPlugins();
const packs = store.packGroups();
expect(packs.length).toBe(1);
expect(packs[0].category).toBe('integration');
expect(packs[0].plugins.length).toBe(1);
expect(packs[0].plugins[0].checks.length).toBe(2);
});
});
describe('reset', () => {
it('should reset to initial state', () => {
(store as any).stateSignal.set('completed');

View File

@@ -4,8 +4,11 @@ import { finalize } from 'rxjs/operators';
import { DOCTOR_API, DoctorApi } from './doctor.client';
import {
CheckListResponse,
CheckMetadata,
CheckResult,
DoctorCategory,
DoctorPackGroup,
DoctorPluginGroup,
DoctorProgress,
DoctorReport,
DoctorSeverity,
@@ -60,6 +63,80 @@ export class DoctorStore {
readonly isRunning = computed(() => this.stateSignal() === 'running');
readonly packGroups = computed<DoctorPackGroup[]>(() => {
const checks = this.checksSignal()?.checks ?? [];
const plugins = this.pluginsSignal()?.plugins ?? [];
if (checks.length === 0 && plugins.length === 0) {
return [];
}
const checksByPlugin = new Map<string, CheckMetadata[]>();
for (const check of checks) {
const pluginId = check.pluginId?.trim() || 'unknown';
const list = checksByPlugin.get(pluginId) ?? [];
list.push(check);
checksByPlugin.set(pluginId, list);
}
for (const list of checksByPlugin.values()) {
list.sort((a, b) => DoctorStore.compareStrings(a.checkId, b.checkId));
}
const pluginGroups = new Map<string, DoctorPluginGroup>();
for (const plugin of plugins) {
const pluginId = plugin.pluginId;
const pluginChecks = checksByPlugin.get(pluginId) ?? [];
pluginGroups.set(pluginId, {
pluginId,
displayName: plugin.displayName,
category: plugin.category,
version: plugin.version,
checkCount: plugin.checkCount,
checks: pluginChecks,
});
}
for (const [pluginId, pluginChecks] of checksByPlugin.entries()) {
if (pluginGroups.has(pluginId)) {
continue;
}
const category = pluginChecks[0]?.category ?? 'uncategorized';
pluginGroups.set(pluginId, {
pluginId,
displayName: pluginId,
category,
version: 'unknown',
checkCount: pluginChecks.length,
checks: pluginChecks,
});
}
const packs = new Map<string, DoctorPackGroup>();
for (const plugin of pluginGroups.values()) {
const category = plugin.category?.trim() || 'uncategorized';
const label = DoctorStore.formatLabel(category);
const pack = packs.get(category) ?? { category, label, plugins: [] };
pack.plugins.push(plugin);
packs.set(category, pack);
}
const packList = Array.from(packs.values());
for (const pack of packList) {
pack.plugins.sort((a, b) => {
const label = DoctorStore.compareStrings(a.displayName, b.displayName);
if (label !== 0) {
return label;
}
return DoctorStore.compareStrings(a.pluginId, b.pluginId);
});
}
packList.sort((a, b) => DoctorStore.compareStrings(a.label, b.label));
return packList;
});
readonly progressPercent = computed(() => {
const p = this.progressSignal();
if (p.total === 0) return 0;
@@ -282,4 +359,26 @@ export class DoctorStore {
if (typeof err === 'string') return err;
return 'An unknown error occurred';
}
private static compareStrings(a: string, b: string): number {
if (a === b) {
return 0;
}
return a < b ? -1 : 1;
}
private static formatLabel(value: string): string {
if (!value) {
return 'Uncategorized';
}
const trimmed = value.trim();
if (!trimmed) {
return 'Uncategorized';
}
return trimmed.length === 1
? trimmed.toUpperCase()
: trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
}
}

View File

@@ -19,6 +19,9 @@
"concelier": "https://concelier.local",
"attestor": "https://attestor.local"
},
"doctor": {
"fixEnabled": false
},
"telemetry": {
"otlpEndpoint": "http://localhost:4318/v1/traces",
"sampleRate": 0.1