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:
@@ -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"]
|
||||
|
||||
448
app/api/v1/admin/code_quality.py
Normal file
448
app/api/v1/admin/code_quality.py
Normal 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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
513
app/services/code_quality_service.py
Normal file
513
app/services/code_quality_service.py
Normal 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()
|
||||
300
app/templates/admin/code-quality-dashboard.html
Normal file
300
app/templates/admin/code-quality-dashboard.html
Normal 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 %}
|
||||
227
app/templates/admin/code-quality-violations.html
Normal file
227
app/templates/admin/code-quality-violations.html
Normal 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 %}
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
106
static/admin/js/code-quality-dashboard.js
Normal file
106
static/admin/js/code-quality-dashboard.js
Normal 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();
|
||||
}
|
||||
};
|
||||
}
|
||||
119
static/admin/js/code-quality-violations.js
Normal file
119
static/admin/js/code-quality-violations.js
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user