Merge pull request 'feat(agentic): real-time dashboard — live agent activity view' (#148) from feat/agentic-dashboard into new

This commit is contained in:
Charon (snider-linux) 2026-02-12 20:03:51 +00:00
commit d2dd23697f
16 changed files with 1061 additions and 0 deletions

View 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');
}
}

View 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');
}
}

View 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');
}
}

View 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');
}
}

View 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');
}
}

View file

@ -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>

View file

@ -0,0 +1,3 @@
<x-dashboard-layout title="Live Activity">
<livewire:dashboard.activity-feed />
</x-dashboard-layout>

View file

@ -0,0 +1,3 @@
<x-dashboard-layout title="Agent Fleet">
<livewire:dashboard.agent-fleet />
</x-dashboard-layout>

View file

@ -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>

View file

@ -0,0 +1,3 @@
<x-dashboard-layout title="Job Queue">
<livewire:dashboard.job-queue />
</x-dashboard-layout>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -7,3 +7,9 @@ use Illuminate\Support\Facades\Route;
Route::get('/', function () {
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');