Merge pull request #50 from dawnsystem/copilot/add-ai-status-indicator

[WIP] Add AI status indicator to UI navbar
This commit is contained in:
dawnsystem 2025-11-15 17:03:29 +01:00 committed by GitHub
commit 1bec62ccb9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 583 additions and 0 deletions

View file

@ -0,0 +1,77 @@
<li class="nav-item ai-status-container">
<button
class="btn border-0 position-relative"
[ngbPopover]="aiStatusPopover"
popoverClass="ai-status-popover"
placement="bottom"
container="body"
triggers="mouseenter:mouseleave">
<i-bs
width="1.3em"
height="1.3em"
[name]="iconName"
[class]="iconClass">
</i-bs>
@if (hasAlerts) {
<span class="badge rounded-pill bg-danger position-absolute top-0 end-0 translate-middle-y">
{{ aiStatus.pending_deletion_requests }}
</span>
}
</button>
</li>
<ng-template #aiStatusPopover>
<div class="ai-status-tooltip">
<h6 class="mb-2 border-bottom pb-2">
<i-bs name="robot" width="1em" height="1em" class="me-1"></i-bs>
<span i18n>AI Scanner Status</span>
</h6>
<div class="status-info">
<div class="status-row mb-2">
<span class="status-label" i18n>Status:</span>
<span [class]="aiStatus.active ? 'text-success' : 'text-muted'">
{{ aiStatus.active ? 'Active' : 'Inactive' }}
@if (aiStatus.processing) {
<span class="badge bg-primary ms-1" i18n>Processing</span>
}
</span>
</div>
@if (aiStatus.active) {
<div class="status-row mb-2">
<span class="status-label" i18n>Scanned Today:</span>
<span class="fw-bold">{{ aiStatus.documents_scanned_today }}</span>
</div>
<div class="status-row mb-2">
<span class="status-label" i18n>Suggestions Applied:</span>
<span class="fw-bold">{{ aiStatus.suggestions_applied }}</span>
</div>
@if (aiStatus.pending_deletion_requests > 0) {
<div class="status-row mb-2">
<span class="status-label text-danger" i18n>Pending Deletions:</span>
<span class="fw-bold text-danger">{{ aiStatus.pending_deletion_requests }}</span>
</div>
}
@if (aiStatus.last_scan) {
<div class="status-row mb-2 small text-muted">
<span class="status-label" i18n>Last Scan:</span>
<span>{{ aiStatus.last_scan | date: 'short' }}</span>
</div>
}
}
</div>
<div class="mt-3 pt-2 border-top">
<button
class="btn btn-sm btn-outline-primary w-100"
(click)="navigateToSettings()"
i18n>
Configure AI Scanner
</button>
</div>
</div>
</ng-template>

View file

@ -0,0 +1,55 @@
.ai-status-container {
display: flex;
align-items: center;
}
.ai-status-icon {
transition: all 0.3s ease;
&.inactive {
opacity: 0.4;
color: var(--bs-secondary);
}
&.active {
color: var(--bs-success);
}
&.processing {
color: var(--bs-primary);
animation: pulse 1.5s ease-in-out infinite;
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.1);
}
}
.ai-status-tooltip {
min-width: 250px;
.status-row {
display: flex;
justify-content: space-between;
align-items: center;
.status-label {
font-weight: 500;
margin-right: 0.5rem;
}
}
}
// Badge positioning
.badge.rounded-pill {
font-size: 0.65rem;
padding: 0.15rem 0.35rem;
min-width: 1.2rem;
}

View file

