feat: implement code quality dashboard with architecture violation tracking

Implement comprehensive code quality dashboard (Phase 2-4) to track and manage
architecture violations found by the validation script.

Backend Implementation:
- Add JSON output support to validate_architecture.py script
- Create CodeQualityService with scan management, violation tracking, and statistics
- Implement REST API endpoints for code quality management:
  * POST /admin/code-quality/scan - trigger new architecture scan
  * GET /admin/code-quality/scans - list scan history
  * GET /admin/code-quality/violations - list violations with filtering/pagination
  * GET /admin/code-quality/violations/{id} - get violation details
  * POST /admin/code-quality/violations/{id}/assign - assign to developer
  * POST /admin/code-quality/violations/{id}/resolve - mark as resolved
  * POST /admin/code-quality/violations/{id}/ignore - mark as ignored
  * POST /admin/code-quality/violations/{id}/comments - add comments
  * GET /admin/code-quality/stats - dashboard statistics
- Fix architecture_scan model imports to use app.core.database

Frontend Implementation:
- Create code quality dashboard page (code-quality-dashboard.html)
  * Summary cards for total violations, errors, warnings, health score
  * Status breakdown (open, assigned, resolved, ignored)
  * Trend visualization for last 7 scans
  * Top violating files list
  * Violations by rule and module
  * Quick action links
- Create violations list page (code-quality-violations.html)
  * Filterable table by severity, status, rule ID, file path
  * Pagination support
  * Violation detail view links
- Add Alpine.js components (code-quality-dashboard.js, code-quality-violations.js)
  * Dashboard state management and scan triggering
  * Violations list with filtering and pagination
  * API integration with authentication
- Add "Code Quality" navigation link in admin sidebar (Developer Tools section)

Routes:
- GET /admin/code-quality - dashboard page
- GET /admin/code-quality/violations - violations list
- GET /admin/code-quality/violations/{id} - violation details

Features:
- Real-time scan execution from UI
- Technical debt score calculation (0-100 scale)
- Violation workflow: open → assigned → resolved/ignored
- Trend tracking across multiple scans
- File and module-level insights
- Assignment system with priorities and due dates
- Collaborative comments on violations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-28 09:40:14 +01:00
parent 74bf2367f8
commit 9db0da25ec
11 changed files with 1854 additions and 4 deletions

View File

@@ -13,6 +13,7 @@ This module combines all admin-related JSON API endpoints:
- Audit logging
- Platform settings
- Notifications and alerts
- Code quality and architecture validation
IMPORTANT:
- This router is for JSON API endpoints only
@@ -35,7 +36,8 @@ from . import (
audit,
settings,
notifications,
content_pages
content_pages,
code_quality
)
# Create admin router
@@ -107,5 +109,13 @@ router.include_router(settings.router, tags=["admin-settings"])
# Include notifications and alerts endpoints
router.include_router(notifications.router, tags=["admin-notifications"])
# ============================================================================
# Code Quality & Architecture
# ============================================================================
# Include code quality and architecture validation endpoints
router.include_router(code_quality.router, prefix="/code-quality", tags=["admin-code-quality"])
# Export the router
__all__ = ["router"]

View File

