feat: add resources and view components
This commit is contained in:
@@ -0,0 +1,583 @@
|
||||
<x-app-layout>
|
||||
@push('styles')
|
||||
<script src="https://cdn.jsdelivr.net/npm/apexcharts" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.3/Sortable.min.js" crossorigin="anonymous"></script>
|
||||
<style>
|
||||
.sparkline-container {
|
||||
position: absolute;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
height: 60px;
|
||||
overflow: hidden;
|
||||
border-bottom-left-radius: 20px;
|
||||
border-bottom-right-radius: 20px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.card-body { position: relative; z-index: 1; }
|
||||
|
||||
/* Skeleton */
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e6e6e6 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.5s infinite;
|
||||
border-radius: 4px; display: inline-block;
|
||||
}
|
||||
@keyframes skeleton-loading {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
.skeleton-text { height: 1rem; margin-bottom: .5rem; width: 100%; }
|
||||
.skeleton-title { height: 2.5rem; width: 60%; margin: 10px auto; }
|
||||
|
||||
/* Widget grid */
|
||||
.widget-col { transition: opacity .2s, transform .2s; }
|
||||
.widget-col.hidden-widget { display: none !important; }
|
||||
.widget-col.widget-ghost { opacity: .3; }
|
||||
.widget-col.widget-chosen { transform: scale(1.02); box-shadow: 0 10px 30px rgba(0,0,0,.15) !important; z-index: 10; }
|
||||
|
||||
/* Customize panel */
|
||||
#widget-customize-panel {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
display: none;
|
||||
}
|
||||
.widget-toggle-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 12px; border-radius: 10px; cursor: pointer;
|
||||
border: 1px solid #f1f5f9; margin-bottom: 6px;
|
||||
transition: background .15s;
|
||||
}
|
||||
.widget-toggle-item:hover { background: #f8fafc; }
|
||||
.widget-toggle-item.active { border-color: #bfdbfe; background: #eff6ff; }
|
||||
|
||||
/* Live pulse dot */
|
||||
.live-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: #22c55e; display: inline-block;
|
||||
animation: pulse-dot 2s infinite;
|
||||
}
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: .5; transform: scale(1.4); }
|
||||
}
|
||||
|
||||
.fw-black { font-weight: 900; }
|
||||
.tracking-tight { letter-spacing: -2px; }
|
||||
.display-3 { font-size: 3.5rem; letter-spacing: -2px; }
|
||||
@media (max-width: 1400px) { .display-3 { font-size: 2.8rem; } }
|
||||
@media (max-width: 576px) { .display-3 { font-size: 2.2rem; } }
|
||||
.extra-small { font-size: .85rem !important; }
|
||||
.mini-progress { height: 4px; background: #f1f5f9; border-radius: 2px; overflow: hidden; }
|
||||
.mini-progress .bar { height: 100%; background: var(--adminuiux-theme-1); border-radius: 2px; transition: width .5s; }
|
||||
.bi-spin { animation: spin .8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.hover-lift { transition: transform .2s, box-shadow .2s; }
|
||||
.hover-lift:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,.1) !important; }
|
||||
|
||||
/* ── Terminal Modal ───────────────────────────────────── */
|
||||
@keyframes blink-cursor {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
#logDetailModal .modal-content {
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
#logDetailModal .modal-content::-webkit-scrollbar { width: 6px; }
|
||||
#logDetailModal .p-4::-webkit-scrollbar { width: 5px; }
|
||||
#logDetailModal .p-4::-webkit-scrollbar-track { background: #0d1117; }
|
||||
#logDetailModal .p-4::-webkit-scrollbar-thumb { background: #30363d; border-radius: 4px; }
|
||||
#terminal-output {
|
||||
min-height: 200px;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
<div class="container-fluid pb-5" id="monitoring-master">
|
||||
|
||||
{{-- ── Welcome Header ───────────────────────────────── --}}
|
||||
<div class="card adminuiux-card bg-dark text-white mb-4 border-0 shadow-lg overflow-hidden animate__animated animate__fadeIn">
|
||||
<div class="card-body p-4 position-relative">
|
||||
<div class="row align-items-center position-relative z-1">
|
||||
<div class="col">
|
||||
<h1 class="display-5 fw-bold text-white mb-1 tracking-tight">{{ __('Operational Dashboard') }}</h1>
|
||||
<p class="small text-white-50 mb-0 d-flex align-items-center gap-2">
|
||||
<span class="live-dot"></span>
|
||||
System operational since
|
||||
<span class="badge text-bg-theme-1 rounded-pill px-3 shadow-sm" id="stat-uptime-badge">{{ $stats['uptime'] }}</span>
|
||||
at <span class="fw-bold">{{ $stats['hostname'] }}</span> ({{ $stats['ip'] }})
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-auto d-flex gap-2">
|
||||
<button class="btn btn-outline-light btn-sm rounded-pill px-3 fw-semibold small" id="btn-customize-widgets">
|
||||
<i class="bi bi-grid me-1"></i> Customize
|
||||
</button>
|
||||
<button class="btn btn-theme-1 btn-square rounded-circle shadow-sm" id="refresh-all-stats" title="Refresh">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-decoration"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Customize Panel ──────────────────────────────── --}}
|
||||
<div id="widget-customize-panel" class="shadow-sm border-0">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h6 class="fw-bold mb-0"><i class="bi bi-sliders me-2 text-theme-1"></i>Customize Widgets</h6>
|
||||
<p class="text-muted small mb-0 mt-1">Toggle widgets on/off. Drag cards to reorder your workspace.</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-secondary rounded-pill px-3" id="btn-toggle-all-widgets">Toggle All</button>
|
||||
<form action="{{ route('dashboard.widgets.reset') }}" method="POST" class="d-inline" id="form-reset-widgets">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-sm btn-light rounded-pill px-3">Reset to Default</button>
|
||||
</form>
|
||||
<button class="btn btn-sm btn-dark rounded-pill px-3" id="btn-save-widgets">
|
||||
<i class="bi bi-check2 me-1"></i>Save Layout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2" id="widget-toggle-list">
|
||||
@foreach ($widgets as $key => $widget)
|
||||
@php $allowed = !$widget['permission'] || auth()->user()->can($widget['permission']); @endphp
|
||||
@if ($allowed)
|
||||
<div class="col-6 col-md-4 col-lg-3">
|
||||
<div class="widget-toggle-item {{ $widget['visible'] ? 'active' : '' }}" data-widget-key="{{ $key }}">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-grip-vertical text-muted"></i>
|
||||
<span class="small fw-semibold">{{ $widget['label'] }}</span>
|
||||
</div>
|
||||
<div class="form-check form-switch mb-0">
|
||||
<input class="form-check-input widget-visibility-toggle" type="checkbox"
|
||||
data-widget="{{ $key }}" {{ $widget['visible'] ? 'checked' : '' }}>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Stat Card Widgets (Top Row) ──────────────────── --}}
|
||||
<div class="row g-3 g-lg-4 mb-4" id="widget-grid-stats">
|
||||
@foreach ($widgets as $key => $widget)
|
||||
@php
|
||||
$isStat = in_array($key, ['cpu', 'ram', 'disk', 'live_users', 'queues']);
|
||||
$allowed = !$widget['permission'] || auth()->user()->can($widget['permission']);
|
||||
@endphp
|
||||
@if ($isStat && $allowed)
|
||||
<div class="col-6 col-sm-4 col-md-3 col-xl widget-col {{ !$widget['visible'] ? 'hidden-widget' : '' }}"
|
||||
data-widget-key="{{ $key }}" data-sort="{{ $widget['sort_order'] }}">
|
||||
@include('pages.dashboard.widget-' . str_replace('_', '-', $key))
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- ── Big Widgets (Bottom Row - Dynamic Sizing) ────── --}}
|
||||
@php
|
||||
$bigKeys = ['activity_feed', 'ai_insight'];
|
||||
$visibleBig = collect($widgets)
|
||||
->only($bigKeys)
|
||||
->filter(fn($w) => $w['visible'] && (!$w['permission'] || auth()->user()->can($w['permission'])));
|
||||
$vCount = $visibleBig->count();
|
||||
|
||||
// Dynamic class based on user request: 1->12, 2->6, 3->4
|
||||
$bigColClass = 'col-12';
|
||||
if ($vCount === 2) $bigColClass = 'col-12 col-lg-6';
|
||||
if ($vCount >= 3) $bigColClass = 'col-12 col-lg-4';
|
||||
@endphp
|
||||
|
||||
<div class="row g-3 g-lg-4" id="widget-grid-big">
|
||||
@foreach ($widgets as $key => $widget)
|
||||
@php
|
||||
$isBig = in_array($key, $bigKeys);
|
||||
$allowed = !$widget['permission'] || auth()->user()->can($widget['permission']);
|
||||
@endphp
|
||||
@if ($isBig && $allowed)
|
||||
<div class="widget-col big-widget-col {{ !$widget['visible'] ? 'hidden-widget' : '' }}"
|
||||
data-widget-key="{{ $key }}" data-sort="{{ $widget['sort_order'] }}">
|
||||
|
||||
@if ($key === 'activity_feed')
|
||||
<div class="card adminuiux-card border-0 shadow-sm h-100 animate__animated animate__fadeIn">
|
||||
<div class="card-header bg-white border-bottom p-4 d-flex justify-content-between align-items-center">
|
||||
<h6 class="fw-bold text-dark mb-0 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-terminal text-theme-1"></i>
|
||||
{{ __('Runtime Activity Feed') }}
|
||||
<span class="live-dot ms-1" title="Live via Reverb"></span>
|
||||
</h6>
|
||||
<a href="{{ route('system-monitoring') }}" class="btn btn-sm btn-light rounded-pill px-3 fw-bold small">
|
||||
<i class="bi bi-gear me-1"></i> FULL MONITOR
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<style>
|
||||
#logs-datatable tbody td:nth-child(3) { white-space: normal !important; min-width: 250px; max-width: 400px; word-break: break-word; }
|
||||
#logs-datatable thead th { white-space: nowrap; }
|
||||
</style>
|
||||
<table id="logs-datatable" class="table table-hover align-middle mb-0 w-100 small compact-table">
|
||||
<thead>
|
||||
<tr class="bg-white">
|
||||
<th>INCIDENT TIME</th><th>LVL</th><th>MANIFEST</th>
|
||||
<th class="text-end pe-4">INTEL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($key === 'ai_insight')
|
||||
<div class="card adminuiux-card border-0 shadow-sm h-100 animate__animated animate__fadeIn" style="animation-delay:.5s">
|
||||
<div class="card-header bg-transparent border-0 pt-4 px-4 d-flex justify-content-between align-items-center">
|
||||
<h6 class="fw-bold text-dark mb-0 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-robot text-theme-1"></i>
|
||||
{{ __('AI Security Insight') }}
|
||||
</h6>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button class="btn btn-sm btn-outline-danger rounded-pill px-3 extra-small" id="btn-ai-clear">
|
||||
<i class="bi bi-trash me-1"></i>Clear
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-dark rounded-pill px-3 extra-small" id="btn-ai-analyze">
|
||||
<i class="bi bi-cpu me-1"></i>Analyze
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body px-4">
|
||||
<div class="small text-muted" style="min-height:200px;line-height:1.6;">
|
||||
<div class="placeholder-glow" id="ai-placeholder" style="display:none;">
|
||||
<div class="d-flex flex-column gap-3">
|
||||
<span class="placeholder col-12 rounded-pill py-2"></span>
|
||||
<span class="placeholder col-10 rounded-pill py-1"></span>
|
||||
<span class="placeholder col-11 rounded-pill py-1"></span>
|
||||
<span class="placeholder col-8 rounded-pill py-1"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ai-content-display" class="animate__animated animate__fadeIn">
|
||||
<div class="text-center py-5 opacity-50">
|
||||
<i class="bi bi-robot display-4 d-block mb-3"></i>
|
||||
<p class="fst-italic">Click analyze to get security insights from your recent activity logs.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- ── Empty State ───────────────────────────────────── --}}
|
||||
<div id="dashboard-empty-state" class="text-center py-5 mt-4 animate__animated animate__fadeIn" style="{{ collect($widgets)->contains('visible', true) ? 'display:none' : '' }}">
|
||||
<div class="card adminuiux-card border-0 shadow-sm p-5 mx-auto" style="max-width: 500px; border-radius: 24px;">
|
||||
<div class="mb-4">
|
||||
<div class="bg-light rounded-circle d-inline-flex align-items-center justify-content-center" style="width: 80px; height: 80px;">
|
||||
<i class="bi bi-grid text-muted display-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
<h4 class="fw-bold">Your dashboard is empty</h4>
|
||||
<p class="text-muted">It seems you have hidden all widgets. Customize your dashboard to display the metrics that matter to you.</p>
|
||||
<button class="btn btn-theme-1 rounded-pill px-4 mt-2" onclick="$('#widget-customize-panel').slideDown();">
|
||||
<i class="bi bi-plus-lg me-1"></i> Customize Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Log Detail Modal - Terminal Style --}}
|
||||
<div class="modal fade" id="logDetailModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||
<div class="modal-content border-0 overflow-hidden" style="background:#0d1117;border-radius:12px;box-shadow:0 32px 80px rgba(0,0,0,0.6);">
|
||||
|
||||
{{-- Terminal Title Bar --}}
|
||||
<div class="d-flex align-items-center justify-content-between px-4 py-3" style="background:#161b22;border-bottom:1px solid #30363d;">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
{{-- macOS-style traffic light dots --}}
|
||||
<span style="width:13px;height:13px;border-radius:50%;background:#ff5f57;display:inline-block;"></span>
|
||||
<span style="width:13px;height:13px;border-radius:50%;background:#febc2e;display:inline-block;"></span>
|
||||
<span style="width:13px;height:13px;border-radius:50%;background:#28c840;display:inline-block;"></span>
|
||||
<span class="ms-3 fw-bold" id="terminal-title" style="font-family:'JetBrains Mono','Fira Code','Courier New',monospace;font-size:0.78rem;color:#8b949e;letter-spacing:1px;">TELEMETRY_DUMP @node_01</span>
|
||||
</div>
|
||||
<button type="button" data-bs-dismiss="modal" class="border-0 bg-transparent p-0" style="color:#8b949e;font-size:1.1rem;line-height:1;">×</button>
|
||||
</div>
|
||||
|
||||
{{-- Terminal Body --}}
|
||||
<div class="p-4" style="max-height:72vh;overflow-y:auto;">
|
||||
<div id="terminal-output" style="font-family:'JetBrains Mono','Fira Code','Courier New',monospace;font-size:0.8rem;line-height:1.8;color:#3fb950;white-space:pre-wrap;word-break:break-word;"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>{{-- /container-fluid --}}
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
|
||||
// ── Sparkline charts (always rendered for stat cards) ─────
|
||||
const sparkOpts = (color) => ({
|
||||
series: [{ data: Array(10).fill(0) }],
|
||||
chart: { type: 'area', height: 60, sparkline: { enabled: true }, animations: { enabled: true, easing: 'linear', dynamicAnimation: { speed: 1000 } } },
|
||||
stroke: { curve: 'smooth', width: 2 },
|
||||
fill: { opacity: .3, type: 'gradient', gradient: { shadeIntensity: 1, opacityFrom: .4, opacityTo: .1 } },
|
||||
colors: [color], tooltip: { enabled: false }
|
||||
});
|
||||
|
||||
const cpuEl = document.querySelector('#chart-cpu-sparkline');
|
||||
const ramEl = document.querySelector('#chart-ram-sparkline');
|
||||
const diskEl = document.querySelector('#chart-disk-sparkline');
|
||||
const cpuChart = cpuEl ? new ApexCharts(cpuEl, sparkOpts('var(--adminuiux-theme-1)')) : null;
|
||||
const ramChart = ramEl ? new ApexCharts(ramEl, sparkOpts('#0dcaf0')) : null;
|
||||
const diskChart = diskEl ? new ApexCharts(diskEl, sparkOpts('#ffc107')) : null;
|
||||
cpuChart?.render(); ramChart?.render(); diskChart?.render();
|
||||
|
||||
function pushSparkline(chart, val) {
|
||||
if (!chart) return;
|
||||
let d = chart.w.globals.series[0].slice();
|
||||
d.push(val); if (d.length > 10) d.shift();
|
||||
chart.updateSeries([{ data: d }]);
|
||||
}
|
||||
|
||||
function updateVal(sel, newVal) {
|
||||
const el = $(sel);
|
||||
if (el.text() !== String(newVal)) {
|
||||
el.text(newVal).addClass('animate__animated animate__pulse');
|
||||
setTimeout(() => el.removeClass('animate__animated animate__pulse'), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function applyStats(d) {
|
||||
if (d.cpu !== undefined) { updateVal('#stat-cpu-percent', d.cpu + '%'); $('#cpu-bar').css('width', d.cpu + '%'); pushSparkline(cpuChart, d.cpu); }
|
||||
if (d.ram) { updateVal('#stat-ram-percent', d.ram.percentage + '%'); $('#stat-ram-used').text(d.ram.used + ' used'); if (d.ram.swap) $('#stat-swap-info').text('Swap: ' + d.ram.swap.percentage + '%'); pushSparkline(ramChart, d.ram.percentage); }
|
||||
if (d.disk) { updateVal('#stat-disk-percent', d.disk.percentage + '%'); $('#stat-disk-total').text(d.disk.free + ' available'); pushSparkline(diskChart, d.disk.percentage); }
|
||||
if (d.users){ updateVal('#stat-users-count', d.users.total); $('#stat-users-auth').text(d.users.authenticated); }
|
||||
if (d.queues){ updateVal('#stat-queues-pending', d.queues.pending); updateVal('#stat-queues-failed', d.queues.failed); }
|
||||
if (d.uptime) $('#stat-uptime-badge').text(d.uptime);
|
||||
const reverbOn = d.has_reverb;
|
||||
if (reverbOn !== undefined) {
|
||||
$('#reverb-icon').toggleClass('active', !!reverbOn);
|
||||
$('#reverb-status-text').text(reverbOn ? 'ACTIVE' : 'IDLE').toggleClass('text-success', !!reverbOn).toggleClass('text-muted', !reverbOn);
|
||||
}
|
||||
}
|
||||
|
||||
@can('view health and logs')
|
||||
// ── Activity DataTable ─────────────────────────────────────
|
||||
const logsTable = $('#logs-datatable').DataTable({
|
||||
processing: true, serverSide: true,
|
||||
ajax: '{{ route("system-monitoring.logs.datatable") }}',
|
||||
order: [[0,'desc']], pageLength: 5, autoWidth: false,
|
||||
dom: 'tr<"p-3 border-top d-flex justify-content-end"p>',
|
||||
columns: [
|
||||
{ data: 0, className: 'ps-4 datetime-col fw-bold' },
|
||||
{ data: 1 }, { data: 2 },
|
||||
{ data: 3, className: 'pe-4 text-end', orderable: false }
|
||||
],
|
||||
drawCallback: function () {
|
||||
$('.view-log-detail').off('click').on('click', function () {
|
||||
const log = $(this).data('log');
|
||||
renderTerminalModal(log);
|
||||
new bootstrap.Modal('#logDetailModal').show();
|
||||
});
|
||||
}
|
||||
});
|
||||
@endcan
|
||||
|
||||
// ── Manual refresh ─────────────────────────────────────────
|
||||
function refreshStats() {
|
||||
const btn = $('#refresh-all-stats');
|
||||
btn.find('i').addClass('bi-spin');
|
||||
$.get('{{ route("system-monitoring.stats") }}', function (d) {
|
||||
applyStats(d);
|
||||
@can('view health and logs')
|
||||
logsTable.ajax.reload(null, false);
|
||||
@endcan
|
||||
setTimeout(() => btn.find('i').removeClass('bi-spin'), 1000);
|
||||
});
|
||||
}
|
||||
$('#refresh-all-stats').on('click', refreshStats);
|
||||
|
||||
// ── Real-time via Reverb ───────────────────────────────────
|
||||
if (window.Echo) {
|
||||
@can('view health and logs')
|
||||
window.Echo.private('admin.monitoring')
|
||||
.listen('.stats.updated', applyStats)
|
||||
.listen('.activity.created', (e) => {
|
||||
const rowNode = logsTable.row.add([
|
||||
e.log.datetime, e.log.level, e.log.manifest,
|
||||
`<button class="btn btn-square btn-light btn-sm rounded-circle view-log-detail"
|
||||
data-log='${JSON.stringify({ message: e.log.description }).replace(/'/g,"'")}'>
|
||||
<i class="bi bi-info-circle"></i></button>`
|
||||
]).draw(false).node();
|
||||
$(rowNode).css('background-color','#fff9c4');
|
||||
setTimeout(() => $(rowNode).css('background-color',''), 3000);
|
||||
$(rowNode).find('.view-log-detail').on('click', function () {
|
||||
const log = $(this).data('log');
|
||||
renderTerminalModal(log);
|
||||
new bootstrap.Modal('#logDetailModal').show();
|
||||
});
|
||||
});
|
||||
@endcan
|
||||
} else {
|
||||
// Fallback polling when Reverb not connected
|
||||
setInterval(refreshStats, 30000);
|
||||
}
|
||||
|
||||
// ── Terminal Modal Renderer ────────────────────────────────
|
||||
function renderTerminalModal(log) {
|
||||
const msg = log.message || '';
|
||||
// Format markdown-ish text for terminal display
|
||||
const formatted = msg
|
||||
.replace(/\*\*(.*?)\*\*/g, '$1') // strip bold markers, keep text
|
||||
.replace(/^#+\s+/gm, '>> ') // headings → terminal prefix
|
||||
.replace(/^(\d+\.)/gm, ' $1'); // indent numbered lists
|
||||
$('#terminal-output').text(formatted);
|
||||
// Animate a typing cursor effect
|
||||
$('#terminal-output').append('<span id="cursor-blink" style="display:inline-block;width:8px;height:14px;background:#3fb950;vertical-align:middle;margin-left:4px;animation:blink-cursor 1s step-start infinite;"></span>');
|
||||
}
|
||||
|
||||
// ── AI Analysis ────────────────────────────────────────────
|
||||
function renderAiContent(text) {
|
||||
const html = text
|
||||
.replace(/### (.*)/g, '<h6 class="fw-bold text-dark mt-3 mb-2">$1</h6>')
|
||||
.replace(/## (.*)/g, '<h6 class="fw-bold text-dark mt-3 mb-2">$1</h6>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\n/g, '<br>');
|
||||
$('#ai-content-display').html(html);
|
||||
}
|
||||
|
||||
@can('view ai log analysis')
|
||||
$.get('{{ route("ai.log-analysis.index") }}', function (d) {
|
||||
if (d.analysis && !d.analysis.includes('Analysis not generated yet')) renderAiContent(d.analysis);
|
||||
});
|
||||
|
||||
$('#btn-ai-analyze').on('click', function () {
|
||||
const btn = $(this);
|
||||
btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span> Analyzing...');
|
||||
$('#ai-content-display').fadeOut(200, function () {
|
||||
$('#ai-placeholder').show();
|
||||
$.post('{{ route("ai.log-analysis.analyze") }}', { _token: '{{ csrf_token() }}' }, function (d) {
|
||||
renderAiContent(d.analysis);
|
||||
$('#ai-placeholder').hide();
|
||||
$('#ai-content-display').fadeIn();
|
||||
btn.prop('disabled', false).html('<i class="bi bi-cpu me-1"></i>Analyze');
|
||||
}).fail(function () {
|
||||
$('#ai-content-display').html('<div class="alert alert-danger p-2 small"><i class="bi bi-exclamation-triangle me-2"></i> Error connecting to AI service.</div>').fadeIn();
|
||||
$('#ai-placeholder').hide();
|
||||
btn.prop('disabled', false).html('<i class="bi bi-cpu me-1"></i>Analyze');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$('#btn-ai-clear').on('click', function () {
|
||||
const btn = $(this);
|
||||
btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm"></span>');
|
||||
$.post('{{ route("ai.log-analysis.clear") }}', { _token: '{{ csrf_token() }}' }, function () {
|
||||
$('#ai-content-display').fadeOut(200, function () {
|
||||
$(this).html(`<div class="text-center py-5 opacity-50"><i class="bi bi-robot display-4 d-block mb-3"></i><p class="fst-italic">Click analyze to get security insights.</p></div>`).fadeIn();
|
||||
});
|
||||
btn.prop('disabled', false).html('<i class="bi bi-trash me-1"></i>Clear');
|
||||
}).fail(() => btn.prop('disabled', false).html('<i class="bi bi-trash me-1"></i>Clear'));
|
||||
});
|
||||
@endcan
|
||||
|
||||
// ── Widget Customize ───────────────────────────────────────
|
||||
$('#btn-customize-widgets').on('click', function () {
|
||||
$('#widget-customize-panel').slideToggle(200);
|
||||
});
|
||||
|
||||
function updateBigWidgetLayout() {
|
||||
const visible = $('#widget-grid-big .widget-col:not(.hidden-widget)');
|
||||
const vCount = visible.length;
|
||||
let colClass = 'col-12';
|
||||
if (vCount === 2) colClass = 'col-12 col-lg-6';
|
||||
if (vCount >= 3) colClass = 'col-12 col-lg-4';
|
||||
|
||||
$('#widget-grid-big .widget-col')
|
||||
.removeClass('col-12 col-lg-6 col-lg-4')
|
||||
.addClass(colClass);
|
||||
}
|
||||
updateBigWidgetLayout(); // Initial call
|
||||
|
||||
$(document).on('change', '.widget-visibility-toggle', function () {
|
||||
const key = $(this).data('widget');
|
||||
const visible = $(this).is(':checked');
|
||||
$(this).closest('.widget-toggle-item').toggleClass('active', visible);
|
||||
$(`[data-widget-key="${key}"]`).toggleClass('hidden-widget', !visible);
|
||||
|
||||
updateBigWidgetLayout();
|
||||
|
||||
// Show/hide empty state
|
||||
const anyVisible = $('.widget-visibility-toggle:checked').length > 0;
|
||||
$('#dashboard-empty-state').toggle(!anyVisible);
|
||||
});
|
||||
|
||||
$('#btn-toggle-all-widgets').on('click', function() {
|
||||
const allChecked = $('.widget-visibility-toggle:checked').length === $('.widget-visibility-toggle').length;
|
||||
$('.widget-visibility-toggle').prop('checked', !allChecked).trigger('change');
|
||||
});
|
||||
|
||||
// Drag-to-reorder stats
|
||||
if (document.getElementById('widget-grid-stats')) {
|
||||
Sortable.create(document.getElementById('widget-grid-stats'), {
|
||||
animation: 150, handle: '.card', ghostClass: 'widget-ghost', chosenClass: 'widget-chosen',
|
||||
});
|
||||
}
|
||||
|
||||
// Drag-to-reorder big widgets
|
||||
if (document.getElementById('widget-grid-big')) {
|
||||
Sortable.create(document.getElementById('widget-grid-big'), {
|
||||
animation: 150, handle: '.card', ghostClass: 'widget-ghost', chosenClass: 'widget-chosen',
|
||||
});
|
||||
}
|
||||
|
||||
// Save layout
|
||||
$('#btn-save-widgets').on('click', function () {
|
||||
const btn = $(this);
|
||||
const originalHtml = btn.html();
|
||||
btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span> Saving...');
|
||||
|
||||
const widgets = [];
|
||||
// Collect from both grids
|
||||
$('.widget-col').each(function (i) {
|
||||
widgets.push({
|
||||
key: $(this).data('widget-key'),
|
||||
visible: !$(this).hasClass('hidden-widget'),
|
||||
sort_order: i + 1,
|
||||
});
|
||||
});
|
||||
|
||||
$.ajax({
|
||||
url: '{{ route("dashboard.widgets.save") }}',
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({ widgets }),
|
||||
success: () => {
|
||||
btn.prop('disabled', false).html('<i class="bi bi-check2 me-1"></i>Saved!');
|
||||
setTimeout(() => btn.html(originalHtml), 2000);
|
||||
if (window.showNotificationToast) {
|
||||
window.showNotificationToast('success', 'Dashboard layout updated successfully');
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
btn.prop('disabled', false).html('<i class="bi bi-exclamation-circle me-1"></i>Error');
|
||||
setTimeout(() => btn.html(originalHtml), 2000);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
$('#form-reset-widgets').on('submit', function() {
|
||||
$(this).find('button').prop('disabled', true).html('<span class="spinner-border spinner-border-sm"></span>');
|
||||
});
|
||||
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
</x-app-layout>
|
||||
Reference in New Issue
Block a user