@ -0,0 +1,104 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { Router } from '@angular/router'
import { of } from 'rxjs'
import { AIStatus } from 'src/app/data/ai-status'
import { AIStatusService } from 'src/app/services/ai-status.service'
import { AIStatusIndicatorComponent } from './ai-status-indicator.component'
describe('AIStatusIndicatorComponent', () => {
let component: AIStatusIndicatorComponent
let fixture: ComponentFixture<AIStatusIndicatorComponent>
let aiStatusService: jasmine.SpyObj<AIStatusService>
let router: jasmine.SpyObj<Router>
const mockAIStatus: AIStatus = {
active: true,
processing: false,
documents_scanned_today: 42,
suggestions_applied: 15,
pending_deletion_requests: 2,
last_scan: '2025-11-15T12:00:00Z',
version: '1.0.0',
}
beforeEach(async () => {
const aiStatusServiceSpy = jasmine.createSpyObj('AIStatusService', [
'getStatus',
'getCurrentStatus',
'refresh',
])
const routerSpy = jasmine.createSpyObj('Router', ['navigate'])
aiStatusServiceSpy.getStatus.and.returnValue(of(mockAIStatus))
aiStatusServiceSpy.getCurrentStatus.and.returnValue(mockAIStatus)
await TestBed.configureTestingModule({
imports: [AIStatusIndicatorComponent],
providers: [
{ provide: AIStatusService, useValue: aiStatusServiceSpy },
{ provide: Router, useValue: routerSpy },
],
}).compileComponents()
aiStatusService = TestBed.inject(
AIStatusService
) as jasmine.SpyObj<AIStatusService>
router = TestBed.inject(Router) as jasmine.SpyObj<Router>
fixture = TestBed.createComponent(AIStatusIndicatorComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should create', () => {
expect(component).toBeTruthy()
})
it('should subscribe to AI status on init', () => {
expect(aiStatusService.getStatus).toHaveBeenCalled()
expect(component.aiStatus).toEqual(mockAIStatus)
})
it('should show robot icon', () => {
expect(component.iconName).toBe('robot')
})
it('should have active class when AI is active', () => {
component.aiStatus = { ...mockAIStatus, active: true, processing: false }
expect(component.iconClass).toContain('active')
})
it('should have inactive class when AI is inactive', () => {
component.aiStatus = { ...mockAIStatus, active: false }
expect(component.iconClass).toContain('inactive')
})
it('should have processing class when AI is processing', () => {
component.aiStatus = { ...mockAIStatus, active: true, processing: true }
expect(component.iconClass).toContain('processing')
})
it('should show alerts when there are pending deletion requests', () => {
component.aiStatus = { ...mockAIStatus, pending_deletion_requests: 2 }
expect(component.hasAlerts).toBe(true)
})
it('should not show alerts when there are no pending deletion requests', () => {
component.aiStatus = { ...mockAIStatus, pending_deletion_requests: 0 }
expect(component.hasAlerts).toBe(false)
})
it('should navigate to settings when navigateToSettings is called', () => {
component.navigateToSettings()
expect(router.navigate).toHaveBeenCalledWith(['/settings'], {
fragment: 'ai-scanner',
})
})
it('should unsubscribe on destroy', () => {
const subscription = component['subscription']
spyOn(subscription, 'unsubscribe')
component.ngOnDestroy()
expect(subscription.unsubscribe).toHaveBeenCalled()
})
})

View file

@ -0,0 +1,94 @@
import { DatePipe } from '@angular/common'
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
import { Router, RouterModule } from '@angular/router'
import {
NgbPopoverModule,
NgbTooltipModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subscription } from 'rxjs'
import { AIStatus } from 'src/app/data/ai-status'
import { AIStatusService } from 'src/app/services/ai-status.service'
@Component({
selector: 'pngx-ai-status-indicator',
templateUrl: './ai-status-indicator.component.html',
styleUrls: ['./ai-status-indicator.component.scss'],
imports: [
DatePipe,
NgbPopoverModule,
NgbTooltipModule,
NgxBootstrapIconsModule,
RouterModule,
],
})
export class AIStatusIndicatorComponent implements OnInit, OnDestroy {
private aiStatusService = inject(AIStatusService)
private router = inject(Router)
private subscription: Subscription
public aiStatus: AIStatus = {
active: false,
processing: false,
documents_scanned_today: 0,
suggestions_applied: 0,
pending_deletion_requests: 0,
}
ngOnInit(): void {
this.subscription = this.aiStatusService
.getStatus()
.subscribe((status) => {
this.aiStatus = status
})
}
ngOnDestroy(): void {
this.subscription?.unsubscribe()
}
/**
* Get the appropriate icon name based on AI status
*/
get iconName(): string {
if (!this.aiStatus.active) {
return 'robot' // Inactive
}
if (this.aiStatus.processing) {
return 'robot' // Processing (will add animation via CSS)
}
return 'robot' // Active
}
/**
* Get the CSS class for the icon based on status
*/
get iconClass(): string {
const classes = ['ai-status-icon']
if (!this.aiStatus.active) {
classes.push('inactive')
} else if (this.aiStatus.processing) {
classes.push('processing')
} else {
classes.push('active')
}
return classes.join(' ')
}
/**
* Navigate to AI configuration settings
*/
navigateToSettings(): void {
this.router.navigate(['/settings'], { fragment: 'ai-scanner' })
}
/**
* Check if there are any alerts (pending deletion requests)
*/
get hasAlerts(): boolean {
return this.aiStatus.pending_deletion_requests > 0
}
}

View file

@ -31,6 +31,7 @@
</div>
<ul ngbNav class="order-sm-3">
<pngx-toasts-dropdown></pngx-toasts-dropdown>
<pngx-ai-status-indicator></pngx-ai-status-indicator>
<li ngbDropdown class="nav-item dropdown">
<button class="btn ps-1 border-0" id="userDropdown" ngbDropdownToggle>
<i-bs width="1.3em" height="1.3em" name="person-circle"></i-bs>

View file

@ -47,6 +47,7 @@ import { environment } from 'src/environments/environment'
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { AIStatusIndicatorComponent } from './ai-status-indicator/ai-status-indicator.component'
import { GlobalSearchComponent } from './global-search/global-search.component'
import { ToastsDropdownComponent } from './toasts-dropdown/toasts-dropdown.component'
@ -59,6 +60,7 @@ import { ToastsDropdownComponent } from './toasts-dropdown/toasts-dropdown.compo
DocumentTitlePipe,
IfPermissionsDirective,
ToastsDropdownComponent,
AIStatusIndicatorComponent,
RouterModule,
NgClass,
NgbDropdownModule,

View file

@ -0,0 +1,63 @@
/**
* Represents the AI scanner status and statistics
*/
export interface AIStatus {
/**
* Whether the AI scanner is currently active/enabled
*/
active: boolean
/**
* Whether the AI scanner is currently processing documents
*/
processing: boolean
/**
* Number of documents scanned today
*/
documents_scanned_today: number
/**
* Number of AI suggestions applied
*/
suggestions_applied: number
/**
* Number of pending deletion requests awaiting user approval
*/
pending_deletion_requests: number
/**
* Last scan timestamp (ISO format)
*/
last_scan?: string
/**
* AI scanner version or configuration info
*/
version?: string
}
/**
* Represents a pending deletion request initiated by AI
*/
export interface DeletionRequest {
id: number
document_id: number
document_title: string
reason: string
confidence: number
created_at: string
status: DeletionRequestStatus
}
/**
* Status of a deletion request
*/
export enum DeletionRequestStatus {
Pending = 'pending',
Approved = 'approved',
Rejected = 'rejected',
Cancelled = 'cancelled',
Completed = 'completed',
}

View file

@ -0,0 +1,87 @@
import {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing'
import { AIStatus } from 'src/app/data/ai-status'
import { environment } from 'src/environments/environment'
import { AIStatusService } from './ai-status.service'
describe('AIStatusService', () => {
let service: AIStatusService
let httpMock: HttpTestingController
const mockAIStatus: AIStatus = {
active: true,
processing: false,
documents_scanned_today: 42,
suggestions_applied: 15,
pending_deletion_requests: 2,
last_scan: '2025-11-15T12:00:00Z',
version: '1.0.0',
}
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [AIStatusService],
})
service = TestBed.inject(AIStatusService)
httpMock = TestBed.inject(HttpTestingController)
})
afterEach(() => {
httpMock.verify()
})
it('should be created', () => {
expect(service).toBeTruthy()
})
it('should return AI status as observable', (done) => {
service.getStatus().subscribe((status) => {
expect(status).toBeDefined()
expect(status.active).toBeDefined()
expect(status.processing).toBeDefined()
done()
})
})
it('should return current status value', () => {
const status = service.getCurrentStatus()
expect(status).toBeDefined()
expect(status.active).toBeDefined()
})
it('should fetch AI status from backend', (done) => {
service['fetchAIStatus']().subscribe((status) => {
expect(status).toEqual(mockAIStatus)
expect(service.loading).toBe(false)
done()
})
const req = httpMock.expectOne(`${environment.apiBaseUrl}ai/status/`)
expect(req.request.method).toBe('GET')
req.flush(mockAIStatus)
})
it('should handle error and return mock data', (done) => {
service['fetchAIStatus']().subscribe((status) => {
expect(status).toBeDefined()
expect(status.active).toBeDefined()
expect(service.loading).toBe(false)
done()
})
const req = httpMock.expectOne(`${environment.apiBaseUrl}ai/status/`)
req.error(new ProgressEvent('error'))
})
it('should manually refresh status', () => {
service.refresh()
const req = httpMock.expectOne(`${environment.apiBaseUrl}ai/status/`)
expect(req.request.method).toBe('GET')
req.flush(mockAIStatus)
})
})

