Initial commit: Any-LLM Gateway with Laravel Admin Interface
- Any-LLM Gateway setup with Docker Compose - Laravel 11 admin interface with Livewire - Dashboard with usage statistics and charts - Gateway Users management with budget tracking - API Keys management with revocation - Budget templates with assignment - Usage Logs with filtering and CSV export - Model Pricing management with calculator - PostgreSQL database integration - Complete authentication system for admins
This commit is contained in:
223
laravel-app/resources/views/api-keys/create.blade.php
Normal file
223
laravel-app/resources/views/api-keys/create.blade.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
{{ __('Create New API Key') }}
|
||||
</h2>
|
||||
<a href="{{ route('api-keys.index') }}"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
Back to List
|
||||
</a>
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-3xl mx-auto sm:px-6 lg:px-8">
|
||||
<!-- Info Box -->
|
||||
<div class="mb-6 bg-blue-50 border-l-4 border-blue-400 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-blue-800">Important Information</h3>
|
||||
<div class="mt-2 text-sm text-blue-700">
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li>The API key will be shown only once after creation</li>
|
||||
<li>Make sure to copy and store it securely</li>
|
||||
<li>Keys are associated with a specific user and inherit their budget limits</li>
|
||||
<li>Expired keys can be deleted but not renewed</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Messages -->
|
||||
@if ($errors->any())
|
||||
<div class="mb-6 bg-red-50 border-l-4 border-red-400 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">There were errors with your submission</h3>
|
||||
<div class="mt-2 text-sm text-red-700">
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
@foreach ($errors->all() as $error)
|
||||
<li>{{ $error }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Create Form -->
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6 text-gray-900">
|
||||
<form method="POST" action="{{ route('api-keys.store') }}" class="space-y-6">
|
||||
@csrf
|
||||
|
||||
<!-- Key Name -->
|
||||
<div>
|
||||
<label for="key_name" class="block text-sm font-medium text-gray-700">
|
||||
Key Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<input type="text"
|
||||
name="key_name"
|
||||
id="key_name"
|
||||
value="{{ old('key_name') }}"
|
||||
required
|
||||
placeholder="e.g., Production API Key, Development Key, Mobile App Key"
|
||||
class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md @error('key_name') border-red-300 @enderror">
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
A descriptive name to identify this key's purpose.
|
||||
</p>
|
||||
@error('key_name')
|
||||
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- User Selection -->
|
||||
<div>
|
||||
<label for="user_id" class="block text-sm font-medium text-gray-700">
|
||||
Associated User <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<select name="user_id"
|
||||
id="user_id"
|
||||
required
|
||||
class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md @error('user_id') border-red-300 @enderror">
|
||||
<option value="">-- Select User --</option>
|
||||
@foreach($gatewayUsers as $user)
|
||||
<option value="{{ $user->user_id }}"
|
||||
{{ old('user_id') == $user->user_id ? 'selected' : '' }}
|
||||
data-spend="{{ $user->spend }}"
|
||||
data-budget="{{ $user->budget ? $user->budget->max_budget : 'N/A' }}">
|
||||
{{ $user->alias ?? $user->user_id }}
|
||||
(Spend: ${{ number_format($user->spend, 2) }}
|
||||
@if($user->budget)
|
||||
/ Budget: ${{ number_format($user->budget->max_budget, 2) }})
|
||||
@else
|
||||
/ No Budget)
|
||||
@endif
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
The user whose budget and limits will apply to this key.
|
||||
</p>
|
||||
@error('user_id')
|
||||
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Expiration Date (Optional) -->
|
||||
<div>
|
||||
<label for="expires_at" class="block text-sm font-medium text-gray-700">
|
||||
Expiration Date (Optional)
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<input type="datetime-local"
|
||||
name="expires_at"
|
||||
id="expires_at"
|
||||
value="{{ old('expires_at') }}"
|
||||
min="{{ now()->format('Y-m-d\TH:i') }}"
|
||||
class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md @error('expires_at') border-red-300 @enderror">
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
Leave empty for a key that never expires. The key will be automatically deactivated after this date.
|
||||
</p>
|
||||
@error('expires_at')
|
||||
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Metadata (Optional) -->
|
||||
<div>
|
||||
<label for="metadata" class="block text-sm font-medium text-gray-700">
|
||||
Metadata (Optional)
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<textarea name="metadata"
|
||||
id="metadata"
|
||||
rows="3"
|
||||
placeholder='{"environment": "production", "app": "mobile"}'
|
||||
class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md @error('metadata') border-red-300 @enderror">{{ old('metadata') }}</textarea>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
Optional JSON metadata for tracking additional information about this key.
|
||||
</p>
|
||||
@error('metadata')
|
||||
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Submit Buttons -->
|
||||
<div class="flex items-center justify-end space-x-4 pt-4">
|
||||
<a href="{{ route('api-keys.index') }}"
|
||||
class="inline-flex justify-center py-2 px-4 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
Cancel
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
Create API Key
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Info Display -->
|
||||
<div id="user-info" class="mt-6 bg-white overflow-hidden shadow-sm sm:rounded-lg hidden">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Selected User Information</h3>
|
||||
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-gray-500">Current Spend</dt>
|
||||
<dd id="user-spend" class="mt-1 text-sm text-gray-900">-</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-gray-500">Budget Limit</dt>
|
||||
<dd id="user-budget" class="mt-1 text-sm text-gray-900">-</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
// Show user info when a user is selected
|
||||
document.getElementById('user_id').addEventListener('change', function() {
|
||||
const selectedOption = this.options[this.selectedIndex];
|
||||
const userInfo = document.getElementById('user-info');
|
||||
|
||||
if (this.value) {
|
||||
const spend = selectedOption.dataset.spend;
|
||||
const budget = selectedOption.dataset.budget;
|
||||
|
||||
document.getElementById('user-spend').textContent = '$' + parseFloat(spend).toFixed(2);
|
||||
document.getElementById('user-budget').textContent = budget !== 'N/A' ? '$' + parseFloat(budget).toFixed(2) : 'No Limit';
|
||||
|
||||
userInfo.classList.remove('hidden');
|
||||
} else {
|
||||
userInfo.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
</x-app-layout>
|
||||
258
laravel-app/resources/views/api-keys/index.blade.php
Normal file
258
laravel-app/resources/views/api-keys/index.blade.php
Normal file
@@ -0,0 +1,258 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
{{ __('API Keys Management') }}
|
||||
</h2>
|
||||
<a href="{{ route('api-keys.create') }}"
|
||||
class="inline-flex items-center px-4 py-2 bg-blue-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-blue-700 focus:bg-blue-700 active:bg-blue-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition ease-in-out duration-150">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
Create New Key
|
||||
</a>
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<!-- Success/Error Messages -->
|
||||
@if (session('success'))
|
||||
<div class="mb-4 bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative" role="alert">
|
||||
<span class="block sm:inline">{{ session('success') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (session('error'))
|
||||
<div class="mb-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
|
||||
<span class="block sm:inline">{{ session('error') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- New API Key Display (only shown once) -->
|
||||
@if (session('new_api_key'))
|
||||
<div class="mb-6 bg-yellow-50 border-l-4 border-yellow-400 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">Save this API Key!</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<p>This is the only time you'll see this key. Copy it now:</p>
|
||||
<div class="mt-2 flex items-center">
|
||||
<code id="new-api-key" class="bg-white px-4 py-2 rounded border border-yellow-300 font-mono text-sm">{{ session('new_api_key') }}</code>
|
||||
<button onclick="copyToClipboard('new-api-key')"
|
||||
class="ml-2 px-3 py-2 bg-yellow-500 text-white rounded hover:bg-yellow-600">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg mb-6">
|
||||
<div class="p-6">
|
||||
<form method="GET" action="{{ route('api-keys.index') }}" class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<!-- Search -->
|
||||
<div>
|
||||
<label for="search" class="block text-sm font-medium text-gray-700 mb-1">Search</label>
|
||||
<input type="text"
|
||||
name="search"
|
||||
id="search"
|
||||
value="{{ request('search') }}"
|
||||
placeholder="Key name..."
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
|
||||
</div>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<div>
|
||||
<label for="status" class="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||
<select name="status"
|
||||
id="status"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
|
||||
<option value="">All</option>
|
||||
<option value="active" {{ request('status') == 'active' ? 'selected' : '' }}>Active</option>
|
||||
<option value="expired" {{ request('status') == 'expired' ? 'selected' : '' }}>Expired</option>
|
||||
<option value="inactive" {{ request('status') == 'inactive' ? 'selected' : '' }}>Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- User Filter -->
|
||||
<div>
|
||||
<label for="user_id" class="block text-sm font-medium text-gray-700 mb-1">User</label>
|
||||
<select name="user_id"
|
||||
id="user_id"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
|
||||
<option value="">All Users</option>
|
||||
@foreach($gatewayUsers as $user)
|
||||
<option value="{{ $user->user_id }}" {{ request('user_id') == $user->user_id ? 'selected' : '' }}>
|
||||
{{ $user->alias ?? $user->user_id }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="flex items-end">
|
||||
<button type="submit"
|
||||
class="w-full inline-flex justify-center items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 focus:bg-gray-700 active:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition ease-in-out duration-150">
|
||||
Filter
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Keys Table -->
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6 text-gray-900">
|
||||
@if($apiKeys->count() > 0)
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Key Name
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
User
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Masked Key
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Last Used
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@foreach($apiKeys as $key)
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-gray-900">
|
||||
{{ $key->key_name }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-900">
|
||||
<a href="{{ route('gateway-users.show', $key->user_id) }}"
|
||||
class="text-blue-600 hover:text-blue-900">
|
||||
{{ $key->gatewayUser->alias ?? $key->user_id }}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<code class="text-xs text-gray-600 bg-gray-100 px-2 py-1 rounded">
|
||||
{{ $key->masked_key }}
|
||||
</code>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
@if($key->is_active && !$key->is_expired)
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||
Active
|
||||
</span>
|
||||
@elseif($key->is_expired)
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800">
|
||||
Expired
|
||||
</span>
|
||||
@else
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
|
||||
Revoked
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ $key->last_used_at ? $key->last_used_at->diffForHumans() : 'Never' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ $key->created_at->format('Y-m-d H:i') }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<a href="{{ route('api-keys.show', $key->id) }}"
|
||||
class="text-blue-600 hover:text-blue-900 mr-3">View</a>
|
||||
@if($key->is_active && !$key->is_expired)
|
||||
<form action="{{ route('api-keys.revoke', $key->id) }}"
|
||||
method="POST"
|
||||
class="inline"
|
||||
onsubmit="return confirm('Are you sure you want to revoke this API key? This action cannot be undone.');">
|
||||
@csrf
|
||||
<button type="submit" class="text-red-600 hover:text-red-900">
|
||||
Revoke
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="mt-4">
|
||||
{{ $apiKeys->links() }}
|
||||
</div>
|
||||
@else
|
||||
<div class="text-center py-12">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No API keys found</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Get started by creating a new API key.</p>
|
||||
<div class="mt-6">
|
||||
<a href="{{ route('api-keys.create') }}"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
<svg class="-ml-1 mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
New API Key
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function copyToClipboard(elementId) {
|
||||
const element = document.getElementById(elementId);
|
||||
const text = element.textContent;
|
||||
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
// Show success message
|
||||
const btn = event.target;
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = 'Copied!';
|
||||
btn.classList.remove('bg-yellow-500', 'hover:bg-yellow-600');
|
||||
btn.classList.add('bg-green-500');
|
||||
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
btn.classList.remove('bg-green-500');
|
||||
btn.classList.add('bg-yellow-500', 'hover:bg-yellow-600');
|
||||
}, 2000);
|
||||
}).catch(err => {
|
||||
alert('Failed to copy: ' + err);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
</x-app-layout>
|
||||
246
laravel-app/resources/views/api-keys/show.blade.php
Normal file
246
laravel-app/resources/views/api-keys/show.blade.php
Normal file
@@ -0,0 +1,246 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
{{ __('API Key Details') }}
|
||||
</h2>
|
||||
<a href="{{ route('api-keys.index') }}"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
Back to List
|
||||
</a>
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||
<!-- Key Information Card -->
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">{{ $apiKey->key_name }}</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Created {{ $apiKey->created_at->format('F d, Y \a\t H:i') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
@if($apiKey->is_active && !$apiKey->is_expired)
|
||||
<span class="px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||
Active
|
||||
</span>
|
||||
@elseif($apiKey->is_expired)
|
||||
<span class="px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800">
|
||||
Expired
|
||||
</span>
|
||||
@else
|
||||
<span class="px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full bg-red-100 text-red-800">
|
||||
Revoked
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- Masked Key -->
|
||||
<div class="sm:col-span-2">
|
||||
<dt class="text-sm font-medium text-gray-500">Masked Key</dt>
|
||||
<dd class="mt-1">
|
||||
<code class="text-sm text-gray-900 bg-gray-100 px-3 py-2 rounded inline-block">
|
||||
{{ $apiKey->masked_key }}
|
||||
</code>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<!-- Associated User -->
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Associated User</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<a href="{{ route('gateway-users.show', $apiKey->user_id) }}"
|
||||
class="text-blue-600 hover:text-blue-900">
|
||||
{{ $apiKey->gatewayUser->alias ?? $apiKey->user_id }}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<!-- Last Used -->
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Last Used</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
{{ $apiKey->last_used_at ? $apiKey->last_used_at->format('Y-m-d H:i:s') : 'Never' }}
|
||||
@if($apiKey->last_used_at)
|
||||
<span class="text-gray-500">({{ $apiKey->last_used_at->diffForHumans() }})</span>
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<!-- Expires At -->
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Expires At</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
{{ $apiKey->expires_at ? $apiKey->expires_at->format('Y-m-d H:i:s') : 'Never' }}
|
||||
@if($apiKey->expires_at)
|
||||
@if($apiKey->is_expired)
|
||||
<span class="text-red-600">(Expired)</span>
|
||||
@else
|
||||
<span class="text-gray-500">({{ $apiKey->expires_at->diffForHumans() }})</span>
|
||||
@endif
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
@if($apiKey->metadata)
|
||||
<div class="sm:col-span-3">
|
||||
<dt class="text-sm font-medium text-gray-500">Metadata</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<pre class="bg-gray-100 p-3 rounded overflow-x-auto"><code>{{ json_encode($apiKey->metadata, JSON_PRETTY_PRINT) }}</code></pre>
|
||||
</dd>
|
||||
</div>
|
||||
@endif
|
||||
</dl>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
@if($apiKey->is_active && !$apiKey->is_expired)
|
||||
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||
<form action="{{ route('api-keys.revoke', $apiKey->id) }}"
|
||||
method="POST"
|
||||
onsubmit="return confirm('Are you sure you want to revoke this API key? This action cannot be undone and all requests using this key will be rejected immediately.');">
|
||||
@csrf
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-4 py-2 bg-red-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-red-700 focus:bg-red-700 active:bg-red-900 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition ease-in-out duration-150">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
Revoke This Key
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Statistics -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6">
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Total Requests</dt>
|
||||
<dd class="mt-1 text-3xl font-semibold text-gray-900">
|
||||
{{ number_format($stats['total_requests']) }}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6">
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Total Cost</dt>
|
||||
<dd class="mt-1 text-3xl font-semibold text-green-600">
|
||||
${{ number_format($stats['total_cost'], 4) }}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6">
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Total Tokens</dt>
|
||||
<dd class="mt-1 text-3xl font-semibold text-purple-600">
|
||||
{{ number_format($stats['total_tokens']) }}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6">
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Last 30 Days</dt>
|
||||
<dd class="mt-1 text-3xl font-semibold text-blue-600">
|
||||
{{ number_format($stats['last_30_days_requests']) }}
|
||||
</dd>
|
||||
<p class="mt-1 text-xs text-gray-500">requests</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Recent Activity</h3>
|
||||
|
||||
@if($recentLogs->count() > 0)
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Timestamp
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Model
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Provider
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Tokens
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Cost
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@foreach($recentLogs as $log)
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ $log->timestamp->format('Y-m-d H:i:s') }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ $log->model }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
@if($log->provider === 'openai') bg-green-100 text-green-800
|
||||
@elseif($log->provider === 'anthropic') bg-purple-100 text-purple-800
|
||||
@else bg-gray-100 text-gray-800
|
||||
@endif">
|
||||
{{ ucfirst($log->provider) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ number_format($log->total_tokens) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
${{ number_format($log->cost, 4) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
@if($log->status === 'success')
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||
Success
|
||||
</span>
|
||||
@else
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
|
||||
{{ ucfirst($log->status) }}
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@else
|
||||
<div class="text-center py-8">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No activity yet</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">This API key hasn't been used yet.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
113
laravel-app/resources/views/budgets/create.blade.php
Normal file
113
laravel-app/resources/views/budgets/create.blade.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
Create Budget Template
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-3xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6 text-gray-900">
|
||||
<form action="{{ route('budgets.store') }}" method="POST">
|
||||
@csrf
|
||||
|
||||
<!-- Budget Name (for display purposes) -->
|
||||
<div class="mb-4">
|
||||
<label for="budget_name" class="block text-sm font-medium text-gray-700">Budget Template Name</label>
|
||||
<input type="text" name="budget_name" id="budget_name"
|
||||
value="{{ old('budget_name') }}"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
placeholder="e.g., Standard Monthly Budget"
|
||||
required>
|
||||
@error('budget_name')
|
||||
<p class="text-red-500 text-xs mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Max Budget -->
|
||||
<div class="mb-4">
|
||||
<label for="max_budget" class="block text-sm font-medium text-gray-700">Maximum Budget ($)</label>
|
||||
<input type="number" name="max_budget" id="max_budget"
|
||||
value="{{ old('max_budget') }}"
|
||||
step="0.01" min="0"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
placeholder="100.00"
|
||||
required>
|
||||
@error('max_budget')
|
||||
<p class="text-red-500 text-xs mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Budget Type -->
|
||||
<div class="mb-4">
|
||||
<label for="budget_type" class="block text-sm font-medium text-gray-700 mb-2">Budget Duration</label>
|
||||
<select name="budget_type" id="budget_type"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
required>
|
||||
<option value="daily" {{ old('budget_type') == 'daily' ? 'selected' : '' }}>Daily (24 hours)</option>
|
||||
<option value="weekly" {{ old('budget_type') == 'weekly' ? 'selected' : '' }}>Weekly (7 days)</option>
|
||||
<option value="monthly" {{ old('budget_type') == 'monthly' ? 'selected' : '' }}>Monthly (30 days)</option>
|
||||
<option value="custom" {{ old('budget_type') == 'custom' ? 'selected' : '' }}>Custom Duration</option>
|
||||
<option value="unlimited" {{ old('budget_type') == 'unlimited' ? 'selected' : '' }}>Unlimited (No Reset)</option>
|
||||
</select>
|
||||
@error('budget_type')
|
||||
<p class="text-red-500 text-xs mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Custom Duration (shown when custom is selected) -->
|
||||
<div class="mb-4" id="custom_duration_field" style="display: none;">
|
||||
<label for="custom_duration_days" class="block text-sm font-medium text-gray-700">Custom Duration (Days)</label>
|
||||
<input type="number" name="custom_duration_days" id="custom_duration_days"
|
||||
value="{{ old('custom_duration_days') }}"
|
||||
min="1"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
placeholder="e.g., 14">
|
||||
@error('custom_duration_days')
|
||||
<p class="text-red-500 text-xs mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Info Box -->
|
||||
<div class="mb-6 p-4 bg-blue-50 rounded-lg">
|
||||
<h4 class="text-sm font-medium text-blue-900 mb-2">ℹ️ Budget Template Info</h4>
|
||||
<ul class="text-sm text-blue-700 space-y-1">
|
||||
<li>• Budget templates can be assigned to multiple users</li>
|
||||
<li>• Users will automatically reset when duration expires</li>
|
||||
<li>• "Unlimited" budgets never reset automatically</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-end gap-4">
|
||||
<a href="{{ route('budgets.index') }}" class="text-gray-600 hover:text-gray-900">Cancel</a>
|
||||
<button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||
Create Budget Template
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
// Toggle custom duration field
|
||||
document.getElementById('budget_type').addEventListener('change', function() {
|
||||
const customField = document.getElementById('custom_duration_field');
|
||||
if (this.value === 'custom') {
|
||||
customField.style.display = 'block';
|
||||
} else {
|
||||
customField.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger on page load if custom was selected
|
||||
if (document.getElementById('budget_type').value === 'custom') {
|
||||
document.getElementById('custom_duration_field').style.display = 'block';
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
</x-app-layout>
|
||||
102
laravel-app/resources/views/budgets/edit.blade.php
Normal file
102
laravel-app/resources/views/budgets/edit.blade.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
Edit Budget Template
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-3xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6 text-gray-900">
|
||||
<form action="{{ route('budgets.update', $budget->budget_id) }}" method="POST">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<!-- Budget ID (read-only) -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700">Budget ID</label>
|
||||
<input type="text" value="{{ $budget->budget_id }}"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 bg-gray-100 shadow-sm"
|
||||
disabled>
|
||||
</div>
|
||||
|
||||
<!-- Max Budget -->
|
||||
<div class="mb-4">
|
||||
<label for="max_budget" class="block text-sm font-medium text-gray-700">Maximum Budget ($)</label>
|
||||
<input type="number" name="max_budget" id="max_budget"
|
||||
value="{{ old('max_budget', $budget->max_budget) }}"
|
||||
step="0.01" min="0"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
required>
|
||||
@error('max_budget')
|
||||
<p class="text-red-500 text-xs mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Budget Type -->
|
||||
<div class="mb-4">
|
||||
<label for="budget_type" class="block text-sm font-medium text-gray-700 mb-2">Budget Duration</label>
|
||||
<select name="budget_type" id="budget_type"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
required>
|
||||
<option value="daily" {{ old('budget_type', $budgetType) == 'daily' ? 'selected' : '' }}>Daily (24 hours)</option>
|
||||
<option value="weekly" {{ old('budget_type', $budgetType) == 'weekly' ? 'selected' : '' }}>Weekly (7 days)</option>
|
||||
<option value="monthly" {{ old('budget_type', $budgetType) == 'monthly' ? 'selected' : '' }}>Monthly (30 days)</option>
|
||||
<option value="custom" {{ old('budget_type', $budgetType) == 'custom' ? 'selected' : '' }}>Custom Duration</option>
|
||||
<option value="unlimited" {{ old('budget_type', $budgetType) == 'unlimited' ? 'selected' : '' }}>Unlimited (No Reset)</option>
|
||||
</select>
|
||||
@error('budget_type')
|
||||
<p class="text-red-500 text-xs mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Custom Duration -->
|
||||
<div class="mb-4" id="custom_duration_field" style="display: {{ $budgetType == 'custom' ? 'block' : 'none' }};">
|
||||
<label for="custom_duration_days" class="block text-sm font-medium text-gray-700">Custom Duration (Days)</label>
|
||||
<input type="number" name="custom_duration_days" id="custom_duration_days"
|
||||
value="{{ old('custom_duration_days', $budget->budget_duration_sec ? floor($budget->budget_duration_sec / 86400) : '') }}"
|
||||
min="1"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
placeholder="e.g., 14">
|
||||
@error('custom_duration_days')
|
||||
<p class="text-red-500 text-xs mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Warning Box -->
|
||||
<div class="mb-6 p-4 bg-yellow-50 rounded-lg">
|
||||
<h4 class="text-sm font-medium text-yellow-900 mb-2">⚠️ Warning</h4>
|
||||
<p class="text-sm text-yellow-700">
|
||||
This budget is currently assigned to <strong>{{ $budget->gatewayUsers()->count() }} user(s)</strong>.
|
||||
Changes will affect all assigned users.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-end gap-4">
|
||||
<a href="{{ route('budgets.show', $budget->budget_id) }}" class="text-gray-600 hover:text-gray-900">Cancel</a>
|
||||
<button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||
Update Budget
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
// Toggle custom duration field
|
||||
document.getElementById('budget_type').addEventListener('change', function() {
|
||||
const customField = document.getElementById('custom_duration_field');
|
||||
if (this.value === 'custom') {
|
||||
customField.style.display = 'block';
|
||||
} else {
|
||||
customField.style.display = 'none';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
</x-app-layout>
|
||||
98
laravel-app/resources/views/budgets/index.blade.php
Normal file
98
laravel-app/resources/views/budgets/index.blade.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
Budget Templates
|
||||
</h2>
|
||||
<a href="{{ route('budgets.create') }}" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||
Create Budget Template
|
||||
</a>
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
@if(session('success'))
|
||||
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative mb-4">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(session('error'))
|
||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4">
|
||||
{{ session('error') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6 text-gray-900">
|
||||
@if($budgets->count() > 0)
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Budget ID
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Max Budget
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Duration
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Assigned Users
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@foreach($budgets as $budget)
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{{ $budget->budget_id }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<span class="font-semibold text-green-600">{{ $budget->max_budget_formatted }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ $budget->duration_human }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{{ $budget->gateway_users_count }} users
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ $budget->created_at->format('M d, Y') }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<a href="{{ route('budgets.show', $budget->budget_id) }}" class="text-blue-600 hover:text-blue-900 mr-3">View</a>
|
||||
<a href="{{ route('budgets.edit', $budget->budget_id) }}" class="text-indigo-600 hover:text-indigo-900 mr-3">Edit</a>
|
||||
<form action="{{ route('budgets.destroy', $budget->budget_id) }}" method="POST" class="inline"
|
||||
onsubmit="return confirm('Are you sure? This budget will be deleted.');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-red-600 hover:text-red-900">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="mt-4">
|
||||
{{ $budgets->links() }}
|
||||
</div>
|
||||
@else
|
||||
<p class="text-gray-500 text-center py-8">No budget templates found. Create your first budget template to get started.</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
164
laravel-app/resources/views/budgets/show.blade.php
Normal file
164
laravel-app/resources/views/budgets/show.blade.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
Budget Details
|
||||
</h2>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('budgets.edit', $budget->budget_id) }}"
|
||||
class="bg-indigo-500 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded">
|
||||
Edit Budget
|
||||
</a>
|
||||
<form action="{{ route('budgets.destroy', $budget->budget_id) }}" method="POST"
|
||||
onsubmit="return confirm('Are you sure? This will delete the budget.');" class="inline">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||
@if(session('success'))
|
||||
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Budget Info Card -->
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Budget Information</h3>
|
||||
<dl class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Budget ID</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 font-mono">{{ $budget->budget_id }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Maximum Budget</dt>
|
||||
<dd class="mt-1 text-2xl font-bold text-green-600">{{ $budget->max_budget_formatted }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Duration</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">{{ $budget->duration_human }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Assigned Users</dt>
|
||||
<dd class="mt-1 text-2xl font-bold text-blue-600">{{ $budget->gatewayUsers()->count() }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Created</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">{{ $budget->created_at->format('M d, Y H:i') }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Last Updated</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">{{ $budget->updated_at->format('M d, Y H:i') }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assigned Users Table -->
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Assigned Users ({{ $budget->gatewayUsers()->count() }})</h3>
|
||||
|
||||
@if($budget->gatewayUsers()->count() > 0)
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">User ID</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Alias</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Current Spend</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Budget Started</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Next Reset</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@foreach($budget->gatewayUsers as $user)
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
|
||||
{{ $user->user_id }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ $user->alias ?? '-' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<span class="font-semibold {{ $user->spend >= $budget->max_budget ? 'text-red-600' : 'text-green-600' }}">
|
||||
{{ $user->spend_formatted }}
|
||||
</span>
|
||||
<span class="text-gray-500">/ {{ $budget->max_budget_formatted }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ $user->budget_started_at?->format('M d, Y') ?? '-' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ $user->next_budget_reset_at?->format('M d, Y') ?? 'Never' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<a href="{{ route('gateway-users.show', $user->user_id) }}" class="text-blue-600 hover:text-blue-900">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
@else
|
||||
<p class="text-gray-500 text-center py-8">No users assigned to this budget yet.</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assign Users Form -->
|
||||
@if($availableUsers->count() > 0)
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Assign Users to Budget</h3>
|
||||
|
||||
<form action="{{ route('budgets.assign-users', $budget->budget_id) }}" method="POST">
|
||||
@csrf
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Select Users</label>
|
||||
<div class="border rounded-lg p-4 max-h-64 overflow-y-auto">
|
||||
@foreach($availableUsers as $user)
|
||||
<div class="flex items-center mb-2">
|
||||
<input type="checkbox" name="user_ids[]" value="{{ $user->user_id }}"
|
||||
id="user_{{ $user->user_id }}"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
<label for="user_{{ $user->user_id }}" class="ml-2 text-sm text-gray-900 cursor-pointer">
|
||||
<span class="font-mono">{{ $user->user_id }}</span>
|
||||
@if($user->alias)
|
||||
<span class="text-gray-500">({{ $user->alias }})</span>
|
||||
@endif
|
||||
</label>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@error('user_ids')
|
||||
<p class="text-red-500 text-xs mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||
Assign Selected Users
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="bg-gray-50 overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6 text-center text-gray-500">
|
||||
All users are currently assigned to budgets. No available users to assign.
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,10 @@
|
||||
@props(['on'])
|
||||
|
||||
<div x-data="{ shown: false, timeout: null }"
|
||||
x-init="@this.on('{{ $on }}', () => { clearTimeout(timeout); shown = true; timeout = setTimeout(() => { shown = false }, 2000); })"
|
||||
x-show.transition.out.opacity.duration.1500ms="shown"
|
||||
x-transition:leave.opacity.duration.1500ms
|
||||
style="display: none;"
|
||||
{{ $attributes->merge(['class' => 'text-sm text-gray-600 dark:text-gray-400']) }}>
|
||||
{{ $slot->isEmpty() ? __('Saved.') : $slot }}
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 316 316" xmlns="http://www.w3.org/2000/svg" {{ $attributes }}>
|
||||
<path d="M305.8 81.125C305.77 80.995 305.69 80.885 305.65 80.755C305.56 80.525 305.49 80.285 305.37 80.075C305.29 79.935 305.17 79.815 305.07 79.685C304.94 79.515 304.83 79.325 304.68 79.175C304.55 79.045 304.39 78.955 304.25 78.845C304.09 78.715 303.95 78.575 303.77 78.475L251.32 48.275C249.97 47.495 248.31 47.495 246.96 48.275L194.51 78.475C194.33 78.575 194.19 78.725 194.03 78.845C193.89 78.955 193.73 79.045 193.6 79.175C193.45 79.325 193.34 79.515 193.21 79.685C193.11 79.815 192.99 79.935 192.91 80.075C192.79 80.285 192.71 80.525 192.63 80.755C192.58 80.875 192.51 80.995 192.48 81.125C192.38 81.495 192.33 81.875 192.33 82.265V139.625L148.62 164.795V52.575C148.62 52.185 148.57 51.805 148.47 51.435C148.44 51.305 148.36 51.195 148.32 51.065C148.23 50.835 148.16 50.595 148.04 50.385C147.96 50.245 147.84 50.125 147.74 49.995C147.61 49.825 147.5 49.635 147.35 49.485C147.22 49.355 147.06 49.265 146.92 49.155C146.76 49.025 146.62 48.885 146.44 48.785L93.99 18.585C92.64 17.805 90.98 17.805 89.63 18.585L37.18 48.785C37 48.885 36.86 49.035 36.7 49.155C36.56 49.265 36.4 49.355 36.27 49.485C36.12 49.635 36.01 49.825 35.88 49.995C35.78 50.125 35.66 50.245 35.58 50.385C35.46 50.595 35.38 50.835 35.3 51.065C35.25 51.185 35.18 51.305 35.15 51.435C35.05 51.805 35 52.185 35 52.575V232.235C35 233.795 35.84 235.245 37.19 236.025L142.1 296.425C142.33 296.555 142.58 296.635 142.82 296.725C142.93 296.765 143.04 296.835 143.16 296.865C143.53 296.965 143.9 297.015 144.28 297.015C144.66 297.015 145.03 296.965 145.4 296.865C145.5 296.835 145.59 296.775 145.69 296.745C145.95 296.655 146.21 296.565 146.45 296.435L251.36 236.035C252.72 235.255 253.55 233.815 253.55 232.245V174.885L303.81 145.945C305.17 145.165 306 143.725 306 142.155V82.265C305.95 81.875 305.89 81.495 305.8 81.125ZM144.2 227.205L100.57 202.515L146.39 176.135L196.66 147.195L240.33 172.335L208.29 190.625L144.2 227.205ZM244.75 114.995V164.795L226.39 154.225L201.03 139.625V89.825L219.39 100.395L244.75 114.995ZM249.12 57.105L292.81 82.265L249.12 107.425L205.43 82.265L249.12 57.105ZM114.49 184.425L96.13 194.995V85.305L121.49 70.705L139.85 60.135V169.815L114.49 184.425ZM91.76 27.425L135.45 52.585L91.76 77.745L48.07 52.585L91.76 27.425ZM43.67 60.135L62.03 70.705L87.39 85.305V202.545V202.555V202.565C87.39 202.735 87.44 202.895 87.46 203.055C87.49 203.265 87.49 203.485 87.55 203.695V203.705C87.6 203.875 87.69 204.035 87.76 204.195C87.84 204.375 87.89 204.575 87.99 204.745C87.99 204.745 87.99 204.755 88 204.755C88.09 204.905 88.22 205.035 88.33 205.175C88.45 205.335 88.55 205.495 88.69 205.635L88.7 205.645C88.82 205.765 88.98 205.855 89.12 205.965C89.28 206.085 89.42 206.225 89.59 206.325C89.6 206.325 89.6 206.325 89.61 206.335C89.62 206.335 89.62 206.345 89.63 206.345L139.87 234.775V285.065L43.67 229.705V60.135ZM244.75 229.705L148.58 285.075V234.775L219.8 194.115L244.75 179.875V229.705ZM297.2 139.625L253.49 164.795V114.995L278.85 100.395L297.21 89.825V139.625H297.2Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,7 @@
|
||||
@props(['status'])
|
||||
|
||||
@if ($status)
|
||||
<div {{ $attributes->merge(['class' => 'font-medium text-sm text-green-600 dark:text-green-400']) }}>
|
||||
{{ $status }}
|
||||
</div>
|
||||
@endif
|
||||
@@ -0,0 +1,3 @@
|
||||
<button {{ $attributes->merge(['type' => 'submit', 'class' => 'inline-flex items-center px-4 py-2 bg-red-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-red-500 active:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition ease-in-out duration-150']) }}>
|
||||
{{ $slot }}
|
||||
</button>
|
||||
@@ -0,0 +1 @@
|
||||
<a {{ $attributes->merge(['class' => 'block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-800 transition duration-150 ease-in-out']) }}>{{ $slot }}</a>
|
||||
35
laravel-app/resources/views/components/dropdown.blade.php
Normal file
35
laravel-app/resources/views/components/dropdown.blade.php
Normal file
@@ -0,0 +1,35 @@
|
||||
@props(['align' => 'right', 'width' => '48', 'contentClasses' => 'py-1 bg-white dark:bg-gray-700'])
|
||||
|
||||
@php
|
||||
$alignmentClasses = match ($align) {
|
||||
'left' => 'ltr:origin-top-left rtl:origin-top-right start-0',
|
||||
'top' => 'origin-top',
|
||||
default => 'ltr:origin-top-right rtl:origin-top-left end-0',
|
||||
};
|
||||
|
||||
$width = match ($width) {
|
||||
'48' => 'w-48',
|
||||
default => $width,
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="relative" x-data="{ open: false }" @click.outside="open = false" @close.stop="open = false">
|
||||
<div @click="open = ! open">
|
||||
{{ $trigger }}
|
||||
</div>
|
||||
|
||||
<div x-show="open"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute z-50 mt-2 {{ $width }} rounded-md shadow-lg {{ $alignmentClasses }}"
|
||||
style="display: none;"
|
||||
@click="open = false">
|
||||
<div class="rounded-md ring-1 ring-black ring-opacity-5 {{ $contentClasses }}">
|
||||
{{ $content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
@props(['messages'])
|
||||
|
||||
@if ($messages)
|
||||
<ul {{ $attributes->merge(['class' => 'text-sm text-red-600 dark:text-red-400 space-y-1']) }}>
|
||||
@foreach ((array) $messages as $message)
|
||||
<li>{{ $message }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
@@ -0,0 +1,5 @@
|
||||
@props(['value'])
|
||||
|
||||
<label {{ $attributes->merge(['class' => 'block font-medium text-sm text-gray-700 dark:text-gray-300']) }}>
|
||||
{{ $value ?? $slot }}
|
||||
</label>
|
||||
78
laravel-app/resources/views/components/modal.blade.php
Normal file
78
laravel-app/resources/views/components/modal.blade.php
Normal file
@@ -0,0 +1,78 @@
|
||||
@props([
|
||||
'name',
|
||||
'show' => false,
|
||||
'maxWidth' => '2xl'
|
||||
])
|
||||
|
||||
@php
|
||||
$maxWidth = [
|
||||
'sm' => 'sm:max-w-sm',
|
||||
'md' => 'sm:max-w-md',
|
||||
'lg' => 'sm:max-w-lg',
|
||||
'xl' => 'sm:max-w-xl',
|
||||
'2xl' => 'sm:max-w-2xl',
|
||||
][$maxWidth];
|
||||
@endphp
|
||||
|
||||
<div
|
||||
x-data="{
|
||||
show: @js($show),
|
||||
focusables() {
|
||||
// All focusable element types...
|
||||
let selector = 'a, button, input:not([type=\'hidden\']), textarea, select, details, [tabindex]:not([tabindex=\'-1\'])'
|
||||
return [...$el.querySelectorAll(selector)]
|
||||
// All non-disabled elements...
|
||||
.filter(el => ! el.hasAttribute('disabled'))
|
||||
},
|
||||
firstFocusable() { return this.focusables()[0] },
|
||||
lastFocusable() { return this.focusables().slice(-1)[0] },
|
||||
nextFocusable() { return this.focusables()[this.nextFocusableIndex()] || this.firstFocusable() },
|
||||
prevFocusable() { return this.focusables()[this.prevFocusableIndex()] || this.lastFocusable() },
|
||||
nextFocusableIndex() { return (this.focusables().indexOf(document.activeElement) + 1) % (this.focusables().length + 1) },
|
||||
prevFocusableIndex() { return Math.max(0, this.focusables().indexOf(document.activeElement)) -1 },
|
||||
}"
|
||||
x-init="$watch('show', value => {
|
||||
if (value) {
|
||||
document.body.classList.add('overflow-y-hidden');
|
||||
{{ $attributes->has('focusable') ? 'setTimeout(() => firstFocusable().focus(), 100)' : '' }}
|
||||
} else {
|
||||
document.body.classList.remove('overflow-y-hidden');
|
||||
}
|
||||
})"
|
||||
x-on:open-modal.window="$event.detail == '{{ $name }}' ? show = true : null"
|
||||
x-on:close-modal.window="$event.detail == '{{ $name }}' ? show = false : null"
|
||||
x-on:close.stop="show = false"
|
||||
x-on:keydown.escape.window="show = false"
|
||||
x-on:keydown.tab.prevent="$event.shiftKey || nextFocusable().focus()"
|
||||
x-on:keydown.shift.tab.prevent="prevFocusable().focus()"
|
||||
x-show="show"
|
||||
class="fixed inset-0 overflow-y-auto px-4 py-6 sm:px-0 z-50"
|
||||
style="display: {{ $show ? 'block' : 'none' }};"
|
||||
>
|
||||
<div
|
||||
x-show="show"
|
||||
class="fixed inset-0 transform transition-all"
|
||||
x-on:click="show = false"
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
>
|
||||
<div class="absolute inset-0 bg-gray-500 dark:bg-gray-900 opacity-75"></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="show"
|
||||
class="mb-6 bg-white dark:bg-gray-800 rounded-lg overflow-hidden shadow-xl transform transition-all sm:w-full {{ $maxWidth }} sm:mx-auto"
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
11
laravel-app/resources/views/components/nav-link.blade.php
Normal file
11
laravel-app/resources/views/components/nav-link.blade.php
Normal file
@@ -0,0 +1,11 @@
|
||||
@props(['active'])
|
||||
|
||||
@php
|
||||
$classes = ($active ?? false)
|
||||
? 'inline-flex items-center px-1 pt-1 border-b-2 border-indigo-400 dark:border-indigo-600 text-sm font-medium leading-5 text-gray-900 dark:text-gray-100 focus:outline-none focus:border-indigo-700 transition duration-150 ease-in-out'
|
||||
: 'inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium leading-5 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-700 focus:outline-none focus:text-gray-700 dark:focus:text-gray-300 focus:border-gray-300 dark:focus:border-gray-700 transition duration-150 ease-in-out';
|
||||
@endphp
|
||||
|
||||
<a {{ $attributes->merge(['class' => $classes]) }}>
|
||||
{{ $slot }}
|
||||
</a>
|
||||
@@ -0,0 +1,3 @@
|
||||
<button {{ $attributes->merge(['type' => 'submit', 'class' => 'inline-flex items-center px-4 py-2 bg-gray-800 dark:bg-gray-200 border border-transparent rounded-md font-semibold text-xs text-white dark:text-gray-800 uppercase tracking-widest hover:bg-gray-700 dark:hover:bg-white focus:bg-gray-700 dark:focus:bg-white active:bg-gray-900 dark:active:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition ease-in-out duration-150']) }}>
|
||||
{{ $slot }}
|
||||
</button>
|
||||
@@ -0,0 +1,11 @@
|
||||
@props(['active'])
|
||||
|
||||
@php
|
||||
$classes = ($active ?? false)
|
||||
? 'block w-full ps-3 pe-4 py-2 border-l-4 border-indigo-400 dark:border-indigo-600 text-start text-base font-medium text-indigo-700 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-900/50 focus:outline-none focus:text-indigo-800 dark:focus:text-indigo-200 focus:bg-indigo-100 dark:focus:bg-indigo-900 focus:border-indigo-700 dark:focus:border-indigo-300 transition duration-150 ease-in-out'
|
||||
: 'block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 hover:border-gray-300 dark:hover:border-gray-600 focus:outline-none focus:text-gray-800 dark:focus:text-gray-200 focus:bg-gray-50 dark:focus:bg-gray-700 focus:border-gray-300 dark:focus:border-gray-600 transition duration-150 ease-in-out';
|
||||
@endphp
|
||||
|
||||
<a {{ $attributes->merge(['class' => $classes]) }}>
|
||||
{{ $slot }}
|
||||
</a>
|
||||
@@ -0,0 +1,3 @@
|
||||
<button {{ $attributes->merge(['type' => 'button', 'class' => 'inline-flex items-center px-4 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-500 rounded-md font-semibold text-xs text-gray-700 dark:text-gray-300 uppercase tracking-widest shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-25 transition ease-in-out duration-150']) }}>
|
||||
{{ $slot }}
|
||||
</button>
|
||||
@@ -0,0 +1,3 @@
|
||||
@props(['disabled' => false])
|
||||
|
||||
<input @disabled($disabled) {{ $attributes->merge(['class' => 'border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 rounded-md shadow-sm']) }}>
|
||||
338
laravel-app/resources/views/dashboard.blade.php
Normal file
338
laravel-app/resources/views/dashboard.blade.php
Normal file
@@ -0,0 +1,338 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
|
||||
{{ __('Dashboard') }} - Any-LLM Gateway
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<!-- Total Users -->
|
||||
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Total Users</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ number_format($stats['total_users']) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{{ $stats['active_users'] }} active, {{ $stats['blocked_users'] }} blocked
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-blue-500">
|
||||
<svg class="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Requests Today -->
|
||||
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Requests Today</p>
|
||||
<p class="text-3xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{{ number_format($stats['total_requests_today']) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{{ number_format($stats['total_requests_month']) }} this month
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-blue-500">
|
||||
<svg class="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spend Today -->
|
||||
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Spend Today</p>
|
||||
<p class="text-3xl font-bold text-green-600 dark:text-green-400">
|
||||
${{ number_format($stats['total_spend_today'], 2) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
${{ number_format($stats['total_spend_month'], 2) }} this month
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-green-500">
|
||||
<svg class="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tokens Today -->
|
||||
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Tokens Today</p>
|
||||
<p class="text-3xl font-bold text-purple-600 dark:text-purple-400">
|
||||
{{ number_format($stats['total_tokens_today']) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Prompt + Completion
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-purple-500">
|
||||
<svg class="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Trend Chart -->
|
||||
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Usage Trend (Last 30 Days)
|
||||
</h3>
|
||||
<canvas id="usageChart" height="80"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Provider Stats & Top Users -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Provider Breakdown -->
|
||||
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Usage by Provider
|
||||
</h3>
|
||||
<canvas id="providerChart" height="250"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Users -->
|
||||
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Top Users by Spend
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
@forelse($topUsers as $user)
|
||||
<div class="flex items-center justify-between border-b border-gray-200 dark:border-gray-700 pb-3">
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $user->alias ?? $user->user_id }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ number_format($user->usage_logs_count ?? 0) }} requests
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-semibold text-green-600 dark:text-green-400">
|
||||
${{ number_format($user->usage_logs_sum_cost ?? 0, 2) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-gray-500 dark:text-gray-400 text-center py-4">
|
||||
No usage data yet
|
||||
</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model Stats -->
|
||||
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Most Used Models
|
||||
</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Model</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Requests</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Tokens</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@forelse($modelStats as $model)
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $model->model }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ number_format($model->count) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ number_format($model->tokens ?? 0) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
${{ number_format($model->total_cost ?? 0, 4) }}
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="4" class="px-6 py-4 text-center text-gray-500 dark:text-gray-400">
|
||||
No usage data yet
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
// Usage Trend Chart
|
||||
const usageCtx = document.getElementById('usageChart').getContext('2d');
|
||||
new Chart(usageCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: @json($dailyUsage->pluck('date')),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Requests',
|
||||
data: @json($dailyUsage->pluck('requests')),
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
tension: 0.4,
|
||||
yAxisID: 'y',
|
||||
},
|
||||
{
|
||||
label: 'Cost ($)',
|
||||
data: @json($dailyUsage->pluck('cost')),
|
||||
borderColor: 'rgb(16, 185, 129)',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
tension: 0.4,
|
||||
yAxisID: 'y1',
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Requests'
|
||||
},
|
||||
ticks: {
|
||||
color: getComputedStyle(document.documentElement).getPropertyValue('--tw-text-opacity') ? '#9CA3AF' : '#6B7280'
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(156, 163, 175, 0.1)'
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Cost ($)'
|
||||
},
|
||||
ticks: {
|
||||
color: getComputedStyle(document.documentElement).getPropertyValue('--tw-text-opacity') ? '#9CA3AF' : '#6B7280'
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
},
|
||||
x: {
|
||||
ticks: {
|
||||
color: getComputedStyle(document.documentElement).getPropertyValue('--tw-text-opacity') ? '#9CA3AF' : '#6B7280'
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(156, 163, 175, 0.1)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: getComputedStyle(document.documentElement).getPropertyValue('--tw-text-opacity') ? '#9CA3AF' : '#6B7280'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Provider Breakdown Chart
|
||||
const providerCtx = document.getElementById('providerChart').getContext('2d');
|
||||
new Chart(providerCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: @json($providerStats->pluck('provider')),
|
||||
datasets: [{
|
||||
data: @json($providerStats->pluck('count')),
|
||||
backgroundColor: [
|
||||
'rgba(59, 130, 246, 0.8)',
|
||||
'rgba(16, 185, 129, 0.8)',
|
||||
'rgba(249, 115, 22, 0.8)',
|
||||
'rgba(168, 85, 247, 0.8)',
|
||||
'rgba(236, 72, 153, 0.8)',
|
||||
'rgba(245, 158, 11, 0.8)',
|
||||
],
|
||||
borderWidth: 2,
|
||||
borderColor: '#1f2937'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: getComputedStyle(document.documentElement).getPropertyValue('--tw-text-opacity') ? '#9CA3AF' : '#6B7280',
|
||||
padding: 15
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
let label = context.label || '';
|
||||
if (label) {
|
||||
label += ': ';
|
||||
}
|
||||
label += context.parsed + ' requests';
|
||||
return label;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
</x-app-layout>
|
||||
91
laravel-app/resources/views/gateway-users/create.blade.php
Normal file
91
laravel-app/resources/views/gateway-users/create.blade.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
|
||||
{{ __('Create Gateway User') }}
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-3xl mx-auto sm:px-6 lg:px-8">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Create Gateway User</h1>
|
||||
<p class="mt-1 text-sm text-gray-600">Add a new API consumer to the gateway</p>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<form method="POST" action="{{ route('gateway-users.store') }}">
|
||||
@csrf
|
||||
|
||||
<!-- Alias -->
|
||||
<div class="mb-6">
|
||||
<label for="alias" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Alias (Optional)
|
||||
</label>
|
||||
<input type="text"
|
||||
name="alias"
|
||||
id="alias"
|
||||
value="{{ old('alias') }}"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm @error('alias') border-red-300 @enderror"
|
||||
placeholder="My Application">
|
||||
<p class="mt-1 text-sm text-gray-500">A friendly name to identify this user</p>
|
||||
@error('alias')
|
||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Budget -->
|
||||
<div class="mb-6">
|
||||
<label for="budget_id" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Budget Template (Optional)
|
||||
</label>
|
||||
<select name="budget_id"
|
||||
id="budget_id"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm @error('budget_id') border-red-300 @enderror">
|
||||
<option value="">No Budget</option>
|
||||
@foreach($budgets as $budget)
|
||||
<option value="{{ $budget->budget_id }}" {{ old('budget_id') == $budget->budget_id ? 'selected' : '' }}>
|
||||
{{ $budget->budget_id }} - ${{ number_format($budget->max_budget, 2) }}
|
||||
({{ floor($budget->budget_duration_sec / 86400) }}d)
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<p class="mt-1 text-sm text-gray-500">Assign a spending limit to this user</p>
|
||||
@error('budget_id')
|
||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Info Box -->
|
||||
<div class="mb-6 bg-blue-50 border-l-4 border-blue-400 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-blue-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-blue-700">
|
||||
A unique user ID will be automatically generated. You can create API keys for this user after creation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-end space-x-3">
|
||||
<a href="{{ route('gateway-users.index') }}"
|
||||
class="inline-flex items-center px-4 py-2 bg-white border border-gray-300 rounded-md font-semibold text-xs text-gray-700 uppercase tracking-widest shadow-sm hover:bg-gray-50">
|
||||
Cancel
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-4 py-2 bg-indigo-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-indigo-700">
|
||||
Create User
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
111
laravel-app/resources/views/gateway-users/edit.blade.php
Normal file
111
laravel-app/resources/views/gateway-users/edit.blade.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
|
||||
{{ __('Edit Gateway User') }}
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-3xl mx-auto sm:px-6 lg:px-8">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Edit Gateway User</h1>
|
||||
<p class="mt-1 text-sm text-gray-600">Update user settings and configuration</p>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<form method="POST" action="{{ route('gateway-users.update', $user->user_id) }}">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<!-- User ID (Read-only) -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
User ID
|
||||
</label>
|
||||
<input type="text"
|
||||
value="{{ $user->user_id }}"
|
||||
readonly
|
||||
class="block w-full rounded-md border-gray-300 bg-gray-50 shadow-sm sm:text-sm font-mono">
|
||||
<p class="mt-1 text-sm text-gray-500">Cannot be changed</p>
|
||||
</div>
|
||||
|
||||
<!-- Alias -->
|
||||
<div class="mb-6">
|
||||
<label for="alias" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Alias (Optional)
|
||||
</label>
|
||||
<input type="text"
|
||||
name="alias"
|
||||
id="alias"
|
||||
value="{{ old('alias', $user->alias) }}"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm @error('alias') border-red-300 @enderror"
|
||||
placeholder="My Application">
|
||||
@error('alias')
|
||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Budget -->
|
||||
<div class="mb-6">
|
||||
<label for="budget_id" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Budget Template
|
||||
</label>
|
||||
<select name="budget_id"
|
||||
id="budget_id"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm @error('budget_id') border-red-300 @enderror">
|
||||
<option value="">No Budget</option>
|
||||
@foreach($budgets as $budget)
|
||||
<option value="{{ $budget->budget_id }}"
|
||||
{{ old('budget_id', $user->budget_id) == $budget->budget_id ? 'selected' : '' }}>
|
||||
{{ $budget->budget_id }} - ${{ number_format($budget->max_budget, 2) }}
|
||||
({{ floor($budget->budget_duration_sec / 86400) }}d)
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('budget_id')
|
||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- Current Spend (Read-only) -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Current Spend
|
||||
</label>
|
||||
<input type="text"
|
||||
value="${{ number_format($user->spend, 2) }}"
|
||||
readonly
|
||||
class="block w-full rounded-md border-gray-300 bg-gray-50 shadow-sm sm:text-sm">
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-between">
|
||||
<form action="{{ route('gateway-users.destroy', $user->user_id) }}"
|
||||
method="POST"
|
||||
onsubmit="return confirm('Are you sure? This will delete the user and all associated data.');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-4 py-2 bg-red-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-red-700">
|
||||
Delete User
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<a href="{{ route('gateway-users.show', $user->user_id) }}"
|
||||
class="inline-flex items-center px-4 py-2 bg-white border border-gray-300 rounded-md font-semibold text-xs text-gray-700 uppercase tracking-widest shadow-sm hover:bg-gray-50">
|
||||
Cancel
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-4 py-2 bg-indigo-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-indigo-700">
|
||||
Update User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
314
laravel-app/resources/views/gateway-users/index.blade.php
Normal file
314
laravel-app/resources/views/gateway-users/index.blade.php
Normal file
@@ -0,0 +1,314 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
|
||||
{{ __('Gateway Users') }}
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Gateway Users</h1>
|
||||
<p class="mt-1 text-sm text-gray-600">Manage API consumers and their access</p>
|
||||
</div>
|
||||
<a href="{{ route('gateway-users.create') }}"
|
||||
class="inline-flex items-center px-4 py-2 bg-indigo-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-indigo-700">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
Create User
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
@if(session('success'))
|
||||
<div class="mb-6 bg-green-50 border-l-4 border-green-400 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-green-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-green-700">{{ session('success') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<form method="GET" action="{{ route('gateway-users.index') }}" class="grid grid-cols-1 md:grid-cols-4 gap-4" id="filterForm">
|
||||
<div>
|
||||
<label for="search" class="block text-sm font-medium text-gray-700">Search</label>
|
||||
<input type="text"
|
||||
name="search"
|
||||
id="search"
|
||||
value="{{ request('search') }}"
|
||||
placeholder="User ID or Alias..."
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="status" class="block text-sm font-medium text-gray-700">Status</label>
|
||||
<select name="status"
|
||||
id="status"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
||||
<option value="">All Users</option>
|
||||
<option value="active" {{ request('status') == 'active' ? 'selected' : '' }}>Active</option>
|
||||
<option value="blocked" {{ request('status') == 'blocked' ? 'selected' : '' }}>Blocked</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="sort" class="block text-sm font-medium text-gray-700">Sort By</label>
|
||||
<select name="sort"
|
||||
id="sort"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
||||
<option value="created_at" {{ request('sort') == 'created_at' ? 'selected' : '' }}>Created Date</option>
|
||||
<option value="spend" {{ request('sort') == 'spend' ? 'selected' : '' }}>Spend</option>
|
||||
<option value="alias" {{ request('sort') == 'alias' ? 'selected' : '' }}>Alias</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<button type="submit"
|
||||
class="w-full inline-flex justify-center items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700">
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Bar (hidden by default) -->
|
||||
<div id="bulkActionsBar" class="bg-indigo-50 border border-indigo-200 rounded-lg p-4 mb-6 hidden">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm font-medium text-gray-700 mr-4">
|
||||
<span id="selectedCount">0</span> user(s) selected
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<form id="bulkActionForm" method="POST" action="{{ route('gateway-users.bulk-action') }}" class="flex items-center space-x-3">
|
||||
@csrf
|
||||
<input type="hidden" name="action" id="bulkActionType">
|
||||
<div id="selectedUserIds"></div>
|
||||
|
||||
<button type="button"
|
||||
onclick="executeBulkAction('block')"
|
||||
class="inline-flex items-center px-3 py-2 bg-red-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-red-700">
|
||||
Block Selected
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick="executeBulkAction('unblock')"
|
||||
class="inline-flex items-center px-3 py-2 bg-green-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-green-700">
|
||||
Unblock Selected
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick="executeBulkAction('delete')"
|
||||
class="inline-flex items-center px-3 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700">
|
||||
Delete Selected
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left">
|
||||
<input type="checkbox"
|
||||
id="selectAll"
|
||||
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
onchange="toggleAllUsers(this)">
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">User</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Budget</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Spend</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">API Keys</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Requests</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@forelse($users as $user)
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<input type="checkbox"
|
||||
class="user-checkbox rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
value="{{ $user->user_id }}"
|
||||
onchange="updateBulkActions()">
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 h-10 w-10 flex items-center justify-center bg-indigo-100 rounded-full">
|
||||
<span class="text-indigo-600 font-semibold text-sm">
|
||||
{{ strtoupper(substr($user->alias ?? $user->user_id, 0, 2)) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<div class="text-sm font-medium text-gray-900">
|
||||
{{ $user->alias ?? 'N/A' }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 font-mono">
|
||||
{{ substr($user->user_id, 0, 20) }}...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
@if($user->budget)
|
||||
<div class="text-sm text-gray-900">{{ $user->budget->budget_id }}</div>
|
||||
<div class="text-sm text-gray-500">${{ number_format($user->budget->max_budget, 2) }}</div>
|
||||
@else
|
||||
<span class="text-sm text-gray-400">No budget</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-semibold text-gray-900">${{ number_format($user->spend, 2) }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-900">{{ $user->api_keys_count }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-900">{{ number_format($user->usage_logs_count) }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
@if($user->blocked)
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
|
||||
Blocked
|
||||
</span>
|
||||
@else
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||
Active
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<a href="{{ route('gateway-users.show', $user->user_id) }}"
|
||||
class="text-indigo-600 hover:text-indigo-900 mr-3">View</a>
|
||||
<a href="{{ route('gateway-users.edit', $user->user_id) }}"
|
||||
class="text-yellow-600 hover:text-yellow-900 mr-3">Edit</a>
|
||||
<form action="{{ route('gateway-users.toggle-block', $user->user_id) }}"
|
||||
method="POST"
|
||||
class="inline-block">
|
||||
@csrf
|
||||
<button type="submit"
|
||||
class="text-{{ $user->blocked ? 'green' : 'red' }}-600 hover:text-{{ $user->blocked ? 'green' : 'red' }}-900">
|
||||
{{ $user->blocked ? 'Unblock' : 'Block' }}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="8" class="px-6 py-12 text-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<svg class="w-16 h-16 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-1">No users found</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">Get started by creating your first gateway user.</p>
|
||||
<a href="{{ route('gateway-users.create') }}"
|
||||
class="inline-flex items-center px-4 py-2 bg-indigo-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-indigo-700">
|
||||
Create User
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
@if($users->hasPages())
|
||||
<div class="mt-6">
|
||||
{{ $users->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Bulk Actions JavaScript
|
||||
function toggleAllUsers(checkbox) {
|
||||
const userCheckboxes = document.querySelectorAll('.user-checkbox');
|
||||
userCheckboxes.forEach(cb => {
|
||||
cb.checked = checkbox.checked;
|
||||
});
|
||||
updateBulkActions();
|
||||
}
|
||||
|
||||
function updateBulkActions() {
|
||||
const checkedBoxes = document.querySelectorAll('.user-checkbox:checked');
|
||||
const bulkActionsBar = document.getElementById('bulkActionsBar');
|
||||
const selectedCount = document.getElementById('selectedCount');
|
||||
const selectAll = document.getElementById('selectAll');
|
||||
|
||||
// Update count
|
||||
selectedCount.textContent = checkedBoxes.length;
|
||||
|
||||
// Show/hide bulk actions bar
|
||||
if (checkedBoxes.length > 0) {
|
||||
bulkActionsBar.classList.remove('hidden');
|
||||
} else {
|
||||
bulkActionsBar.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Update "Select All" checkbox state
|
||||
const allCheckboxes = document.querySelectorAll('.user-checkbox');
|
||||
if (checkedBoxes.length === 0) {
|
||||
selectAll.checked = false;
|
||||
selectAll.indeterminate = false;
|
||||
} else if (checkedBoxes.length === allCheckboxes.length) {
|
||||
selectAll.checked = true;
|
||||
selectAll.indeterminate = false;
|
||||
} else {
|
||||
selectAll.checked = false;
|
||||
selectAll.indeterminate = true;
|
||||
}
|
||||
}
|
||||
|
||||
function executeBulkAction(action) {
|
||||
const checkedBoxes = document.querySelectorAll('.user-checkbox:checked');
|
||||
|
||||
if (checkedBoxes.length === 0) {
|
||||
alert('Please select at least one user.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirmation for delete action
|
||||
if (action === 'delete') {
|
||||
if (!confirm(`Are you sure you want to delete ${checkedBoxes.length} user(s)? This will also delete all associated API keys and usage logs. This action cannot be undone!`)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Set action type
|
||||
document.getElementById('bulkActionType').value = action;
|
||||
|
||||
// Add selected user IDs as hidden inputs
|
||||
const selectedUserIds = document.getElementById('selectedUserIds');
|
||||
selectedUserIds.innerHTML = '';
|
||||
|
||||
checkedBoxes.forEach(checkbox => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'user_ids[]';
|
||||
input.value = checkbox.value;
|
||||
selectedUserIds.appendChild(input);
|
||||
});
|
||||
|
||||
// Submit form
|
||||
document.getElementById('bulkActionForm').submit();
|
||||
}
|
||||
</script>
|
||||
|
||||
</x-app-layout>
|
||||
358
laravel-app/resources/views/gateway-users/show.blade.php
Normal file
358
laravel-app/resources/views/gateway-users/show.blade.php
Normal file
@@ -0,0 +1,358 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
|
||||
{{ $user->alias ?? 'User Details' }}
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">{{ $user->alias ?? 'User Details' }}</h1>
|
||||
<p class="mt-1 text-sm text-gray-600 font-mono">{{ $user->user_id }}</p>
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<form action="{{ route('gateway-users.toggle-block', $user->user_id) }}" method="POST" class="inline">
|
||||
@csrf
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-4 py-2 bg-{{ $user->blocked ? 'green' : 'red' }}-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-{{ $user->blocked ? 'green' : 'red' }}-700">
|
||||
{{ $user->blocked ? 'Unblock User' : 'Block User' }}
|
||||
</button>
|
||||
</form>
|
||||
<a href="{{ route('gateway-users.edit', $user->user_id) }}"
|
||||
class="inline-flex items-center px-4 py-2 bg-indigo-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-indigo-700">
|
||||
Edit User
|
||||
</a>
|
||||
<form action="{{ route('gateway-users.destroy', $user->user_id) }}"
|
||||
method="POST"
|
||||
class="inline"
|
||||
onsubmit="return confirm('Are you sure you want to delete this user? This will also delete all associated API keys and usage logs. This action cannot be undone!');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-4 py-2 bg-red-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-red-700">
|
||||
Delete User
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
@if(session('success'))
|
||||
<div class="mb-6 bg-green-50 border-l-4 border-green-400 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-green-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-green-700">{{ session('success') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Stats Overview -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 p-3 bg-green-100 rounded-lg">
|
||||
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600">Total Spend</p>
|
||||
<p class="text-2xl font-bold text-gray-900">${{ number_format($user->spend, 2) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 p-3 bg-blue-100 rounded-lg">
|
||||
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600">Requests (30d)</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ number_format($stats->total_requests ?? 0) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 p-3 bg-purple-100 rounded-lg">
|
||||
<svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600">Tokens (30d)</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ number_format($stats->total_tokens ?? 0) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 p-3 bg-yellow-100 rounded-lg">
|
||||
<svg class="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600">API Keys</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ $user->apiKeys->count() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Details & API Keys -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- User Details -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">User Information</h2>
|
||||
<dl class="space-y-3">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-sm font-medium text-gray-600">User ID</dt>
|
||||
<dd class="text-sm text-gray-900 font-mono">{{ $user->user_id }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-sm font-medium text-gray-600">Alias</dt>
|
||||
<dd class="text-sm text-gray-900">{{ $user->alias ?? 'N/A' }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-sm font-medium text-gray-600">Status</dt>
|
||||
<dd>
|
||||
@if($user->blocked)
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
|
||||
Blocked
|
||||
</span>
|
||||
@else
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||
Active
|
||||
</span>
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-sm font-medium text-gray-600">Budget</dt>
|
||||
<dd class="text-sm text-gray-900">
|
||||
@if($user->budget)
|
||||
{{ $user->budget->budget_id }} (${{ number_format($user->budget->max_budget, 2) }})
|
||||
@else
|
||||
No budget assigned
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-sm font-medium text-gray-600">Created</dt>
|
||||
<dd class="text-sm text-gray-900">{{ $user->created_at->format('M d, Y') }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- API Keys -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900">API Keys</h2>
|
||||
{{-- TODO: Enable when API Keys Management is implemented --}}
|
||||
{{-- <a href="{{ route('api-keys.create', ['user_id' => $user->user_id]) }}"
|
||||
class="text-sm text-indigo-600 hover:text-indigo-900">
|
||||
+ Create Key
|
||||
</a> --}}
|
||||
<span class="text-sm text-gray-400">Coming soon</span>
|
||||
</div>
|
||||
@if($user->apiKeys->count() > 0)
|
||||
<div class="space-y-3">
|
||||
@foreach($user->apiKeys as $apiKey)
|
||||
<div class="flex justify-between items-center p-3 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900">{{ $apiKey->key_name ?? 'Unnamed' }}</div>
|
||||
<div class="text-xs text-gray-500 font-mono">{{ substr($apiKey->id, 0, 12) }}...</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
@if($apiKey->is_active)
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">
|
||||
Active
|
||||
</span>
|
||||
@else
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-gray-100 text-gray-800">
|
||||
Inactive
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<p class="text-sm text-gray-500 text-center py-4">No API keys created yet</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 30-Day Usage Chart -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Usage Trend (Last 30 Days)</h2>
|
||||
<canvas id="usageChart" style="max-height: 300px;"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- 30-Day Statistics -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="text-sm text-gray-600 mb-1">Cost (30d)</div>
|
||||
<div class="text-2xl font-bold text-green-600">${{ number_format($stats->total_cost ?? 0, 2) }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
Avg: ${{ number_format(($stats->total_cost ?? 0) / max($stats->total_requests ?? 1, 1), 4) }}/request
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="text-sm text-gray-600 mb-1">Prompt Tokens</div>
|
||||
<div class="text-2xl font-bold text-blue-600">{{ number_format($stats->total_prompt_tokens ?? 0) }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
{{ number_format(($stats->total_prompt_tokens ?? 0) / max($stats->total_requests ?? 1, 1)) }}/request
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="text-sm text-gray-600 mb-1">Completion Tokens</div>
|
||||
<div class="text-2xl font-bold text-purple-600">{{ number_format($stats->total_completion_tokens ?? 0) }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
{{ number_format(($stats->total_completion_tokens ?? 0) / max($stats->total_requests ?? 1, 1)) }}/request
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Recent Activity</h2>
|
||||
</div>
|
||||
@if($recentLogs->count() > 0)
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Timestamp</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Model</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Provider</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Tokens</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Cost</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@foreach($recentLogs->take(20) as $log)
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ $log->timestamp->format('M d, H:i') }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ $log->model }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
||||
{{ $log->provider }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ number_format($log->total_tokens) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-semibold text-gray-900">
|
||||
${{ number_format($log->cost, 4) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
@if($log->status === 'success')
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||
Success
|
||||
</span>
|
||||
@else
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
|
||||
{{ $log->status }}
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@else
|
||||
<div class="px-6 py-12 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No activity yet</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">This user hasn't made any API requests yet.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
// Usage Chart
|
||||
const usageCtx = document.getElementById('usageChart').getContext('2d');
|
||||
new Chart(usageCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: @json($dailyUsage->pluck('date')->map(fn($d) => \Carbon\Carbon::parse($d)->format('M d'))),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Requests',
|
||||
data: @json($dailyUsage->pluck('requests')),
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
yAxisID: 'y',
|
||||
},
|
||||
{
|
||||
label: 'Cost ($)',
|
||||
data: @json($dailyUsage->pluck('cost')),
|
||||
borderColor: 'rgb(16, 185, 129)',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
yAxisID: 'y1',
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Requests'
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Cost ($)'
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
</x-app-layout>
|
||||
38
laravel-app/resources/views/layouts/app.blade.php
Normal file
38
laravel-app/resources/views/layouts/app.blade.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
|
||||
<title>{{ config('app.name', 'Laravel') }}</title>
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- Scripts -->
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="font-sans antialiased">
|
||||
<div class="min-h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<livewire:layout.navigation />
|
||||
|
||||
<!-- Page Heading -->
|
||||
@if (isset($header))
|
||||
<header class="bg-white dark:bg-gray-800 shadow">
|
||||
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
{{ $header }}
|
||||
</div>
|
||||
</header>
|
||||
@endif
|
||||
|
||||
<!-- Page Content -->
|
||||
<main>
|
||||
{{ $slot }}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@stack('scripts')
|
||||
</body>
|
||||
</html>
|
||||
30
laravel-app/resources/views/layouts/guest.blade.php
Normal file
30
laravel-app/resources/views/layouts/guest.blade.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
|
||||
<title>{{ config('app.name', 'Laravel') }}</title>
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- Scripts -->
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="font-sans text-gray-900 antialiased">
|
||||
<div class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100 dark:bg-gray-900">
|
||||
<div>
|
||||
<a href="/" wire:navigate>
|
||||
<x-application-logo class="w-20 h-20 fill-current text-gray-500" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-gray-800 shadow-md overflow-hidden sm:rounded-lg">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
0
laravel-app/resources/views/livewire/.gitkeep
Normal file
0
laravel-app/resources/views/livewire/.gitkeep
Normal file
140
laravel-app/resources/views/livewire/layout/navigation.blade.php
Normal file
140
laravel-app/resources/views/livewire/layout/navigation.blade.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Actions\Logout;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new class extends Component
|
||||
{
|
||||
/**
|
||||
* Log the current user out of the application.
|
||||
*/
|
||||
public function logout(Logout $logout): void
|
||||
{
|
||||
$logout();
|
||||
|
||||
$this->redirect('/', navigate: true);
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<nav x-data="{ open: false }" class="bg-white dark:bg-gray-800 border-b border-gray-100 dark:border-gray-700">
|
||||
<!-- Primary Navigation Menu -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex">
|
||||
<!-- Logo -->
|
||||
<div class="shrink-0 flex items-center">
|
||||
<a href="{{ route('dashboard') }}" wire:navigate>
|
||||
<x-application-logo class="block h-9 w-auto fill-current text-gray-800 dark:text-gray-200" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Links -->
|
||||
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
|
||||
<x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')" wire:navigate>
|
||||
{{ __('Dashboard') }}
|
||||
</x-nav-link>
|
||||
<x-nav-link :href="route('gateway-users.index')" :active="request()->routeIs('gateway-users.*')" wire:navigate>
|
||||
{{ __('Gateway Users') }}
|
||||
</x-nav-link>
|
||||
<x-nav-link :href="route('api-keys.index')" :active="request()->routeIs('api-keys.*')" wire:navigate>
|
||||
{{ __('API Keys') }}
|
||||
</x-nav-link>
|
||||
<x-nav-link :href="route('budgets.index')" :active="request()->routeIs('budgets.*')" wire:navigate>
|
||||
{{ __('Budgets') }}
|
||||
</x-nav-link>
|
||||
<x-nav-link :href="route('usage-logs.index')" :active="request()->routeIs('usage-logs.*')" wire:navigate>
|
||||
{{ __('Usage Logs') }}
|
||||
</x-nav-link>
|
||||
<x-nav-link :href="route('model-pricing.index')" :active="request()->routeIs('model-pricing.*')" wire:navigate>
|
||||
{{ __('Pricing') }}
|
||||
</x-nav-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Dropdown -->
|
||||
<div class="hidden sm:flex sm:items-center sm:ms-6">
|
||||
<x-dropdown align="right" width="48">
|
||||
<x-slot name="trigger">
|
||||
<button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none transition ease-in-out duration-150">
|
||||
<div x-data="{{ json_encode(['name' => auth()->user()->name]) }}" x-text="name" x-on:profile-updated.window="name = $event.detail.name"></div>
|
||||
|
||||
<div class="ms-1">
|
||||
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="content">
|
||||
<x-dropdown-link :href="route('profile')" wire:navigate>
|
||||
{{ __('Profile') }}
|
||||
</x-dropdown-link>
|
||||
|
||||
<!-- Authentication -->
|
||||
<button wire:click="logout" class="w-full text-start">
|
||||
<x-dropdown-link>
|
||||
{{ __('Log Out') }}
|
||||
</x-dropdown-link>
|
||||
</button>
|
||||
</x-slot>
|
||||
</x-dropdown>
|
||||
</div>
|
||||
|
||||
<!-- Hamburger -->
|
||||
<div class="-me-2 flex items-center sm:hidden">
|
||||
<button @click="open = ! open" class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 dark:text-gray-500 hover:text-gray-500 dark:hover:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-900 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-900 focus:text-gray-500 dark:focus:text-gray-400 transition duration-150 ease-in-out">
|
||||
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
|
||||
<path :class="{'hidden': open, 'inline-flex': ! open }" class="inline-flex" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
<path :class="{'hidden': ! open, 'inline-flex': open }" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Responsive Navigation Menu -->
|
||||
<div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden">
|
||||
<div class="pt-2 pb-3 space-y-1">
|
||||
<x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')" wire:navigate>
|
||||
{{ __('Dashboard') }}
|
||||
</x-responsive-nav-link>
|
||||
<x-responsive-nav-link :href="route('gateway-users.index')" :active="request()->routeIs('gateway-users.*')" wire:navigate>
|
||||
{{ __('Gateway Users') }}
|
||||
</x-responsive-nav-link>
|
||||
<x-responsive-nav-link :href="route('api-keys.index')" :active="request()->routeIs('api-keys.*')" wire:navigate>
|
||||
{{ __('API Keys') }}
|
||||
</x-responsive-nav-link>
|
||||
<x-responsive-nav-link :href="route('budgets.index')" :active="request()->routeIs('budgets.*')" wire:navigate>
|
||||
{{ __('Budgets') }}
|
||||
</x-responsive-nav-link>
|
||||
<x-responsive-nav-link :href="route('usage-logs.index')" :active="request()->routeIs('usage-logs.*')" wire:navigate>
|
||||
{{ __('Usage Logs') }}
|
||||
</x-responsive-nav-link>
|
||||
<x-responsive-nav-link :href="route('model-pricing.index')" :active="request()->routeIs('model-pricing.*')" wire:navigate>
|
||||
{{ __('Pricing') }}
|
||||
</x-responsive-nav-link>
|
||||
</div>
|
||||
|
||||
<!-- Responsive Settings Options -->
|
||||
<div class="pt-4 pb-1 border-t border-gray-200 dark:border-gray-600">
|
||||
<div class="px-4">
|
||||
<div class="font-medium text-base text-gray-800 dark:text-gray-200" x-data="{{ json_encode(['name' => auth()->user()->name]) }}" x-text="name" x-on:profile-updated.window="name = $event.detail.name"></div>
|
||||
<div class="font-medium text-sm text-gray-500">{{ auth()->user()->email }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 space-y-1">
|
||||
<x-responsive-nav-link :href="route('profile')" wire:navigate>
|
||||
{{ __('Profile') }}
|
||||
</x-responsive-nav-link>
|
||||
|
||||
<!-- Authentication -->
|
||||
<button wire:click="logout" class="w-full text-start">
|
||||
<x-responsive-nav-link>
|
||||
{{ __('Log Out') }}
|
||||
</x-responsive-nav-link>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new #[Layout('layouts.guest')] class extends Component
|
||||
{
|
||||
public string $password = '';
|
||||
|
||||
/**
|
||||
* Confirm the current user's password.
|
||||
*/
|
||||
public function confirmPassword(): void
|
||||
{
|
||||
$this->validate([
|
||||
'password' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
if (! Auth::guard('web')->validate([
|
||||
'email' => Auth::user()->email,
|
||||
'password' => $this->password,
|
||||
])) {
|
||||
throw ValidationException::withMessages([
|
||||
'password' => __('auth.password'),
|
||||
]);
|
||||
}
|
||||
|
||||
session(['auth.password_confirmed_at' => time()]);
|
||||
|
||||
$this->redirectIntended(default: route('dashboard', absolute: false), navigate: true);
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div>
|
||||
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ __('This is a secure area of the application. Please confirm your password before continuing.') }}
|
||||
</div>
|
||||
|
||||
<form wire:submit="confirmPassword">
|
||||
<!-- Password -->
|
||||
<div>
|
||||
<x-input-label for="password" :value="__('Password')" />
|
||||
|
||||
<x-text-input wire:model="password"
|
||||
id="password"
|
||||
class="block mt-1 w-full"
|
||||
type="password"
|
||||
name="password"
|
||||
required autocomplete="current-password" />
|
||||
|
||||
<x-input-error :messages="$errors->get('password')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<x-primary-button>
|
||||
{{ __('Confirm') }}
|
||||
</x-primary-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new #[Layout('layouts.guest')] class extends Component
|
||||
{
|
||||
public string $email = '';
|
||||
|
||||
/**
|
||||
* Send a password reset link to the provided email address.
|
||||
*/
|
||||
public function sendPasswordResetLink(): void
|
||||
{
|
||||
$this->validate([
|
||||
'email' => ['required', 'string', 'email'],
|
||||
]);
|
||||
|
||||
// We will send the password reset link to this user. Once we have attempted
|
||||
// to send the link, we will examine the response then see the message we
|
||||
// need to show to the user. Finally, we'll send out a proper response.
|
||||
$status = Password::sendResetLink(
|
||||
$this->only('email')
|
||||
);
|
||||
|
||||
if ($status != Password::RESET_LINK_SENT) {
|
||||
$this->addError('email', __($status));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->reset('email');
|
||||
|
||||
session()->flash('status', __($status));
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div>
|
||||
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ __('Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.') }}
|
||||
</div>
|
||||
|
||||
<!-- Session Status -->
|
||||
<x-auth-session-status class="mb-4" :status="session('status')" />
|
||||
|
||||
<form wire:submit="sendPasswordResetLink">
|
||||
<!-- Email Address -->
|
||||
<div>
|
||||
<x-input-label for="email" :value="__('Email')" />
|
||||
<x-text-input wire:model="email" id="email" class="block mt-1 w-full" type="email" name="email" required autofocus />
|
||||
<x-input-error :messages="$errors->get('email')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end mt-4">
|
||||
<x-primary-button>
|
||||
{{ __('Email Password Reset Link') }}
|
||||
</x-primary-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Forms\LoginForm;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new #[Layout('layouts.guest')] class extends Component
|
||||
{
|
||||
public LoginForm $form;
|
||||
|
||||
/**
|
||||
* Handle an incoming authentication request.
|
||||
*/
|
||||
public function login(): void
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
$this->form->authenticate();
|
||||
|
||||
Session::regenerate();
|
||||
|
||||
$this->redirectIntended(default: route('dashboard', absolute: false), navigate: true);
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div>
|
||||
<!-- Session Status -->
|
||||
<x-auth-session-status class="mb-4" :status="session('status')" />
|
||||
|
||||
<form wire:submit="login">
|
||||
<!-- Email Address -->
|
||||
<div>
|
||||
<x-input-label for="email" :value="__('Email')" />
|
||||
<x-text-input wire:model="form.email" id="email" class="block mt-1 w-full" type="email" name="email" required autofocus autocomplete="username" />
|
||||
<x-input-error :messages="$errors->get('form.email')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="mt-4">
|
||||
<x-input-label for="password" :value="__('Password')" />
|
||||
|
||||
<x-text-input wire:model="form.password" id="password" class="block mt-1 w-full"
|
||||
type="password"
|
||||
name="password"
|
||||
required autocomplete="current-password" />
|
||||
|
||||
<x-input-error :messages="$errors->get('form.password')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Remember Me -->
|
||||
<div class="block mt-4">
|
||||
<label for="remember" class="inline-flex items-center">
|
||||
<input wire:model="form.remember" id="remember" type="checkbox" class="rounded dark:bg-gray-900 border-gray-300 dark:border-gray-700 text-indigo-600 shadow-sm focus:ring-indigo-500 dark:focus:ring-indigo-600 dark:focus:ring-offset-gray-800" name="remember">
|
||||
<span class="ms-2 text-sm text-gray-600 dark:text-gray-400">{{ __('Remember me') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end mt-4">
|
||||
@if (Route::has('password.request'))
|
||||
<a class="underline text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800" href="{{ route('password.request') }}" wire:navigate>
|
||||
{{ __('Forgot your password?') }}
|
||||
</a>
|
||||
@endif
|
||||
|
||||
<x-primary-button class="ms-3">
|
||||
{{ __('Log in') }}
|
||||
</x-primary-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new #[Layout('layouts.guest')] class extends Component
|
||||
{
|
||||
public string $name = '';
|
||||
public string $email = '';
|
||||
public string $password = '';
|
||||
public string $password_confirmation = '';
|
||||
|
||||
/**
|
||||
* Handle an incoming registration request.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$validated = $this->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
|
||||
'password' => ['required', 'string', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
$validated['password'] = Hash::make($validated['password']);
|
||||
|
||||
event(new Registered($user = User::create($validated)));
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
$this->redirect(route('dashboard', absolute: false), navigate: true);
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div>
|
||||
<form wire:submit="register">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<x-input-label for="name" :value="__('Name')" />
|
||||
<x-text-input wire:model="name" id="name" class="block mt-1 w-full" type="text" name="name" required autofocus autocomplete="name" />
|
||||
<x-input-error :messages="$errors->get('name')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Email Address -->
|
||||
<div class="mt-4">
|
||||
<x-input-label for="email" :value="__('Email')" />
|
||||
<x-text-input wire:model="email" id="email" class="block mt-1 w-full" type="email" name="email" required autocomplete="username" />
|
||||
<x-input-error :messages="$errors->get('email')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="mt-4">
|
||||
<x-input-label for="password" :value="__('Password')" />
|
||||
|
||||
<x-text-input wire:model="password" id="password" class="block mt-1 w-full"
|
||||
type="password"
|
||||
name="password"
|
||||
required autocomplete="new-password" />
|
||||
|
||||
<x-input-error :messages="$errors->get('password')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<div class="mt-4">
|
||||
<x-input-label for="password_confirmation" :value="__('Confirm Password')" />
|
||||
|
||||
<x-text-input wire:model="password_confirmation" id="password_confirmation" class="block mt-1 w-full"
|
||||
type="password"
|
||||
name="password_confirmation" required autocomplete="new-password" />
|
||||
|
||||
<x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end mt-4">
|
||||
<a class="underline text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800" href="{{ route('login') }}" wire:navigate>
|
||||
{{ __('Already registered?') }}
|
||||
</a>
|
||||
|
||||
<x-primary-button class="ms-4">
|
||||
{{ __('Register') }}
|
||||
</x-primary-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new #[Layout('layouts.guest')] class extends Component
|
||||
{
|
||||
#[Locked]
|
||||
public string $token = '';
|
||||
public string $email = '';
|
||||
public string $password = '';
|
||||
public string $password_confirmation = '';
|
||||
|
||||
/**
|
||||
* Mount the component.
|
||||
*/
|
||||
public function mount(string $token): void
|
||||
{
|
||||
$this->token = $token;
|
||||
|
||||
$this->email = request()->string('email');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the password for the given user.
|
||||
*/
|
||||
public function resetPassword(): void
|
||||
{
|
||||
$this->validate([
|
||||
'token' => ['required'],
|
||||
'email' => ['required', 'string', 'email'],
|
||||
'password' => ['required', 'string', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
// Here we will attempt to reset the user's password. If it is successful we
|
||||
// will update the password on an actual user model and persist it to the
|
||||
// database. Otherwise we will parse the error and return the response.
|
||||
$status = Password::reset(
|
||||
$this->only('email', 'password', 'password_confirmation', 'token'),
|
||||
function ($user) {
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($this->password),
|
||||
'remember_token' => Str::random(60),
|
||||
])->save();
|
||||
|
||||
event(new PasswordReset($user));
|
||||
}
|
||||
);
|
||||
|
||||
// If the password was successfully reset, we will redirect the user back to
|
||||
// the application's home authenticated view. If there is an error we can
|
||||
// redirect them back to where they came from with their error message.
|
||||
if ($status != Password::PASSWORD_RESET) {
|
||||
$this->addError('email', __($status));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Session::flash('status', __($status));
|
||||
|
||||
$this->redirectRoute('login', navigate: true);
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div>
|
||||
<form wire:submit="resetPassword">
|
||||
<!-- Email Address -->
|
||||
<div>
|
||||
<x-input-label for="email" :value="__('Email')" />
|
||||
<x-text-input wire:model="email" id="email" class="block mt-1 w-full" type="email" name="email" required autofocus autocomplete="username" />
|
||||
<x-input-error :messages="$errors->get('email')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="mt-4">
|
||||
<x-input-label for="password" :value="__('Password')" />
|
||||
<x-text-input wire:model="password" id="password" class="block mt-1 w-full" type="password" name="password" required autocomplete="new-password" />
|
||||
<x-input-error :messages="$errors->get('password')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<div class="mt-4">
|
||||
<x-input-label for="password_confirmation" :value="__('Confirm Password')" />
|
||||
|
||||
<x-text-input wire:model="password_confirmation" id="password_confirmation" class="block mt-1 w-full"
|
||||
type="password"
|
||||
name="password_confirmation" required autocomplete="new-password" />
|
||||
|
||||
<x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end mt-4">
|
||||
<x-primary-button>
|
||||
{{ __('Reset Password') }}
|
||||
</x-primary-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Actions\Logout;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new #[Layout('layouts.guest')] class extends Component
|
||||
{
|
||||
/**
|
||||
* Send an email verification notification to the user.
|
||||
*/
|
||||
public function sendVerification(): void
|
||||
{
|
||||
if (Auth::user()->hasVerifiedEmail()) {
|
||||
$this->redirectIntended(default: route('dashboard', absolute: false), navigate: true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Auth::user()->sendEmailVerificationNotification();
|
||||
|
||||
Session::flash('status', 'verification-link-sent');
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the current user out of the application.
|
||||
*/
|
||||
public function logout(Logout $logout): void
|
||||
{
|
||||
$logout();
|
||||
|
||||
$this->redirect('/', navigate: true);
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div>
|
||||
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ __('Thanks for signing up! Before getting started, could you verify your email address by clicking on the link we just emailed to you? If you didn\'t receive the email, we will gladly send you another.') }}
|
||||
</div>
|
||||
|
||||
@if (session('status') == 'verification-link-sent')
|
||||
<div class="mb-4 font-medium text-sm text-green-600 dark:text-green-400">
|
||||
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<x-primary-button wire:click="sendVerification">
|
||||
{{ __('Resend Verification Email') }}
|
||||
</x-primary-button>
|
||||
|
||||
<button wire:click="logout" type="submit" class="underline text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800">
|
||||
{{ __('Log Out') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Actions\Logout;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new class extends Component
|
||||
{
|
||||
public string $password = '';
|
||||
|
||||
/**
|
||||
* Delete the currently authenticated user.
|
||||
*/
|
||||
public function deleteUser(Logout $logout): void
|
||||
{
|
||||
$this->validate([
|
||||
'password' => ['required', 'string', 'current_password'],
|
||||
]);
|
||||
|
||||
tap(Auth::user(), $logout(...))->delete();
|
||||
|
||||
$this->redirect('/', navigate: true);
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<section class="space-y-6">
|
||||
<header>
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ __('Delete Account') }}
|
||||
</h2>
|
||||
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.') }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<x-danger-button
|
||||
x-data=""
|
||||
x-on:click.prevent="$dispatch('open-modal', 'confirm-user-deletion')"
|
||||
>{{ __('Delete Account') }}</x-danger-button>
|
||||
|
||||
<x-modal name="confirm-user-deletion" :show="$errors->isNotEmpty()" focusable>
|
||||
<form wire:submit="deleteUser" class="p-6">
|
||||
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ __('Are you sure you want to delete your account?') }}
|
||||
</h2>
|
||||
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }}
|
||||
</p>
|
||||
|
||||
<div class="mt-6">
|
||||
<x-input-label for="password" value="{{ __('Password') }}" class="sr-only" />
|
||||
|
||||
<x-text-input
|
||||
wire:model="password"
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
class="mt-1 block w-3/4"
|
||||
placeholder="{{ __('Password') }}"
|
||||
/>
|
||||
|
||||
<x-input-error :messages="$errors->get('password')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<x-secondary-button x-on:click="$dispatch('close')">
|
||||
{{ __('Cancel') }}
|
||||
</x-secondary-button>
|
||||
|
||||
<x-danger-button class="ms-3">
|
||||
{{ __('Delete Account') }}
|
||||
</x-danger-button>
|
||||
</div>
|
||||
</form>
|
||||
</x-modal>
|
||||
</section>
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new class extends Component
|
||||
{
|
||||
public string $current_password = '';
|
||||
public string $password = '';
|
||||
public string $password_confirmation = '';
|
||||
|
||||
/**
|
||||
* Update the password for the currently authenticated user.
|
||||
*/
|
||||
public function updatePassword(): void
|
||||
{
|
||||
try {
|
||||
$validated = $this->validate([
|
||||
'current_password' => ['required', 'string', 'current_password'],
|
||||
'password' => ['required', 'string', Password::defaults(), 'confirmed'],
|
||||
]);
|
||||
} catch (ValidationException $e) {
|
||||
$this->reset('current_password', 'password', 'password_confirmation');
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
Auth::user()->update([
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
|
||||
$this->reset('current_password', 'password', 'password_confirmation');
|
||||
|
||||
$this->dispatch('password-updated');
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<section>
|
||||
<header>
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ __('Update Password') }}
|
||||
</h2>
|
||||
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ __('Ensure your account is using a long, random password to stay secure.') }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form wire:submit="updatePassword" class="mt-6 space-y-6">
|
||||
<div>
|
||||
<x-input-label for="update_password_current_password" :value="__('Current Password')" />
|
||||
<x-text-input wire:model="current_password" id="update_password_current_password" name="current_password" type="password" class="mt-1 block w-full" autocomplete="current-password" />
|
||||
<x-input-error :messages="$errors->get('current_password')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="update_password_password" :value="__('New Password')" />
|
||||
<x-text-input wire:model="password" id="update_password_password" name="password" type="password" class="mt-1 block w-full" autocomplete="new-password" />
|
||||
<x-input-error :messages="$errors->get('password')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="update_password_password_confirmation" :value="__('Confirm Password')" />
|
||||
<x-text-input wire:model="password_confirmation" id="update_password_password_confirmation" name="password_confirmation" type="password" class="mt-1 block w-full" autocomplete="new-password" />
|
||||
<x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<x-primary-button>{{ __('Save') }}</x-primary-button>
|
||||
|
||||
<x-action-message class="me-3" on="password-updated">
|
||||
{{ __('Saved.') }}
|
||||
</x-action-message>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new class extends Component
|
||||
{
|
||||
public string $name = '';
|
||||
public string $email = '';
|
||||
|
||||
/**
|
||||
* Mount the component.
|
||||
*/
|
||||
public function mount(): void
|
||||
{
|
||||
$this->name = Auth::user()->name;
|
||||
$this->email = Auth::user()->email;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the profile information for the currently authenticated user.
|
||||
*/
|
||||
public function updateProfileInformation(): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
$validated = $this->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', Rule::unique(User::class)->ignore($user->id)],
|
||||
]);
|
||||
|
||||
$user->fill($validated);
|
||||
|
||||
if ($user->isDirty('email')) {
|
||||
$user->email_verified_at = null;
|
||||
}
|
||||
|
||||
$user->save();
|
||||
|
||||
$this->dispatch('profile-updated', name: $user->name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email verification notification to the current user.
|
||||
*/
|
||||
public function sendVerification(): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if ($user->hasVerifiedEmail()) {
|
||||
$this->redirectIntended(default: route('dashboard', absolute: false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$user->sendEmailVerificationNotification();
|
||||
|
||||
Session::flash('status', 'verification-link-sent');
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<section>
|
||||
<header>
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ __('Profile Information') }}
|
||||
</h2>
|
||||
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ __("Update your account's profile information and email address.") }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form wire:submit="updateProfileInformation" class="mt-6 space-y-6">
|
||||
<div>
|
||||
<x-input-label for="name" :value="__('Name')" />
|
||||
<x-text-input wire:model="name" id="name" name="name" type="text" class="mt-1 block w-full" required autofocus autocomplete="name" />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('name')" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="email" :value="__('Email')" />
|
||||
<x-text-input wire:model="email" id="email" name="email" type="email" class="mt-1 block w-full" required autocomplete="username" />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('email')" />
|
||||
|
||||
@if (auth()->user() instanceof \Illuminate\Contracts\Auth\MustVerifyEmail && ! auth()->user()->hasVerifiedEmail())
|
||||
<div>
|
||||
<p class="text-sm mt-2 text-gray-800 dark:text-gray-200">
|
||||
{{ __('Your email address is unverified.') }}
|
||||
|
||||
<button wire:click.prevent="sendVerification" class="underline text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800">
|
||||
{{ __('Click here to re-send the verification email.') }}
|
||||
</button>
|
||||
</p>
|
||||
|
||||
@if (session('status') === 'verification-link-sent')
|
||||
<p class="mt-2 font-medium text-sm text-green-600 dark:text-green-400">
|
||||
{{ __('A new verification link has been sent to your email address.') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<x-primary-button>{{ __('Save') }}</x-primary-button>
|
||||
|
||||
<x-action-message class="me-3" on="profile-updated">
|
||||
{{ __('Saved.') }}
|
||||
</x-action-message>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -0,0 +1,26 @@
|
||||
<nav class="-mx-3 flex flex-1 justify-end">
|
||||
@auth
|
||||
<a
|
||||
href="{{ url('/dashboard') }}"
|
||||
class="rounded-md px-3 py-2 text-black ring-1 ring-transparent transition hover:text-black/70 focus:outline-none focus-visible:ring-[#FF2D20] dark:text-white dark:hover:text-white/80 dark:focus-visible:ring-white"
|
||||
>
|
||||
Dashboard
|
||||
</a>
|
||||
@else
|
||||
<a
|
||||
href="{{ route('login') }}"
|
||||
class="rounded-md px-3 py-2 text-black ring-1 ring-transparent transition hover:text-black/70 focus:outline-none focus-visible:ring-[#FF2D20] dark:text-white dark:hover:text-white/80 dark:focus-visible:ring-white"
|
||||
>
|
||||
Log in
|
||||
</a>
|
||||
|
||||
@if (Route::has('register'))
|
||||
<a
|
||||
href="{{ route('register') }}"
|
||||
class="rounded-md px-3 py-2 text-black ring-1 ring-transparent transition hover:text-black/70 focus:outline-none focus-visible:ring-[#FF2D20] dark:text-white dark:hover:text-white/80 dark:focus-visible:ring-white"
|
||||
>
|
||||
Register
|
||||
</a>
|
||||
@endif
|
||||
@endauth
|
||||
</nav>
|
||||
138
laravel-app/resources/views/model-pricing/calculator.blade.php
Normal file
138
laravel-app/resources/views/model-pricing/calculator.blade.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
Cost Calculator
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-3xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6">
|
||||
<p class="text-sm text-gray-600 mb-6">
|
||||
Calculate the cost of API requests based on token usage and model pricing.
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="calc_model" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Select Model *
|
||||
</label>
|
||||
<select id="calc_model"
|
||||
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||
<option value="">Choose a model...</option>
|
||||
@foreach($models as $model)
|
||||
<option value="{{ $model->model_key }}"
|
||||
data-input-price="{{ $model->input_price_per_million }}"
|
||||
data-output-price="{{ $model->output_price_per_million }}">
|
||||
{{ $model->model_key }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="input_tokens" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Input Tokens
|
||||
</label>
|
||||
<input type="number" id="input_tokens" value="1000" min="0"
|
||||
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="output_tokens" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Output Tokens
|
||||
</label>
|
||||
<input type="number" id="output_tokens" value="500" min="0"
|
||||
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onclick="calculateCost()"
|
||||
class="w-full bg-blue-500 hover:bg-blue-700 text-white font-bold py-3 px-4 rounded">
|
||||
Calculate Cost
|
||||
</button>
|
||||
|
||||
<div id="result" class="hidden">
|
||||
<div class="bg-gray-50 rounded-lg p-6 space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-gray-600">Model:</span>
|
||||
<span id="result_model" class="text-sm font-medium text-gray-900"></span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-gray-600">Input Tokens:</span>
|
||||
<span id="result_input_tokens" class="text-sm font-medium text-gray-900"></span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-gray-600">Output Tokens:</span>
|
||||
<span id="result_output_tokens" class="text-sm font-medium text-gray-900"></span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-gray-600">Total Tokens:</span>
|
||||
<span id="result_total_tokens" class="text-sm font-medium text-gray-900"></span>
|
||||
</div>
|
||||
<div class="border-t pt-3 mt-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-lg font-semibold text-gray-900">Total Cost:</span>
|
||||
<span id="result_cost" class="text-2xl font-bold text-green-600"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h4 class="font-semibold text-blue-900 mb-2">💡 Quick Reference</h4>
|
||||
<p class="text-sm text-blue-800">
|
||||
Prices are calculated per million tokens. For example, if input price is $3.00/M and you use 1,000 tokens,
|
||||
the cost would be: (1,000 / 1,000,000) × $3.00 = $0.003
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function calculateCost() {
|
||||
const modelKey = document.getElementById('calc_model').value;
|
||||
const inputTokens = parseInt(document.getElementById('input_tokens').value) || 0;
|
||||
const outputTokens = parseInt(document.getElementById('output_tokens').value) || 0;
|
||||
|
||||
if (!modelKey) {
|
||||
alert('Please select a model');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get pricing from selected option
|
||||
const select = document.getElementById('calc_model');
|
||||
const option = select.options[select.selectedIndex];
|
||||
const inputPrice = parseFloat(option.dataset.inputPrice);
|
||||
const outputPrice = parseFloat(option.dataset.outputPrice);
|
||||
|
||||
// Calculate cost
|
||||
const inputCost = (inputTokens / 1000000) * inputPrice;
|
||||
const outputCost = (outputTokens / 1000000) * outputPrice;
|
||||
const totalCost = inputCost + outputCost;
|
||||
const totalTokens = inputTokens + outputTokens;
|
||||
|
||||
// Display results
|
||||
document.getElementById('result_model').textContent = modelKey;
|
||||
document.getElementById('result_input_tokens').textContent = inputTokens.toLocaleString();
|
||||
document.getElementById('result_output_tokens').textContent = outputTokens.toLocaleString();
|
||||
document.getElementById('result_total_tokens').textContent = totalTokens.toLocaleString();
|
||||
document.getElementById('result_cost').textContent = '$' + totalCost.toFixed(6);
|
||||
|
||||
document.getElementById('result').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Allow Enter key to calculate
|
||||
document.getElementById('input_tokens').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') calculateCost();
|
||||
});
|
||||
document.getElementById('output_tokens').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') calculateCost();
|
||||
});
|
||||
</script>
|
||||
</x-app-layout>
|
||||
71
laravel-app/resources/views/model-pricing/create.blade.php
Normal file
71
laravel-app/resources/views/model-pricing/create.blade.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
Add New Model Pricing
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6">
|
||||
<form method="POST" action="{{ route('model-pricing.store') }}">
|
||||
@csrf
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="model_key" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Model Key *
|
||||
</label>
|
||||
<input type="text" name="model_key" id="model_key"
|
||||
value="{{ old('model_key') }}" required
|
||||
placeholder="e.g., gpt-4, claude-3-opus-20240229"
|
||||
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||
@error('model_key')
|
||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="input_price_per_million" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Input Price per Million Tokens *
|
||||
</label>
|
||||
<input type="number" name="input_price_per_million" id="input_price_per_million"
|
||||
value="{{ old('input_price_per_million') }}" step="0.01" min="0" required
|
||||
placeholder="e.g., 3.00"
|
||||
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||
@error('input_price_per_million')
|
||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
<p class="mt-1 text-xs text-gray-500">Price in USD per 1 million input tokens</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="output_price_per_million" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Output Price per Million Tokens *
|
||||
</label>
|
||||
<input type="number" name="output_price_per_million" id="output_price_per_million"
|
||||
value="{{ old('output_price_per_million') }}" step="0.01" min="0" required
|
||||
placeholder="e.g., 15.00"
|
||||
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||
@error('output_price_per_million')
|
||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
<p class="mt-1 text-xs text-gray-500">Price in USD per 1 million output tokens</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<a href="{{ route('model-pricing.index') }}"
|
||||
class="bg-gray-300 hover:bg-gray-400 text-gray-800 font-bold py-2 px-4 rounded">
|
||||
Cancel
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||
Create Model Pricing
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
64
laravel-app/resources/views/model-pricing/edit.blade.php
Normal file
64
laravel-app/resources/views/model-pricing/edit.blade.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
Edit Model Pricing: {{ $model->model_key }}
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6">
|
||||
<form method="POST" action="{{ route('model-pricing.update', $model->model_key) }}">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Model Key</label>
|
||||
<input type="text" value="{{ $model->model_key }}" disabled
|
||||
class="w-full rounded-md border-gray-300 bg-gray-100 shadow-sm">
|
||||
<p class="mt-1 text-xs text-gray-500">Model key cannot be changed</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="input_price_per_million" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Input Price per Million Tokens *
|
||||
</label>
|
||||
<input type="number" name="input_price_per_million" id="input_price_per_million"
|
||||
value="{{ old('input_price_per_million', $model->input_price_per_million) }}"
|
||||
step="0.01" min="0" required
|
||||
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||
@error('input_price_per_million')
|
||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="output_price_per_million" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Output Price per Million Tokens *
|
||||
</label>
|
||||
<input type="number" name="output_price_per_million" id="output_price_per_million"
|
||||
value="{{ old('output_price_per_million', $model->output_price_per_million) }}"
|
||||
step="0.01" min="0" required
|
||||
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||
@error('output_price_per_million')
|
||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<a href="{{ route('model-pricing.index') }}"
|
||||
class="bg-gray-300 hover:bg-gray-400 text-gray-800 font-bold py-2 px-4 rounded">
|
||||
Cancel
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||
Update Model Pricing
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
58
laravel-app/resources/views/model-pricing/import.blade.php
Normal file
58
laravel-app/resources/views/model-pricing/import.blade.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
Import Model Pricing from CSV
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-3xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold mb-2">CSV Format Instructions</h3>
|
||||
<p class="text-sm text-gray-600 mb-2">Your CSV file should have the following format:</p>
|
||||
<div class="bg-gray-100 p-4 rounded font-mono text-sm">
|
||||
model_key,input_price_per_million,output_price_per_million<br>
|
||||
gpt-4,30.00,60.00<br>
|
||||
gpt-3.5-turbo,0.50,1.50<br>
|
||||
claude-3-opus-20240229,15.00,75.00
|
||||
</div>
|
||||
<ul class="mt-3 text-sm text-gray-600 list-disc list-inside space-y-1">
|
||||
<li>First row must be headers (will be skipped)</li>
|
||||
<li>Existing models will be updated with new prices</li>
|
||||
<li>New models will be created</li>
|
||||
<li>Maximum file size: 2MB</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('model-pricing.import') }}" enctype="multipart/form-data">
|
||||
@csrf
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="csv_file" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Select CSV File *
|
||||
</label>
|
||||
<input type="file" name="csv_file" id="csv_file" required accept=".csv,.txt"
|
||||
class="w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
|
||||
@error('csv_file')
|
||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<a href="{{ route('model-pricing.index') }}"
|
||||
class="bg-gray-300 hover:bg-gray-400 text-gray-800 font-bold py-2 px-4 rounded">
|
||||
Cancel
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded">
|
||||
Import CSV
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
96
laravel-app/resources/views/model-pricing/index.blade.php
Normal file
96
laravel-app/resources/views/model-pricing/index.blade.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
Model Pricing
|
||||
</h2>
|
||||
<div class="flex space-x-2">
|
||||
<a href="{{ route('model-pricing.calculator') }}"
|
||||
class="bg-purple-500 hover:bg-purple-700 text-white font-bold py-2 px-4 rounded">
|
||||
Cost Calculator
|
||||
</a>
|
||||
<a href="{{ route('model-pricing.import-form') }}"
|
||||
class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded">
|
||||
Import CSV
|
||||
</a>
|
||||
<a href="{{ route('model-pricing.create') }}"
|
||||
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||
Add New Model
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
@if(session('success'))
|
||||
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative mb-4">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6 text-gray-900">
|
||||
@if($modelPricing->count() > 0)
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Model
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Input Price
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Output Price
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@foreach($modelPricing as $model)
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{{ $model->model_key }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-right text-blue-600 font-semibold">
|
||||
{{ $model->input_price_formatted }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-right text-green-600 font-semibold">
|
||||
{{ $model->output_price_formatted }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<a href="{{ route('model-pricing.edit', $model->model_key) }}"
|
||||
class="text-indigo-600 hover:text-indigo-900 mr-3">Edit</a>
|
||||
<form action="{{ route('model-pricing.destroy', $model->model_key) }}"
|
||||
method="POST" class="inline"
|
||||
onsubmit="return confirm('Are you sure?');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-red-600 hover:text-red-900">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="mt-4">
|
||||
{{ $modelPricing->links() }}
|
||||
</div>
|
||||
@else
|
||||
<div class="text-center py-12">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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"></path>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No model pricing found</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Get started by adding a new model pricing.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
29
laravel-app/resources/views/profile.blade.php
Normal file
29
laravel-app/resources/views/profile.blade.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
|
||||
{{ __('Profile') }}
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||
<div class="p-4 sm:p-8 bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
||||
<div class="max-w-xl">
|
||||
<livewire:profile.update-profile-information-form />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 sm:p-8 bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
||||
<div class="max-w-xl">
|
||||
<livewire:profile.update-password-form />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 sm:p-8 bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
||||
<div class="max-w-xl">
|
||||
<livewire:profile.delete-user-form />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
200
laravel-app/resources/views/usage-logs/index.blade.php
Normal file
200
laravel-app/resources/views/usage-logs/index.blade.php
Normal file
@@ -0,0 +1,200 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
Usage Logs
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||
|
||||
{{-- Filter Form --}}
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Filters</h3>
|
||||
<form method="GET" action="{{ route('usage-logs.index') }}" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{{-- Date From --}}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Date From</label>
|
||||
<input type="date" name="date_from" value="{{ request('date_from') }}"
|
||||
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
{{-- Date To --}}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Date To</label>
|
||||
<input type="date" name="date_to" value="{{ request('date_to') }}"
|
||||
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
{{-- User Filter --}}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">User</label>
|
||||
<select name="user_id" class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||
<option value="">All Users</option>
|
||||
@foreach($users as $user)
|
||||
<option value="{{ $user->user_id }}" {{ request('user_id') == $user->user_id ? 'selected' : '' }}>
|
||||
{{ $user->alias ?? $user->user_id }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{-- Provider Filter --}}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Provider</label>
|
||||
<select name="provider" class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||
<option value="">All Providers</option>
|
||||
@foreach($providers as $provider)
|
||||
<option value="{{ $provider }}" {{ request('provider') == $provider ? 'selected' : '' }}>
|
||||
{{ ucfirst($provider) }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{-- Model Filter --}}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Model</label>
|
||||
<select name="model" class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||
<option value="">All Models</option>
|
||||
@foreach($models as $model)
|
||||
<option value="{{ $model }}" {{ request('model') == $model ? 'selected' : '' }}>
|
||||
{{ $model }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{-- Status Filter --}}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||
<select name="status" class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||
<option value="">All Status</option>
|
||||
<option value="success" {{ request('status') == 'success' ? 'selected' : '' }}>Success</option>
|
||||
<option value="failed" {{ request('status') == 'failed' ? 'selected' : '' }}>Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center pt-4">
|
||||
<button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||
Apply Filters
|
||||
</button>
|
||||
<a href="{{ route('usage-logs.index') }}" class="text-gray-600 hover:text-gray-900">
|
||||
Clear Filters
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Summary Statistics --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="text-sm text-gray-600">Total Requests</div>
|
||||
<div class="text-2xl font-bold text-gray-900">{{ number_format($summary->total_requests ?? 0) }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="text-sm text-gray-600">Successful</div>
|
||||
<div class="text-2xl font-bold text-green-600">{{ number_format($summary->successful_requests ?? 0) }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="text-sm text-gray-600">Total Tokens</div>
|
||||
<div class="text-2xl font-bold text-purple-600">{{ number_format($summary->total_tokens ?? 0) }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="text-sm text-gray-600">Total Cost</div>
|
||||
<div class="text-2xl font-bold text-blue-600">${{ number_format($summary->total_cost ?? 0, 2) }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="text-sm text-gray-600">Avg Cost/Request</div>
|
||||
<div class="text-2xl font-bold text-orange-600">
|
||||
${{ $summary->total_requests > 0 ? number_format(($summary->total_cost ?? 0) / $summary->total_requests, 4) : '0.0000' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Logs Table --}}
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold">Usage Logs ({{ $logs->total() }} results)</h3>
|
||||
<a href="{{ route('usage-logs.export', request()->query()) }}"
|
||||
class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded inline-flex items-center">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
Export CSV
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if($logs->count() > 0)
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Timestamp</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">User</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Provider / Model</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Tokens</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Cost</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@foreach($logs as $log)
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ $log->timestamp->format('M d, Y H:i:s') }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm">
|
||||
<div class="font-medium text-gray-900">{{ $log->gatewayUser?->alias ?? 'N/A' }}</div>
|
||||
<div class="text-xs text-gray-500">{{ substr($log->user_id, 0, 16) }}...</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm">
|
||||
<div class="font-medium text-gray-900">{{ ucfirst($log->provider) }}</div>
|
||||
<div class="text-xs text-gray-500">{{ $log->model }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-right">
|
||||
<div class="text-gray-900">{{ number_format($log->total_tokens) }}</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ number_format($log->prompt_tokens) }}p / {{ number_format($log->completion_tokens) }}c
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-right font-semibold text-green-600">
|
||||
{{ $log->cost_formatted }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-center">
|
||||
@if($log->status === 'success')
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">Success</span>
|
||||
@else
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800"
|
||||
title="{{ $log->error_message }}">Failed</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{-- Pagination --}}
|
||||
<div class="mt-4">
|
||||
{{ $logs->links() }}
|
||||
</div>
|
||||
@else
|
||||
<div class="text-center py-12">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No logs found</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Try adjusting your filters or date range.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
145
laravel-app/resources/views/welcome.blade.php
Normal file
145
laravel-app/resources/views/welcome.blade.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<title>Laravel</title>
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,600&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- Styles -->
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="antialiased font-sans">
|
||||
<div class="bg-gray-50 text-black/50 dark:bg-black dark:text-white/50">
|
||||
<img id="background" class="absolute -left-20 top-0 max-w-[877px]" src="https://laravel.com/assets/img/welcome/background.svg" />
|
||||
<div class="relative min-h-screen flex flex-col items-center justify-center selection:bg-[#FF2D20] selection:text-white">
|
||||
<div class="relative w-full max-w-2xl px-6 lg:max-w-7xl">
|
||||
<header class="grid grid-cols-2 items-center gap-2 py-10 lg:grid-cols-3">
|
||||
<div class="flex lg:justify-center lg:col-start-2">
|
||||
<svg class="h-12 w-auto text-white lg:h-16 lg:text-[#FF2D20]" viewBox="0 0 62 65" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M61.8548 14.6253C61.8778 14.7102 61.8895 14.7978 61.8897 14.8858V28.5615C61.8898 28.737 61.8434 28.9095 61.7554 29.0614C61.6675 29.2132 61.5409 29.3392 61.3887 29.4265L49.9104 36.0351V49.1337C49.9104 49.4902 49.7209 49.8192 49.4118 49.9987L25.4519 63.7916C25.3971 63.8227 25.3372 63.8427 25.2774 63.8639C25.255 63.8714 25.2338 63.8851 25.2101 63.8913C25.0426 63.9354 24.8666 63.9354 24.6991 63.8913C24.6716 63.8838 24.6467 63.8689 24.6205 63.8589C24.5657 63.8389 24.5084 63.8215 24.456 63.7916L0.501061 49.9987C0.348882 49.9113 0.222437 49.7853 0.134469 49.6334C0.0465019 49.4816 0.000120578 49.3092 0 49.1337L0 8.10652C0 8.01678 0.0124642 7.92953 0.0348998 7.84477C0.0423783 7.8161 0.0598282 7.78993 0.0697995 7.76126C0.0884958 7.70891 0.105946 7.65531 0.133367 7.6067C0.152063 7.5743 0.179485 7.54812 0.20192 7.51821C0.230588 7.47832 0.256763 7.43719 0.290416 7.40229C0.319084 7.37362 0.356476 7.35243 0.388883 7.32751C0.425029 7.29759 0.457436 7.26518 0.498568 7.2415L12.4779 0.345059C12.6296 0.257786 12.8015 0.211853 12.9765 0.211853C13.1515 0.211853 13.3234 0.257786 13.475 0.345059L25.4531 7.2415H25.4556C25.4955 7.26643 25.5292 7.29759 25.5653 7.32626C25.5977 7.35119 25.6339 7.37362 25.6625 7.40104C25.6974 7.43719 25.7224 7.47832 25.7523 7.51821C25.7735 7.54812 25.8021 7.5743 25.8196 7.6067C25.8483 7.65656 25.8645 7.70891 25.8844 7.76126C25.8944 7.78993 25.9118 7.8161 25.9193 7.84602C25.9423 7.93096 25.954 8.01853 25.9542 8.10652V33.7317L35.9355 27.9844V14.8846C35.9355 14.7973 35.948 14.7088 35.9704 14.6253C35.9792 14.5954 35.9954 14.5692 36.0053 14.5405C36.0253 14.4882 36.0427 14.4346 36.0702 14.386C36.0888 14.3536 36.1163 14.3274 36.1375 14.2975C36.1674 14.2576 36.1923 14.2165 36.2272 14.1816C36.2559 14.1529 36.292 14.1317 36.3244 14.1068C36.3618 14.0769 36.3942 14.0445 36.4341 14.0208L48.4147 7.12434C48.5663 7.03694 48.7383 6.99094 48.9133 6.99094C49.0883 6.99094 49.2602 7.03694 49.4118 7.12434L61.3899 14.0208C61.4323 14.0457 61.4647 14.0769 61.5021 14.1055C61.5333 14.1305 61.5694 14.1529 61.5981 14.1803C61.633 14.2165 61.6579 14.2576 61.6878 14.2975C61.7103 14.3274 61.7377 14.3536 61.7551 14.386C61.7838 14.4346 61.8 14.4882 61.8199 14.5405C61.8312 14.5692 61.8474 14.5954 61.8548 14.6253ZM59.893 27.9844V16.6121L55.7013 19.0252L49.9104 22.3593V33.7317L59.8942 27.9844H59.893ZM47.9149 48.5566V37.1768L42.2187 40.4299L25.953 49.7133V61.2003L47.9149 48.5566ZM1.99677 9.83281V48.5566L23.9562 61.199V49.7145L12.4841 43.2219L12.4804 43.2194L12.4754 43.2169C12.4368 43.1945 12.4044 43.1621 12.3682 43.1347C12.3371 43.1097 12.3009 43.0898 12.2735 43.0624L12.271 43.0586C12.2386 43.0275 12.2162 42.9888 12.1887 42.9539C12.1638 42.9203 12.1339 42.8916 12.114 42.8567L12.1127 42.853C12.0903 42.8156 12.0766 42.7707 12.0604 42.7283C12.0442 42.6909 12.023 42.656 12.013 42.6161C12.0005 42.5688 11.998 42.5177 11.9931 42.4691C11.9881 42.4317 11.9781 42.3943 11.9781 42.3569V15.5801L6.18848 12.2446L1.99677 9.83281ZM12.9777 2.36177L2.99764 8.10652L12.9752 13.8513L22.9541 8.10527L12.9752 2.36177H12.9777ZM18.1678 38.2138L23.9574 34.8809V9.83281L19.7657 12.2459L13.9749 15.5801V40.6281L18.1678 38.2138ZM48.9133 9.14105L38.9344 14.8858L48.9133 20.6305L58.8909 14.8846L48.9133 9.14105ZM47.9149 22.3593L42.124 19.0252L37.9323 16.6121V27.9844L43.7219 31.3174L47.9149 33.7317V22.3593ZM24.9533 47.987L39.59 39.631L46.9065 35.4555L36.9352 29.7145L25.4544 36.3242L14.9907 42.3482L24.9533 47.987Z" fill="currentColor"/></svg>
|
||||
</div>
|
||||
@if (Route::has('login'))
|
||||
<livewire:welcome.navigation />
|
||||
@endif
|
||||
</header>
|
||||
|
||||
<main class="mt-6">
|
||||
<div class="grid gap-6 lg:grid-cols-2 lg:gap-8">
|
||||
<a
|
||||
href="https://laravel.com/docs"
|
||||
id="docs-card"
|
||||
class="flex flex-col items-start gap-6 overflow-hidden rounded-lg bg-white p-6 shadow-[0px_14px_34px_0px_rgba(0,0,0,0.08)] ring-1 ring-white/[0.05] transition duration-300 hover:text-black/70 hover:ring-black/20 focus:outline-none focus-visible:ring-[#FF2D20] md:row-span-3 lg:p-10 lg:pb-10 dark:bg-zinc-900 dark:ring-zinc-800 dark:hover:text-white/70 dark:hover:ring-zinc-700 dark:focus-visible:ring-[#FF2D20]"
|
||||
>
|
||||
<div id="screenshot-container" class="relative flex w-full flex-1 items-stretch">
|
||||
<img
|
||||
src="https://laravel.com/assets/img/welcome/docs-light.svg"
|
||||
alt="Laravel documentation screenshot"
|
||||
class="aspect-video h-full w-full flex-1 rounded-[10px] object-top object-cover drop-shadow-[0px_4px_34px_rgba(0,0,0,0.06)] dark:hidden"
|
||||
onerror="
|
||||
document.getElementById('screenshot-container').classList.add('!hidden');
|
||||
document.getElementById('docs-card').classList.add('!row-span-1');
|
||||
document.getElementById('docs-card-content').classList.add('!flex-row');
|
||||
document.getElementById('background').classList.add('!hidden');
|
||||
"
|
||||
/>
|
||||
<img
|
||||
src="https://laravel.com/assets/img/welcome/docs-dark.svg"
|
||||
alt="Laravel documentation screenshot"
|
||||
class="hidden aspect-video h-full w-full flex-1 rounded-[10px] object-top object-cover drop-shadow-[0px_4px_34px_rgba(0,0,0,0.25)] dark:block"
|
||||
/>
|
||||
<div
|
||||
class="absolute -bottom-16 -left-16 h-40 w-[calc(100%+8rem)] bg-gradient-to-b from-transparent via-white to-white dark:via-zinc-900 dark:to-zinc-900"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="relative flex items-center gap-6 lg:items-end">
|
||||
<div id="docs-card-content" class="flex items-start gap-6 lg:flex-col">
|
||||
<div class="flex size-12 shrink-0 items-center justify-center rounded-full bg-[#FF2D20]/10 sm:size-16">
|
||||
<svg class="size-5 sm:size-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#FF2D20" d="M23 4a1 1 0 0 0-1.447-.894L12.224 7.77a.5.5 0 0 1-.448 0L2.447 3.106A1 1 0 0 0 1 4v13.382a1.99 1.99 0 0 0 1.105 1.79l9.448 4.728c.14.065.293.1.447.1.154-.005.306-.04.447-.105l9.453-4.724a1.99 1.99 0 0 0 1.1-1.789V4ZM3 6.023a.25.25 0 0 1 .362-.223l7.5 3.75a.251.251 0 0 1 .138.223v11.2a.25.25 0 0 1-.362.224l-7.5-3.75a.25.25 0 0 1-.138-.22V6.023Zm18 11.2a.25.25 0 0 1-.138.224l-7.5 3.75a.249.249 0 0 1-.329-.099.249.249 0 0 1-.033-.12V9.772a.251.251 0 0 1 .138-.224l7.5-3.75a.25.25 0 0 1 .362.224v11.2Z"/><path fill="#FF2D20" d="m3.55 1.893 8 4.048a1.008 1.008 0 0 0 .9 0l8-4.048a1 1 0 0 0-.9-1.785l-7.322 3.706a.506.506 0 0 1-.452 0L4.454.108a1 1 0 0 0-.9 1.785H3.55Z"/></svg>
|
||||
</div>
|
||||
|
||||
<div class="pt-3 sm:pt-5 lg:pt-0">
|
||||
<h2 class="text-xl font-semibold text-black dark:text-white">Documentation</h2>
|
||||
|
||||
<p class="mt-4 text-sm/relaxed">
|
||||
Laravel has wonderful documentation covering every aspect of the framework. Whether you are a newcomer or have prior experience with Laravel, we recommend reading our documentation from beginning to end.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svg class="size-6 shrink-0 stroke-[#FF2D20]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75"/></svg>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://laracasts.com"
|
||||
class="flex items-start gap-4 rounded-lg bg-white p-6 shadow-[0px_14px_34px_0px_rgba(0,0,0,0.08)] ring-1 ring-white/[0.05] transition duration-300 hover:text-black/70 hover:ring-black/20 focus:outline-none focus-visible:ring-[#FF2D20] lg:pb-10 dark:bg-zinc-900 dark:ring-zinc-800 dark:hover:text-white/70 dark:hover:ring-zinc-700 dark:focus-visible:ring-[#FF2D20]"
|
||||
>
|
||||
<div class="flex size-12 shrink-0 items-center justify-center rounded-full bg-[#FF2D20]/10 sm:size-16">
|
||||
<svg class="size-5 sm:size-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><g fill="#FF2D20"><path d="M24 8.25a.5.5 0 0 0-.5-.5H.5a.5.5 0 0 0-.5.5v12a2.5 2.5 0 0 0 2.5 2.5h19a2.5 2.5 0 0 0 2.5-2.5v-12Zm-7.765 5.868a1.221 1.221 0 0 1 0 2.264l-6.626 2.776A1.153 1.153 0 0 1 8 18.123v-5.746a1.151 1.151 0 0 1 1.609-1.035l6.626 2.776ZM19.564 1.677a.25.25 0 0 0-.177-.427H15.6a.106.106 0 0 0-.072.03l-4.54 4.543a.25.25 0 0 0 .177.427h3.783c.027 0 .054-.01.073-.03l4.543-4.543ZM22.071 1.318a.047.047 0 0 0-.045.013l-4.492 4.492a.249.249 0 0 0 .038.385.25.25 0 0 0 .14.042h5.784a.5.5 0 0 0 .5-.5v-2a2.5 2.5 0 0 0-1.925-2.432ZM13.014 1.677a.25.25 0 0 0-.178-.427H9.101a.106.106 0 0 0-.073.03l-4.54 4.543a.25.25 0 0 0 .177.427H8.4a.106.106 0 0 0 .073-.03l4.54-4.543ZM6.513 1.677a.25.25 0 0 0-.177-.427H2.5A2.5 2.5 0 0 0 0 3.75v2a.5.5 0 0 0 .5.5h1.4a.106.106 0 0 0 .073-.03l4.54-4.543Z"/></g></svg>
|
||||
</div>
|
||||
|
||||
<div class="pt-3 sm:pt-5">
|
||||
<h2 class="text-xl font-semibold text-black dark:text-white">Laracasts</h2>
|
||||
|
||||
<p class="mt-4 text-sm/relaxed">
|
||||
Laracasts offers thousands of video tutorials on Laravel, PHP, and JavaScript development. Check them out, see for yourself, and massively level up your development skills in the process.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<svg class="size-6 shrink-0 self-center stroke-[#FF2D20]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75"/></svg>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://laravel-news.com"
|
||||
class="flex items-start gap-4 rounded-lg bg-white p-6 shadow-[0px_14px_34px_0px_rgba(0,0,0,0.08)] ring-1 ring-white/[0.05] transition duration-300 hover:text-black/70 hover:ring-black/20 focus:outline-none focus-visible:ring-[#FF2D20] lg:pb-10 dark:bg-zinc-900 dark:ring-zinc-800 dark:hover:text-white/70 dark:hover:ring-zinc-700 dark:focus-visible:ring-[#FF2D20]"
|
||||
>
|
||||
<div class="flex size-12 shrink-0 items-center justify-center rounded-full bg-[#FF2D20]/10 sm:size-16">
|
||||
<svg class="size-5 sm:size-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><g fill="#FF2D20"><path d="M8.75 4.5H5.5c-.69 0-1.25.56-1.25 1.25v4.75c0 .69.56 1.25 1.25 1.25h3.25c.69 0 1.25-.56 1.25-1.25V5.75c0-.69-.56-1.25-1.25-1.25Z"/><path d="M24 10a3 3 0 0 0-3-3h-2V2.5a2 2 0 0 0-2-2H2a2 2 0 0 0-2 2V20a3.5 3.5 0 0 0 3.5 3.5h17A3.5 3.5 0 0 0 24 20V10ZM3.5 21.5A1.5 1.5 0 0 1 2 20V3a.5.5 0 0 1 .5-.5h14a.5.5 0 0 1 .5.5v17c0 .295.037.588.11.874a.5.5 0 0 1-.484.625L3.5 21.5ZM22 20a1.5 1.5 0 1 1-3 0V9.5a.5.5 0 0 1 .5-.5H21a1 1 0 0 1 1 1v10Z"/><path d="M12.751 6.047h2a.75.75 0 0 1 .75.75v.5a.75.75 0 0 1-.75.75h-2A.75.75 0 0 1 12 7.3v-.5a.75.75 0 0 1 .751-.753ZM12.751 10.047h2a.75.75 0 0 1 .75.75v.5a.75.75 0 0 1-.75.75h-2A.75.75 0 0 1 12 11.3v-.5a.75.75 0 0 1 .751-.753ZM4.751 14.047h10a.75.75 0 0 1 .75.75v.5a.75.75 0 0 1-.75.75h-10A.75.75 0 0 1 4 15.3v-.5a.75.75 0 0 1 .751-.753ZM4.75 18.047h7.5a.75.75 0 0 1 .75.75v.5a.75.75 0 0 1-.75.75h-7.5A.75.75 0 0 1 4 19.3v-.5a.75.75 0 0 1 .75-.753Z"/></g></svg>
|
||||
</div>
|
||||
|
||||
<div class="pt-3 sm:pt-5">
|
||||
<h2 class="text-xl font-semibold text-black dark:text-white">Laravel News</h2>
|
||||
|
||||
<p class="mt-4 text-sm/relaxed">
|
||||
Laravel News is a community driven portal and newsletter aggregating all of the latest and most important news in the Laravel ecosystem, including new package releases and tutorials.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<svg class="size-6 shrink-0 self-center stroke-[#FF2D20]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75"/></svg>
|
||||
</a>
|
||||
|
||||
<div class="flex items-start gap-4 rounded-lg bg-white p-6 shadow-[0px_14px_34px_0px_rgba(0,0,0,0.08)] ring-1 ring-white/[0.05] lg:pb-10 dark:bg-zinc-900 dark:ring-zinc-800">
|
||||
<div class="flex size-12 shrink-0 items-center justify-center rounded-full bg-[#FF2D20]/10 sm:size-16">
|
||||
<svg class="size-5 sm:size-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<g fill="#FF2D20">
|
||||
<path
|
||||
d="M16.597 12.635a.247.247 0 0 0-.08-.237 2.234 2.234 0 0 1-.769-1.68c.001-.195.03-.39.084-.578a.25.25 0 0 0-.09-.267 8.8 8.8 0 0 0-4.826-1.66.25.25 0 0 0-.268.181 2.5 2.5 0 0 1-2.4 1.824.045.045 0 0 0-.045.037 12.255 12.255 0 0 0-.093 3.86.251.251 0 0 0 .208.214c2.22.366 4.367 1.08 6.362 2.118a.252.252 0 0 0 .32-.079 10.09 10.09 0 0 0 1.597-3.733ZM13.616 17.968a.25.25 0 0 0-.063-.407A19.697 19.697 0 0 0 8.91 15.98a.25.25 0 0 0-.287.325c.151.455.334.898.548 1.328.437.827.981 1.594 1.619 2.28a.249.249 0 0 0 .32.044 29.13 29.13 0 0 0 2.506-1.99ZM6.303 14.105a.25.25 0 0 0 .265-.274 13.048 13.048 0 0 1 .205-4.045.062.062 0 0 0-.022-.07 2.5 2.5 0 0 1-.777-.982.25.25 0 0 0-.271-.149 11 11 0 0 0-5.6 2.815.255.255 0 0 0-.075.163c-.008.135-.02.27-.02.406.002.8.084 1.598.246 2.381a.25.25 0 0 0 .303.193 19.924 19.924 0 0 1 5.746-.438ZM9.228 20.914a.25.25 0 0 0 .1-.393 11.53 11.53 0 0 1-1.5-2.22 12.238 12.238 0 0 1-.91-2.465.248.248 0 0 0-.22-.187 18.876 18.876 0 0 0-5.69.33.249.249 0 0 0-.179.336c.838 2.142 2.272 4 4.132 5.353a.254.254 0 0 0 .15.048c1.41-.01 2.807-.282 4.117-.802ZM18.93 12.957l-.005-.008a.25.25 0 0 0-.268-.082 2.21 2.21 0 0 1-.41.081.25.25 0 0 0-.217.2c-.582 2.66-2.127 5.35-5.75 7.843a.248.248 0 0 0-.09.299.25.25 0 0 0 .065.091 28.703 28.703 0 0 0 2.662 2.12.246.246 0 0 0 .209.037c2.579-.701 4.85-2.242 6.456-4.378a.25.25 0 0 0 .048-.189 13.51 13.51 0 0 0-2.7-6.014ZM5.702 7.058a.254.254 0 0 0 .2-.165A2.488 2.488 0 0 1 7.98 5.245a.093.093 0 0 0 .078-.062 19.734 19.734 0 0 1 3.055-4.74.25.25 0 0 0-.21-.41 12.009 12.009 0 0 0-10.4 8.558.25.25 0 0 0 .373.281 12.912 12.912 0 0 1 4.826-1.814ZM10.773 22.052a.25.25 0 0 0-.28-.046c-.758.356-1.55.635-2.365.833a.25.25 0 0 0-.022.48c1.252.43 2.568.65 3.893.65.1 0 .2 0 .3-.008a.25.25 0 0 0 .147-.444c-.526-.424-1.1-.917-1.673-1.465ZM18.744 8.436a.249.249 0 0 0 .15.228 2.246 2.246 0 0 1 1.352 2.054c0 .337-.08.67-.23.972a.25.25 0 0 0 .042.28l.007.009a15.016 15.016 0 0 1 2.52 4.6.25.25 0 0 0 .37.132.25.25 0 0 0 .096-.114c.623-1.464.944-3.039.945-4.63a12.005 12.005 0 0 0-5.78-10.258.25.25 0 0 0-.373.274c.547 2.109.85 4.274.901 6.453ZM9.61 5.38a.25.25 0 0 0 .08.31c.34.24.616.561.8.935a.25.25 0 0 0 .3.127.631.631 0 0 1 .206-.034c2.054.078 4.036.772 5.69 1.991a.251.251 0 0 0 .267.024c.046-.024.093-.047.141-.067a.25.25 0 0 0 .151-.23A29.98 29.98 0 0 0 15.957.764a.25.25 0 0 0-.16-.164 11.924 11.924 0 0 0-2.21-.518.252.252 0 0 0-.215.076A22.456 22.456 0 0 0 9.61 5.38Z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="pt-3 sm:pt-5">
|
||||
<h2 class="text-xl font-semibold text-black dark:text-white">Vibrant Ecosystem</h2>
|
||||
|
||||
<p class="mt-4 text-sm/relaxed">
|
||||
Laravel's robust library of first-party tools and libraries, such as <a href="https://forge.laravel.com" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white dark:focus-visible:ring-[#FF2D20]">Forge</a>, <a href="https://vapor.laravel.com" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white">Vapor</a>, <a href="https://nova.laravel.com" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white">Nova</a>, <a href="https://envoyer.io" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white">Envoyer</a>, and <a href="https://herd.laravel.com" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white">Herd</a> help you take your projects to the next level. Pair them with powerful open source libraries like <a href="https://laravel.com/docs/billing" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white">Cashier</a>, <a href="https://laravel.com/docs/dusk" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white">Dusk</a>, <a href="https://laravel.com/docs/broadcasting" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white">Echo</a>, <a href="https://laravel.com/docs/horizon" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white">Horizon</a>, <a href="https://laravel.com/docs/sanctum" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white">Sanctum</a>, <a href="https://laravel.com/docs/telescope" class="rounded-sm underline hover:text-black focus:outline-none focus-visible:ring-1 focus-visible:ring-[#FF2D20] dark:hover:text-white">Telescope</a>, and more.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="py-16 text-center text-sm text-black dark:text-white/70">
|
||||
Laravel v{{ Illuminate\Foundation\Application::VERSION }} (PHP v{{ PHP_VERSION }})
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user