@@ -0,0 +1,448 @@
"""
Code Quality API Endpoints
RESTful API for architecture validation and violation management
"""
from typing import Optional
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from pydantic import BaseModel, Field
from app.core.database import get_db
from app.services.code_quality_service import code_quality_service
from app.api.deps import get_current_admin_api
from models.database.user import User
router = APIRouter()
# Pydantic Models for API
class ScanResponse(BaseModel):
"""Response model for a scan"""
id: int
timestamp: str
total_files: int
total_violations: int
errors: int
warnings: int
duration_seconds: float
triggered_by: str
git_commit_hash: Optional[str]
class Config:
from_attributes = True
class ViolationResponse(BaseModel):
"""Response model for a violation"""
id: int
scan_id: int
rule_id: str
rule_name: str
severity: str
file_path: str
line_number: int
message: str
context: Optional[str]
suggestion: Optional[str]
status: str
assigned_to: Optional[int]
resolved_at: Optional[str]
resolved_by: Optional[int]
resolution_note: Optional[str]
created_at: str
class Config:
from_attributes = True
class ViolationListResponse(BaseModel):
"""Response model for paginated violations list"""
violations: list[ViolationResponse]
total: int
page: int
page_size: int
total_pages: int
class ViolationDetailResponse(ViolationResponse):
"""Response model for single violation with relationships"""
assignments: list = []
comments: list = []
class AssignViolationRequest(BaseModel):
"""Request model for assigning a violation"""
user_id: int = Field(..., description="User ID to assign to")
due_date: Optional[datetime] = Field(None, description="Due date for resolution")
priority: str = Field("medium", description="Priority level (low, medium, high, critical)")
class ResolveViolationRequest(BaseModel):
"""Request model for resolving a violation"""
resolution_note: str = Field(..., description="Note about the resolution")
class IgnoreViolationRequest(BaseModel):
"""Request model for ignoring a violation"""
reason: str = Field(..., description="Reason for ignoring")
class AddCommentRequest(BaseModel):
"""Request model for adding a comment"""
comment: str = Field(..., min_length=1, description="Comment text")
class DashboardStatsResponse(BaseModel):
"""Response model for dashboard statistics"""
total_violations: int
errors: int
warnings: int
open: int
assigned: int
resolved: int
ignored: int
technical_debt_score: int
trend: list
by_severity: dict
by_rule: dict
by_module: dict
top_files: list
last_scan: Optional[str]
# API Endpoints
@router.post("/scan", response_model=ScanResponse)
async def trigger_scan(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_api)
):
"""
Trigger a new architecture scan
Requires authentication. Runs the validator script and stores results.
"""
try:
scan = code_quality_service.run_scan(db, triggered_by=f"manual:{current_user.username}")
return ScanResponse(
id=scan.id,
timestamp=scan.timestamp.isoformat(),
total_files=scan.total_files,
total_violations=scan.total_violations,
errors=scan.errors,
warnings=scan.warnings,
duration_seconds=scan.duration_seconds,
triggered_by=scan.triggered_by,
git_commit_hash=scan.git_commit_hash
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Scan failed: {str(e)}")
@router.get("/scans", response_model=list[ScanResponse])
async def list_scans(
limit: int = Query(30, ge=1, le=100, description="Number of scans to return"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_api)
):
"""
Get scan history
Returns recent scans for trend analysis.
"""
scans = code_quality_service.get_scan_history(db, limit=limit)
return [
ScanResponse(
id=scan.id,
timestamp=scan.timestamp.isoformat(),
total_files=scan.total_files,
total_violations=scan.total_violations,
errors=scan.errors,
warnings=scan.warnings,
duration_seconds=scan.duration_seconds,
triggered_by=scan.triggered_by,
git_commit_hash=scan.git_commit_hash
)
for scan in scans
]
@router.get("/violations", response_model=ViolationListResponse)
async def list_violations(
scan_id: Optional[int] = Query(None, description="Filter by scan ID (defaults to latest)"),
severity: Optional[str] = Query(None, description="Filter by severity (error, warning)"),
status: Optional[str] = Query(None, description="Filter by status (open, assigned, resolved, ignored)"),
rule_id: Optional[str] = Query(None, description="Filter by rule ID"),
file_path: Optional[str] = Query(None, description="Filter by file path (partial match)"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(50, ge=1, le=200, description="Items per page"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_api)
):
"""
Get violations with filtering and pagination
Returns violations from latest scan by default.
"""
offset = (page - 1) * page_size
violations, total = code_quality_service.get_violations(
db,
scan_id=scan_id,
severity=severity,
status=status,
rule_id=rule_id,
file_path=file_path,
limit=page_size,
offset=offset
)
total_pages = (total + page_size - 1) // page_size
return ViolationListResponse(
violations=[
ViolationResponse(
id=v.id,
scan_id=v.scan_id,
rule_id=v.rule_id,
rule_name=v.rule_name,
severity=v.severity,
file_path=v.file_path,
line_number=v.line_number,
message=v.message,
context=v.context,
suggestion=v.suggestion,
status=v.status,
assigned_to=v.assigned_to,
resolved_at=v.resolved_at.isoformat() if v.resolved_at else None,
resolved_by=v.resolved_by,
resolution_note=v.resolution_note,
created_at=v.created_at.isoformat()
)
for v in violations
],
total=total,
page=page,
page_size=page_size,
total_pages=total_pages
)
@router.get("/violations/{violation_id}", response_model=ViolationDetailResponse)
async def get_violation(
violation_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_api)
):
"""
Get single violation with details
Includes assignments and comments.
"""
violation = code_quality_service.get_violation_by_id(db, violation_id)
if not violation:
raise HTTPException(status_code=404, detail="Violation not found")
# Format assignments
assignments = [
{
'id': a.id,
'user_id': a.user_id,
'assigned_at': a.assigned_at.isoformat(),
'assigned_by': a.assigned_by,
'due_date': a.due_date.isoformat() if a.due_date else None,
'priority': a.priority
}
for a in violation.assignments
]
# Format comments
comments = [
{
'id': c.id,
'user_id': c.user_id,
'comment': c.comment,
'created_at': c.created_at.isoformat()
}
for c in violation.comments
]
return ViolationDetailResponse(
id=violation.id,
scan_id=violation.scan_id,
rule_id=violation.rule_id,
rule_name=violation.rule_name,
severity=violation.severity,
file_path=violation.file_path,
line_number=violation.line_number,
message=violation.message,
context=violation.context,
suggestion=violation.suggestion,
status=violation.status,
assigned_to=violation.assigned_to,
resolved_at=violation.resolved_at.isoformat() if violation.resolved_at else None,
resolved_by=violation.resolved_by,
resolution_note=violation.resolution_note,
created_at=violation.created_at.isoformat(),
assignments=assignments,
comments=comments
)
@router.post("/violations/{violation_id}/assign")
async def assign_violation(
violation_id: int,
request: AssignViolationRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_api)
):
"""
Assign violation to a developer
Updates violation status to 'assigned'.
"""
try:
assignment = code_quality_service.assign_violation(
db,
violation_id=violation_id,
user_id=request.user_id,
assigned_by=current_user.id,
due_date=request.due_date,
priority=request.priority
)
return {
'id': assignment.id,
'violation_id': assignment.violation_id,
'user_id': assignment.user_id,
'assigned_at': assignment.assigned_at.isoformat(),
'assigned_by': assignment.assigned_by,
'due_date': assignment.due_date.isoformat() if assignment.due_date else None,
'priority': assignment.priority
}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/violations/{violation_id}/resolve")
async def resolve_violation(
violation_id: int,
request: ResolveViolationRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_api)
):
"""
Mark violation as resolved
Records resolution timestamp and user.
"""
try:
violation = code_quality_service.resolve_violation(
db,
violation_id=violation_id,
resolved_by=current_user.id,
resolution_note=request.resolution_note
)
return {
'id': violation.id,
'status': violation.status,
'resolved_at': violation.resolved_at.isoformat() if violation.resolved_at else None,
'resolved_by': violation.resolved_by,
'resolution_note': violation.resolution_note
}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/violations/{violation_id}/ignore")
async def ignore_violation(
violation_id: int,
request: IgnoreViolationRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_api)
):
"""
Mark violation as ignored (won't fix)
Records reason for ignoring.
"""
try:
violation = code_quality_service.ignore_violation(
db,
violation_id=violation_id,
ignored_by=current_user.id,
reason=request.reason
)
return {
'id': violation.id,
'status': violation.status,
'resolved_at': violation.resolved_at.isoformat() if violation.resolved_at else None,
'resolved_by': violation.resolved_by,
'resolution_note': violation.resolution_note
}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/violations/{violation_id}/comments")
async def add_comment(
violation_id: int,
request: AddCommentRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_api)
):
"""
Add comment to violation
For team collaboration and discussion.
"""
try:
comment = code_quality_service.add_comment(
db,
violation_id=violation_id,
user_id=current_user.id,
comment=request.comment
)
return {
'id': comment.id,
'violation_id': comment.violation_id,
'user_id': comment.user_id,
'comment': comment.comment,
'created_at': comment.created_at.isoformat()
}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/stats", response_model=DashboardStatsResponse)
async def get_dashboard_stats(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_api)
):
"""
Get dashboard statistics
Returns comprehensive stats for the dashboard including:
- Total counts by severity and status
- Technical debt score
- Trend data (last 7 scans)
- Top violating files
- Violations by rule and module
"""
stats = code_quality_service.get_dashboard_stats(db)
return DashboardStatsResponse(**stats)

