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:
wtrinkl
2025-11-16 12:38:05 +01:00
commit b1363aeab9
148 changed files with 23995 additions and 0 deletions

38
.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# Docker
.env
*.env.local
# Laravel
laravel-app/.env
laravel-app/vendor/
laravel-app/node_modules/
laravel-app/public/hot
laravel-app/public/storage
laravel-app/storage/*.key
laravel-app/storage/logs/*.log
laravel-app/bootstrap/cache/*
# Database
*.sql
*.sqlite
*.db
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build artifacts
laravel-app/public/build/

27
Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
FROM python:3.13-slim as base
WORKDIR /app
COPY pyproject.toml ./
COPY src/ ./src/
ARG VERSION=0.0.0+docker
ENV SETUPTOOLS_SCM_PRETEND_VERSION="${VERSION}"
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir .[all,gateway]
RUN useradd -m -u 1000 gateway && \
chown -R gateway:gateway /app
USER gateway
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
ENV GATEWAY_HOST=0.0.0.0
ENV GATEWAY_PORT=8000
CMD ["any-llm-gateway", "serve"]

1330
LARAVEL_IMPLEMENTATION.md Normal file

File diff suppressed because it is too large Load Diff

371
README.md Normal file
View File

@@ -0,0 +1,371 @@
# Any-LLM Gateway + Laravel Admin
Vollständiges Docker-Setup mit:
- **Any-LLM Gateway** (API Gateway für LLMs)
- **Laravel Admin Panel** (Verwaltungsoberfläche)
- **PostgreSQL** (Datenbank)
- **Adminer** (Datenbank-Management-Tool)
- **Gateway Tester** (Test-Oberfläche)
---
## 🚀 Quick Start
### Voraussetzungen
- Docker & Docker Compose installiert
- Ports 80, 8000, 8080, 8081 verfügbar
### Installation
```bash
cd /opt/any-llm
./setup-laravel.sh
```
Das Setup-Script führt automatisch aus:
1. ✅ Laravel Installation
2. ✅ Livewire & Breeze Setup
3. ✅ Docker Container Build & Start
4. ✅ Datenbank-Migrationen
5. ✅ Admin-User Erstellung
6. ✅ Assets-Kompilierung
**⏱️ Dauer: ca. 5-10 Minuten**
---
## 🌐 URLs & Zugangsdaten
### Services
| Service | URL | Beschreibung |
|---------|-----|--------------|
| **Laravel Admin** | http://localhost:80 | Verwaltungsoberfläche |
| **Gateway API** | http://localhost:8000 | Any-LLM Gateway |
| **Gateway Tester** | http://localhost:8080 | Test-Interface |
| **Adminer** | http://localhost:8081 | PostgreSQL Management |
### Login-Daten
#### Laravel Admin
```
Email: admin@example.com
Password: password123
```
#### Adminer (PostgreSQL)
```
System: PostgreSQL
Server: postgres
Username: gateway
Password: gateway
Database: gateway
```
---
## 📁 Projekt-Struktur
```
/opt/any-llm/
├── config.yml # Gateway Konfiguration
├── docker-compose.yml # Docker Services
├── setup-laravel.sh # Setup Script
├── laravel/ # Laravel Docker Config
│ ├── Dockerfile
│ ├── nginx.conf
│ ├── supervisord.conf
│ └── php.ini
├── laravel-app/ # Laravel Projekt (wird generiert)
│ ├── app/
│ ├── database/
│ ├── resources/
│ └── ...
├── web/ # Gateway Tester
│ ├── index.html
│ └── default.conf
└── LARAVEL_IMPLEMENTATION.md # Detailliertes Implementierungskonzept
```
---
## 🔧 Docker Commands
### Container Management
```bash
# Alle Container starten
docker compose up -d
# Alle Container stoppen
docker compose down
# Logs anzeigen
docker compose logs -f
# Logs eines bestimmten Services
docker compose logs -f laravel
docker compose logs -f gateway
# Container neu bauen
docker compose up -d --build
# In Container einloggen
docker compose exec laravel bash
docker compose exec postgres psql -U gateway -d gateway
```
### Laravel Commands (im Container)
```bash
# Artisan Commands
docker compose exec laravel php artisan migrate
docker compose exec laravel php artisan make:model ModelName
docker compose exec laravel php artisan make:controller ControllerName
# Composer
docker compose exec laravel composer install
docker compose exec laravel composer require package-name
# NPM
docker compose exec laravel npm install
docker compose exec laravel npm run dev
docker compose exec laravel npm run build
# Tinker (Laravel REPL)
docker compose exec laravel php artisan tinker
```
---
## 🗄️ Datenbank
### Schema
Die Gateway-Datenbank enthält folgende Tabellen:
```
users → Gateway API Users
api_keys → Virtual Keys
usage_logs → Request Tracking
budgets → Budget Definitions
budget_reset_logs → Budget History
model_pricing → Model Cost Configuration
admins → Laravel Admin Users (neu)
```
### Zugriff
**Via Adminer Web-Interface:**
- http://localhost:8081
- Login mit oben genannten Credentials
**Via CLI:**
```bash
docker compose exec postgres psql -U gateway -d gateway
# Beispiel-Queries
SELECT * FROM users;
SELECT * FROM usage_logs ORDER BY timestamp DESC LIMIT 10;
```
---
## 🛠️ Entwicklung
### Laravel Development
```bash
# In Laravel Container einloggen
docker compose exec laravel bash
# Routes anzeigen
php artisan route:list
# Model erstellen
php artisan make:model MyModel -m
# Controller erstellen
php artisan make:controller MyController --resource
# Livewire Component erstellen
php artisan make:livewire MyComponent
```
### Frontend Development
```bash
# NPM Dev Server (mit Hot Reload)
docker compose exec laravel npm run dev
# Production Build
docker compose exec laravel npm run build
# Tailwind JIT Mode
# → Läuft automatisch mit npm run dev
```
---
## 📝 Nächste Schritte
### 1. Models erstellen
Folge dem Implementierungskonzept in `LARAVEL_IMPLEMENTATION.md`:
```bash
docker compose exec laravel php artisan make:model GatewayUser
docker compose exec laravel php artisan make:model ApiKey
docker compose exec laravel php artisan make:model UsageLog
docker compose exec laravel php artisan make:model Budget
docker compose exec laravel php artisan make:model ModelPricing
```
### 2. Controllers implementieren
```bash
docker compose exec laravel php artisan make:controller DashboardController
docker compose exec laravel php artisan make:controller GatewayUserController --resource
```
### 3. Views erstellen
Die Views werden in `laravel-app/resources/views/` erstellt.
Struktur:
```
resources/views/
├── layouts/
│ ├── app.blade.php
│ └── navigation.blade.php
├── dashboard.blade.php
├── gateway-users/
│ ├── index.blade.php
│ ├── show.blade.php
│ └── ...
└── ...
```
---
## 🐛 Troubleshooting
### Container startet nicht
```bash
# Logs prüfen
docker compose logs laravel
# Container neu bauen
docker compose down
docker compose up -d --build
```
### Permissions Fehler
```bash
# Storage Permissions setzen
docker compose exec laravel chmod -R 777 storage bootstrap/cache
```
### Port bereits belegt
Ports in `docker-compose.yml` anpassen:
```yaml
laravel:
ports:
- "8001:80" # Statt 80:80
```
### Datenbank Connection Fehler
```bash
# Prüfe ob PostgreSQL läuft
docker compose ps postgres
# Teste Connection
docker compose exec laravel php artisan migrate:status
```
### Assets werden nicht geladen
```bash
# Assets neu kompilieren
docker compose exec laravel npm run build
# Storage Link neu erstellen
docker compose exec laravel php artisan storage:link
```
---
## 🔐 Sicherheit
### Production Checklist
Vor dem Production-Deployment:
1.`.env` Werte ändern:
```
APP_ENV=production
APP_DEBUG=false
APP_KEY=... (neu generieren!)
```
2. ✅ Starkes Admin-Passwort setzen
3. ✅ PostgreSQL Passwort ändern
4. ✅ Adminer auf localhost beschränken oder deaktivieren
5. ✅ SSL/TLS einrichten (Let's Encrypt)
6. ✅ Laravel Caches aktivieren:
```bash
php artisan config:cache
php artisan route:cache
php artisan view:cache
```
---
## 📚 Dokumentation
- **Implementierungskonzept**: `LARAVEL_IMPLEMENTATION.md`
- **Any-LLM Gateway**: https://github.com/mozilla-ai/any-llm
- **Laravel Docs**: https://laravel.com/docs
- **Livewire Docs**: https://livewire.laravel.com
- **Tailwind CSS**: https://tailwindcss.com
---
## 🆘 Support
Bei Fragen oder Problemen:
1. Logs prüfen: `docker compose logs -f`
2. Container Status: `docker compose ps`
3. Implementierungskonzept lesen: `LARAVEL_IMPLEMENTATION.md`
---
## 📋 Checkliste
- [ ] Setup Script ausgeführt
- [ ] Laravel läuft auf http://localhost:80
- [ ] Admin-Login funktioniert
- [ ] Adminer erreichbar (http://localhost:8081)
- [ ] Gateway API funktioniert (http://localhost:8000)
- [ ] Models erstellt (siehe Implementierungskonzept)
- [ ] Controllers implementiert
- [ ] Dashboard Views erstellt
- [ ] Statistiken funktionieren
- [ ] Production-ready gemacht
---
**Viel Erfolg mit der Entwicklung! 🚀**

40
config.yml Normal file
View File

@@ -0,0 +1,40 @@
# Configuration for any-llm-gateway
# Database connection URL
database_url: "postgresql://gateway:gateway@postgres:5432/gateway"
# Server configuration - if you want to use a different port, change it here and in the docker-compose.yml file.
host: "0.0.0.0"
port: 8000
# Master key for protecting key macnagement endpoints
master_key: bdab4b5261d6e6ed7173c999ababd7c66066d76d3a06c8506a880ecdcfb41bcd
# Pre-configured provider credentials
providers:
# Uncomment and add your API keys for the providers you want to use:
openai:
api_key: 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
api_base: "https://api.openai.com/v1" # optional
anthropic:
api_key: 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
# mistral:
# api_key: YOUR_MISTRAL_API_KEY_HERE
# Add more providers as needed...
# Model pricing configuration (optional but necessary for price tracking)
# Format: "provider:model" -> input/output price per million tokens
# Database pricing takes precedence - config only sets initial values
# Prices are in USD per million tokens
pricing: {}
# See https://cloud.google.com/vertex-ai/generative-ai/pricing
# vertexai:Qwen3-235B-A22B-Instruct-2507:
# input_price_per_million: 0.25
# output_price_per_million: 1.00
# openai:gpt-5:
# input_price_per_million: 0.25
# output_price_per_million: 1.00

98
docker-compose.yml Normal file
View File

@@ -0,0 +1,98 @@
services:
gateway:
# if pulling from ghcr.io, use the following instead, and comment out the build section:
image: ghcr.io/mozilla-ai/any-llm/gateway:latest
# build:
# context: ..
# dockerfile: docker/Dockerfile
# args:
# VERSION: ${VERSION:-0.0.0+local}
# If you want to use a different port, change it here and in the config.yml file.
ports:
- "8000:8000"
volumes:
- ./config.yml:/app/config.yml
command: ["any-llm-gateway", "serve", "--config", "/app/config.yml"]
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
networks:
- any-llm-network
postgres:
image: postgres:16-alpine
environment:
- POSTGRES_USER=gateway
- POSTGRES_PASSWORD=gateway
- POSTGRES_DB=gateway
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U gateway"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- any-llm-network
web:
image: nginx:alpine
ports:
- "8080:80"
volumes:
- ./web:/usr/share/nginx/html:ro
- ./web/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- gateway
restart: unless-stopped
networks:
- any-llm-network
# Laravel Admin Panel
laravel:
build:
context: ./laravel
dockerfile: Dockerfile
ports:
- "80:80"
volumes:
- ./laravel-app:/var/www
environment:
- APP_ENV=local
- APP_DEBUG=true
- APP_KEY=base64:dXFQ1q9f0T9fNZGde+9h/JOsaBPPmGv5qzA87b9FQnQ=
- DB_CONNECTION=pgsql
- DB_HOST=postgres
- DB_PORT=5432
- DB_DATABASE=gateway
- DB_USERNAME=gateway
- DB_PASSWORD=gateway
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
networks:
- any-llm-network
# Adminer - Database Management UI
adminer:
image: adminer:latest
ports:
- "8081:8080"
environment:
- ADMINER_DEFAULT_SERVER=postgres
- ADMINER_DESIGN=dracula
depends_on:
- postgres
restart: unless-stopped
networks:
- any-llm-network
volumes:
postgres_data:
networks:
any-llm-network:
driver: bridge

18
laravel-app/.editorconfig Normal file
View File

@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[compose.yaml]
indent_size = 4

65
laravel-app/.env.example Normal file
View File

@@ -0,0 +1,65 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"

11
laravel-app/.gitattributes vendored Normal file
View File

@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore

24
laravel-app/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.fleet
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
Homestead.json
Homestead.yaml
Thumbs.db

59
laravel-app/README.md Normal file
View File

@@ -0,0 +1,59 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
## Laravel Sponsors
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
### Premium Partners
- **[Vehikl](https://vehikl.com)**
- **[Tighten Co.](https://tighten.co)**
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
- **[64 Robots](https://64robots.com)**
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
- **[Redberry](https://redberry.international/laravel-development)**
- **[Active Logic](https://activelogic.com)**
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).

View File

@@ -0,0 +1,201 @@
<?php
namespace App\Http\Controllers;
use App\Models\ApiKey;
use App\Models\GatewayUser;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class ApiKeyController extends Controller
{
/**
* Display a listing of the API keys.
*/
public function index(Request $request)
{
$query = ApiKey::with('gatewayUser');
// Filter by status
if ($request->has('status')) {
switch ($request->status) {
case 'active':
$query->active();
break;
case 'expired':
$query->expired();
break;
case 'inactive':
$query->where('is_active', false);
break;
}
}
// Filter by user
if ($request->has('user_id') && $request->user_id) {
$query->where('user_id', $request->user_id);
}
// Search by key name
if ($request->has('search') && $request->search) {
$query->where('key_name', 'like', '%' . $request->search . '%');
}
// Sort
$sortBy = $request->get('sort_by', 'created_at');
$sortOrder = $request->get('sort_order', 'desc');
$query->orderBy($sortBy, $sortOrder);
$apiKeys = $query->paginate(20)->withQueryString();
$gatewayUsers = GatewayUser::orderBy('alias')->get();
return view('api-keys.index', compact('apiKeys', 'gatewayUsers'));
}
/**
* Show the form for creating a new API key.
*/
public function create()
{
$gatewayUsers = GatewayUser::orderBy('alias')->get();
return view('api-keys.create', compact('gatewayUsers'));
}
/**
* Store a newly created API key.
*/
public function store(Request $request)
{
$validated = $request->validate([
'key_name' => 'required|string|max:255',
'user_id' => 'required|string|exists:users,user_id',
'expires_at' => 'nullable|date|after:now',
'metadata' => 'nullable|json',
]);
try {
// Get master key from config
$masterKey = env('GATEWAY_MASTER_KEY');
if (!$masterKey) {
return back()->with('error', 'Gateway Master Key not configured');
}
// Prepare request payload
$payload = [
'user_id' => $validated['user_id'],
'key_name' => $validated['key_name'],
];
// Add optional fields only if they have values
if (!empty($validated['expires_at'])) {
$payload['expires_at'] = $validated['expires_at'];
}
if (!empty($validated['metadata'])) {
$payload['metadata'] = json_decode($validated['metadata'], true) ?: new \stdClass();
}
// Create Virtual Key via Any-LLM Gateway API
$response = Http::withHeaders([
'X-AnyLLM-Key' => 'Bearer ' . $masterKey,
'Content-Type' => 'application/json',
])->post(env('GATEWAY_API_URL', 'http://gateway:8000') . '/v1/keys', $payload);
if (!$response->successful()) {
Log::error('Failed to create API key', [
'status' => $response->status(),
'body' => $response->body()
]);
return back()->with('error', 'Failed to create API key: ' . $response->body());
}
$data = $response->json();
// The actual key is only available once - store it in session for display
session()->flash('new_api_key', $data['key'] ?? null);
session()->flash('new_api_key_id', $data['id'] ?? null);
return redirect()->route('api-keys.index')
->with('success', 'API Key created successfully! Make sure to copy it now - it won\'t be shown again.');
} catch (\Exception $e) {
Log::error('Exception creating API key', ['error' => $e->getMessage()]);
return back()->with('error', 'Failed to create API key: ' . $e->getMessage());
}
}
/**
* Display the specified API key.
*/
public function show(string $id)
{
$apiKey = ApiKey::with(['gatewayUser', 'usageLogs'])->findOrFail($id);
// Get usage statistics
$stats = [
'total_requests' => $apiKey->usageLogs()->count(),
'total_cost' => $apiKey->usageLogs()->sum('cost'),
'total_tokens' => $apiKey->usageLogs()->sum('total_tokens'),
'last_30_days_requests' => $apiKey->usageLogs()
->where('timestamp', '>=', now()->subDays(30))
->count(),
];
// Get recent activity
$recentLogs = $apiKey->usageLogs()
->orderByDesc('timestamp')
->limit(20)
->get();
return view('api-keys.show', compact('apiKey', 'stats', 'recentLogs'));
}
/**
* Revoke the specified API key.
*/
public function revoke(string $id)
{
try {
$apiKey = ApiKey::findOrFail($id);
// Get master key from config
$masterKey = env('GATEWAY_MASTER_KEY');
if (!$masterKey) {
return back()->with('error', 'Gateway Master Key not configured');
}
// Revoke via Any-LLM Gateway API
$response = Http::withHeaders([
'X-AnyLLM-Key' => 'Bearer ' . $masterKey,
'Content-Type' => 'application/json',
])->delete(env('GATEWAY_API_URL', 'http://gateway:8000') . '/v1/keys/' . $id);
if (!$response->successful()) {
Log::error('Failed to revoke API key', [
'key_id' => $id,
'status' => $response->status(),
'body' => $response->body()
]);
return back()->with('error', 'Failed to revoke API key: ' . $response->body());
}
return redirect()->route('api-keys.index')
->with('success', 'API Key revoked successfully');
} catch (\Exception $e) {
Log::error('Exception revoking API key', ['error' => $e->getMessage()]);
return back()->with('error', 'Failed to revoke API key: ' . $e->getMessage());
}
}
/**
* Remove the specified API key.
*/
public function destroy(string $id)
{
// This is an alias for revoke
return $this->revoke($id);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
class VerifyEmailController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
*/
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
if ($request->user()->markEmailAsVerified()) {
event(new Verified($request->user()));
}
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
}

