documentation cleanse, sprints work and planning. remaining non EF DAL migration to EF

This commit is contained in:
master
2026-02-25 01:24:07 +02:00
parent b07d27772e
commit 4db038123b
9090 changed files with 4836 additions and 2909 deletions

View File

@@ -107,7 +107,7 @@ export class UnifiedSearchClient {
limit = 10,
): Observable<UnifiedSearchResponse> {
const normalizedQuery = query.trim();
if (normalizedQuery.length < 2) {
if (normalizedQuery.length < 1) {
return of({
query,
topK: limit,

View File

@@ -1,14 +1,17 @@
import { Injectable, signal } from '@angular/core';
import { Subject } from 'rxjs';
import type { EntityCard, SynthesisResult, UnifiedSearchDomain } from '../api/unified-search.models';
export interface SearchToChatContext {
query: string;
entityCards: any[]; // EntityCard[]
synthesis: any | null; // SynthesisResult
entityCards: EntityCard[];
synthesis: SynthesisResult | null;
suggestedPrompt?: string;
}
export interface ChatToSearchContext {
query: string;
domain?: string;
domain?: UnifiedSearchDomain;
entityKey?: string;
}
@@ -16,6 +19,8 @@ export interface ChatToSearchContext {
export class SearchChatContextService {
private readonly _searchToChat = signal<SearchToChatContext | null>(null);
private readonly _chatToSearch = signal<ChatToSearchContext | null>(null);
private readonly _chatToSearchRequests = new Subject<void>();
readonly chatToSearchRequested$ = this._chatToSearchRequests.asObservable();
setSearchToChat(context: SearchToChatContext): void {
this._searchToChat.set(context);
@@ -29,6 +34,7 @@ export class SearchChatContextService {
setChatToSearch(context: ChatToSearchContext): void {
this._chatToSearch.set(context);
this._chatToSearchRequests.next();
}
consumeChatToSearch(): ChatToSearchContext | null {

View File

@@ -107,10 +107,20 @@ interface MessageSegment {
<ul class="citations-list">
@for (citation of turn.citations; track citation.path) {
<li class="citation-item">
<stellaops-object-link-chip
[link]="citationToLink(citation)"
[verified]="citation.verified"
(navigate)="onLinkNavigate($event)"/>
<div class="citation-item__actions">
<stellaops-object-link-chip
[link]="citationToLink(citation)"
[verified]="citation.verified"
(navigate)="onLinkNavigate($event)"/>
<button
type="button"
class="citation-search-related"
(click)="onSearchRelated(citation)"
title="Search related results"
>
Search related
</button>
</div>
</li>
}
</ul>
@@ -118,7 +128,7 @@ interface MessageSegment {
}
<!-- Search for more -->
@if (turn.role === 'assistant') {
@if (turn.role === 'assistant' && (turn.citations?.length ?? 0) > 0) {
<button
type="button"
class="search-more-link"
@@ -337,6 +347,29 @@ interface MessageSegment {
display: inline-block;
}
.citation-item__actions {
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
.citation-search-related {
border: 1px solid var(--color-border-secondary, #d1d5db);
background: transparent;
color: var(--color-text-secondary, #4b5563);
border-radius: 999px;
font-size: var(--font-size-sm, 0.6875rem);
padding: 0.125rem 0.5rem;
cursor: pointer;
transition: background-color 0.12s, border-color 0.12s;
}
.citation-search-related:hover {
background: var(--color-nav-hover, #f3f4f6);
border-color: var(--color-brand-primary, #2563eb);
color: var(--color-brand-primary, #2563eb);
}
.search-more-link {
display: inline-flex;
align-items: center;
@@ -522,12 +555,24 @@ export class ChatMessageComponent {
*/
onSearchForMore(): void {
const query = this.extractSearchQuery(this.turn.content);
const firstCitation = this.turn.citations?.[0];
const domain = firstCitation
? this.mapCitationTypeToDomain(firstCitation.type)
: undefined;
this.searchChatContext.setChatToSearch({
query,
domain,
});
this.searchForMore.emit(query);
}
onSearchRelated(citation: { type: string; path: string }): void {
const query = this.extractSearchQueryFromCitation(citation.type, citation.path);
const domain = this.mapCitationTypeToDomain(citation.type);
this.searchChatContext.setChatToSearch({ query, domain });
this.searchForMore.emit(query);
}
private extractSearchQuery(content: string): string {
// Extract CVE IDs if present
const cveRegex = /CVE-\d{4}-\d{4,}/gi;
@@ -548,6 +593,48 @@ export class ChatMessageComponent {
return plainText.length > 100 ? plainText.substring(0, 100) : plainText;
}
private extractSearchQueryFromCitation(type: string, path: string): string {
const normalizedPath = (path ?? '').trim();
const cveMatch = normalizedPath.match(/CVE-\d{4}-\d{4,}/i);
if (cveMatch && cveMatch[0]) {
return cveMatch[0].toUpperCase();
}
if (type === 'policy') {
return normalizedPath.split(':')[0] || normalizedPath;
}
if (type === 'docs') {
return normalizedPath.replace(/[/_-]+/g, ' ').trim();
}
return normalizedPath.length > 120
? normalizedPath.substring(0, 120)
: normalizedPath;
}
private mapCitationTypeToDomain(type: string): 'knowledge' | 'findings' | 'vex' | 'policy' | 'platform' | undefined {
switch (type) {
case 'docs':
return 'knowledge';
case 'vex':
return 'vex';
case 'policy':
return 'policy';
case 'finding':
case 'scan':
case 'sbom':
case 'reach':
return 'findings';
case 'runtime':
case 'attest':
case 'auth':
return 'platform';
default:
return undefined;
}
}
async copyMessage(): Promise<void> {
try {
await navigator.clipboard.writeText(this.turn.content);

View File

@@ -532,6 +532,16 @@ export class ChatComponent implements OnInit, OnDestroy {
@Input() tenantId = 'default';
@Input() context?: ConversationContext;
@Input() conversationId?: string;
@Input()
set initialUserMessage(value: string | null | undefined) {
const normalized = value?.trim() ?? '';
if (!normalized) {
return;
}
this.pendingInitialMessage = normalized;
this.trySendPendingInitialMessage();
}
@Output() close = new EventEmitter<void>();
@Output() linkNavigate = new EventEmitter<ParsedObjectLink>();
@@ -544,6 +554,7 @@ export class ChatComponent implements OnInit, OnDestroy {
private readonly chatService = inject(ChatService);
private readonly router = inject(Router);
private readonly destroy$ = new Subject<void>();
private pendingInitialMessage: string | null = null;
inputValue = '';
readonly progressStage = signal<string | null>(null);
@@ -632,9 +643,13 @@ export class ChatComponent implements OnInit, OnDestroy {
// Start or load conversation
if (this.conversationId) {
this.chatService.getConversation(this.conversationId).subscribe();
this.chatService.getConversation(this.conversationId).subscribe(() => {
this.trySendPendingInitialMessage();
});
} else {
this.chatService.createConversation(this.tenantId, this.context).subscribe();
this.chatService.createConversation(this.tenantId, this.context).subscribe(() => {
this.trySendPendingInitialMessage();
});
}
}
@@ -669,7 +684,9 @@ export class ChatComponent implements OnInit, OnDestroy {
startNewConversation(): void {
this.chatService.clearConversation();
this.chatService.createConversation(this.tenantId, this.context).subscribe();
this.chatService.createConversation(this.tenantId, this.context).subscribe(() => {
this.trySendPendingInitialMessage();
});
}
retryLastAction(): void {
@@ -702,4 +719,20 @@ export class ChatComponent implements OnInit, OnDestroy {
el.scrollTop = el.scrollHeight;
}
}
private trySendPendingInitialMessage(): void {
const message = this.pendingInitialMessage?.trim();
if (!message || this.isStreaming()) {
return;
}
const conversation = this.conversation();
if (!conversation) {
return;
}
this.pendingInitialMessage = null;
this.chatService.sendMessage(conversation.conversationId, message);
this.inputValue = '';
}
}

View File

@@ -106,7 +106,9 @@ export interface CreateConversationRequest {
* Request to add a turn (message) to a conversation.
*/
export interface AddTurnRequest {
message: string;
content: string;
/** @deprecated Use `content` */
message?: string;
}
/**
@@ -266,25 +268,25 @@ export function parseObjectLinks(content: string): ParsedObjectLink[] {
export function getObjectLinkUrl(link: ParsedObjectLink): string {
switch (link.type) {
case 'sbom':
return `/sbom/${encodeURIComponent(link.path)}`;
return `/security/supply-chain-data/viewer?focus=${encodeURIComponent(link.path)}`;
case 'reach':
return `/reachability/${encodeURIComponent(link.path)}`;
return `/security/reachability?q=${encodeURIComponent(link.path)}`;
case 'runtime':
return `/timeline/${encodeURIComponent(link.path)}`;
return `/ops/operations/signals?q=${encodeURIComponent(link.path)}`;
case 'vex':
return `/vex-hub/${encodeURIComponent(link.path)}`;
return `/security/advisories-vex?q=${encodeURIComponent(link.path)}`;
case 'attest':
return `/proof-chain/${encodeURIComponent(link.path)}`;
return `/evidence/proofs?q=${encodeURIComponent(link.path)}`;
case 'auth':
return `/admin/auth/${encodeURIComponent(link.path)}`;
return `/settings/identity-providers?q=${encodeURIComponent(link.path)}`;
case 'docs':
return `/docs/${encodeURIComponent(link.path)}`;
case 'finding':
return `/triage/findings/${encodeURIComponent(link.path)}`;
return `/security/findings/${encodeURIComponent(link.path)}`;
case 'scan':
return `/scans/${encodeURIComponent(link.path)}`;
return `/security/scans/${encodeURIComponent(link.path)}`;
case 'policy':
return `/policy/${encodeURIComponent(link.path)}`;
return `/ops/policy?q=${encodeURIComponent(link.path)}`;
default:
return '#';
}

View File

@@ -187,7 +187,7 @@ export class ChatService {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
},
body: JSON.stringify({ message }),
body: JSON.stringify({ content: message }),
signal: abortController.signal,
})
.then((response) => {

View File

@@ -0,0 +1,197 @@
import { ChangeDetectionStrategy, Component, ElementRef, HostListener, ViewChild, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { PlatformContextStore } from '../../core/context/platform-context.store';
import { SearchChatContextService, type SearchToChatContext } from '../../core/services/search-chat-context.service';
import { ChatComponent } from '../advisory-ai/chat';
import { SecurityFindingsPageComponent } from './security-findings-page.component';
@Component({
selector: 'app-security-triage-chat-host',
standalone: true,
imports: [SecurityFindingsPageComponent, ChatComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="triage-host">
<app-security-findings-page />
<button
#assistantFab
type="button"
class="assistant-fab"
(click)="openAssistantPanel()"
aria-label="Open AdvisoryAI assistant"
>
Ask AdvisoryAI
</button>
@if (assistantOpen()) {
<section
#assistantDrawer
class="assistant-drawer"
role="dialog"
aria-label="AdvisoryAI assistant"
tabindex="-1"
>
<stellaops-chat
[tenantId]="context.tenantId() ?? 'default'"
[initialUserMessage]="assistantInitialMessage()"
(close)="closeAssistantPanel()"
(searchForMore)="onChatSearchForMore($event)"
/>
</section>
}
</section>
`,
styles: [`
.triage-host {
position: relative;
min-height: 100%;
}
.assistant-fab {
position: fixed;
right: 1.25rem;
bottom: 1.25rem;
z-index: 135;
border: 1px solid #7dd3fc;
background: #f0f9ff;
color: #0369a1;
border-radius: 999px;
padding: 0.5rem 0.9rem;
font-size: 0.75rem;
box-shadow: 0 4px 14px rgba(2, 132, 199, 0.2);
cursor: pointer;
}
.assistant-fab:hover {
background: #e0f2fe;
border-color: #0284c7;
color: #0c4a6e;
}
.assistant-drawer {
position: fixed;
top: 5rem;
right: 1rem;
width: min(520px, calc(100vw - 2rem));
height: min(78vh, 760px);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
box-shadow: var(--shadow-dropdown);
z-index: 140;
overflow: hidden;
}
@media (max-width: 900px) {
.assistant-fab {
right: 0.75rem;
bottom: 0.75rem;
}
.assistant-drawer {
top: 0;
right: 0;
width: 100vw;
height: 100vh;
border-radius: 0;
}
}
`],
})
export class SecurityTriageChatHostComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly searchChatContext = inject(SearchChatContextService);
readonly context = inject(PlatformContextStore);
@ViewChild('assistantDrawer') private assistantDrawerRef?: ElementRef<HTMLElement>;
@ViewChild('assistantFab') private assistantFabRef?: ElementRef<HTMLButtonElement>;
readonly assistantOpen = signal(false);
readonly assistantInitialMessage = signal<string | null>(null);
constructor() {
this.route.queryParamMap
.pipe(takeUntilDestroyed())
.subscribe((params) => {
if ((params.get('openChat') ?? '').toLowerCase() === 'true') {
this.openAssistantFromSearchIntent(params.get('q') ?? '');
}
});
}
openAssistantPanel(): void {
this.assistantInitialMessage.set(
'Help me prioritize the current security triage findings and explain the top risk first.',
);
this.assistantOpen.set(true);
this.queueDrawerFocus();
}
closeAssistantPanel(): void {
this.assistantOpen.set(false);
setTimeout(() => this.assistantFabRef?.nativeElement?.focus(), 0);
}
onChatSearchForMore(query: string): void {
const normalizedQuery = query.trim();
if (!normalizedQuery) {
return;
}
this.assistantOpen.set(false);
}
private openAssistantFromSearchIntent(querySeed: string): void {
const searchContext = this.searchChatContext.consumeSearchToChat();
const prompt = this.buildAssistantPrompt(searchContext, querySeed);
this.assistantInitialMessage.set(prompt);
this.assistantOpen.set(true);
this.queueDrawerFocus();
void this.router.navigate([], {
relativeTo: this.route,
replaceUrl: true,
queryParamsHandling: 'merge',
queryParams: { openChat: null },
});
}
private buildAssistantPrompt(searchContext: SearchToChatContext | null, querySeed: string): string {
if (searchContext?.suggestedPrompt?.trim()) {
return searchContext.suggestedPrompt.trim();
}
const query = searchContext?.query?.trim() || querySeed.trim();
const cards = (searchContext?.entityCards ?? []).slice(0, 5);
if (cards.length > 0) {
const cardSummary = cards
.map((card, index) =>
`${index + 1}. ${card.title} (${card.domain}${card.severity ? `, ${card.severity}` : ''})`)
.join('\n');
return `I searched for "${query || 'security issue'}" and got:\n${cardSummary}\nHelp me understand the risk and choose the best next action.`;
}
if (query) {
return `Help me understand "${query}" and guide me to the most relevant next step.`;
}
return 'Help me prioritize the current security triage findings and explain the top risk first.';
}
@HostListener('window:keydown.escape')
onEscapePressed(): void {
if (!this.assistantOpen()) {
return;
}
this.closeAssistantPanel();
}
private queueDrawerFocus(): void {
setTimeout(() => this.assistantDrawerRef?.nativeElement?.focus(), 0);
}
}

View File

@@ -10,12 +10,13 @@ import {
signal,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { NavigationEnd, Router } from '@angular/router';
import { Subject, of } from 'rxjs';
import {
catchError,
debounceTime,
distinctUntilChanged,
filter,
switchMap,
takeUntil,
} from 'rxjs/operators';
@@ -33,6 +34,7 @@ import { EntityCardComponent } from '../../shared/components/entity-card/entity-
import { SynthesisPanelComponent } from '../../shared/components/synthesis-panel/synthesis-panel.component';
import { AmbientContextService } from '../../core/services/ambient-context.service';
import { SearchChatContextService } from '../../core/services/search-chat-context.service';
import { normalizeSearchActionRoute } from './search-route-matrix';
type SearchDomainFilter = 'all' | UnifiedSearchDomain;
@@ -52,7 +54,7 @@ type SearchDomainFilter = 'all' | UnifiedSearchDomain;
#searchInput
type="text"
class="search__input"
placeholder="Search everything..."
[placeholder]="inputPlaceholder()"
[ngModel]="query()"
(ngModelChange)="onQueryChange($event)"
(focus)="onFocus()"
@@ -67,6 +69,13 @@ type SearchDomainFilter = 'all' | UnifiedSearchDomain;
@if (showResults()) {
<div class="search__results" id="search-results">
@if (showDegradedModeBanner()) {
<div class="search__degraded-banner" role="status" aria-live="polite">
<span class="search__degraded-title">Fallback mode:</span>
{{ degradedModeMessage() }}
</div>
}
@if (isLoading()) {
<div class="search__loading">Searching...</div>
} @else if (query().trim().length >= 1 && cards().length === 0) {
@@ -203,7 +212,10 @@ type SearchDomainFilter = 'all' | UnifiedSearchDomain;
<div class="search__domain-grid">
@for (domain of domainGuide; track domain.title) {
<div class="search__domain-card">
<div class="search__domain-title">{{ domain.title }}</div>
<div class="search__domain-title">
<span class="search__domain-icon" aria-hidden="true">{{ domain.icon }}</span>
{{ domain.title }}
</div>
<div class="search__domain-desc">{{ domain.description }}</div>
<button
type="button"
@@ -346,6 +358,20 @@ type SearchDomainFilter = 'all' | UnifiedSearchDomain;
font-size: 0.875rem;
}
.search__degraded-banner {
padding: 0.5rem 0.75rem;
background: #fff7ed;
border-bottom: 1px solid #fed7aa;
color: #7c2d12;
font-size: 0.75rem;
line-height: 1.3;
}
.search__degraded-title {
font-weight: var(--font-weight-semibold);
margin-right: 0.25rem;
}
.search__cards {
padding: 0.25rem 0;
}
@@ -475,9 +501,24 @@ type SearchDomainFilter = 'all' | UnifiedSearchDomain;
font-size: 0.75rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
display: flex;
align-items: center;
gap: 0.35rem;
margin-bottom: 0.125rem;
}
.search__domain-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.05rem;
height: 1.05rem;
border-radius: 999px;
background: var(--color-surface-tertiary);
font-size: 0.675rem;
line-height: 1;
}
.search__domain-desc {
font-size: 0.6875rem;
color: var(--color-text-muted);
@@ -608,7 +649,9 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
private readonly destroy$ = new Subject<void>();
private readonly searchTerms$ = new Subject<string>();
private readonly recentSearchStorageKey = 'stella-recent-searches';
private wasDegradedMode = false;
private escapeCount = 0;
private placeholderRotationHandle: ReturnType<typeof setInterval> | null = null;
@ViewChild('searchInput') searchInputRef!: ElementRef<HTMLInputElement>;
@@ -621,24 +664,46 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
readonly isFocused = signal(false);
readonly isLoading = signal(false);
readonly selectedIndex = signal(0);
readonly placeholderIndex = signal(0);
readonly searchResponse = signal<UnifiedSearchResponse | null>(null);
readonly recentSearches = signal<string[]>([]);
readonly activeDomainFilter = signal<SearchDomainFilter>('all');
readonly expandedCardKey = signal<string | null>(null);
readonly pendingDomainFilter = signal<SearchDomainFilter | null>(null);
readonly showResults = computed(() => this.isFocused());
readonly diagnosticsMode = computed(() => this.searchResponse()?.diagnostics?.mode ?? 'unknown');
readonly isDegradedMode = computed(() => {
const mode = this.diagnosticsMode();
return mode === 'legacy-fallback' || mode === 'fallback-empty';
});
readonly showDegradedModeBanner = computed(() =>
!this.isLoading() &&
this.query().trim().length >= 1 &&
this.isDegradedMode(),
);
readonly degradedModeMessage = computed(() => {
if (this.diagnosticsMode() === 'fallback-empty') {
return 'Unified search is unavailable and legacy fallback returned no results. Try a broader query or retry.';
}
return 'Showing legacy fallback results. Coverage and ranking may differ until unified search recovers.';
});
readonly domainGuide: ReadonlyArray<{
icon: string;
title: string;
description: string;
example: string;
}> = [
{ title: 'Security Findings', description: 'CVEs, vulnerabilities, and exposure data', example: 'CVE-2024-21626' },
{ title: 'VEX Statements', description: 'Vulnerability exploitability assessments', example: 'not_affected' },
{ title: 'Policy Rules', description: 'Release gate rules and enforcement', example: 'DENY-CRITICAL-PROD' },
{ title: 'Documentation', description: 'Guides, architecture, and runbooks', example: 'how to deploy' },
{ title: 'API Reference', description: 'OpenAPI endpoints and contracts', example: 'POST /api/v1/scanner/scans' },
{ title: 'Health Checks', description: 'System diagnostics and remediation', example: 'database connectivity' },
{ icon: 'S', title: 'Security Findings', description: 'CVEs, vulnerabilities, and exposure data', example: 'CVE-2024-21626' },
{ icon: 'V', title: 'VEX Statements', description: 'Vulnerability exploitability assessments', example: 'not_affected' },
{ icon: 'P', title: 'Policy Rules', description: 'Release gate rules and enforcement', example: 'DENY-CRITICAL-PROD' },
{ icon: 'D', title: 'Documentation', description: 'Guides, architecture, and runbooks', example: 'how to deploy' },
{ icon: 'A', title: 'API Reference', description: 'OpenAPI endpoints and contracts', example: 'POST /api/v1/scanner/scans' },
{ icon: 'H', title: 'Health Checks', description: 'System diagnostics and remediation', example: 'database connectivity' },
{ icon: 'R', title: 'Release Workflows', description: 'Promotion status, rollout history, and gate decisions', example: 'failed promotion' },
{ icon: 'C', title: 'Platform Catalog', description: 'Components, integrations, and environment inventory', example: 'registry integration' },
];
readonly contextualSuggestions = computed<string[]>(() => {
@@ -659,6 +724,16 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
return ['How do I deploy?', 'What is a VEX statement?', 'Show critical findings'];
});
readonly inputPlaceholder = computed(() => {
const suggestions = this.contextualSuggestions();
if (suggestions.length === 0) {
return 'Search everything...';
}
const index = this.placeholderIndex() % suggestions.length;
return `Try: ${suggestions[index]}`;
});
readonly cards = computed(() => this.searchResponse()?.cards ?? []);
readonly synthesis = computed(() => this.searchResponse()?.synthesis ?? null);
readonly refinements = computed(() => this.searchResponse()?.refinements ?? []);
@@ -687,6 +762,23 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
});
ngOnInit(): void {
this.placeholderRotationHandle = setInterval(() => {
this.placeholderIndex.update((current) => current + 1);
}, 4500);
this.searchChatContext.chatToSearchRequested$
.pipe(takeUntil(this.destroy$))
.subscribe(() => this.consumeChatToSearchContext());
this.router.events
.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
takeUntil(this.destroy$),
)
.subscribe(() => this.consumeChatToSearchContext());
this.consumeChatToSearchContext();
this.searchTerms$
.pipe(
debounceTime(200),
@@ -730,23 +822,38 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
this.searchResponse.set(response);
this.selectedIndex.set(0);
this.activeDomainFilter.set('all');
const pendingDomainFilter = this.pendingDomainFilter();
if (
pendingDomainFilter &&
response.cards.some((card) => card.domain === pendingDomainFilter)
) {
this.activeDomainFilter.set(pendingDomainFilter);
} else {
this.activeDomainFilter.set('all');
}
this.pendingDomainFilter.set(null);
this.expandedCardKey.set(null);
this.isLoading.set(false);
// Sprint 106 / G6: Emit search analytics events
this.emitSearchAnalytics(response);
this.trackDegradedMode(response);
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
if (this.placeholderRotationHandle) {
clearInterval(this.placeholderRotationHandle);
this.placeholderRotationHandle = null;
}
}
onFocus(): void {
this.isFocused.set(true);
this.escapeCount = 0;
this.consumeChatToSearchContext();
this.loadRecentSearches();
this.loadServerHistory();
}
@@ -840,20 +947,26 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
}
onAskAiFromCard(card: EntityCard): void {
const askPrompt = this.buildAskAiPromptForCard(card);
this.searchChatContext.setSearchToChat({
query: card.title,
query: this.query().trim() || card.title,
entityCards: [card],
synthesis: this.synthesis(),
suggestedPrompt: askPrompt,
});
this.closeResults();
void this.router.navigate(['/security/triage'], { queryParams: { openChat: 'true', q: card.title } });
void this.router.navigate(['/security/triage'], {
queryParams: { openChat: 'true', q: this.query().trim() || card.title },
});
}
onAskAiFromSynthesis(): void {
const askPrompt = this.buildAskAiPromptForSynthesis();
this.searchChatContext.setSearchToChat({
query: this.query(),
entityCards: this.filteredCards(),
synthesis: this.synthesis(),
suggestedPrompt: askPrompt,
});
this.closeResults();
void this.router.navigate(['/security/triage'], { queryParams: { openChat: 'true', q: this.query() } });
@@ -873,22 +986,26 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
): void {
const cards = this.filteredCards();
const position = cards.findIndex((c) => c.entityKey === event.entityKey);
const comment = this.promptFeedbackComment(event.signal);
this.searchClient.submitFeedback({
query: this.query(),
entityKey: event.entityKey,
domain: card.domain,
position: position >= 0 ? position : 0,
signal: event.signal,
comment,
});
}
onSynthesisFeedback(event: { signal: 'helpful' | 'not_helpful' }): void {
const comment = this.promptFeedbackComment(event.signal);
this.searchClient.submitFeedback({
query: this.query(),
entityKey: '__synthesis__',
domain: 'synthesis',
position: -1,
signal: event.signal,
comment,
});
}
@@ -935,10 +1052,12 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
}
private executeAction(action: EntityCardAction): void {
const normalizedRoute = action.route ? this.normalizeActionRoute(action.route) : undefined;
switch (action.actionType) {
case 'navigate':
if (action.route) {
void this.router.navigateByUrl(action.route);
if (normalizedRoute) {
void this.router.navigateByUrl(normalizedRoute);
}
break;
case 'copy':
@@ -951,13 +1070,13 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
if (action.command) {
void this.copyToClipboard(action.command);
}
if (action.route) {
void this.router.navigateByUrl(action.route);
if (normalizedRoute) {
void this.router.navigateByUrl(normalizedRoute);
}
break;
case 'details':
if (action.route) {
void this.router.navigateByUrl(action.route);
if (normalizedRoute) {
void this.router.navigateByUrl(normalizedRoute);
}
break;
}
@@ -1001,6 +1120,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
this.selectedIndex.set(0);
this.searchResponse.set(null);
this.activeDomainFilter.set('all');
this.pendingDomainFilter.set(null);
this.expandedCardKey.set(null);
this.isFocused.set(false);
this.escapeCount = 0;
@@ -1035,15 +1155,6 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
} catch {
// Ignore localStorage failures.
}
// Sprint 106 / G6: Also record to server-side history (fire-and-forget)
const resultCount = this.cards().length;
this.searchClient.recordAnalytics([{
eventType: 'query',
query: normalized,
resultCount,
durationMs: this.searchResponse()?.diagnostics?.durationMs,
}]);
}
/** Sprint 106 / G6: Load search history from server, merge with localStorage */
@@ -1062,14 +1173,33 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
/** Sprint 106 / G6: Emit analytics for search responses */
private emitSearchAnalytics(response: UnifiedSearchResponse): void {
if (response.cards.length === 0 && response.query.trim().length > 0) {
this.searchClient.recordAnalytics([{
eventType: 'zero_result',
query: response.query,
const normalized = response.query.trim();
if (!normalized) {
return;
}
const events: Array<{
eventType: 'query' | 'zero_result';
query: string;
resultCount: number;
durationMs?: number;
}> = [{
eventType: 'query' as const,
query: normalized,
resultCount: response.cards.length,
durationMs: response.diagnostics?.durationMs,
}];
if (response.cards.length === 0) {
events.push({
eventType: 'zero_result' as const,
query: normalized,
resultCount: 0,
durationMs: response.diagnostics?.durationMs,
}]);
});
}
this.searchClient.recordAnalytics(events);
}
/** Sprint 106 / G6: Emit analytics for card clicks */
@@ -1084,6 +1214,78 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
}]);
}
private consumeChatToSearchContext(): void {
const context = this.searchChatContext.consumeChatToSearch();
if (!context || !context.query.trim()) {
return;
}
const query = context.query.trim();
this.query.set(query);
this.selectedIndex.set(0);
this.searchResponse.set(null);
this.expandedCardKey.set(null);
this.isFocused.set(true);
this.pendingDomainFilter.set(context.domain ?? null);
this.searchTerms$.next(query);
this.saveRecentSearch(query);
setTimeout(() => this.searchInputRef?.nativeElement?.focus(), 0);
}
private buildAskAiPromptForCard(card: EntityCard): string {
switch (card.domain) {
case 'findings':
return `Tell me about ${card.title}, why it matters, and what action I should take first.`;
case 'vex':
return `Explain this VEX assessment for ${card.title} and what it means for release decisions.`;
case 'policy':
return `Explain this policy rule (${card.title}) and how it affects promotions.`;
case 'platform':
return `Explain this platform item (${card.title}) and what an operator should do next.`;
default:
return `Summarize ${card.title} and guide me through the next steps.`;
}
}
private buildAskAiPromptForSynthesis(): string {
const query = this.query().trim();
if (!query) {
return 'I need help understanding these search results and what to do next.';
}
return `I searched for "${query}". Help me understand the results and recommend a clear next action.`;
}
private normalizeActionRoute(route: string): string {
return normalizeSearchActionRoute(route);
}
private trackDegradedMode(response: UnifiedSearchResponse): void {
const mode = response.diagnostics?.mode ?? 'unknown';
const isDegraded = mode === 'legacy-fallback' || mode === 'fallback-empty';
if (isDegraded && !this.wasDegradedMode) {
this.searchClient.recordAnalytics([{
eventType: 'query',
query: '__degraded_mode_enter__',
domain: 'platform',
resultCount: response.cards.length,
durationMs: response.diagnostics?.durationMs,
}]);
} else if (!isDegraded && this.wasDegradedMode) {
this.searchClient.recordAnalytics([{
eventType: 'query',
query: '__degraded_mode_exit__',
domain: 'platform',
resultCount: response.cards.length,
durationMs: response.diagnostics?.durationMs,
}]);
}
this.wasDegradedMode = isDegraded;
}
clearSearchHistory(): void {
this.recentSearches.set([]);
try {
@@ -1123,4 +1325,26 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
textarea.remove();
}
}
private promptFeedbackComment(signal: 'helpful' | 'not_helpful'): string | undefined {
if (typeof window === 'undefined') {
return undefined;
}
const promptText = signal === 'helpful'
? 'Optional: what was most helpful?'
: 'Optional: what was missing or incorrect?';
const response = window.prompt(promptText);
if (!response) {
return undefined;
}
const normalized = response.trim();
if (!normalized) {
return undefined;
}
return normalized.slice(0, 500);
}
}

View File

@@ -0,0 +1,34 @@
export function normalizeSearchActionRoute(route: string): string {
if (!route.startsWith('/')) {
return route;
}
let parsedUrl: URL;
try {
parsedUrl = new URL(route, 'https://stellaops.local');
} catch {
return route;
}
const pathname = parsedUrl.pathname;
if (pathname.startsWith('/triage/findings/')) {
parsedUrl.pathname = `/security/findings/${pathname.substring('/triage/findings/'.length)}`;
} else if (pathname.startsWith('/vex-hub/')) {
const lookup = decodeURIComponent(pathname.substring('/vex-hub/'.length));
parsedUrl.pathname = '/security/advisories-vex';
parsedUrl.search = lookup ? `?q=${encodeURIComponent(lookup)}` : '';
} else if (pathname.startsWith('/proof-chain/')) {
const digest = decodeURIComponent(pathname.substring('/proof-chain/'.length));
parsedUrl.pathname = '/evidence/proofs';
parsedUrl.search = digest ? `?q=${encodeURIComponent(digest)}` : '';
} else if (pathname.startsWith('/policy/')) {
const lookup = decodeURIComponent(pathname.substring('/policy/'.length));
parsedUrl.pathname = '/ops/policy';
parsedUrl.search = lookup ? `?q=${encodeURIComponent(lookup)}` : '';
} else if (pathname.startsWith('/scans/')) {
parsedUrl.pathname = `/security/scans/${pathname.substring('/scans/'.length)}`;
}
return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`;
}

View File

@@ -24,8 +24,8 @@ export const SECURITY_ROUTES: Routes = [
title: 'Security Triage',
data: { breadcrumb: 'Triage' },
loadComponent: () =>
import('../features/security/security-findings-page.component').then(
(m) => m.SecurityFindingsPageComponent,
import('../features/security/security-triage-chat-host.component').then(
(m) => m.SecurityTriageChatHostComponent,
),
},
{

View File

@@ -1,140 +1,106 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { of } from 'rxjs';
import { NavigationEnd, Router } from '@angular/router';
import { Subject, of } from 'rxjs';
import { SearchClient } from '../../app/core/api/search.client';
import {
GlobalSearchComponent,
SearchResult,
} from '../../app/layout/global-search/global-search.component';
import { UnifiedSearchClient } from '../../app/core/api/unified-search.client';
import { AmbientContextService } from '../../app/core/services/ambient-context.service';
import { SearchChatContextService } from '../../app/core/services/search-chat-context.service';
import { GlobalSearchComponent } from '../../app/layout/global-search/global-search.component';
describe('GlobalSearchComponent', () => {
let fixture: ComponentFixture<GlobalSearchComponent>;
let component: GlobalSearchComponent;
let router: { navigateByUrl: jasmine.Spy };
let searchClient: { search: jasmine.Spy };
let searchClient: jasmine.SpyObj<UnifiedSearchClient>;
let routerEvents: Subject<unknown>;
let router: { url: string; events: Subject<unknown>; navigateByUrl: jasmine.Spy; navigate: jasmine.Spy };
let searchChatContext: jasmine.SpyObj<SearchChatContextService>;
beforeEach(async () => {
routerEvents = new Subject<unknown>();
router = {
url: '/security/triage',
events: routerEvents,
navigateByUrl: jasmine.createSpy('navigateByUrl').and.returnValue(Promise.resolve(true)),
navigate: jasmine.createSpy('navigate').and.returnValue(Promise.resolve(true)),
};
searchClient = {
search: jasmine.createSpy('search').and.returnValue(
of({
query: 'docker login fails',
groups: [
{
type: 'docs',
label: 'Docs',
totalCount: 1,
hasMore: false,
results: [
{
id: 'docs:docs/operations.md:docker-registry-login-fails',
type: 'docs',
title: 'Registry login troubleshooting',
subtitle: 'docs/operations/troubleshooting.md#docker-registry-login-fails',
description: 'Use custom CA bundle and verify trust store.',
route: '/docs/docs%2Foperations%2Ftroubleshooting.md#docker-registry-login-fails',
matchScore: 0.95,
open: {
kind: 'docs',
docs: {
path: 'docs/operations/troubleshooting.md',
anchor: 'docker-registry-login-fails',
spanStart: 42,
spanEnd: 68,
},
},
},
],
},
],
totalCount: 1,
durationMs: 4,
}),
),
};
searchClient = jasmine.createSpyObj('UnifiedSearchClient', [
'search',
'recordAnalytics',
'getHistory',
'clearHistory',
'submitFeedback',
]) as jasmine.SpyObj<UnifiedSearchClient>;
searchClient.search.and.returnValue(of({
query: 'a',
topK: 10,
cards: [],
synthesis: null,
diagnostics: {
ftsMatches: 0,
vectorMatches: 0,
entityCardCount: 0,
durationMs: 1,
usedVector: false,
mode: 'fts-only',
},
}));
searchClient.getHistory.and.returnValue(of([]));
searchChatContext = jasmine.createSpyObj('SearchChatContextService', [
'consumeChatToSearch',
'setSearchToChat',
]) as jasmine.SpyObj<SearchChatContextService>;
(searchChatContext as any).chatToSearchRequested$ = of();
searchChatContext.consumeChatToSearch.and.returnValue(null);
await TestBed.configureTestingModule({
imports: [GlobalSearchComponent],
providers: [
{ provide: Router, useValue: router },
{ provide: SearchClient, useValue: searchClient },
{ provide: UnifiedSearchClient, useValue: searchClient },
{
provide: AmbientContextService,
useValue: {
buildContextFilter: () => undefined,
},
},
{ provide: SearchChatContextService, useValue: searchChatContext },
],
}).compileComponents();
localStorage.clear();
fixture = TestBed.createComponent(GlobalSearchComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
afterEach(() => {
localStorage.clear();
});
async function waitForDebounce(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 240));
function waitForDebounce(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 250));
}
it('renders the global search input and shortcut hint', () => {
const text = fixture.nativeElement.textContent as string;
const input = fixture.nativeElement.querySelector('input[aria-label="Global search"]') as HTMLInputElement;
expect(input).toBeTruthy();
expect(input.placeholder).toContain('Search docs');
expect(text).toContain('K');
it('renders the global search input', () => {
const input = fixture.nativeElement.querySelector('input[aria-label="Global search"]') as HTMLInputElement | null;
expect(input).not.toBeNull();
expect(input?.placeholder).toContain('Try:');
});
it('queries SearchClient and renders grouped results', async () => {
component.onFocus();
component.onQueryChange('docker login fails');
await waitForDebounce();
fixture.detectChanges();
expect(searchClient.search).toHaveBeenCalledWith('docker login fails');
expect(component.groupedResults().length).toBe(1);
expect(component.groupedResults()[0].type).toBe('docs');
expect(component.flatResults().length).toBe(1);
});
it('does not query API for terms shorter than two characters', async () => {
it('queries unified search for one-character query terms', async () => {
component.onFocus();
component.onQueryChange('a');
await waitForDebounce();
expect(searchClient.search).toHaveBeenCalledWith('a', undefined);
});
it('consumes chat-to-search context when navigation changes', () => {
searchChatContext.consumeChatToSearch.and.returnValue({
query: 'CVE-2024-21626',
domain: 'findings',
} as any);
routerEvents.next(new NavigationEnd(1, '/security/triage', '/security/triage'));
fixture.detectChanges();
expect(searchClient.search).not.toHaveBeenCalled();
expect(component.searchResponse()).toBeNull();
});
it('navigates to selected result and persists recent search', () => {
component.query.set('docker login fails');
const result: SearchResult = {
id: 'docs:troubleshooting',
type: 'docs',
title: 'Registry login troubleshooting',
subtitle: 'docs/operations/troubleshooting.md#docker-registry-login-fails',
description: 'Use custom CA bundle and verify trust store.',
route: '/docs/docs%2Foperations%2Ftroubleshooting.md#docker-registry-login-fails',
matchScore: 0.95,
open: {
kind: 'docs',
docs: {
path: 'docs/operations/troubleshooting.md',
anchor: 'docker-registry-login-fails',
spanStart: 42,
spanEnd: 68,
},
},
};
component.onSelect(result);
expect(router.navigateByUrl).toHaveBeenCalledWith('/docs/docs%2Foperations%2Ftroubleshooting.md#docker-registry-login-fails');
const stored = JSON.parse(localStorage.getItem('stella-recent-searches') ?? '[]') as string[];
expect(stored[0]).toBe('docker login fails');
expect(component.query()).toBe('CVE-2024-21626');
});
});

View File

@@ -0,0 +1,27 @@
import { normalizeSearchActionRoute } from '../../app/layout/global-search/search-route-matrix';
describe('normalizeSearchActionRoute', () => {
it('maps findings routes into security finding detail', () => {
expect(normalizeSearchActionRoute('/triage/findings/abc-123')).toBe('/security/findings/abc-123');
});
it('maps vex hub routes into advisories page query', () => {
expect(normalizeSearchActionRoute('/vex-hub/CVE-2024-21626')).toBe('/security/advisories-vex?q=CVE-2024-21626');
});
it('maps proof-chain routes into evidence proofs query', () => {
expect(normalizeSearchActionRoute('/proof-chain/sha256:deadbeef')).toBe('/evidence/proofs?q=sha256%3Adeadbeef');
});
it('maps policy routes into policy search route', () => {
expect(normalizeSearchActionRoute('/policy/DENY-CRITICAL-PROD')).toBe('/ops/policy?q=DENY-CRITICAL-PROD');
});
it('maps scan routes into security scans route', () => {
expect(normalizeSearchActionRoute('/scans/scan-42')).toBe('/security/scans/scan-42');
});
it('preserves already valid app routes', () => {
expect(normalizeSearchActionRoute('/docs/ops/runbook#overview')).toBe('/docs/ops/runbook#overview');
});
});