View File

@@ -6,7 +6,7 @@ Database models for tracking code quality scans and violations
from sqlalchemy import Column, Integer, String, Float, DateTime, Text, Boolean, ForeignKey, JSON
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
from app.core.database import Base
class ArchitectureScan(Base):

View File

@@ -25,6 +25,9 @@ Routes:
- GET /content-pages → Content pages list (auth required)
- GET /content-pages/create → Create content page (auth required)
- GET /content-pages/{page_id}/edit → Edit content page (auth required)
- GET /code-quality → Code quality dashboard (auth required)
- GET /code-quality/violations → Violations list (auth required)
- GET /code-quality/violations/{violation_id} → Violation details (auth required)
"""
from fastapi import APIRouter, Request, Depends, Path
@@ -490,3 +493,66 @@ async def admin_test_vendors_users_migration(
"user": current_user,
}
)
# ============================================================================
# CODE QUALITY & ARCHITECTURE ROUTES
# ============================================================================
@router.get("/code-quality", response_class=HTMLResponse, include_in_schema=False)
async def admin_code_quality_dashboard(
request: Request,
current_user: User = Depends(get_current_admin_from_cookie_or_header),
db: Session = Depends(get_db)
):
"""
Render code quality dashboard.
Shows architecture violations, trends, and technical debt score.
"""
return templates.TemplateResponse(
"admin/code-quality-dashboard.html",
{
"request": request,
"user": current_user,
}
)
@router.get("/code-quality/violations", response_class=HTMLResponse, include_in_schema=False)
async def admin_code_quality_violations(
request: Request,
current_user: User = Depends(get_current_admin_from_cookie_or_header),
db: Session = Depends(get_db)
):
"""
Render violations list page.
Shows all violations with filtering and sorting options.
"""
return templates.TemplateResponse(
"admin/code-quality-violations.html",
{
"request": request,
"user": current_user,
}
)
@router.get("/code-quality/violations/{violation_id}", response_class=HTMLResponse, include_in_schema=False)
async def admin_code_quality_violation_detail(
request: Request,
violation_id: int = Path(..., description="Violation ID"),
current_user: User = Depends(get_current_admin_from_cookie_or_header),
db: Session = Depends(get_db)
):
"""
Render violation detail page.
Shows violation details, code context, assignments, and comments.
"""
return templates.TemplateResponse(
"admin/code-quality-violation-detail.html",
{
"request": request,
"user": current_user,
"violation_id": violation_id,
}
)

View File

@@ -0,0 +1,513 @@
"""
Code Quality Service
Business logic for managing architecture scans and violations
"""
import subprocess
import json
import logging
from datetime import datetime
from typing import List, Tuple, Optional, Dict
from pathlib import Path
from sqlalchemy.orm import Session
from sqlalchemy import func, desc
from app.models.architecture_scan import (
ArchitectureScan,
ArchitectureViolation,
ArchitectureRule,
ViolationAssignment,
ViolationComment
)
logger = logging.getLogger(__name__)
class CodeQualityService:
"""Service for managing code quality scans and violations"""
def run_scan(self, db: Session, triggered_by: str = 'manual') -> ArchitectureScan:
"""
Run architecture validator and store results in database
Args:
db: Database session
triggered_by: Who/what triggered the scan ('manual', 'scheduled', 'ci/cd')
Returns:
ArchitectureScan object with results
Raises:
Exception: If validator script fails
"""
logger.info(f"Starting architecture scan (triggered by: {triggered_by})")
# Get git commit hash
git_commit = self._get_git_commit_hash()
# Run validator with JSON output
start_time = datetime.now()
try:
result = subprocess.run(
['python', 'scripts/validate_architecture.py', '--json'],
capture_output=True,
text=True,
timeout=300 # 5 minute timeout
)
except subprocess.TimeoutExpired:
logger.error("Architecture scan timed out after 5 minutes")
raise Exception("Scan timed out")
duration = (datetime.now() - start_time).total_seconds()
# Parse JSON output (get only the JSON part, skip progress messages)
try:
# Find the JSON part in stdout
lines = result.stdout.strip().split('\n')
json_start = -1
for i, line in enumerate(lines):
if line.strip().startswith('{'):
json_start = i
break
if json_start == -1:
raise ValueError("No JSON output found")
json_output = '\n'.join(lines[json_start:])
data = json.loads(json_output)
except (json.JSONDecodeError, ValueError) as e:
logger.error(f"Failed to parse validator output: {e}")
logger.error(f"Stdout: {result.stdout}")
logger.error(f"Stderr: {result.stderr}")
raise Exception(f"Failed to parse scan results: {e}")
# Create scan record
scan = ArchitectureScan(
timestamp=datetime.now(),
total_files=data.get('files_checked', 0),
total_violations=data.get('total_violations', 0),
errors=data.get('errors', 0),
warnings=data.get('warnings', 0),
duration_seconds=duration,
triggered_by=triggered_by,
git_commit_hash=git_commit
)
db.add(scan)
db.flush() # Get scan.id
# Create violation records
violations_data = data.get('violations', [])
logger.info(f"Creating {len(violations_data)} violation records")
for v in violations_data:
violation = ArchitectureViolation(
scan_id=scan.id,
rule_id=v['rule_id'],
rule_name=v['rule_name'],
severity=v['severity'],
file_path=v['file_path'],
line_number=v['line_number'],
message=v['message'],
context=v.get('context', ''),
suggestion=v.get('suggestion', ''),
status='open'
)
db.add(violation)
db.commit()
db.refresh(scan)
logger.info(f"Scan completed: {scan.total_violations} violations found")
return scan
def get_latest_scan(self, db: Session) -> Optional[ArchitectureScan]:
"""Get the most recent scan"""
return db.query(ArchitectureScan).order_by(desc(ArchitectureScan.timestamp)).first()
def get_scan_by_id(self, db: Session, scan_id: int) -> Optional[ArchitectureScan]:
"""Get scan by ID"""
return db.query(ArchitectureScan).filter(ArchitectureScan.id == scan_id).first()
def get_scan_history(self, db: Session, limit: int = 30) -> List[ArchitectureScan]:
"""
Get scan history for trend graphs
Args:
db: Database session
limit: Maximum number of scans to return
Returns:
List of ArchitectureScan objects, newest first
"""
return db.query(ArchitectureScan)\
.order_by(desc(ArchitectureScan.timestamp))\
.limit(limit)\
.all()
def get_violations(
self,
db: Session,
scan_id: int = None,
severity: str = None,
status: str = None,
rule_id: str = None,
file_path: str = None,
limit: int = 100,
offset: int = 0
) -> Tuple[List[ArchitectureViolation], int]:
"""
Get violations with filtering and pagination
Args:
db: Database session
scan_id: Filter by scan ID (if None, use latest scan)
severity: Filter by severity ('error', 'warning')
status: Filter by status ('open', 'assigned', 'resolved', etc.)
rule_id: Filter by rule ID
file_path: Filter by file path (partial match)
limit: Page size
offset: Page offset
Returns:
Tuple of (violations list, total count)
"""
# If no scan_id specified, use latest scan
if scan_id is None:
latest_scan = self.get_latest_scan(db)
if not latest_scan:
return [], 0
scan_id = latest_scan.id
# Build query
query = db.query(ArchitectureViolation).filter(
ArchitectureViolation.scan_id == scan_id
)
# Apply filters
if severity:
query = query.filter(ArchitectureViolation.severity == severity)
if status:
query = query.filter(ArchitectureViolation.status == status)
if rule_id:
query = query.filter(ArchitectureViolation.rule_id == rule_id)
if file_path:
query = query.filter(ArchitectureViolation.file_path.like(f'%{file_path}%'))
# Get total count
total = query.count()
# Get page of results
violations = query.order_by(
ArchitectureViolation.severity.desc(),
ArchitectureViolation.file_path
).limit(limit).offset(offset).all()
return violations, total
def get_violation_by_id(self, db: Session, violation_id: int) -> Optional[ArchitectureViolation]:
"""Get single violation with details"""
return db.query(ArchitectureViolation).filter(
ArchitectureViolation.id == violation_id
).first()
def assign_violation(
self,
db: Session,
violation_id: int,
user_id: int,
assigned_by: int,
due_date: datetime = None,
priority: str = 'medium'
) -> ViolationAssignment:
"""
Assign violation to a developer
Args:
db: Database session
violation_id: Violation ID
user_id: User to assign to
assigned_by: User who is assigning
due_date: Due date (optional)
priority: Priority level ('low', 'medium', 'high', 'critical')
Returns:
ViolationAssignment object
"""
# Update violation status
violation = self.get_violation_by_id(db, violation_id)
if violation:
violation.status = 'assigned'
violation.assigned_to = user_id
# Create assignment record
assignment = ViolationAssignment(
violation_id=violation_id,
user_id=user_id,
assigned_by=assigned_by,
due_date=due_date,
priority=priority
)
db.add(assignment)
db.commit()
logger.info(f"Violation {violation_id} assigned to user {user_id}")
return assignment
def resolve_violation(
self,
db: Session,
violation_id: int,
resolved_by: int,
resolution_note: str
) -> ArchitectureViolation:
"""
Mark violation as resolved
Args:
db: Database session
violation_id: Violation ID
resolved_by: User who resolved it
resolution_note: Note about resolution
Returns:
Updated ArchitectureViolation object
"""
violation = self.get_violation_by_id(db, violation_id)
if not violation:
raise ValueError(f"Violation {violation_id} not found")
violation.status = 'resolved'
violation.resolved_at = datetime.now()
violation.resolved_by = resolved_by
violation.resolution_note = resolution_note
db.commit()
logger.info(f"Violation {violation_id} resolved by user {resolved_by}")
return violation
def ignore_violation(
self,
db: Session,
violation_id: int,
ignored_by: int,
reason: str
) -> ArchitectureViolation:
"""
Mark violation as ignored/won't fix
Args:
db: Database session
violation_id: Violation ID
ignored_by: User who ignored it
reason: Reason for ignoring
Returns:
Updated ArchitectureViolation object
"""
violation = self.get_violation_by_id(db, violation_id)
if not violation:
raise ValueError(f"Violation {violation_id} not found")
violation.status = 'ignored'
violation.resolved_at = datetime.now()
violation.resolved_by = ignored_by
violation.resolution_note = f"Ignored: {reason}"
db.commit()
logger.info(f"Violation {violation_id} ignored by user {ignored_by}")
return violation
def add_comment(
self,
db: Session,
violation_id: int,
user_id: int,
comment: str
) -> ViolationComment:
"""
Add comment to violation
Args:
db: Database session
violation_id: Violation ID
user_id: User posting comment
comment: Comment text
Returns:
ViolationComment object
"""
comment_obj = ViolationComment(
violation_id=violation_id,
user_id=user_id,
comment=comment
)
db.add(comment_obj)
db.commit()
logger.info(f"Comment added to violation {violation_id} by user {user_id}")
return comment_obj
def get_dashboard_stats(self, db: Session) -> Dict:
"""
Get statistics for dashboard
Returns:
Dictionary with various statistics
"""
latest_scan = self.get_latest_scan(db)
if not latest_scan:
return {
'total_violations': 0,
'errors': 0,
'warnings': 0,
'open': 0,
'assigned': 0,
'resolved': 0,
'ignored': 0,
'technical_debt_score': 100,
'trend': [],
'by_severity': {},
'by_rule': {},
'by_module': {},
'top_files': []
}
# Get violation counts by status
status_counts = db.query(
ArchitectureViolation.status,
func.count(ArchitectureViolation.id)
).filter(
ArchitectureViolation.scan_id == latest_scan.id
).group_by(ArchitectureViolation.status).all()
status_dict = {status: count for status, count in status_counts}
# Get violations by severity
severity_counts = db.query(
ArchitectureViolation.severity,
func.count(ArchitectureViolation.id)
).filter(
ArchitectureViolation.scan_id == latest_scan.id
).group_by(ArchitectureViolation.severity).all()
by_severity = {sev: count for sev, count in severity_counts}
# Get violations by rule
rule_counts = db.query(
ArchitectureViolation.rule_id,
func.count(ArchitectureViolation.id)
).filter(
ArchitectureViolation.scan_id == latest_scan.id
).group_by(ArchitectureViolation.rule_id).all()
by_rule = {rule: count for rule, count in sorted(rule_counts, key=lambda x: x[1], reverse=True)[:10]}
# Get top violating files
file_counts = db.query(
ArchitectureViolation.file_path,
func.count(ArchitectureViolation.id).label('count')
).filter(
ArchitectureViolation.scan_id == latest_scan.id
).group_by(ArchitectureViolation.file_path)\
.order_by(desc('count'))\
.limit(10).all()
top_files = [{'file': file, 'count': count} for file, count in file_counts]
# Get violations by module (extract module from file path)
by_module = {}
violations = db.query(ArchitectureViolation.file_path).filter(
ArchitectureViolation.scan_id == latest_scan.id
).all()
for v in violations:
path_parts = v.file_path.split('/')
if len(path_parts) >= 2:
module = '/'.join(path_parts[:2]) # e.g., 'app/api'
else:
module = path_parts[0]
by_module[module] = by_module.get(module, 0) + 1
# Sort by count and take top 10
by_module = dict(sorted(by_module.items(), key=lambda x: x[1], reverse=True)[:10])
# Calculate technical debt score
tech_debt_score = self.calculate_technical_debt_score(db, latest_scan.id)
# Get trend (last 7 scans)
trend_scans = self.get_scan_history(db, limit=7)
trend = [
{
'timestamp': scan.timestamp.isoformat(),
'violations': scan.total_violations,
'errors': scan.errors,
'warnings': scan.warnings
}
for scan in reversed(trend_scans) # Oldest first for chart
]
return {
'total_violations': latest_scan.total_violations,
'errors': latest_scan.errors,
'warnings': latest_scan.warnings,
'open': status_dict.get('open', 0),
'assigned': status_dict.get('assigned', 0),
'resolved': status_dict.get('resolved', 0),
'ignored': status_dict.get('ignored', 0),
'technical_debt_score': tech_debt_score,
'trend': trend,
'by_severity': by_severity,
'by_rule': by_rule,
'by_module': by_module,
'top_files': top_files,
'last_scan': latest_scan.timestamp.isoformat() if latest_scan else None
}
def calculate_technical_debt_score(self, db: Session, scan_id: int = None) -> int:
"""
Calculate technical debt score (0-100)
Formula: 100 - (errors * 0.5 + warnings * 0.05)
Capped at 0 minimum
Args:
db: Database session
scan_id: Scan ID (if None, use latest)
Returns:
Score from 0-100
"""
if scan_id is None:
latest_scan = self.get_latest_scan(db)
if not latest_scan:
return 100
scan_id = latest_scan.id
scan = self.get_scan_by_id(db, scan_id)
if not scan:
return 100
score = 100 - (scan.errors * 0.5 + scan.warnings * 0.05)
return max(0, min(100, int(score))) # Clamp to 0-100
def _get_git_commit_hash(self) -> Optional[str]:
"""Get current git commit hash"""
try:
result = subprocess.run(
['git', 'rev-parse', 'HEAD'],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
return result.stdout.strip()[:40]
except Exception:
pass
return None
# Singleton instance
code_quality_service = CodeQualityService()

View File

@@ -0,0 +1,300 @@
{# app/templates/admin/code-quality-dashboard.html #}
{% extends "admin/base.html" %}
{% block title %}Code Quality Dashboard{% endblock %}
{% block alpine_data %}codeQualityDashboard(){% endblock %}
{% block extra_scripts %}
<script src="/static/admin/js/code-quality-dashboard.js"></script>
{% endblock %}
{% block content %}
<!-- Page Header with Actions -->
<div class="flex items-center justify-between my-6">
<div>
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Code Quality Dashboard
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Architecture validation and technical debt tracking
</p>
</div>
<div class="flex space-x-3">
<button
@click="refresh()"
:disabled="loading"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:shadow-outline-gray disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!loading" x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span>Refresh</span>
</button>
<button
@click="runScan()"
:disabled="scanning"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!scanning" x-html="$icon('search', 'w-4 h-4 mr-2')"></span>
<span x-show="scanning" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="scanning ? 'Scanning...' : 'Run Scan'"></span>
</button>
</div>
</div>
<!-- Loading State -->
<div x-show="loading" class="text-center py-12">
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading dashboard...</p>
</div>
<!-- Error State -->
<div x-show="error && !loading" class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start">
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
<div>
<p class="font-semibold">Error loading dashboard</p>
<p class="text-sm" x-text="error"></p>
</div>
</div>
<!-- Success Message -->
<div x-show="successMessage" x-transition class="mb-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg flex items-start">
<span x-html="$icon('check-circle', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
<div>
<p class="font-semibold" x-text="successMessage"></p>
</div>
</div>
<!-- Dashboard Content -->
<div x-show="!loading && !error">
<!-- Stats Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<!-- Card: Total Violations -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
<span x-html="$icon('exclamation', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Violations
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_violations">
0
</p>
</div>
</div>
<!-- Card: Errors -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<span x-html="$icon('alert', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Errors
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.errors">
0
</p>
</div>
</div>
<!-- Card: Warnings -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-yellow-500 bg-yellow-100 rounded-full dark:text-yellow-100 dark:bg-yellow-500">
<span x-html="$icon('info', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Warnings
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.warnings">
0
</p>
</div>
</div>
<!-- Card: Technical Debt Score -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 rounded-full"
:class="{
'text-green-500 bg-green-100 dark:text-green-100 dark:bg-green-500': stats.technical_debt_score >= 80,
'text-yellow-500 bg-yellow-100 dark:text-yellow-100 dark:bg-yellow-500': stats.technical_debt_score >= 50 && stats.technical_debt_score < 80,
'text-red-500 bg-red-100 dark:text-red-100 dark:bg-red-500': stats.technical_debt_score < 50
}">
<span x-html="$icon('chart', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Health Score
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.technical_debt_score + '/100'">
0/100
</p>
</div>
</div>
</div>
<!-- Status Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<!-- Open -->
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<p class="text-sm font-medium text-gray-600 dark:text-gray-400 mb-1">Open</p>
<p class="text-2xl font-semibold text-gray-700 dark:text-gray-200" x-text="stats.open">0</p>
</div>
<!-- Assigned -->
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<p class="text-sm font-medium text-gray-600 dark:text-gray-400 mb-1">Assigned</p>
<p class="text-2xl font-semibold text-gray-700 dark:text-gray-200" x-text="stats.assigned">0</p>
</div>
<!-- Resolved -->
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<p class="text-sm font-medium text-gray-600 dark:text-gray-400 mb-1">Resolved</p>
<p class="text-2xl font-semibold text-green-600 dark:text-green-400" x-text="stats.resolved">0</p>
</div>
<!-- Ignored -->
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<p class="text-sm font-medium text-gray-600 dark:text-gray-400 mb-1">Ignored</p>
<p class="text-2xl font-semibold text-gray-700 dark:text-gray-200" x-text="stats.ignored">0</p>
</div>
</div>
<!-- Trend Chart and Top Files -->
<div class="grid gap-6 mb-8 md:grid-cols-2">
<!-- Trend Chart -->
<div class="p-6 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<h4 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Violation Trends (Last 7 Scans)
</h4>
<div class="h-64 flex items-center justify-center text-gray-500 dark:text-gray-400">
<template x-if="stats.trend && stats.trend.length > 0">
<div class="w-full">
<template x-for="(scan, idx) in stats.trend" :key="idx">
<div class="mb-2">
<div class="flex justify-between text-sm mb-1">
<span x-text="new Date(scan.timestamp).toLocaleDateString()"></span>
<span x-text="scan.violations"></span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div class="bg-purple-600 h-2 rounded-full"
:style="'width: ' + Math.min(100, (scan.violations / Math.max(...stats.trend.map(s => s.violations)) * 100)) + '%'">
</div>
</div>
</div>
</template>
</div>
</template>
<template x-if="!stats.trend || stats.trend.length === 0">
<p>No scan history available</p>
</template>
</div>
</div>
<!-- Top Violating Files -->
<div class="p-6 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<h4 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Top Violating Files
</h4>
<div class="space-y-3">
<template x-if="stats.top_files && stats.top_files.length > 0">
<template x-for="(file, idx) in stats.top_files.slice(0, 10)" :key="idx">
<div class="flex justify-between items-center">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 truncate" x-text="file.file"></p>
</div>
<span class="ml-2 px-2 py-1 text-xs font-semibold rounded-full"
:class="{
'bg-red-100 text-red-800': file.count >= 10,
'bg-yellow-100 text-yellow-800': file.count >= 5 && file.count < 10,
'bg-blue-100 text-blue-800': file.count < 5
}"
x-text="file.count">
</span>
</div>
</template>
</template>
<template x-if="!stats.top_files || stats.top_files.length === 0">
<p class="text-sm text-gray-500 dark:text-gray-400">No violations found</p>
</template>
</div>
</div>
</div>
<!-- Violations by Rule and Module -->
<div class="grid gap-6 mb-8 md:grid-cols-2">
<!-- By Rule -->
<div class="p-6 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<h4 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Top Rules Violated
</h4>
<div class="space-y-2">
<template x-if="stats.by_rule && Object.keys(stats.by_rule).length > 0">
<template x-for="[rule_id, count] in Object.entries(stats.by_rule)" :key="rule_id">
<div class="flex justify-between items-center text-sm">
<span class="text-gray-700 dark:text-gray-300" x-text="rule_id"></span>
<span class="font-semibold text-gray-900 dark:text-gray-100" x-text="count"></span>
</div>
</template>
</template>
<template x-if="!stats.by_rule || Object.keys(stats.by_rule).length === 0">
<p class="text-sm text-gray-500 dark:text-gray-400">No violations found</p>
</template>
</div>
</div>
<!-- By Module -->
<div class="p-6 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<h4 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Violations by Module
</h4>
<div class="space-y-2">
<template x-if="stats.by_module && Object.keys(stats.by_module).length > 0">
<template x-for="[module, count] in Object.entries(stats.by_module)" :key="module">
<div class="flex justify-between items-center text-sm">
<span class="text-gray-700 dark:text-gray-300" x-text="module"></span>
<span class="font-semibold text-gray-900 dark:text-gray-100" x-text="count"></span>
</div>
</template>
</template>
<template x-if="!stats.by_module || Object.keys(stats.by_module).length === 0">
<p class="text-sm text-gray-500 dark:text-gray-400">No violations found</p>
</template>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="mb-8">
<div class="p-6 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<h4 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Quick Actions
</h4>
<div class="flex flex-wrap gap-3">
<a href="/admin/code-quality/violations"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
<span x-html="$icon('list', 'w-4 h-4 mr-2')"></span>
View All Violations
</a>
<a href="/admin/code-quality/violations?status=open"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:shadow-outline-gray">
<span x-html="$icon('folder-open', 'w-4 h-4 mr-2')"></span>
Open Violations
</a>
<a href="/admin/code-quality/violations?severity=error"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:shadow-outline-gray">
<span x-html="$icon('alert', 'w-4 h-4 mr-2')"></span>
Errors Only
</a>
</div>
</div>
</div>
<!-- Last Scan Info -->
<div x-show="stats.last_scan" class="text-sm text-gray-600 dark:text-gray-400 text-center">
Last scan: <span x-text="stats.last_scan ? new Date(stats.last_scan).toLocaleString() : 'Never'"></span>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,227 @@
{# app/templates/admin/code-quality-violations.html #}
{% extends "admin/base.html" %}
{% block title %}Violations List{% endblock %}
{% block alpine_data %}codeQualityViolations(){% endblock %}
{% block extra_scripts %}
<script src="/static/admin/js/code-quality-violations.js"></script>
{% endblock %}
{% block content %}
<!-- Page Header -->
<div class="flex items-center justify-between my-6">
<div>
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Architecture Violations
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Review and manage code quality violations
</p>
</div>
<a href="/admin/code-quality"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:shadow-outline-gray">
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
Back to Dashboard
</a>
</div>
<!-- Loading State -->
<div x-show="loading" class="text-center py-12">
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading violations...</p>
</div>
<!-- Error State -->
<div x-show="error && !loading" class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start">
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
<div>
<p class="font-semibold">Error loading violations</p>
<p class="text-sm" x-text="error"></p>
</div>
</div>
<!-- Content -->
<div x-show="!loading">
<!-- Filters -->
<div class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Filters</h3>
<div class="grid gap-4 md:grid-cols-4">
<!-- Severity Filter -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Severity
</label>
<select x-model="filters.severity"
@change="applyFilters()"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-select rounded-md">
<option value="">All</option>
<option value="error">Error</option>
<option value="warning">Warning</option>
</select>
</div>
<!-- Status Filter -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Status
</label>
<select x-model="filters.status"
@change="applyFilters()"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-select rounded-md">
<option value="">All</option>
<option value="open">Open</option>
<option value="assigned">Assigned</option>
<option value="resolved">Resolved</option>
<option value="ignored">Ignored</option>
</select>
</div>
<!-- Rule ID Filter -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
Rule ID
</label>
<input x-model="filters.rule_id"
@input.debounce.500ms="applyFilters()"
type="text"
placeholder="e.g. API-001"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input rounded-md">
</div>
<!-- File Path Filter -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
File Path
</label>
<input x-model="filters.file_path"
@input.debounce.500ms="applyFilters()"
type="text"
placeholder="e.g. app/api"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input rounded-md">
</div>
</div>
</div>
<!-- Violations Table -->
<div class="w-full overflow-hidden rounded-lg shadow-xs">
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-no-wrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Rule</th>
<th class="px-4 py-3">Severity</th>
<th class="px-4 py-3">File</th>
<th class="px-4 py-3">Line</th>
<th class="px-4 py-3">Message</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="violations.length === 0">
<tr>
<td colspan="7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
No violations found
</td>
</tr>
</template>
<template x-for="violation in violations" :key="violation.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
<!-- Rule ID -->
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div>
<p class="font-semibold" x-text="violation.rule_id"></p>
</div>
</div>
</td>
<!-- Severity Badge -->
<td class="px-4 py-3 text-xs">
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
:class="{
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': violation.severity === 'error',
'text-yellow-700 bg-yellow-100 dark:text-yellow-100 dark:bg-yellow-700': violation.severity === 'warning'
}"
x-text="violation.severity">
</span>
</td>
<!-- File Path -->
<td class="px-4 py-3 text-sm">
<p class="truncate max-w-xs" :title="violation.file_path" x-text="violation.file_path"></p>
</td>
<!-- Line Number -->
<td class="px-4 py-3 text-sm">
<p x-text="violation.line_number"></p>
</td>
<!-- Message -->
<td class="px-4 py-3 text-sm">
<p class="truncate max-w-md" :title="violation.message" x-text="violation.message"></p>
</td>
<!-- Status Badge -->
<td class="px-4 py-3 text-xs">
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
:class="{
'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700': violation.status === 'open',
'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700': violation.status === 'assigned',
'text-green-700 bg-green-100 dark:text-green-100 dark:bg-green-700': violation.status === 'resolved',
'text-orange-700 bg-orange-100 dark:text-orange-100 dark:bg-orange-700': violation.status === 'ignored'
}"
x-text="violation.status">
</span>
</td>
<!-- Actions -->
<td class="px-4 py-3">
<div class="flex items-center space-x-2">
<a :href="'/admin/code-quality/violations/' + violation.id"
class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="View Details">
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</a>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- Pagination -->
<div class="mt-6 px-4 py-3 bg-white border-t border-gray-200 dark:border-gray-700 dark:bg-gray-800 rounded-b-lg">
<div class="flex items-center justify-between">
<div class="flex items-center">
<span class="text-sm text-gray-700 dark:text-gray-400">
Showing
<span class="font-semibold" x-text="violations.length"></span>
of
<span class="font-semibold" x-text="pagination.total"></span>
results
</span>
</div>
<div class="flex items-center space-x-2">
<button @click="previousPage()"
:disabled="pagination.page === 1"
class="px-3 py-1 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed">
<span x-html="$icon('arrow-left', 'w-4 h-4')"></span>
</button>
<span class="text-sm text-gray-700 dark:text-gray-400">
Page <span x-text="pagination.page"></span> of <span x-text="pagination.total_pages"></span>
</span>
<button @click="nextPage()"
:disabled="pagination.page === pagination.total_pages"
class="px-3 py-1 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed">
<span x-html="$icon('arrow-right', 'w-4 h-4')"></span>
</button>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -125,6 +125,17 @@
<span class="ml-4">Testing Hub</span>
</a>
</li>
<!-- Code Quality -->
<li class="relative px-6 py-3">
<span x-show="currentPage === 'code-quality'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'code-quality' ? 'text-gray-800 dark:text-gray-100' : ''"
href="/admin/code-quality">
<span x-html="$icon('shield-check')"></span>
<span class="ml-4">Code Quality</span>
</a>
</li>
</ul>
<!-- Settings Section -->
@@ -290,6 +301,17 @@
<span class="ml-4">Testing Hub</span>
</a>
</li>
<!-- Code Quality -->
<li class="relative px-6 py-3">
<span x-show="currentPage === 'code-quality'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'code-quality' ? 'text-gray-800 dark:text-gray-100' : ''"
href="/admin/code-quality">
<span x-html="$icon('shield-check')"></span>
<span class="ml-4">Code Quality</span>
</a>
</li>
</ul>
<!-- Settings Section -->

View File

@@ -584,6 +584,36 @@ class ArchitectureValidator:
print("=" * 80)
return 0
def print_json(self) -> int:
"""Print validation results as JSON"""
import json
violations_json = []
for v in self.result.violations:
rel_path = str(v.file_path.relative_to(self.project_root)) if self.project_root in v.file_path.parents else str(v.file_path)
violations_json.append({
'rule_id': v.rule_id,
'rule_name': v.rule_name,
'severity': v.severity.value,
'file_path': rel_path,
'line_number': v.line_number,
'message': v.message,
'context': v.context or '',
'suggestion': v.suggestion or ''
})
output = {
'files_checked': self.result.files_checked,
'total_violations': len(self.result.violations),
'errors': len([v for v in self.result.violations if v.severity == Severity.ERROR]),
'warnings': len([v for v in self.result.violations if v.severity == Severity.WARNING]),
'violations': violations_json
}
print(json.dumps(output, indent=2))
return 1 if self.result.has_errors() else 0
def _print_violation(self, v: Violation):
"""Print a single violation"""
rel_path = v.file_path.relative_to(self.project_root) if self.project_root in v.file_path.parents else v.file_path
@@ -634,6 +664,12 @@ def main():
help="Only show errors, suppress warnings"
)
parser.add_argument(
'--json',
action='store_true',
help="Output results as JSON (for programmatic use)"
)
args = parser.parse_args()
# Create validator
@@ -642,8 +678,11 @@ def main():
# Run validation
result = validator.validate_all(args.path)
# Print report
exit_code = validator.print_report()
# Output results
if args.json:
exit_code = validator.print_json()
else:
exit_code = validator.print_report()
sys.exit(exit_code)

View File

@@ -0,0 +1,106 @@
/**
* Code Quality Dashboard Component
* Manages the code quality dashboard page
*/
function codeQualityDashboard() {
return {
loading: false,
scanning: false,
error: null,
successMessage: null,
stats: {
total_violations: 0,
errors: 0,
warnings: 0,
open: 0,
assigned: 0,
resolved: 0,
ignored: 0,
technical_debt_score: 100,
trend: [],
by_severity: {},
by_rule: {},
by_module: {},
top_files: [],
last_scan: null
},
async init() {
await this.loadStats();
},
async loadStats() {
this.loading = true;
this.error = null;
try {
const response = await fetch('/api/v1/admin/code-quality/stats', {
headers: {
'Authorization': `Bearer ${getAccessToken()}`
}
});
if (!response.ok) {
if (response.status === 401) {
window.location.href = '/admin/login';
return;
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
this.stats = await response.json();
} catch (err) {
console.error('Failed to load stats:', err);
this.error = err.message;
} finally {
this.loading = false;
}
},
async runScan() {
this.scanning = true;
this.error = null;
this.successMessage = null;
try {
const response = await fetch('/api/v1/admin/code-quality/scan', {
method: 'POST',
headers: {
'Authorization': `Bearer ${getAccessToken()}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
if (response.status === 401) {
window.location.href = '/admin/login';
return;
}
const errorData = await response.json();
throw new Error(errorData.detail || `HTTP ${response.status}`);
}
const scan = await response.json();
this.successMessage = `Scan completed: ${scan.total_violations} violations found (${scan.errors} errors, ${scan.warnings} warnings)`;
// Reload stats after scan
await this.loadStats();
// Clear success message after 5 seconds
setTimeout(() => {
this.successMessage = null;
}, 5000);
} catch (err) {
console.error('Failed to run scan:', err);
this.error = err.message;
} finally {
this.scanning = false;
}
},
async refresh() {
await this.loadStats();
}
};
}

View File

@@ -0,0 +1,119 @@
/**
* Code Quality Violations List Component
* Manages the violations list page with filtering and pagination
*/
function codeQualityViolations() {
return {
loading: false,
error: null,
violations: [],
pagination: {
page: 1,
page_size: 50,
total: 0,
total_pages: 0
},
filters: {
severity: '',
status: '',
rule_id: '',
file_path: ''
},
async init() {
// Load filters from URL params
const params = new URLSearchParams(window.location.search);
this.filters.severity = params.get('severity') || '';
this.filters.status = params.get('status') || '';
this.filters.rule_id = params.get('rule_id') || '';
this.filters.file_path = params.get('file_path') || '';
await this.loadViolations();
},
async loadViolations() {
this.loading = true;
this.error = null;
try {
// Build query params
const params = new URLSearchParams({
page: this.pagination.page.toString(),
page_size: this.pagination.page_size.toString()
});
if (this.filters.severity) params.append('severity', this.filters.severity);
if (this.filters.status) params.append('status', this.filters.status);
if (this.filters.rule_id) params.append('rule_id', this.filters.rule_id);
if (this.filters.file_path) params.append('file_path', this.filters.file_path);
const response = await fetch(`/api/v1/admin/code-quality/violations?${params.toString()}`, {
headers: {
'Authorization': `Bearer ${getAccessToken()}`
}
});
if (!response.ok) {
if (response.status === 401) {
window.location.href = '/admin/login';
return;
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
this.violations = data.violations;
this.pagination = {
page: data.page,
page_size: data.page_size,
total: data.total,
total_pages: data.total_pages
};
// Update URL with current filters (without reloading)
this.updateURL();
} catch (err) {
console.error('Failed to load violations:', err);
this.error = err.message;
} finally {
this.loading = false;
}
},
applyFilters() {
// Reset to page 1 when filters change
this.pagination.page = 1;
this.loadViolations();
},
async nextPage() {
if (this.pagination.page < this.pagination.total_pages) {
this.pagination.page++;
await this.loadViolations();
}
},
async previousPage() {
if (this.pagination.page > 1) {
this.pagination.page--;
await this.loadViolations();
}
},
updateURL() {
const params = new URLSearchParams();
if (this.filters.severity) params.set('severity', this.filters.severity);
if (this.filters.status) params.set('status', this.filters.status);
if (this.filters.rule_id) params.set('rule_id', this.filters.rule_id);
if (this.filters.file_path) params.set('file_path', this.filters.file_path);
const newURL = params.toString()
? `${window.location.pathname}?${params.toString()}`
: window.location.pathname;
window.history.replaceState({}, '', newURL);
}
};
}