View File

@@ -0,0 +1,179 @@
<?php
namespace App\Http\Controllers;
use App\Models\Budget;
use App\Models\GatewayUser;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class BudgetController extends Controller
{
/**
* Display a listing of budgets
*/
public function index()
{
$budgets = Budget::withCount('gatewayUsers')
->orderBy('created_at', 'desc')
->paginate(20);
return view('budgets.index', compact('budgets'));
}
/**
* Show the form for creating a new budget
*/
public function create()
{
return view('budgets.create');
}
/**
* Store a newly created budget
*/
public function store(Request $request)
{
$validated = $request->validate([
'budget_name' => 'required|string|max:255',
'max_budget' => 'required|numeric|min:0',
'budget_type' => 'required|in:daily,weekly,monthly,custom,unlimited',
'custom_duration_days' => 'nullable|integer|min:1|required_if:budget_type,custom',
]);
// Calculate budget_duration_sec based on type
$duration = match($validated['budget_type']) {
'daily' => 86400, // 1 day
'weekly' => 604800, // 7 days
'monthly' => 2592000, // 30 days
'custom' => ($validated['custom_duration_days'] ?? 1) * 86400,
'unlimited' => null,
};
$budget = Budget::create([
'budget_id' => 'budget-' . Str::uuid(),
'max_budget' => $validated['max_budget'],
'budget_duration_sec' => $duration,
]);
return redirect()
->route('budgets.index')
->with('success', 'Budget template created successfully!');
}
/**
* Display the specified budget
*/
public function show(string $id)
{
$budget = Budget::with('gatewayUsers')->findOrFail($id);
// Get users without budget for potential assignment
$availableUsers = GatewayUser::whereNull('budget_id')
->orWhere('budget_id', '')
->get();
return view('budgets.show', compact('budget', 'availableUsers'));
}
/**
* Show the form for editing the specified budget
*/
public function edit(string $id)
{
$budget = Budget::findOrFail($id);
// Determine budget type from duration
$budgetType = 'unlimited';
if ($budget->budget_duration_sec) {
$days = $budget->budget_duration_sec / 86400;
$budgetType = match(true) {
$days == 1 => 'daily',
$days == 7 => 'weekly',
$days == 30 => 'monthly',
default => 'custom'
};
}
return view('budgets.edit', compact('budget', 'budgetType'));
}
/**
* Update the specified budget
*/
public function update(Request $request, string $id)
{
$budget = Budget::findOrFail($id);
$validated = $request->validate([
'max_budget' => 'required|numeric|min:0',
'budget_type' => 'required|in:daily,weekly,monthly,custom,unlimited',
'custom_duration_days' => 'nullable|integer|min:1|required_if:budget_type,custom',
]);
// Calculate budget_duration_sec based on type
$duration = match($validated['budget_type']) {
'daily' => 86400,
'weekly' => 604800,
'monthly' => 2592000,
'custom' => ($validated['custom_duration_days'] ?? 1) * 86400,
'unlimited' => null,
};
$budget->update([
'max_budget' => $validated['max_budget'],
'budget_duration_sec' => $duration,
]);
return redirect()
->route('budgets.show', $budget->budget_id)
->with('success', 'Budget updated successfully!');
}
/**
* Remove the specified budget
*/
public function destroy(string $id)
{
$budget = Budget::findOrFail($id);
// Check if budget is assigned to users
if ($budget->gatewayUsers()->count() > 0) {
return redirect()
->route('budgets.index')
->with('error', 'Cannot delete budget that is assigned to users. Please reassign users first.');
}
$budget->delete();
return redirect()
->route('budgets.index')
->with('success', 'Budget deleted successfully!');
}
/**
* Assign budget to users (bulk)
*/
public function assignUsers(Request $request, string $id)
{
$budget = Budget::findOrFail($id);
$validated = $request->validate([
'user_ids' => 'required|array',
'user_ids.*' => 'exists:users,user_id',
]);
GatewayUser::whereIn('user_id', $validated['user_ids'])
->update([
'budget_id' => $budget->budget_id,
'budget_started_at' => now(),
'next_budget_reset_at' => $budget->budget_duration_sec
? now()->addSeconds($budget->budget_duration_sec)
: null,
]);
return redirect()
->route('budgets.show', $budget->budget_id)
->with('success', count($validated['user_ids']) . ' user(s) assigned to budget successfully!');
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers;
use App\Services\StatisticsService;
use Illuminate\Http\Request;
class DashboardController extends Controller
{
public function __construct(
private StatisticsService $statsService
) {}
/**
* Display the dashboard
*/
public function index()
{
$stats = $this->statsService->getDashboardStats();
$dailyUsage = $this->statsService->getDailyUsageChart(30);
$topUsers = $this->statsService->getTopUsers(5);
$providerStats = $this->statsService->getUsageByProvider(30);
$modelStats = $this->statsService->getUsageByModel(30);
return view('dashboard', compact(
'stats',
'dailyUsage',
'topUsers',
'providerStats',
'modelStats'
));
}
}

View File

@@ -0,0 +1,242 @@
<?php
namespace App\Http\Controllers;
use App\Models\GatewayUser;
use App\Models\Budget;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class GatewayUserController extends Controller
{
/**
* Display a listing of the gateway users.
*/
public function index(Request $request)
{
$query = GatewayUser::with('budget')
->withCount(['apiKeys', 'usageLogs']);
// Search
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('user_id', 'like', "%{$search}%")
->orWhere('alias', 'like', "%{$search}%");
});
}
// Filter by status
if ($request->filled('status')) {
if ($request->status === 'active') {
$query->active();
} elseif ($request->status === 'blocked') {
$query->blocked();
}
}
// Sort
$sortField = $request->get('sort', 'created_at');
$sortDirection = $request->get('direction', 'desc');
$query->orderBy($sortField, $sortDirection);
$users = $query->paginate(20)->withQueryString();
return view('gateway-users.index', compact('users'));
}
/**
* Show the form for creating a new gateway user.
*/
public function create()
{
$budgets = Budget::all();
return view('gateway-users.create', compact('budgets'));
}
/**
* Store a newly created gateway user in storage.
*/
public function store(Request $request)
{
$validated = $request->validate([
'alias' => 'nullable|string|max:255',
'budget_id' => 'nullable|exists:budgets,budget_id',
'metadata' => 'nullable|array',
]);
// Generate unique user_id
$userId = 'user_' . Str::random(16);
$user = GatewayUser::create([
'user_id' => $userId,
'alias' => $validated['alias'] ?? null,
'budget_id' => $validated['budget_id'] ?? null,
'spend' => 0,
'blocked' => false,
'metadata' => $validated['metadata'] ?? [],
]);
return redirect()
->route('gateway-users.show', $user->user_id)
->with('success', 'Gateway User created successfully!');
}
/**
* Display the specified gateway user.
*/
public function show(string $userId)
{
$user = GatewayUser::with(['apiKeys', 'budget'])
->findOrFail($userId);
// Get usage statistics for last 30 days
$stats = $this->getUserStatistics($userId, 30);
// Get recent logs
$recentLogs = $user->usageLogs()
->with('apiKey')
->orderByDesc('timestamp')
->limit(50)
->get();
// Get daily usage for chart (last 30 days)
$dailyUsage = $user->usageLogs()
->selectRaw('DATE(timestamp) as date, COUNT(*) as requests, SUM(cost) as cost, SUM(total_tokens) as tokens')
->where('timestamp', '>=', now()->subDays(30))
->groupBy('date')
->orderBy('date')
->get();
return view('gateway-users.show', compact('user', 'stats', 'recentLogs', 'dailyUsage'));
}
/**
* Show the form for editing the specified gateway user.
*/
public function edit(string $userId)
{
$user = GatewayUser::findOrFail($userId);
$budgets = Budget::all();
return view('gateway-users.edit', compact('user', 'budgets'));
}
/**
* Update the specified gateway user in storage.
*/
public function update(Request $request, string $userId)
{
$user = GatewayUser::findOrFail($userId);
$validated = $request->validate([
'alias' => 'nullable|string|max:255',
'budget_id' => 'nullable|exists:budgets,budget_id',
'metadata' => 'nullable|array',
]);
$user->update($validated);
return redirect()
->route('gateway-users.show', $user->user_id)
->with('success', 'Gateway User updated successfully!');
}
/**
* Remove the specified gateway user from storage.
*/
public function destroy(string $userId)
{
$user = GatewayUser::findOrFail($userId);
// Delete associated API keys and usage logs
$user->apiKeys()->delete();
$user->usageLogs()->delete();
$user->delete();
return redirect()
->route('gateway-users.index')
->with('success', 'Gateway User deleted successfully!');
}
/**
* Toggle block status of a gateway user.
*/
public function toggleBlock(string $userId)
{
$user = GatewayUser::findOrFail($userId);
$user->blocked = !$user->blocked;
$user->save();
$status = $user->blocked ? 'blocked' : 'unblocked';
return redirect()
->back()
->with('success', "User has been {$status} successfully!");
}
/**
* Bulk actions for gateway users.
*/
public function bulkAction(Request $request)
{
$validated = $request->validate([
'action' => 'required|in:block,unblock,delete',
'user_ids' => 'required|array|min:1',
'user_ids.*' => 'exists:users,user_id',
]);
$count = 0;
switch ($validated['action']) {
case 'block':
GatewayUser::whereIn('user_id', $validated['user_ids'])
->update(['blocked' => true]);
$count = count($validated['user_ids']);
$message = "{$count} user(s) have been blocked successfully!";
break;
case 'unblock':
GatewayUser::whereIn('user_id', $validated['user_ids'])
->update(['blocked' => false]);
$count = count($validated['user_ids']);
$message = "{$count} user(s) have been unblocked successfully!";
break;
case 'delete':
foreach ($validated['user_ids'] as $userId) {
$user = GatewayUser::find($userId);
if ($user) {
$user->apiKeys()->delete();
$user->usageLogs()->delete();
$user->delete();
$count++;
}
}
$message = "{$count} user(s) have been deleted successfully!";
break;
}
return redirect()
->route('gateway-users.index')
->with('success', $message);
}
/**
* Get user statistics for a given time period.
*/
private function getUserStatistics(string $userId, int $days)
{
return \App\Models\UsageLog::where('user_id', $userId)
->where('timestamp', '>=', now()->subDays($days))
->selectRaw('
COUNT(*) as total_requests,
SUM(prompt_tokens) as total_prompt_tokens,
SUM(completion_tokens) as total_completion_tokens,
SUM(total_tokens) as total_tokens,
SUM(cost) as total_cost,
AVG(total_tokens) as avg_tokens_per_request
')
->first();
}
}

