Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions frontend/src/app/models/dtos/FileScanResultDto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export type ScanVerdict = 'CLEAN' | 'INFECTED' | 'ERROR';

export interface FileScanResultDto {
path: string;
verdict: ScanVerdict;
detail?: string;
}
15 changes: 15 additions & 0 deletions frontend/src/app/models/dtos/ScanJobDto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { FileScanResultDto } from './FileScanResultDto';

export type ScanStatus = 'PENDING' | 'RUNNING' | 'COMPLETED' | 'FAILED';

export interface ScanJobDto {
jobId: string;
path: string;
status: ScanStatus;
createdAt?: string;
finishedAt?: string;
filesScanned: number;
infectedCount: number;
error?: string;
findings?: FileScanResultDto[];
}
160 changes: 160 additions & 0 deletions frontend/src/app/pages/cloud/cloud.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ <h1 class="cloud-title text-base sm:text-lg">Cloud Page</h1>
</p>
</div>
<div class="cloud-headline-actions">
<p-button
label="Scan"
icon="pi pi-shield"
[outlined]="true"
styleClass="p-button-sm cloud-accent-btn text-xxs sm:text-xs"
pTooltip="Scan this folder for viruses"
tooltipPosition="bottom"
(click)="scanCurrentFolder()"
></p-button>
<p-button
label="Trash"
icon="pi pi-trash"
Expand Down Expand Up @@ -473,4 +482,155 @@ <h1 class="cloud-title text-base sm:text-lg">Cloud Page</h1>
></p-button>
</ng-template>
</p-dialog>

<p-dialog
[(visible)]="showScanDialog"
(onHide)="onScanDialogHide()"
[modal]="true"
[closable]="true"
[draggable]="false"
[resizable]="false"
header="Virus scan"
styleClass="cloud-dialog"
[style]="{ width: '34rem', maxWidth: '95vw' }"
>
<div class="cloud-scan-body">
<p class="cloud-scan-target text-xxs sm:text-xs">
Scanning <strong>{{ scanFolderLabel }}</strong>
</p>

<!-- Failed to even start (rate limit, bad request, network) -->
<div *ngIf="scanError" class="cloud-scan-state cloud-scan-state--error">
<i class="pi pi-exclamation-triangle"></i>
<span class="text-xxs sm:text-xs">{{ scanError }}</span>
</div>

<ng-container *ngIf="!scanError && scanJob as job">
<!-- Running / pending: indeterminate progress + live counters -->
<div
*ngIf="scanning"
class="cloud-scan-state cloud-scan-state--running"
>
<i class="pi pi-spin pi-spinner"></i>
<div class="cloud-scan-running-text">
<span class="cloud-scan-status text-xxs sm:text-xs">{{
job.status === "PENDING" ? "Queued…" : "Scanning…"
}}</span>
<span class="cloud-scan-counters text-xxs">
{{ job.filesScanned }} file{{
job.filesScanned === 1 ? "" : "s"
}}
scanned · {{ job.infectedCount }} infected
</span>
</div>
</div>

<!-- Terminal: FAILED -->
<div
*ngIf="!scanning && job.status === 'FAILED'"
class="cloud-scan-state cloud-scan-state--error"
>
<i class="pi pi-times-circle"></i>
<span class="text-xxs sm:text-xs">{{
job.error || "The scan failed to complete."
}}</span>
</div>

<!-- Terminal: COMPLETED but scanner disabled/unavailable -->
<div
*ngIf="!scanning && scanScannerUnavailable"
class="cloud-scan-state cloud-scan-state--warn"
>
<i class="pi pi-info-circle"></i>
<span class="text-xxs sm:text-xs">{{ scanUnavailableMessage }}</span>
</div>

<!-- Terminal: COMPLETED, scanned, no threats -->
<div
*ngIf="!scanning && scanNoThreats"
class="cloud-scan-state cloud-scan-state--clean"
>
<i class="pi pi-verified"></i>
<div class="cloud-scan-running-text">
<span class="text-xxs sm:text-xs">No threats found.</span>
<span class="cloud-scan-counters text-xxs"
>{{ job.filesScanned }} file{{
job.filesScanned === 1 ? "" : "s"
}}
scanned.</span
>
</div>
</div>

