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 is troubleshooting installation or runtime issues
|
||||||
- User needs to verify their installation is working
|
- 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
|
## Principles
|
||||||
|
|
||||||
### 1. User Space First
|
### 1. User Space First
|
||||||
@ -25,8 +35,8 @@ Aphoria standalone needs only Rust. StemeDB server is optional for solo develope
|
|||||||
### 3. Fast Verification
|
### 3. Fast Verification
|
||||||
Every installation step has an immediate verification command.
|
Every installation step has an immediate verification command.
|
||||||
|
|
||||||
### 4. Ephemeral by Default
|
### 4. Hooks Are Mandatory
|
||||||
Default scans are fast and ephemeral. Server/persistence is opt-in.
|
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
|
### 5. Progressive Disclosure
|
||||||
Start with minimal setup, add complexity only when needed.
|
Start with minimal setup, add complexity only when needed.
|
||||||
@ -215,19 +225,64 @@ curl http://localhost:18180/health
|
|||||||
# Expected: {"status":"ok"}
|
# 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:
|
Connect Aphoria to StemeDB server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# In project directory
|
# Add to aphoria.toml
|
||||||
cat > aphoria.toml << 'EOF'
|
|
||||||
[project]
|
|
||||||
name = "my-project"
|
|
||||||
|
|
||||||
[hosted]
|
[hosted]
|
||||||
url = "http://localhost:18180"
|
url = "http://localhost:18180"
|
||||||
EOF
|
|
||||||
|
|
||||||
# Test connection
|
# Test connection
|
||||||
aphoria scan --persist --sync
|
aphoria scan --persist --sync
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -23,6 +23,9 @@ Thumbs.db
|
|||||||
credentials.json
|
credentials.json
|
||||||
service-account*.json
|
service-account*.json
|
||||||
|
|
||||||
|
# Aphoria project data (contains keys)
|
||||||
|
.aphoria/
|
||||||
|
|
||||||
# Python virtual environments
|
# Python virtual environments
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
|
|||||||
@ -61,7 +61,7 @@ dirs = "5.0"
|
|||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = "0.3"
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
# rkyv for zero-copy (consistent with stemedb)
|
# rkyv for zero-copy (consistent with stemedb)
|
||||||
rkyv = { version = "0.7", features = ["validation"] }
|
rkyv = { version = "0.7", features = ["validation"] }
|
||||||
|
|||||||
@ -36,6 +36,10 @@ pub struct Cli {
|
|||||||
#[arg(short, long, global = true)]
|
#[arg(short, long, global = true)]
|
||||||
pub config: Option<PathBuf>,
|
pub config: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Enable verbose logging (shows internal tracing output)
|
||||||
|
#[arg(short, long, global = true)]
|
||||||
|
pub verbose: bool,
|
||||||
|
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
pub command: Commands,
|
pub command: Commands,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -159,6 +159,8 @@ impl AuditTrail {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get all audit events.
|
/// Get all audit events.
|
||||||
|
///
|
||||||
|
/// Resilient parser that skips malformed lines rather than failing.
|
||||||
pub fn get_all_events(&self) -> Result<Vec<AuditEvent>, AphoriaError> {
|
pub fn get_all_events(&self) -> Result<Vec<AuditEvent>, AphoriaError> {
|
||||||
let path = self.events_path();
|
let path = self.events_path();
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
@ -170,17 +172,53 @@ impl AuditTrail {
|
|||||||
let reader = BufReader::new(file);
|
let reader = BufReader::new(file);
|
||||||
|
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
|
let mut skipped = 0;
|
||||||
|
|
||||||
for line in reader.lines() {
|
for line in reader.lines() {
|
||||||
let line = line
|
let line = match line {
|
||||||
.map_err(|e| AphoriaError::Storage(format!("Failed to read audit line: {}", e)))?;
|
Ok(l) => l,
|
||||||
|
Err(_) => {
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if line.trim().is_empty() {
|
if line.trim().is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let event: AuditEvent = serde_json::from_str(&line)
|
// Try to parse each JSON object on the line (handles concatenated objects)
|
||||||
.map_err(|e| AphoriaError::Storage(format!("Failed to parse event: {}", e)))?;
|
let mut remaining = line.trim();
|
||||||
events.push(event);
|
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)
|
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("Aphoria status:\n");
|
||||||
output.push_str(&format!(" Data directory: {}\\n", data_dir.display()));
|
output.push_str(&format!(" Data directory: {}\n", data_dir.display()));
|
||||||
output.push_str(&format!(" Project root: {}\\n", project_root.display()));
|
output.push_str(&format!(" Project root: {}\n", project_root.display()));
|
||||||
|
|
||||||
if aphoria_dir.join("baseline").exists() {
|
if aphoria_dir.join("baseline").exists() {
|
||||||
let baseline = std::fs::read_to_string(aphoria_dir.join("baseline"))?;
|
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 {
|
} else {
|
||||||
output.push_str(" Baseline: none\n");
|
output.push_str(" Baseline: none\n");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
use aphoria::AphoriaConfig;
|
use aphoria::AphoriaConfig;
|
||||||
|
|
||||||
@ -16,11 +17,16 @@ use cli::Cli;
|
|||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> ExitCode {
|
async fn main() -> ExitCode {
|
||||||
// Initialize tracing for internal logging
|
|
||||||
tracing_subscriber::fmt::init();
|
|
||||||
|
|
||||||
let cli = Cli::parse();
|
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
|
// Load configuration
|
||||||
let config = match load_config(cli.config.as_deref()) {
|
let config = match load_config(cli.config.as_deref()) {
|
||||||
Ok(cfg) => cfg,
|
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,
|
Menu,
|
||||||
X,
|
X,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
Scan,
|
||||||
|
Library,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@ -23,6 +25,8 @@ const navigation = [
|
|||||||
{ name: "Quarantine", href: "/quarantine", icon: ShieldAlert },
|
{ name: "Quarantine", href: "/quarantine", icon: ShieldAlert },
|
||||||
{ name: "Circuit Breakers", href: "/circuit", icon: Zap },
|
{ name: "Circuit Breakers", href: "/circuit", icon: Zap },
|
||||||
{ name: "Audit Trail", href: "/audit", icon: FileText },
|
{ name: "Audit Trail", href: "/audit", icon: FileText },
|
||||||
|
{ name: "Corpus", href: "/corpus", icon: Library },
|
||||||
|
{ name: "Scans", href: "/scans", icon: Scan },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar() {
|
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 SourceImpactResponse,
|
||||||
type QuarantineSourceResponse,
|
type QuarantineSourceResponse,
|
||||||
type RestoreSourceResponse,
|
type RestoreSourceResponse,
|
||||||
|
type GetPatternsResponse,
|
||||||
|
type ScanRequest,
|
||||||
|
type ScanResponse,
|
||||||
|
type ListScansResponse,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export class StemeDBClient {
|
export class StemeDBClient {
|
||||||
@ -162,6 +166,31 @@ export class StemeDBClient {
|
|||||||
getApiKey(): string | null {
|
getApiKey(): string | null {
|
||||||
return this.apiKey;
|
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
|
// Singleton client for server components
|
||||||
|
|||||||
@ -244,6 +244,103 @@ export interface RestoreSourceResponse {
|
|||||||
message: string;
|
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 {
|
export class ApiError extends Error {
|
||||||
public userMessage: string;
|
public userMessage: string;
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a timestamp as a relative time string (e.g., "2h ago", "5m ago")
|
* 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 {
|
export function formatTimeAgo(timestamp: number): string {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@ -16,6 +17,53 @@ export function formatTimeAgo(timestamp: number): string {
|
|||||||
return "just now";
|
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)
|
* Format a timestamp as time (HH:MM)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -8,7 +8,7 @@ description = "HTTP API for Episteme (StemeDB)"
|
|||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = ["aphoria"]
|
||||||
aphoria = ["dep:aphoria"]
|
aphoria = ["dep:aphoria"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -144,3 +144,39 @@ pub struct GetPatternsResponse {
|
|||||||
/// Total number of patterns matching (before limit applied).
|
/// Total number of patterns matching (before limit applied).
|
||||||
pub total_matching: usize,
|
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
|
// Re-export all public handlers to preserve API
|
||||||
pub use policy::{bless, export_policy, import_policy};
|
pub use policy::{bless, export_policy, import_policy};
|
||||||
pub use report::{get_patterns, push_community_observations, push_observations};
|
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.
|
//! Project scanning handlers for conflict detection.
|
||||||
|
|
||||||
use axum::{http::StatusCode, Json};
|
use axum::{extract::State, http::StatusCode, Json};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tracing::instrument;
|
use tracing::instrument;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
dto::aphoria::{FindingDto, ScanRequest, ScanResponse, ScanSummaryDto},
|
dto::aphoria::{FindingDto, ListScansResponse, ScanRequest, ScanResponse, ScanSummaryDto},
|
||||||
error::{ApiError, Result},
|
error::{ApiError, Result},
|
||||||
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::super::aphoria_helpers::conflict_result_to_dto;
|
use super::super::aphoria_helpers::conflict_result_to_dto;
|
||||||
@ -30,7 +31,10 @@ use super::super::aphoria_helpers::conflict_result_to_dto;
|
|||||||
tag = "aphoria"
|
tag = "aphoria"
|
||||||
)]
|
)]
|
||||||
#[instrument(skip_all, fields(target_path = %req.target_path, format = %req.format))]
|
#[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);
|
let target_path = PathBuf::from(&req.target_path);
|
||||||
|
|
||||||
// Check path exists
|
// Check path exists
|
||||||
@ -87,6 +91,9 @@ pub async fn scan(Json(req): Json<ScanRequest>) -> Result<(StatusCode, Json<Scan
|
|||||||
summary,
|
summary,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Cache the scan result
|
||||||
|
state.scan_cache.record(&response).await;
|
||||||
|
|
||||||
// Return 422 if fail_on_flag is true and there are blocks
|
// Return 422 if fail_on_flag is true and there are blocks
|
||||||
let status = if req.fail_on_flag && has_blocks {
|
let status = if req.fail_on_flag && has_blocks {
|
||||||
StatusCode::UNPROCESSABLE_ENTITY
|
StatusCode::UNPROCESSABLE_ENTITY
|
||||||
@ -96,3 +103,21 @@ pub async fn scan(Json(req): Json<ScanRequest>) -> Result<(StatusCode, Json<Scan
|
|||||||
|
|
||||||
Ok((status, Json(response)))
|
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")]
|
#[cfg(feature = "aphoria")]
|
||||||
pub use 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,
|
push_observations, scan,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -36,6 +36,8 @@ pub mod handlers;
|
|||||||
pub mod hex;
|
pub mod hex;
|
||||||
pub mod middleware;
|
pub mod middleware;
|
||||||
mod routers;
|
mod routers;
|
||||||
|
#[cfg(feature = "aphoria")]
|
||||||
|
pub mod scan_cache;
|
||||||
pub mod services;
|
pub mod services;
|
||||||
pub mod state;
|
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/export", post(handlers::export_policy))
|
||||||
.route("/v1/aphoria/policy/import", post(handlers::import_policy))
|
.route("/v1/aphoria/policy/import", post(handlers::import_policy))
|
||||||
.route("/v1/aphoria/scan", post(handlers::scan))
|
.route("/v1/aphoria/scan", post(handlers::scan))
|
||||||
|
.route("/v1/aphoria/scans", get(handlers::list_scans))
|
||||||
.route("/v1/aphoria/observations", post(handlers::push_observations))
|
.route("/v1/aphoria/observations", post(handlers::push_observations))
|
||||||
// Community corpus endpoints
|
// Community corpus endpoints
|
||||||
.route(
|
.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::group_commit::{GroupCommitBuffer, GroupCommitConfig};
|
||||||
use stemedb_wal::Journal;
|
use stemedb_wal::Journal;
|
||||||
|
|
||||||
|
#[cfg(feature = "aphoria")]
|
||||||
|
use crate::scan_cache::ScanCache;
|
||||||
|
|
||||||
/// Quota store type alias for convenience.
|
/// Quota store type alias for convenience.
|
||||||
pub type QuotaStoreImpl = GenericQuotaStore<Arc<HybridStore>>;
|
pub type QuotaStoreImpl = GenericQuotaStore<Arc<HybridStore>>;
|
||||||
|
|
||||||
@ -79,6 +82,10 @@ pub struct AppState {
|
|||||||
/// When GroupCommitBuffer successfully flushes a batch, it signals this
|
/// When GroupCommitBuffer successfully flushes a batch, it signals this
|
||||||
/// Notify so IngestWorker can immediately refresh and process new records.
|
/// Notify so IngestWorker can immediately refresh and process new records.
|
||||||
pub flush_notify: Arc<Notify>,
|
pub flush_notify: Arc<Notify>,
|
||||||
|
|
||||||
|
/// Scan cache for Aphoria scan history (feature-gated).
|
||||||
|
#[cfg(feature = "aphoria")]
|
||||||
|
pub scan_cache: ScanCache,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@ -141,6 +148,8 @@ impl AppState {
|
|||||||
circuit_breaker_store,
|
circuit_breaker_store,
|
||||||
api_key_store,
|
api_key_store,
|
||||||
flush_notify,
|
flush_notify,
|
||||||
|
#[cfg(feature = "aphoria")]
|
||||||
|
scan_cache: ScanCache::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user