View File

@@ -0,0 +1,208 @@
<?php
namespace App\Http\Controllers;
use App\Models\ModelPricing;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class ModelPricingController extends Controller
{
/**
* Display a listing of model pricing
*/
public function index()
{
$modelPricing = ModelPricing::orderBy('model_key')
->paginate(20);
return view('model-pricing.index', compact('modelPricing'));
}
/**
* Show the form for creating a new model pricing
*/
public function create()
{
return view('model-pricing.create');
}
/**
* Store a newly created model pricing
*/
public function store(Request $request)
{
$validated = $request->validate([
'model_key' => 'required|string|max:255|unique:model_pricing,model_key',
'input_price_per_million' => 'required|numeric|min:0',
'output_price_per_million' => 'required|numeric|min:0',
]);
ModelPricing::create($validated);
return redirect()
->route('model-pricing.index')
->with('success', 'Model pricing created successfully!');
}
/**
* Display the specified model pricing
*/
public function show(string $modelKey)
{
$model = ModelPricing::findOrFail($modelKey);
return view('model-pricing.show', compact('model'));
}
/**
* Show the form for editing the specified model pricing
*/
public function edit(string $modelKey)
{
$model = ModelPricing::findOrFail($modelKey);
return view('model-pricing.edit', compact('model'));
}
/**
* Update the specified model pricing
*/
public function update(Request $request, string $modelKey)
{
$model = ModelPricing::findOrFail($modelKey);
$validated = $request->validate([
'input_price_per_million' => 'required|numeric|min:0',
'output_price_per_million' => 'required|numeric|min:0',
]);
$model->update($validated);
return redirect()
->route('model-pricing.index')
->with('success', 'Model pricing updated successfully!');
}
/**
* Remove the specified model pricing
*/
public function destroy(string $modelKey)
{
$model = ModelPricing::findOrFail($modelKey);
$model->delete();
return redirect()
->route('model-pricing.index')
->with('success', 'Model pricing deleted successfully!');
}
/**
* Show the cost calculator
*/
public function calculator()
{
$models = ModelPricing::orderBy('model_key')->get();
return view('model-pricing.calculator', compact('models'));
}
/**
* Calculate cost based on input
*/
public function calculate(Request $request)
{
$validated = $request->validate([
'model_key' => 'required|exists:model_pricing,model_key',
'input_tokens' => 'required|integer|min:0',
'output_tokens' => 'required|integer|min:0',
]);
$model = ModelPricing::findOrFail($validated['model_key']);
$cost = $model->calculateCost(
$validated['input_tokens'],
$validated['output_tokens']
);
return response()->json([
'model' => $model->model_key,
'input_tokens' => $validated['input_tokens'],
'output_tokens' => $validated['output_tokens'],
'total_tokens' => $validated['input_tokens'] + $validated['output_tokens'],
'cost' => $cost,
'cost_formatted' => '$' . number_format($cost, 6),
]);
}
/**
* Show CSV import form
*/
public function importForm()
{
return view('model-pricing.import');
}
/**
* Import model pricing from CSV
*/
public function import(Request $request)
{
$request->validate([
'csv_file' => 'required|file|mimes:csv,txt|max:2048',
]);
$file = $request->file('csv_file');
$handle = fopen($file->getRealPath(), 'r');
$imported = 0;
$updated = 0;
$errors = [];
// Skip header row
fgetcsv($handle);
while (($row = fgetcsv($handle)) !== false) {
if (count($row) < 3) {
continue; // Skip invalid rows
}
$modelKey = trim($row[0]);
$inputPrice = floatval($row[1]);
$outputPrice = floatval($row[2]);
if (empty($modelKey) || $inputPrice < 0 || $outputPrice < 0) {
$errors[] = "Invalid data for model: {$modelKey}";
continue;
}
$existing = ModelPricing::find($modelKey);
if ($existing) {
$existing->update([
'input_price_per_million' => $inputPrice,
'output_price_per_million' => $outputPrice,
]);
$updated++;
} else {
ModelPricing::create([
'model_key' => $modelKey,
'input_price_per_million' => $inputPrice,
'output_price_per_million' => $outputPrice,
]);
$imported++;
}
}
fclose($handle);
$message = "Import completed! Created: {$imported}, Updated: {$updated}";
if (count($errors) > 0) {
$message .= '. Errors: ' . implode(', ', array_slice($errors, 0, 5));
}
return redirect()
->route('model-pricing.index')
->with('success', $message);
}
}

