audit, advisories and doctors/setup work
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
@if (result.remediation) {
|
||||
<st-remediation-panel
|
||||
[remediation]="result.remediation"
|
||||
[fixEnabled]="fixEnabled"
|
||||
[verificationCommand]="result.verificationCommand" />
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)" />
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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}$/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user