feat(agentic): add real-time dashboard with Livewire components (#96)
Add a live agent activity dashboard to the Core App Laravel frontend. Provides real-time visibility into agent fleet status, job queue, activity feed, metrics, and human-in-the-loop actions — replacing SSH + tail -f as the operator interface. Dashboard panels: - Agent Fleet: grid of agent cards with heartbeat, status, model info - Job Queue: filterable table with cancel/retry actions - Live Activity Feed: real-time stream with agent/type filters - Metrics: stat cards, budget gauge, cost breakdown, throughput chart - Human Actions: inline question answering, review gate approval Tech: Laravel Blade + Livewire 4 + Tailwind CSS + Alpine.js + ApexCharts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f4ff0495f6
commit
f174650f37
16 changed files with 1061 additions and 0 deletions
111
cmd/core-app/laravel/app/Livewire/Dashboard/ActivityFeed.php
Normal file
111
cmd/core-app/laravel/app/Livewire/Dashboard/ActivityFeed.php
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Livewire\Dashboard;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class ActivityFeed extends Component
|
||||||
|
{
|
||||||
|
public array $entries = [];
|
||||||
|
public string $agentFilter = 'all';
|
||||||
|
public string $typeFilter = 'all';
|
||||||
|
public bool $showOnlyQuestions = false;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->loadEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadEntries(): void
|
||||||
|
{
|
||||||
|
// Placeholder data — will be replaced with real-time WebSocket feed
|
||||||
|
$this->entries = [
|
||||||
|
[
|
||||||
|
'id' => 'act-001',
|
||||||
|
'agent' => 'Athena',
|
||||||
|
'type' => 'code_write',
|
||||||
|
'message' => 'Created AgentFleet Livewire component',
|
||||||
|
'job' => '#96',
|
||||||
|
'timestamp' => now()->subMinutes(2)->toIso8601String(),
|
||||||
|
'is_question' => false,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'act-002',
|
||||||
|
'agent' => 'Athena',
|
||||||
|
'type' => 'tool_call',
|
||||||
|
'message' => 'Read file: cmd/core-app/laravel/composer.json',
|
||||||
|
'job' => '#96',
|
||||||
|
'timestamp' => now()->subMinutes(5)->toIso8601String(),
|
||||||
|
'is_question' => false,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'act-003',
|
||||||
|
'agent' => 'Clotho',
|
||||||
|
'type' => 'question',
|
||||||
|
'message' => 'Should I apply the fix to both the TCP and Unix socket transports, or just TCP?',
|
||||||
|
'job' => '#84',
|
||||||
|
'timestamp' => now()->subMinutes(8)->toIso8601String(),
|
||||||
|
'is_question' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'act-004',
|
||||||
|
'agent' => 'Virgil',
|
||||||
|
'type' => 'pr_created',
|
||||||
|
'message' => 'Opened PR #89: fix WebSocket reconnection logic',
|
||||||
|
'job' => '#89',
|
||||||
|
'timestamp' => now()->subMinutes(15)->toIso8601String(),
|
||||||
|
'is_question' => false,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'act-005',
|
||||||
|
'agent' => 'Virgil',
|
||||||
|
'type' => 'test_run',
|
||||||
|
'message' => 'All 47 tests passed (0.8s)',
|
||||||
|
'job' => '#89',
|
||||||
|
'timestamp' => now()->subMinutes(18)->toIso8601String(),
|
||||||
|
'is_question' => false,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'act-006',
|
||||||
|
'agent' => 'Athena',
|
||||||
|
'type' => 'git_push',
|
||||||
|
'message' => 'Pushed branch feat/agentic-dashboard',
|
||||||
|
'job' => '#96',
|
||||||
|
'timestamp' => now()->subMinutes(22)->toIso8601String(),
|
||||||
|
'is_question' => false,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'act-007',
|
||||||
|
'agent' => 'Clotho',
|
||||||
|
'type' => 'code_write',
|
||||||
|
'message' => 'Added input validation for MCP file_write paths',
|
||||||
|
'job' => '#84',
|
||||||
|
'timestamp' => now()->subMinutes(30)->toIso8601String(),
|
||||||
|
'is_question' => false,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFilteredEntriesProperty(): array
|
||||||
|
{
|
||||||
|
return array_filter($this->entries, function ($entry) {
|
||||||
|
if ($this->showOnlyQuestions && !$entry['is_question']) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($this->agentFilter !== 'all' && $entry['agent'] !== $this->agentFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($this->typeFilter !== 'all' && $entry['type'] !== $this->typeFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.dashboard.activity-feed');
|
||||||
|
}
|
||||||
|
}
|
||||||
85
cmd/core-app/laravel/app/Livewire/Dashboard/AgentFleet.php
Normal file
85
cmd/core-app/laravel/app/Livewire/Dashboard/AgentFleet.php
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Livewire\Dashboard;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class AgentFleet extends Component
|
||||||
|
{
|
||||||
|
/** @var array<int, array{name: string, host: string, model: string, status: string, job: string, heartbeat: string, uptime: string}> */
|
||||||
|
public array $agents = [];
|
||||||
|
|
||||||
|
public ?string $selectedAgent = null;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->loadAgents();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadAgents(): void
|
||||||
|
{
|
||||||
|
// Placeholder data — will be replaced with real API calls to Go backend
|
||||||
|
$this->agents = [
|
||||||
|
[
|
||||||
|
'id' => 'athena',
|
||||||
|
'name' => 'Athena',
|
||||||
|
'host' => 'studio.snider.dev',
|
||||||
|
'model' => 'claude-opus-4-6',
|
||||||
|
'status' => 'working',
|
||||||
|
'job' => '#96 agentic dashboard',
|
||||||
|
'heartbeat' => 'green',
|
||||||
|
'uptime' => '4h 23m',
|
||||||
|
'tokens_today' => 142_580,
|
||||||
|
'jobs_completed' => 3,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'virgil',
|
||||||
|
'name' => 'Virgil',
|
||||||
|
'host' => 'studio.snider.dev',
|
||||||
|
'model' => 'claude-opus-4-6',
|
||||||
|
'status' => 'idle',
|
||||||
|
'job' => '',
|
||||||
|
'heartbeat' => 'green',
|
||||||
|
'uptime' => '12h 07m',
|
||||||
|
'tokens_today' => 89_230,
|
||||||
|
'jobs_completed' => 5,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'clotho',
|
||||||
|
'name' => 'Clotho',
|
||||||
|
'host' => 'darwin-au',
|
||||||
|
'model' => 'claude-sonnet-4-5',
|
||||||
|
'status' => 'working',
|
||||||
|
'job' => '#84 security audit',
|
||||||
|
'heartbeat' => 'yellow',
|
||||||
|
'uptime' => '1h 45m',
|
||||||
|
'tokens_today' => 34_100,
|
||||||
|
'jobs_completed' => 1,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'charon',
|
||||||
|
'name' => 'Charon',
|
||||||
|
'host' => 'linux.snider.dev',
|
||||||
|
'model' => 'claude-haiku-4-5',
|
||||||
|
'status' => 'unhealthy',
|
||||||
|
'job' => '',
|
||||||
|
'heartbeat' => 'red',
|
||||||
|
'uptime' => '0m',
|
||||||
|
'tokens_today' => 0,
|
||||||
|
'jobs_completed' => 0,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function selectAgent(string $agentId): void
|
||||||
|
{
|
||||||
|
$this->selectedAgent = $this->selectedAgent === $agentId ? null : $agentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.dashboard.agent-fleet');
|
||||||
|
}
|
||||||
|
}
|
||||||
93
cmd/core-app/laravel/app/Livewire/Dashboard/HumanActions.php
Normal file
93
cmd/core-app/laravel/app/Livewire/Dashboard/HumanActions.php
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Livewire\Dashboard;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class HumanActions extends Component
|
||||||
|
{
|
||||||
|
public array $pendingQuestions = [];
|
||||||
|
public array $reviewGates = [];
|
||||||
|
public string $answerText = '';
|
||||||
|
public ?string $answeringId = null;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->loadPending();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadPending(): void
|
||||||
|
{
|
||||||
|
// Placeholder data — will be replaced with real data from Go backend
|
||||||
|
$this->pendingQuestions = [
|
||||||
|
[
|
||||||
|
'id' => 'q-001',
|
||||||
|
'agent' => 'Clotho',
|
||||||
|
'job' => '#84',
|
||||||
|
'question' => 'Should I apply the fix to both the TCP and Unix socket transports, or just TCP?',
|
||||||
|
'asked_at' => now()->subMinutes(8)->toIso8601String(),
|
||||||
|
'context' => 'Working on security audit — found unvalidated input in transport layer.',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->reviewGates = [
|
||||||
|
[
|
||||||
|
'id' => 'rg-001',
|
||||||
|
'agent' => 'Virgil',
|
||||||
|
'job' => '#89',
|
||||||
|
'type' => 'pr_review',
|
||||||
|
'title' => 'PR #89: fix WebSocket reconnection logic',
|
||||||
|
'description' => 'Adds exponential backoff and connection state tracking.',
|
||||||
|
'submitted_at' => now()->subMinutes(15)->toIso8601String(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function startAnswer(string $questionId): void
|
||||||
|
{
|
||||||
|
$this->answeringId = $questionId;
|
||||||
|
$this->answerText = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function submitAnswer(): void
|
||||||
|
{
|
||||||
|
if (! $this->answeringId || trim($this->answerText) === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove answered question from list
|
||||||
|
$this->pendingQuestions = array_values(
|
||||||
|
array_filter($this->pendingQuestions, fn ($q) => $q['id'] !== $this->answeringId)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->answeringId = null;
|
||||||
|
$this->answerText = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancelAnswer(): void
|
||||||
|
{
|
||||||
|
$this->answeringId = null;
|
||||||
|
$this->answerText = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approveGate(string $gateId): void
|
||||||
|
{
|
||||||
|
$this->reviewGates = array_values(
|
||||||
|
array_filter($this->reviewGates, fn ($g) => $g['id'] !== $gateId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rejectGate(string $gateId): void
|
||||||
|
{
|
||||||
|
$this->reviewGates = array_values(
|
||||||
|
array_filter($this->reviewGates, fn ($g) => $g['id'] !== $gateId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.dashboard.human-actions');
|
||||||
|
}
|
||||||
|
}
|
||||||
125
cmd/core-app/laravel/app/Livewire/Dashboard/JobQueue.php
Normal file
125
cmd/core-app/laravel/app/Livewire/Dashboard/JobQueue.php
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Livewire\Dashboard;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class JobQueue extends Component
|
||||||
|
{
|
||||||
|
public array $jobs = [];
|
||||||
|
public string $statusFilter = 'all';
|
||||||
|
public string $agentFilter = 'all';
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->loadJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadJobs(): void
|
||||||
|
{
|
||||||
|
// Placeholder data — will be replaced with real API calls to Go backend
|
||||||
|
$this->jobs = [
|
||||||
|
[
|
||||||
|
'id' => 'job-001',
|
||||||
|
'issue' => '#96',
|
||||||
|
'repo' => 'host-uk/core',
|
||||||
|
'title' => 'feat(agentic): real-time dashboard',
|
||||||
|
'agent' => 'Athena',
|
||||||
|
'status' => 'in_progress',
|
||||||
|
'priority' => 1,
|
||||||
|
'queued_at' => now()->subMinutes(45)->toIso8601String(),
|
||||||
|
'started_at' => now()->subMinutes(30)->toIso8601String(),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'job-002',
|
||||||
|
'issue' => '#84',
|
||||||
|
'repo' => 'host-uk/core',
|
||||||
|
'title' => 'fix: security audit findings',
|
||||||
|
'agent' => 'Clotho',
|
||||||
|
'status' => 'in_progress',
|
||||||
|
'priority' => 2,
|
||||||
|
'queued_at' => now()->subHours(2)->toIso8601String(),
|
||||||
|
'started_at' => now()->subHours(1)->toIso8601String(),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'job-003',
|
||||||
|
'issue' => '#102',
|
||||||
|
'repo' => 'host-uk/core',
|
||||||
|
'title' => 'feat: add rate limiting to MCP',
|
||||||
|
'agent' => null,
|
||||||
|
'status' => 'queued',
|
||||||
|
'priority' => 3,
|
||||||
|
'queued_at' => now()->subMinutes(10)->toIso8601String(),
|
||||||
|
'started_at' => null,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'job-004',
|
||||||
|
'issue' => '#89',
|
||||||
|
'repo' => 'host-uk/core',
|
||||||
|
'title' => 'fix: WebSocket reconnection',
|
||||||
|
'agent' => 'Virgil',
|
||||||
|
'status' => 'review',
|
||||||
|
'priority' => 2,
|
||||||
|
'queued_at' => now()->subHours(4)->toIso8601String(),
|
||||||
|
'started_at' => now()->subHours(3)->toIso8601String(),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'job-005',
|
||||||
|
'issue' => '#78',
|
||||||
|
'repo' => 'host-uk/core',
|
||||||
|
'title' => 'docs: update CLAUDE.md',
|
||||||
|
'agent' => 'Virgil',
|
||||||
|
'status' => 'completed',
|
||||||
|
'priority' => 4,
|
||||||
|
'queued_at' => now()->subHours(6)->toIso8601String(),
|
||||||
|
'started_at' => now()->subHours(5)->toIso8601String(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedStatusFilter(): void
|
||||||
|
{
|
||||||
|
// Livewire auto-updates the view
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancelJob(string $jobId): void
|
||||||
|
{
|
||||||
|
$this->jobs = array_map(function ($job) use ($jobId) {
|
||||||
|
if ($job['id'] === $jobId && in_array($job['status'], ['queued', 'in_progress'])) {
|
||||||
|
$job['status'] = 'cancelled';
|
||||||
|
}
|
||||||
|
return $job;
|
||||||
|
}, $this->jobs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function retryJob(string $jobId): void
|
||||||
|
{
|
||||||
|
$this->jobs = array_map(function ($job) use ($jobId) {
|
||||||
|
if ($job['id'] === $jobId && in_array($job['status'], ['failed', 'cancelled'])) {
|
||||||
|
$job['status'] = 'queued';
|
||||||
|
$job['agent'] = null;
|
||||||
|
}
|
||||||
|
return $job;
|
||||||
|
}, $this->jobs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFilteredJobsProperty(): array
|
||||||
|
{
|
||||||
|
return array_filter($this->jobs, function ($job) {
|
||||||
|
if ($this->statusFilter !== 'all' && $job['status'] !== $this->statusFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($this->agentFilter !== 'all' && ($job['agent'] ?? '') !== $this->agentFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.dashboard.job-queue');
|
||||||
|
}
|
||||||
|
}
|
||||||
60
cmd/core-app/laravel/app/Livewire/Dashboard/Metrics.php
Normal file
60
cmd/core-app/laravel/app/Livewire/Dashboard/Metrics.php
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Livewire\Dashboard;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Metrics extends Component
|
||||||
|
{
|
||||||
|
public array $stats = [];
|
||||||
|
public array $throughputData = [];
|
||||||
|
public array $costBreakdown = [];
|
||||||
|
public float $budgetUsed = 0;
|
||||||
|
public float $budgetLimit = 0;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->loadMetrics();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadMetrics(): void
|
||||||
|
{
|
||||||
|
// Placeholder data — will be replaced with real metrics from Go backend
|
||||||
|
$this->stats = [
|
||||||
|
'jobs_completed' => 12,
|
||||||
|
'prs_merged' => 8,
|
||||||
|
'tokens_used' => 1_245_800,
|
||||||
|
'cost_today' => 18.42,
|
||||||
|
'active_agents' => 3,
|
||||||
|
'queue_depth' => 4,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->budgetUsed = 18.42;
|
||||||
|
$this->budgetLimit = 50.00;
|
||||||
|
|
||||||
|
// Hourly throughput for chart
|
||||||
|
$this->throughputData = [
|
||||||
|
['hour' => '00:00', 'jobs' => 0, 'tokens' => 0],
|
||||||
|
['hour' => '02:00', 'jobs' => 0, 'tokens' => 0],
|
||||||
|
['hour' => '04:00', 'jobs' => 1, 'tokens' => 45_000],
|
||||||
|
['hour' => '06:00', 'jobs' => 2, 'tokens' => 120_000],
|
||||||
|
['hour' => '08:00', 'jobs' => 3, 'tokens' => 195_000],
|
||||||
|
['hour' => '10:00', 'jobs' => 2, 'tokens' => 280_000],
|
||||||
|
['hour' => '12:00', 'jobs' => 1, 'tokens' => 340_000],
|
||||||
|
['hour' => '14:00', 'jobs' => 3, 'tokens' => 450_000],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->costBreakdown = [
|
||||||
|
['model' => 'claude-opus-4-6', 'cost' => 12.80, 'tokens' => 856_000],
|
||||||
|
['model' => 'claude-sonnet-4-5', 'cost' => 4.20, 'tokens' => 312_000],
|
||||||
|
['model' => 'claude-haiku-4-5', 'cost' => 1.42, 'tokens' => 77_800],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.dashboard.metrics');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="h-full">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ $title ?? 'Agentic Dashboard' }} — Core</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
surface: { DEFAULT: '#0d1117', raised: '#161b22', overlay: '#21262d' },
|
||||||
|
border: { DEFAULT: '#30363d', subtle: '#21262d' },
|
||||||
|
accent: { DEFAULT: '#39d0d8', dim: '#1b6b6f' },
|
||||||
|
success: '#238636',
|
||||||
|
warning: '#d29922',
|
||||||
|
danger: '#da3633',
|
||||||
|
muted: '#8b949e',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
|
||||||
|
<style>
|
||||||
|
[x-cloak] { display: none !important; }
|
||||||
|
@keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: .4; } }
|
||||||
|
.heartbeat { animation: pulse-dot 2s ease-in-out infinite; }
|
||||||
|
.scrollbar-thin::-webkit-scrollbar { width: 6px; }
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-thumb { background: #30363d; border-radius: 3px; }
|
||||||
|
</style>
|
||||||
|
@livewireStyles
|
||||||
|
</head>
|
||||||
|
<body class="h-full bg-surface text-gray-200 antialiased">
|
||||||
|
<div class="flex h-full" x-data="{ sidebarOpen: true }">
|
||||||
|
{{-- Sidebar --}}
|
||||||
|
<aside class="flex flex-col w-56 border-r border-border bg-surface-raised shrink-0 transition-all"
|
||||||
|
:class="sidebarOpen ? 'w-56' : 'w-16'">
|
||||||
|
<div class="flex items-center gap-2 px-4 h-14 border-b border-border">
|
||||||
|
<svg class="w-6 h-6 text-accent shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="font-semibold text-sm tracking-wide" x-show="sidebarOpen" x-cloak>Agentic</span>
|
||||||
|
</div>
|
||||||
|
<nav class="flex-1 py-2 space-y-0.5 px-2">
|
||||||
|
<a href="{{ route('dashboard') }}"
|
||||||
|
class="flex items-center gap-3 px-3 py-2 text-sm rounded-md {{ request()->routeIs('dashboard') ? 'bg-surface-overlay text-white' : 'text-muted hover:bg-surface-overlay hover:text-white' }} transition">
|
||||||
|
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>
|
||||||
|
<span x-show="sidebarOpen">Dashboard</span>
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('dashboard.agents') }}"
|
||||||
|
class="flex items-center gap-3 px-3 py-2 text-sm rounded-md {{ request()->routeIs('dashboard.agents') ? 'bg-surface-overlay text-white' : 'text-muted hover:bg-surface-overlay hover:text-white' }} transition">
|
||||||
|
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||||
|
<span x-show="sidebarOpen">Agent Fleet</span>
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('dashboard.jobs') }}"
|
||||||
|
class="flex items-center gap-3 px-3 py-2 text-sm rounded-md {{ request()->routeIs('dashboard.jobs') ? 'bg-surface-overlay text-white' : 'text-muted hover:bg-surface-overlay hover:text-white' }} transition">
|
||||||
|
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
|
||||||
|
<span x-show="sidebarOpen">Job Queue</span>
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('dashboard.activity') }}"
|
||||||
|
class="flex items-center gap-3 px-3 py-2 text-sm rounded-md {{ request()->routeIs('dashboard.activity') ? 'bg-surface-overlay text-white' : 'text-muted hover:bg-surface-overlay hover:text-white' }} transition">
|
||||||
|
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
|
||||||
|
<span x-show="sidebarOpen">Activity</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<div class="border-t border-border p-2">
|
||||||
|
<button @click="sidebarOpen = !sidebarOpen"
|
||||||
|
class="flex items-center justify-center w-full px-3 py-2 text-muted hover:text-white rounded-md hover:bg-surface-overlay transition">
|
||||||
|
<svg class="w-4 h-4 transition-transform" :class="sidebarOpen ? '' : 'rotate-180'" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{{-- Main content --}}
|
||||||
|
<main class="flex-1 overflow-auto">
|
||||||
|
<header class="sticky top-0 z-10 flex items-center justify-between h-14 px-6 border-b border-border bg-surface/80 backdrop-blur">
|
||||||
|
<h1 class="text-sm font-semibold">{{ $title ?? 'Dashboard' }}</h1>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="flex items-center gap-2 text-xs text-muted"
|
||||||
|
x-data="{ connected: true }"
|
||||||
|
x-init="
|
||||||
|
setInterval(() => {
|
||||||
|
connected = navigator.onLine;
|
||||||
|
}, 3000)
|
||||||
|
">
|
||||||
|
<span class="w-2 h-2 rounded-full heartbeat"
|
||||||
|
:class="connected ? 'bg-green-500' : 'bg-red-500'"></span>
|
||||||
|
<span x-text="connected ? 'Connected' : 'Disconnected'"></span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-muted font-mono">{{ now()->format('H:i') }}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="p-6">
|
||||||
|
{{ $slot }}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
@livewireScripts
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
<x-dashboard-layout title="Live Activity">
|
||||||
|
<livewire:dashboard.activity-feed />
|
||||||
|
</x-dashboard-layout>
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
<x-dashboard-layout title="Agent Fleet">
|
||||||
|
<livewire:dashboard.agent-fleet />
|
||||||
|
</x-dashboard-layout>
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
<x-dashboard-layout title="Dashboard">
|
||||||
|
{{-- Metrics overview at top --}}
|
||||||
|
<section class="mb-8">
|
||||||
|
<livewire:dashboard.metrics />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||||
|
{{-- Left column: Agent fleet + Human actions --}}
|
||||||
|
<div class="xl:col-span-2 space-y-6">
|
||||||
|
<section>
|
||||||
|
<h2 class="text-sm font-semibold text-muted uppercase tracking-wider mb-3">Agent Fleet</h2>
|
||||||
|
<livewire:dashboard.agent-fleet />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 class="text-sm font-semibold text-muted uppercase tracking-wider mb-3">Job Queue</h2>
|
||||||
|
<livewire:dashboard.job-queue />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Right column: Actions + Activity --}}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<section>
|
||||||
|
<h2 class="text-sm font-semibold text-muted uppercase tracking-wider mb-3">Human Actions</h2>
|
||||||
|
<livewire:dashboard.human-actions />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 class="text-sm font-semibold text-muted uppercase tracking-wider mb-3">Live Activity</h2>
|
||||||
|
<livewire:dashboard.activity-feed />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-dashboard-layout>
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
<x-dashboard-layout title="Job Queue">
|
||||||
|
<livewire:dashboard.job-queue />
|
||||||
|
</x-dashboard-layout>
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
<div wire:poll.3s="loadEntries">
|
||||||
|
{{-- Filters --}}
|
||||||
|
<div class="flex flex-wrap items-center gap-3 mb-4">
|
||||||
|
<select wire:model.live="agentFilter"
|
||||||
|
class="bg-surface-overlay border border-border rounded-md px-3 py-1.5 text-xs text-gray-300 focus:border-accent focus:outline-none">
|
||||||
|
<option value="all">All agents</option>
|
||||||
|
<option value="Athena">Athena</option>
|
||||||
|
<option value="Virgil">Virgil</option>
|
||||||
|
<option value="Clotho">Clotho</option>
|
||||||
|
<option value="Charon">Charon</option>
|
||||||
|
</select>
|
||||||
|
<select wire:model.live="typeFilter"
|
||||||
|
class="bg-surface-overlay border border-border rounded-md px-3 py-1.5 text-xs text-gray-300 focus:border-accent focus:outline-none">
|
||||||
|
<option value="all">All types</option>
|
||||||
|
<option value="code_write">Code write</option>
|
||||||
|
<option value="tool_call">Tool call</option>
|
||||||
|
<option value="test_run">Test run</option>
|
||||||
|
<option value="pr_created">PR created</option>
|
||||||
|
<option value="git_push">Git push</option>
|
||||||
|
<option value="question">Question</option>
|
||||||
|
</select>
|
||||||
|
<label class="flex items-center gap-2 text-xs text-muted cursor-pointer">
|
||||||
|
<input type="checkbox" wire:model.live="showOnlyQuestions"
|
||||||
|
class="rounded border-border bg-surface-overlay text-accent focus:ring-accent">
|
||||||
|
Waiting for answer only
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Feed --}}
|
||||||
|
<div class="space-y-2 max-h-[600px] overflow-y-auto scrollbar-thin">
|
||||||
|
@forelse ($this->filteredEntries as $entry)
|
||||||
|
<div class="bg-surface-raised border rounded-lg px-4 py-3 transition
|
||||||
|
{{ $entry['is_question'] ? 'border-yellow-500/50 bg-yellow-500/5' : 'border-border' }}">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
{{-- Type icon --}}
|
||||||
|
@php
|
||||||
|
$typeIcons = [
|
||||||
|
'code_write' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>',
|
||||||
|
'tool_call' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>',
|
||||||
|
'test_run' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>',
|
||||||
|
'pr_created' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"/>',
|
||||||
|
'git_push' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"/>',
|
||||||
|
'question' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01"/>',
|
||||||
|
];
|
||||||
|
$iconPath = $typeIcons[$entry['type']] ?? $typeIcons['tool_call'];
|
||||||
|
$iconColor = $entry['is_question'] ? 'text-yellow-400' : 'text-muted';
|
||||||
|
@endphp
|
||||||
|
<svg class="w-4 h-4 mt-0.5 shrink-0 {{ $iconColor }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">{!! $iconPath !!}</svg>
|
||||||
|
|
||||||
|
{{-- Content --}}
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-0.5">
|
||||||
|
<span class="text-xs font-semibold text-gray-300">{{ $entry['agent'] }}</span>
|
||||||
|
<span class="text-[10px] text-muted font-mono">{{ $entry['job'] }}</span>
|
||||||
|
@if ($entry['is_question'])
|
||||||
|
<span class="text-[10px] px-1.5 py-0.5 rounded bg-yellow-500/20 text-yellow-400 font-medium">NEEDS ANSWER</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-400 leading-relaxed">{{ $entry['message'] }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Timestamp --}}
|
||||||
|
<span class="text-[11px] text-muted shrink-0">
|
||||||
|
{{ \Carbon\Carbon::parse($entry['timestamp'])->diffForHumans(short: true) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div class="text-center py-8 text-muted text-sm">No activity matching filters.</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
<div wire:poll.5s="loadAgents">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
@foreach ($agents as $agent)
|
||||||
|
<div wire:click="selectAgent('{{ $agent['id'] }}')"
|
||||||
|
class="bg-surface-raised border rounded-lg p-4 cursor-pointer transition hover:border-accent
|
||||||
|
{{ $selectedAgent === $agent['id'] ? 'border-accent' : 'border-border' }}">
|
||||||
|
{{-- Header --}}
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-2.5 h-2.5 rounded-full heartbeat
|
||||||
|
{{ $agent['heartbeat'] === 'green' ? 'bg-green-500' : ($agent['heartbeat'] === 'yellow' ? 'bg-yellow-500' : 'bg-red-500') }}"></span>
|
||||||
|
<span class="font-semibold text-sm">{{ $agent['name'] }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-[10px] px-2 py-0.5 rounded-full font-medium uppercase tracking-wider
|
||||||
|
{{ $agent['status'] === 'working' ? 'bg-blue-500/20 text-blue-400' : ($agent['status'] === 'idle' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400') }}">
|
||||||
|
{{ $agent['status'] }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Info --}}
|
||||||
|
<div class="space-y-1.5 text-xs text-muted">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span>Host</span>
|
||||||
|
<span class="text-gray-300 font-mono">{{ $agent['host'] }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span>Model</span>
|
||||||
|
<span class="text-gray-300 font-mono text-[11px]">{{ $agent['model'] }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span>Uptime</span>
|
||||||
|
<span class="text-gray-300">{{ $agent['uptime'] }}</span>
|
||||||
|
</div>
|
||||||
|
@if ($agent['job'])
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span>Job</span>
|
||||||
|
<span class="text-accent text-[11px]">{{ $agent['job'] }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Expanded detail --}}
|
||||||
|
@if ($selectedAgent === $agent['id'])
|
||||||
|
<div class="mt-3 pt-3 border-t border-border space-y-1.5 text-xs text-muted">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span>Tokens today</span>
|
||||||
|
<span class="text-gray-300">{{ number_format($agent['tokens_today']) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span>Jobs completed</span>
|
||||||
|
<span class="text-gray-300">{{ $agent['jobs_completed'] }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
<div wire:poll.3s="loadPending">
|
||||||
|
{{-- Pending questions --}}
|
||||||
|
@if (count($pendingQuestions) > 0)
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-yellow-500 heartbeat"></span>
|
||||||
|
Agent Questions ({{ count($pendingQuestions) }})
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
@foreach ($pendingQuestions as $q)
|
||||||
|
<div class="bg-yellow-500/5 border border-yellow-500/30 rounded-lg p-4">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<span class="text-xs font-semibold text-yellow-400">{{ $q['agent'] }}</span>
|
||||||
|
<span class="text-[10px] text-muted font-mono">{{ $q['job'] }}</span>
|
||||||
|
<span class="text-[10px] text-muted">{{ \Carbon\Carbon::parse($q['asked_at'])->diffForHumans(short: true) }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-300 mb-2">{{ $q['question'] }}</p>
|
||||||
|
@if (!empty($q['context']))
|
||||||
|
<p class="text-xs text-muted mb-3">{{ $q['context'] }}</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($answeringId === $q['id'])
|
||||||
|
<div class="mt-3">
|
||||||
|
<textarea wire:model="answerText"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Type your answer..."
|
||||||
|
class="w-full bg-surface-overlay border border-border rounded-md px-3 py-2 text-sm text-gray-300 placeholder-muted focus:border-accent focus:outline-none resize-none"></textarea>
|
||||||
|
<div class="flex gap-2 mt-2">
|
||||||
|
<button wire:click="submitAnswer"
|
||||||
|
class="px-3 py-1.5 text-xs font-medium rounded bg-accent text-surface hover:opacity-90 transition">
|
||||||
|
Send Answer
|
||||||
|
</button>
|
||||||
|
<button wire:click="cancelAnswer"
|
||||||
|
class="px-3 py-1.5 text-xs font-medium rounded bg-surface-overlay text-muted hover:text-white border border-border transition">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<button wire:click="startAnswer('{{ $q['id'] }}')"
|
||||||
|
class="px-3 py-1.5 text-xs font-medium rounded bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30 transition">
|
||||||
|
Answer
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Review gates --}}
|
||||||
|
@if (count($reviewGates) > 0)
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-purple-500 heartbeat"></span>
|
||||||
|
Review Gates ({{ count($reviewGates) }})
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
@foreach ($reviewGates as $gate)
|
||||||
|
<div class="bg-surface-raised border border-purple-500/30 rounded-lg p-4">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<span class="text-xs font-semibold text-purple-400">{{ $gate['agent'] }}</span>
|
||||||
|
<span class="text-[10px] text-muted font-mono">{{ $gate['job'] }}</span>
|
||||||
|
<span class="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-400 font-medium uppercase">{{ str_replace('_', ' ', $gate['type']) }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm font-medium text-gray-300 mb-1">{{ $gate['title'] }}</p>
|
||||||
|
<p class="text-xs text-muted mb-3">{{ $gate['description'] }}</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button wire:click="approveGate('{{ $gate['id'] }}')"
|
||||||
|
class="px-3 py-1.5 text-xs font-medium rounded bg-green-500/20 text-green-400 hover:bg-green-500/30 transition">
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
<button wire:click="rejectGate('{{ $gate['id'] }}')"
|
||||||
|
class="px-3 py-1.5 text-xs font-medium rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 transition">
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (count($pendingQuestions) === 0 && count($reviewGates) === 0)
|
||||||
|
<div class="text-center py-12 text-muted">
|
||||||
|
<svg class="w-8 h-8 mx-auto mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm">No pending actions. All agents are autonomous.</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
<div wire:poll.5s="loadJobs">
|
||||||
|
{{-- Filters --}}
|
||||||
|
<div class="flex flex-wrap gap-3 mb-4">
|
||||||
|
<select wire:model.live="statusFilter"
|
||||||
|
class="bg-surface-overlay border border-border rounded-md px-3 py-1.5 text-xs text-gray-300 focus:border-accent focus:outline-none">
|
||||||
|
<option value="all">All statuses</option>
|
||||||
|
<option value="queued">Queued</option>
|
||||||
|
<option value="in_progress">In Progress</option>
|
||||||
|
<option value="review">Review</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
<option value="failed">Failed</option>
|
||||||
|
<option value="cancelled">Cancelled</option>
|
||||||
|
</select>
|
||||||
|
<select wire:model.live="agentFilter"
|
||||||
|
class="bg-surface-overlay border border-border rounded-md px-3 py-1.5 text-xs text-gray-300 focus:border-accent focus:outline-none">
|
||||||
|
<option value="all">All agents</option>
|
||||||
|
<option value="Athena">Athena</option>
|
||||||
|
<option value="Virgil">Virgil</option>
|
||||||
|
<option value="Clotho">Clotho</option>
|
||||||
|
<option value="Charon">Charon</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Table --}}
|
||||||
|
<div class="bg-surface-raised border border-border rounded-lg overflow-hidden">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-border text-xs text-muted uppercase tracking-wider">
|
||||||
|
<th class="text-left px-4 py-3 font-medium">Job</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium">Issue</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium">Agent</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium">Status</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium">Priority</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium">Queued</th>
|
||||||
|
<th class="text-right px-4 py-3 font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-border">
|
||||||
|
@forelse ($this->filteredJobs as $job)
|
||||||
|
<tr class="hover:bg-surface-overlay/50 transition">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="font-mono text-xs text-muted">{{ $job['id'] }}</div>
|
||||||
|
<div class="text-xs text-gray-300 mt-0.5 truncate max-w-[200px]">{{ $job['title'] }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class="text-accent font-mono text-xs">{{ $job['issue'] }}</span>
|
||||||
|
<div class="text-[11px] text-muted">{{ $job['repo'] }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-xs">
|
||||||
|
{{ $job['agent'] ?? '—' }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
@php
|
||||||
|
$statusColors = [
|
||||||
|
'queued' => 'bg-yellow-500/20 text-yellow-400',
|
||||||
|
'in_progress' => 'bg-blue-500/20 text-blue-400',
|
||||||
|
'review' => 'bg-purple-500/20 text-purple-400',
|
||||||
|
'completed' => 'bg-green-500/20 text-green-400',
|
||||||
|
'failed' => 'bg-red-500/20 text-red-400',
|
||||||
|
'cancelled' => 'bg-gray-500/20 text-gray-400',
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
<span class="text-[10px] px-2 py-0.5 rounded-full font-medium uppercase tracking-wider {{ $statusColors[$job['status']] ?? '' }}">
|
||||||
|
{{ str_replace('_', ' ', $job['status']) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class="text-xs font-mono text-muted">P{{ $job['priority'] }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-xs text-muted">
|
||||||
|
{{ \Carbon\Carbon::parse($job['queued_at'])->diffForHumans(short: true) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<div class="flex items-center justify-end gap-1">
|
||||||
|
@if (in_array($job['status'], ['queued', 'in_progress']))
|
||||||
|
<button wire:click="cancelJob('{{ $job['id'] }}')"
|
||||||
|
class="text-[11px] px-2 py-1 rounded bg-red-500/10 text-red-400 hover:bg-red-500/20 transition">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
@if (in_array($job['status'], ['failed', 'cancelled']))
|
||||||
|
<button wire:click="retryJob('{{ $job['id'] }}')"
|
||||||
|
class="text-[11px] px-2 py-1 rounded bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition">
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="px-4 py-8 text-center text-muted text-sm">No jobs match the selected filters.</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
<div wire:poll.10s="loadMetrics">
|
||||||
|
{{-- Stat cards --}}
|
||||||
|
<div class="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4 mb-6">
|
||||||
|
@php
|
||||||
|
$statCards = [
|
||||||
|
['label' => 'Jobs Completed', 'value' => $stats['jobs_completed'], 'icon' => 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z', 'color' => 'text-green-400'],
|
||||||
|
['label' => 'PRs Merged', 'value' => $stats['prs_merged'], 'icon' => 'M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4', 'color' => 'text-purple-400'],
|
||||||
|
['label' => 'Tokens Used', 'value' => number_format($stats['tokens_used']), 'icon' => 'M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z', 'color' => 'text-blue-400'],
|
||||||
|
['label' => 'Cost Today', 'value' => '$' . number_format($stats['cost_today'], 2), 'icon' => 'M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z', 'color' => 'text-yellow-400'],
|
||||||
|
['label' => 'Active Agents', 'value' => $stats['active_agents'], 'icon' => 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z', 'color' => 'text-accent'],
|
||||||
|
['label' => 'Queue Depth', 'value' => $stats['queue_depth'], 'icon' => 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', 'color' => 'text-orange-400'],
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
@foreach ($statCards as $card)
|
||||||
|
<div class="bg-surface-raised border border-border rounded-lg p-4">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<svg class="w-4 h-4 {{ $card['color'] }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ $card['icon'] }}"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-[11px] text-muted uppercase tracking-wider">{{ $card['label'] }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xl font-bold font-mono {{ $card['color'] }}">{{ $card['value'] }}</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{{-- Budget gauge --}}
|
||||||
|
<div class="bg-surface-raised border border-border rounded-lg p-5">
|
||||||
|
<h3 class="text-sm font-semibold mb-4">Budget</h3>
|
||||||
|
<div class="flex items-end gap-3 mb-3">
|
||||||
|
<span class="text-3xl font-bold font-mono text-accent">${{ number_format($budgetUsed, 2) }}</span>
|
||||||
|
<span class="text-sm text-muted mb-1">/ ${{ number_format($budgetLimit, 2) }}</span>
|
||||||
|
</div>
|
||||||
|
@php
|
||||||
|
$pct = $budgetLimit > 0 ? min(100, ($budgetUsed / $budgetLimit) * 100) : 0;
|
||||||
|
$barColor = $pct > 80 ? 'bg-red-500' : ($pct > 60 ? 'bg-yellow-500' : 'bg-accent');
|
||||||
|
@endphp
|
||||||
|
<div class="w-full h-3 bg-surface-overlay rounded-full overflow-hidden">
|
||||||
|
<div class="{{ $barColor }} h-full rounded-full transition-all duration-500" style="width: {{ $pct }}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted mt-2">{{ number_format($pct, 0) }}% of daily budget used</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Cost breakdown by model --}}
|
||||||
|
<div class="bg-surface-raised border border-border rounded-lg p-5">
|
||||||
|
<h3 class="text-sm font-semibold mb-4">Cost by Model</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
@foreach ($costBreakdown as $model)
|
||||||
|
@php
|
||||||
|
$modelPct = $budgetUsed > 0 ? ($model['cost'] / $budgetUsed) * 100 : 0;
|
||||||
|
$modelColors = [
|
||||||
|
'claude-opus-4-6' => 'bg-purple-500',
|
||||||
|
'claude-sonnet-4-5' => 'bg-blue-500',
|
||||||
|
'claude-haiku-4-5' => 'bg-green-500',
|
||||||
|
];
|
||||||
|
$barCol = $modelColors[$model['model']] ?? 'bg-gray-500';
|
||||||
|
@endphp
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between text-xs mb-1">
|
||||||
|
<span class="font-mono text-gray-300">{{ $model['model'] }}</span>
|
||||||
|
<span class="text-muted">${{ number_format($model['cost'], 2) }} ({{ number_format($model['tokens']) }} tokens)</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full h-2 bg-surface-overlay rounded-full overflow-hidden">
|
||||||
|
<div class="{{ $barCol }} h-full rounded-full transition-all duration-500" style="width: {{ $modelPct }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Throughput chart --}}
|
||||||
|
<div class="bg-surface-raised border border-border rounded-lg p-5 mt-6"
|
||||||
|
x-data="{
|
||||||
|
chart: null,
|
||||||
|
init() {
|
||||||
|
this.chart = new ApexCharts(this.$refs.chart, {
|
||||||
|
chart: {
|
||||||
|
type: 'area',
|
||||||
|
height: 240,
|
||||||
|
background: 'transparent',
|
||||||
|
toolbar: { show: false },
|
||||||
|
zoom: { enabled: false },
|
||||||
|
},
|
||||||
|
theme: { mode: 'dark' },
|
||||||
|
colors: ['#39d0d8', '#8b5cf6'],
|
||||||
|
series: [
|
||||||
|
{ name: 'Jobs', data: {{ json_encode(array_column($throughputData, 'jobs')) }} },
|
||||||
|
{ name: 'Tokens (k)', data: {{ json_encode(array_map(fn($t) => round($t / 1000, 1), array_column($throughputData, 'tokens'))) }} },
|
||||||
|
],
|
||||||
|
xaxis: {
|
||||||
|
categories: {{ json_encode(array_column($throughputData, 'hour')) }},
|
||||||
|
labels: { style: { colors: '#8b949e', fontSize: '11px' } },
|
||||||
|
},
|
||||||
|
yaxis: [
|
||||||
|
{ labels: { style: { colors: '#39d0d8' } }, title: { text: 'Jobs', style: { color: '#39d0d8' } } },
|
||||||
|
{ opposite: true, labels: { style: { colors: '#8b5cf6' } }, title: { text: 'Tokens (k)', style: { color: '#8b5cf6' } } },
|
||||||
|
],
|
||||||
|
grid: { borderColor: '#21262d', strokeDashArray: 3 },
|
||||||
|
stroke: { curve: 'smooth', width: 2 },
|
||||||
|
fill: { type: 'gradient', gradient: { opacityFrom: 0.3, opacityTo: 0.05 } },
|
||||||
|
dataLabels: { enabled: false },
|
||||||
|
legend: { labels: { colors: '#8b949e' } },
|
||||||
|
tooltip: { theme: 'dark' },
|
||||||
|
});
|
||||||
|
this.chart.render();
|
||||||
|
}
|
||||||
|
}">
|
||||||
|
<h3 class="text-sm font-semibold mb-4">Throughput</h3>
|
||||||
|
<div x-ref="chart"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -7,3 +7,9 @@ use Illuminate\Support\Facades\Route;
|
||||||
Route::get('/', function () {
|
Route::get('/', function () {
|
||||||
return view('welcome');
|
return view('welcome');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Agentic Dashboard
|
||||||
|
Route::get('/dashboard', fn () => view('dashboard.index'))->name('dashboard');
|
||||||
|
Route::get('/dashboard/agents', fn () => view('dashboard.agents'))->name('dashboard.agents');
|
||||||
|
Route::get('/dashboard/jobs', fn () => view('dashboard.jobs'))->name('dashboard.jobs');
|
||||||
|
Route::get('/dashboard/activity', fn () => view('dashboard.activity'))->name('dashboard.activity');
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue