feat(Docker): Upgrade to PHP 8.5.0RC3 with native ext-uri support
BREAKING CHANGE: Requires PHP 8.5.0RC3 Changes: - Update Docker base image from php:8.4-fpm to php:8.5.0RC3-fpm - Enable ext-uri for native WHATWG URL parsing support - Update composer.json PHP requirement from ^8.4 to ^8.5 - Add ext-uri as required extension in composer.json - Move URL classes from Url.php85/ to Url/ directory (now compatible) - Remove temporary PHP 8.4 compatibility workarounds Benefits: - Native URL parsing with Uri\WhatWg\Url class - Better performance for URL operations - Future-proof with latest PHP features - Eliminates PHP version compatibility issues
This commit is contained in:
@@ -28,7 +28,7 @@ final readonly class MLDashboardAdminController
|
||||
#[Route(path: '/admin/ml/dashboard', method: Method::GET, name: AdminRoutes::ML_DASHBOARD)]
|
||||
public function dashboard(HttpRequest $request): ViewResult
|
||||
{
|
||||
$timeWindowHours = (int) ($request->queryParameters['timeWindow'] ?? 24);
|
||||
$timeWindowHours = $request->query->getInt('timeWindow', 24);
|
||||
$timeWindow = Duration::fromHours($timeWindowHours);
|
||||
|
||||
// Get all models
|
||||
@@ -115,6 +115,12 @@ final readonly class MLDashboardAdminController
|
||||
$byType[$typeName] = ($byType[$typeName] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Fetch confusion matrices
|
||||
$confusionMatrices = $this->getConfusionMatrices($allModels, $timeWindow);
|
||||
|
||||
// Fetch registry summary
|
||||
$registrySummary = $this->getRegistrySummary($allModels);
|
||||
|
||||
$data = [
|
||||
'title' => 'ML Model Dashboard',
|
||||
'page_title' => 'Machine Learning Model Dashboard',
|
||||
@@ -143,6 +149,17 @@ final readonly class MLDashboardAdminController
|
||||
'has_alerts' => count($degradationAlerts) > 0,
|
||||
'alert_count' => count($degradationAlerts),
|
||||
|
||||
// Confusion matrices
|
||||
'confusion_matrices' => $confusionMatrices,
|
||||
'has_confusion_matrices' => count($confusionMatrices) > 0,
|
||||
|
||||
// Registry summary
|
||||
'registry_total_versions' => $registrySummary['total_versions'],
|
||||
'registry_production_count' => $registrySummary['production_count'],
|
||||
'registry_development_count' => $registrySummary['development_count'],
|
||||
'registry_models' => $registrySummary['models'],
|
||||
'has_registry_summary' => count($registrySummary['models']) > 0,
|
||||
|
||||
// Links
|
||||
'api_dashboard_url' => '/api/ml/dashboard',
|
||||
'api_health_url' => '/api/ml/dashboard/health',
|
||||
@@ -172,4 +189,109 @@ final readonly class MLDashboardAdminController
|
||||
|
||||
return $allModels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get confusion matrices for classification models
|
||||
*/
|
||||
private function getConfusionMatrices(array $allModels, Duration $timeWindow): array
|
||||
{
|
||||
$matrices = [];
|
||||
|
||||
foreach ($allModels as $metadata) {
|
||||
$metrics = $this->performanceMonitor->getCurrentMetrics(
|
||||
$metadata->modelName,
|
||||
$metadata->version,
|
||||
$timeWindow
|
||||
);
|
||||
|
||||
if (isset($metrics['confusion_matrix'])) {
|
||||
$cm = $metrics['confusion_matrix'];
|
||||
$total = $metrics['total_predictions'];
|
||||
|
||||
$fpRate = $total > 0 ? $cm['false_positive'] / $total : 0.0;
|
||||
$fnRate = $total > 0 ? $cm['false_negative'] / $total : 0.0;
|
||||
|
||||
$matrices[] = [
|
||||
'model_name' => $metadata->modelName,
|
||||
'version' => $metadata->version->toString(),
|
||||
'type' => $metadata->modelType->value,
|
||||
'true_positives' => number_format($cm['true_positive']),
|
||||
'true_negatives' => number_format($cm['true_negative']),
|
||||
'false_positives' => number_format($cm['false_positive']),
|
||||
'false_negatives' => number_format($cm['false_negative']),
|
||||
'fp_rate' => round($fpRate, 4),
|
||||
'fn_rate' => round($fnRate, 4),
|
||||
'fp_rate_percent' => round($fpRate * 100, 2),
|
||||
'fn_rate_percent' => round($fnRate * 100, 2),
|
||||
'fp_rate_badge' => $fpRate > 0.1 ? 'warning' : 'success',
|
||||
'fn_rate_badge' => $fnRate > 0.1 ? 'warning' : 'success',
|
||||
'total_predictions' => $total,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $matrices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model registry summary statistics
|
||||
*/
|
||||
private function getRegistrySummary(array $allModels): array
|
||||
{
|
||||
// Group by model name
|
||||
$modelGroups = [];
|
||||
$productionCount = 0;
|
||||
$developmentCount = 0;
|
||||
|
||||
foreach ($allModels as $metadata) {
|
||||
$modelName = $metadata->modelName;
|
||||
if (!isset($modelGroups[$modelName])) {
|
||||
$modelGroups[$modelName] = [
|
||||
'model_name' => $modelName,
|
||||
'type' => $metadata->modelType->value,
|
||||
'versions' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$modelGroups[$modelName]['versions'][] = [
|
||||
'version' => $metadata->version->toString(),
|
||||
'environment' => $metadata->environment,
|
||||
];
|
||||
|
||||
// Count environments
|
||||
if ($metadata->environment === 'production') {
|
||||
$productionCount++;
|
||||
} elseif ($metadata->environment === 'development') {
|
||||
$developmentCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate summary per model
|
||||
$modelsSummary = [];
|
||||
foreach ($modelGroups as $modelName => $group) {
|
||||
// Sort versions
|
||||
$versions = array_column($group['versions'], 'version');
|
||||
usort($versions, 'version_compare');
|
||||
|
||||
// Get latest environment
|
||||
$latestVersion = end($versions);
|
||||
$latestVersionData = array_filter($group['versions'], fn($v) => $v['version'] === $latestVersion);
|
||||
$latestEnv = !empty($latestVersionData) ? reset($latestVersionData)['environment'] : 'unknown';
|
||||
|
||||
$modelsSummary[] = [
|
||||
'model_name' => $modelName,
|
||||
'type' => $group['type'],
|
||||
'version_count' => count($versions),
|
||||
'latest_version' => $latestVersion,
|
||||
'environment' => $latestEnv,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'total_versions' => count($allModels),
|
||||
'production_count' => $productionCount,
|
||||
'development_count' => $developmentCount,
|
||||
'models' => $modelsSummary,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin\Notifications;
|
||||
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Http\HttpRequest;
|
||||
use App\Framework\Http\Status;
|
||||
use App\Framework\Router\Result\ViewResult;
|
||||
use App\Framework\Http\Responses\JsonResponse;
|
||||
use App\Framework\Notification\Storage\NotificationRepository;
|
||||
use App\Framework\Notification\ValueObjects\NotificationId;
|
||||
use App\Framework\Meta\MetaData;
|
||||
|
||||
/**
|
||||
* Admin Notifications Controller
|
||||
*
|
||||
* Displays and manages system notifications for administrators
|
||||
*/
|
||||
final readonly class NotificationsAdminController
|
||||
{
|
||||
public function __construct(
|
||||
private NotificationRepository $notificationRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Display notifications list
|
||||
*/
|
||||
#[Route(path: '/admin/notifications', method: Method::GET)]
|
||||
public function index(HttpRequest $request): ViewResult
|
||||
{
|
||||
// For now, use 'admin' as recipient ID
|
||||
// TODO: Replace with actual authenticated admin user ID
|
||||
$adminUserId = 'admin';
|
||||
|
||||
// Get pagination parameters
|
||||
$page = $request->query->getInt('page', 1);
|
||||
$perPage = 20;
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
// Fetch notifications
|
||||
$notifications = $this->notificationRepository->findByUser(
|
||||
$adminUserId,
|
||||
limit: $perPage,
|
||||
offset: $offset
|
||||
);
|
||||
|
||||
// Get unread count
|
||||
$unreadCount = $this->notificationRepository->countUnreadByUser($adminUserId);
|
||||
|
||||
// Transform notifications for template
|
||||
$notificationsList = array_map(function ($notification) {
|
||||
return [
|
||||
'id' => $notification->id->toString(),
|
||||
'type' => $notification->type->toString(),
|
||||
'title' => $notification->title,
|
||||
'body' => $notification->body,
|
||||
'priority' => $notification->priority->value,
|
||||
'status' => $notification->status->value,
|
||||
'is_read' => $notification->status->value === 'read',
|
||||
'created_at' => $notification->createdAt->format('Y-m-d H:i:s'),
|
||||
'created_at_human' => $this->getHumanReadableTime($notification->createdAt->toDateTime()),
|
||||
'action_url' => $notification->actionUrl,
|
||||
'action_label' => $notification->actionLabel ?? 'View Details',
|
||||
'icon' => $this->getNotificationIcon($notification->type->toString()),
|
||||
'badge_class' => $this->getPriorityBadgeClass($notification->priority->value),
|
||||
];
|
||||
}, $notifications);
|
||||
|
||||
$data = [
|
||||
'notifications' => $notificationsList,
|
||||
'unread_count' => $unreadCount,
|
||||
'current_page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'has_more' => count($notifications) === $perPage,
|
||||
'has_notifications' => count($notifications) > 0,
|
||||
'has_unread' => $unreadCount > 0, // Boolean flag for if attribute
|
||||
'show_pagination' => count($notifications) === $perPage || $page > 1, // Boolean flag
|
||||
];
|
||||
|
||||
return new ViewResult(
|
||||
template: 'notification-index',
|
||||
metaData: new MetaData(
|
||||
title: 'Notifications - Admin',
|
||||
description: 'System notifications for administrators'
|
||||
),
|
||||
data : $data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark notification as read
|
||||
*/
|
||||
#[Route(path: '/admin/notifications/{id}/read', method: Method::POST)]
|
||||
public function markAsRead(HttpRequest $request, string $id): JsonResponse
|
||||
{
|
||||
$notificationId = NotificationId::fromString($id);
|
||||
|
||||
$success = $this->notificationRepository->markAsRead($notificationId);
|
||||
|
||||
if (!$success) {
|
||||
return new JsonResponse(
|
||||
body: ['error' => 'Notification not found'],
|
||||
status: Status::NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
// Get updated unread count
|
||||
$unreadCount = $this->notificationRepository->countUnreadByUser('admin');
|
||||
|
||||
return new JsonResponse([
|
||||
'success' => true,
|
||||
'unread_count' => $unreadCount,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications as read
|
||||
*/
|
||||
#[Route(path: '/admin/notifications/read-all', method: Method::POST)]
|
||||
public function markAllAsRead(HttpRequest $request): JsonResponse
|
||||
{
|
||||
$count = $this->notificationRepository->markAllAsReadForUser('admin');
|
||||
|
||||
return new JsonResponse([
|
||||
'success' => true,
|
||||
'marked_count' => $count,
|
||||
'unread_count' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread count for badge
|
||||
*/
|
||||
#[Route(path: '/admin/notifications/unread-count', method: Method::GET)]
|
||||
public function getUnreadCount(HttpRequest $request): JsonResponse
|
||||
{
|
||||
$unreadCount = $this->notificationRepository->countUnreadByUser('admin');
|
||||
|
||||
return new JsonResponse([
|
||||
'unread_count' => $unreadCount,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable time difference
|
||||
*/
|
||||
private function getHumanReadableTime(\DateTimeInterface $timestamp): string
|
||||
{
|
||||
$now = new \DateTime();
|
||||
$diff = $now->diff($timestamp);
|
||||
|
||||
if ($diff->y > 0) {
|
||||
return $diff->y . ' year' . ($diff->y > 1 ? 's' : '') . ' ago';
|
||||
}
|
||||
if ($diff->m > 0) {
|
||||
return $diff->m . ' month' . ($diff->m > 1 ? 's' : '') . ' ago';
|
||||
}
|
||||
if ($diff->d > 0) {
|
||||
return $diff->d . ' day' . ($diff->d > 1 ? 's' : '') . ' ago';
|
||||
}
|
||||
if ($diff->h > 0) {
|
||||
return $diff->h . ' hour' . ($diff->h > 1 ? 's' : '') . ' ago';
|
||||
}
|
||||
if ($diff->i > 0) {
|
||||
return $diff->i . ' minute' . ($diff->i > 1 ? 's' : '') . ' ago';
|
||||
}
|
||||
|
||||
return 'Just now';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon for notification type
|
||||
*/
|
||||
private function getNotificationIcon(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'ml_performance_degradation' => '⚠️',
|
||||
'ml_model_deployed' => '🚀',
|
||||
'ml_training_complete' => '✅',
|
||||
'system_alert' => '🚨',
|
||||
'security_alert' => '🔒',
|
||||
'info' => 'ℹ️',
|
||||
default => '📢',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get badge class for priority
|
||||
*/
|
||||
private function getPriorityBadgeClass(string $priority): string
|
||||
{
|
||||
return match ($priority) {
|
||||
'urgent' => 'admin-badge--danger',
|
||||
'high' => 'admin-badge--warning',
|
||||
'normal' => 'admin-badge--info',
|
||||
'low' => 'admin-badge--secondary',
|
||||
default => 'admin-badge--info',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{meta.title}</title>
|
||||
<meta name="description" content="{meta.description}">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<style>
|
||||
.notification-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.notification-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.notification-header h1 {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.unread-badge {
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notification-actions {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-mark-all {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-mark-all:hover {
|
||||
background: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
background: white;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notification-item.unread {
|
||||
border-left: 4px solid var(--color-primary);
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.notification-item:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.notification-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.notification-title-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.notification-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-badge--danger {
|
||||
background: #fee;
|
||||
color: #c33;
|
||||
}
|
||||
|
||||
.admin-badge--warning {
|
||||
background: #ffc;
|
||||
color: #c93;
|
||||
}
|
||||
|
||||
.admin-badge--info {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.admin-badge--secondary {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.notification-body {
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.notification-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.notification-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.notification-action:hover {
|
||||
color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.btn-mark-read {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-mark-read:hover {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.pagination-link {
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.pagination-link:hover {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-layout">
|
||||
<aside class="admin-sidebar">
|
||||
<div class="admin-sidebar-header">
|
||||
<h2>Admin Panel</h2>
|
||||
</div>
|
||||
<nav class="admin-nav">
|
||||
<a href="/admin/dashboard" class="admin-nav-link">
|
||||
📊 Dashboard
|
||||
</a>
|
||||
<a href="/admin/ml/dashboard" class="admin-nav-link">
|
||||
🤖 ML Dashboard
|
||||
</a>
|
||||
<a href="/admin/notifications" class="admin-nav-link admin-nav-link--active">
|
||||
🔔 Notifications
|
||||
<span class="unread-badge" if="$unread_count > 0">{{ $unread_count }}</span>
|
||||
</a>
|
||||
<a href="/admin/users" class="admin-nav-link">
|
||||
👥 Users
|
||||
</a>
|
||||
<a href="/admin/settings" class="admin-nav-link">
|
||||
⚙️ Settings
|
||||
</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="admin-main">
|
||||
<div class="notification-container">
|
||||
<div class="notification-header">
|
||||
<h1>Notifications</h1>
|
||||
<span class="unread-badge" if="$unread_count > 0">{{ $unread_count }} unread</span>
|
||||
</div>
|
||||
|
||||
<div class="notification-actions" if="$unread_count > 0">
|
||||
<button class="btn-mark-all" onclick="markAllAsRead()">
|
||||
Mark all as read
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="notification-list" if="$has_notifications">
|
||||
<div foreach="$notifications as $notification" class="notification-item" data-id="{{ $notification['id'] }}">
|
||||
<div class="notification-item-header">
|
||||
<div class="notification-title-wrapper">
|
||||
<span class="notification-icon">{{ $notification['icon'] }}</span>
|
||||
<h3 class="notification-title">{{ $notification['title'] }}</h3>
|
||||
</div>
|
||||
<div class="notification-meta">
|
||||
<span class="admin-badge {{ $notification['badge_class'] }}">{{ $notification['priority'] }}</span>
|
||||
<span class="notification-time">{{ $notification['created_at_human'] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notification-body">
|
||||
{{ $notification['body'] }}
|
||||
</div>
|
||||
|
||||
<div class="notification-footer">
|
||||
<div>
|
||||
<a href="{{ $notification['action_url'] }}" class="notification-action" if="$notification['action_url']">
|
||||
{{ $notification['action_label'] }} →
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
class="btn-mark-read"
|
||||
onclick="markAsRead('{{ $notification['id'] }}')"
|
||||
>
|
||||
Mark as read
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pagination" if="$has_more || $current_page > 1">
|
||||
<a href="/admin/notifications?page={{ $current_page - 1 }}" class="pagination-link" if="$current_page > 1">
|
||||
← Previous
|
||||
</a>
|
||||
|
||||
<span class="pagination-info">Page {{ $current_page }}</span>
|
||||
|
||||
<a href="/admin/notifications?page={{ $current_page + 1 }}" class="pagination-link" if="$has_more">
|
||||
Next →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" if="!$has_notifications">
|
||||
<div class="empty-state-icon">📭</div>
|
||||
<h2 class="empty-state-title">No notifications</h2>
|
||||
<p>You're all caught up! No notifications to display.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function markAsRead(notificationId) {
|
||||
try {
|
||||
const response = await fetch(`/admin/notifications/${notificationId}/read`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Update UI
|
||||
const item = document.querySelector(`[data-id="${notificationId}"]`);
|
||||
if (item) {
|
||||
item.classList.remove('unread');
|
||||
|
||||
const button = item.querySelector('.btn-mark-read');
|
||||
if (button) {
|
||||
button.textContent = '✓ Read';
|
||||
button.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Update unread count badge
|
||||
updateUnreadCount(data.unread_count);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error marking notification as read:', error);
|
||||
alert('Failed to mark notification as read. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllAsRead() {
|
||||
if (!confirm('Mark all notifications as read?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/notifications/read-all', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Reload page to show updated state
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error marking all notifications as read:', error);
|
||||
alert('Failed to mark all notifications as read. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
function updateUnreadCount(count) {
|
||||
const badges = document.querySelectorAll('.unread-badge');
|
||||
badges.forEach(badge => {
|
||||
if (count === 0) {
|
||||
badge.style.display = 'none';
|
||||
} else {
|
||||
badge.textContent = count;
|
||||
badge.style.display = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Poll for new notifications every 30 seconds
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch('/admin/notifications/unread-count');
|
||||
const data = await response.json();
|
||||
|
||||
const currentBadge = document.querySelector('.unread-badge');
|
||||
const currentCount = currentBadge ? parseInt(currentBadge.textContent) || 0 : 0;
|
||||
|
||||
if (data.unread_count !== currentCount) {
|
||||
updateUnreadCount(data.unread_count);
|
||||
|
||||
if (data.unread_count > currentCount) {
|
||||
console.log('New notifications received');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error polling for notifications:', error);
|
||||
}
|
||||
}, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -228,6 +228,123 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confusion Matrices Section -->
|
||||
<div class="admin-card" if="{{ $has_confusion_matrices }}">
|
||||
<div class="admin-card__header">
|
||||
<h3 class="admin-card__title">Classification Performance (Confusion Matrices)</h3>
|
||||
</div>
|
||||
<div class="admin-card__content">
|
||||
<div class="admin-grid admin-grid--2-col">
|
||||
<div foreach="$confusion_matrices as $matrix" class="confusion-matrix-card">
|
||||
<h4 class="confusion-matrix-card__title">{{ $matrix['model_name'] }} v{{ $matrix['version'] }}</h4>
|
||||
|
||||
<div class="confusion-matrix">
|
||||
<div class="confusion-matrix__grid">
|
||||
<!-- True Positive -->
|
||||
<div class="confusion-matrix__cell confusion-matrix__cell--tp">
|
||||
<div class="confusion-matrix__cell-label">True Positive</div>
|
||||
<div class="confusion-matrix__cell-value">{{ $matrix['true_positives'] }}</div>
|
||||
</div>
|
||||
|
||||
<!-- False Positive -->
|
||||
<div class="confusion-matrix__cell confusion-matrix__cell--fp">
|
||||
<div class="confusion-matrix__cell-label">False Positive</div>
|
||||
<div class="confusion-matrix__cell-value">{{ $matrix['false_positives'] }}</div>
|
||||
</div>
|
||||
|
||||
<!-- False Negative -->
|
||||
<div class="confusion-matrix__cell confusion-matrix__cell--fn">
|
||||
<div class="confusion-matrix__cell-label">False Negative</div>
|
||||
<div class="confusion-matrix__cell-value">{{ $matrix['false_negatives'] }}</div>
|
||||
</div>
|
||||
|
||||
<!-- True Negative -->
|
||||
<div class="confusion-matrix__cell confusion-matrix__cell--tn">
|
||||
<div class="confusion-matrix__cell-label">True Negative</div>
|
||||
<div class="confusion-matrix__cell-value">{{ $matrix['true_negatives'] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="confusion-matrix__rates">
|
||||
<div class="admin-stat-item">
|
||||
<span class="admin-stat-item__label">False Positive Rate</span>
|
||||
<span class="admin-stat-item__value">
|
||||
<span class="admin-badge admin-badge--{{ $matrix['fp_rate_badge'] }}">
|
||||
{{ $matrix['fp_rate_percent'] }}%
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="admin-stat-item">
|
||||
<span class="admin-stat-item__label">False Negative Rate</span>
|
||||
<span class="admin-stat-item__value">
|
||||
<span class="admin-badge admin-badge--{{ $matrix['fn_rate_badge'] }}">
|
||||
{{ $matrix['fn_rate_percent'] }}%
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model Registry Summary -->
|
||||
<div class="admin-card" if="{{ $has_registry_summary }}">
|
||||
<div class="admin-card__header">
|
||||
<h3 class="admin-card__title">Model Registry Summary</h3>
|
||||
</div>
|
||||
<div class="admin-card__content">
|
||||
<div class="admin-grid admin-grid--3-col">
|
||||
<div class="admin-stat-item">
|
||||
<span class="admin-stat-item__label">Total Versions</span>
|
||||
<span class="admin-stat-item__value">{{ $registry_total_versions }}</span>
|
||||
</div>
|
||||
<div class="admin-stat-item">
|
||||
<span class="admin-stat-item__label">Production Models</span>
|
||||
<span class="admin-stat-item__value">
|
||||
<span class="admin-badge admin-badge--success">{{ $registry_production_count }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="admin-stat-item">
|
||||
<span class="admin-stat-item__label">Development Models</span>
|
||||
<span class="admin-stat-item__value">
|
||||
<span class="admin-badge admin-badge--info">{{ $registry_development_count }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-table-container">
|
||||
<table class="admin-table admin-table--compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Model Name</th>
|
||||
<th>Total Versions</th>
|
||||
<th>Type</th>
|
||||
<th>Latest Version</th>
|
||||
<th>Environment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr foreach="$registry_models as $regModel">
|
||||
<td><strong>{{ $regModel['model_name'] }}</strong></td>
|
||||
<td>{{ $regModel['version_count'] }}</td>
|
||||
<td>
|
||||
<span class="admin-badge admin-badge--info">{{ $regModel['type'] }}</span>
|
||||
</td>
|
||||
<td><code>{{ $regModel['latest_version'] }}</code></td>
|
||||
<td>
|
||||
<span class="admin-badge admin-badge--{{ $regModel['environment'] === 'production' ? 'success' : 'info' }}">
|
||||
{{ $regModel['environment'] }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Information Card -->
|
||||
<div class="admin-card">
|
||||
<div class="admin-card__header">
|
||||
@@ -247,6 +364,18 @@
|
||||
<code>GET {{ $api_health_url }}</code>
|
||||
</span>
|
||||
</div>
|
||||
<div class="admin-stat-item">
|
||||
<span class="admin-stat-item__label">Confusion Matrices</span>
|
||||
<span class="admin-stat-item__value">
|
||||
<code>GET /api/ml/dashboard/confusion-matrices</code>
|
||||
</span>
|
||||
</div>
|
||||
<div class="admin-stat-item">
|
||||
<span class="admin-stat-item__label">Registry Summary</span>
|
||||
<span class="admin-stat-item__value">
|
||||
<code>GET /api/ml/dashboard/registry-summary</code>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -350,8 +350,8 @@ final readonly class MLABTestingController
|
||||
)]
|
||||
public function calculateSampleSize(HttpRequest $request): JsonResult
|
||||
{
|
||||
$confidenceLevel = (float) ($request->queryParameters['confidence_level'] ?? 0.95);
|
||||
$marginOfError = (float) ($request->queryParameters['margin_of_error'] ?? 0.05);
|
||||
$confidenceLevel = $request->query->getFloat('confidence_level', 0.95);
|
||||
$marginOfError = $request->query->getFloat('margin_of_error', 0.05);
|
||||
|
||||
// Validate parameters
|
||||
if ($confidenceLevel < 0.5 || $confidenceLevel > 0.99) {
|
||||
|
||||
@@ -91,7 +91,7 @@ final readonly class MLDashboardController
|
||||
)]
|
||||
public function getDashboardData(HttpRequest $request): JsonResult
|
||||
{
|
||||
$timeWindowHours = (int) ($request->queryParameters['timeWindow'] ?? 24);
|
||||
$timeWindowHours = $request->query->getInt('timeWindow', 24);
|
||||
$timeWindow = Duration::fromHours($timeWindowHours);
|
||||
|
||||
// Get all models
|
||||
@@ -280,7 +280,7 @@ final readonly class MLDashboardController
|
||||
)]
|
||||
public function getAlerts(HttpRequest $request): JsonResult
|
||||
{
|
||||
$severityFilter = $request->queryParameters['severity'] ?? null;
|
||||
$severityFilter = $request->query->get('severity');
|
||||
$allModels = $this->getAllModels();
|
||||
$timeWindow = Duration::fromHours(1);
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ final readonly class MLModelsController
|
||||
)]
|
||||
public function listModels(HttpRequest $request): JsonResult
|
||||
{
|
||||
$typeFilter = $request->queryParameters['type'] ?? null;
|
||||
$typeFilter = $request->query->get('type');
|
||||
|
||||
// Get all model names
|
||||
$modelNames = $this->registry->getAllModelNames();
|
||||
@@ -161,7 +161,7 @@ final readonly class MLModelsController
|
||||
)]
|
||||
public function getModel(string $modelName, HttpRequest $request): JsonResult
|
||||
{
|
||||
$versionString = $request->queryParameters['version'] ?? null;
|
||||
$versionString = $request->query->get('version');
|
||||
|
||||
try {
|
||||
if ($versionString !== null) {
|
||||
@@ -253,8 +253,8 @@ final readonly class MLModelsController
|
||||
)]
|
||||
public function getMetrics(string $modelName, HttpRequest $request): JsonResult
|
||||
{
|
||||
$versionString = $request->queryParameters['version'] ?? null;
|
||||
$timeWindowHours = (int) ($request->queryParameters['timeWindow'] ?? 1);
|
||||
$versionString = $request->query->get('version');
|
||||
$timeWindowHours = $request->query->getInt('timeWindow', 1);
|
||||
|
||||
try {
|
||||
if ($versionString !== null) {
|
||||
@@ -439,7 +439,7 @@ final readonly class MLModelsController
|
||||
)]
|
||||
public function unregisterModel(string $modelName, HttpRequest $request): JsonResult
|
||||
{
|
||||
$versionString = $request->queryParameters['version'] ?? null;
|
||||
$versionString = $request->query->get('version');
|
||||
|
||||
if ($versionString === null) {
|
||||
return new JsonResult([
|
||||
|
||||
@@ -37,9 +37,9 @@ interface LiveComponentState extends SerializableState
|
||||
* Create State VO from array data (from client or storage)
|
||||
*
|
||||
* @param array $data Raw state data
|
||||
* @return static Hydrated state object
|
||||
* @return self Hydrated state object
|
||||
*/
|
||||
public static function fromArray(array $data): static;
|
||||
public static function fromArray(array $data): self;
|
||||
|
||||
/**
|
||||
* Convert State VO to array for serialization
|
||||
|
||||
Reference in New Issue
Block a user