feat: add Aphoria dashboard scans and corpus UI
- Add scans panel with finding details, verdict badges, and filters - Add corpus panel for managing knowledge sources - Add scan cache for API state management - Update sidebar navigation with new routes - Extend API types for scans and corpus endpoints - Add .aphoria/ to gitignore (contains project keys) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
3ce37573b8
commit
c849627620
@ -14,6 +14,16 @@ You are an expert at installing and running StemeDB and Aphoria. You guide users
|
||||
- User is troubleshooting installation or runtime issues
|
||||
- User needs to verify their installation is working
|
||||
|
||||
## Vision: Autonomous Learning
|
||||
|
||||
Aphoria's goal is **autonomous pattern learning**:
|
||||
- Scan projects → extract claims about what code does
|
||||
- Compare against authorities → RFCs, OWASP, specs
|
||||
- Learn from usage → patterns feed back to corpus
|
||||
- Autonomous promotion → good patterns become authoritative automatically
|
||||
|
||||
**Hooks are not optional.** They trigger the learning loop. Every commit = scan = observation = learning.
|
||||
|
||||
## Principles
|
||||
|
||||
### 1. User Space First
|
||||
@ -25,8 +35,8 @@ Aphoria standalone needs only Rust. StemeDB server is optional for solo develope
|
||||
### 3. Fast Verification
|
||||
Every installation step has an immediate verification command.
|
||||
|
||||
### 4. Ephemeral by Default
|
||||
Default scans are fast and ephemeral. Server/persistence is opt-in.
|
||||
### 4. Hooks Are Mandatory
|
||||
When setting up on a project, **always install pre-commit hooks**. Without hooks, no autonomous learning happens. Don't ask - just do it.
|
||||
|
||||
### 5. Progressive Disclosure
|
||||
Start with minimal setup, add complexity only when needed.
|
||||
@ -215,19 +225,64 @@ curl http://localhost:18180/health
|
||||
# Expected: {"status":"ok"}
|
||||
```
|
||||
|
||||
### Phase 6 (Optional): Configure Hosted Mode
|
||||
### Phase 6: Set Up Project (MANDATORY)
|
||||
|
||||
When installing Aphoria on any project, **always do all three**:
|
||||
|
||||
```bash
|
||||
# 1. Create config
|
||||
cat > aphoria.toml << 'EOF'
|
||||
[project]
|
||||
name = "my-project"
|
||||
description = "Brief description"
|
||||
|
||||
[scan]
|
||||
domains = ["security", "cryptography"]
|
||||
|
||||
[thresholds]
|
||||
flag = 0.4
|
||||
block = 0.6
|
||||
|
||||
[learning]
|
||||
persist = true
|
||||
sync = true
|
||||
EOF
|
||||
|
||||
# 2. Install pre-commit hook (NOT OPTIONAL)
|
||||
mkdir -p .git/hooks
|
||||
cat > .git/hooks/pre-commit << 'EOF'
|
||||
#!/bin/bash
|
||||
aphoria scan --staged --exit-code
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Aphoria found conflicts."
|
||||
exit 1
|
||||
fi
|
||||
EOF
|
||||
chmod +x .git/hooks/pre-commit
|
||||
|
||||
# 3. Create documentation
|
||||
cat > aphoria.md << 'EOF'
|
||||
# Aphoria Integration
|
||||
|
||||
This project uses Aphoria for autonomous security pattern learning.
|
||||
Every commit triggers a scan. Observations feed back to the corpus.
|
||||
See aphoria.toml for configuration.
|
||||
EOF
|
||||
|
||||
# 4. Run baseline
|
||||
aphoria scan --persist --sync
|
||||
```
|
||||
|
||||
**Do not skip the hook.** It's the trigger for autonomous learning.
|
||||
|
||||
### Phase 7 (Optional): Configure Hosted Mode
|
||||
|
||||
Connect Aphoria to StemeDB server:
|
||||
|
||||
```bash
|
||||
# In project directory
|
||||
cat > aphoria.toml << 'EOF'
|
||||
[project]
|
||||
name = "my-project"
|
||||
|
||||
# Add to aphoria.toml
|
||||
[hosted]
|
||||
url = "http://localhost:18180"
|
||||
EOF
|
||||
|
||||
# Test connection
|
||||
aphoria scan --persist --sync
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -23,6 +23,9 @@ Thumbs.db
|
||||
credentials.json
|
||||
service-account*.json
|
||||
|
||||
# Aphoria project data (contains keys)
|
||||
.aphoria/
|
||||
|
||||
# Python virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
@ -61,7 +61,7 @@ dirs = "5.0"
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# rkyv for zero-copy (consistent with stemedb)
|
||||
rkyv = { version = "0.7", features = ["validation"] }
|
||||
|
||||
@ -36,6 +36,10 @@ pub struct Cli {
|
||||
#[arg(short, long, global = true)]
|
||||
pub config: Option<PathBuf>,
|
||||
|
||||
/// Enable verbose logging (shows internal tracing output)
|
||||
#[arg(short, long, global = true)]
|
||||
pub verbose: bool,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Commands,
|
||||
}
|
||||
|
||||
@ -159,6 +159,8 @@ impl AuditTrail {
|
||||
}
|
||||
|
||||
/// Get all audit events.
|
||||
///
|
||||
/// Resilient parser that skips malformed lines rather than failing.
|
||||
pub fn get_all_events(&self) -> Result<Vec<AuditEvent>, AphoriaError> {
|
||||
let path = self.events_path();
|
||||
if !path.exists() {
|
||||
@ -170,17 +172,53 @@ impl AuditTrail {
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
let mut events = Vec::new();
|
||||
let mut skipped = 0;
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = line
|
||||
.map_err(|e| AphoriaError::Storage(format!("Failed to read audit line: {}", e)))?;
|
||||
let line = match line {
|
||||
Ok(l) => l,
|
||||
Err(_) => {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let event: AuditEvent = serde_json::from_str(&line)
|
||||
.map_err(|e| AphoriaError::Storage(format!("Failed to parse event: {}", e)))?;
|
||||
events.push(event);
|
||||
// Try to parse each JSON object on the line (handles concatenated objects)
|
||||
let mut remaining = line.trim();
|
||||
while !remaining.is_empty() {
|
||||
// Find the end of the current JSON object
|
||||
match serde_json::from_str::<AuditEvent>(remaining) {
|
||||
Ok(event) => {
|
||||
events.push(event);
|
||||
// Consumed the whole line
|
||||
break;
|
||||
}
|
||||
Err(_) => {
|
||||
// Try to find where the first JSON object ends by looking for }{
|
||||
if let Some(split_pos) = remaining.find("}{") {
|
||||
let first_obj = &remaining[..=split_pos];
|
||||
if let Ok(event) = serde_json::from_str::<AuditEvent>(first_obj) {
|
||||
events.push(event);
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
remaining = &remaining[split_pos + 1..];
|
||||
} else {
|
||||
// Can't parse, skip this line
|
||||
skipped += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if skipped > 0 {
|
||||
tracing::warn!(skipped, "Skipped malformed audit entries");
|
||||
}
|
||||
|
||||
Ok(events)
|
||||
|
||||
@ -23,12 +23,12 @@ pub async fn show_status(config: &AphoriaConfig) -> Result<String, AphoriaError>
|
||||
}
|
||||
|
||||
output.push_str("Aphoria status:\n");
|
||||
output.push_str(&format!(" Data directory: {}\\n", data_dir.display()));
|
||||
output.push_str(&format!(" Project root: {}\\n", project_root.display()));
|
||||
output.push_str(&format!(" Data directory: {}\n", data_dir.display()));
|
||||
output.push_str(&format!(" Project root: {}\n", project_root.display()));
|
||||
|
||||
if aphoria_dir.join("baseline").exists() {
|
||||
let baseline = std::fs::read_to_string(aphoria_dir.join("baseline"))?;
|
||||
output.push_str(&format!(" Baseline: {}\\n", baseline.trim()));
|
||||
output.push_str(&format!(" Baseline: {}\n", baseline.trim()));
|
||||
} else {
|
||||
output.push_str(" Baseline: none\n");
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::Parser;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use aphoria::AphoriaConfig;
|
||||
|
||||
@ -16,11 +17,16 @@ use cli::Cli;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
// Initialize tracing for internal logging
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
// Initialize tracing only if verbose or RUST_LOG is set
|
||||
// Default: silent (clean CLI output)
|
||||
if cli.verbose || std::env::var("RUST_LOG").is_ok() {
|
||||
let filter =
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("aphoria=info"));
|
||||
tracing_subscriber::fmt().with_env_filter(filter).init();
|
||||
}
|
||||
|
||||
// Load configuration
|
||||
let config = match load_config(cli.config.as_deref()) {
|
||||
Ok(cfg) => cfg,
|
||||
|
||||
13
applications/stemedb-dashboard/src/app/corpus/page.tsx
Normal file
13
applications/stemedb-dashboard/src/app/corpus/page.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { Header } from "@/components/layout/header";
|
||||
import { CorpusPanel } from "@/components/corpus";
|
||||
|
||||
export default function CorpusPage() {
|
||||
return (
|
||||
<>
|
||||
<Header title="Community Corpus" />
|
||||
<div className="p-6">
|
||||
<CorpusPanel />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
13
applications/stemedb-dashboard/src/app/scans/page.tsx
Normal file
13
applications/stemedb-dashboard/src/app/scans/page.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { Header } from "@/components/layout/header";
|
||||
import { ScansPanel } from "@/components/scans";
|
||||
|
||||
export default function ScansPage() {
|
||||
return (
|
||||
<>
|
||||
<Header title="Aphoria Scans" />
|
||||
<div className="p-6">
|
||||
<ScansPanel />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
// Corpus page constants
|
||||
|
||||
export const CORPUS_FETCH_LIMIT = 100;
|
||||
export const DEFAULT_MIN_PROJECTS = 1;
|
||||
|
||||
// Re-export shared formatters for convenience
|
||||
export { formatRelativeTime, formatUnixTimestamp } from "@/lib/format";
|
||||
|
||||
// Subject path parsing
|
||||
export function extractDomain(subject: string): string {
|
||||
// Extract domain from code://rust/*/tls → rust
|
||||
const match = subject.match(/^code:\/\/([^/]+)/);
|
||||
return match ? match[1] : "unknown";
|
||||
}
|
||||
|
||||
export function extractConcept(subject: string): string {
|
||||
// Extract last part of path: code://rust/*/tls/cert → cert
|
||||
const parts = subject.split("/");
|
||||
return parts[parts.length - 1] || subject;
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { Library, Search } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface CorpusEmptyStateProps {
|
||||
hasFilter: boolean;
|
||||
onClearFilter?: () => void;
|
||||
}
|
||||
|
||||
export function CorpusEmptyState({ hasFilter, onClearFilter }: CorpusEmptyStateProps) {
|
||||
if (hasFilter) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Search className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
No patterns match your filter
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4 max-w-md">
|
||||
Try adjusting the subject prefix or lowering the minimum projects threshold.
|
||||
</p>
|
||||
{onClearFilter && (
|
||||
<Button variant="outline" onClick={onClearFilter}>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Library className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
No community patterns yet
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
When teams opt-in to community sharing, their anonymized scan patterns
|
||||
will appear here. Run scans with community sharing enabled to contribute.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface CorpusFiltersProps {
|
||||
subjectPrefix: string;
|
||||
minProjects: number;
|
||||
onSubjectPrefixChange: (value: string) => void;
|
||||
onMinProjectsChange: (value: number) => void;
|
||||
totalCount: number;
|
||||
filteredCount: number;
|
||||
}
|
||||
|
||||
export function CorpusFilters({
|
||||
subjectPrefix,
|
||||
minProjects,
|
||||
onSubjectPrefixChange,
|
||||
onMinProjectsChange,
|
||||
totalCount,
|
||||
filteredCount,
|
||||
}: CorpusFiltersProps) {
|
||||
const hasActiveFilter = subjectPrefix !== "" || minProjects > 1;
|
||||
|
||||
const handleClear = () => {
|
||||
onSubjectPrefixChange("");
|
||||
onMinProjectsChange(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<Input
|
||||
placeholder="Filter by subject prefix (e.g., code://rust)"
|
||||
value={subjectPrefix}
|
||||
onChange={(e) => onSubjectPrefixChange(e.target.value)}
|
||||
className="max-w-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="min-projects" className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
Min projects:
|
||||
</label>
|
||||
<Input
|
||||
id="min-projects"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={minProjects}
|
||||
onChange={(e) => onMinProjectsChange(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasActiveFilter && (
|
||||
<Button variant="ghost" size="sm" onClick={handleClear}>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="text-sm text-muted-foreground ml-auto">
|
||||
{filteredCount === totalCount
|
||||
? `${totalCount} patterns`
|
||||
: `${filteredCount} of ${totalCount} patterns`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import type { PatternDto } from "@/lib/api";
|
||||
import { CorpusRow } from "./corpus-row";
|
||||
|
||||
interface CorpusListProps {
|
||||
patterns: PatternDto[];
|
||||
}
|
||||
|
||||
export function CorpusList({ patterns }: CorpusListProps) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{patterns.map((pattern) => (
|
||||
<CorpusRow
|
||||
key={`${pattern.subject}:${pattern.predicate}:${pattern.value}`}
|
||||
pattern={pattern}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
export function CorpusLoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 animate-pulse">
|
||||
{/* Filter skeleton */}
|
||||
<div className="flex gap-4">
|
||||
<div className="h-10 w-64 bg-muted rounded-md" />
|
||||
<div className="h-10 w-32 bg-muted rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* Pattern cards skeleton */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div key={i} className="rounded-lg border border-border bg-card p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="h-5 w-3/4 bg-muted rounded" />
|
||||
<div className="h-4 w-1/2 bg-muted rounded" />
|
||||
<div className="flex gap-2 mt-4">
|
||||
<div className="h-6 w-20 bg-muted rounded-full" />
|
||||
<div className="h-6 w-20 bg-muted rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import {
|
||||
StemeDBClient,
|
||||
type GetPatternsResponse,
|
||||
ApiError,
|
||||
} from "@/lib/api";
|
||||
import type { PanelState } from "@/lib/types";
|
||||
import { CORPUS_FETCH_LIMIT, DEFAULT_MIN_PROJECTS } from "./constants";
|
||||
import { ErrorState } from "@/components/shared/error-state";
|
||||
import { CorpusFilters } from "./corpus-filters";
|
||||
import { CorpusList } from "./corpus-list";
|
||||
import { CorpusLoadingSkeleton } from "./corpus-loading-skeleton";
|
||||
import { CorpusEmptyState } from "./corpus-empty-state";
|
||||
|
||||
export function CorpusPanel() {
|
||||
const [state, setState] = useState<PanelState<GetPatternsResponse>>({
|
||||
status: "idle",
|
||||
});
|
||||
const [subjectPrefix, setSubjectPrefix] = useState("");
|
||||
const [minProjects, setMinProjects] = useState(DEFAULT_MIN_PROJECTS);
|
||||
|
||||
// Debounced filter values for API calls
|
||||
const [debouncedPrefix, setDebouncedPrefix] = useState("");
|
||||
|
||||
// Debounce the subject prefix
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedPrefix(subjectPrefix);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [subjectPrefix]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setState({ status: "loading" });
|
||||
try {
|
||||
const client = new StemeDBClient();
|
||||
const data = await client.getPatterns({
|
||||
subjectPrefix: debouncedPrefix || undefined,
|
||||
minProjects,
|
||||
limit: CORPUS_FETCH_LIMIT,
|
||||
});
|
||||
setState({ status: "success", data });
|
||||
} catch (err) {
|
||||
// 404 means no patterns - treat as empty success
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
setState({
|
||||
status: "success",
|
||||
data: { patterns: [], total_matching: 0 },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
err instanceof ApiError
|
||||
? err.userMessage
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Unknown error";
|
||||
setState({ status: "error", error: message });
|
||||
}
|
||||
}, [debouncedPrefix, minProjects]);
|
||||
|
||||
// Fetch on mount and when filters change
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// Patterns from successful state (filtering done server-side)
|
||||
const patterns = state.status === "success" ? state.data.patterns : [];
|
||||
|
||||
const handleClearFilter = useCallback(() => {
|
||||
setSubjectPrefix("");
|
||||
setMinProjects(DEFAULT_MIN_PROJECTS);
|
||||
}, []);
|
||||
|
||||
const hasFilter = subjectPrefix !== "" || minProjects > DEFAULT_MIN_PROJECTS;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h2 className="text-lg font-medium text-card-foreground mb-2">
|
||||
Community Corpus
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Explore patterns discovered across projects using Aphoria. These anonymized
|
||||
observations help establish community consensus on configurations and practices.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
{state.status === "idle" && <CorpusLoadingSkeleton />}
|
||||
{state.status === "loading" && <CorpusLoadingSkeleton />}
|
||||
|
||||
{state.status === "error" && (
|
||||
<ErrorState
|
||||
title="Failed to Load Patterns"
|
||||
error={state.error}
|
||||
onRetry={fetchData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state.status === "success" && (
|
||||
<div className="space-y-6">
|
||||
<CorpusFilters
|
||||
subjectPrefix={subjectPrefix}
|
||||
minProjects={minProjects}
|
||||
onSubjectPrefixChange={setSubjectPrefix}
|
||||
onMinProjectsChange={setMinProjects}
|
||||
totalCount={state.data.total_matching}
|
||||
filteredCount={patterns.length}
|
||||
/>
|
||||
|
||||
{patterns.length === 0 ? (
|
||||
<CorpusEmptyState
|
||||
hasFilter={hasFilter}
|
||||
onClearFilter={handleClearFilter}
|
||||
/>
|
||||
) : (
|
||||
<CorpusList patterns={patterns} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { PatternDto } from "@/lib/api";
|
||||
import { formatRelativeTime, extractDomain, extractConcept } from "./constants";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Users, Clock, Eye } from "lucide-react";
|
||||
|
||||
interface CorpusRowProps {
|
||||
pattern: PatternDto;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CorpusRow({ pattern, className }: CorpusRowProps) {
|
||||
const domain = extractDomain(pattern.subject);
|
||||
const concept = extractConcept(pattern.subject);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-border bg-card p-4 hover:border-border/80 transition-colors",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2 mb-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="outline" className="text-xs font-mono">
|
||||
{domain}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{pattern.subject}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-base font-medium text-foreground">
|
||||
{concept}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{" "}.{pattern.predicate}
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Value */}
|
||||
<div className="mb-4">
|
||||
<code className="text-sm bg-muted px-2 py-1 rounded font-mono break-all">
|
||||
{pattern.value}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
<span>{pattern.project_count} projects</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
<span>{pattern.observation_count} observations</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span>Last seen {formatRelativeTime(pattern.last_seen)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
export { CorpusPanel } from "./corpus-panel";
|
||||
export { CorpusFilters } from "./corpus-filters";
|
||||
export { CorpusList } from "./corpus-list";
|
||||
export { CorpusRow } from "./corpus-row";
|
||||
export { CorpusEmptyState } from "./corpus-empty-state";
|
||||
export { CorpusLoadingSkeleton } from "./corpus-loading-skeleton";
|
||||
export * from "./constants";
|
||||
@ -12,6 +12,8 @@ import {
|
||||
Menu,
|
||||
X,
|
||||
BookOpen,
|
||||
Scan,
|
||||
Library,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
@ -23,6 +25,8 @@ const navigation = [
|
||||
{ name: "Quarantine", href: "/quarantine", icon: ShieldAlert },
|
||||
{ name: "Circuit Breakers", href: "/circuit", icon: Zap },
|
||||
{ name: "Audit Trail", href: "/audit", icon: FileText },
|
||||
{ name: "Corpus", href: "/corpus", icon: Library },
|
||||
{ name: "Scans", href: "/scans", icon: Scan },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
// Scans page constants
|
||||
|
||||
export type VerdictType = "BLOCK" | "FLAG" | "PASS" | "ACK";
|
||||
|
||||
export const verdictLabels: Record<VerdictType, string> = {
|
||||
BLOCK: "Block",
|
||||
FLAG: "Flag",
|
||||
PASS: "Pass",
|
||||
ACK: "Acknowledged",
|
||||
};
|
||||
|
||||
export const verdictColors: Record<VerdictType, string> = {
|
||||
BLOCK: "bg-red-500/20 text-red-700 dark:text-red-300",
|
||||
FLAG: "bg-amber-500/20 text-amber-700 dark:text-amber-300",
|
||||
PASS: "bg-emerald-500/20 text-emerald-700 dark:text-emerald-300",
|
||||
ACK: "bg-blue-500/20 text-blue-700 dark:text-blue-300",
|
||||
};
|
||||
|
||||
export const verdictIcons: Record<VerdictType, string> = {
|
||||
BLOCK: "\u25CF", // ●
|
||||
FLAG: "\u26A0", // ⚠
|
||||
PASS: "\u2713", // ✓
|
||||
ACK: "\u2714", // ✔
|
||||
};
|
||||
|
||||
// Re-export shared formatters for convenience
|
||||
export { formatRelativeTime, formatUnixDateTime } from "@/lib/format";
|
||||
@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import type { FindingDto } from "@/lib/api";
|
||||
import { VerdictBadge } from "./verdict-badge";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { FileCode, AlertTriangle, BookOpen, Shield } from "lucide-react";
|
||||
|
||||
interface FindingDetailSheetProps {
|
||||
finding: FindingDto | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function FindingDetailSheet({ finding, isOpen, onClose }: FindingDetailSheetProps) {
|
||||
if (!finding) return null;
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<SheetContent className="overflow-y-auto">
|
||||
<SheetHeader className="pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<VerdictBadge verdict={finding.verdict} />
|
||||
</div>
|
||||
<SheetTitle className="text-lg font-semibold break-words">
|
||||
{finding.concept_path}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="text-sm">
|
||||
<span className="font-mono">.{finding.predicate}</span>
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Code Location */}
|
||||
<section>
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium text-foreground mb-3">
|
||||
<FileCode className="h-4 w-4" />
|
||||
Source Location
|
||||
</h3>
|
||||
<div className="rounded-md bg-muted p-3 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">File:</span>
|
||||
<code className="font-mono text-foreground">{finding.file}</code>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Line:</span>
|
||||
<span className="font-mono text-foreground">{finding.line}</span>
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground block mb-1">Value in code:</span>
|
||||
<code className="block bg-background p-2 rounded font-mono text-sm break-all">
|
||||
{finding.code_value}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Conflict Details */}
|
||||
{finding.conflicts.length > 0 && (
|
||||
<section>
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium text-foreground mb-3">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
Conflicting Sources ({finding.conflicts.length})
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{finding.conflicts.map((conflict, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-md border border-border bg-card p-3 space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{conflict.source_class}
|
||||
</Badge>
|
||||
{conflict.policy_source && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
via {conflict.policy_source.pack_name} v{conflict.policy_source.pack_version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">Expected: </span>
|
||||
<code className="font-mono bg-muted px-1 rounded">{conflict.value}</code>
|
||||
</div>
|
||||
{conflict.citation && (
|
||||
<div className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||
<BookOpen className="h-3 w-3 mt-0.5 flex-shrink-0" />
|
||||
<span>{conflict.citation}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Acknowledgment */}
|
||||
{finding.acknowledgment && (
|
||||
<section>
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium text-foreground mb-3">
|
||||
<Shield className="h-4 w-4 text-blue-500" />
|
||||
Acknowledged
|
||||
</h3>
|
||||
<div className="rounded-md bg-blue-500/10 border border-blue-500/20 p-3 space-y-2">
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">By: </span>
|
||||
<span className="text-foreground">{finding.acknowledgment.by}</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">Reason: </span>
|
||||
<span className="text-foreground">{finding.acknowledgment.reason}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{finding.acknowledgment.timestamp}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Debug Trace */}
|
||||
{finding.trace && (
|
||||
<section>
|
||||
<h3 className="text-sm font-medium text-foreground mb-3">
|
||||
Resolution Trace
|
||||
</h3>
|
||||
<div className="rounded-md bg-muted p-3 space-y-2 text-xs font-mono">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Code claim: </span>
|
||||
<span className="text-foreground">{finding.trace.code_claim}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Authority match: </span>
|
||||
<span className="text-foreground">{finding.trace.authority_match}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Authority tier: </span>
|
||||
<span className="text-foreground">{finding.trace.authority_tier}</span>
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
<div>
|
||||
<span className="text-muted-foreground">Resolution: </span>
|
||||
<span className="text-foreground">{finding.trace.resolution}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Conflict Score */}
|
||||
<section>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-muted-foreground">Conflict Score</span>
|
||||
<span className="font-mono">{(finding.conflict_score * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FindingDto } from "@/lib/api";
|
||||
import { VerdictBadge } from "./verdict-badge";
|
||||
import { FileCode, ChevronRight } from "lucide-react";
|
||||
|
||||
interface FindingRowProps {
|
||||
finding: FindingDto;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FindingRow({ finding, onClick, className }: FindingRowProps) {
|
||||
// Extract last part of concept path for display
|
||||
const conceptParts = finding.concept_path.split("/");
|
||||
const conceptShort = conceptParts[conceptParts.length - 1] || finding.concept_path;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-3 rounded-md border border-border bg-card/50 hover:bg-card/80 transition-colors cursor-pointer",
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
onClick?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="flex-shrink-0">
|
||||
<FileCode className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-sm text-foreground truncate">
|
||||
{conceptShort}
|
||||
<span className="text-muted-foreground">.{finding.predicate}</span>
|
||||
</span>
|
||||
<VerdictBadge verdict={finding.verdict} size="xs" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="truncate">{finding.file}:{finding.line}</span>
|
||||
<span className="text-muted-foreground/50">|</span>
|
||||
<code className="bg-muted px-1 rounded truncate max-w-[200px]">
|
||||
{finding.code_value}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
applications/stemedb-dashboard/src/components/scans/index.ts
Normal file
11
applications/stemedb-dashboard/src/components/scans/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export { ScansPanel } from "./scans-panel";
|
||||
export { ScanForm } from "./scan-form";
|
||||
export { ScansList } from "./scans-list";
|
||||
export { ScanRow } from "./scan-row";
|
||||
export { ScanDetail } from "./scan-detail";
|
||||
export { FindingRow } from "./finding-row";
|
||||
export { FindingDetailSheet } from "./finding-detail-sheet";
|
||||
export { VerdictBadge } from "./verdict-badge";
|
||||
export { ScansEmptyState } from "./scans-empty-state";
|
||||
export { ScansLoadingSkeleton } from "./scans-loading-skeleton";
|
||||
export * from "./constants";
|
||||
@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import type { FindingDto } from "@/lib/api";
|
||||
import { FindingRow } from "./finding-row";
|
||||
|
||||
interface ScanDetailProps {
|
||||
findings: FindingDto[];
|
||||
onFindingClick?: (finding: FindingDto) => void;
|
||||
}
|
||||
|
||||
export function ScanDetail({ findings, onFindingClick }: ScanDetailProps) {
|
||||
if (findings.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground py-4 text-center">
|
||||
No findings in this scan.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 mt-4">
|
||||
{findings.map((finding, index) => (
|
||||
<FindingRow
|
||||
key={`${finding.concept_path}-${finding.file}-${finding.line}-${index}`}
|
||||
finding={finding}
|
||||
onClick={() => onFindingClick?.(finding)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Loader2, Scan } from "lucide-react";
|
||||
|
||||
interface ScanFormProps {
|
||||
onScan: (targetPath: string) => Promise<void>;
|
||||
isScanning: boolean;
|
||||
}
|
||||
|
||||
export function ScanForm({ onScan, isScanning }: ScanFormProps) {
|
||||
const [targetPath, setTargetPath] = useState("");
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!targetPath.trim() || isScanning) return;
|
||||
await onScan(targetPath.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex gap-4 items-end">
|
||||
<div className="flex-1">
|
||||
<label htmlFor="target-path" className="block text-sm font-medium text-foreground mb-2">
|
||||
Project Path
|
||||
</label>
|
||||
<Input
|
||||
id="target-path"
|
||||
placeholder="/path/to/your/project"
|
||||
value={targetPath}
|
||||
onChange={(e) => setTargetPath(e.target.value)}
|
||||
disabled={isScanning}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={isScanning || !targetPath.trim()}>
|
||||
{isScanning ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Scanning...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Scan className="h-4 w-4 mr-2" />
|
||||
Run Scan
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
101
applications/stemedb-dashboard/src/components/scans/scan-row.tsx
Normal file
101
applications/stemedb-dashboard/src/components/scans/scan-row.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ScanListItem, FindingDto } from "@/lib/api";
|
||||
import { formatRelativeTime } from "./constants";
|
||||
import { VerdictBadge } from "./verdict-badge";
|
||||
import { ScanDetail } from "./scan-detail";
|
||||
import { ChevronDown, ChevronRight, FolderCode, FileSearch } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface ScanRowProps {
|
||||
scan: ScanListItem;
|
||||
onFindingClick?: (finding: FindingDto) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ScanRow({ scan, onFindingClick, className }: ScanRowProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const hasIssues = scan.summary.blocked > 0 || scan.summary.flagged > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-border bg-card overflow-hidden",
|
||||
hasIssues && "border-amber-500/30",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-4 p-4 cursor-pointer hover:bg-muted/30 transition-colors",
|
||||
expanded && "border-b border-border"
|
||||
)}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
setExpanded(!expanded);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Expand icon */}
|
||||
<div className="flex-shrink-0 text-muted-foreground">
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<FolderCode className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="font-medium text-foreground truncate">{scan.project}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatRelativeTime(scan.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<FileSearch className="h-3 w-3" />
|
||||
{scan.files_scanned} files
|
||||
</span>
|
||||
<span>{scan.claims_extracted} claims</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary badges */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{scan.summary.blocked > 0 && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
{scan.summary.blocked} blocked
|
||||
</Badge>
|
||||
)}
|
||||
{scan.summary.flagged > 0 && (
|
||||
<Badge variant="outline" className="text-xs border-amber-500/50 text-amber-600">
|
||||
{scan.summary.flagged} flagged
|
||||
</Badge>
|
||||
)}
|
||||
{scan.summary.blocked === 0 && scan.summary.flagged === 0 && (
|
||||
<Badge variant="outline" className="text-xs border-emerald-500/50 text-emerald-600">
|
||||
Clean
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded detail */}
|
||||
{expanded && (
|
||||
<div className="p-4 bg-muted/20">
|
||||
<ScanDetail findings={scan.findings} onFindingClick={onFindingClick} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { Scan } from "lucide-react";
|
||||
|
||||
export function ScansEmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Scan className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
No scans yet
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
Run your first scan by entering a project path above. Scans check your
|
||||
code for conflicts against authoritative sources.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import type { ScanListItem, FindingDto } from "@/lib/api";
|
||||
import { ScanRow } from "./scan-row";
|
||||
|
||||
interface ScansListProps {
|
||||
scans: ScanListItem[];
|
||||
onFindingClick?: (finding: FindingDto) => void;
|
||||
}
|
||||
|
||||
export function ScansList({ scans, onFindingClick }: ScansListProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{scans.map((scan) => (
|
||||
<ScanRow
|
||||
key={scan.scan_id}
|
||||
scan={scan}
|
||||
onFindingClick={onFindingClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
export function ScansLoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 animate-pulse">
|
||||
{/* Scan form skeleton */}
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<div className="flex gap-4 items-end">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 w-24 bg-muted rounded" />
|
||||
<div className="h-10 bg-muted rounded" />
|
||||
</div>
|
||||
<div className="h-10 w-24 bg-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scan list skeleton */}
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="rounded-lg border border-border bg-card p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="h-5 w-40 bg-muted rounded" />
|
||||
<div className="h-6 w-20 bg-muted rounded" />
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="h-4 w-32 bg-muted rounded" />
|
||||
<div className="h-4 w-32 bg-muted rounded" />
|
||||
<div className="h-4 w-32 bg-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import {
|
||||
StemeDBClient,
|
||||
type ListScansResponse,
|
||||
type FindingDto,
|
||||
ApiError,
|
||||
} from "@/lib/api";
|
||||
import type { PanelState } from "@/lib/types";
|
||||
import { ErrorState } from "@/components/shared/error-state";
|
||||
import { ScanForm } from "./scan-form";
|
||||
import { ScansList } from "./scans-list";
|
||||
import { ScansLoadingSkeleton } from "./scans-loading-skeleton";
|
||||
import { ScansEmptyState } from "./scans-empty-state";
|
||||
import { FindingDetailSheet } from "./finding-detail-sheet";
|
||||
|
||||
export function ScansPanel() {
|
||||
const [state, setState] = useState<PanelState<ListScansResponse>>({
|
||||
status: "idle",
|
||||
});
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [scanError, setScanError] = useState<string | null>(null);
|
||||
const [selectedFinding, setSelectedFinding] = useState<FindingDto | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setState({ status: "loading" });
|
||||
try {
|
||||
const client = new StemeDBClient();
|
||||
const data = await client.listScans();
|
||||
setState({ status: "success", data });
|
||||
} catch (err) {
|
||||
// 404 means no scans - treat as empty success
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
setState({
|
||||
status: "success",
|
||||
data: { scans: [] },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
err instanceof ApiError
|
||||
? err.userMessage
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Unknown error";
|
||||
setState({ status: "error", error: message });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch on mount
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleScan = useCallback(async (targetPath: string) => {
|
||||
setIsScanning(true);
|
||||
setScanError(null);
|
||||
try {
|
||||
const client = new StemeDBClient();
|
||||
await client.runScan({ target_path: targetPath });
|
||||
// Refresh the list to show the new scan
|
||||
await fetchData();
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof ApiError
|
||||
? err.userMessage
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Scan failed";
|
||||
setScanError(message);
|
||||
} finally {
|
||||
setIsScanning(false);
|
||||
}
|
||||
}, [fetchData]);
|
||||
|
||||
const handleFindingClick = useCallback((finding: FindingDto) => {
|
||||
setSelectedFinding(finding);
|
||||
}, []);
|
||||
|
||||
const handleCloseSheet = useCallback(() => {
|
||||
setSelectedFinding(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h2 className="text-lg font-medium text-card-foreground mb-2">
|
||||
Aphoria Scans
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Run scans to check your code for conflicts against authoritative sources.
|
||||
View scan history and drill into findings for details.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Scan Form */}
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<ScanForm onScan={handleScan} isScanning={isScanning} />
|
||||
{scanError && (
|
||||
<div className="mt-4 p-3 rounded-md bg-red-500/10 border border-red-500/20 text-sm text-red-600 dark:text-red-400">
|
||||
{scanError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h3 className="text-sm font-medium text-foreground mb-4">Recent Scans</h3>
|
||||
|
||||
{state.status === "idle" && <ScansLoadingSkeleton />}
|
||||
{state.status === "loading" && <ScansLoadingSkeleton />}
|
||||
|
||||
{state.status === "error" && (
|
||||
<ErrorState
|
||||
title="Failed to Load Scans"
|
||||
error={state.error}
|
||||
onRetry={fetchData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state.status === "success" && (
|
||||
state.data.scans.length === 0 ? (
|
||||
<ScansEmptyState />
|
||||
) : (
|
||||
<ScansList scans={state.data.scans} onFindingClick={handleFindingClick} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Finding Detail Sheet */}
|
||||
<FindingDetailSheet
|
||||
finding={selectedFinding}
|
||||
isOpen={selectedFinding !== null}
|
||||
onClose={handleCloseSheet}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type VerdictType, verdictLabels, verdictColors, verdictIcons } from "./constants";
|
||||
|
||||
interface VerdictBadgeProps {
|
||||
verdict: VerdictType;
|
||||
className?: string;
|
||||
size?: "sm" | "xs";
|
||||
}
|
||||
|
||||
export function VerdictBadge({ verdict, className, size = "sm" }: VerdictBadgeProps) {
|
||||
const label = verdictLabels[verdict];
|
||||
const color = verdictColors[verdict];
|
||||
const icon = verdictIcons[verdict];
|
||||
|
||||
const sizeClass = size === "xs" ? "text-[10px] px-1.5 py-0.5" : "text-xs px-2.5 py-0.5";
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded font-medium",
|
||||
color,
|
||||
sizeClass,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className={size === "xs" ? "text-[8px]" : "text-[10px]"}>{icon}</span>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -10,6 +10,10 @@ import {
|
||||
type SourceImpactResponse,
|
||||
type QuarantineSourceResponse,
|
||||
type RestoreSourceResponse,
|
||||
type GetPatternsResponse,
|
||||
type ScanRequest,
|
||||
type ScanResponse,
|
||||
type ListScansResponse,
|
||||
} from "./types";
|
||||
|
||||
export class StemeDBClient {
|
||||
@ -162,6 +166,31 @@ export class StemeDBClient {
|
||||
getApiKey(): string | null {
|
||||
return this.apiKey;
|
||||
}
|
||||
|
||||
// Aphoria methods
|
||||
async getPatterns(params: {
|
||||
subjectPrefix?: string;
|
||||
minProjects?: number;
|
||||
limit?: number;
|
||||
} = {}): Promise<GetPatternsResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.subjectPrefix) searchParams.set("subject_prefix", params.subjectPrefix);
|
||||
if (params.minProjects !== undefined) searchParams.set("min_projects", String(params.minProjects));
|
||||
if (params.limit !== undefined) searchParams.set("limit", String(params.limit));
|
||||
const query = searchParams.toString();
|
||||
return this.fetch<GetPatternsResponse>(`/v1/aphoria/patterns${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
async runScan(request: ScanRequest): Promise<ScanResponse> {
|
||||
return this.fetch<ScanResponse>("/v1/aphoria/scan", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
async listScans(): Promise<ListScansResponse> {
|
||||
return this.fetch<ListScansResponse>("/v1/aphoria/scans");
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton client for server components
|
||||
|
||||
@ -244,6 +244,103 @@ export interface RestoreSourceResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Aphoria DTOs
|
||||
// ============================================================================
|
||||
|
||||
export interface PatternDto {
|
||||
subject: string;
|
||||
predicate: string;
|
||||
value: string;
|
||||
project_count: number;
|
||||
observation_count: number;
|
||||
first_seen: number;
|
||||
last_seen: number;
|
||||
}
|
||||
|
||||
export interface GetPatternsResponse {
|
||||
patterns: PatternDto[];
|
||||
total_matching: number;
|
||||
}
|
||||
|
||||
export interface FindingDto {
|
||||
concept_path: string;
|
||||
predicate: string;
|
||||
code_value: string;
|
||||
file: string;
|
||||
line: number;
|
||||
conflict_score: number;
|
||||
verdict: "BLOCK" | "FLAG" | "PASS" | "ACK";
|
||||
conflicts: ConflictingSourceDto[];
|
||||
acknowledgment?: AcknowledgmentDto;
|
||||
trace?: ConflictTraceDto;
|
||||
}
|
||||
|
||||
export interface ConflictingSourceDto {
|
||||
path: string;
|
||||
source_class: string;
|
||||
value: string;
|
||||
citation?: string;
|
||||
policy_source?: PolicySourceDto;
|
||||
}
|
||||
|
||||
export interface PolicySourceDto {
|
||||
pack_name: string;
|
||||
pack_version: string;
|
||||
issuer_hex: string;
|
||||
}
|
||||
|
||||
export interface AcknowledgmentDto {
|
||||
timestamp: string;
|
||||
by: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ConflictTraceDto {
|
||||
code_claim: string;
|
||||
authority_match: string;
|
||||
authority_tier: string;
|
||||
resolution: string;
|
||||
}
|
||||
|
||||
export interface ScanSummaryDto {
|
||||
total: number;
|
||||
blocked: number;
|
||||
flagged: number;
|
||||
passed: number;
|
||||
acknowledged: number;
|
||||
}
|
||||
|
||||
export interface ScanResponse {
|
||||
project: string;
|
||||
scan_id: string;
|
||||
files_scanned: number;
|
||||
claims_extracted: number;
|
||||
findings: FindingDto[];
|
||||
summary: ScanSummaryDto;
|
||||
}
|
||||
|
||||
export interface ScanRequest {
|
||||
target_path: string;
|
||||
format?: string;
|
||||
fail_on_flag?: boolean;
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
export interface ScanListItem {
|
||||
scan_id: string;
|
||||
project: string;
|
||||
files_scanned: number;
|
||||
claims_extracted: number;
|
||||
summary: ScanSummaryDto;
|
||||
timestamp: number;
|
||||
findings: FindingDto[];
|
||||
}
|
||||
|
||||
export interface ListScansResponse {
|
||||
scans: ScanListItem[];
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
public userMessage: string;
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
/**
|
||||
* Format a timestamp as a relative time string (e.g., "2h ago", "5m ago")
|
||||
* @param timestamp - Timestamp in milliseconds
|
||||
*/
|
||||
export function formatTimeAgo(timestamp: number): string {
|
||||
const now = Date.now();
|
||||
@ -16,6 +17,53 @@ export function formatTimeAgo(timestamp: number): string {
|
||||
return "just now";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Unix timestamp (seconds) as a relative time string.
|
||||
* Used for API responses that return Unix timestamps.
|
||||
* @param timestamp - Unix timestamp in seconds
|
||||
*/
|
||||
export function formatRelativeTime(timestamp: number): string {
|
||||
const now = Date.now() / 1000;
|
||||
const diff = now - timestamp;
|
||||
|
||||
if (diff < 60) return "just now";
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
|
||||
return formatUnixTimestamp(timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Unix timestamp (seconds) as a date string.
|
||||
* @param timestamp - Unix timestamp in seconds
|
||||
* @param options - Intl.DateTimeFormatOptions for customization
|
||||
*/
|
||||
export function formatUnixTimestamp(
|
||||
timestamp: number,
|
||||
options?: Intl.DateTimeFormatOptions
|
||||
): string {
|
||||
const defaultOptions: Intl.DateTimeFormatOptions = {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
};
|
||||
return new Date(timestamp * 1000).toLocaleDateString("en-US", options ?? defaultOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Unix timestamp (seconds) as a date-time string.
|
||||
* @param timestamp - Unix timestamp in seconds
|
||||
*/
|
||||
export function formatUnixDateTime(timestamp: number): string {
|
||||
return new Date(timestamp * 1000).toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a timestamp as time (HH:MM)
|
||||
*/
|
||||
|
||||
@ -8,7 +8,7 @@ description = "HTTP API for Episteme (StemeDB)"
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
default = ["aphoria"]
|
||||
aphoria = ["dep:aphoria"]
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -144,3 +144,39 @@ pub struct GetPatternsResponse {
|
||||
/// Total number of patterns matching (before limit applied).
|
||||
pub total_matching: usize,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Scan History Endpoint DTOs
|
||||
// ============================================================================
|
||||
|
||||
/// A single scan from the scan history.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ScanListItem {
|
||||
/// Unique scan ID.
|
||||
pub scan_id: String,
|
||||
|
||||
/// Project name.
|
||||
pub project: String,
|
||||
|
||||
/// Number of files scanned.
|
||||
pub files_scanned: usize,
|
||||
|
||||
/// Number of claims extracted.
|
||||
pub claims_extracted: usize,
|
||||
|
||||
/// Summary counts by verdict.
|
||||
pub summary: ScanSummaryDto,
|
||||
|
||||
/// Unix timestamp of when the scan was performed.
|
||||
pub timestamp: u64,
|
||||
|
||||
/// Findings (conflicts detected).
|
||||
pub findings: Vec<FindingDto>,
|
||||
}
|
||||
|
||||
/// Response containing recent scan history.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ListScansResponse {
|
||||
/// Recent scans, newest first.
|
||||
pub scans: Vec<ScanListItem>,
|
||||
}
|
||||
|
||||
@ -13,4 +13,4 @@ pub(crate) mod scan;
|
||||
// Re-export all public handlers to preserve API
|
||||
pub use policy::{bless, export_policy, import_policy};
|
||||
pub use report::{get_patterns, push_community_observations, push_observations};
|
||||
pub use scan::scan;
|
||||
pub use scan::{list_scans, scan};
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
//! Project scanning handlers for conflict detection.
|
||||
|
||||
use axum::{http::StatusCode, Json};
|
||||
use axum::{extract::State, http::StatusCode, Json};
|
||||
use std::path::PathBuf;
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::{
|
||||
dto::aphoria::{FindingDto, ScanRequest, ScanResponse, ScanSummaryDto},
|
||||
dto::aphoria::{FindingDto, ListScansResponse, ScanRequest, ScanResponse, ScanSummaryDto},
|
||||
error::{ApiError, Result},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
use super::super::aphoria_helpers::conflict_result_to_dto;
|
||||
@ -30,7 +31,10 @@ use super::super::aphoria_helpers::conflict_result_to_dto;
|
||||
tag = "aphoria"
|
||||
)]
|
||||
#[instrument(skip_all, fields(target_path = %req.target_path, format = %req.format))]
|
||||
pub async fn scan(Json(req): Json<ScanRequest>) -> Result<(StatusCode, Json<ScanResponse>)> {
|
||||
pub async fn scan(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ScanRequest>,
|
||||
) -> Result<(StatusCode, Json<ScanResponse>)> {
|
||||
let target_path = PathBuf::from(&req.target_path);
|
||||
|
||||
// Check path exists
|
||||
@ -87,6 +91,9 @@ pub async fn scan(Json(req): Json<ScanRequest>) -> Result<(StatusCode, Json<Scan
|
||||
summary,
|
||||
};
|
||||
|
||||
// Cache the scan result
|
||||
state.scan_cache.record(&response).await;
|
||||
|
||||
// Return 422 if fail_on_flag is true and there are blocks
|
||||
let status = if req.fail_on_flag && has_blocks {
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
@ -96,3 +103,21 @@ pub async fn scan(Json(req): Json<ScanRequest>) -> Result<(StatusCode, Json<Scan
|
||||
|
||||
Ok((status, Json(response)))
|
||||
}
|
||||
|
||||
/// List recent scan results from cache.
|
||||
///
|
||||
/// Returns the last 100 scans, newest first.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/aphoria/scans",
|
||||
responses(
|
||||
(status = 200, description = "Scan history retrieved successfully", body = ListScansResponse),
|
||||
(status = 500, description = "Internal server error", body = crate::dto::ErrorResponse),
|
||||
),
|
||||
tag = "aphoria"
|
||||
)]
|
||||
#[instrument(skip_all)]
|
||||
pub async fn list_scans(State(state): State<AppState>) -> Result<Json<ListScansResponse>> {
|
||||
let response = state.scan_cache.list().await;
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
@ -77,6 +77,6 @@ pub use metrics::metrics_handler;
|
||||
|
||||
#[cfg(feature = "aphoria")]
|
||||
pub use aphoria::{
|
||||
bless, export_policy, get_patterns, import_policy, push_community_observations,
|
||||
bless, export_policy, get_patterns, import_policy, list_scans, push_community_observations,
|
||||
push_observations, scan,
|
||||
};
|
||||
|
||||
@ -36,6 +36,8 @@ pub mod handlers;
|
||||
pub mod hex;
|
||||
pub mod middleware;
|
||||
mod routers;
|
||||
#[cfg(feature = "aphoria")]
|
||||
pub mod scan_cache;
|
||||
pub mod services;
|
||||
pub mod state;
|
||||
|
||||
|
||||
@ -379,6 +379,7 @@ fn build_api_routes() -> Router<AppState> {
|
||||
.route("/v1/aphoria/policy/export", post(handlers::export_policy))
|
||||
.route("/v1/aphoria/policy/import", post(handlers::import_policy))
|
||||
.route("/v1/aphoria/scan", post(handlers::scan))
|
||||
.route("/v1/aphoria/scans", get(handlers::list_scans))
|
||||
.route("/v1/aphoria/observations", post(handlers::push_observations))
|
||||
// Community corpus endpoints
|
||||
.route(
|
||||
|
||||
138
crates/stemedb-api/src/scan_cache.rs
Normal file
138
crates/stemedb-api/src/scan_cache.rs
Normal file
@ -0,0 +1,138 @@
|
||||
//! In-memory scan result cache for the Aphoria scan history endpoint.
|
||||
//!
|
||||
//! Caches the last N scan results to provide a scan history API.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::dto::aphoria::{ListScansResponse, ScanListItem, ScanResponse, ScanSummaryDto};
|
||||
|
||||
/// Maximum number of scans to cache.
|
||||
const MAX_CACHED_SCANS: usize = 100;
|
||||
|
||||
/// Thread-safe scan cache that stores recent scan results.
|
||||
#[derive(Clone)]
|
||||
pub struct ScanCache {
|
||||
inner: Arc<RwLock<ScanCacheInner>>,
|
||||
}
|
||||
|
||||
struct ScanCacheInner {
|
||||
scans: VecDeque<ScanListItem>,
|
||||
}
|
||||
|
||||
impl Default for ScanCache {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ScanCache {
|
||||
/// Create a new empty scan cache.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(ScanCacheInner {
|
||||
scans: VecDeque::with_capacity(MAX_CACHED_SCANS),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a new scan result.
|
||||
///
|
||||
/// Adds the scan to the front of the cache, removing the oldest
|
||||
/// scan if we exceed MAX_CACHED_SCANS.
|
||||
pub async fn record(&self, response: &ScanResponse) {
|
||||
let timestamp =
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
|
||||
let item = ScanListItem {
|
||||
scan_id: response.scan_id.clone(),
|
||||
project: response.project.clone(),
|
||||
files_scanned: response.files_scanned,
|
||||
claims_extracted: response.claims_extracted,
|
||||
summary: ScanSummaryDto {
|
||||
total: response.summary.total,
|
||||
blocked: response.summary.blocked,
|
||||
flagged: response.summary.flagged,
|
||||
passed: response.summary.passed,
|
||||
acknowledged: response.summary.acknowledged,
|
||||
},
|
||||
timestamp,
|
||||
findings: response.findings.clone(),
|
||||
};
|
||||
|
||||
let mut inner = self.inner.write().await;
|
||||
inner.scans.push_front(item);
|
||||
if inner.scans.len() > MAX_CACHED_SCANS {
|
||||
inner.scans.pop_back();
|
||||
}
|
||||
}
|
||||
|
||||
/// List all cached scans, newest first.
|
||||
pub async fn list(&self) -> ListScansResponse {
|
||||
let inner = self.inner.read().await;
|
||||
ListScansResponse { scans: inner.scans.iter().cloned().collect() }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dto::aphoria::ScanSummaryDto;
|
||||
|
||||
fn make_scan(id: &str) -> ScanResponse {
|
||||
ScanResponse {
|
||||
project: "test-project".to_string(),
|
||||
scan_id: id.to_string(),
|
||||
files_scanned: 10,
|
||||
claims_extracted: 5,
|
||||
findings: vec![],
|
||||
summary: ScanSummaryDto {
|
||||
total: 0,
|
||||
blocked: 0,
|
||||
flagged: 0,
|
||||
passed: 0,
|
||||
acknowledged: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_record_and_list() {
|
||||
let cache = ScanCache::new();
|
||||
|
||||
cache.record(&make_scan("scan-1")).await;
|
||||
cache.record(&make_scan("scan-2")).await;
|
||||
|
||||
let list = cache.list().await;
|
||||
assert_eq!(list.scans.len(), 2);
|
||||
assert_eq!(list.scans[0].scan_id, "scan-2");
|
||||
assert_eq!(list.scans[1].scan_id, "scan-1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cache_eviction() {
|
||||
let cache = ScanCache::new();
|
||||
|
||||
// Fill cache beyond limit (MAX_CACHED_SCANS = 100)
|
||||
for i in 0..105 {
|
||||
cache.record(&make_scan(&format!("scan-{}", i))).await;
|
||||
}
|
||||
|
||||
let list = cache.list().await;
|
||||
// Should be capped at MAX_CACHED_SCANS
|
||||
assert_eq!(list.scans.len(), MAX_CACHED_SCANS);
|
||||
// Newest first
|
||||
assert_eq!(list.scans[0].scan_id, "scan-104");
|
||||
// Oldest retained (first 5 were evicted: scan-0 through scan-4)
|
||||
assert_eq!(list.scans[99].scan_id, "scan-5");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_empty_cache() {
|
||||
let cache = ScanCache::new();
|
||||
let list = cache.list().await;
|
||||
assert!(list.scans.is_empty());
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,9 @@ use stemedb_storage::{
|
||||
use stemedb_wal::group_commit::{GroupCommitBuffer, GroupCommitConfig};
|
||||
use stemedb_wal::Journal;
|
||||
|
||||
#[cfg(feature = "aphoria")]
|
||||
use crate::scan_cache::ScanCache;
|
||||
|
||||
/// Quota store type alias for convenience.
|
||||
pub type QuotaStoreImpl = GenericQuotaStore<Arc<HybridStore>>;
|
||||
|
||||
@ -79,6 +82,10 @@ pub struct AppState {
|
||||
/// When GroupCommitBuffer successfully flushes a batch, it signals this
|
||||
/// Notify so IngestWorker can immediately refresh and process new records.
|
||||
pub flush_notify: Arc<Notify>,
|
||||
|
||||
/// Scan cache for Aphoria scan history (feature-gated).
|
||||
#[cfg(feature = "aphoria")]
|
||||
pub scan_cache: ScanCache,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@ -141,6 +148,8 @@ impl AppState {
|
||||
circuit_breaker_store,
|
||||
api_key_store,
|
||||
flush_notify,
|
||||
#[cfg(feature = "aphoria")]
|
||||
scan_cache: ScanCache::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user