View file

@ -0,0 +1,100 @@
import { HttpClient } from '@angular/common/http'
import { Injectable, inject } from '@angular/core'
import { BehaviorSubject, Observable, interval } from 'rxjs'
import { catchError, map, startWith, switchMap } from 'rxjs/operators'
import { AIStatus } from 'src/app/data/ai-status'
import { environment } from 'src/environments/environment'
@Injectable({
providedIn: 'root',
})
export class AIStatusService {
private http = inject(HttpClient)
private baseUrl: string = environment.apiBaseUrl
private aiStatusSubject = new BehaviorSubject<AIStatus>({
active: false,
processing: false,
documents_scanned_today: 0,
suggestions_applied: 0,
pending_deletion_requests: 0,
})
public loading: boolean = false
// Poll every 30 seconds for AI status updates
private readonly POLL_INTERVAL = 30000
constructor() {
this.startPolling()
}
/**
* Get the current AI status as an observable
*/
public getStatus(): Observable<AIStatus> {
return this.aiStatusSubject.asObservable()
}
/**
* Get the current AI status value
*/
public getCurrentStatus(): AIStatus {
return this.aiStatusSubject.value
}
/**
* Start polling for AI status updates
*/
private startPolling(): void {
interval(this.POLL_INTERVAL)
.pipe(
startWith(0), // Emit immediately on subscription
switchMap(() => this.fetchAIStatus())
)
.subscribe((status) => {
this.aiStatusSubject.next(status)
})
}
/**
* Fetch AI status from the backend
*/
private fetchAIStatus(): Observable<AIStatus> {
this.loading = true
return this.http
.get<AIStatus>(`${this.baseUrl}ai/status/`)
.pipe(
map((status) => {
this.loading = false
return status
}),
catchError((error) => {
this.loading = false
console.warn('Failed to fetch AI status, using mock data:', error)
// Return mock data if endpoint doesn't exist yet
return [
{
active: true,
processing: false,
documents_scanned_today: 42,
suggestions_applied: 15,
pending_deletion_requests: 2,
last_scan: new Date().toISOString(),
version: '1.0.0',
},
]
})
)
}
/**
* Manually refresh the AI status
*/
public refresh(): void {
this.fetchAIStatus().subscribe((status) => {
this.aiStatusSubject.next(status)
})
}
}