View File

@@ -0,0 +1,187 @@
<?php
namespace App\Http\Controllers;
use App\Models\UsageLog;
use App\Models\GatewayUser;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class UsageLogController extends Controller
{
/**
* Display a listing of usage logs with filters
*/
public function index(Request $request)
{
$query = UsageLog::with(['gatewayUser', 'apiKey']);
// Date Range Filter
if ($request->filled('date_from')) {
$query->where('timestamp', '>=', $request->date_from . ' 00:00:00');
}
if ($request->filled('date_to')) {
$query->where('timestamp', '<=', $request->date_to . ' 23:59:59');
}
// User Filter
if ($request->filled('user_id')) {
$query->where('user_id', $request->user_id);
}
// Provider Filter
if ($request->filled('provider')) {
$query->where('provider', $request->provider);
}
// Model Filter
if ($request->filled('model')) {
$query->where('model', $request->model);
}
// Status Filter
if ($request->filled('status')) {
if ($request->status === 'success') {
$query->success();
} elseif ($request->status === 'failed') {
$query->failed();
}
}
// Get filter options for dropdowns
$users = GatewayUser::select('user_id', 'alias')
->orderBy('alias')
->get();
$providers = UsageLog::select('provider')
->distinct()
->whereNotNull('provider')
->orderBy('provider')
->pluck('provider');
$models = UsageLog::select('model')
->distinct()
->whereNotNull('model')
->orderBy('model')
->pluck('model');
// Get summary statistics for current filter
$summary = $query->clone()
->selectRaw('
COUNT(*) as total_requests,
SUM(CASE WHEN status = \'success\' THEN 1 ELSE 0 END) as successful_requests,
SUM(prompt_tokens) as total_prompt_tokens,
SUM(completion_tokens) as total_completion_tokens,
SUM(total_tokens) as total_tokens,
SUM(cost) as total_cost
')
->first();
// Paginate results
$logs = $query->orderByDesc('timestamp')
->paginate(50)
->withQueryString(); // Preserve query parameters
return view('usage-logs.index', compact(
'logs',
'users',
'providers',
'models',
'summary'
));
}
/**
* Export usage logs to CSV
*/
public function export(Request $request)
{
$query = UsageLog::with(['gatewayUser', 'apiKey']);
// Apply same filters as index
if ($request->filled('date_from')) {
$query->where('timestamp', '>=', $request->date_from . ' 00:00:00');
}
if ($request->filled('date_to')) {
$query->where('timestamp', '<=', $request->date_to . ' 23:59:59');
}
if ($request->filled('user_id')) {
$query->where('user_id', $request->user_id);
}
if ($request->filled('provider')) {
$query->where('provider', $request->provider);
}
if ($request->filled('model')) {
$query->where('model', $request->model);
}
if ($request->filled('status')) {
if ($request->status === 'success') {
$query->success();
} elseif ($request->status === 'failed') {
$query->failed();
}
}
// Limit export to 10,000 records for performance
$logs = $query->orderByDesc('timestamp')
->limit(10000)
->get();
$filename = 'usage-logs-' . now()->format('Y-m-d-His') . '.csv';
$headers = [
'Content-Type' => 'text/csv',
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
];
$callback = function() use ($logs) {
$file = fopen('php://output', 'w');
// CSV Header
fputcsv($file, [
'Timestamp',
'User ID',
'User Alias',
'API Key',
'Provider',
'Model',
'Endpoint',
'Prompt Tokens',
'Completion Tokens',
'Total Tokens',
'Cost',
'Status',
'Error Message'
]);
// CSV Rows
foreach ($logs as $log) {
fputcsv($file, [
$log->timestamp->format('Y-m-d H:i:s'),
$log->user_id,
$log->gatewayUser?->alias ?? 'N/A',
$log->api_key_id,
$log->provider,
$log->model,
$log->endpoint,
$log->prompt_tokens,
$log->completion_tokens,
$log->total_tokens,
$log->cost,
$log->status,
$log->error_message ?? ''
]);
}
fclose($file);
};
return response()->stream($callback, 200, $headers);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Livewire\Actions;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
class Logout
{
/**
* Log the current user out of the application.
*/
public function __invoke(): void
{
Auth::guard('web')->logout();
Session::invalidate();
Session::regenerateToken();
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Livewire\Forms;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Validate;
use Livewire\Form;
class LoginForm extends Form
{
#[Validate('required|string|email')]
public string $email = '';
#[Validate('required|string')]
public string $password = '';
#[Validate('boolean')]
public bool $remember = false;
/**
* Attempt to authenticate the request's credentials.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function authenticate(): void
{
$this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only(['email', 'password']), $this->remember)) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'form.email' => trans('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
}
/**
* Ensure the authentication request is not rate limited.
*/
protected function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout(request()));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'form.email' => trans('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the authentication rate limiting throttle key.
*/
protected function throttleKey(): string
{
return Str::transliterate(Str::lower($this->email).'|'.request()->ip());
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class Admin extends Authenticatable
{
use Notifiable;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'name',
'email',
'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var array<int, string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ApiKey extends Model
{
protected $primaryKey = 'id';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = [
'id',
'key_hash',
'key_name',
'user_id',
'last_used_at',
'expires_at',
'is_active',
'metadata',
];
protected function casts(): array
{
return [
'is_active' => 'boolean',
'metadata' => 'array',
'created_at' => 'datetime',
'last_used_at' => 'datetime',
'expires_at' => 'datetime',
];
}
public function gatewayUser()
{
return $this->belongsTo(GatewayUser::class, 'user_id', 'user_id');
}
public function usageLogs()
{
return $this->hasMany(UsageLog::class, 'api_key_id', 'id');
}
public function scopeActive($query)
{
return $query->where('is_active', true)
->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
}
public function scopeExpired($query)
{
return $query->whereNotNull('expires_at')
->where('expires_at', '<=', now());
}
public function getMaskedKeyAttribute()
{
return 'gw-' . substr($this->id, 0, 8) . '...' . substr($this->id, -8);
}
public function getIsExpiredAttribute()
{
return $this->expires_at && $this->expires_at->isPast();
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Budget extends Model
{
protected $primaryKey = 'budget_id';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = [
'budget_id',
'max_budget',
'budget_duration_sec',
];
protected function casts(): array
{
return [
'max_budget' => 'double',
'budget_duration_sec' => 'integer',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}
public function gatewayUsers()
{
return $this->hasMany(GatewayUser::class, 'budget_id', 'budget_id');
}
public function getMaxBudgetFormattedAttribute()
{
return '$' . number_format($this->max_budget, 2);
}
public function getDurationHumanAttribute()
{
if (!$this->budget_duration_sec) return 'No limit';
$days = floor($this->budget_duration_sec / 86400);
$hours = floor(($this->budget_duration_sec % 86400) / 3600);
return "{$days}d {$hours}h";
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class GatewayUser extends Model
{
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'users';
/**
* The primary key for the model.
*
* @var string
*/
protected $primaryKey = 'user_id';
/**
* Indicates if the IDs are auto-incrementing.
*
* @var bool
*/
public $incrementing = false;
/**
* The data type of the primary key ID.
*
* @var string
*/
protected $keyType = 'string';
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'alias',
'spend',
'budget_id',
'blocked',
'metadata',
'budget_started_at',
'next_budget_reset_at',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'spend' => 'double',
'blocked' => 'boolean',
'metadata' => 'array',
'created_at' => 'datetime',
'updated_at' => 'datetime',
'budget_started_at' => 'datetime',
'next_budget_reset_at' => 'datetime',
];
}
/**
* Get the API keys for this user.
*/
public function apiKeys()
{
return $this->hasMany(ApiKey::class, 'user_id', 'user_id');
}
/**
* Get the usage logs for this user.
*/
public function usageLogs()
{
return $this->hasMany(UsageLog::class, 'user_id', 'user_id');
}
/**
* Get the budget for this user.
*/
public function budget()
{
return $this->belongsTo(Budget::class, 'budget_id', 'budget_id');
}
/**
* Scope a query to only include active users.
*/
public function scopeActive($query)
{
return $query->where('blocked', false);
}
/**
* Scope a query to only include blocked users.
*/
public function scopeBlocked($query)
{
return $query->where('blocked', true);
}
/**
* Get the formatted spend amount.
*/
public function getSpendFormattedAttribute()
{
return '$' . number_format($this->spend, 2);
}
/**
* Get the total number of requests.
*/
public function getTotalRequestsAttribute()
{
return $this->usageLogs()->count();
}
/**
* Get the total number of tokens used.
*/
public function getTotalTokensAttribute()
{
return $this->usageLogs()->sum('total_tokens');
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ModelPricing extends Model
{
protected $table = 'model_pricing';
protected $primaryKey = 'model_key';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = [
'model_key',
'input_price_per_million',
'output_price_per_million',
];
protected function casts(): array
{
return [
'input_price_per_million' => 'double',
'output_price_per_million' => 'double',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}
// Accessors
public function getInputPriceFormattedAttribute()
{
return '$' . number_format($this->input_price_per_million, 2) . '/M';
}
public function getOutputPriceFormattedAttribute()
{
return '$' . number_format($this->output_price_per_million, 2) . '/M';
}
/**
* Calculate cost for given token counts
*/
public function calculateCost($inputTokens, $outputTokens)
{
$inputCost = ($inputTokens / 1000000) * $this->input_price_per_million;
$outputCost = ($outputTokens / 1000000) * $this->output_price_per_million;
return $inputCost + $outputCost;
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class UsageLog extends Model
{
protected $primaryKey = 'id';
public $incrementing = false;
protected $keyType = 'string';
public $timestamps = false;
protected $fillable = [
'id',
'api_key_id',
'user_id',
'timestamp',
'model',
'provider',
'endpoint',
'prompt_tokens',
'completion_tokens',
'total_tokens',
'cost',
'status',
'error_message',
];
protected function casts(): array
{
return [
'timestamp' => 'datetime',
'prompt_tokens' => 'integer',
'completion_tokens' => 'integer',
'total_tokens' => 'integer',
'cost' => 'double',
];
}
public function gatewayUser()
{
return $this->belongsTo(GatewayUser::class, 'user_id', 'user_id');
}
public function apiKey()
{
return $this->belongsTo(ApiKey::class, 'api_key_id', 'id');
}
public function scopeSuccess($query)
{
return $query->where('status', 'success');
}
public function scopeFailed($query)
{
return $query->where('status', '!=', 'success');
}
public function scopeToday($query)
{
return $query->whereDate('timestamp', today());
}
public function scopeDateRange($query, $start, $end)
{
return $query->whereBetween('timestamp', [$start, $end]);
}
public function getCostFormattedAttribute()
{
return $this->cost ? '$' . number_format($this->cost, 4) : 'N/A';
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Livewire\Volt\Volt;
class VoltServiceProvider extends ServiceProvider
{
/**
* Register services.
*/
public function register(): void
{
//
}
/**
* Bootstrap services.
*/
public function boot(): void
{
Volt::mount([
config('livewire.view_path', resource_path('views/livewire')),
resource_path('views/pages'),
]);
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Services;
use App\Models\UsageLog;
use App\Models\GatewayUser;
use Illuminate\Support\Facades\DB;
class StatisticsService
{
/**
* Get dashboard overview statistics
*/
public function getDashboardStats()
{
return [
'total_users' => GatewayUser::count(),
'active_users' => GatewayUser::active()->count(),
'blocked_users' => GatewayUser::blocked()->count(),
'total_requests_today' => UsageLog::today()->count(),
'total_spend_today' => UsageLog::today()->sum('cost') ?? 0,
'total_tokens_today' => UsageLog::today()->sum('total_tokens') ?? 0,
'total_spend_month' => UsageLog::whereMonth('timestamp', now()->month)->sum('cost') ?? 0,
'total_requests_month' => UsageLog::whereMonth('timestamp', now()->month)->count(),
];
}
/**
* Get usage breakdown by provider
*/
public function getUsageByProvider($days = 30)
{
return UsageLog::selectRaw('provider, COUNT(*) as count, SUM(cost) as total_cost')
->where('timestamp', '>=', now()->subDays($days))
->groupBy('provider')
->orderByDesc('count')
->get();
}
/**
* Get usage breakdown by model
*/
public function getUsageByModel($days = 30)
{
return UsageLog::selectRaw('model, COUNT(*) as count, SUM(total_tokens) as tokens, SUM(cost) as total_cost')
->where('timestamp', '>=', now()->subDays($days))
->groupBy('model')
->orderByDesc('count')
->limit(10)
->get();
}
/**
* Get daily usage chart data
*/
public function getDailyUsageChart($days = 30)
{
return UsageLog::selectRaw('DATE(timestamp) as date, COUNT(*) as requests, SUM(cost) as cost, SUM(total_tokens) as tokens')
->where('timestamp', '>=', now()->subDays($days))
->groupBy('date')
->orderBy('date')
->get();
}
/**
* Get top users by spend
*/
public function getTopUsers($limit = 10)
{
return GatewayUser::withCount('usageLogs')
->withSum('usageLogs', 'cost')
->orderByDesc('usage_logs_sum_cost')
->limit($limit)
->get();
}
/**
* Get recent activity
*/
public function getRecentActivity($limit = 20)
{
return UsageLog::with(['gatewayUser', 'apiKey'])
->orderByDesc('timestamp')
->limit($limit)
->get();
}
/**
* Get user statistics
*/
public function getUserStatistics($userId, $days = 30)
{
return UsageLog::where('user_id', $userId)
->where('timestamp', '>=', now()->subDays($days))
->selectRaw('
COUNT(*) as total_requests,
SUM(prompt_tokens) as total_prompt_tokens,
SUM(completion_tokens) as total_completion_tokens,
SUM(total_tokens) as total_tokens,
SUM(cost) as total_cost,
AVG(total_tokens) as avg_tokens_per_request
')
->first();
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\View\Components;
use Illuminate\View\Component;
use Illuminate\View\View;
class AppLayout extends Component
{
/**
* Get the view / contents that represents the component.
*/
public function render(): View
{
return view('layouts.app');
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\View\Components;
use Illuminate\View\Component;
use Illuminate\View\View;
class GuestLayout extends Component
{
/**
* Get the view / contents that represents the component.
*/
public function render(): View
{
return view('layouts.guest');
}
}

18
laravel-app/artisan Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);

View File

@@ -0,0 +1,18 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();

2
laravel-app/bootstrap/cache/.gitignore vendored Executable file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -0,0 +1,6 @@
<?php
return [
App\Providers\AppServiceProvider::class,
App\Providers\VoltServiceProvider::class,
];

89
laravel-app/composer.json Normal file
View File

@@ -0,0 +1,89 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.2",
"laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1",
"livewire/livewire": "^3.6.4",
"livewire/volt": "^1.7.0"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/breeze": "^2.3",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^11.5.3"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"test": [
"@php artisan config:clear --ansi",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

8567
laravel-app/composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

126
laravel-app/config/app.php Normal file
View File

@@ -0,0 +1,126 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];

127
laravel-app/config/auth.php Normal file
View File

@@ -0,0 +1,127 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'admins'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'admins',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'admins' => [
'driver' => 'eloquent',
'model' => App\Models\Admin::class,
],
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', App\Models\User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'admins' => [
'provider' => 'admins',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];

View File

@@ -0,0 +1,117 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'database'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "octane",
| "failover", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
];

View File

@@ -0,0 +1,183 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'prefer',
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],
];

View File

@@ -0,0 +1,80 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('APP_URL').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];

View File

@@ -0,0 +1,132 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];

118
laravel-app/config/mail.php Normal file
View File

@@ -0,0 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', 'Example'),
],
];

View File

@@ -0,0 +1,129 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection options for every queue backend
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
| "deferred", "background", "failover", "null"
|
*/
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'block_for' => 0,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control how and where failed jobs are stored. Laravel ships with
| support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs',
],
];

View File

@@ -0,0 +1,38 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Mailgun, Postmark, AWS and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
'postmark' => [
'key' => env('POSTMARK_API_KEY'),
],
'resend' => [
'key' => env('RESEND_API_KEY'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'slack' => [
'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
];

View File

@@ -0,0 +1,217 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Session Driver
|--------------------------------------------------------------------------
|
| This option determines the default session driver that is utilized for
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
|
*/
'driver' => env('SESSION_DRIVER', 'database'),
/*
|--------------------------------------------------------------------------
| Session Lifetime
|--------------------------------------------------------------------------
|
| Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them
| to expire immediately when the browser is closed then you may
| indicate that via the expire_on_close configuration option.
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
/*
|--------------------------------------------------------------------------
| Session Encryption
|--------------------------------------------------------------------------
|
| This option allows you to easily specify that all of your session data
| should be encrypted before it's stored. All encryption is performed
| automatically by Laravel and you may use the session like normal.
|
*/
'encrypt' => env('SESSION_ENCRYPT', false),
/*
|--------------------------------------------------------------------------
| Session File Location
|--------------------------------------------------------------------------
|
| When utilizing the "file" session driver, the session files are placed
| on disk. The default storage location is defined here; however, you
| are free to provide another location where they should be stored.
|
*/
'files' => storage_path('framework/sessions'),
/*
|--------------------------------------------------------------------------
| Session Database Connection
|--------------------------------------------------------------------------
|
| When using the "database" or "redis" session drivers, you may specify a
| connection that should be used to manage these sessions. This should
| correspond to a connection in your database configuration options.
|
*/
'connection' => env('SESSION_CONNECTION'),
/*
|--------------------------------------------------------------------------
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table to
| be used to store sessions. Of course, a sensible default is defined
| for you; however, you're welcome to change this to another table.
|
*/
'table' => env('SESSION_TABLE', 'sessions'),
/*
|--------------------------------------------------------------------------
| Session Cache Store
|--------------------------------------------------------------------------
|
| When using one of the framework's cache driven session backends, you may
| define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores.
|
| Affects: "dynamodb", "memcached", "redis"
|
*/
'store' => env('SESSION_STORE'),
/*
|--------------------------------------------------------------------------
| Session Sweeping Lottery
|--------------------------------------------------------------------------
|
| Some session drivers must manually sweep their storage location to get
| rid of old sessions from storage. Here are the chances that it will
| happen on a given request. By default, the odds are 2 out of 100.
|
*/
'lottery' => [2, 100],
/*
|--------------------------------------------------------------------------
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Here you may change the name of the session cookie that is created by
| the framework. Typically, you should not need to change this value
| since doing so does not grant a meaningful security improvement.
|
*/
'cookie' => env(
'SESSION_COOKIE',
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
),
/*
|--------------------------------------------------------------------------
| Session Cookie Path
|--------------------------------------------------------------------------
|
| The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of
| your application, but you're free to change this when necessary.
|
*/
'path' => env('SESSION_PATH', '/'),
/*
|--------------------------------------------------------------------------
| Session Cookie Domain
|--------------------------------------------------------------------------
|
| This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root
| domain and all subdomains. Typically, this shouldn't be changed.
|
*/
'domain' => env('SESSION_DOMAIN'),
/*
|--------------------------------------------------------------------------
| HTTPS Only Cookies
|--------------------------------------------------------------------------
|
| By setting this option to true, session cookies will only be sent back
| to the server if the browser has a HTTPS connection. This will keep
| the cookie from being sent to you when it can't be done securely.
|
*/
'secure' => env('SESSION_SECURE_COOKIE'),
/*
|--------------------------------------------------------------------------
| HTTP Access Only
|--------------------------------------------------------------------------
|
| Setting this value to true will prevent JavaScript from accessing the
| value of the cookie and the cookie will only be accessible through
| the HTTP protocol. It's unlikely you should disable this option.
|
*/
'http_only' => env('SESSION_HTTP_ONLY', true),
/*
|--------------------------------------------------------------------------
| Same-Site Cookies
|--------------------------------------------------------------------------
|
| This option determines how your cookies behave when cross-site requests
| take place, and can be used to mitigate CSRF attacks. By default, we
| will set this value to "lax" to permit secure cross-site requests.
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
| Supported: "lax", "strict", "none", null
|
*/
'same_site' => env('SESSION_SAME_SITE', 'lax'),
/*
|--------------------------------------------------------------------------
| Partitioned Cookies
|--------------------------------------------------------------------------
|
| Setting this value to true will tie the cookie to the top-level site for
| a cross-site context. Partitioned cookies are accepted by the browser
| when flagged "secure" and the Same-Site attribute is set to "none".
|
*/
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
];

1
laravel-app/database/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.sqlite*

View File

@@ -0,0 +1,44 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}

View File

@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->integer('expiration');
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};

View File

@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('admins', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('admins');
}
};

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Password Reset Tokens für Admins
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
// Sessions Tabelle (für admins)
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index(); // referenziert admins.id
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};

View File

@@ -0,0 +1,25 @@
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
use WithoutModelEvents;
/**
* Seed the application's database.
*/
public function run(): void
{
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
}
}

3923
laravel-app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
laravel-app/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://www.schemastore.org/package.json",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.2",
"@tailwindcss/vite": "^4.0.0",
"autoprefixer": "^10.4.2",
"axios": "^1.11.0",
"concurrently": "^9.0.1",
"laravel-vite-plugin": "^2.0.0",
"postcss": "^8.4.31",
"tailwindcss": "^3.1.0",
"vite": "^7.0.7"
}
}

35
laravel-app/phpunit.xml Normal file
View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
</php>
</phpunit>

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,25 @@
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
RewriteEngine On
# Handle Authorization Header
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
# Handle X-XSRF-Token Header
RewriteCond %{HTTP:x-xsrf-token} .
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>

View File

View File

@@ -0,0 +1,20 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
// Determine if the application is in maintenance mode...
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
// Register the Composer autoloader...
require __DIR__.'/../vendor/autoload.php';
// Bootstrap Laravel and handle the request...
/** @var Application $app */
$app = require_once __DIR__.'/../bootstrap/app.php';
$app->handleRequest(Request::capture());

View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow:

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1 @@
import './bootstrap';

4
laravel-app/resources/js/bootstrap.js vendored Normal file
View File

@@ -0,0 +1,4 @@
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -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']) }}>

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

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

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

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

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

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

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

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More