<!-- Terminal: COMPLETED with infected findings -->
<div
*ngIf="
!scanning && job.status === 'COMPLETED' && job.infectedCount > 0
"
class="cloud-scan-findings"
>
<div class="cloud-scan-state cloud-scan-state--infected">
<i class="pi pi-exclamation-circle"></i>
<span class="text-xxs sm:text-xs">
{{ job.infectedCount }} infected file{{
job.infectedCount === 1 ? "" : "s"
}}
found out of {{ job.filesScanned }} scanned.
</span>
</div>
<ul class="cloud-scan-list">
<li *ngFor="let finding of scanInfectedFindings">
<span class="cloud-scan-file text-xxs sm:text-xs">{{
finding.path
}}</span>
<span class="cloud-scan-threat text-xxs">{{
finding.detail || "Threat detected"
}}</span>
</li>
</ul>
</div>

<!-- Files that could not be scanned (shown unless the scanner was
entirely unavailable, which is reported separately above) -->
<div
*ngIf="
!scanning && !scanScannerUnavailable && scanErroredFindings.length
"
class="cloud-scan-findings"
>
<p class="cloud-scan-subhead text-xxs">
{{ scanErroredFindings.length }} file{{
scanErroredFindings.length === 1 ? "" : "s"
}}
could not be scanned
</p>
<ul class="cloud-scan-list">
<li *ngFor="let finding of scanErroredFindings">
<span class="cloud-scan-file text-xxs sm:text-xs">{{
finding.path
}}</span>
<span class="cloud-scan-threat text-xxs">{{
finding.detail || "Could not scan"
}}</span>
</li>
</ul>
</div>
</ng-container>
</div>

<ng-template pTemplate="footer">
<p-button
label="Close"
styleClass="p-button-text p-button-sm text-xxs sm:text-xs"
(click)="showScanDialog = false"
></p-button>
<p-button
label="Scan again"
icon="pi pi-refresh"
styleClass="p-button-sm text-xxs sm:text-xs"
[disabled]="scanning"
(click)="scanCurrentFolder()"
></p-button>
</ng-template>
</p-dialog>
</div>
116 changes: 116 additions & 0 deletions frontend/src/app/pages/cloud/cloud.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,122 @@
gap: 0.35rem;
}

.cloud-scan-body {
display: flex;
flex-direction: column;
gap: 0.75rem;
}

.cloud-scan-target {
margin: 0;
color: var(--color-text-secondary);

strong {
color: var(--color-text-primary);
word-break: break-word;
}
}

.cloud-scan-state {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.7rem 0.85rem;
border-radius: 0.55rem;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-primary);

i {
font-size: 1.1rem;
flex-shrink: 0;
}
}

.cloud-scan-running-text {
display: flex;
flex-direction: column;
gap: 0.1rem;
}

.cloud-scan-counters {
color: var(--color-text-secondary);
}

.cloud-scan-state--running i {
color: var(--color-primary);
}

.cloud-scan-state--clean {
border-color: color-mix(in srgb, #10b981 45%, var(--color-border));
background: color-mix(in srgb, #10b981 10%, transparent);

i {
color: #059669;
}
}

.cloud-scan-state--warn {
border-color: color-mix(in srgb, #f59e0b 45%, var(--color-border));
background: color-mix(in srgb, #f59e0b 12%, transparent);

i {
color: #d97706;
}
}

.cloud-scan-state--error,
.cloud-scan-state--infected {
border-color: color-mix(in srgb, #ef4444 45%, var(--color-border));
background: color-mix(in srgb, #ef4444 10%, transparent);

i {
color: #dc2626;
}
}

.cloud-scan-findings {
display: flex;
flex-direction: column;
gap: 0.45rem;
}

.cloud-scan-subhead {
margin: 0;
color: var(--color-text-secondary);
}

.cloud-scan-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
max-height: 15rem;
overflow-y: auto;

li {
display: flex;
flex-direction: column;
gap: 0.1rem;
padding: 0.5rem 0.65rem;
border-radius: 0.45rem;
border: 1px solid var(--color-border);
background: var(--color-surface);
}
}

.cloud-scan-file {
color: var(--color-text-primary);
word-break: break-all;
}

.cloud-scan-threat {
color: var(--color-text-secondary);
word-break: break-word;
}

.cloud-editor {
width: 100%;
min-height: 14rem;
Expand Down
Loading