documentation cleanse, sprints work and planning. remaining non EF DAL migration to EF
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 '#';
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user