mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-12-09 16:25:33 +01:00
Merge pull request #50 from dawnsystem/copilot/add-ai-status-indicator
[WIP] Add AI status indicator to UI navbar
This commit is contained in:
commit
1bec62ccb9
9 changed files with 583 additions and 0 deletions
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
63
src-ui/src/app/data/ai-status.ts
Normal file
63
src-ui/src/app/data/ai-status.ts
Normal 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',
|
||||
}
|
||||
87
src-ui/src/app/services/ai-status.service.spec.ts
Normal file
87
src-ui/src/app/services/ai-status.service.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
100
src-ui/src/app/services/ai-status.service.ts
Normal file
100
src-ui/src/app/services/ai-status.service.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue