Merge remote-tracking branch 'origin/next' into audit-policies
This commit is contained in:
commit
78d8afa602
203 changed files with 5440 additions and 1096 deletions
404
.agents/skills/configure-nightwatch/SKILL.md
Normal file
404
.agents/skills/configure-nightwatch/SKILL.md
Normal file
|
|
@ -0,0 +1,404 @@
|
||||||
|
---
|
||||||
|
name: configure-nightwatch
|
||||||
|
description: Configures Laravel Nightwatch data collection, sampling rates, filtering rules, and redaction policies. Use when setting up Nightwatch, managing data volume, protecting sensitive data (PII), or optimizing event collection for production workloads.
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
|
||||||
|
# Nightwatch Configuration Guide
|
||||||
|
|
||||||
|
This skill helps configure Laravel Nightwatch data collection to balance observability, performance, and privacy. Covers sampling strategies, filtering rules, and redaction methods across all event types.
|
||||||
|
|
||||||
|
## Documentation Reference
|
||||||
|
|
||||||
|
The [Nightwatch Documentation](https://nightwatch.laravel.com/docs) is the definitive and up-to-date source of information for all Nightwatch configuration options. This skill provides practical guidance and common patterns, but always consult the official documentation as the primary source of truth for specific details, environment variables, and API behavior. The documentation includes comprehensive coverage of:
|
||||||
|
|
||||||
|
- [Filtering and Configuration](https://nightwatch.laravel.com/docs/filtering) - Core concepts for sampling, filtering, and redaction
|
||||||
|
- Individual event type pages with specific configuration options:
|
||||||
|
- [Requests](https://nightwatch.laravel.com/docs/requests) - Request sampling, header handling, payload capture
|
||||||
|
- [Commands](https://nightwatch.laravel.com/docs/commands) - Command sampling and redaction
|
||||||
|
- [Queries](https://nightwatch.laravel.com/docs/queries) - Query filtering and redaction
|
||||||
|
- [Cache](https://nightwatch.laravel.com/docs/cache) - Cache event filtering by key or pattern
|
||||||
|
- [Jobs](https://nightwatch.laravel.com/docs/jobs) - Job filtering and sampling decoupling
|
||||||
|
- [Mail](https://nightwatch.laravel.com/docs/mail) - Mail event filtering
|
||||||
|
- [Notifications](https://nightwatch.laravel.com/docs/notifications) - Notification filtering by channel
|
||||||
|
- [Exceptions](https://nightwatch.laravel.com/docs/exceptions) - Exception sampling and throttling
|
||||||
|
- [Outgoing Requests](https://nightwatch.laravel.com/docs/outgoing-requests) - HTTP request filtering
|
||||||
|
- [reference.md](reference.md) - Quick lookup table by event type, production presets, and verification checklist
|
||||||
|
|
||||||
|
## Data Collection Flow
|
||||||
|
|
||||||
|
Nightwatch processes events through three stages:
|
||||||
|
|
||||||
|
1. **Sampling** - Controls which entry points are captured (requests, commands, scheduled tasks)
|
||||||
|
2. **Filtering** - Excludes specific events after sampling (queries, cache, mail, etc.)
|
||||||
|
3. **Redaction** - Modifies captured data to remove/obfuscate sensitive information
|
||||||
|
|
||||||
|
```
|
||||||
|
Request/Command/Scheduled Task
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[Sampling?] ----NO----> Drop entire trace
|
||||||
|
| YES
|
||||||
|
v
|
||||||
|
Events generated
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[Filtering?] ----YES---> Drop specific event
|
||||||
|
| NO
|
||||||
|
v
|
||||||
|
[Redaction] ----------> Store modified data
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sampling Configuration
|
||||||
|
|
||||||
|
Sampling determines which entry points (requests, commands, scheduled tasks) trigger full trace collection. When an entry point is sampled, all related events are captured.
|
||||||
|
|
||||||
|
### Global Sample Rates
|
||||||
|
|
||||||
|
Configure via environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
# Default: 100% sampling (all requests/commands captured)
|
||||||
|
|
||||||
|
NIGHTWATCH_REQUEST_SAMPLE_RATE=0.1 # Recommended: 10% of requests
|
||||||
|
|
||||||
|
NIGHTWATCH_COMMAND_SAMPLE_RATE=1.0 # Capture all commands
|
||||||
|
|
||||||
|
NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0 # Always capture exceptions
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recommendation**: Start with `0.1` (10%) for requests in production, adjust based on volume and needs.
|
||||||
|
|
||||||
|
### Route-Based Sampling
|
||||||
|
|
||||||
|
Apply different rates to specific routes using the `Sample` middleware:
|
||||||
|
|
||||||
|
```php routes/web.php
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Laravel\Nightwatch\Http\Middleware\Sample;
|
||||||
|
|
||||||
|
// Sample admin routes at 100%
|
||||||
|
Route::middleware(Sample::rate(1.0))->prefix('admin')->group(function () {
|
||||||
|
// All admin routes sampled fully
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sample API routes at 5%
|
||||||
|
Route::middleware(Sample::rate(0.05))->prefix('api')->group(function () {
|
||||||
|
// API routes sampled sparingly
|
||||||
|
});
|
||||||
|
|
||||||
|
// Always sample critical endpoints
|
||||||
|
Route::post('/checkout', [CheckoutController::class, 'process'])
|
||||||
|
->middleware(Sample::always());
|
||||||
|
|
||||||
|
// Never sample health checks
|
||||||
|
Route::get('/health', [HealthController::class, 'check'])
|
||||||
|
->middleware(Sample::never());
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unmatched Route Sampling
|
||||||
|
|
||||||
|
Handle 404/bot traffic with reduced sampling:
|
||||||
|
|
||||||
|
```php routes/web.php
|
||||||
|
Route::fallback(fn () => abort(404))
|
||||||
|
->middleware(Sample::rate(0.01)); // 1% sampling for unmatched routes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dynamic Sampling
|
||||||
|
|
||||||
|
Sample based on runtime conditions (user role, request attributes):
|
||||||
|
|
||||||
|
```php app/Http/Middleware/SampleAdminRequests.php
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Laravel\Nightwatch\Facades\Nightwatch;
|
||||||
|
|
||||||
|
class SampleAdminRequests
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next)
|
||||||
|
{
|
||||||
|
if ($request->user()?->isAdmin()) {
|
||||||
|
Nightwatch::sample(); // Always sample admin requests
|
||||||
|
}
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Command Sampling
|
||||||
|
|
||||||
|
Exclude specific commands from sampling:
|
||||||
|
|
||||||
|
```php AppServiceProvider.php
|
||||||
|
use Illuminate\Console\Events\CommandStarting;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Laravel\Nightwatch\Facades\Nightwatch;
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
Event::listen(function (CommandStarting $event) {
|
||||||
|
if (in_array($event->command, ['schedule:finish', 'horizon:snapshot'])) {
|
||||||
|
Nightwatch::dontSample();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vendor Commands
|
||||||
|
|
||||||
|
Nightwatch automatically ignores framework/internal commands. Opt-in to capture them:
|
||||||
|
|
||||||
|
```php
|
||||||
|
Nightwatch::captureDefaultVendorCommands();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Filtering Configuration
|
||||||
|
|
||||||
|
Filtering excludes specific events from collection after sampling. Use filtering to reduce noise and quota usage.
|
||||||
|
|
||||||
|
### Database Queries
|
||||||
|
|
||||||
|
**Filter all queries** (disable query collection):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NIGHTWATCH_IGNORE_QUERIES=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filter specific queries** by SQL pattern:
|
||||||
|
|
||||||
|
```php AppServiceProvider.php
|
||||||
|
use Laravel\Nightwatch\Facades\Nightwatch;
|
||||||
|
use Laravel\Nightwatch\Records\Query;
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
// Filter job table queries (PostgreSQL)
|
||||||
|
Nightwatch::rejectQueries(function (Query $query) {
|
||||||
|
return str_contains($query->sql, 'into "jobs"');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter cache table queries (MySQL)
|
||||||
|
Nightwatch::rejectQueries(function (Query $query) {
|
||||||
|
return str_contains($query->sql, 'from `cache`')
|
||||||
|
|| str_contains($query->sql, 'into `cache`');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cache Events
|
||||||
|
|
||||||
|
**Filter all cache events**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NIGHTWATCH_IGNORE_CACHE_EVENTS=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filter by cache key patterns**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
Nightwatch::rejectCacheKeys([
|
||||||
|
'my-app:users', // Exact match
|
||||||
|
'/^my-app:posts:/', // Regex: starts with my-app:posts:
|
||||||
|
'/^[a-zA-Z0-9]{40}$/', // Regex: session IDs
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filter with callback**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\CacheEvent;
|
||||||
|
|
||||||
|
Nightwatch::rejectCacheEvents(function (CacheEvent $cacheEvent) {
|
||||||
|
return str_starts_with($cacheEvent->key, 'temp:');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mail Events
|
||||||
|
|
||||||
|
**Filter all mail**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NIGHTWATCH_IGNORE_MAIL=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filter specific mail**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\Mail;
|
||||||
|
|
||||||
|
Nightwatch::rejectMail(function (Mail $mail) {
|
||||||
|
return str_contains($mail->subject, 'Newsletter');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notification Events
|
||||||
|
|
||||||
|
**Filter all notifications**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NIGHTWATCH_IGNORE_NOTIFICATIONS=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filter by channel**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\Notification;
|
||||||
|
|
||||||
|
Nightwatch::rejectNotifications(function (Notification $notification) {
|
||||||
|
return $notification->channel === 'database';
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Outgoing HTTP Requests
|
||||||
|
|
||||||
|
**Filter all outgoing requests**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NIGHTWATCH_IGNORE_OUTGOING_REQUESTS=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filter by URL**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\OutgoingRequest;
|
||||||
|
|
||||||
|
Nightwatch::rejectOutgoingRequests(function (OutgoingRequest $request) {
|
||||||
|
return str_contains($request->url, 'analytics.example.com');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Queued Jobs
|
||||||
|
|
||||||
|
**Filter specific jobs**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\QueuedJob;
|
||||||
|
|
||||||
|
Nightwatch::rejectQueuedJobs(function (QueuedJob $job) {
|
||||||
|
return $job->name === 'App\Jobs\LowPriorityJob';
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Decoupling Job Sampling
|
||||||
|
|
||||||
|
Sample jobs independently from parent contexts:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
Queue::before(fn () => Nightwatch::sample(rate: 0.5));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Redaction Configuration
|
||||||
|
|
||||||
|
Redaction modifies captured data to remove or obfuscate sensitive information. Unlike filtering, redaction keeps the event but sanitizes its content.
|
||||||
|
|
||||||
|
### Request Redaction
|
||||||
|
|
||||||
|
**Redact sensitive headers** (automatically redacts: Authorization, Cookie, X-XSRF-TOKEN):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
# Customize redacted headers
|
||||||
|
|
||||||
|
NIGHTWATCH_REDACT_HEADERS=Authorization,Cookie,Proxy-Authorization,X-API-Key
|
||||||
|
```
|
||||||
|
|
||||||
|
**Redact request payloads** (disabled by default):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
# Enable payload capture
|
||||||
|
|
||||||
|
NIGHTWATCH_CAPTURE_REQUEST_PAYLOAD=true
|
||||||
|
|
||||||
|
# Customize redacted fields
|
||||||
|
|
||||||
|
NIGHTWATCH_REDACT_PAYLOAD_FIELDS=password,password_confirmation,ssn,credit_card
|
||||||
|
```
|
||||||
|
|
||||||
|
**Programmatic redaction**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Facades\Nightwatch;
|
||||||
|
use Laravel\Nightwatch\Records\Request;
|
||||||
|
|
||||||
|
Nightwatch::redactRequests(function (Request $request) {
|
||||||
|
$request->url = str_replace('secret', '***', $request->url);
|
||||||
|
$request->ip = preg_replace('/\d+$/', '***', $request->ip);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Redaction
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\Query;
|
||||||
|
|
||||||
|
Nightwatch::redactQueries(function (Query $query) {
|
||||||
|
$query->sql = str_replace('secret_token', '***', $query->sql);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cache Redaction
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\CacheEvent;
|
||||||
|
|
||||||
|
Nightwatch::redactCacheEvents(function (CacheEvent $cacheEvent) {
|
||||||
|
$cacheEvent->key = str_replace('user:', 'user:***:', $cacheEvent->key);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Command Redaction
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\Command;
|
||||||
|
|
||||||
|
Nightwatch::redactCommands(function (Command $command) {
|
||||||
|
$command->command = preg_replace('/--password=\S+/', '--password=***', $command->command);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exception Redaction
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\Exception;
|
||||||
|
|
||||||
|
Nightwatch::redactExceptions(function (Exception $exception) {
|
||||||
|
$exception->message = str_replace('secret', '***', $exception->message);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mail Redaction
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\Mail;
|
||||||
|
|
||||||
|
Nightwatch::redactMail(function (Mail $mail) {
|
||||||
|
$mail->subject = str_replace('Invoice #', 'Invoice ***', $mail->subject);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Outgoing Request Redaction
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\OutgoingRequest;
|
||||||
|
|
||||||
|
Nightwatch::redactOutgoingRequests(function (OutgoingRequest $outgoingRequest) {
|
||||||
|
$outgoingRequest->url = preg_replace('/api_key=\w+/', 'api_key=***', $outgoingRequest->url);
|
||||||
|
});
|
||||||
|
```
|
||||||
108
.agents/skills/configure-nightwatch/reference.md
Normal file
108
.agents/skills/configure-nightwatch/reference.md
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
# Nightwatch Configuration Reference
|
||||||
|
|
||||||
|
## Configuration Summary by Event Type
|
||||||
|
|
||||||
|
| Event Type | Sampling | Filtering | Redaction |
|
||||||
|
| --------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------- |
|
||||||
|
| **Requests** | `NIGHTWATCH_REQUEST_SAMPLE_RATE`, Route middleware | Not applicable | Headers, payload, URL, IP |
|
||||||
|
| **Commands** | `NIGHTWATCH_COMMAND_SAMPLE_RATE`, Event listener | Not applicable | Command arguments |
|
||||||
|
| **Queries** | Parent context | `rejectQueries()`, `NIGHTWATCH_IGNORE_QUERIES` | SQL statement |
|
||||||
|
| **Cache** | Parent context | `rejectCacheKeys()`, `rejectCacheEvents()`, `NIGHTWATCH_IGNORE_CACHE_EVENTS` | Cache key |
|
||||||
|
| **Jobs** | Parent context, Queue::before | `rejectQueuedJobs()` | Not applicable |
|
||||||
|
| **Mail** | Parent context | `rejectMail()`, `NIGHTWATCH_IGNORE_MAIL` | Subject |
|
||||||
|
| **Notifications** | Parent context | `rejectNotifications()`, `NIGHTWATCH_IGNORE_NOTIFICATIONS` | Not applicable |
|
||||||
|
| **Outgoing Requests** | Parent context | `rejectOutgoingRequests()`, `NIGHTWATCH_IGNORE_OUTGOING_REQUESTS` | URL |
|
||||||
|
| **Exceptions** | `NIGHTWATCH_EXCEPTION_SAMPLE_RATE` | Not applicable | Exception message |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production Recommendations
|
||||||
|
|
||||||
|
### High-Traffic Applications
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
# Conservative sampling
|
||||||
|
|
||||||
|
NIGHTWATCH_REQUEST_SAMPLE_RATE=0.01 # 1% of requests
|
||||||
|
|
||||||
|
NIGHTWATCH_COMMAND_SAMPLE_RATE=0.1 # 10% of commands
|
||||||
|
|
||||||
|
NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0 # Always capture exceptions
|
||||||
|
|
||||||
|
# Filter noisy events
|
||||||
|
|
||||||
|
NIGHTWATCH_IGNORE_CACHE_EVENTS=true
|
||||||
|
NIGHTWATCH_IGNORE_QUERIES=true # Or filter specific queries programmatically
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### Privacy-Conscious Applications
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
# Disable sensitive data collection
|
||||||
|
|
||||||
|
NIGHTWATCH_CAPTURE_REQUEST_PAYLOAD=false
|
||||||
|
NIGHTWATCH_REDACT_HEADERS=Authorization,Cookie,Proxy-Authorization,X-XSRF-TOKEN
|
||||||
|
|
||||||
|
# Or use redaction in AppServiceProvider
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### Balanced Configuration (Recommended Start)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
# Sample rates
|
||||||
|
|
||||||
|
NIGHTWATCH_REQUEST_SAMPLE_RATE=0.1
|
||||||
|
NIGHTWATCH_COMMAND_SAMPLE_RATE=1.0
|
||||||
|
NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0
|
||||||
|
|
||||||
|
# Filter obvious noise programmatically
|
||||||
|
|
||||||
|
# Redact PII as needed
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Checklist
|
||||||
|
|
||||||
|
After configuration:
|
||||||
|
|
||||||
|
- [ ] Sampling rates appropriate for traffic volume
|
||||||
|
- [ ] Noisy events filtered (cache, certain queries)
|
||||||
|
- [ ] Sensitive data redacted (PII, tokens, credentials)
|
||||||
|
- [ ] Exceptions always captured for debugging
|
||||||
|
- [ ] Test in development with `NIGHTWATCH_REQUEST_SAMPLE_RATE=1.0`
|
||||||
|
- [ ] Monitor event quota usage in Nightwatch dashboard
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Filter Health Checks + Reduce Sampling
|
||||||
|
|
||||||
|
```php
|
||||||
|
Route::get('/health', fn() => ['status' => 'ok'])
|
||||||
|
->middleware(Sample::never());
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exclude Internal/Vendor Queries
|
||||||
|
|
||||||
|
```php
|
||||||
|
Nightwatch::rejectQueries(fn($q) =>
|
||||||
|
str_contains($q->sql, 'telescope') ||
|
||||||
|
str_contains($q->sql, 'pulse')
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Protect User Data in Cache Keys
|
||||||
|
|
||||||
|
```php
|
||||||
|
Nightwatch::redactCacheEvents(fn($e) =>
|
||||||
|
$e->key = preg_replace('/user:\d+/', 'user:***', $e->key)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
name: fortify-development
|
name: fortify-development
|
||||||
description: 'ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.'
|
description: 'ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), passkeys, profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, passkeys, WebAuthn, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.'
|
||||||
license: MIT
|
license: MIT
|
||||||
metadata:
|
metadata:
|
||||||
author: laravel
|
author: laravel
|
||||||
|
|
@ -32,6 +32,7 @@ ## Available Features
|
||||||
- `Features::updateProfileInformation()` - Profile updates
|
- `Features::updateProfileInformation()` - Profile updates
|
||||||
- `Features::updatePasswords()` - Password changes
|
- `Features::updatePasswords()` - Password changes
|
||||||
- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes
|
- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes
|
||||||
|
- `Features::passkeys()` - Passwordless authentication with WebAuthn passkeys
|
||||||
|
|
||||||
> Use `search-docs` for feature configuration options and customization patterns.
|
> Use `search-docs` for feature configuration options and customization patterns.
|
||||||
|
|
||||||
|
|
@ -50,6 +51,18 @@ ### Two-Factor Authentication Setup
|
||||||
|
|
||||||
> Use `search-docs` for TOTP implementation and recovery code handling patterns.
|
> Use `search-docs` for TOTP implementation and recovery code handling patterns.
|
||||||
|
|
||||||
|
### Passkeys Setup
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] Add PasskeyAuthenticatable trait to User model and implement PasskeyUser
|
||||||
|
- [ ] Enable passkeys feature in config/fortify.php
|
||||||
|
- [ ] If the passkeys table migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate
|
||||||
|
- [ ] Configure passkeys relying_party_id, allowed_origins, user_handle_secret, and timeout if defaults are not suitable
|
||||||
|
- [ ] Build UI with @laravel/passkeys for registration, login, confirmation, and deletion
|
||||||
|
```
|
||||||
|
|
||||||
|
> Use `search-docs` for passkey configuration options. For `@laravel/passkeys` frontend usage, refer to the package's README on npm.
|
||||||
|
|
||||||
### Email Verification Setup
|
### Email Verification Setup
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -129,3 +142,10 @@ ## Key Endpoints
|
||||||
| 2FA Challenge | POST | `/two-factor-challenge` |
|
| 2FA Challenge | POST | `/two-factor-challenge` |
|
||||||
| Get QR Code | GET | `/user/two-factor-qr-code` |
|
| Get QR Code | GET | `/user/two-factor-qr-code` |
|
||||||
| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` |
|
| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` |
|
||||||
|
| Passkey Login Options | GET | `/passkeys/login/options` |
|
||||||
|
| Passkey Login | POST | `/passkeys/login` |
|
||||||
|
| Passkey Confirm Options| GET | `/passkeys/confirm/options` |
|
||||||
|
| Passkey Confirm | POST | `/passkeys/confirm` |
|
||||||
|
| Passkey Options | GET | `/user/passkeys/options` |
|
||||||
|
| Register Passkey | POST | `/user/passkeys` |
|
||||||
|
| Delete Passkey | DELETE | `/user/passkeys/{passkey}` |
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ ### 8. Testing Patterns → `rules/testing.md`
|
||||||
### 9. Queue & Job Patterns → `rules/queue-jobs.md`
|
### 9. Queue & Job Patterns → `rules/queue-jobs.md`
|
||||||
|
|
||||||
- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]`
|
- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]`
|
||||||
- `ShouldBeUnique` to prevent duplicates; `WithoutOverlapping::untilProcessing()` for concurrency
|
- `ShouldBeUnique` to prevent duplicates; `ShouldBeUniqueUntilProcessing` for early lock release
|
||||||
- Always implement `failed()`; with `retryUntil()`, set `$tries = 0`
|
- Always implement `failed()`; with `retryUntil()`, set `$tries = 0`
|
||||||
- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs
|
- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs
|
||||||
- Horizon for complex multi-queue scenarios
|
- Horizon for complex multi-queue scenarios
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ ## Code to Interfaces
|
||||||
|
|
||||||
## Default Sort by Descending
|
## Default Sort by Descending
|
||||||
|
|
||||||
When no explicit order is specified, sort by `id` or `created_at` descending. Explicit ordering prevents cross-database inconsistencies between MySQL and Postgres.
|
When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined.
|
||||||
|
|
||||||
Incorrect:
|
Incorrect:
|
||||||
```php
|
```php
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ # Caching Best Practices
|
||||||
|
|
||||||
## Use `Cache::remember()` Instead of Manual Get/Put
|
## Use `Cache::remember()` Instead of Manual Get/Put
|
||||||
|
|
||||||
Atomic pattern prevents race conditions and removes boilerplate.
|
Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions.
|
||||||
|
|
||||||
Incorrect:
|
Incorrect:
|
||||||
```php
|
```php
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ # Configuration Best Practices
|
||||||
|
|
||||||
## `env()` Only in Config Files
|
## `env()` Only in Config Files
|
||||||
|
|
||||||
Direct `env()` calls return `null` when config is cached.
|
Direct `env()` calls may return `null` when config is cached.
|
||||||
|
|
||||||
Incorrect:
|
Incorrect:
|
||||||
```php
|
```php
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,11 @@ ## Always Queue Notifications
|
||||||
|
|
||||||
## Use `afterCommit()` on Notifications in Transactions
|
## Use `afterCommit()` on Notifications in Transactions
|
||||||
|
|
||||||
Same race condition as events — the queued notification job may run before the transaction commits.
|
Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$user->notify((new InvoicePaid($invoice))->afterCommit());
|
||||||
|
```
|
||||||
|
|
||||||
## Route Notification Channels to Dedicated Queues
|
## Route Notification Channels to Dedicated Queues
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ ## Use Retry with Backoff for External APIs
|
||||||
Only retry on specific errors:
|
Only retry on specific errors:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$response = Http::retry(3, 100, function (Exception $exception, PendingRequest $request) {
|
$response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) {
|
||||||
return $exception instanceof ConnectionException
|
return $exception instanceof ConnectionException
|
||||||
|| ($exception instanceof RequestException && $exception->response->serverError());
|
|| ($exception instanceof RequestException && $exception->response->serverError());
|
||||||
})->post('https://api.example.com/data');
|
})->post('https://api.example.com/data');
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ ## Use `afterCommit()` on Mailables Inside Transactions
|
||||||
|
|
||||||
## Use `assertQueued()` Not `assertSent()` for Queued Mailables
|
## Use `assertQueued()` Not `assertSent()` for Queued Mailables
|
||||||
|
|
||||||
`Mail::assertSent()` only catches synchronous mail. Queued mailables silently pass `assertSent`, giving false confidence.
|
`Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint.
|
||||||
|
|
||||||
Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`.
|
Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -106,25 +106,23 @@ ## `retryUntil()` Needs `$tries = 0`
|
||||||
```php
|
```php
|
||||||
public $tries = 0;
|
public $tries = 0;
|
||||||
|
|
||||||
public function retryUntil(): DateTime
|
public function retryUntil(): \DateTimeInterface
|
||||||
{
|
{
|
||||||
return now()->addHours(4);
|
return now()->addHours(4);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Use `WithoutOverlapping::untilProcessing()`
|
## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release
|
||||||
|
|
||||||
Prevents concurrent execution while allowing new instances to queue.
|
`ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue.
|
||||||
|
|
||||||
```php
|
```php
|
||||||
public function middleware(): array
|
class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing
|
||||||
{
|
{
|
||||||
return [new WithoutOverlapping($this->product->id)->untilProcessing()];
|
// Lock releases when processing begins, not when it finishes
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Without `untilProcessing()`, the lock extends through queue wait time. With it, the lock releases when processing starts.
|
|
||||||
|
|
||||||
## Use Horizon for Complex Queue Scenarios
|
## Use Horizon for Complex Queue Scenarios
|
||||||
|
|
||||||
Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities.
|
Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities.
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,8 @@ ## Use Resource Controllers
|
||||||
|
|
||||||
```php
|
```php
|
||||||
Route::resource('posts', PostController::class);
|
Route::resource('posts', PostController::class);
|
||||||
Route::apiResource('api/posts', Api\PostController::class);
|
// In routes/api.php — the /api prefix is applied automatically
|
||||||
|
Route::apiResource('posts', Api\PostController::class);
|
||||||
```
|
```
|
||||||
|
|
||||||
## Keep Controllers Thin
|
## Keep Controllers Thin
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ ## Authorize Every Action
|
||||||
|
|
||||||
Incorrect:
|
Incorrect:
|
||||||
```php
|
```php
|
||||||
public function update(Request $request, Post $post)
|
public function update(UpdatePostRequest $request, Post $post)
|
||||||
{
|
{
|
||||||
$post->update($request->validated());
|
$post->update($request->validated());
|
||||||
}
|
}
|
||||||
|
|
@ -90,7 +90,7 @@ ## Escape Output to Prevent XSS
|
||||||
|
|
||||||
## CSRF Protection
|
## CSRF Protection
|
||||||
|
|
||||||
Include `@csrf` in all POST/PUT/DELETE Blade forms. Not needed in Inertia.
|
Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied.
|
||||||
|
|
||||||
Incorrect:
|
Incorrect:
|
||||||
```blade
|
```blade
|
||||||
|
|
@ -121,7 +121,7 @@ ## Rate Limit Auth and API Routes
|
||||||
|
|
||||||
## Validate File Uploads
|
## Validate File Uploads
|
||||||
|
|
||||||
Validate MIME type, extension, and size. Never trust client-provided filenames.
|
Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames.
|
||||||
|
|
||||||
```php
|
```php
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -2,7 +2,7 @@ # Testing Best Practices
|
||||||
|
|
||||||
## Use `LazilyRefreshDatabase` Over `RefreshDatabase`
|
## Use `LazilyRefreshDatabase` Over `RefreshDatabase`
|
||||||
|
|
||||||
`RefreshDatabase` runs all migrations every test run even when the schema hasn't changed. `LazilyRefreshDatabase` only migrates when needed, significantly speeding up large suites.
|
`RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date.
|
||||||
|
|
||||||
## Use Model Assertions Over Raw Database Assertions
|
## Use Model Assertions Over Raw Database Assertions
|
||||||
|
|
||||||
|
|
|
||||||
96
.agents/skills/mcp-development/SKILL.md
Normal file
96
.agents/skills/mcp-development/SKILL.md
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
---
|
||||||
|
name: mcp-development
|
||||||
|
description: "Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP."
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
|
||||||
|
# MCP Development
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Use `search-docs` for detailed Laravel MCP patterns and documentation.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
Register MCP servers in `routes/ai.php`:
|
||||||
|
|
||||||
|
<!-- Register MCP Server -->
|
||||||
|
```php
|
||||||
|
use Laravel\Mcp\Facades\Mcp;
|
||||||
|
|
||||||
|
Mcp::web();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating MCP Primitives
|
||||||
|
|
||||||
|
Create MCP tools, resources, prompts, and servers using artisan commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan make:mcp-tool ToolName # Create a tool
|
||||||
|
|
||||||
|
php artisan make:mcp-resource ResourceName # Create a resource
|
||||||
|
|
||||||
|
php artisan make:mcp-prompt PromptName # Create a prompt
|
||||||
|
|
||||||
|
php artisan make:mcp-server ServerName # Create a server
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
After creating primitives, register them in your server's `$tools`, `$resources`, or `$prompts` properties.
|
||||||
|
|
||||||
|
### Tools
|
||||||
|
|
||||||
|
<!-- MCP Tool Example -->
|
||||||
|
```php
|
||||||
|
use Laravel\Mcp\Server\Tool;
|
||||||
|
use Laravel\Mcp\Server\Request;
|
||||||
|
use Laravel\Mcp\Server\Response;
|
||||||
|
|
||||||
|
class MyTool extends Tool
|
||||||
|
{
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return new Response(['result' => 'success']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registering Primitives in a Server
|
||||||
|
|
||||||
|
Each MCP server must explicitly declare the tools, resources, and prompts it exposes.
|
||||||
|
|
||||||
|
<!-- Register Primitives in MCP Server -->
|
||||||
|
```php
|
||||||
|
use Laravel\Mcp\Server;
|
||||||
|
|
||||||
|
class AppServer extends Server
|
||||||
|
{
|
||||||
|
protected array $tools = [
|
||||||
|
\App\Mcp\Tools\MyTool::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
protected array $resources = [
|
||||||
|
\App\Mcp\Resources\MyResource::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
protected array $prompts = [
|
||||||
|
\App\Mcp\Prompts\MyPrompt::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Check `routes/ai.php` for proper registration
|
||||||
|
2. Test tool via MCP client
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
- Running `mcp:start` command (it hangs waiting for input)
|
||||||
|
- Using HTTPS locally with Node-based MCP clients
|
||||||
|
- Not using `search-docs` for the latest MCP documentation
|
||||||
|
- Not registering MCP server routes in `routes/ai.php`
|
||||||
|
- Do not register `ai.php` in `bootstrap.php`; it is registered automatically.
|
||||||
|
- OAuth registration supports custom URI schemes (e.g., `cursor://`, `vscode://`) for native desktop clients via `mcp.custom_schemes` config
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
name: pest-testing
|
name: pest-testing
|
||||||
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
|
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
|
||||||
license: MIT
|
license: MIT
|
||||||
metadata:
|
metadata:
|
||||||
author: laravel
|
author: laravel
|
||||||
|
|
@ -18,6 +18,12 @@ ### Creating Tests
|
||||||
|
|
||||||
All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
|
All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
|
||||||
|
|
||||||
|
The `{name}` argument should include only the path and test name, but should not include the test suite.
|
||||||
|
- Incorrect: `php artisan make:test --pest Feature/SomeFeatureTest` will generate `tests/Feature/Feature/SomeFeatureTest.php`
|
||||||
|
- Correct: `php artisan make:test --pest SomeControllerTest` will generate `tests/Feature/SomeControllerTest.php`
|
||||||
|
- Incorrect: `php artisan make:test --pest --unit Unit/SomeServiceTest` will generate `tests/Unit/Unit/SomeServiceTest.php`
|
||||||
|
- Correct: `php artisan make:test --pest --unit SomeServiceTest` will generate `tests/Unit/SomeServiceTest.php`
|
||||||
|
|
||||||
### Test Organization
|
### Test Organization
|
||||||
|
|
||||||
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
|
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
|
||||||
|
|
@ -26,6 +32,8 @@ ### Test Organization
|
||||||
|
|
||||||
### Basic Test Structure
|
### Basic Test Structure
|
||||||
|
|
||||||
|
Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`.
|
||||||
|
|
||||||
<!-- Basic Pest Test Example -->
|
<!-- Basic Pest Test Example -->
|
||||||
```php
|
```php
|
||||||
it('is true', function () {
|
it('is true', function () {
|
||||||
|
|
@ -155,3 +163,4 @@ ## Common Pitfalls
|
||||||
- Forgetting datasets for repetitive validation tests
|
- Forgetting datasets for repetitive validation tests
|
||||||
- Deleting tests without approval
|
- Deleting tests without approval
|
||||||
- Forgetting `assertNoJavaScriptErrors()` in browser tests
|
- Forgetting `assertNoJavaScriptErrors()` in browser tests
|
||||||
|
- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test`
|
||||||
|
|
|
||||||
404
.claude/skills/configure-nightwatch/SKILL.md
Normal file
404
.claude/skills/configure-nightwatch/SKILL.md
Normal file
|
|
@ -0,0 +1,404 @@
|
||||||
|
---
|
||||||
|
name: configure-nightwatch
|
||||||
|
description: Configures Laravel Nightwatch data collection, sampling rates, filtering rules, and redaction policies. Use when setting up Nightwatch, managing data volume, protecting sensitive data (PII), or optimizing event collection for production workloads.
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
|
||||||
|
# Nightwatch Configuration Guide
|
||||||
|
|
||||||
|
This skill helps configure Laravel Nightwatch data collection to balance observability, performance, and privacy. Covers sampling strategies, filtering rules, and redaction methods across all event types.
|
||||||
|
|
||||||
|
## Documentation Reference
|
||||||
|
|
||||||
|
The [Nightwatch Documentation](https://nightwatch.laravel.com/docs) is the definitive and up-to-date source of information for all Nightwatch configuration options. This skill provides practical guidance and common patterns, but always consult the official documentation as the primary source of truth for specific details, environment variables, and API behavior. The documentation includes comprehensive coverage of:
|
||||||
|
|
||||||
|
- [Filtering and Configuration](https://nightwatch.laravel.com/docs/filtering) - Core concepts for sampling, filtering, and redaction
|
||||||
|
- Individual event type pages with specific configuration options:
|
||||||
|
- [Requests](https://nightwatch.laravel.com/docs/requests) - Request sampling, header handling, payload capture
|
||||||
|
- [Commands](https://nightwatch.laravel.com/docs/commands) - Command sampling and redaction
|
||||||
|
- [Queries](https://nightwatch.laravel.com/docs/queries) - Query filtering and redaction
|
||||||
|
- [Cache](https://nightwatch.laravel.com/docs/cache) - Cache event filtering by key or pattern
|
||||||
|
- [Jobs](https://nightwatch.laravel.com/docs/jobs) - Job filtering and sampling decoupling
|
||||||
|
- [Mail](https://nightwatch.laravel.com/docs/mail) - Mail event filtering
|
||||||
|
- [Notifications](https://nightwatch.laravel.com/docs/notifications) - Notification filtering by channel
|
||||||
|
- [Exceptions](https://nightwatch.laravel.com/docs/exceptions) - Exception sampling and throttling
|
||||||
|
- [Outgoing Requests](https://nightwatch.laravel.com/docs/outgoing-requests) - HTTP request filtering
|
||||||
|
- [reference.md](reference.md) - Quick lookup table by event type, production presets, and verification checklist
|
||||||
|
|
||||||
|
## Data Collection Flow
|
||||||
|
|
||||||
|
Nightwatch processes events through three stages:
|
||||||
|
|
||||||
|
1. **Sampling** - Controls which entry points are captured (requests, commands, scheduled tasks)
|
||||||
|
2. **Filtering** - Excludes specific events after sampling (queries, cache, mail, etc.)
|
||||||
|
3. **Redaction** - Modifies captured data to remove/obfuscate sensitive information
|
||||||
|
|
||||||
|
```
|
||||||
|
Request/Command/Scheduled Task
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[Sampling?] ----NO----> Drop entire trace
|
||||||
|
| YES
|
||||||
|
v
|
||||||
|
Events generated
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[Filtering?] ----YES---> Drop specific event
|
||||||
|
| NO
|
||||||
|
v
|
||||||
|
[Redaction] ----------> Store modified data
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sampling Configuration
|
||||||
|
|
||||||
|
Sampling determines which entry points (requests, commands, scheduled tasks) trigger full trace collection. When an entry point is sampled, all related events are captured.
|
||||||
|
|
||||||
|
### Global Sample Rates
|
||||||
|
|
||||||
|
Configure via environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
# Default: 100% sampling (all requests/commands captured)
|
||||||
|
|
||||||
|
NIGHTWATCH_REQUEST_SAMPLE_RATE=0.1 # Recommended: 10% of requests
|
||||||
|
|
||||||
|
NIGHTWATCH_COMMAND_SAMPLE_RATE=1.0 # Capture all commands
|
||||||
|
|
||||||
|
NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0 # Always capture exceptions
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recommendation**: Start with `0.1` (10%) for requests in production, adjust based on volume and needs.
|
||||||
|
|
||||||
|
### Route-Based Sampling
|
||||||
|
|
||||||
|
Apply different rates to specific routes using the `Sample` middleware:
|
||||||
|
|
||||||
|
```php routes/web.php
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Laravel\Nightwatch\Http\Middleware\Sample;
|
||||||
|
|
||||||
|
// Sample admin routes at 100%
|
||||||
|
Route::middleware(Sample::rate(1.0))->prefix('admin')->group(function () {
|
||||||
|
// All admin routes sampled fully
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sample API routes at 5%
|
||||||
|
Route::middleware(Sample::rate(0.05))->prefix('api')->group(function () {
|
||||||
|
// API routes sampled sparingly
|
||||||
|
});
|
||||||
|
|
||||||
|
// Always sample critical endpoints
|
||||||
|
Route::post('/checkout', [CheckoutController::class, 'process'])
|
||||||
|
->middleware(Sample::always());
|
||||||
|
|
||||||
|
// Never sample health checks
|
||||||
|
Route::get('/health', [HealthController::class, 'check'])
|
||||||
|
->middleware(Sample::never());
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unmatched Route Sampling
|
||||||
|
|
||||||
|
Handle 404/bot traffic with reduced sampling:
|
||||||
|
|
||||||
|
```php routes/web.php
|
||||||
|
Route::fallback(fn () => abort(404))
|
||||||
|
->middleware(Sample::rate(0.01)); // 1% sampling for unmatched routes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dynamic Sampling
|
||||||
|
|
||||||
|
Sample based on runtime conditions (user role, request attributes):
|
||||||
|
|
||||||
|
```php app/Http/Middleware/SampleAdminRequests.php
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Laravel\Nightwatch\Facades\Nightwatch;
|
||||||
|
|
||||||
|
class SampleAdminRequests
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next)
|
||||||
|
{
|
||||||
|
if ($request->user()?->isAdmin()) {
|
||||||
|
Nightwatch::sample(); // Always sample admin requests
|
||||||
|
}
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Command Sampling
|
||||||
|
|
||||||
|
Exclude specific commands from sampling:
|
||||||
|
|
||||||
|
```php AppServiceProvider.php
|
||||||
|
use Illuminate\Console\Events\CommandStarting;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Laravel\Nightwatch\Facades\Nightwatch;
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
Event::listen(function (CommandStarting $event) {
|
||||||
|
if (in_array($event->command, ['schedule:finish', 'horizon:snapshot'])) {
|
||||||
|
Nightwatch::dontSample();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vendor Commands
|
||||||
|
|
||||||
|
Nightwatch automatically ignores framework/internal commands. Opt-in to capture them:
|
||||||
|
|
||||||
|
```php
|
||||||
|
Nightwatch::captureDefaultVendorCommands();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Filtering Configuration
|
||||||
|
|
||||||
|
Filtering excludes specific events from collection after sampling. Use filtering to reduce noise and quota usage.
|
||||||
|
|
||||||
|
### Database Queries
|
||||||
|
|
||||||
|
**Filter all queries** (disable query collection):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NIGHTWATCH_IGNORE_QUERIES=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filter specific queries** by SQL pattern:
|
||||||
|
|
||||||
|
```php AppServiceProvider.php
|
||||||
|
use Laravel\Nightwatch\Facades\Nightwatch;
|
||||||
|
use Laravel\Nightwatch\Records\Query;
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
// Filter job table queries (PostgreSQL)
|
||||||
|
Nightwatch::rejectQueries(function (Query $query) {
|
||||||
|
return str_contains($query->sql, 'into "jobs"');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter cache table queries (MySQL)
|
||||||
|
Nightwatch::rejectQueries(function (Query $query) {
|
||||||
|
return str_contains($query->sql, 'from `cache`')
|
||||||
|
|| str_contains($query->sql, 'into `cache`');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cache Events
|
||||||
|
|
||||||
|
**Filter all cache events**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NIGHTWATCH_IGNORE_CACHE_EVENTS=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filter by cache key patterns**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
Nightwatch::rejectCacheKeys([
|
||||||
|
'my-app:users', // Exact match
|
||||||
|
'/^my-app:posts:/', // Regex: starts with my-app:posts:
|
||||||
|
'/^[a-zA-Z0-9]{40}$/', // Regex: session IDs
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filter with callback**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\CacheEvent;
|
||||||
|
|
||||||
|
Nightwatch::rejectCacheEvents(function (CacheEvent $cacheEvent) {
|
||||||
|
return str_starts_with($cacheEvent->key, 'temp:');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mail Events
|
||||||
|
|
||||||
|
**Filter all mail**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NIGHTWATCH_IGNORE_MAIL=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filter specific mail**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\Mail;
|
||||||
|
|
||||||
|
Nightwatch::rejectMail(function (Mail $mail) {
|
||||||
|
return str_contains($mail->subject, 'Newsletter');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notification Events
|
||||||
|
|
||||||
|
**Filter all notifications**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NIGHTWATCH_IGNORE_NOTIFICATIONS=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filter by channel**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\Notification;
|
||||||
|
|
||||||
|
Nightwatch::rejectNotifications(function (Notification $notification) {
|
||||||
|
return $notification->channel === 'database';
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Outgoing HTTP Requests
|
||||||
|
|
||||||
|
**Filter all outgoing requests**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NIGHTWATCH_IGNORE_OUTGOING_REQUESTS=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filter by URL**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\OutgoingRequest;
|
||||||
|
|
||||||
|
Nightwatch::rejectOutgoingRequests(function (OutgoingRequest $request) {
|
||||||
|
return str_contains($request->url, 'analytics.example.com');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Queued Jobs
|
||||||
|
|
||||||
|
**Filter specific jobs**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\QueuedJob;
|
||||||
|
|
||||||
|
Nightwatch::rejectQueuedJobs(function (QueuedJob $job) {
|
||||||
|
return $job->name === 'App\Jobs\LowPriorityJob';
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Decoupling Job Sampling
|
||||||
|
|
||||||
|
Sample jobs independently from parent contexts:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
Queue::before(fn () => Nightwatch::sample(rate: 0.5));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Redaction Configuration
|
||||||
|
|
||||||
|
Redaction modifies captured data to remove or obfuscate sensitive information. Unlike filtering, redaction keeps the event but sanitizes its content.
|
||||||
|
|
||||||
|
### Request Redaction
|
||||||
|
|
||||||
|
**Redact sensitive headers** (automatically redacts: Authorization, Cookie, X-XSRF-TOKEN):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
# Customize redacted headers
|
||||||
|
|
||||||
|
NIGHTWATCH_REDACT_HEADERS=Authorization,Cookie,Proxy-Authorization,X-API-Key
|
||||||
|
```
|
||||||
|
|
||||||
|
**Redact request payloads** (disabled by default):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
# Enable payload capture
|
||||||
|
|
||||||
|
NIGHTWATCH_CAPTURE_REQUEST_PAYLOAD=true
|
||||||
|
|
||||||
|
# Customize redacted fields
|
||||||
|
|
||||||
|
NIGHTWATCH_REDACT_PAYLOAD_FIELDS=password,password_confirmation,ssn,credit_card
|
||||||
|
```
|
||||||
|
|
||||||
|
**Programmatic redaction**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Facades\Nightwatch;
|
||||||
|
use Laravel\Nightwatch\Records\Request;
|
||||||
|
|
||||||
|
Nightwatch::redactRequests(function (Request $request) {
|
||||||
|
$request->url = str_replace('secret', '***', $request->url);
|
||||||
|
$request->ip = preg_replace('/\d+$/', '***', $request->ip);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Redaction
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\Query;
|
||||||
|
|
||||||
|
Nightwatch::redactQueries(function (Query $query) {
|
||||||
|
$query->sql = str_replace('secret_token', '***', $query->sql);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cache Redaction
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\CacheEvent;
|
||||||
|
|
||||||
|
Nightwatch::redactCacheEvents(function (CacheEvent $cacheEvent) {
|
||||||
|
$cacheEvent->key = str_replace('user:', 'user:***:', $cacheEvent->key);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Command Redaction
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\Command;
|
||||||
|
|
||||||
|
Nightwatch::redactCommands(function (Command $command) {
|
||||||
|
$command->command = preg_replace('/--password=\S+/', '--password=***', $command->command);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exception Redaction
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\Exception;
|
||||||
|
|
||||||
|
Nightwatch::redactExceptions(function (Exception $exception) {
|
||||||
|
$exception->message = str_replace('secret', '***', $exception->message);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mail Redaction
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\Mail;
|
||||||
|
|
||||||
|
Nightwatch::redactMail(function (Mail $mail) {
|
||||||
|
$mail->subject = str_replace('Invoice #', 'Invoice ***', $mail->subject);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Outgoing Request Redaction
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\OutgoingRequest;
|
||||||
|
|
||||||
|
Nightwatch::redactOutgoingRequests(function (OutgoingRequest $outgoingRequest) {
|
||||||
|
$outgoingRequest->url = preg_replace('/api_key=\w+/', 'api_key=***', $outgoingRequest->url);
|
||||||
|
});
|
||||||
|
```
|
||||||
108
.claude/skills/configure-nightwatch/reference.md
Normal file
108
.claude/skills/configure-nightwatch/reference.md
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
# Nightwatch Configuration Reference
|
||||||
|
|
||||||
|
## Configuration Summary by Event Type
|
||||||
|
|
||||||
|
| Event Type | Sampling | Filtering | Redaction |
|
||||||
|
| --------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------- |
|
||||||
|
| **Requests** | `NIGHTWATCH_REQUEST_SAMPLE_RATE`, Route middleware | Not applicable | Headers, payload, URL, IP |
|
||||||
|
| **Commands** | `NIGHTWATCH_COMMAND_SAMPLE_RATE`, Event listener | Not applicable | Command arguments |
|
||||||
|
| **Queries** | Parent context | `rejectQueries()`, `NIGHTWATCH_IGNORE_QUERIES` | SQL statement |
|
||||||
|
| **Cache** | Parent context | `rejectCacheKeys()`, `rejectCacheEvents()`, `NIGHTWATCH_IGNORE_CACHE_EVENTS` | Cache key |
|
||||||
|
| **Jobs** | Parent context, Queue::before | `rejectQueuedJobs()` | Not applicable |
|
||||||
|
| **Mail** | Parent context | `rejectMail()`, `NIGHTWATCH_IGNORE_MAIL` | Subject |
|
||||||
|
| **Notifications** | Parent context | `rejectNotifications()`, `NIGHTWATCH_IGNORE_NOTIFICATIONS` | Not applicable |
|
||||||
|
| **Outgoing Requests** | Parent context | `rejectOutgoingRequests()`, `NIGHTWATCH_IGNORE_OUTGOING_REQUESTS` | URL |
|
||||||
|
| **Exceptions** | `NIGHTWATCH_EXCEPTION_SAMPLE_RATE` | Not applicable | Exception message |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production Recommendations
|
||||||
|
|
||||||
|
### High-Traffic Applications
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
# Conservative sampling
|
||||||
|
|
||||||
|
NIGHTWATCH_REQUEST_SAMPLE_RATE=0.01 # 1% of requests
|
||||||
|
|
||||||
|
NIGHTWATCH_COMMAND_SAMPLE_RATE=0.1 # 10% of commands
|
||||||
|
|
||||||
|
NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0 # Always capture exceptions
|
||||||
|
|
||||||
|
# Filter noisy events
|
||||||
|
|
||||||
|
NIGHTWATCH_IGNORE_CACHE_EVENTS=true
|
||||||
|
NIGHTWATCH_IGNORE_QUERIES=true # Or filter specific queries programmatically
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### Privacy-Conscious Applications
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
# Disable sensitive data collection
|
||||||
|
|
||||||
|
NIGHTWATCH_CAPTURE_REQUEST_PAYLOAD=false
|
||||||
|
NIGHTWATCH_REDACT_HEADERS=Authorization,Cookie,Proxy-Authorization,X-XSRF-TOKEN
|
||||||
|
|
||||||
|
# Or use redaction in AppServiceProvider
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### Balanced Configuration (Recommended Start)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
# Sample rates
|
||||||
|
|
||||||
|
NIGHTWATCH_REQUEST_SAMPLE_RATE=0.1
|
||||||
|
NIGHTWATCH_COMMAND_SAMPLE_RATE=1.0
|
||||||
|
NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0
|
||||||
|
|
||||||
|
# Filter obvious noise programmatically
|
||||||
|
|
||||||
|
# Redact PII as needed
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Checklist
|
||||||
|
|
||||||
|
After configuration:
|
||||||
|
|
||||||
|
- [ ] Sampling rates appropriate for traffic volume
|
||||||
|
- [ ] Noisy events filtered (cache, certain queries)
|
||||||
|
- [ ] Sensitive data redacted (PII, tokens, credentials)
|
||||||
|
- [ ] Exceptions always captured for debugging
|
||||||
|
- [ ] Test in development with `NIGHTWATCH_REQUEST_SAMPLE_RATE=1.0`
|
||||||
|
- [ ] Monitor event quota usage in Nightwatch dashboard
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Filter Health Checks + Reduce Sampling
|
||||||
|
|
||||||
|
```php
|
||||||
|
Route::get('/health', fn() => ['status' => 'ok'])
|
||||||
|
->middleware(Sample::never());
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exclude Internal/Vendor Queries
|
||||||
|
|
||||||
|
```php
|
||||||
|
Nightwatch::rejectQueries(fn($q) =>
|
||||||
|
str_contains($q->sql, 'telescope') ||
|
||||||
|
str_contains($q->sql, 'pulse')
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Protect User Data in Cache Keys
|
||||||
|
|
||||||
|
```php
|
||||||
|
Nightwatch::redactCacheEvents(fn($e) =>
|
||||||
|
$e->key = preg_replace('/user:\d+/', 'user:***', $e->key)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
name: fortify-development
|
name: fortify-development
|
||||||
description: 'ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.'
|
description: 'ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), passkeys, profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, passkeys, WebAuthn, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.'
|
||||||
license: MIT
|
license: MIT
|
||||||
metadata:
|
metadata:
|
||||||
author: laravel
|
author: laravel
|
||||||
|
|
@ -32,6 +32,7 @@ ## Available Features
|
||||||
- `Features::updateProfileInformation()` - Profile updates
|
- `Features::updateProfileInformation()` - Profile updates
|
||||||
- `Features::updatePasswords()` - Password changes
|
- `Features::updatePasswords()` - Password changes
|
||||||
- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes
|
- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes
|
||||||
|
- `Features::passkeys()` - Passwordless authentication with WebAuthn passkeys
|
||||||
|
|
||||||
> Use `search-docs` for feature configuration options and customization patterns.
|
> Use `search-docs` for feature configuration options and customization patterns.
|
||||||
|
|
||||||
|
|
@ -50,6 +51,18 @@ ### Two-Factor Authentication Setup
|
||||||
|
|
||||||
> Use `search-docs` for TOTP implementation and recovery code handling patterns.
|
> Use `search-docs` for TOTP implementation and recovery code handling patterns.
|
||||||
|
|
||||||
|
### Passkeys Setup
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] Add PasskeyAuthenticatable trait to User model and implement PasskeyUser
|
||||||
|
- [ ] Enable passkeys feature in config/fortify.php
|
||||||
|
- [ ] If the passkeys table migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate
|
||||||
|
- [ ] Configure passkeys relying_party_id, allowed_origins, user_handle_secret, and timeout if defaults are not suitable
|
||||||
|
- [ ] Build UI with @laravel/passkeys for registration, login, confirmation, and deletion
|
||||||
|
```
|
||||||
|
|
||||||
|
> Use `search-docs` for passkey configuration options. For `@laravel/passkeys` frontend usage, refer to the package's README on npm.
|
||||||
|
|
||||||
### Email Verification Setup
|
### Email Verification Setup
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -129,3 +142,10 @@ ## Key Endpoints
|
||||||
| 2FA Challenge | POST | `/two-factor-challenge` |
|
| 2FA Challenge | POST | `/two-factor-challenge` |
|
||||||
| Get QR Code | GET | `/user/two-factor-qr-code` |
|
| Get QR Code | GET | `/user/two-factor-qr-code` |
|
||||||
| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` |
|
| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` |
|
||||||
|
| Passkey Login Options | GET | `/passkeys/login/options` |
|
||||||
|
| Passkey Login | POST | `/passkeys/login` |
|
||||||
|
| Passkey Confirm Options| GET | `/passkeys/confirm/options` |
|
||||||
|
| Passkey Confirm | POST | `/passkeys/confirm` |
|
||||||
|
| Passkey Options | GET | `/user/passkeys/options` |
|
||||||
|
| Register Passkey | POST | `/user/passkeys` |
|
||||||
|
| Delete Passkey | DELETE | `/user/passkeys/{passkey}` |
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ ### 8. Testing Patterns → `rules/testing.md`
|
||||||
### 9. Queue & Job Patterns → `rules/queue-jobs.md`
|
### 9. Queue & Job Patterns → `rules/queue-jobs.md`
|
||||||
|
|
||||||
- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]`
|
- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]`
|
||||||
- `ShouldBeUnique` to prevent duplicates; `WithoutOverlapping::untilProcessing()` for concurrency
|
- `ShouldBeUnique` to prevent duplicates; `ShouldBeUniqueUntilProcessing` for early lock release
|
||||||
- Always implement `failed()`; with `retryUntil()`, set `$tries = 0`
|
- Always implement `failed()`; with `retryUntil()`, set `$tries = 0`
|
||||||
- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs
|
- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs
|
||||||
- Horizon for complex multi-queue scenarios
|
- Horizon for complex multi-queue scenarios
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ ## Code to Interfaces
|
||||||
|
|
||||||
## Default Sort by Descending
|
## Default Sort by Descending
|
||||||
|
|
||||||
When no explicit order is specified, sort by `id` or `created_at` descending. Explicit ordering prevents cross-database inconsistencies between MySQL and Postgres.
|
When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined.
|
||||||
|
|
||||||
Incorrect:
|
Incorrect:
|
||||||
```php
|
```php
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ # Caching Best Practices
|
||||||
|
|
||||||
## Use `Cache::remember()` Instead of Manual Get/Put
|
## Use `Cache::remember()` Instead of Manual Get/Put
|
||||||
|
|
||||||
Atomic pattern prevents race conditions and removes boilerplate.
|
Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions.
|
||||||
|
|
||||||
Incorrect:
|
Incorrect:
|
||||||
```php
|
```php
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ # Configuration Best Practices
|
||||||
|
|
||||||
## `env()` Only in Config Files
|
## `env()` Only in Config Files
|
||||||
|
|
||||||
Direct `env()` calls return `null` when config is cached.
|
Direct `env()` calls may return `null` when config is cached.
|
||||||
|
|
||||||
Incorrect:
|
Incorrect:
|
||||||
```php
|
```php
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,11 @@ ## Always Queue Notifications
|
||||||
|
|
||||||
## Use `afterCommit()` on Notifications in Transactions
|
## Use `afterCommit()` on Notifications in Transactions
|
||||||
|
|
||||||
Same race condition as events — the queued notification job may run before the transaction commits.
|
Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$user->notify((new InvoicePaid($invoice))->afterCommit());
|
||||||
|
```
|
||||||
|
|
||||||
## Route Notification Channels to Dedicated Queues
|
## Route Notification Channels to Dedicated Queues
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ ## Use Retry with Backoff for External APIs
|
||||||
Only retry on specific errors:
|
Only retry on specific errors:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$response = Http::retry(3, 100, function (Exception $exception, PendingRequest $request) {
|
$response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) {
|
||||||
return $exception instanceof ConnectionException
|
return $exception instanceof ConnectionException
|
||||||
|| ($exception instanceof RequestException && $exception->response->serverError());
|
|| ($exception instanceof RequestException && $exception->response->serverError());
|
||||||
})->post('https://api.example.com/data');
|
})->post('https://api.example.com/data');
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ ## Use `afterCommit()` on Mailables Inside Transactions
|
||||||
|
|
||||||
## Use `assertQueued()` Not `assertSent()` for Queued Mailables
|
## Use `assertQueued()` Not `assertSent()` for Queued Mailables
|
||||||
|
|
||||||
`Mail::assertSent()` only catches synchronous mail. Queued mailables silently pass `assertSent`, giving false confidence.
|
`Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint.
|
||||||
|
|
||||||
Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`.
|
Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -106,25 +106,23 @@ ## `retryUntil()` Needs `$tries = 0`
|
||||||
```php
|
```php
|
||||||
public $tries = 0;
|
public $tries = 0;
|
||||||
|
|
||||||
public function retryUntil(): DateTime
|
public function retryUntil(): \DateTimeInterface
|
||||||
{
|
{
|
||||||
return now()->addHours(4);
|
return now()->addHours(4);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Use `WithoutOverlapping::untilProcessing()`
|
## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release
|
||||||
|
|
||||||
Prevents concurrent execution while allowing new instances to queue.
|
`ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue.
|
||||||
|
|
||||||
```php
|
```php
|
||||||
public function middleware(): array
|
class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing
|
||||||
{
|
{
|
||||||
return [new WithoutOverlapping($this->product->id)->untilProcessing()];
|
// Lock releases when processing begins, not when it finishes
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Without `untilProcessing()`, the lock extends through queue wait time. With it, the lock releases when processing starts.
|
|
||||||
|
|
||||||
## Use Horizon for Complex Queue Scenarios
|
## Use Horizon for Complex Queue Scenarios
|
||||||
|
|
||||||
Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities.
|
Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities.
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,8 @@ ## Use Resource Controllers
|
||||||
|
|
||||||
```php
|
```php
|
||||||
Route::resource('posts', PostController::class);
|
Route::resource('posts', PostController::class);
|
||||||
Route::apiResource('api/posts', Api\PostController::class);
|
// In routes/api.php — the /api prefix is applied automatically
|
||||||
|
Route::apiResource('posts', Api\PostController::class);
|
||||||
```
|
```
|
||||||
|
|
||||||
## Keep Controllers Thin
|
## Keep Controllers Thin
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ ## Authorize Every Action
|
||||||
|
|
||||||
Incorrect:
|
Incorrect:
|
||||||
```php
|
```php
|
||||||
public function update(Request $request, Post $post)
|
public function update(UpdatePostRequest $request, Post $post)
|
||||||
{
|
{
|
||||||
$post->update($request->validated());
|
$post->update($request->validated());
|
||||||
}
|
}
|
||||||
|
|
@ -90,7 +90,7 @@ ## Escape Output to Prevent XSS
|
||||||
|
|
||||||
## CSRF Protection
|
## CSRF Protection
|
||||||
|
|
||||||
Include `@csrf` in all POST/PUT/DELETE Blade forms. Not needed in Inertia.
|
Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied.
|
||||||
|
|
||||||
Incorrect:
|
Incorrect:
|
||||||
```blade
|
```blade
|
||||||
|
|
@ -121,7 +121,7 @@ ## Rate Limit Auth and API Routes
|
||||||
|
|
||||||
## Validate File Uploads
|
## Validate File Uploads
|
||||||
|
|
||||||
Validate MIME type, extension, and size. Never trust client-provided filenames.
|
Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames.
|
||||||
|
|
||||||
```php
|
```php
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -2,7 +2,7 @@ # Testing Best Practices
|
||||||
|
|
||||||
## Use `LazilyRefreshDatabase` Over `RefreshDatabase`
|
## Use `LazilyRefreshDatabase` Over `RefreshDatabase`
|
||||||
|
|
||||||
`RefreshDatabase` runs all migrations every test run even when the schema hasn't changed. `LazilyRefreshDatabase` only migrates when needed, significantly speeding up large suites.
|
`RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date.
|
||||||
|
|
||||||
## Use Model Assertions Over Raw Database Assertions
|
## Use Model Assertions Over Raw Database Assertions
|
||||||
|
|
||||||
|
|
|
||||||
96
.claude/skills/mcp-development/SKILL.md
Normal file
96
.claude/skills/mcp-development/SKILL.md
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
---
|
||||||
|
name: mcp-development
|
||||||
|
description: "Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP."
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
|
||||||
|
# MCP Development
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Use `search-docs` for detailed Laravel MCP patterns and documentation.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
Register MCP servers in `routes/ai.php`:
|
||||||
|
|
||||||
|
<!-- Register MCP Server -->
|
||||||
|
```php
|
||||||
|
use Laravel\Mcp\Facades\Mcp;
|
||||||
|
|
||||||
|
Mcp::web();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating MCP Primitives
|
||||||
|
|
||||||
|
Create MCP tools, resources, prompts, and servers using artisan commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan make:mcp-tool ToolName # Create a tool
|
||||||
|
|
||||||
|
php artisan make:mcp-resource ResourceName # Create a resource
|
||||||
|
|
||||||
|
php artisan make:mcp-prompt PromptName # Create a prompt
|
||||||
|
|
||||||
|
php artisan make:mcp-server ServerName # Create a server
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
After creating primitives, register them in your server's `$tools`, `$resources`, or `$prompts` properties.
|
||||||
|
|
||||||
|
### Tools
|
||||||
|
|
||||||
|
<!-- MCP Tool Example -->
|
||||||
|
```php
|
||||||
|
use Laravel\Mcp\Server\Tool;
|
||||||
|
use Laravel\Mcp\Server\Request;
|
||||||
|
use Laravel\Mcp\Server\Response;
|
||||||
|
|
||||||
|
class MyTool extends Tool
|
||||||
|
{
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return new Response(['result' => 'success']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registering Primitives in a Server
|
||||||
|
|
||||||
|
Each MCP server must explicitly declare the tools, resources, and prompts it exposes.
|
||||||
|
|
||||||
|
<!-- Register Primitives in MCP Server -->
|
||||||
|
```php
|
||||||
|
use Laravel\Mcp\Server;
|
||||||
|
|
||||||
|
class AppServer extends Server
|
||||||
|
{
|
||||||
|
protected array $tools = [
|
||||||
|
\App\Mcp\Tools\MyTool::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
protected array $resources = [
|
||||||
|
\App\Mcp\Resources\MyResource::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
protected array $prompts = [
|
||||||
|
\App\Mcp\Prompts\MyPrompt::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Check `routes/ai.php` for proper registration
|
||||||
|
2. Test tool via MCP client
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
- Running `mcp:start` command (it hangs waiting for input)
|
||||||
|
- Using HTTPS locally with Node-based MCP clients
|
||||||
|
- Not using `search-docs` for the latest MCP documentation
|
||||||
|
- Not registering MCP server routes in `routes/ai.php`
|
||||||
|
- Do not register `ai.php` in `bootstrap.php`; it is registered automatically.
|
||||||
|
- OAuth registration supports custom URI schemes (e.g., `cursor://`, `vscode://`) for native desktop clients via `mcp.custom_schemes` config
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
name: pest-testing
|
name: pest-testing
|
||||||
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
|
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
|
||||||
license: MIT
|
license: MIT
|
||||||
metadata:
|
metadata:
|
||||||
author: laravel
|
author: laravel
|
||||||
|
|
@ -18,6 +18,12 @@ ### Creating Tests
|
||||||
|
|
||||||
All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
|
All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
|
||||||
|
|
||||||
|
The `{name}` argument should include only the path and test name, but should not include the test suite.
|
||||||
|
- Incorrect: `php artisan make:test --pest Feature/SomeFeatureTest` will generate `tests/Feature/Feature/SomeFeatureTest.php`
|
||||||
|
- Correct: `php artisan make:test --pest SomeControllerTest` will generate `tests/Feature/SomeControllerTest.php`
|
||||||
|
- Incorrect: `php artisan make:test --pest --unit Unit/SomeServiceTest` will generate `tests/Unit/Unit/SomeServiceTest.php`
|
||||||
|
- Correct: `php artisan make:test --pest --unit SomeServiceTest` will generate `tests/Unit/SomeServiceTest.php`
|
||||||
|
|
||||||
### Test Organization
|
### Test Organization
|
||||||
|
|
||||||
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
|
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
|
||||||
|
|
@ -26,6 +32,8 @@ ### Test Organization
|
||||||
|
|
||||||
### Basic Test Structure
|
### Basic Test Structure
|
||||||
|
|
||||||
|
Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`.
|
||||||
|
|
||||||
<!-- Basic Pest Test Example -->
|
<!-- Basic Pest Test Example -->
|
||||||
```php
|
```php
|
||||||
it('is true', function () {
|
it('is true', function () {
|
||||||
|
|
@ -155,3 +163,4 @@ ## Common Pitfalls
|
||||||
- Forgetting datasets for repetitive validation tests
|
- Forgetting datasets for repetitive validation tests
|
||||||
- Deleting tests without approval
|
- Deleting tests without approval
|
||||||
- Forgetting `assertNoJavaScriptErrors()` in browser tests
|
- Forgetting `assertNoJavaScriptErrors()` in browser tests
|
||||||
|
- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test`
|
||||||
|
|
|
||||||
404
.cursor/skills/configure-nightwatch/SKILL.md
Normal file
404
.cursor/skills/configure-nightwatch/SKILL.md
Normal file
|
|
@ -0,0 +1,404 @@
|
||||||
|
---
|
||||||
|
name: configure-nightwatch
|
||||||
|
description: Configures Laravel Nightwatch data collection, sampling rates, filtering rules, and redaction policies. Use when setting up Nightwatch, managing data volume, protecting sensitive data (PII), or optimizing event collection for production workloads.
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
|
||||||
|
# Nightwatch Configuration Guide
|
||||||
|
|
||||||
|
This skill helps configure Laravel Nightwatch data collection to balance observability, performance, and privacy. Covers sampling strategies, filtering rules, and redaction methods across all event types.
|
||||||
|
|
||||||
|
## Documentation Reference
|
||||||
|
|
||||||
|
The [Nightwatch Documentation](https://nightwatch.laravel.com/docs) is the definitive and up-to-date source of information for all Nightwatch configuration options. This skill provides practical guidance and common patterns, but always consult the official documentation as the primary source of truth for specific details, environment variables, and API behavior. The documentation includes comprehensive coverage of:
|
||||||
|
|
||||||
|
- [Filtering and Configuration](https://nightwatch.laravel.com/docs/filtering) - Core concepts for sampling, filtering, and redaction
|
||||||
|
- Individual event type pages with specific configuration options:
|
||||||
|
- [Requests](https://nightwatch.laravel.com/docs/requests) - Request sampling, header handling, payload capture
|
||||||
|
- [Commands](https://nightwatch.laravel.com/docs/commands) - Command sampling and redaction
|
||||||
|
- [Queries](https://nightwatch.laravel.com/docs/queries) - Query filtering and redaction
|
||||||
|
- [Cache](https://nightwatch.laravel.com/docs/cache) - Cache event filtering by key or pattern
|
||||||
|
- [Jobs](https://nightwatch.laravel.com/docs/jobs) - Job filtering and sampling decoupling
|
||||||
|
- [Mail](https://nightwatch.laravel.com/docs/mail) - Mail event filtering
|
||||||
|
- [Notifications](https://nightwatch.laravel.com/docs/notifications) - Notification filtering by channel
|
||||||
|
- [Exceptions](https://nightwatch.laravel.com/docs/exceptions) - Exception sampling and throttling
|
||||||
|
- [Outgoing Requests](https://nightwatch.laravel.com/docs/outgoing-requests) - HTTP request filtering
|
||||||
|
- [reference.md](reference.md) - Quick lookup table by event type, production presets, and verification checklist
|
||||||
|
|
||||||
|
## Data Collection Flow
|
||||||
|
|
||||||
|
Nightwatch processes events through three stages:
|
||||||
|
|
||||||
|
1. **Sampling** - Controls which entry points are captured (requests, commands, scheduled tasks)
|
||||||
|
2. **Filtering** - Excludes specific events after sampling (queries, cache, mail, etc.)
|
||||||
|
3. **Redaction** - Modifies captured data to remove/obfuscate sensitive information
|
||||||
|
|
||||||
|
```
|
||||||
|
Request/Command/Scheduled Task
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[Sampling?] ----NO----> Drop entire trace
|
||||||
|
| YES
|
||||||
|
v
|
||||||
|
Events generated
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[Filtering?] ----YES---> Drop specific event
|
||||||
|
| NO
|
||||||
|
v
|
||||||
|
[Redaction] ----------> Store modified data
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sampling Configuration
|
||||||
|
|
||||||
|
Sampling determines which entry points (requests, commands, scheduled tasks) trigger full trace collection. When an entry point is sampled, all related events are captured.
|
||||||
|
|
||||||
|
### Global Sample Rates
|
||||||
|
|
||||||
|
Configure via environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
# Default: 100% sampling (all requests/commands captured)
|
||||||
|
|
||||||
|
NIGHTWATCH_REQUEST_SAMPLE_RATE=0.1 # Recommended: 10% of requests
|
||||||
|
|
||||||
|
NIGHTWATCH_COMMAND_SAMPLE_RATE=1.0 # Capture all commands
|
||||||
|
|
||||||
|
NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0 # Always capture exceptions
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recommendation**: Start with `0.1` (10%) for requests in production, adjust based on volume and needs.
|
||||||
|
|
||||||
|
### Route-Based Sampling
|
||||||
|
|
||||||
|
Apply different rates to specific routes using the `Sample` middleware:
|
||||||
|
|
||||||
|
```php routes/web.php
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Laravel\Nightwatch\Http\Middleware\Sample;
|
||||||
|
|
||||||
|
// Sample admin routes at 100%
|
||||||
|
Route::middleware(Sample::rate(1.0))->prefix('admin')->group(function () {
|
||||||
|
// All admin routes sampled fully
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sample API routes at 5%
|
||||||
|
Route::middleware(Sample::rate(0.05))->prefix('api')->group(function () {
|
||||||
|
// API routes sampled sparingly
|
||||||
|
});
|
||||||
|
|
||||||
|
// Always sample critical endpoints
|
||||||
|
Route::post('/checkout', [CheckoutController::class, 'process'])
|
||||||
|
->middleware(Sample::always());
|
||||||
|
|
||||||
|
// Never sample health checks
|
||||||
|
Route::get('/health', [HealthController::class, 'check'])
|
||||||
|
->middleware(Sample::never());
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unmatched Route Sampling
|
||||||
|
|
||||||
|
Handle 404/bot traffic with reduced sampling:
|
||||||
|
|
||||||
|
```php routes/web.php
|
||||||
|
Route::fallback(fn () => abort(404))
|
||||||
|
->middleware(Sample::rate(0.01)); // 1% sampling for unmatched routes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dynamic Sampling
|
||||||
|
|
||||||
|
Sample based on runtime conditions (user role, request attributes):
|
||||||
|
|
||||||
|
```php app/Http/Middleware/SampleAdminRequests.php
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Laravel\Nightwatch\Facades\Nightwatch;
|
||||||
|
|
||||||
|
class SampleAdminRequests
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next)
|
||||||
|
{
|
||||||
|
if ($request->user()?->isAdmin()) {
|
||||||
|
Nightwatch::sample(); // Always sample admin requests
|
||||||
|
}
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Command Sampling
|
||||||
|
|
||||||
|
Exclude specific commands from sampling:
|
||||||
|
|
||||||
|
```php AppServiceProvider.php
|
||||||
|
use Illuminate\Console\Events\CommandStarting;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Laravel\Nightwatch\Facades\Nightwatch;
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
Event::listen(function (CommandStarting $event) {
|
||||||
|
if (in_array($event->command, ['schedule:finish', 'horizon:snapshot'])) {
|
||||||
|
Nightwatch::dontSample();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vendor Commands
|
||||||
|
|
||||||
|
Nightwatch automatically ignores framework/internal commands. Opt-in to capture them:
|
||||||
|
|
||||||
|
```php
|
||||||
|
Nightwatch::captureDefaultVendorCommands();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Filtering Configuration
|
||||||
|
|
||||||
|
Filtering excludes specific events from collection after sampling. Use filtering to reduce noise and quota usage.
|
||||||
|
|
||||||
|
### Database Queries
|
||||||
|
|
||||||
|
**Filter all queries** (disable query collection):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NIGHTWATCH_IGNORE_QUERIES=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filter specific queries** by SQL pattern:
|
||||||
|
|
||||||
|
```php AppServiceProvider.php
|
||||||
|
use Laravel\Nightwatch\Facades\Nightwatch;
|
||||||
|
use Laravel\Nightwatch\Records\Query;
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
// Filter job table queries (PostgreSQL)
|
||||||
|
Nightwatch::rejectQueries(function (Query $query) {
|
||||||
|
return str_contains($query->sql, 'into "jobs"');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter cache table queries (MySQL)
|
||||||
|
Nightwatch::rejectQueries(function (Query $query) {
|
||||||
|
return str_contains($query->sql, 'from `cache`')
|
||||||
|
|| str_contains($query->sql, 'into `cache`');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cache Events
|
||||||
|
|
||||||
|
**Filter all cache events**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NIGHTWATCH_IGNORE_CACHE_EVENTS=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filter by cache key patterns**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
Nightwatch::rejectCacheKeys([
|
||||||
|
'my-app:users', // Exact match
|
||||||
|
'/^my-app:posts:/', // Regex: starts with my-app:posts:
|
||||||
|
'/^[a-zA-Z0-9]{40}$/', // Regex: session IDs
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filter with callback**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\CacheEvent;
|
||||||
|
|
||||||
|
Nightwatch::rejectCacheEvents(function (CacheEvent $cacheEvent) {
|
||||||
|
return str_starts_with($cacheEvent->key, 'temp:');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mail Events
|
||||||
|
|
||||||
|
**Filter all mail**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NIGHTWATCH_IGNORE_MAIL=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filter specific mail**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\Mail;
|
||||||
|
|
||||||
|
Nightwatch::rejectMail(function (Mail $mail) {
|
||||||
|
return str_contains($mail->subject, 'Newsletter');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notification Events
|
||||||
|
|
||||||
|
**Filter all notifications**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NIGHTWATCH_IGNORE_NOTIFICATIONS=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filter by channel**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\Notification;
|
||||||
|
|
||||||
|
Nightwatch::rejectNotifications(function (Notification $notification) {
|
||||||
|
return $notification->channel === 'database';
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Outgoing HTTP Requests
|
||||||
|
|
||||||
|
**Filter all outgoing requests**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NIGHTWATCH_IGNORE_OUTGOING_REQUESTS=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filter by URL**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\OutgoingRequest;
|
||||||
|
|
||||||
|
Nightwatch::rejectOutgoingRequests(function (OutgoingRequest $request) {
|
||||||
|
return str_contains($request->url, 'analytics.example.com');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Queued Jobs
|
||||||
|
|
||||||
|
**Filter specific jobs**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\QueuedJob;
|
||||||
|
|
||||||
|
Nightwatch::rejectQueuedJobs(function (QueuedJob $job) {
|
||||||
|
return $job->name === 'App\Jobs\LowPriorityJob';
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Decoupling Job Sampling
|
||||||
|
|
||||||
|
Sample jobs independently from parent contexts:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
Queue::before(fn () => Nightwatch::sample(rate: 0.5));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Redaction Configuration
|
||||||
|
|
||||||
|
Redaction modifies captured data to remove or obfuscate sensitive information. Unlike filtering, redaction keeps the event but sanitizes its content.
|
||||||
|
|
||||||
|
### Request Redaction
|
||||||
|
|
||||||
|
**Redact sensitive headers** (automatically redacts: Authorization, Cookie, X-XSRF-TOKEN):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
# Customize redacted headers
|
||||||
|
|
||||||
|
NIGHTWATCH_REDACT_HEADERS=Authorization,Cookie,Proxy-Authorization,X-API-Key
|
||||||
|
```
|
||||||
|
|
||||||
|
**Redact request payloads** (disabled by default):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
# Enable payload capture
|
||||||
|
|
||||||
|
NIGHTWATCH_CAPTURE_REQUEST_PAYLOAD=true
|
||||||
|
|
||||||
|
# Customize redacted fields
|
||||||
|
|
||||||
|
NIGHTWATCH_REDACT_PAYLOAD_FIELDS=password,password_confirmation,ssn,credit_card
|
||||||
|
```
|
||||||
|
|
||||||
|
**Programmatic redaction**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Facades\Nightwatch;
|
||||||
|
use Laravel\Nightwatch\Records\Request;
|
||||||
|
|
||||||
|
Nightwatch::redactRequests(function (Request $request) {
|
||||||
|
$request->url = str_replace('secret', '***', $request->url);
|
||||||
|
$request->ip = preg_replace('/\d+$/', '***', $request->ip);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Redaction
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\Query;
|
||||||
|
|
||||||
|
Nightwatch::redactQueries(function (Query $query) {
|
||||||
|
$query->sql = str_replace('secret_token', '***', $query->sql);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cache Redaction
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\CacheEvent;
|
||||||
|
|
||||||
|
Nightwatch::redactCacheEvents(function (CacheEvent $cacheEvent) {
|
||||||
|
$cacheEvent->key = str_replace('user:', 'user:***:', $cacheEvent->key);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Command Redaction
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\Command;
|
||||||
|
|
||||||
|
Nightwatch::redactCommands(function (Command $command) {
|
||||||
|
$command->command = preg_replace('/--password=\S+/', '--password=***', $command->command);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exception Redaction
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\Exception;
|
||||||
|
|
||||||
|
Nightwatch::redactExceptions(function (Exception $exception) {
|
||||||
|
$exception->message = str_replace('secret', '***', $exception->message);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mail Redaction
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\Mail;
|
||||||
|
|
||||||
|
Nightwatch::redactMail(function (Mail $mail) {
|
||||||
|
$mail->subject = str_replace('Invoice #', 'Invoice ***', $mail->subject);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Outgoing Request Redaction
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Nightwatch\Records\OutgoingRequest;
|
||||||
|
|
||||||
|
Nightwatch::redactOutgoingRequests(function (OutgoingRequest $outgoingRequest) {
|
||||||
|
$outgoingRequest->url = preg_replace('/api_key=\w+/', 'api_key=***', $outgoingRequest->url);
|
||||||
|
});
|
||||||
|
```
|
||||||
108
.cursor/skills/configure-nightwatch/reference.md
Normal file
108
.cursor/skills/configure-nightwatch/reference.md
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
# Nightwatch Configuration Reference
|
||||||
|
|
||||||
|
## Configuration Summary by Event Type
|
||||||
|
|
||||||
|
| Event Type | Sampling | Filtering | Redaction |
|
||||||
|
| --------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------- |
|
||||||
|
| **Requests** | `NIGHTWATCH_REQUEST_SAMPLE_RATE`, Route middleware | Not applicable | Headers, payload, URL, IP |
|
||||||
|
| **Commands** | `NIGHTWATCH_COMMAND_SAMPLE_RATE`, Event listener | Not applicable | Command arguments |
|
||||||
|
| **Queries** | Parent context | `rejectQueries()`, `NIGHTWATCH_IGNORE_QUERIES` | SQL statement |
|
||||||
|
| **Cache** | Parent context | `rejectCacheKeys()`, `rejectCacheEvents()`, `NIGHTWATCH_IGNORE_CACHE_EVENTS` | Cache key |
|
||||||
|
| **Jobs** | Parent context, Queue::before | `rejectQueuedJobs()` | Not applicable |
|
||||||
|
| **Mail** | Parent context | `rejectMail()`, `NIGHTWATCH_IGNORE_MAIL` | Subject |
|
||||||
|
| **Notifications** | Parent context | `rejectNotifications()`, `NIGHTWATCH_IGNORE_NOTIFICATIONS` | Not applicable |
|
||||||
|
| **Outgoing Requests** | Parent context | `rejectOutgoingRequests()`, `NIGHTWATCH_IGNORE_OUTGOING_REQUESTS` | URL |
|
||||||
|
| **Exceptions** | `NIGHTWATCH_EXCEPTION_SAMPLE_RATE` | Not applicable | Exception message |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production Recommendations
|
||||||
|
|
||||||
|
### High-Traffic Applications
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
# Conservative sampling
|
||||||
|
|
||||||
|
NIGHTWATCH_REQUEST_SAMPLE_RATE=0.01 # 1% of requests
|
||||||
|
|
||||||
|
NIGHTWATCH_COMMAND_SAMPLE_RATE=0.1 # 10% of commands
|
||||||
|
|
||||||
|
NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0 # Always capture exceptions
|
||||||
|
|
||||||
|
# Filter noisy events
|
||||||
|
|
||||||
|
NIGHTWATCH_IGNORE_CACHE_EVENTS=true
|
||||||
|
NIGHTWATCH_IGNORE_QUERIES=true # Or filter specific queries programmatically
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### Privacy-Conscious Applications
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
# Disable sensitive data collection
|
||||||
|
|
||||||
|
NIGHTWATCH_CAPTURE_REQUEST_PAYLOAD=false
|
||||||
|
NIGHTWATCH_REDACT_HEADERS=Authorization,Cookie,Proxy-Authorization,X-XSRF-TOKEN
|
||||||
|
|
||||||
|
# Or use redaction in AppServiceProvider
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### Balanced Configuration (Recommended Start)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
# Sample rates
|
||||||
|
|
||||||
|
NIGHTWATCH_REQUEST_SAMPLE_RATE=0.1
|
||||||
|
NIGHTWATCH_COMMAND_SAMPLE_RATE=1.0
|
||||||
|
NIGHTWATCH_EXCEPTION_SAMPLE_RATE=1.0
|
||||||
|
|
||||||
|
# Filter obvious noise programmatically
|
||||||
|
|
||||||
|
# Redact PII as needed
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Checklist
|
||||||
|
|
||||||
|
After configuration:
|
||||||
|
|
||||||
|
- [ ] Sampling rates appropriate for traffic volume
|
||||||
|
- [ ] Noisy events filtered (cache, certain queries)
|
||||||
|
- [ ] Sensitive data redacted (PII, tokens, credentials)
|
||||||
|
- [ ] Exceptions always captured for debugging
|
||||||
|
- [ ] Test in development with `NIGHTWATCH_REQUEST_SAMPLE_RATE=1.0`
|
||||||
|
- [ ] Monitor event quota usage in Nightwatch dashboard
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Filter Health Checks + Reduce Sampling
|
||||||
|
|
||||||
|
```php
|
||||||
|
Route::get('/health', fn() => ['status' => 'ok'])
|
||||||
|
->middleware(Sample::never());
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exclude Internal/Vendor Queries
|
||||||
|
|
||||||
|
```php
|
||||||
|
Nightwatch::rejectQueries(fn($q) =>
|
||||||
|
str_contains($q->sql, 'telescope') ||
|
||||||
|
str_contains($q->sql, 'pulse')
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Protect User Data in Cache Keys
|
||||||
|
|
||||||
|
```php
|
||||||
|
Nightwatch::redactCacheEvents(fn($e) =>
|
||||||
|
$e->key = preg_replace('/user:\d+/', 'user:***', $e->key)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
name: fortify-development
|
name: fortify-development
|
||||||
description: 'ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.'
|
description: 'ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), passkeys, profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, passkeys, WebAuthn, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.'
|
||||||
license: MIT
|
license: MIT
|
||||||
metadata:
|
metadata:
|
||||||
author: laravel
|
author: laravel
|
||||||
|
|
@ -32,6 +32,7 @@ ## Available Features
|
||||||
- `Features::updateProfileInformation()` - Profile updates
|
- `Features::updateProfileInformation()` - Profile updates
|
||||||
- `Features::updatePasswords()` - Password changes
|
- `Features::updatePasswords()` - Password changes
|
||||||
- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes
|
- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes
|
||||||
|
- `Features::passkeys()` - Passwordless authentication with WebAuthn passkeys
|
||||||
|
|
||||||
> Use `search-docs` for feature configuration options and customization patterns.
|
> Use `search-docs` for feature configuration options and customization patterns.
|
||||||
|
|
||||||
|
|
@ -50,6 +51,18 @@ ### Two-Factor Authentication Setup
|
||||||
|
|
||||||
> Use `search-docs` for TOTP implementation and recovery code handling patterns.
|
> Use `search-docs` for TOTP implementation and recovery code handling patterns.
|
||||||
|
|
||||||
|
### Passkeys Setup
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] Add PasskeyAuthenticatable trait to User model and implement PasskeyUser
|
||||||
|
- [ ] Enable passkeys feature in config/fortify.php
|
||||||
|
- [ ] If the passkeys table migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate
|
||||||
|
- [ ] Configure passkeys relying_party_id, allowed_origins, user_handle_secret, and timeout if defaults are not suitable
|
||||||
|
- [ ] Build UI with @laravel/passkeys for registration, login, confirmation, and deletion
|
||||||
|
```
|
||||||
|
|
||||||
|
> Use `search-docs` for passkey configuration options. For `@laravel/passkeys` frontend usage, refer to the package's README on npm.
|
||||||
|
|
||||||
### Email Verification Setup
|
### Email Verification Setup
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -129,3 +142,10 @@ ## Key Endpoints
|
||||||
| 2FA Challenge | POST | `/two-factor-challenge` |
|
| 2FA Challenge | POST | `/two-factor-challenge` |
|
||||||
| Get QR Code | GET | `/user/two-factor-qr-code` |
|
| Get QR Code | GET | `/user/two-factor-qr-code` |
|
||||||
| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` |
|
| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` |
|
||||||
|
| Passkey Login Options | GET | `/passkeys/login/options` |
|
||||||
|
| Passkey Login | POST | `/passkeys/login` |
|
||||||
|
| Passkey Confirm Options| GET | `/passkeys/confirm/options` |
|
||||||
|
| Passkey Confirm | POST | `/passkeys/confirm` |
|
||||||
|
| Passkey Options | GET | `/user/passkeys/options` |
|
||||||
|
| Register Passkey | POST | `/user/passkeys` |
|
||||||
|
| Delete Passkey | DELETE | `/user/passkeys/{passkey}` |
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ ### 8. Testing Patterns → `rules/testing.md`
|
||||||
### 9. Queue & Job Patterns → `rules/queue-jobs.md`
|
### 9. Queue & Job Patterns → `rules/queue-jobs.md`
|
||||||
|
|
||||||
- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]`
|
- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]`
|
||||||
- `ShouldBeUnique` to prevent duplicates; `WithoutOverlapping::untilProcessing()` for concurrency
|
- `ShouldBeUnique` to prevent duplicates; `ShouldBeUniqueUntilProcessing` for early lock release
|
||||||
- Always implement `failed()`; with `retryUntil()`, set `$tries = 0`
|
- Always implement `failed()`; with `retryUntil()`, set `$tries = 0`
|
||||||
- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs
|
- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs
|
||||||
- Horizon for complex multi-queue scenarios
|
- Horizon for complex multi-queue scenarios
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ ## Code to Interfaces
|
||||||
|
|
||||||
## Default Sort by Descending
|
## Default Sort by Descending
|
||||||
|
|
||||||
When no explicit order is specified, sort by `id` or `created_at` descending. Explicit ordering prevents cross-database inconsistencies between MySQL and Postgres.
|
When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined.
|
||||||
|
|
||||||
Incorrect:
|
Incorrect:
|
||||||
```php
|
```php
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ # Caching Best Practices
|
||||||
|
|
||||||
## Use `Cache::remember()` Instead of Manual Get/Put
|
## Use `Cache::remember()` Instead of Manual Get/Put
|
||||||
|
|
||||||
Atomic pattern prevents race conditions and removes boilerplate.
|
Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions.
|
||||||
|
|
||||||
Incorrect:
|
Incorrect:
|
||||||
```php
|
```php
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ # Configuration Best Practices
|
||||||
|
|
||||||
## `env()` Only in Config Files
|
## `env()` Only in Config Files
|
||||||
|
|
||||||
Direct `env()` calls return `null` when config is cached.
|
Direct `env()` calls may return `null` when config is cached.
|
||||||
|
|
||||||
Incorrect:
|
Incorrect:
|
||||||
```php
|
```php
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,11 @@ ## Always Queue Notifications
|
||||||
|
|
||||||
## Use `afterCommit()` on Notifications in Transactions
|
## Use `afterCommit()` on Notifications in Transactions
|
||||||
|
|
||||||
Same race condition as events — the queued notification job may run before the transaction commits.
|
Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$user->notify((new InvoicePaid($invoice))->afterCommit());
|
||||||
|
```
|
||||||
|
|
||||||
## Route Notification Channels to Dedicated Queues
|
## Route Notification Channels to Dedicated Queues
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ ## Use Retry with Backoff for External APIs
|
||||||
Only retry on specific errors:
|
Only retry on specific errors:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$response = Http::retry(3, 100, function (Exception $exception, PendingRequest $request) {
|
$response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) {
|
||||||
return $exception instanceof ConnectionException
|
return $exception instanceof ConnectionException
|
||||||
|| ($exception instanceof RequestException && $exception->response->serverError());
|
|| ($exception instanceof RequestException && $exception->response->serverError());
|
||||||
})->post('https://api.example.com/data');
|
})->post('https://api.example.com/data');
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ ## Use `afterCommit()` on Mailables Inside Transactions
|
||||||
|
|
||||||
## Use `assertQueued()` Not `assertSent()` for Queued Mailables
|
## Use `assertQueued()` Not `assertSent()` for Queued Mailables
|
||||||
|
|
||||||
`Mail::assertSent()` only catches synchronous mail. Queued mailables silently pass `assertSent`, giving false confidence.
|
`Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint.
|
||||||
|
|
||||||
Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`.
|
Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -106,25 +106,23 @@ ## `retryUntil()` Needs `$tries = 0`
|
||||||
```php
|
```php
|
||||||
public $tries = 0;
|
public $tries = 0;
|
||||||
|
|
||||||
public function retryUntil(): DateTime
|
public function retryUntil(): \DateTimeInterface
|
||||||
{
|
{
|
||||||
return now()->addHours(4);
|
return now()->addHours(4);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Use `WithoutOverlapping::untilProcessing()`
|
## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release
|
||||||
|
|
||||||
Prevents concurrent execution while allowing new instances to queue.
|
`ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue.
|
||||||
|
|
||||||
```php
|
```php
|
||||||
public function middleware(): array
|
class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing
|
||||||
{
|
{
|
||||||
return [new WithoutOverlapping($this->product->id)->untilProcessing()];
|
// Lock releases when processing begins, not when it finishes
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Without `untilProcessing()`, the lock extends through queue wait time. With it, the lock releases when processing starts.
|
|
||||||
|
|
||||||
## Use Horizon for Complex Queue Scenarios
|
## Use Horizon for Complex Queue Scenarios
|
||||||
|
|
||||||
Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities.
|
Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities.
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,8 @@ ## Use Resource Controllers
|
||||||
|
|
||||||
```php
|
```php
|
||||||
Route::resource('posts', PostController::class);
|
Route::resource('posts', PostController::class);
|
||||||
Route::apiResource('api/posts', Api\PostController::class);
|
// In routes/api.php — the /api prefix is applied automatically
|
||||||
|
Route::apiResource('posts', Api\PostController::class);
|
||||||
```
|
```
|
||||||
|
|
||||||
## Keep Controllers Thin
|
## Keep Controllers Thin
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ ## Authorize Every Action
|
||||||
|
|
||||||
Incorrect:
|
Incorrect:
|
||||||
```php
|
```php
|
||||||
public function update(Request $request, Post $post)
|
public function update(UpdatePostRequest $request, Post $post)
|
||||||
{
|
{
|
||||||
$post->update($request->validated());
|
$post->update($request->validated());
|
||||||
}
|
}
|
||||||
|
|
@ -90,7 +90,7 @@ ## Escape Output to Prevent XSS
|
||||||
|
|
||||||
## CSRF Protection
|
## CSRF Protection
|
||||||
|
|
||||||
Include `@csrf` in all POST/PUT/DELETE Blade forms. Not needed in Inertia.
|
Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied.
|
||||||
|
|
||||||
Incorrect:
|
Incorrect:
|
||||||
```blade
|
```blade
|
||||||
|
|
@ -121,7 +121,7 @@ ## Rate Limit Auth and API Routes
|
||||||
|
|
||||||
## Validate File Uploads
|
## Validate File Uploads
|
||||||
|
|
||||||
Validate MIME type, extension, and size. Never trust client-provided filenames.
|
Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames.
|
||||||
|
|
||||||
```php
|
```php
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -2,7 +2,7 @@ # Testing Best Practices
|
||||||
|
|
||||||
## Use `LazilyRefreshDatabase` Over `RefreshDatabase`
|
## Use `LazilyRefreshDatabase` Over `RefreshDatabase`
|
||||||
|
|
||||||
`RefreshDatabase` runs all migrations every test run even when the schema hasn't changed. `LazilyRefreshDatabase` only migrates when needed, significantly speeding up large suites.
|
`RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date.
|
||||||
|
|
||||||
## Use Model Assertions Over Raw Database Assertions
|
## Use Model Assertions Over Raw Database Assertions
|
||||||
|
|
||||||
|
|
|
||||||
96
.cursor/skills/mcp-development/SKILL.md
Normal file
96
.cursor/skills/mcp-development/SKILL.md
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
---
|
||||||
|
name: mcp-development
|
||||||
|
description: "Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP."
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
|
||||||
|
# MCP Development
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Use `search-docs` for detailed Laravel MCP patterns and documentation.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
Register MCP servers in `routes/ai.php`:
|
||||||
|
|
||||||
|
<!-- Register MCP Server -->
|
||||||
|
```php
|
||||||
|
use Laravel\Mcp\Facades\Mcp;
|
||||||
|
|
||||||
|
Mcp::web();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating MCP Primitives
|
||||||
|
|
||||||
|
Create MCP tools, resources, prompts, and servers using artisan commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan make:mcp-tool ToolName # Create a tool
|
||||||
|
|
||||||
|
php artisan make:mcp-resource ResourceName # Create a resource
|
||||||
|
|
||||||
|
php artisan make:mcp-prompt PromptName # Create a prompt
|
||||||
|
|
||||||
|
php artisan make:mcp-server ServerName # Create a server
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
After creating primitives, register them in your server's `$tools`, `$resources`, or `$prompts` properties.
|
||||||
|
|
||||||
|
### Tools
|
||||||
|
|
||||||
|
<!-- MCP Tool Example -->
|
||||||
|
```php
|
||||||
|
use Laravel\Mcp\Server\Tool;
|
||||||
|
use Laravel\Mcp\Server\Request;
|
||||||
|
use Laravel\Mcp\Server\Response;
|
||||||
|
|
||||||
|
class MyTool extends Tool
|
||||||
|
{
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return new Response(['result' => 'success']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registering Primitives in a Server
|
||||||
|
|
||||||
|
Each MCP server must explicitly declare the tools, resources, and prompts it exposes.
|
||||||
|
|
||||||
|
<!-- Register Primitives in MCP Server -->
|
||||||
|
```php
|
||||||
|
use Laravel\Mcp\Server;
|
||||||
|
|
||||||
|
class AppServer extends Server
|
||||||
|
{
|
||||||
|
protected array $tools = [
|
||||||
|
\App\Mcp\Tools\MyTool::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
protected array $resources = [
|
||||||
|
\App\Mcp\Resources\MyResource::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
protected array $prompts = [
|
||||||
|
\App\Mcp\Prompts\MyPrompt::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Check `routes/ai.php` for proper registration
|
||||||
|
2. Test tool via MCP client
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
- Running `mcp:start` command (it hangs waiting for input)
|
||||||
|
- Using HTTPS locally with Node-based MCP clients
|
||||||
|
- Not using `search-docs` for the latest MCP documentation
|
||||||
|
- Not registering MCP server routes in `routes/ai.php`
|
||||||
|
- Do not register `ai.php` in `bootstrap.php`; it is registered automatically.
|
||||||
|
- OAuth registration supports custom URI schemes (e.g., `cursor://`, `vscode://`) for native desktop clients via `mcp.custom_schemes` config
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
name: pest-testing
|
name: pest-testing
|
||||||
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
|
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
|
||||||
license: MIT
|
license: MIT
|
||||||
metadata:
|
metadata:
|
||||||
author: laravel
|
author: laravel
|
||||||
|
|
@ -18,6 +18,12 @@ ### Creating Tests
|
||||||
|
|
||||||
All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
|
All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
|
||||||
|
|
||||||
|
The `{name}` argument should include only the path and test name, but should not include the test suite.
|
||||||
|
- Incorrect: `php artisan make:test --pest Feature/SomeFeatureTest` will generate `tests/Feature/Feature/SomeFeatureTest.php`
|
||||||
|
- Correct: `php artisan make:test --pest SomeControllerTest` will generate `tests/Feature/SomeControllerTest.php`
|
||||||
|
- Incorrect: `php artisan make:test --pest --unit Unit/SomeServiceTest` will generate `tests/Unit/Unit/SomeServiceTest.php`
|
||||||
|
- Correct: `php artisan make:test --pest --unit SomeServiceTest` will generate `tests/Unit/SomeServiceTest.php`
|
||||||
|
|
||||||
### Test Organization
|
### Test Organization
|
||||||
|
|
||||||
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
|
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
|
||||||
|
|
@ -26,6 +32,8 @@ ### Test Organization
|
||||||
|
|
||||||
### Basic Test Structure
|
### Basic Test Structure
|
||||||
|
|
||||||
|
Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`.
|
||||||
|
|
||||||
<!-- Basic Pest Test Example -->
|
<!-- Basic Pest Test Example -->
|
||||||
```php
|
```php
|
||||||
it('is true', function () {
|
it('is true', function () {
|
||||||
|
|
@ -155,3 +163,4 @@ ## Common Pitfalls
|
||||||
- Forgetting datasets for repetitive validation tests
|
- Forgetting datasets for repetitive validation tests
|
||||||
- Deleting tests without approval
|
- Deleting tests without approval
|
||||||
- Forgetting `assertNoJavaScriptErrors()` in browser tests
|
- Forgetting `assertNoJavaScriptErrors()` in browser tests
|
||||||
|
- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test`
|
||||||
|
|
|
||||||
26
AGENTS.md
26
AGENTS.md
|
|
@ -17,6 +17,7 @@ ## Foundational Context
|
||||||
- laravel/fortify (FORTIFY) - v1
|
- laravel/fortify (FORTIFY) - v1
|
||||||
- laravel/framework (LARAVEL) - v12
|
- laravel/framework (LARAVEL) - v12
|
||||||
- laravel/horizon (HORIZON) - v5
|
- laravel/horizon (HORIZON) - v5
|
||||||
|
- laravel/mcp (MCP) - v0
|
||||||
- laravel/nightwatch (NIGHTWATCH) - v1
|
- laravel/nightwatch (NIGHTWATCH) - v1
|
||||||
- laravel/pail (PAIL) - v1
|
- laravel/pail (PAIL) - v1
|
||||||
- laravel/prompts (PROMPTS) - v0
|
- laravel/prompts (PROMPTS) - v0
|
||||||
|
|
@ -25,29 +26,16 @@ ## Foundational Context
|
||||||
- livewire/livewire (LIVEWIRE) - v3
|
- livewire/livewire (LIVEWIRE) - v3
|
||||||
- laravel/boost (BOOST) - v2
|
- laravel/boost (BOOST) - v2
|
||||||
- laravel/dusk (DUSK) - v8
|
- laravel/dusk (DUSK) - v8
|
||||||
- laravel/mcp (MCP) - v0
|
|
||||||
- laravel/pint (PINT) - v1
|
- laravel/pint (PINT) - v1
|
||||||
- laravel/telescope (TELESCOPE) - v5
|
- laravel/telescope (TELESCOPE) - v5
|
||||||
- pestphp/pest (PEST) - v4
|
- pestphp/pest (PEST) - v4
|
||||||
- phpunit/phpunit (PHPUNIT) - v12
|
- phpunit/phpunit (PHPUNIT) - v12
|
||||||
- rector/rector (RECTOR) - v2
|
- rector/rector (RECTOR) - v2
|
||||||
- laravel-echo (ECHO) - v2
|
|
||||||
- tailwindcss (TAILWINDCSS) - v4
|
- tailwindcss (TAILWINDCSS) - v4
|
||||||
- vue (VUE) - v3
|
|
||||||
|
|
||||||
## Skills Activation
|
## Skills Activation
|
||||||
|
|
||||||
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
|
This project has domain-specific skills available in `**/skills/**`. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
|
||||||
|
|
||||||
- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.
|
|
||||||
- `configuring-horizon` — Use this skill whenever the user mentions Horizon by name in a Laravel context. Covers the full Horizon lifecycle: installing Horizon (horizon:install, Sail setup), configuring config/horizon.php (supervisor blocks, queue assignments, balancing strategies, minProcesses/maxProcesses), fixing the dashboard (authorization via Gate::define viewHorizon, blank metrics, horizon:snapshot scheduling), and troubleshooting production issues (worker crashes, timeout chain ordering, LongWaitDetected notifications, waits config). Also covers job tagging and silencing. Do not use for generic Laravel queues without Horizon, SQS or database drivers, standalone Redis setup, Linux supervisord, Telescope, or job batching.
|
|
||||||
- `socialite-development` — Manages OAuth social authentication with Laravel Socialite. Activate when adding social login providers; configuring OAuth redirect/callback flows; retrieving authenticated user details; customizing scopes or parameters; setting up community providers; testing with Socialite fakes; or when the user mentions social login, OAuth, Socialite, or third-party authentication.
|
|
||||||
- `livewire-development` — Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, loading states, migrating from Livewire 2 to 3, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire.
|
|
||||||
- `pest-testing` — Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code.
|
|
||||||
- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS.
|
|
||||||
- `fortify-development` — ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.
|
|
||||||
- `laravel-actions` — Build, refactor, and troubleshoot Laravel Actions using lorisleiva/laravel-actions. Use when implementing reusable action classes (object/controller/job/listener/command), converting service classes/controllers/jobs into actions, orchestrating workflows via faked actions, or debugging action entrypoints and wiring.
|
|
||||||
- `debugging-output-and-previewing-html-using-ray` — Use when user says "send to Ray," "show in Ray," "debug in Ray," "log to Ray," "display in Ray," or wants to visualize data, debug output, or show diagrams in the Ray desktop application.
|
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|
@ -107,7 +95,6 @@ ## Artisan
|
||||||
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
|
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
|
||||||
- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
|
- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
|
||||||
- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory.
|
- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory.
|
||||||
- To check environment variables, read the `.env` file directly.
|
|
||||||
|
|
||||||
## Tinker
|
## Tinker
|
||||||
|
|
||||||
|
|
@ -122,10 +109,16 @@ # PHP
|
||||||
- Always use curly braces for control structures, even for single-line bodies.
|
- Always use curly braces for control structures, even for single-line bodies.
|
||||||
- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private.
|
- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private.
|
||||||
- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool`
|
- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool`
|
||||||
- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`.
|
- Follow existing application Enum naming conventions.
|
||||||
- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
|
- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
|
||||||
- Use array shape type definitions in PHPDoc blocks.
|
- Use array shape type definitions in PHPDoc blocks.
|
||||||
|
|
||||||
|
=== deployments rules ===
|
||||||
|
|
||||||
|
# Deployment
|
||||||
|
|
||||||
|
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
|
||||||
|
|
||||||
=== tests rules ===
|
=== tests rules ===
|
||||||
|
|
||||||
# Test Enforcement
|
# Test Enforcement
|
||||||
|
|
@ -209,6 +202,7 @@ # Laravel Pint Code Formatter
|
||||||
## Pest
|
## Pest
|
||||||
|
|
||||||
- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`.
|
- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`.
|
||||||
|
- The `{name}` argument should not include the test suite directory. Use `php artisan make:test --pest SomeFeatureTest` instead of `php artisan make:test --pest Feature/SomeFeatureTest`.
|
||||||
- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`.
|
- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`.
|
||||||
- Do NOT delete tests without approval.
|
- Do NOT delete tests without approval.
|
||||||
|
|
||||||
|
|
|
||||||
26
CLAUDE.md
26
CLAUDE.md
|
|
@ -157,6 +157,7 @@ ## Foundational Context
|
||||||
- laravel/fortify (FORTIFY) - v1
|
- laravel/fortify (FORTIFY) - v1
|
||||||
- laravel/framework (LARAVEL) - v12
|
- laravel/framework (LARAVEL) - v12
|
||||||
- laravel/horizon (HORIZON) - v5
|
- laravel/horizon (HORIZON) - v5
|
||||||
|
- laravel/mcp (MCP) - v0
|
||||||
- laravel/nightwatch (NIGHTWATCH) - v1
|
- laravel/nightwatch (NIGHTWATCH) - v1
|
||||||
- laravel/pail (PAIL) - v1
|
- laravel/pail (PAIL) - v1
|
||||||
- laravel/prompts (PROMPTS) - v0
|
- laravel/prompts (PROMPTS) - v0
|
||||||
|
|
@ -165,29 +166,16 @@ ## Foundational Context
|
||||||
- livewire/livewire (LIVEWIRE) - v3
|
- livewire/livewire (LIVEWIRE) - v3
|
||||||
- laravel/boost (BOOST) - v2
|
- laravel/boost (BOOST) - v2
|
||||||
- laravel/dusk (DUSK) - v8
|
- laravel/dusk (DUSK) - v8
|
||||||
- laravel/mcp (MCP) - v0
|
|
||||||
- laravel/pint (PINT) - v1
|
- laravel/pint (PINT) - v1
|
||||||
- laravel/telescope (TELESCOPE) - v5
|
- laravel/telescope (TELESCOPE) - v5
|
||||||
- pestphp/pest (PEST) - v4
|
- pestphp/pest (PEST) - v4
|
||||||
- phpunit/phpunit (PHPUNIT) - v12
|
- phpunit/phpunit (PHPUNIT) - v12
|
||||||
- rector/rector (RECTOR) - v2
|
- rector/rector (RECTOR) - v2
|
||||||
- laravel-echo (ECHO) - v2
|
|
||||||
- tailwindcss (TAILWINDCSS) - v4
|
- tailwindcss (TAILWINDCSS) - v4
|
||||||
- vue (VUE) - v3
|
|
||||||
|
|
||||||
## Skills Activation
|
## Skills Activation
|
||||||
|
|
||||||
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
|
This project has domain-specific skills available in `**/skills/**`. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
|
||||||
|
|
||||||
- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.
|
|
||||||
- `configuring-horizon` — Use this skill whenever the user mentions Horizon by name in a Laravel context. Covers the full Horizon lifecycle: installing Horizon (horizon:install, Sail setup), configuring config/horizon.php (supervisor blocks, queue assignments, balancing strategies, minProcesses/maxProcesses), fixing the dashboard (authorization via Gate::define viewHorizon, blank metrics, horizon:snapshot scheduling), and troubleshooting production issues (worker crashes, timeout chain ordering, LongWaitDetected notifications, waits config). Also covers job tagging and silencing. Do not use for generic Laravel queues without Horizon, SQS or database drivers, standalone Redis setup, Linux supervisord, Telescope, or job batching.
|
|
||||||
- `socialite-development` — Manages OAuth social authentication with Laravel Socialite. Activate when adding social login providers; configuring OAuth redirect/callback flows; retrieving authenticated user details; customizing scopes or parameters; setting up community providers; testing with Socialite fakes; or when the user mentions social login, OAuth, Socialite, or third-party authentication.
|
|
||||||
- `livewire-development` — Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, loading states, migrating from Livewire 2 to 3, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire.
|
|
||||||
- `pest-testing` — Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code.
|
|
||||||
- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS.
|
|
||||||
- `fortify-development` — ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.
|
|
||||||
- `laravel-actions` — Build, refactor, and troubleshoot Laravel Actions using lorisleiva/laravel-actions. Use when implementing reusable action classes (object/controller/job/listener/command), converting service classes/controllers/jobs into actions, orchestrating workflows via faked actions, or debugging action entrypoints and wiring.
|
|
||||||
- `debugging-output-and-previewing-html-using-ray` — Use when user says "send to Ray," "show in Ray," "debug in Ray," "log to Ray," "display in Ray," or wants to visualize data, debug output, or show diagrams in the Ray desktop application.
|
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|
@ -247,7 +235,6 @@ ## Artisan
|
||||||
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
|
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
|
||||||
- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
|
- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
|
||||||
- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory.
|
- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory.
|
||||||
- To check environment variables, read the `.env` file directly.
|
|
||||||
|
|
||||||
## Tinker
|
## Tinker
|
||||||
|
|
||||||
|
|
@ -262,10 +249,16 @@ # PHP
|
||||||
- Always use curly braces for control structures, even for single-line bodies.
|
- Always use curly braces for control structures, even for single-line bodies.
|
||||||
- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private.
|
- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private.
|
||||||
- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool`
|
- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool`
|
||||||
- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`.
|
- Follow existing application Enum naming conventions.
|
||||||
- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
|
- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
|
||||||
- Use array shape type definitions in PHPDoc blocks.
|
- Use array shape type definitions in PHPDoc blocks.
|
||||||
|
|
||||||
|
=== deployments rules ===
|
||||||
|
|
||||||
|
# Deployment
|
||||||
|
|
||||||
|
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
|
||||||
|
|
||||||
=== tests rules ===
|
=== tests rules ===
|
||||||
|
|
||||||
# Test Enforcement
|
# Test Enforcement
|
||||||
|
|
@ -349,6 +342,7 @@ # Laravel Pint Code Formatter
|
||||||
## Pest
|
## Pest
|
||||||
|
|
||||||
- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`.
|
- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`.
|
||||||
|
- The `{name}` argument should not include the test suite directory. Use `php artisan make:test --pest SomeFeatureTest` instead of `php artisan make:test --pest Feature/SomeFeatureTest`.
|
||||||
- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`.
|
- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`.
|
||||||
- Do NOT delete tests without approval.
|
- Do NOT delete tests without approval.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
use App\Models\Subscription;
|
use App\Models\Subscription;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Stripe\Exception\InvalidRequestException;
|
||||||
use Stripe\StripeClient;
|
use Stripe\StripeClient;
|
||||||
|
|
||||||
class CancelSubscription
|
class CancelSubscription
|
||||||
|
|
@ -21,7 +22,7 @@ public function __construct(User $user, bool $isDryRun = false)
|
||||||
$this->isDryRun = $isDryRun;
|
$this->isDryRun = $isDryRun;
|
||||||
|
|
||||||
if (! $isDryRun && isCloud()) {
|
if (! $isDryRun && isCloud()) {
|
||||||
$this->stripe = new StripeClient(config('subscription.stripe_api_key'));
|
$this->stripe = app(StripeClient::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,7 +65,7 @@ public function verifySubscriptionsInStripe(): array
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$stripe = new StripeClient(config('subscription.stripe_api_key'));
|
$stripe = app(StripeClient::class);
|
||||||
$subscriptions = $this->getSubscriptionsPreview();
|
$subscriptions = $this->getSubscriptionsPreview();
|
||||||
|
|
||||||
$verified = collect();
|
$verified = collect();
|
||||||
|
|
@ -88,7 +89,7 @@ public function verifySubscriptionsInStripe(): array
|
||||||
'reason' => "Status in Stripe: {$stripeSubscription->status}",
|
'reason' => "Status in Stripe: {$stripeSubscription->status}",
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
} catch (InvalidRequestException $e) {
|
||||||
// Subscription doesn't exist in Stripe
|
// Subscription doesn't exist in Stripe
|
||||||
$notFound->push([
|
$notFound->push([
|
||||||
'subscription' => $subscription,
|
'subscription' => $subscription,
|
||||||
|
|
@ -181,7 +182,7 @@ public static function cancelById(string $subscriptionId): bool
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$stripe = new StripeClient(config('subscription.stripe_api_key'));
|
$stripe = app(StripeClient::class);
|
||||||
$stripe->subscriptions->cancel($subscriptionId, []);
|
$stripe->subscriptions->cancel($subscriptionId, []);
|
||||||
|
|
||||||
// Update local record if exists
|
// Update local record if exists
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Actions\Stripe;
|
namespace App\Actions\Stripe;
|
||||||
|
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
|
use Stripe\Exception\InvalidRequestException;
|
||||||
use Stripe\StripeClient;
|
use Stripe\StripeClient;
|
||||||
|
|
||||||
class CancelSubscriptionAtPeriodEnd
|
class CancelSubscriptionAtPeriodEnd
|
||||||
|
|
@ -11,7 +12,7 @@ class CancelSubscriptionAtPeriodEnd
|
||||||
|
|
||||||
public function __construct(?StripeClient $stripe = null)
|
public function __construct(?StripeClient $stripe = null)
|
||||||
{
|
{
|
||||||
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
|
$this->stripe = $stripe ?? app(StripeClient::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -47,7 +48,7 @@ public function execute(Team $team): array
|
||||||
\Log::info("Subscription {$subscription->stripe_subscription_id} set to cancel at period end for team {$team->name}");
|
\Log::info("Subscription {$subscription->stripe_subscription_id} set to cancel at period end for team {$team->name}");
|
||||||
|
|
||||||
return ['success' => true, 'error' => null];
|
return ['success' => true, 'error' => null];
|
||||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
} catch (InvalidRequestException $e) {
|
||||||
\Log::error("Stripe cancel at period end error for team {$team->id}: ".$e->getMessage());
|
\Log::error("Stripe cancel at period end error for team {$team->id}: ".$e->getMessage());
|
||||||
|
|
||||||
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
|
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
namespace App\Actions\Stripe;
|
namespace App\Actions\Stripe;
|
||||||
|
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Stripe\Exception\InvalidRequestException;
|
||||||
use Stripe\StripeClient;
|
use Stripe\StripeClient;
|
||||||
|
|
||||||
class RefundSubscription
|
class RefundSubscription
|
||||||
|
|
@ -13,7 +15,7 @@ class RefundSubscription
|
||||||
|
|
||||||
public function __construct(?StripeClient $stripe = null)
|
public function __construct(?StripeClient $stripe = null)
|
||||||
{
|
{
|
||||||
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
|
$this->stripe = $stripe ?? app(StripeClient::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -39,7 +41,7 @@ public function checkEligibility(Team $team): array
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$stripeSubscription = $this->stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
|
$stripeSubscription = $this->stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
|
||||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
} catch (InvalidRequestException $e) {
|
||||||
return $this->ineligible('Subscription not found in Stripe.');
|
return $this->ineligible('Subscription not found in Stripe.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,7 +51,7 @@ public function checkEligibility(Team $team): array
|
||||||
return $this->ineligible("Subscription status is '{$stripeSubscription->status}'.", $currentPeriodEnd);
|
return $this->ineligible("Subscription status is '{$stripeSubscription->status}'.", $currentPeriodEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
$startDate = \Carbon\Carbon::createFromTimestamp($stripeSubscription->start_date);
|
$startDate = Carbon::createFromTimestamp($stripeSubscription->start_date);
|
||||||
$daysSinceStart = (int) $startDate->diffInDays(now());
|
$daysSinceStart = (int) $startDate->diffInDays(now());
|
||||||
$daysRemaining = self::REFUND_WINDOW_DAYS - $daysSinceStart;
|
$daysRemaining = self::REFUND_WINDOW_DAYS - $daysSinceStart;
|
||||||
|
|
||||||
|
|
@ -130,7 +132,7 @@ public function execute(Team $team): array
|
||||||
\Log::info("Refunded and cancelled subscription {$subscription->stripe_subscription_id} for team {$team->name}");
|
\Log::info("Refunded and cancelled subscription {$subscription->stripe_subscription_id} for team {$team->name}");
|
||||||
|
|
||||||
return ['success' => true, 'error' => null];
|
return ['success' => true, 'error' => null];
|
||||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
} catch (InvalidRequestException $e) {
|
||||||
\Log::error("Stripe refund error for team {$team->id}: ".$e->getMessage());
|
\Log::error("Stripe refund error for team {$team->id}: ".$e->getMessage());
|
||||||
|
|
||||||
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
|
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Actions\Stripe;
|
namespace App\Actions\Stripe;
|
||||||
|
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
|
use Stripe\Exception\InvalidRequestException;
|
||||||
use Stripe\StripeClient;
|
use Stripe\StripeClient;
|
||||||
|
|
||||||
class ResumeSubscription
|
class ResumeSubscription
|
||||||
|
|
@ -11,7 +12,7 @@ class ResumeSubscription
|
||||||
|
|
||||||
public function __construct(?StripeClient $stripe = null)
|
public function __construct(?StripeClient $stripe = null)
|
||||||
{
|
{
|
||||||
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
|
$this->stripe = $stripe ?? app(StripeClient::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -43,7 +44,7 @@ public function execute(Team $team): array
|
||||||
\Log::info("Subscription {$subscription->stripe_subscription_id} resumed for team {$team->name}");
|
\Log::info("Subscription {$subscription->stripe_subscription_id} resumed for team {$team->name}");
|
||||||
|
|
||||||
return ['success' => true, 'error' => null];
|
return ['success' => true, 'error' => null];
|
||||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
} catch (InvalidRequestException $e) {
|
||||||
\Log::error("Stripe resume subscription error for team {$team->id}: ".$e->getMessage());
|
\Log::error("Stripe resume subscription error for team {$team->id}: ".$e->getMessage());
|
||||||
|
|
||||||
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
|
return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()];
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ class UpdateSubscriptionQuantity
|
||||||
|
|
||||||
public function __construct(?StripeClient $stripe = null)
|
public function __construct(?StripeClient $stripe = null)
|
||||||
{
|
{
|
||||||
$this->stripe = $stripe ?? new StripeClient(config('subscription.stripe_api_key'));
|
$this->stripe = $stripe ?? app(StripeClient::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
|
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
use Stripe\Exception\InvalidRequestException;
|
||||||
|
use Stripe\StripeClient;
|
||||||
|
|
||||||
class CloudFixSubscription extends Command
|
class CloudFixSubscription extends Command
|
||||||
{
|
{
|
||||||
|
|
@ -31,7 +33,7 @@ class CloudFixSubscription extends Command
|
||||||
*/
|
*/
|
||||||
public function handle()
|
public function handle()
|
||||||
{
|
{
|
||||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
$stripe = app(StripeClient::class);
|
||||||
|
|
||||||
if ($this->option('verify-all')) {
|
if ($this->option('verify-all')) {
|
||||||
return $this->verifyAllActiveSubscriptions($stripe);
|
return $this->verifyAllActiveSubscriptions($stripe);
|
||||||
|
|
@ -111,7 +113,7 @@ public function handle()
|
||||||
/**
|
/**
|
||||||
* Fix canceled subscriptions in the database
|
* Fix canceled subscriptions in the database
|
||||||
*/
|
*/
|
||||||
private function fixCanceledSubscriptions(\Stripe\StripeClient $stripe)
|
private function fixCanceledSubscriptions(StripeClient $stripe)
|
||||||
{
|
{
|
||||||
$isDryRun = $this->option('dry-run');
|
$isDryRun = $this->option('dry-run');
|
||||||
$checkOne = $this->option('one');
|
$checkOne = $this->option('one');
|
||||||
|
|
@ -220,7 +222,7 @@ private function fixCanceledSubscriptions(\Stripe\StripeClient $stripe)
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
} catch (InvalidRequestException $e) {
|
||||||
if ($e->getStripeCode() === 'resource_missing') {
|
if ($e->getStripeCode() === 'resource_missing') {
|
||||||
$toFixCount++;
|
$toFixCount++;
|
||||||
|
|
||||||
|
|
@ -326,7 +328,7 @@ private function fixCanceledSubscriptions(\Stripe\StripeClient $stripe)
|
||||||
/**
|
/**
|
||||||
* Verify all active subscriptions against Stripe API
|
* Verify all active subscriptions against Stripe API
|
||||||
*/
|
*/
|
||||||
private function verifyAllActiveSubscriptions(\Stripe\StripeClient $stripe)
|
private function verifyAllActiveSubscriptions(StripeClient $stripe)
|
||||||
{
|
{
|
||||||
$isDryRun = $this->option('dry-run');
|
$isDryRun = $this->option('dry-run');
|
||||||
$shouldFix = $this->option('fix-verified');
|
$shouldFix = $this->option('fix-verified');
|
||||||
|
|
@ -570,7 +572,7 @@ private function verifyAllActiveSubscriptions(\Stripe\StripeClient $stripe)
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
} catch (InvalidRequestException $e) {
|
||||||
$this->error(' → Error: '.$e->getMessage());
|
$this->error(' → Error: '.$e->getMessage());
|
||||||
|
|
||||||
if ($e->getStripeCode() === 'resource_missing' || $e->getHttpStatus() === 404) {
|
if ($e->getStripeCode() === 'resource_missing' || $e->getHttpStatus() === 404) {
|
||||||
|
|
@ -730,7 +732,7 @@ private function fixSubscription($team, $subscription, $status)
|
||||||
/**
|
/**
|
||||||
* Search for subscriptions by customer ID
|
* Search for subscriptions by customer ID
|
||||||
*/
|
*/
|
||||||
private function searchSubscriptionsByCustomer(\Stripe\StripeClient $stripe, $customerId, $requireActive = false)
|
private function searchSubscriptionsByCustomer(StripeClient $stripe, $customerId, $requireActive = false)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$subscriptions = $stripe->subscriptions->all([
|
$subscriptions = $stripe->subscriptions->all([
|
||||||
|
|
@ -770,7 +772,7 @@ private function searchSubscriptionsByCustomer(\Stripe\StripeClient $stripe, $cu
|
||||||
/**
|
/**
|
||||||
* Search for subscriptions by team member emails
|
* Search for subscriptions by team member emails
|
||||||
*/
|
*/
|
||||||
private function searchSubscriptionsByEmails(\Stripe\StripeClient $stripe, $emails)
|
private function searchSubscriptionsByEmails(StripeClient $stripe, $emails)
|
||||||
{
|
{
|
||||||
$this->line(' → Searching by team member emails...');
|
$this->line(' → Searching by team member emails...');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\Process;
|
||||||
use Symfony\Component\Yaml\Yaml;
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
class Services extends Command
|
class Services extends Command
|
||||||
|
|
@ -77,6 +78,7 @@ private function processFile(string $file): false|array
|
||||||
'category' => $data->get('category'),
|
'category' => $data->get('category'),
|
||||||
'logo' => $data->get('logo', 'svgs/default.webp'),
|
'logo' => $data->get('logo', 'svgs/default.webp'),
|
||||||
'minversion' => $data->get('minversion', '0.0.0'),
|
'minversion' => $data->get('minversion', '0.0.0'),
|
||||||
|
'template_last_updated_at' => $this->templateLastUpdatedAt($file),
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($port = $data->get('port')) {
|
if ($port = $data->get('port')) {
|
||||||
|
|
@ -99,6 +101,26 @@ private function processFile(string $file): false|array
|
||||||
return $payload;
|
return $payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function templateLastUpdatedAt(string $file): ?string
|
||||||
|
{
|
||||||
|
$process = Process::path(base_path())->run([
|
||||||
|
'git',
|
||||||
|
'log',
|
||||||
|
'-1',
|
||||||
|
'--format=%cI',
|
||||||
|
'--',
|
||||||
|
"templates/compose/{$file}",
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($process->failed()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$timestamp = trim($process->output());
|
||||||
|
|
||||||
|
return $timestamp === '' ? null : $timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
private function generateServiceTemplatesWithFqdn(): void
|
private function generateServiceTemplatesWithFqdn(): void
|
||||||
{
|
{
|
||||||
$serviceTemplatesWithFqdn = collect(array_merge(
|
$serviceTemplatesWithFqdn = collect(array_merge(
|
||||||
|
|
@ -155,6 +177,7 @@ private function processFileWithFqdn(string $file): false|array
|
||||||
'category' => $data->get('category'),
|
'category' => $data->get('category'),
|
||||||
'logo' => $data->get('logo', 'svgs/default.webp'),
|
'logo' => $data->get('logo', 'svgs/default.webp'),
|
||||||
'minversion' => $data->get('minversion', '0.0.0'),
|
'minversion' => $data->get('minversion', '0.0.0'),
|
||||||
|
'template_last_updated_at' => $this->templateLastUpdatedAt($file),
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($port = $data->get('port')) {
|
if ($port = $data->get('port')) {
|
||||||
|
|
@ -232,6 +255,7 @@ private function processFileWithFqdnRaw(string $file): false|array
|
||||||
'category' => $data->get('category'),
|
'category' => $data->get('category'),
|
||||||
'logo' => $data->get('logo', 'svgs/default.webp'),
|
'logo' => $data->get('logo', 'svgs/default.webp'),
|
||||||
'minversion' => $data->get('minversion', '0.0.0'),
|
'minversion' => $data->get('minversion', '0.0.0'),
|
||||||
|
'template_last_updated_at' => $this->templateLastUpdatedAt($file),
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($port = $data->get('port')) {
|
if ($port = $data->get('port')) {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
|
class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
|
||||||
{
|
{
|
||||||
|
|
@ -70,16 +71,25 @@ private function handleClosedAction(Application $application): void
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($found) {
|
if ($found) {
|
||||||
ApplicationPullRequestUpdateJob::dispatchSync(
|
try {
|
||||||
application: $application,
|
$this->dispatchPullRequestClosedUpdate($application, $found);
|
||||||
preview: $found,
|
} catch (Throwable $e) {
|
||||||
status: ProcessStatus::CLOSED
|
report($e);
|
||||||
);
|
} finally {
|
||||||
|
CleanupPreviewDeployment::run($application, $this->pullRequestId, $found);
|
||||||
CleanupPreviewDeployment::run($application, $this->pullRequestId, $found);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function dispatchPullRequestClosedUpdate(Application $application, ApplicationPreview $preview): void
|
||||||
|
{
|
||||||
|
ApplicationPullRequestUpdateJob::dispatchSync(
|
||||||
|
application: $application,
|
||||||
|
preview: $preview,
|
||||||
|
status: ProcessStatus::CLOSED
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private function handleOpenAction(Application $application, ?GithubApp $githubApp): void
|
private function handleOpenAction(Application $application, ?GithubApp $githubApp): void
|
||||||
{
|
{
|
||||||
if (! $application->isPRDeployable()) {
|
if (! $application->isPRDeployable()) {
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ public function handle(): void
|
||||||
$data = data_get($this->event, 'data.object');
|
$data = data_get($this->event, 'data.object');
|
||||||
switch ($type) {
|
switch ($type) {
|
||||||
case 'radar.early_fraud_warning.created':
|
case 'radar.early_fraud_warning.created':
|
||||||
$stripe = new StripeClient(config('subscription.stripe_api_key'));
|
$stripe = app(StripeClient::class);
|
||||||
$id = data_get($data, 'id');
|
$id = data_get($data, 'id');
|
||||||
$charge = data_get($data, 'charge');
|
$charge = data_get($data, 'charge');
|
||||||
if ($charge) {
|
if ($charge) {
|
||||||
|
|
@ -100,7 +100,7 @@ public function handle(): void
|
||||||
|
|
||||||
if ($subscription->stripe_subscription_id) {
|
if ($subscription->stripe_subscription_id) {
|
||||||
try {
|
try {
|
||||||
$stripe = new StripeClient(config('subscription.stripe_api_key'));
|
$stripe = app(StripeClient::class);
|
||||||
$stripeSubscription = $stripe->subscriptions->retrieve(
|
$stripeSubscription = $stripe->subscriptions->retrieve(
|
||||||
$subscription->stripe_subscription_id
|
$subscription->stripe_subscription_id
|
||||||
);
|
);
|
||||||
|
|
@ -166,7 +166,7 @@ public function handle(): void
|
||||||
// Verify payment status with Stripe API before sending failure notification
|
// Verify payment status with Stripe API before sending failure notification
|
||||||
if ($paymentIntentId) {
|
if ($paymentIntentId) {
|
||||||
try {
|
try {
|
||||||
$stripe = new StripeClient(config('subscription.stripe_api_key'));
|
$stripe = app(StripeClient::class);
|
||||||
$paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId);
|
$paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId);
|
||||||
|
|
||||||
if (in_array($paymentIntent->status, ['processing', 'succeeded', 'requires_action', 'requires_confirmation'])) {
|
if (in_array($paymentIntent->status, ['processing', 'succeeded', 'requires_action', 'requires_confirmation'])) {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
use Illuminate\Notifications\Messages\MailMessage;
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Stripe\StripeClient;
|
||||||
|
|
||||||
class SubscriptionInvoiceFailedJob implements ShouldBeEncrypted, ShouldQueue
|
class SubscriptionInvoiceFailedJob implements ShouldBeEncrypted, ShouldQueue
|
||||||
{
|
{
|
||||||
|
|
@ -27,7 +28,7 @@ public function handle()
|
||||||
$subscription = $this->team->subscription;
|
$subscription = $this->team->subscription;
|
||||||
if ($subscription && $subscription->stripe_customer_id) {
|
if ($subscription && $subscription->stripe_customer_id) {
|
||||||
try {
|
try {
|
||||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
$stripe = app(StripeClient::class);
|
||||||
|
|
||||||
if ($subscription->stripe_subscription_id) {
|
if ($subscription->stripe_subscription_id) {
|
||||||
$stripeSubscription = $stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
|
$stripeSubscription = $stripe->subscriptions->retrieve($subscription->stripe_subscription_id);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Stripe\StripeClient;
|
||||||
|
|
||||||
class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue
|
class SyncStripeSubscriptionsJob implements ShouldBeEncrypted, ShouldQueue
|
||||||
{
|
{
|
||||||
|
|
@ -33,7 +34,7 @@ public function handle(?\Closure $onProgress = null): array
|
||||||
->where('stripe_invoice_paid', true)
|
->where('stripe_invoice_paid', true)
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
$stripe = app(StripeClient::class);
|
||||||
|
|
||||||
// Bulk fetch all valid subscription IDs from Stripe (active + past_due)
|
// Bulk fetch all valid subscription IDs from Stripe (active + past_due)
|
||||||
$validStripeIds = $this->fetchValidStripeSubscriptionIds($stripe, $onProgress);
|
$validStripeIds = $this->fetchValidStripeSubscriptionIds($stripe, $onProgress);
|
||||||
|
|
@ -123,7 +124,7 @@ public function handle(?\Closure $onProgress = null): array
|
||||||
*
|
*
|
||||||
* @return array{email: string, customer_id: string, subscription_id: string, status: string}|null
|
* @return array{email: string, customer_id: string, subscription_id: string, status: string}|null
|
||||||
*/
|
*/
|
||||||
private function findActiveSubscriptionByEmail(\Stripe\StripeClient $stripe, string $customerId): ?array
|
private function findActiveSubscriptionByEmail(StripeClient $stripe, string $customerId): ?array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$customer = $stripe->customers->retrieve($customerId);
|
$customer = $stripe->customers->retrieve($customerId);
|
||||||
|
|
@ -177,7 +178,7 @@ private function findActiveSubscriptionByEmail(\Stripe\StripeClient $stripe, str
|
||||||
*
|
*
|
||||||
* @return array<string>
|
* @return array<string>
|
||||||
*/
|
*/
|
||||||
private function fetchValidStripeSubscriptionIds(\Stripe\StripeClient $stripe, ?\Closure $onProgress = null): array
|
private function fetchValidStripeSubscriptionIds(StripeClient $stripe, ?\Closure $onProgress = null): array
|
||||||
{
|
{
|
||||||
$validIds = [];
|
$validIds = [];
|
||||||
$fetched = 0;
|
$fetched = 0;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Stripe\StripeClient;
|
||||||
|
|
||||||
class VerifyStripeSubscriptionStatusJob implements ShouldBeEncrypted, ShouldQueue
|
class VerifyStripeSubscriptionStatusJob implements ShouldBeEncrypted, ShouldQueue
|
||||||
{
|
{
|
||||||
|
|
@ -29,7 +30,7 @@ public function handle(): void
|
||||||
if (! $this->subscription->stripe_subscription_id &&
|
if (! $this->subscription->stripe_subscription_id &&
|
||||||
$this->subscription->stripe_customer_id) {
|
$this->subscription->stripe_customer_id) {
|
||||||
try {
|
try {
|
||||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
$stripe = app(StripeClient::class);
|
||||||
$subscriptions = $stripe->subscriptions->all([
|
$subscriptions = $stripe->subscriptions->all([
|
||||||
'customer' => $this->subscription->stripe_customer_id,
|
'customer' => $this->subscription->stripe_customer_id,
|
||||||
'limit' => 1,
|
'limit' => 1,
|
||||||
|
|
@ -50,7 +51,7 @@ public function handle(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
$stripe = app(StripeClient::class);
|
||||||
$stripeSubscription = $stripe->subscriptions->retrieve(
|
$stripeSubscription = $stripe->subscriptions->retrieve(
|
||||||
$this->subscription->stripe_subscription_id
|
$this->subscription->stripe_subscription_id
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1053,6 +1053,7 @@ private function loadCreatableItems()
|
||||||
'quickcommand' => '(type: new postgresql)',
|
'quickcommand' => '(type: new postgresql)',
|
||||||
'type' => 'postgresql',
|
'type' => 'postgresql',
|
||||||
'category' => 'Databases',
|
'category' => 'Databases',
|
||||||
|
'logo' => 'svgs/postgresql.svg',
|
||||||
'resourceType' => 'database',
|
'resourceType' => 'database',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -1062,6 +1063,7 @@ private function loadCreatableItems()
|
||||||
'quickcommand' => '(type: new mysql)',
|
'quickcommand' => '(type: new mysql)',
|
||||||
'type' => 'mysql',
|
'type' => 'mysql',
|
||||||
'category' => 'Databases',
|
'category' => 'Databases',
|
||||||
|
'logo' => 'svgs/mysql.svg',
|
||||||
'resourceType' => 'database',
|
'resourceType' => 'database',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -1071,6 +1073,7 @@ private function loadCreatableItems()
|
||||||
'quickcommand' => '(type: new mariadb)',
|
'quickcommand' => '(type: new mariadb)',
|
||||||
'type' => 'mariadb',
|
'type' => 'mariadb',
|
||||||
'category' => 'Databases',
|
'category' => 'Databases',
|
||||||
|
'logo' => 'svgs/mariadb.svg',
|
||||||
'resourceType' => 'database',
|
'resourceType' => 'database',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -1080,6 +1083,7 @@ private function loadCreatableItems()
|
||||||
'quickcommand' => '(type: new redis)',
|
'quickcommand' => '(type: new redis)',
|
||||||
'type' => 'redis',
|
'type' => 'redis',
|
||||||
'category' => 'Databases',
|
'category' => 'Databases',
|
||||||
|
'logo' => 'svgs/redis.svg',
|
||||||
'resourceType' => 'database',
|
'resourceType' => 'database',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -1089,6 +1093,7 @@ private function loadCreatableItems()
|
||||||
'quickcommand' => '(type: new keydb)',
|
'quickcommand' => '(type: new keydb)',
|
||||||
'type' => 'keydb',
|
'type' => 'keydb',
|
||||||
'category' => 'Databases',
|
'category' => 'Databases',
|
||||||
|
'logo' => 'svgs/keydb.svg',
|
||||||
'resourceType' => 'database',
|
'resourceType' => 'database',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -1098,6 +1103,7 @@ private function loadCreatableItems()
|
||||||
'quickcommand' => '(type: new dragonfly)',
|
'quickcommand' => '(type: new dragonfly)',
|
||||||
'type' => 'dragonfly',
|
'type' => 'dragonfly',
|
||||||
'category' => 'Databases',
|
'category' => 'Databases',
|
||||||
|
'logo' => 'svgs/dragonfly.svg',
|
||||||
'resourceType' => 'database',
|
'resourceType' => 'database',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -1107,6 +1113,7 @@ private function loadCreatableItems()
|
||||||
'quickcommand' => '(type: new mongodb)',
|
'quickcommand' => '(type: new mongodb)',
|
||||||
'type' => 'mongodb',
|
'type' => 'mongodb',
|
||||||
'category' => 'Databases',
|
'category' => 'Databases',
|
||||||
|
'logo' => 'svgs/mongodb.svg',
|
||||||
'resourceType' => 'database',
|
'resourceType' => 'database',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -1116,6 +1123,7 @@ private function loadCreatableItems()
|
||||||
'quickcommand' => '(type: new clickhouse)',
|
'quickcommand' => '(type: new clickhouse)',
|
||||||
'type' => 'clickhouse',
|
'type' => 'clickhouse',
|
||||||
'category' => 'Databases',
|
'category' => 'Databases',
|
||||||
|
'logo' => 'svgs/clickhouse-icon.svg',
|
||||||
'resourceType' => 'database',
|
'resourceType' => 'database',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Select extends Component
|
class Select extends Component
|
||||||
|
|
@ -107,7 +106,7 @@ public function updatedSelectedEnvironment()
|
||||||
public function loadServices()
|
public function loadServices()
|
||||||
{
|
{
|
||||||
$services = get_service_templates();
|
$services = get_service_templates();
|
||||||
$templateLastUpdatedMap = $this->serviceTemplateLastUpdatedMap($services->keys());
|
$templateLastUpdatedMap = $this->serviceTemplateLastUpdatedMap($services);
|
||||||
|
|
||||||
$services = collect($services)->map(function ($service, $key) use ($templateLastUpdatedMap) {
|
$services = collect($services)->map(function ($service, $key) use ($templateLastUpdatedMap) {
|
||||||
$default_logo = 'images/default.webp';
|
$default_logo = 'images/default.webp';
|
||||||
|
|
@ -279,19 +278,31 @@ private function serviceTemplatesLastUpdated(): ?string
|
||||||
return $this->formatLastModified($this->serviceTemplatesPath());
|
return $this->formatLastModified($this->serviceTemplatesPath());
|
||||||
}
|
}
|
||||||
|
|
||||||
private function serviceTemplateLastUpdatedMap(Collection $serviceNames): array
|
private function serviceTemplateLastUpdatedMap(Collection $services): array
|
||||||
{
|
{
|
||||||
$bundleMtime = file_exists($this->serviceTemplatesPath()) ? filemtime($this->serviceTemplatesPath()) : 0;
|
return $services
|
||||||
|
->mapWithKeys(fn ($service, $serviceName) => [
|
||||||
|
(string) $serviceName => $this->serviceTemplateLastUpdatedFromPayload($service)
|
||||||
|
?? $this->serviceTemplateLastUpdated((string) $serviceName),
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
return Cache::remember(
|
private function serviceTemplateLastUpdatedFromPayload(mixed $service): ?string
|
||||||
"service-template-last-updated-map:{$bundleMtime}",
|
{
|
||||||
now()->addDay(),
|
$timestamp = data_get($service, 'template_last_updated_at');
|
||||||
fn () => $serviceNames
|
|
||||||
->mapWithKeys(fn ($serviceName) => [
|
if (! is_string($timestamp) || $timestamp === '') {
|
||||||
(string) $serviceName => $this->serviceTemplateLastUpdated((string) $serviceName),
|
return null;
|
||||||
])
|
}
|
||||||
->all()
|
|
||||||
);
|
try {
|
||||||
|
return CarbonImmutable::parse($timestamp)
|
||||||
|
->timezone(config('app.timezone'))
|
||||||
|
->format('M j, Y H:i');
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function serviceTemplateLastUpdated(string $serviceName): ?string
|
private function serviceTemplateLastUpdated(string $serviceName): ?string
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ class Actions extends Component
|
||||||
|
|
||||||
public bool $refundAlreadyUsed = false;
|
public bool $refundAlreadyUsed = false;
|
||||||
|
|
||||||
|
public bool $refundLatestPayment = false;
|
||||||
|
|
||||||
public string $billingInterval = 'monthly';
|
public string $billingInterval = 'monthly';
|
||||||
|
|
||||||
public ?string $nextBillingDate = null;
|
public ?string $nextBillingDate = null;
|
||||||
|
|
@ -100,7 +102,7 @@ public function refundSubscription(string $password): bool|string
|
||||||
return 'Invalid password.';
|
return 'Invalid password.';
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = (new RefundSubscription)->execute(currentTeam());
|
$result = app(RefundSubscription::class)->execute(currentTeam());
|
||||||
|
|
||||||
if ($result['success']) {
|
if ($result['success']) {
|
||||||
$this->dispatch('success', 'Subscription refunded successfully.');
|
$this->dispatch('success', 'Subscription refunded successfully.');
|
||||||
|
|
@ -114,12 +116,28 @@ public function refundSubscription(string $password): bool|string
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function cancelImmediately(string $password): bool|string
|
public function cancelImmediately(string $password, array $selectedActions = []): bool|string
|
||||||
{
|
{
|
||||||
if (! shouldSkipPasswordConfirmation() && ! Hash::check($password, auth()->user()->password)) {
|
if (! shouldSkipPasswordConfirmation() && ! Hash::check($password, auth()->user()->password)) {
|
||||||
return 'Invalid password.';
|
return 'Invalid password.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (in_array('refundLatestPayment', $selectedActions, true)) {
|
||||||
|
// Eligibility is re-validated server-side inside RefundSubscription::execute()
|
||||||
|
$result = app(RefundSubscription::class)->execute(currentTeam());
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
$this->dispatch('success', 'Subscription refunded and cancelled successfully.');
|
||||||
|
$this->redirect(route('subscription.index'), navigate: true);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->dispatch('error', 'Something went wrong with the refund. Please <a href="'.config('constants.urls.contact').'" target="_blank" class="underline">contact us</a>.');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
$team = currentTeam();
|
$team = currentTeam();
|
||||||
$subscription = $team->subscription;
|
$subscription = $team->subscription;
|
||||||
|
|
||||||
|
|
@ -130,7 +148,7 @@ public function cancelImmediately(string $password): bool|string
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$stripe = new StripeClient(config('subscription.stripe_api_key'));
|
$stripe = app(StripeClient::class);
|
||||||
$stripe->subscriptions->cancel($subscription->stripe_subscription_id);
|
$stripe->subscriptions->cancel($subscription->stripe_subscription_id);
|
||||||
|
|
||||||
$subscription->update([
|
$subscription->update([
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
use App\Models\InstanceSettings;
|
use App\Models\InstanceSettings;
|
||||||
use App\Providers\RouteServiceProvider;
|
use App\Providers\RouteServiceProvider;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
use Stripe\StripeClient;
|
||||||
|
|
||||||
class Index extends Component
|
class Index extends Component
|
||||||
{
|
{
|
||||||
|
|
@ -52,7 +53,7 @@ public function getStripeStatus()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$subscription = currentTeam()->subscription;
|
$subscription = currentTeam()->subscription;
|
||||||
$stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
|
$stripe = app(StripeClient::class);
|
||||||
$customer = $stripe->customers->retrieve(currentTeam()->subscription->stripe_customer_id);
|
$customer = $stripe->customers->retrieve(currentTeam()->subscription->stripe_customer_id);
|
||||||
if ($customer) {
|
if ($customer) {
|
||||||
$subscriptions = $stripe->subscriptions->all(['customer' => $customer->id]);
|
$subscriptions = $stripe->subscriptions->all(['customer' => $customer->id]);
|
||||||
|
|
|
||||||
|
|
@ -625,7 +625,7 @@ public function extraFields()
|
||||||
}
|
}
|
||||||
$fields->put('Unleash', $data->toArray());
|
$fields->put('Unleash', $data->toArray());
|
||||||
break;
|
break;
|
||||||
case $image->contains('grafana'):
|
case $this->isGrafanaImage($image->toString()):
|
||||||
$data = collect([]);
|
$data = collect([]);
|
||||||
$admin_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_GRAFANA')->first();
|
$admin_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_GRAFANA')->first();
|
||||||
$data = $data->merge([
|
$data = $data->merge([
|
||||||
|
|
@ -1379,6 +1379,15 @@ public function extraFields()
|
||||||
return $fields;
|
return $fields;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isGrafanaImage(string $image): bool
|
||||||
|
{
|
||||||
|
return in_array($image, [
|
||||||
|
'grafana/grafana',
|
||||||
|
'grafana/grafana-oss',
|
||||||
|
'grafana/grafana-enterprise',
|
||||||
|
], true);
|
||||||
|
}
|
||||||
|
|
||||||
public function saveExtraFields($fields)
|
public function saveExtraFields($fields)
|
||||||
{
|
{
|
||||||
foreach ($fields as $field) {
|
foreach ($fields as $field) {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
use Laravel\Sanctum\Sanctum;
|
use Laravel\Sanctum\Sanctum;
|
||||||
use Laravel\Telescope\TelescopeServiceProvider;
|
use Laravel\Telescope\TelescopeServiceProvider;
|
||||||
|
use Stripe\StripeClient;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
|
|
@ -19,6 +20,8 @@ public function register(): void
|
||||||
if (App::isLocal()) {
|
if (App::isLocal()) {
|
||||||
$this->app->register(TelescopeServiceProvider::class);
|
$this->app->register(TelescopeServiceProvider::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->app->bind(StripeClient::class, fn () => new StripeClient(config('subscription.stripe_api_key')));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
|
|
|
||||||
|
|
@ -5,23 +5,25 @@
|
||||||
"codex",
|
"codex",
|
||||||
"opencode"
|
"opencode"
|
||||||
],
|
],
|
||||||
|
"cloud": false,
|
||||||
"guidelines": true,
|
"guidelines": true,
|
||||||
"mcp": true,
|
"mcp": true,
|
||||||
"nightwatch_mcp": false,
|
"nightwatch_mcp": false,
|
||||||
"packages": [
|
"packages": [
|
||||||
"laravel/fortify",
|
|
||||||
"spatie/laravel-ray",
|
"spatie/laravel-ray",
|
||||||
"lorisleiva/laravel-actions"
|
"lorisleiva/laravel-actions"
|
||||||
],
|
],
|
||||||
"sail": false,
|
"sail": false,
|
||||||
"skills": [
|
"skills": [
|
||||||
|
"fortify-development",
|
||||||
"laravel-best-practices",
|
"laravel-best-practices",
|
||||||
"configuring-horizon",
|
"configuring-horizon",
|
||||||
|
"mcp-development",
|
||||||
|
"configure-nightwatch",
|
||||||
"socialite-development",
|
"socialite-development",
|
||||||
"livewire-development",
|
"livewire-development",
|
||||||
"pest-testing",
|
"pest-testing",
|
||||||
"tailwindcss-development",
|
"tailwindcss-development",
|
||||||
"fortify-development",
|
|
||||||
"laravel-actions",
|
"laravel-actions",
|
||||||
"debugging-output-and-previewing-html-using-ray"
|
"debugging-output-and-previewing-html-using-ray"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1063,7 +1063,6 @@ function sslip(Server $server)
|
||||||
|
|
||||||
function get_service_templates(bool $force = false): Collection
|
function get_service_templates(bool $force = false): Collection
|
||||||
{
|
{
|
||||||
|
|
||||||
if ($force) {
|
if ($force) {
|
||||||
try {
|
try {
|
||||||
$response = Http::retry(3, 1000)->get(config('constants.services.official'));
|
$response = Http::retry(3, 1000)->get(config('constants.services.official'));
|
||||||
|
|
@ -1074,15 +1073,16 @@ function get_service_templates(bool $force = false): Collection
|
||||||
|
|
||||||
return collect($services);
|
return collect($services);
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
$services = File::get(base_path('templates/'.config('constants.services.file_name')));
|
return get_service_templates();
|
||||||
|
|
||||||
return collect(json_decode($services))->sortKeys();
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
$services = File::get(base_path('templates/'.config('constants.services.file_name')));
|
|
||||||
|
|
||||||
return collect(json_decode($services))->sortKeys();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$path = base_path('templates/'.config('constants.services.file_name'));
|
||||||
|
$mtime = filemtime($path) ?: 0;
|
||||||
|
|
||||||
|
return Cache::remember("service-templates:{$mtime}", now()->addDay(), function () use ($path) {
|
||||||
|
return collect(json_decode(File::get($path)))->sortKeys();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getResourceByUuid(string $uuid, ?int $teamId = null)
|
function getResourceByUuid(string $uuid, ?int $teamId = null)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'coolify' => [
|
'coolify' => [
|
||||||
'version' => '4.1.2',
|
'version' => '4.2.0',
|
||||||
'helper_version' => '1.0.14',
|
'helper_version' => '1.0.14',
|
||||||
'realtime_version' => '1.0.16',
|
'realtime_version' => '1.0.16',
|
||||||
'railpack_version' => '0.23.0',
|
'railpack_version' => '0.23.0',
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@
|
||||||
*/
|
*/
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
if (DB::getDriverName() === 'sqlite') {
|
// SQLite (testing) uses type affinity, so json columns already accept text.
|
||||||
|
if (DB::connection()->getDriverName() !== 'pgsql') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -21,7 +22,7 @@ public function up(): void
|
||||||
|
|
||||||
public function down(): void
|
public function down(): void
|
||||||
{
|
{
|
||||||
if (DB::getDriverName() === 'sqlite') {
|
if (DB::connection()->getDriverName() !== 'pgsql') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
{
|
{
|
||||||
"coolify": {
|
"coolify": {
|
||||||
"v4": {
|
"v4": {
|
||||||
"version": "4.1.2"
|
"version": "4.2.0"
|
||||||
},
|
},
|
||||||
"nightly": {
|
"nightly": {
|
||||||
"version": "4.2.0"
|
"version": "4.2.1"
|
||||||
},
|
},
|
||||||
"helper": {
|
"helper": {
|
||||||
"version": "1.0.14"
|
"version": "1.0.14"
|
||||||
|
|
|
||||||
8
public/svgs/clickhouse-icon.svg
Normal file
8
public/svgs/clickhouse-icon.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<svg viewBox="1.70837 1.875 22.25025 22.2493" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<style>svg{color:#d4d4d4}</style>
|
||||||
|
<rect x="2.70837" y="2.875" width="2.24992" height="20.2493" rx="0.236664" />
|
||||||
|
<rect x="7.2085" y="2.875" width="2.24992" height="20.2493" rx="0.236664" />
|
||||||
|
<rect x="11.7086" y="2.875" width="2.24992" height="20.2493" rx="0.236664" />
|
||||||
|
<rect x="16.2076" y="2.875" width="2.24992" height="20.2493" rx="0.236664" />
|
||||||
|
<rect x="20.7087" y="10.7502" width="2.24992" height="4.49985" rx="0.236664" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 534 B |
|
|
@ -1 +1,8 @@
|
||||||
<svg width="215" height="90" viewBox="0 0 100 43" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#clip0_378_10860)"><rect x="2.70837" y="2.875" width="2.24992" height="20.2493" rx="0.236664" fill="currentColor" /><rect x="7.2085" y="2.875" width="2.24992" height="20.2493" rx="0.236664" fill="currentColor" /><rect x="11.7086" y="2.875" width="2.24992" height="20.2493" rx="0.236664" fill="currentColor" /><rect x="16.2076" y="2.875" width="2.24992" height="20.2493" rx="0.236664" fill="currentColor" /><rect x="20.7087" y="10.7502" width="2.24992" height="4.49985" rx="0.236664" fill="currentColor" /></g></svg>
|
<svg viewBox="1.70837 1.875 22.25025 22.2493" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<style>svg{color:#d4d4d4}</style>
|
||||||
|
<rect x="2.70837" y="2.875" width="2.24992" height="20.2493" rx="0.236664" />
|
||||||
|
<rect x="7.2085" y="2.875" width="2.24992" height="20.2493" rx="0.236664" />
|
||||||
|
<rect x="11.7086" y="2.875" width="2.24992" height="20.2493" rx="0.236664" />
|
||||||
|
<rect x="16.2076" y="2.875" width="2.24992" height="20.2493" rx="0.236664" />
|
||||||
|
<rect x="20.7087" y="10.7502" width="2.24992" height="4.49985" rx="0.236664" />
|
||||||
|
</svg>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 642 B After Width: | Height: | Size: 534 B |
1
public/svgs/dragonfly.svg
Normal file
1
public/svgs/dragonfly.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 88 88" fill="none"><style>svg{color:#d4d4d4}</style><path fill-rule="evenodd" clip-rule="evenodd" d="M44 0C7.766 0 0 7.766 0 44C0 80.234 7.766 88 44 88C80.234 88 88 80.234 88 44C88 7.766 80.234 0 44 0ZM39.1171 32.53C39.6448 32.8121 40 33.4016 40 34C40 36.2091 41.7909 38 44 38C46.2091 38 48 36.2091 48 34C48 33.4016 48.3552 32.8121 48.8829 32.53C50.1428 31.8566 51 30.5284 51 29C51 26.7909 49.2091 25 47 25C46.6142 25 46.2411 25.0546 45.8881 25.1566C44.7322 25.4904 43.2678 25.4904 42.1119 25.1566C41.7589 25.0546 41.3858 25 41 25C38.7909 25 37 26.7909 37 29C37 30.5284 37.8572 31.8566 39.1171 32.53ZM40.6174 37.6822C40.4565 37.7954 40.3289 37.9561 40.2566 38.1489L39.2486 40.837C39.0877 41.266 39.079 41.7371 39.2238 42.1717L40.8506 47.0521C40.9488 47.3466 41.1133 47.6174 41.374 47.786C41.8309 48.0815 42.7062 48.5 43.9999 48.5C45.2937 48.5 46.169 48.0815 46.6259 47.786C46.8866 47.6174 47.0511 47.3466 47.1492 47.0521L48.776 42.1717C48.9209 41.7371 48.9122 41.266 48.7513 40.837L47.7433 38.1489C47.671 37.9561 47.5434 37.7954 47.3825 37.6822C46.4922 38.5005 45.3044 39 43.9999 39C42.6955 39 41.5077 38.5005 40.6174 37.6822ZM42 60L41.0211 48.7423C41.6348 49.0994 42.6312 49.5 44 49.5C45.3684 49.5 46.365 49.0996 46.979 48.7423L46 60C46 60 45.5 60.5 44 60.5C42.5 60.5 42 60 42 60ZM42.5 68L42.0111 61.1559C42.0291 61.1635 42.0474 61.171 42.0662 61.1785C42.5095 61.3558 43.1373 61.5 44 61.5C44.8628 61.5 45.4906 61.3558 45.9339 61.1785C45.9526 61.171 45.9709 61.1635 45.9888 61.156L45.5 68C45.5 68 45.25 69 44 69C42.75 69 42.5 68 42.5 68ZM18.4999 40H38.4C37.6448 41.2587 37.9828 42.1357 38.3163 43.001L38.3164 43.0012L38.3164 43.0013L38.3165 43.0014L38.3165 43.0015L38.3166 43.0017C38.3804 43.1673 38.4441 43.3326 38.4999 43.5L19.817 48.8504C18.6241 49.1865 17.3473 48.7543 16.6036 47.7628L15.4279 46.1951C14.7461 45.2861 14.6364 44.0698 15.1446 43.0535L15.8422 41.6582C16.3462 40.6501 17.3729 40.0096 18.4999 40ZM16 38.4663L38.7499 39C38.7499 39 39.1093 38.1012 39.2499 37.5C39.2981 37.294 39.349 37.0881 39.3922 36.9172C39.4529 36.6771 39.3398 36.4307 39.1124 36.3324C36.4624 35.187 21.0442 28.5249 18.4745 27.4663C17.4762 27.0394 16.3252 27.1853 15.4649 27.8476L13.17 29.6144C12.4171 30.1941 11.9975 31.0823 12 32C12.0008 32.2913 12.0441 32.5855 12.1328 32.8738L13.2451 36.4663C13.6189 37.6811 14.7301 38.4133 16 38.4663ZM69.5 40H49.6C50.3552 41.2587 50.0172 42.1357 49.6836 43.001L49.6836 43.0012L49.6835 43.0013L49.6835 43.0014C49.6196 43.1672 49.5559 43.3325 49.5 43.5L68.1829 48.8504C69.3759 49.1865 70.6527 48.7543 71.3963 47.7628L72.5721 46.1951C73.2539 45.2861 73.3636 44.0698 72.8554 43.0535L72.1578 41.6583C71.6537 40.6501 70.6271 40.0096 69.5 40ZM72 38.4663L49.25 39C49.25 39 48.8907 38.1012 48.75 37.5C48.7018 37.294 48.6509 37.0881 48.6077 36.9173C48.547 36.6771 48.6602 36.4307 48.8875 36.3325C51.5376 35.187 66.9558 28.5249 69.5255 27.4663C70.5238 27.0394 71.6748 27.1853 72.5351 27.8476L74.83 29.6144C75.5829 30.1941 76.0025 31.0823 76 32C75.9992 32.2913 75.9559 32.5855 75.8672 32.8738L74.7549 36.4663C74.3811 37.6811 73.2699 38.4133 72 38.4663Z" fill="currentColor"/></svg>
|
||||||
|
After Width: | Height: | Size: 3.1 KiB |
1
public/svgs/keydb.svg
Normal file
1
public/svgs/keydb.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg version="1.1" id="svg1326" viewBox="0 0 160 182" sodipodi:docname="keydb.svg" inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"><style>svg{color:#d4d4d4}</style><defs id="defs1330"/><sodipodi:namedview id="namedview1328" pagecolor="currentColor" bordercolor="#666666" borderopacity="1.0" inkscape:pageshadow="2" inkscape:pageopacity="0.00784314" inkscape:pagecheckerboard="true" showgrid="false" inkscape:zoom="1.5064209" inkscape:cx="374.06544" inkscape:cy="123.80338" inkscape:window-width="1536" inkscape:window-height="889" inkscape:window-x="-8" inkscape:window-y="-8" inkscape:window-maximized="1" inkscape:current-layer="svg1326"/><path d="M 78.589334,169.85617 12.63903,131.77977 c -1.0281,-0.591 -1.6035,-1.6659 -1.6046,-2.7722 h -0.0134 V 52.833267 c 0,-1.2966 0.7688,-2.4135 1.8749,-2.9206 l 65.739904,-37.9545 c 1.0386,-0.5966 2.2717,-0.5456 3.2321,0.0264 l 65.951096,38.0761 c 1.0282,0.591 1.6042,1.6659 1.6047,2.7726 h 0.0134 v 76.174303 c 0,1.2962 -0.7685,2.4137 -1.8743,2.9202 l -65.740496,37.9554 c -1.0389,0.5964 -2.2717,0.5453 -3.233,-0.027 z M 17.44353,127.16987 v 0 l 62.785704,36.2488 62.785996,-36.2488 V 54.671267 l -62.785996,-36.2489 -62.785704,36.2489 z" style="fill:currentColor;fill-rule:evenodd" id="path1290"/><path d="M 80.229234,14.730167 14.23273,129.00757 h 131.9933 z" style="opacity:.88;fill:#ff0;fill-opacity:1;fill-rule:evenodd" id="path1292"/><path d="M 80.229234,21.136467 19.78603,125.79677 h 120.8861 z M 11.45983,127.40197 v 0 L 77.433934,13.163767 c 0.2713,-0.4856 0.6733,-0.9068 1.1895,-1.2056 1.531,-0.8864 3.4908,-0.3645 4.3778,1.1671 L 148.88863,127.21177 c 0.3464,0.5125 0.5485,1.1308 0.5485,1.7958 0,1.7733 -1.4378,3.2111 -3.2108,3.2111 H 14.23213 v -0.007 c -0.5459,5e-4 -1.099,-0.1386 -1.6052,-0.4318 -1.531,-0.8863 -2.0537,-2.8465 -1.1671,-4.3778 z" style="fill:currentColor;fill-rule:evenodd" id="path1294"/><path d="m 12.63903,55.605567 c -1.5309,-0.8799 -2.059,-2.8347 -1.1792,-4.3657 0.8802,-1.531 2.8347,-2.0591 4.3657,-1.1792 L 80.229234,87.229065 144.63323,50.060667 c 1.531,-0.8799 3.4855,-0.3518 4.3651,1.1792 0.8796,1.531 0.3517,3.4858 -1.1793,4.3657 L 81.867334,93.666065 c -0.9603,0.5723 -2.1931,0.6227 -3.2315,0.026 z" style="opacity:1;fill:currentColor;fill-rule:evenodd" id="path1296"/><path d="m 83.440034,167.11087 c 0,1.773 -1.4377,3.2111 -3.2108,3.2111 -1.7736,0 -3.2114,-1.4381 -3.2114,-3.2111 V 90.920065 c 0,-1.773 1.4378,-3.2111 3.2114,-3.2111 1.7731,0 3.2108,1.4381 3.2108,3.2111 z" style="fill:currentColor;fill-rule:evenodd" id="path1298"/><path d="m 146.22633,137.25697 c 4.5555,0 8.2491,-3.693 8.2491,-8.2494 0,-4.5564 -3.6936,-8.2491 -8.2491,-8.2491 -4.5564,0 -8.2503,3.6927 -8.2503,8.2491 0,4.5564 3.6939,8.2494 8.2503,8.2494 z" style="fill:currentColor;fill-rule:evenodd" id="path1300"/><path d="m 146.22633,61.082867 c 4.5555,0 8.2491,-3.6938 8.2491,-8.2496 0,-4.5562 -3.6936,-8.2494 -8.2491,-8.2494 -4.5564,0 -8.2503,3.6932 -8.2503,8.2494 0,4.5558 3.6939,8.2496 8.2503,8.2496 z" style="fill:currentColor;fill-rule:evenodd" id="path1302"/><path d="m 14.23213,61.082867 c 4.5561,0 8.25,-3.6938 8.25,-8.2496 0,-4.5562 -3.6939,-8.2494 -8.25,-8.2494 -4.5555003,0 -8.2494003,3.6932 -8.2494003,8.2494 0,4.5558 3.6939,8.2496 8.2494003,8.2496 z" style="fill:currentColor;fill-rule:evenodd" id="path1304"/><path d="m 14.23213,137.25697 c 4.5561,0 8.25,-3.693 8.25,-8.2494 0,-4.5564 -3.6939,-8.2491 -8.25,-8.2491 -4.5555003,0 -8.2494003,3.6927 -8.2494003,8.2491 0,4.5564 3.6939,8.2494 8.2494003,8.2494 z" style="fill:currentColor;fill-rule:evenodd" id="path1306"/><path d="m 80.229234,175.36027 c 4.5558,0 8.2497,-3.6933 8.2497,-8.2494 0,-4.5562 -3.6939,-8.2494 -8.2497,-8.2494 -4.5564,0 -8.2497,3.6932 -8.2497,8.2494 0,4.5561 3.6933,8.2494 8.2497,8.2494 z" style="fill:currentColor;fill-rule:evenodd" id="path1308"/><path d="m 80.229234,99.170065 c 4.5558,0 8.2497,-3.6938 8.2497,-8.25 0,-4.5561 -3.6939,-8.2494 -8.2497,-8.2494 -4.5564,0 -8.2497,3.6933 -8.2497,8.2494 0,4.5562 3.6933,8.25 8.2497,8.25 z" style="fill:currentColor;fill-rule:evenodd" id="path1310"/><path d="m 80.229234,22.979567 c 4.5558,0 8.2497,-3.6932 8.2497,-8.2494 0,-4.5561 -3.6939,-8.2492996 -8.2497,-8.2492996 -4.5564,0 -8.2497,3.6931996 -8.2497,8.2492996 0,4.5562 3.6933,8.2494 8.2497,8.2494 z" style="fill:currentColor;fill-rule:evenodd" id="path1312"/></svg>
|
||||||
|
After Width: | Height: | Size: 4.4 KiB |
|
|
@ -3,34 +3,84 @@
|
||||||
Advanced
|
Advanced
|
||||||
</x-slot>
|
</x-slot>
|
||||||
@if ($application->status === 'running')
|
@if ($application->status === 'running')
|
||||||
<div class="dropdown-iteme" @if(!auth()->user()->can('deploy', $application)) data-disabled @endif wire:click='force_deploy_without_cache'>
|
@can('deploy', $application)
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
|
<x-modal-confirmation title="Confirm Application Force Deployment?" buttonTitle="Force deploy"
|
||||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
submitAction="force_deploy_without_cache" :actions="[
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
'This application will be force deployed without build cache.',
|
||||||
<path
|
]" :confirmWithText="false" :confirmWithPassword="false"
|
||||||
d="M12.983 8.978c3.955 -.182 7.017 -1.446 7.017 -2.978c0 -1.657 -3.582 -3 -8 -3c-1.661 0 -3.204 .19 -4.483 .515m-2.783 1.228c-.471 .382 -.734 .808 -.734 1.257c0 1.22 1.944 2.271 4.734 2.74" />
|
step2ButtonText="Confirm">
|
||||||
<path
|
<x-slot:content>
|
||||||
d="M4 6v6c0 1.657 3.582 3 8 3c.986 0 1.93 -.067 2.802 -.19m3.187 -.82c1.251 -.53 2.011 -1.228 2.011 -1.99v-6" />
|
<div class="dropdown-iteme">
|
||||||
<path d="M4 12v6c0 1.657 3.582 3 8 3c3.217 0 5.991 -.712 7.261 -1.74m.739 -3.26v-4" />
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
|
||||||
<path d="M3 3l18 18" />
|
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
</svg>
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
Force deploy (without
|
<path
|
||||||
cache)
|
d="M12.983 8.978c3.955 -.182 7.017 -1.446 7.017 -2.978c0 -1.657 -3.582 -3 -8 -3c-1.661 0 -3.204 .19 -4.483 .515m-2.783 1.228c-.471 .382 -.734 .808 -.734 1.257c0 1.22 1.944 2.271 4.734 2.74" />
|
||||||
</div>
|
<path
|
||||||
|
d="M4 6v6c0 1.657 3.582 3 8 3c.986 0 1.93 -.067 2.802 -.19m3.187 -.82c1.251 -.53 2.011 -1.228 2.011 -1.99v-6" />
|
||||||
|
<path d="M4 12v6c0 1.657 3.582 3 8 3c3.217 0 5.991 -.712 7.261 -1.74m.739 -3.26v-4" />
|
||||||
|
<path d="M3 3l18 18" />
|
||||||
|
</svg>
|
||||||
|
Force deploy (without
|
||||||
|
cache)
|
||||||
|
</div>
|
||||||
|
</x-slot:content>
|
||||||
|
</x-modal-confirmation>
|
||||||
|
@else
|
||||||
|
<div class="dropdown-iteme" data-disabled>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
|
||||||
|
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path
|
||||||
|
d="M12.983 8.978c3.955 -.182 7.017 -1.446 7.017 -2.978c0 -1.657 -3.582 -3 -8 -3c-1.661 0 -3.204 .19 -4.483 .515m-2.783 1.228c-.471 .382 -.734 .808 -.734 1.257c0 1.22 1.944 2.271 4.734 2.74" />
|
||||||
|
<path
|
||||||
|
d="M4 6v6c0 1.657 3.582 3 8 3c.986 0 1.93 -.067 2.802 -.19m3.187 -.82c1.251 -.53 2.011 -1.228 2.011 -1.99v-6" />
|
||||||
|
<path d="M4 12v6c0 1.657 3.582 3 8 3c3.217 0 5.991 -.712 7.261 -1.74m.739 -3.26v-4" />
|
||||||
|
<path d="M3 3l18 18" />
|
||||||
|
</svg>
|
||||||
|
Force deploy (without
|
||||||
|
cache)
|
||||||
|
</div>
|
||||||
|
@endcan
|
||||||
@else
|
@else
|
||||||
<div class="dropdown-item" @if(!auth()->user()->can('deploy', $application)) data-disabled @endif wire:click='deploy(true)'>
|
@can('deploy', $application)
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" stroke-width="1.5"
|
<x-modal-confirmation title="Confirm Application Force Deployment?" buttonTitle="Force deploy"
|
||||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
submitAction="deploy(true)" :actions="[
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
'This application will be force deployed without build cache.',
|
||||||
<path
|
]" :confirmWithText="false" :confirmWithPassword="false"
|
||||||
d="M12.983 8.978c3.955 -.182 7.017 -1.446 7.017 -2.978c0 -1.657 -3.582 -3 -8 -3c-1.661 0 -3.204 .19 -4.483 .515m-2.783 1.228c-.471 .382 -.734 .808 -.734 1.257c0 1.22 1.944 2.271 4.734 2.74" />
|
step2ButtonText="Confirm">
|
||||||
<path
|
<x-slot:content>
|
||||||
d="M4 6v6c0 1.657 3.582 3 8 3c.986 0 1.93 -.067 2.802 -.19m3.187 -.82c1.251 -.53 2.011 -1.228 2.011 -1.99v-6" />
|
<div class="dropdown-item">
|
||||||
<path d="M4 12v6c0 1.657 3.582 3 8 3c3.217 0 5.991 -.712 7.261 -1.74m.739 -3.26v-4" />
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" stroke-width="1.5"
|
||||||
<path d="M3 3l18 18" />
|
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
</svg>
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
Force deploy (without
|
<path
|
||||||
cache)
|
d="M12.983 8.978c3.955 -.182 7.017 -1.446 7.017 -2.978c0 -1.657 -3.582 -3 -8 -3c-1.661 0 -3.204 .19 -4.483 .515m-2.783 1.228c-.471 .382 -.734 .808 -.734 1.257c0 1.22 1.944 2.271 4.734 2.74" />
|
||||||
</div>
|
<path
|
||||||
|
d="M4 6v6c0 1.657 3.582 3 8 3c.986 0 1.93 -.067 2.802 -.19m3.187 -.82c1.251 -.53 2.011 -1.228 2.011 -1.99v-6" />
|
||||||
|
<path d="M4 12v6c0 1.657 3.582 3 8 3c3.217 0 5.991 -.712 7.261 -1.74m.739 -3.26v-4" />
|
||||||
|
<path d="M3 3l18 18" />
|
||||||
|
</svg>
|
||||||
|
Force deploy (without
|
||||||
|
cache)
|
||||||
|
</div>
|
||||||
|
</x-slot:content>
|
||||||
|
</x-modal-confirmation>
|
||||||
|
@else
|
||||||
|
<div class="dropdown-item" data-disabled>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" stroke-width="1.5"
|
||||||
|
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path
|
||||||
|
d="M12.983 8.978c3.955 -.182 7.017 -1.446 7.017 -2.978c0 -1.657 -3.582 -3 -8 -3c-1.661 0 -3.204 .19 -4.483 .515m-2.783 1.228c-.471 .382 -.734 .808 -.734 1.257c0 1.22 1.944 2.271 4.734 2.74" />
|
||||||
|
<path
|
||||||
|
d="M4 6v6c0 1.657 3.582 3 8 3c.986 0 1.93 -.067 2.802 -.19m3.187 -.82c1.251 -.53 2.011 -1.228 2.011 -1.99v-6" />
|
||||||
|
<path d="M4 12v6c0 1.657 3.582 3 8 3c3.217 0 5.991 -.712 7.261 -1.74m.739 -3.26v-4" />
|
||||||
|
<path d="M3 3l18 18" />
|
||||||
|
</svg>
|
||||||
|
Force deploy (without
|
||||||
|
cache)
|
||||||
|
</div>
|
||||||
|
@endcan
|
||||||
@endif
|
@endif
|
||||||
</x-dropdown>
|
</x-dropdown>
|
||||||
|
|
|
||||||
|
|
@ -376,7 +376,11 @@ class="{{ request()->is('settings*') ? 'menu-item-active menu-item' : 'menu-item
|
||||||
<livewire:settings-dropdown trigger="changelog-sidebar" />
|
<livewire:settings-dropdown trigger="changelog-sidebar" />
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<div class="menu-item" title="Theme" aria-label="Theme switcher" :class="collapsed && 'lg:hidden'">
|
<button type="button" @click.stop="cycleTheme()"
|
||||||
|
:title="`Theme: ${theme === 'system' ? 'System default' : theme}. Click to change.`"
|
||||||
|
:aria-label="`Theme: ${theme === 'system' ? 'System default' : theme}. Click to change theme.`"
|
||||||
|
class="menu-item"
|
||||||
|
:class="collapsed && 'lg:hidden'">
|
||||||
<svg x-show="theme === 'dark'" class="menu-item-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
<svg x-show="theme === 'dark'" class="menu-item-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||||
|
|
@ -389,34 +393,8 @@ class="{{ request()->is('settings*') ? 'menu-item-active menu-item' : 'menu-item
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="menu-item-label">Theme</span>
|
<span class="text-left menu-item-label">Theme</span>
|
||||||
<div class="ml-auto flex items-center gap-0.5 rounded-sm bg-neutral-100 p-0.5 dark:bg-coolgray-200">
|
</button>
|
||||||
<button type="button" @click.stop="setTheme('light')" title="Light" aria-label="Use light theme"
|
|
||||||
class="grid size-6 place-items-center rounded-sm text-xs hover:bg-white hover:text-coollabs dark:hover:bg-base dark:hover:text-warning"
|
|
||||||
:class="theme === 'light' && 'bg-white text-coollabs shadow-sm dark:bg-base dark:text-warning'">
|
|
||||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button type="button" @click.stop="setTheme('system')" title="System default" aria-label="Use system theme"
|
|
||||||
class="grid size-6 place-items-center rounded-sm text-xs hover:bg-white hover:text-coollabs dark:hover:bg-base dark:hover:text-warning"
|
|
||||||
:class="theme === 'system' && 'bg-white text-coollabs shadow-sm dark:bg-base dark:text-warning'">
|
|
||||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button type="button" @click.stop="setTheme('dark')" title="Dark" aria-label="Use dark theme"
|
|
||||||
class="grid size-6 place-items-center rounded-sm text-xs hover:bg-white hover:text-coollabs dark:hover:bg-base dark:hover:text-warning"
|
|
||||||
:class="theme === 'dark' && 'bg-white text-coollabs shadow-sm dark:bg-base dark:text-warning'">
|
|
||||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" @click.stop="cycleTheme()"
|
<button type="button" @click.stop="cycleTheme()"
|
||||||
:title="`Theme: ${theme === 'system' ? 'System default' : theme}. Click to change.`"
|
:title="`Theme: ${theme === 'system' ? 'System default' : theme}. Click to change.`"
|
||||||
:aria-label="`Theme: ${theme === 'system' ? 'System default' : theme}. Click to change theme.`"
|
:aria-label="`Theme: ${theme === 'system' ? 'System default' : theme}. Click to change theme.`"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
'isImportSupported' => false,
|
'isImportSupported' => false,
|
||||||
])
|
])
|
||||||
|
|
||||||
<div class="sub-menu-wrapper">
|
<div class="sub-menu-wrapper hidden md:flex">
|
||||||
<a class="sub-menu-item"
|
<a class="sub-menu-item"
|
||||||
class="{{ request()->routeIs('project.service.configuration') ? 'menu-item-active' : '' }}"
|
class="{{ request()->routeIs('project.service.configuration') ? 'menu-item-active' : '' }}"
|
||||||
{{ wireNavigate() }}
|
{{ wireNavigate() }}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Advanced
|
Advanced
|
||||||
</x-slot>
|
</x-slot>
|
||||||
@if (str($service->status)->contains('running'))
|
@if (str($service->status)->contains('running'))
|
||||||
<div class="dropdown-item" @if(!auth()->user()->can('deploy', $service)) data-disabled @endif @click="$wire.dispatch('pullAndRestartEvent')">
|
<div class="dropdown-item" @if(!auth()->user()->can('deploy', $service)) data-disabled @endif @click="document.getElementById('service-pullAndRestart-trigger')?.click()">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
|
||||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
Pull Latest Images & Restart
|
Pull Latest Images & Restart
|
||||||
</div>
|
</div>
|
||||||
@elseif (str($service->status)->contains('degraded'))
|
@elseif (str($service->status)->contains('degraded'))
|
||||||
<div class="dropdown-item" @if(!auth()->user()->can('deploy', $service)) data-disabled @endif @click="$wire.dispatch('forceDeployEvent')">
|
<div class="dropdown-item" @if(!auth()->user()->can('deploy', $service)) data-disabled @endif @click="document.getElementById('service-forceDeploy-trigger')?.click()">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
stroke-linecap="round" stroke-linejoin="round" data-darkreader-inline-stroke=""
|
stroke-linecap="round" stroke-linejoin="round" data-darkreader-inline-stroke=""
|
||||||
style="--darkreader-inline-stroke: currentColor;" class="w-6 h-6" stroke-width="2">
|
style="--darkreader-inline-stroke: currentColor;" class="w-6 h-6" stroke-width="2">
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
Force Restart
|
Force Restart
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<div class="dropdown-item" @if(!auth()->user()->can('deploy', $service)) data-disabled @endif @click="$wire.dispatch('forceDeployEvent')">
|
<div class="dropdown-item" @if(!auth()->user()->can('deploy', $service)) data-disabled @endif @click="document.getElementById('service-forceDeploy-trigger')?.click()">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
stroke-linecap="round" stroke-linejoin="round" data-darkreader-inline-stroke=""
|
stroke-linecap="round" stroke-linejoin="round" data-darkreader-inline-stroke=""
|
||||||
style="--darkreader-inline-stroke: currentColor;" class="w-4 h-4" stroke-width="2">
|
style="--darkreader-inline-stroke: currentColor;" class="w-4 h-4" stroke-width="2">
|
||||||
|
|
@ -36,7 +36,7 @@
|
||||||
</svg>
|
</svg>
|
||||||
Force Deploy
|
Force Deploy
|
||||||
</div>
|
</div>
|
||||||
<div class="dropdown-item" @if(!auth()->user()->can('stop', $service)) data-disabled @endif wire:click='stop(true)''>
|
<div class="dropdown-item" @if(!auth()->user()->can('stop', $service)) data-disabled @endif @click="document.getElementById('service-cleanup-trigger')?.click()">
|
||||||
<svg class="w-4 h-4" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
<svg class="w-4 h-4" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path fill="currentColor" d="M26 20h-6v-2h6zm4 8h-6v-2h6zm-2-4h-6v-2h6z" />
|
<path fill="currentColor" d="M26 20h-6v-2h6zm4 8h-6v-2h6zm-2-4h-6v-2h6z" />
|
||||||
<path fill="currentColor"
|
<path fill="currentColor"
|
||||||
|
|
|
||||||
38
resources/views/components/status-badge.blade.php
Normal file
38
resources/views/components/status-badge.blade.php
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
@props([
|
||||||
|
'label' => null,
|
||||||
|
'status' => null,
|
||||||
|
'type' => 'neutral',
|
||||||
|
'as' => 'span',
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$typeClasses = [
|
||||||
|
'neutral' => 'border-neutral-200 bg-neutral-100 text-black dark:border-coolgray-300 dark:bg-coolgray-200 dark:text-white',
|
||||||
|
'success' => 'border-green-200 bg-green-50 text-green-800 dark:border-green-900 dark:bg-green-950/30 dark:text-green-300',
|
||||||
|
'warning' => 'border-yellow-300 bg-yellow-50 text-yellow-900 dark:border-yellow-800 dark:bg-yellow-950/30 dark:text-yellow-200',
|
||||||
|
'error' => 'border-red-300 bg-red-50 text-red-800 dark:border-red-900 dark:bg-red-950/30 dark:text-red-300',
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if ($as === 'button')
|
||||||
|
<button {{ $attributes->class([
|
||||||
|
'inline-flex h-5 max-w-full items-center gap-1 rounded-sm border px-1.5 text-xs font-medium leading-4 transition-colors',
|
||||||
|
$typeClasses[$type] ?? $typeClasses['neutral'],
|
||||||
|
])->merge(['type' => 'button']) }}>
|
||||||
|
{{ collect([$label, $status])->filter()->join(' ') }}
|
||||||
|
</button>
|
||||||
|
@elseif ($as === 'a')
|
||||||
|
<a {{ $attributes->class([
|
||||||
|
'inline-flex h-5 max-w-full items-center gap-1 rounded-sm border px-1.5 text-xs font-medium leading-4 transition-colors',
|
||||||
|
$typeClasses[$type] ?? $typeClasses['neutral'],
|
||||||
|
]) }}>
|
||||||
|
{{ collect([$label, $status])->filter()->join(' ') }}
|
||||||
|
</a>
|
||||||
|
@else
|
||||||
|
<span {{ $attributes->class([
|
||||||
|
'inline-flex h-5 max-w-full items-center gap-1 rounded-sm border px-1.5 text-xs font-medium leading-4',
|
||||||
|
$typeClasses[$type] ?? $typeClasses['neutral'],
|
||||||
|
]) }}>
|
||||||
|
{{ collect([$label, $status])->filter()->join(' ') }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
|
@ -2,32 +2,18 @@
|
||||||
'status' => 'Degraded',
|
'status' => 'Degraded',
|
||||||
])
|
])
|
||||||
@php
|
@php
|
||||||
// Handle both colon format (backend) and parentheses format (from services.blade.php)
|
|
||||||
// degraded:unhealthy → Degraded (unhealthy)
|
|
||||||
// degraded (unhealthy) → degraded (unhealthy) (already formatted, display as-is)
|
|
||||||
|
|
||||||
if (str($status)->contains('(')) {
|
if (str($status)->contains('(')) {
|
||||||
// Already in parentheses format from services.blade.php - use as-is
|
|
||||||
$displayStatus = $status;
|
$displayStatus = $status;
|
||||||
$healthStatus = str($status)->after('(')->before(')')->trim()->value();
|
$healthStatus = null;
|
||||||
} elseif (str($status)->contains(':') && !str($status)->startsWith('Proxy')) {
|
} elseif (str($status)->contains(':') && ! str($status)->startsWith('Proxy')) {
|
||||||
// Colon format from backend - transform it
|
|
||||||
$parts = explode(':', $status);
|
$parts = explode(':', $status);
|
||||||
$displayStatus = str($parts[0])->headline();
|
$displayStatus = str($parts[0])->headline()->value();
|
||||||
$healthStatus = $parts[1] ?? null;
|
$healthStatus = $parts[1] ?? null;
|
||||||
} else {
|
} else {
|
||||||
// Simple status without health
|
$displayStatus = str($status)->headline()->value();
|
||||||
$displayStatus = str($status)->headline();
|
|
||||||
$healthStatus = null;
|
$healthStatus = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$badgeStatus = $healthStatus ? "{$displayStatus} ({$healthStatus})" : $displayStatus;
|
||||||
@endphp
|
@endphp
|
||||||
<div class="flex items-center" >
|
<x-status-badge status="{{ $badgeStatus }}" type="warning" />
|
||||||
<x-loading wire:loading.delay.longer />
|
|
||||||
<span wire:loading.remove.delay.longer class="flex items-center">
|
|
||||||
<div class="badge badge-warning"></div>
|
|
||||||
<div class="pl-2 pr-1 text-xs font-bold dark:text-warning">{{ $displayStatus }}</div>
|
|
||||||
@if ($healthStatus && !str($displayStatus)->contains('('))
|
|
||||||
<div class="text-xs dark:text-warning">({{ $healthStatus }})</div>
|
|
||||||
@endif
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
|
||||||
|
|
@ -18,33 +18,18 @@
|
||||||
<x-status.stopped :status="$resource->status" />
|
<x-status.stopped :status="$resource->status" />
|
||||||
@endif
|
@endif
|
||||||
@if (isset($resource->restart_count) && $resource->restart_count > 0 && (!str($resource->status)->startsWith('exited') || $stoppedAfterRestartLimit))
|
@if (isset($resource->restart_count) && $resource->restart_count > 0 && (!str($resource->status)->startsWith('exited') || $stoppedAfterRestartLimit))
|
||||||
<div class="flex items-center">
|
<x-status-badge status="{{ $resource->restart_count }}x restarts" type="warning"
|
||||||
<span class="text-xs dark:text-warning" title="Container has restarted {{ $resource->restart_count }} time{{ $resource->restart_count > 1 ? 's' : '' }}. Last restart: {{ $resource->last_restart_at?->diffForHumans() }}">
|
title="Container has restarted {{ $resource->restart_count }} time{{ $resource->restart_count > 1 ? 's' : '' }}. Last restart: {{ $resource->last_restart_at?->diffForHumans() }}" />
|
||||||
({{ $resource->restart_count }}x restarts)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
@endif
|
@endif
|
||||||
@if ($stoppedAfterRestartLimit)
|
@if ($stoppedAfterRestartLimit)
|
||||||
<div class="flex items-center">
|
<x-status-badge status="Stopped after reaching restart limit ({{ $resource->restart_count }}/{{ $resource->max_restart_count }})."
|
||||||
<span class="text-xs dark:text-warning" title="Container has crashed and Coolify stopped it after {{ $resource->restart_count }} restart attempts.">
|
type="warning"
|
||||||
Stopped after reaching restart limit ({{ $resource->restart_count }}/{{ $resource->max_restart_count }}).
|
title="Container has crashed and Coolify stopped it after {{ $resource->restart_count }} restart attempts." />
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
@endif
|
@endif
|
||||||
@if (!str($resource->status)->contains('exited') && $showRefreshButton)
|
@if (!str($resource->status)->contains('exited') && $showRefreshButton)
|
||||||
<button wire:loading.remove.delay.shortest wire:target="manualCheckStatus" title="Refresh Status" wire:click='manualCheckStatus'
|
<x-status-badge as="button" wire:target="manualCheckStatus" wire:loading.attr="disabled"
|
||||||
class="dark:hover:fill-white fill-black dark:fill-warning">
|
wire:click='manualCheckStatus' status="Refresh" type="neutral" title="Refresh Status"
|
||||||
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
aria-label="Refresh status"
|
||||||
<path
|
class="min-w-[4.5rem] justify-center cursor-pointer border-transparent hover:bg-neutral-200 disabled:cursor-wait disabled:opacity-70 dark:hover:bg-coolgray-300" />
|
||||||
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button wire:loading.delay.shortest wire:target="manualCheckStatus" title="Refreshing Status" wire:click='manualCheckStatus'
|
|
||||||
class="dark:hover:fill-white fill-black dark:fill-warning">
|
|
||||||
<svg class="w-4 h-4 animate-spin" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path
|
|
||||||
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,42 +5,23 @@
|
||||||
'noLoading' => false,
|
'noLoading' => false,
|
||||||
])
|
])
|
||||||
@php
|
@php
|
||||||
// Handle both colon format (backend) and parentheses format (from services.blade.php)
|
|
||||||
// starting:unknown → Starting (unknown)
|
|
||||||
// starting (unknown) → starting (unknown) (already formatted, display as-is)
|
|
||||||
|
|
||||||
if (str($status)->contains('(')) {
|
if (str($status)->contains('(')) {
|
||||||
// Already in parentheses format from services.blade.php - use as-is
|
|
||||||
$displayStatus = $status;
|
$displayStatus = $status;
|
||||||
$healthStatus = str($status)->after('(')->before(')')->trim()->value();
|
$healthStatus = null;
|
||||||
} elseif (str($status)->contains(':') && !str($status)->startsWith('Proxy')) {
|
} elseif (str($status)->contains(':') && ! str($status)->startsWith('Proxy')) {
|
||||||
// Colon format from backend - transform it
|
|
||||||
$parts = explode(':', $status);
|
$parts = explode(':', $status);
|
||||||
$displayStatus = str($parts[0])->headline();
|
$displayStatus = str($parts[0])->headline()->value();
|
||||||
$healthStatus = $parts[1] ?? null;
|
$healthStatus = $parts[1] ?? null;
|
||||||
} else {
|
} else {
|
||||||
// Simple status without health
|
$displayStatus = str($status)->headline()->value();
|
||||||
$displayStatus = str($status)->headline();
|
|
||||||
$healthStatus = null;
|
$healthStatus = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$badgeStatus = $healthStatus ? "{$displayStatus} ({$healthStatus})" : $displayStatus;
|
||||||
@endphp
|
@endphp
|
||||||
<div class="flex items-center">
|
@if ($lastDeploymentLink)
|
||||||
@if (!$noLoading)
|
<x-status-badge as="a" href="{{ $lastDeploymentLink }}" target="_blank" status="{{ $badgeStatus }}" type="warning"
|
||||||
<x-loading wire:loading.delay.longer />
|
title="{{ $title }}" class="cursor-pointer underline" />
|
||||||
@endif
|
@else
|
||||||
<span wire:loading.remove.delay.longer class="flex items-center">
|
<x-status-badge status="{{ $badgeStatus }}" type="warning" title="{{ $title }}" />
|
||||||
<div class="badge badge-warning"></div>
|
@endif
|
||||||
<div class="pl-2 pr-1 text-xs font-bold dark:text-warning" @if($title) title="{{$title}}" @endif>
|
|
||||||
@if ($lastDeploymentLink)
|
|
||||||
<a href="{{ $lastDeploymentLink }}" target="_blank" class="underline cursor-pointer">
|
|
||||||
{{ $displayStatus }}
|
|
||||||
</a>
|
|
||||||
@else
|
|
||||||
{{ $displayStatus }}
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@if ($healthStatus && !str($displayStatus)->contains('('))
|
|
||||||
<div class="text-xs dark:text-warning">({{ $healthStatus }})</div>
|
|
||||||
@endif
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
|
||||||
|
|
@ -5,80 +5,47 @@
|
||||||
'noLoading' => false,
|
'noLoading' => false,
|
||||||
])
|
])
|
||||||
@php
|
@php
|
||||||
// Handle both colon format (backend) and parentheses format (from services.blade.php)
|
|
||||||
// running:healthy → Running (healthy)
|
|
||||||
// running (healthy) → running (healthy) (already formatted, display as-is)
|
|
||||||
|
|
||||||
if (str($status)->contains('(')) {
|
if (str($status)->contains('(')) {
|
||||||
// Already in parentheses format from services.blade.php - use as-is
|
|
||||||
$displayStatus = $status;
|
$displayStatus = $status;
|
||||||
$healthStatus = str($status)->after('(')->before(')')->trim()->value();
|
$healthStatus = null;
|
||||||
} elseif (str($status)->contains(':') && !str($status)->startsWith('Proxy')) {
|
} elseif (str($status)->contains(':') && ! str($status)->startsWith('Proxy')) {
|
||||||
// Colon format from backend - transform it
|
|
||||||
$parts = explode(':', $status);
|
$parts = explode(':', $status);
|
||||||
$displayStatus = str($parts[0])->headline();
|
$displayStatus = str($parts[0])->headline()->value();
|
||||||
$healthStatus = $parts[1] ?? null;
|
$healthStatus = $parts[1] ?? null;
|
||||||
} else {
|
} else {
|
||||||
// Simple status without health
|
$displayStatus = str($status)->headline()->value();
|
||||||
$displayStatus = str($status)->headline();
|
|
||||||
$healthStatus = null;
|
$healthStatus = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$badgeStatus = $healthStatus ? "{$displayStatus} ({$healthStatus})" : $displayStatus;
|
||||||
|
$showUnknownHelper = ! str($status)->startsWith('Proxy') && (str($status)->contains('unknown') || str($healthStatus)->contains('unknown'));
|
||||||
|
$showUnhealthyHelper = ! str($status)->startsWith('Proxy') && (str($status)->contains('unhealthy') || str($healthStatus)->contains('unhealthy'));
|
||||||
@endphp
|
@endphp
|
||||||
<div class="flex items-center">
|
<div class="flex items-center gap-1">
|
||||||
<div class="flex items-center">
|
@if ($lastDeploymentLink)
|
||||||
<div wire:loading.delay.longer wire:target="checkProxy(true)" class="badge badge-warning"></div>
|
<x-status-badge as="a" href="{{ $lastDeploymentLink }}" target="_blank" status="{{ $badgeStatus }}" type="success"
|
||||||
<div wire:loading.remove.delay.longer wire:target="checkProxy(true)" class="badge badge-success"></div>
|
title="{{ $title }}" class="cursor-pointer underline" />
|
||||||
<div class="pl-2 pr-1 text-xs font-bold text-success"
|
@else
|
||||||
@if ($title) title="{{ $title }}" @endif>
|
<x-status-badge status="{{ $badgeStatus }}" type="success" title="{{ $title }}" />
|
||||||
@if ($lastDeploymentLink)
|
@endif
|
||||||
<a href="{{ $lastDeploymentLink }}" target="_blank" class="underline cursor-pointer">
|
@if ($showUnknownHelper)
|
||||||
{{ $displayStatus }}
|
<div>
|
||||||
</a>
|
|
||||||
@else
|
|
||||||
{{ $displayStatus }}
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@if ($healthStatus && !str($displayStatus)->contains('('))
|
|
||||||
<div class="text-xs text-success">({{ $healthStatus }})</div>
|
|
||||||
@endif
|
|
||||||
@php
|
|
||||||
$showUnknownHelper =
|
|
||||||
!str($status)->startsWith('Proxy') &&
|
|
||||||
(str($status)->contains('unknown') || str($healthStatus)->contains('unknown'));
|
|
||||||
$showUnhealthyHelper =
|
|
||||||
!str($status)->startsWith('Proxy') &&
|
|
||||||
(str($status)->contains('unhealthy') || str($healthStatus)->contains('unhealthy'));
|
|
||||||
@endphp
|
|
||||||
@if ($showUnknownHelper)
|
|
||||||
<div class="px-2">
|
|
||||||
<x-helper
|
<x-helper
|
||||||
helper="No health check configured. <span class='dark:text-warning text-coollabs'>The resource may be functioning normally.</span><br><br>Traefik and Caddy will route traffic to this container even without a health check. However, configuring a health check is recommended to ensure the resource is ready before receiving traffic.<br><br>More details in the <a href='https://coolify.io/docs/knowledge-base/proxy/traefik/healthchecks' class='underline dark:text-warning text-coollabs' target='_blank'>documentation</a>.">
|
helper="No health check configured. <span class='dark:text-warning text-coollabs'>The resource may be functioning normally.</span><br><br>Traefik and Caddy will route traffic to this container even without a health check. However, configuring a health check is recommended to ensure the resource is ready before receiving traffic.<br><br>More details in the <a href='https://coolify.io/docs/knowledge-base/proxy/traefik/healthchecks' class='underline dark:text-warning text-coollabs' target='_blank'>documentation</a>.">
|
||||||
<x-slot:icon>
|
<x-slot:icon>
|
||||||
<svg class="hidden w-4 h-4 dark:text-warning lg:block" viewBox="0 0 256 256"
|
<x-status-badge status="No health check" type="warning" class="cursor-help" />
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path fill="currentColor"
|
|
||||||
d="M240.26 186.1L152.81 34.23a28.74 28.74 0 0 0-49.62 0L15.74 186.1a27.45 27.45 0 0 0 0 27.71A28.31 28.31 0 0 0 40.55 228h174.9a28.31 28.31 0 0 0 24.79-14.19a27.45 27.45 0 0 0 .02-27.71m-20.8 15.7a4.46 4.46 0 0 1-4 2.2H40.55a4.46 4.46 0 0 1-4-2.2a3.56 3.56 0 0 1 0-3.73L124 46.2a4.77 4.77 0 0 1 8 0l87.44 151.87a3.56 3.56 0 0 1 .02 3.73M116 136v-32a12 12 0 0 1 24 0v32a12 12 0 0 1-24 0m28 40a16 16 0 1 1-16-16a16 16 0 0 1 16 16">
|
|
||||||
</path>
|
|
||||||
</svg>
|
|
||||||
</x-slot:icon>
|
</x-slot:icon>
|
||||||
</x-helper>
|
</x-helper>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@if ($showUnhealthyHelper)
|
@if ($showUnhealthyHelper)
|
||||||
<div class="px-2">
|
<div>
|
||||||
<x-helper
|
<x-helper
|
||||||
helper="Unhealthy state. <span class='dark:text-warning text-coollabs'>The health check is failing.</span><br><br>This resource will <span class='dark:text-warning text-coollabs'>NOT work with Traefik</span> as it expects a healthy state. Your action is required to fix the health check or the underlying issue causing it to fail.<br><br>More details in the <a href='https://coolify.io/docs/knowledge-base/proxy/traefik/healthchecks' class='underline dark:text-warning text-coollabs' target='_blank'>documentation</a>.">
|
helper="Unhealthy state. <span class='dark:text-warning text-coollabs'>The health check is failing.</span><br><br>This resource will <span class='dark:text-warning text-coollabs'>NOT work with Traefik</span> as it expects a healthy state. Your action is required to fix the health check or the underlying issue causing it to fail.<br><br>More details in the <a href='https://coolify.io/docs/knowledge-base/proxy/traefik/healthchecks' class='underline dark:text-warning text-coollabs' target='_blank'>documentation</a>.">
|
||||||
<x-slot:icon>
|
<x-slot:icon>
|
||||||
<svg class="hidden w-4 h-4 dark:text-warning lg:block" viewBox="0 0 256 256"
|
<x-status-badge status="Unhealthy" type="warning" class="cursor-help" />
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path fill="currentColor"
|
|
||||||
d="M240.26 186.1L152.81 34.23a28.74 28.74 0 0 0-49.62 0L15.74 186.1a27.45 27.45 0 0 0 0 27.71A28.31 28.31 0 0 0 40.55 228h174.9a28.31 28.31 0 0 0 24.79-14.19a27.45 27.45 0 0 0 .02-27.71m-20.8 15.7a4.46 4.46 0 0 1-4 2.2H40.55a4.46 4.46 0 0 1-4-2.2a3.56 3.56 0 0 1 0-3.73L124 46.2a4.77 4.77 0 0 1 8 0l87.44 151.87a3.56 3.56 0 0 1 .02 3.73M116 136v-32a12 12 0 0 1 24 0v32a12 12 0 0 1-24 0m28 40a16 16 0 1 1-16-16a16 16 0 0 1 16 16">
|
|
||||||
</path>
|
|
||||||
</svg>
|
|
||||||
</x-slot:icon>
|
</x-slot:icon>
|
||||||
</x-helper>
|
</x-helper>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -14,19 +14,9 @@
|
||||||
<x-status.stopped :status="$displayStatus" />
|
<x-status.stopped :status="$displayStatus" />
|
||||||
@endif
|
@endif
|
||||||
@if (!str($complexStatus)->contains('exited') && $showRefreshButton)
|
@if (!str($complexStatus)->contains('exited') && $showRefreshButton)
|
||||||
<button wire:loading.remove.delay.shortest wire:target="manualCheckStatus" title="Refresh Status" wire:click='manualCheckStatus'
|
<x-status-badge as="button" wire:target="manualCheckStatus" wire:loading.attr="disabled"
|
||||||
class="dark:hover:fill-white fill-black dark:fill-warning">
|
wire:click='manualCheckStatus' status="Refresh" type="neutral" title="Refresh Status"
|
||||||
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
aria-label="Refresh status"
|
||||||
<path
|
class="min-w-[4.5rem] justify-center cursor-pointer border-transparent hover:bg-neutral-200 disabled:cursor-wait disabled:opacity-70 dark:hover:bg-coolgray-300" />
|
||||||
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button wire:loading.delay.shortest wire:target="manualCheckStatus" title="Refreshing Status" wire:click='manualCheckStatus'
|
|
||||||
class="dark:hover:fill-white fill-black dark:fill-warning">
|
|
||||||
<svg class="w-4 h-4 animate-spin" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path
|
|
||||||
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,46 +3,12 @@
|
||||||
'noLoading' => false,
|
'noLoading' => false,
|
||||||
])
|
])
|
||||||
@php
|
@php
|
||||||
// Handle both colon format (backend) and parentheses format (from services.blade.php)
|
|
||||||
// For exited containers, health status is hidden (health checks don't run on stopped containers)
|
|
||||||
// exited:unhealthy → Exited
|
|
||||||
// exited (unhealthy) → Exited
|
|
||||||
|
|
||||||
if (str($status)->contains('(')) {
|
if (str($status)->contains('(')) {
|
||||||
// Already in parentheses format from services.blade.php - use as-is
|
$displayStatus = str($status)->before('(')->trim()->headline()->value();
|
||||||
$displayStatus = $status;
|
|
||||||
$healthStatus = str($status)->after('(')->before(')')->trim()->value();
|
|
||||||
|
|
||||||
// Don't show health status for exited containers (health checks don't run on stopped containers)
|
|
||||||
if (str($displayStatus)->lower()->contains('exited')) {
|
|
||||||
$displayStatus = str($status)->before('(')->trim()->headline();
|
|
||||||
$healthStatus = null;
|
|
||||||
}
|
|
||||||
} elseif (str($status)->contains(':')) {
|
} elseif (str($status)->contains(':')) {
|
||||||
// Colon format from backend - transform it
|
$displayStatus = str(explode(':', $status)[0])->headline()->value();
|
||||||
$parts = explode(':', $status);
|
|
||||||
$displayStatus = str($parts[0])->headline();
|
|
||||||
$healthStatus = $parts[1] ?? null;
|
|
||||||
|
|
||||||
// Don't show health status for exited containers (health checks don't run on stopped containers)
|
|
||||||
if (str($displayStatus)->lower()->contains('exited')) {
|
|
||||||
$healthStatus = null;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Simple status without health
|
$displayStatus = str($status)->headline()->value();
|
||||||
$displayStatus = str($status)->headline();
|
|
||||||
$healthStatus = null;
|
|
||||||
}
|
}
|
||||||
@endphp
|
@endphp
|
||||||
<div class="flex items-center">
|
<x-status-badge status="{{ $displayStatus }}" type="error" />
|
||||||
@if (!$noLoading)
|
|
||||||
<x-loading wire:loading.delay.longer />
|
|
||||||
@endif
|
|
||||||
<span wire:loading.remove.delay.longer class="flex items-center">
|
|
||||||
<div class="badge badge-error "></div>
|
|
||||||
<div class="pl-2 pr-1 text-xs font-bold text-error">{{ $displayStatus }}</div>
|
|
||||||
@if ($healthStatus && !str($displayStatus)->contains('('))
|
|
||||||
<div class="text-xs text-error">({{ $healthStatus }})</div>
|
|
||||||
@endif
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
|
||||||
|
|
@ -48,11 +48,11 @@
|
||||||
characterData: true
|
characterData: true
|
||||||
});" x-destroy="observer && observer.disconnect()"
|
});" x-destroy="observer && observer.disconnect()"
|
||||||
@class([
|
@class([
|
||||||
'flex flex-col w-full px-4 py-2 overflow-y-auto bg-white border border-solid rounded-sm dark:text-white dark:bg-coolgray-100 scrollbar border-neutral-300 dark:border-coolgray-300',
|
'flex flex-col w-full min-w-0 max-w-full px-4 py-2 overflow-y-auto bg-white border border-solid rounded-sm dark:text-white dark:bg-coolgray-100 scrollbar border-neutral-300 dark:border-coolgray-300',
|
||||||
'flex-1 min-h-0' => $fullHeight,
|
'flex-1 min-h-0' => $fullHeight,
|
||||||
'max-h-96' => !$fullHeight,
|
'max-h-96' => !$fullHeight,
|
||||||
])>
|
])>
|
||||||
<pre class="font-logs whitespace-pre-wrap" @if ($isPollingActive) wire:poll.1000ms="polling" @endif>{{ RunRemoteProcess::decodeOutput($activity) }}</pre>
|
<pre class="font-logs min-w-0 max-w-full whitespace-pre-wrap wrap-anywhere" @if ($isPollingActive) wire:poll.1000ms="polling" @endif>{{ RunRemoteProcess::decodeOutput($activity) }}</pre>
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
@if ($showWaiting)
|
@if ($showWaiting)
|
||||||
|
|
|
||||||
|
|
@ -632,7 +632,7 @@ class="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tr
|
||||||
@foreach ($searchResults as $result)
|
@foreach ($searchResults as $result)
|
||||||
@if (!isset($result['is_creatable_suggestion']))
|
@if (!isset($result['is_creatable_suggestion']))
|
||||||
<a href="{{ $result['link'] ?? '#' }}"
|
<a href="{{ $result['link'] ?? '#' }}"
|
||||||
class="search-result-item block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-coolgray-200 transition-colors focus:outline-none focus:bg-warning-50 dark:focus:bg-warning-900/20 border-transparent hover:border-coollabs focus:border-warning-500 dark:focus:border-warning-400">
|
class="search-result-item block px-4 py-3 hover:bg-neutral-100 dark:hover:bg-coolgray-200 transition-colors focus:outline-none focus:bg-neutral-100 dark:focus:bg-coolgray-200 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-coollabs dark:focus-visible:ring-warning">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2 mb-1">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
|
@ -696,12 +696,12 @@ class="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tr
|
||||||
<!-- Category Items -->
|
<!-- Category Items -->
|
||||||
@foreach ($items as $item)
|
@foreach ($items as $item)
|
||||||
<button type="button" wire:click="navigateToResource('{{ $item['type'] }}')"
|
<button type="button" wire:click="navigateToResource('{{ $item['type'] }}')"
|
||||||
class="search-result-item w-full text-left block px-4 py-3 hover:bg-warning-50 dark:hover:bg-warning-900/20 transition-colors focus:outline-none focus:bg-warning-100 dark:focus:bg-warning-900/30 border-transparent hover:border-warning-500 focus:border-warning-500">
|
class="search-result-item w-full text-left block px-4 py-3 hover:bg-neutral-100 dark:hover:bg-coolgray-200 transition-colors focus:outline-none focus:bg-neutral-100 dark:focus:bg-coolgray-200 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-coollabs dark:focus-visible:ring-warning">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||||
@if (! empty($item['logo']))
|
@if (! empty($item['logo']))
|
||||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center overflow-hidden">
|
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center overflow-hidden">
|
||||||
<img src="{{ asset($item['logo']) }}" alt="{{ $item['name'] }}" class="w-7 h-7 object-contain">
|
<img src="{{ asset($item['logo']) }}" alt="{{ $item['name'] }}" class="w-8 h-8 object-contain">
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<div
|
<div
|
||||||
|
|
@ -755,7 +755,7 @@ class="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tr
|
||||||
</template>
|
</template>
|
||||||
<template x-for="(result, index) in searchResults" :key="index">
|
<template x-for="(result, index) in searchResults" :key="index">
|
||||||
<a :href="result.link || '#'"
|
<a :href="result.link || '#'"
|
||||||
class="search-result-item block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-coolgray-200 transition-colors focus:outline-none focus:bg-warning-50 dark:focus:bg-warning-900/20 border-transparent hover:border-coollabs focus:border-warning-500 dark:focus:border-warning-400">
|
class="search-result-item block px-4 py-3 hover:bg-neutral-100 dark:hover:bg-coolgray-200 transition-colors focus:outline-none focus:bg-neutral-100 dark:focus:bg-coolgray-200 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-coollabs dark:focus-visible:ring-warning">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2 mb-1">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
|
@ -811,12 +811,12 @@ class="shrink-0 h-5 w-5 text-neutral-300 dark:text-neutral-600 self-center"
|
||||||
|
|
||||||
<template x-for="item in items" :key="item.type">
|
<template x-for="item in items" :key="item.type">
|
||||||
<button type="button" @click="$wire.navigateToResource(item.type)"
|
<button type="button" @click="$wire.navigateToResource(item.type)"
|
||||||
class="search-result-item w-full text-left block px-4 py-3 hover:bg-warning-50 dark:hover:bg-warning-900/20 transition-colors focus:outline-none focus:bg-warning-100 dark:focus:bg-warning-900/30 border-transparent hover:border-warning-500 focus:border-warning-500">
|
class="search-result-item w-full text-left block px-4 py-3 hover:bg-neutral-100 dark:hover:bg-coolgray-200 transition-colors focus:outline-none focus:bg-neutral-100 dark:focus:bg-coolgray-200 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-coollabs dark:focus-visible:ring-warning">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||||
<template x-if="item.logo">
|
<template x-if="item.logo">
|
||||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center overflow-hidden">
|
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center overflow-hidden">
|
||||||
<img :src="'/' + item.logo" :alt="item.name" class="w-7 h-7 object-contain">
|
<img :src="'/' + item.logo" :alt="item.name" class="w-8 h-8 object-contain">
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!item.logo">
|
<template x-if="!item.logo">
|
||||||
|
|
|
||||||
|
|
@ -6,55 +6,128 @@
|
||||||
<livewire:project.shared.configuration-checker :resource="$application" />
|
<livewire:project.shared.configuration-checker :resource="$application" />
|
||||||
<livewire:project.application.heading :application="$application" />
|
<livewire:project.application.heading :application="$application" />
|
||||||
|
|
||||||
<div class="flex flex-col h-full gap-8 sm:flex-row">
|
@php
|
||||||
<div class="sub-menu-wrapper">
|
$applicationRouteParameters = [
|
||||||
<a class='sub-menu-item' {{ wireNavigate() }} wire:current.exact="menu-item-active"
|
'project_uuid' => $project->uuid,
|
||||||
href="{{ route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">General</span></a>
|
'environment_uuid' => $environment->uuid,
|
||||||
<a class='sub-menu-item' {{ wireNavigate() }} wire:current.exact="menu-item-active"
|
'application_uuid' => $application->uuid,
|
||||||
href="{{ route('project.application.advanced', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Advanced</span></a>
|
];
|
||||||
@if ($application->destination->server->isSwarm())
|
|
||||||
<a class="sub-menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
|
$configurationMenuItems = [
|
||||||
href="{{ route('project.application.swarm', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Swarm</span>
|
[
|
||||||
|
'label' => 'General',
|
||||||
|
'route' => 'project.application.configuration',
|
||||||
|
'active' => $currentRoute === 'project.application.configuration',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Advanced',
|
||||||
|
'route' => 'project.application.advanced',
|
||||||
|
'active' => $currentRoute === 'project.application.advanced',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Swarm',
|
||||||
|
'route' => 'project.application.swarm',
|
||||||
|
'active' => $currentRoute === 'project.application.swarm',
|
||||||
|
'visible' => $application->destination->server->isSwarm(),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Environment Variables',
|
||||||
|
'route' => 'project.application.environment-variables',
|
||||||
|
'active' => $currentRoute === 'project.application.environment-variables',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Persistent Storage',
|
||||||
|
'route' => 'project.application.persistent-storage',
|
||||||
|
'active' => $currentRoute === 'project.application.persistent-storage',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Git Source',
|
||||||
|
'route' => 'project.application.source',
|
||||||
|
'active' => $currentRoute === 'project.application.source',
|
||||||
|
'visible' => $application->git_based(),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Servers',
|
||||||
|
'route' => 'project.application.servers',
|
||||||
|
'active' => $currentRoute === 'project.application.servers',
|
||||||
|
'badge' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Scheduled Tasks',
|
||||||
|
'route' => 'project.application.scheduled-tasks.show',
|
||||||
|
'active' => str($currentRoute)->startsWith('project.application.scheduled-tasks'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Webhooks',
|
||||||
|
'route' => 'project.application.webhooks',
|
||||||
|
'active' => $currentRoute === 'project.application.webhooks',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Preview Deployments',
|
||||||
|
'route' => 'project.application.preview-deployments',
|
||||||
|
'active' => $currentRoute === 'project.application.preview-deployments',
|
||||||
|
'visible' => $application->git_based() || $application->build_pack === 'dockerimage',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Healthcheck',
|
||||||
|
'route' => 'project.application.healthcheck',
|
||||||
|
'active' => $currentRoute === 'project.application.healthcheck',
|
||||||
|
'visible' => $application->build_pack !== 'dockercompose',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Rollback',
|
||||||
|
'route' => 'project.application.rollback',
|
||||||
|
'active' => $currentRoute === 'project.application.rollback',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Resource Limits',
|
||||||
|
'route' => 'project.application.resource-limits',
|
||||||
|
'active' => $currentRoute === 'project.application.resource-limits',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Resource Operations',
|
||||||
|
'route' => 'project.application.resource-operations',
|
||||||
|
'active' => $currentRoute === 'project.application.resource-operations',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Metrics',
|
||||||
|
'route' => 'project.application.metrics',
|
||||||
|
'active' => $currentRoute === 'project.application.metrics',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Tags',
|
||||||
|
'route' => 'project.application.tags',
|
||||||
|
'active' => $currentRoute === 'project.application.tags',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Danger Zone',
|
||||||
|
'route' => 'project.application.danger',
|
||||||
|
'active' => $currentRoute === 'project.application.danger',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$configurationMenuItems = array_values(array_filter(
|
||||||
|
$configurationMenuItems,
|
||||||
|
fn (array $item): bool => $item['visible'] ?? true,
|
||||||
|
));
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
|
||||||
|
<div class="sub-menu-wrapper hidden md:flex">
|
||||||
|
@foreach ($configurationMenuItems as $menuItem)
|
||||||
|
<a @class([
|
||||||
|
'sub-menu-item',
|
||||||
|
'flex items-center gap-2' => $menuItem['badge'] ?? false,
|
||||||
|
'menu-item-active' => $menuItem['active'],
|
||||||
|
]) {{ wireNavigate() }} href="{{ route($menuItem['route'], $applicationRouteParameters) }}">
|
||||||
|
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
|
||||||
|
@if ($menuItem['badge'] ?? false)
|
||||||
|
<livewire:project.application.server-status-badge :application="$application" />
|
||||||
|
@endif
|
||||||
</a>
|
</a>
|
||||||
@endif
|
@endforeach
|
||||||
<a class='sub-menu-item' {{ wireNavigate() }} wire:current.exact="menu-item-active"
|
|
||||||
href="{{ route('project.application.environment-variables', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Environment Variables</span></a>
|
|
||||||
<a class='sub-menu-item' {{ wireNavigate() }} wire:current.exact="menu-item-active"
|
|
||||||
href="{{ route('project.application.persistent-storage', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Persistent Storage</span></a>
|
|
||||||
@if ($application->git_based())
|
|
||||||
<a class='sub-menu-item' {{ wireNavigate() }} wire:current.exact="menu-item-active"
|
|
||||||
href="{{ route('project.application.source', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Git Source</span></a>
|
|
||||||
@endif
|
|
||||||
<a class="sub-menu-item flex items-center gap-2" {{ wireNavigate() }} wire:current.exact="menu-item-active"
|
|
||||||
href="{{ route('project.application.servers', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Servers</span>
|
|
||||||
<livewire:project.application.server-status-badge :application="$application" />
|
|
||||||
</a>
|
|
||||||
<a @class(['sub-menu-item', 'menu-item-active' => str($currentRoute)->startsWith('project.application.scheduled-tasks')]) {{ wireNavigate() }}
|
|
||||||
href="{{ route('project.application.scheduled-tasks.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Scheduled Tasks</span></a>
|
|
||||||
<a class="sub-menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
|
|
||||||
href="{{ route('project.application.webhooks', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Webhooks</span></a>
|
|
||||||
@if ($application->git_based() || $application->build_pack === 'dockerimage')
|
|
||||||
<a class="sub-menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
|
|
||||||
href="{{ route('project.application.preview-deployments', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Preview Deployments</span></a>
|
|
||||||
@endif
|
|
||||||
@if ($application->build_pack !== 'dockercompose')
|
|
||||||
<a class="sub-menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
|
|
||||||
href="{{ route('project.application.healthcheck', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Healthcheck</span></a>
|
|
||||||
@endif
|
|
||||||
<a class="sub-menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
|
|
||||||
href="{{ route('project.application.rollback', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Rollback</span></a>
|
|
||||||
<a class="sub-menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
|
|
||||||
href="{{ route('project.application.resource-limits', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Resource Limits</span></a>
|
|
||||||
<a class="sub-menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
|
|
||||||
href="{{ route('project.application.resource-operations', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Resource Operations</span></a>
|
|
||||||
<a class="sub-menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
|
|
||||||
href="{{ route('project.application.metrics', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Metrics</span></a>
|
|
||||||
<a class="sub-menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
|
|
||||||
href="{{ route('project.application.tags', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Tags</span></a>
|
|
||||||
<a class="sub-menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
|
|
||||||
href="{{ route('project.application.danger', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Danger Zone</span></a>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full sm:flex-grow">
|
<div class="w-full md:flex-grow">
|
||||||
@if ($currentRoute === 'project.application.configuration')
|
@if ($currentRoute === 'project.application.configuration')
|
||||||
<livewire:project.application.general :application="$application" />
|
<livewire:project.application.general :application="$application" />
|
||||||
@elseif ($currentRoute === 'project.application.swarm' && $application->destination->server->isSwarm())
|
@elseif ($currentRoute === 'project.application.swarm' && $application->destination->server->isSwarm())
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,453 @@
|
||||||
<nav wire:poll.10000ms="checkStatus" class="pb-6">
|
<nav wire:poll.10000ms="checkStatus" class="pb-6">
|
||||||
|
@php
|
||||||
|
$applicationMenuItems = [
|
||||||
|
[
|
||||||
|
'label' => 'Configuration',
|
||||||
|
'route' => 'project.application.configuration',
|
||||||
|
'active' => request()->routeIs('project.application.configuration'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Deployments',
|
||||||
|
'route' => 'project.application.deployment.index',
|
||||||
|
'active' => request()->routeIs('project.application.deployment.index', 'project.application.deployment.show'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Logs',
|
||||||
|
'route' => 'project.application.logs',
|
||||||
|
'active' => request()->routeIs('project.application.logs'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Terminal',
|
||||||
|
'route' => 'project.application.command',
|
||||||
|
'active' => request()->routeIs('project.application.command'),
|
||||||
|
'navigate' => false,
|
||||||
|
'visible' => ! $application->destination->server->isSwarm() && auth()->user()?->can('canAccessTerminal'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$configurationMenuItems = [
|
||||||
|
[
|
||||||
|
'label' => 'General',
|
||||||
|
'route' => 'project.application.configuration',
|
||||||
|
'active' => request()->routeIs('project.application.configuration'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Advanced',
|
||||||
|
'route' => 'project.application.advanced',
|
||||||
|
'active' => request()->routeIs('project.application.advanced'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Swarm',
|
||||||
|
'route' => 'project.application.swarm',
|
||||||
|
'active' => request()->routeIs('project.application.swarm'),
|
||||||
|
'visible' => $application->destination->server->isSwarm(),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Environment Variables',
|
||||||
|
'route' => 'project.application.environment-variables',
|
||||||
|
'active' => request()->routeIs('project.application.environment-variables'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Persistent Storage',
|
||||||
|
'route' => 'project.application.persistent-storage',
|
||||||
|
'active' => request()->routeIs('project.application.persistent-storage'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Git Source',
|
||||||
|
'route' => 'project.application.source',
|
||||||
|
'active' => request()->routeIs('project.application.source'),
|
||||||
|
'visible' => $application->git_based(),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Servers',
|
||||||
|
'route' => 'project.application.servers',
|
||||||
|
'active' => request()->routeIs('project.application.servers'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Scheduled Tasks',
|
||||||
|
'route' => 'project.application.scheduled-tasks.show',
|
||||||
|
'active' => request()->routeIs('project.application.scheduled-tasks.show', 'project.application.scheduled-tasks'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Webhooks',
|
||||||
|
'route' => 'project.application.webhooks',
|
||||||
|
'active' => request()->routeIs('project.application.webhooks'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Preview Deployments',
|
||||||
|
'route' => 'project.application.preview-deployments',
|
||||||
|
'active' => request()->routeIs('project.application.preview-deployments'),
|
||||||
|
'visible' => $application->git_based() || $application->build_pack === 'dockerimage',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Healthcheck',
|
||||||
|
'route' => 'project.application.healthcheck',
|
||||||
|
'active' => request()->routeIs('project.application.healthcheck'),
|
||||||
|
'visible' => $application->build_pack !== 'dockercompose',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Rollback',
|
||||||
|
'route' => 'project.application.rollback',
|
||||||
|
'active' => request()->routeIs('project.application.rollback'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Resource Limits',
|
||||||
|
'route' => 'project.application.resource-limits',
|
||||||
|
'active' => request()->routeIs('project.application.resource-limits'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Resource Operations',
|
||||||
|
'route' => 'project.application.resource-operations',
|
||||||
|
'active' => request()->routeIs('project.application.resource-operations'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Metrics',
|
||||||
|
'route' => 'project.application.metrics',
|
||||||
|
'active' => request()->routeIs('project.application.metrics'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Tags',
|
||||||
|
'route' => 'project.application.tags',
|
||||||
|
'active' => request()->routeIs('project.application.tags'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Danger Zone',
|
||||||
|
'route' => 'project.application.danger',
|
||||||
|
'active' => request()->routeIs('project.application.danger'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$applicationMenuItems = array_values(array_filter(
|
||||||
|
$applicationMenuItems,
|
||||||
|
fn (array $item): bool => $item['visible'] ?? true,
|
||||||
|
));
|
||||||
|
$configurationMenuItems = array_values(array_filter(
|
||||||
|
$configurationMenuItems,
|
||||||
|
fn (array $item): bool => $item['visible'] ?? true,
|
||||||
|
));
|
||||||
|
$activeConfigurationMenuItem = collect($configurationMenuItems)->firstWhere('active', true);
|
||||||
|
$activeApplicationMenuItem = collect($applicationMenuItems)->firstWhere('active', true);
|
||||||
|
$activeMobileMenuItem = $activeConfigurationMenuItem
|
||||||
|
?? $activeApplicationMenuItem
|
||||||
|
?? $applicationMenuItems[0];
|
||||||
|
$activeMobileMenuGroup = $activeConfigurationMenuItem ? 'configuration' : 'application';
|
||||||
|
$activeMobileNavigation = ($activeMobileMenuItem['navigate'] ?? true) ? 'navigate' : 'location';
|
||||||
|
$activeMobileMenuValue = $activeMobileNavigation.'|'.$activeMobileMenuGroup.'|'.route($activeMobileMenuItem['route'], $parameters);
|
||||||
|
$mobileSectionChangeHandler = <<<'JS'
|
||||||
|
const value = $event.target.value;
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.startsWith('navigate|')) {
|
||||||
|
const url = value.split('|').slice(2).join('|');
|
||||||
|
window.Livewire?.navigate ? window.Livewire.navigate(url) : window.location.href = url;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.startsWith('location|')) {
|
||||||
|
const url = value.split('|').slice(2).join('|');
|
||||||
|
window.location.href = url;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetToCurrent();
|
||||||
|
|
||||||
|
if (value.startsWith('external:')) {
|
||||||
|
window.open(value.slice(9), '_blank', 'noopener');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = value.slice(7);
|
||||||
|
document.getElementById(`application-mobile-${action}-trigger`)?.click();
|
||||||
|
JS;
|
||||||
|
@endphp
|
||||||
<x-resources.breadcrumbs :resource="$application" :parameters="$parameters" :title="$lastDeploymentInfo" :lastDeploymentLink="$lastDeploymentLink" />
|
<x-resources.breadcrumbs :resource="$application" :parameters="$parameters" :title="$lastDeploymentInfo" :lastDeploymentLink="$lastDeploymentLink" />
|
||||||
<div class="navbar-main">
|
<div class="navbar-main">
|
||||||
|
<div class="w-full md:hidden">
|
||||||
|
@if (!($application->build_pack === 'dockercompose' && is_null($application->docker_compose_raw)))
|
||||||
|
<div id="application-mobile-actions" class="mt-2 mb-3 md:hidden">
|
||||||
|
<div class="mb-1 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Actions</div>
|
||||||
|
<div class="flex flex-nowrap items-center gap-2 overflow-x-auto">
|
||||||
|
@if (!str($application->status)->startsWith('exited'))
|
||||||
|
@if (!$application->destination->server->isSwarm())
|
||||||
|
<button type="button" class="button shrink-0"
|
||||||
|
@click="document.getElementById('application-mobile-deploy-trigger')?.click()">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-orange-400"
|
||||||
|
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
|
<path
|
||||||
|
d="M10.09 4.01l.496 -.495a2 2 0 0 1 2.828 0l7.071 7.07a2 2 0 0 1 0 2.83l-7.07 7.07a2 2 0 0 1 -2.83 0l-7.07 -7.07a2 2 0 0 1 0 -2.83l3.535 -3.535h-3.988">
|
||||||
|
</path>
|
||||||
|
<path d="M7.05 11.038v-3.988"></path>
|
||||||
|
</svg>
|
||||||
|
Redeploy
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
@if ($application->build_pack !== 'dockercompose')
|
||||||
|
@if ($application->destination->server->isSwarm())
|
||||||
|
<button type="button" class="button shrink-0"
|
||||||
|
@click="document.getElementById('application-mobile-deploy-trigger')?.click()">
|
||||||
|
<svg class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round" stroke-width="2">
|
||||||
|
<path
|
||||||
|
d="M19.933 13.041a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747" />
|
||||||
|
<path d="M20 4v5h-5" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
Update Service
|
||||||
|
</button>
|
||||||
|
@else
|
||||||
|
<button type="button" class="button shrink-0"
|
||||||
|
@click="document.getElementById('application-mobile-restart-trigger')?.click()">
|
||||||
|
<svg class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round" stroke-width="2">
|
||||||
|
<path
|
||||||
|
d="M19.933 13.041a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747" />
|
||||||
|
<path d="M20 4v5h-5" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
Restart
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
<x-forms.button isError class="shrink-0"
|
||||||
|
@click="document.getElementById('application-mobile-stop-trigger')?.click()">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" viewBox="0 0 24 24"
|
||||||
|
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
|
<path
|
||||||
|
d="M6 5m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z">
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
d="M14 5m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
Stop
|
||||||
|
</x-forms.button>
|
||||||
|
@else
|
||||||
|
<button type="button" class="button shrink-0"
|
||||||
|
@click="document.getElementById('application-mobile-deploy-trigger')?.click()">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-warning"
|
||||||
|
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M7 4v16l13 -8z" />
|
||||||
|
</svg>
|
||||||
|
Deploy
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
@if (!$application->destination->server->isSwarm())
|
||||||
|
@if ($application->status === 'running')
|
||||||
|
<button type="button" class="button shrink-0"
|
||||||
|
@click="document.getElementById('application-mobile-force-deploy-trigger')?.click()">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-warning"
|
||||||
|
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M7 4v16l13 -8z" />
|
||||||
|
</svg>
|
||||||
|
Force deploy (without cache)
|
||||||
|
</button>
|
||||||
|
@else
|
||||||
|
<button type="button" class="button shrink-0"
|
||||||
|
@click="document.getElementById('application-mobile-deploy-force-trigger')?.click()">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-warning"
|
||||||
|
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M7 4v16l13 -8z" />
|
||||||
|
</svg>
|
||||||
|
Force deploy (without cache)
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<label id="application-mobile-section-label" for="application-mobile-section" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Section</label>
|
||||||
|
<select id="application-mobile-section" class="select w-full" aria-label="Application menu"
|
||||||
|
data-current-value="{{ $activeMobileMenuValue }}"
|
||||||
|
x-data="{
|
||||||
|
init() {
|
||||||
|
this.syncFromLocation();
|
||||||
|
window.Livewire?.hook?.('morphed', ({ el }) => {
|
||||||
|
if (el.contains(this.$el)) {
|
||||||
|
queueMicrotask(() => this.syncFromLocation());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
selected: $el.dataset.currentValue,
|
||||||
|
current: $el.dataset.currentValue,
|
||||||
|
syncFromLocation() {
|
||||||
|
const currentUrl = new URL(window.location.href);
|
||||||
|
const matchingOptions = Array.from(this.$el.options).filter((option) => {
|
||||||
|
if (!option.value.startsWith('navigate|') && !option.value.startsWith('location|')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionUrl = new URL(option.value.split('|').slice(2).join('|'), window.location.origin);
|
||||||
|
|
||||||
|
return optionUrl.pathname === currentUrl.pathname;
|
||||||
|
});
|
||||||
|
const selectedOption = matchingOptions.find((option) => {
|
||||||
|
return option.value.startsWith('navigate|configuration|') || option.value.startsWith('navigate|resource|');
|
||||||
|
}) || matchingOptions[0];
|
||||||
|
|
||||||
|
if (selectedOption) {
|
||||||
|
this.current = selectedOption.value;
|
||||||
|
this.selected = selectedOption.value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resetToCurrent() {
|
||||||
|
this.selected = this.current;
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
x-on:livewire:navigated.window="syncFromLocation()"
|
||||||
|
x-model="selected"
|
||||||
|
x-on:change="{{ $mobileSectionChangeHandler }}">
|
||||||
|
<optgroup label="Application">
|
||||||
|
@foreach ($applicationMenuItems as $menuItem)
|
||||||
|
<option value="{{ ($menuItem['navigate'] ?? true) ? 'navigate' : 'location' }}|application|{{ route($menuItem['route'], $parameters) }}">
|
||||||
|
{{ $menuItem['label'] }}
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Configuration">
|
||||||
|
@foreach ($configurationMenuItems as $menuItem)
|
||||||
|
<option value="navigate|configuration|{{ route($menuItem['route'], $parameters) }}">
|
||||||
|
{{ $menuItem['label'] }}
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Links">
|
||||||
|
@if (
|
||||||
|
(data_get($application, 'fqdn') ||
|
||||||
|
collect(json_decode($application->docker_compose_domains))->contains(fn($fqdn) => !empty(data_get($fqdn, 'domain'))) ||
|
||||||
|
data_get($application, 'previews', collect([]))->count() > 0 ||
|
||||||
|
data_get($application, 'ports_mappings_array')) &&
|
||||||
|
data_get($application, 'settings.is_raw_compose_deployment_enabled') !== true)
|
||||||
|
@if (data_get($application, 'gitBrancLocation'))
|
||||||
|
<option value="external:{{ $application->gitBranchLocation }}">Git Repository</option>
|
||||||
|
@endif
|
||||||
|
@if (data_get($application, 'build_pack') === 'dockercompose')
|
||||||
|
@foreach (collect(json_decode($application->docker_compose_domains)) as $fqdn)
|
||||||
|
@if (data_get($fqdn, 'domain'))
|
||||||
|
@foreach (explode(',', data_get($fqdn, 'domain')) as $domain)
|
||||||
|
<option value="external:{{ getFqdnWithoutPort($domain) }}">{{ getFqdnWithoutPort($domain) }}</option>
|
||||||
|
@endforeach
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
@endif
|
||||||
|
@if (data_get($application, 'fqdn'))
|
||||||
|
@foreach (str(data_get($application, 'fqdn'))->explode(',') as $fqdn)
|
||||||
|
<option value="external:{{ getFqdnWithoutPort($fqdn) }}">{{ getFqdnWithoutPort($fqdn) }}</option>
|
||||||
|
@endforeach
|
||||||
|
@endif
|
||||||
|
@if (data_get($application, 'previews', collect())->count() > 0)
|
||||||
|
@if (data_get($application, 'build_pack') === 'dockercompose')
|
||||||
|
@foreach ($application->previews as $preview)
|
||||||
|
@foreach (collect(json_decode($preview->docker_compose_domains)) as $fqdn)
|
||||||
|
@if (data_get($fqdn, 'domain'))
|
||||||
|
@foreach (explode(',', data_get($fqdn, 'domain')) as $domain)
|
||||||
|
<option value="external:{{ getFqdnWithoutPort($domain) }}">PR{{ data_get($preview, 'pull_request_id') }} | {{ getFqdnWithoutPort($domain) }}</option>
|
||||||
|
@endforeach
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
@endforeach
|
||||||
|
@else
|
||||||
|
@foreach (data_get($application, 'previews') as $preview)
|
||||||
|
@if (data_get($preview, 'fqdn'))
|
||||||
|
<option value="external:{{ getFqdnWithoutPort(data_get($preview, 'fqdn')) }}">PR{{ data_get($preview, 'pull_request_id') }} | {{ data_get($preview, 'fqdn') }}</option>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
@if (data_get($application, 'ports_mappings_array'))
|
||||||
|
@foreach ($application->ports_mappings_array as $port)
|
||||||
|
@if ($application->destination->server->id === 0)
|
||||||
|
<option value="external:http://localhost:{{ explode(':', $port)[0] }}">Port {{ $port }}</option>
|
||||||
|
@else
|
||||||
|
<option value="external:http://{{ $application->destination->server->ip }}:{{ explode(':', $port)[0] }}">{{ $application->destination->server->ip }}:{{ explode(':', $port)[0] }}</option>
|
||||||
|
@if (count($application->additional_servers) > 0)
|
||||||
|
@foreach ($application->additional_servers as $server)
|
||||||
|
<option value="external:http://{{ $server->ip }}:{{ explode(':', $port)[0] }}">{{ $server->ip }}:{{ explode(':', $port)[0] }}</option>
|
||||||
|
@endforeach
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
<option disabled>No links available</option>
|
||||||
|
@endif
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
<x-modal-confirmation title="Confirm Application Stopping?" buttonTitle="Stop"
|
||||||
|
submitAction="stop" :checkboxes="$checkboxes" :actions="[
|
||||||
|
'This application will be stopped.',
|
||||||
|
'All non-persistent data of this application will be deleted.',
|
||||||
|
]" :confirmWithText="false" :confirmWithPassword="false"
|
||||||
|
step1ButtonText="Continue" step2ButtonText="Confirm">
|
||||||
|
<x-slot:trigger>
|
||||||
|
<button id="application-mobile-stop-trigger" type="button" class="hidden">Stop</button>
|
||||||
|
</x-slot:trigger>
|
||||||
|
</x-modal-confirmation>
|
||||||
|
<x-modal-confirmation title="Confirm Application Deployment?" buttonTitle="Deploy"
|
||||||
|
submitAction="deploy" :actions="[
|
||||||
|
'This application will be deployed.',
|
||||||
|
]" :confirmWithText="false" :confirmWithPassword="false"
|
||||||
|
step2ButtonText="Confirm">
|
||||||
|
<x-slot:trigger>
|
||||||
|
<button id="application-mobile-deploy-trigger" type="button" class="hidden">Deploy</button>
|
||||||
|
</x-slot:trigger>
|
||||||
|
</x-modal-confirmation>
|
||||||
|
<x-modal-confirmation title="Confirm Application Restart?" buttonTitle="Restart"
|
||||||
|
submitAction="restart" :actions="[
|
||||||
|
'This application will be restarted without rebuilding.',
|
||||||
|
]" :confirmWithText="false" :confirmWithPassword="false"
|
||||||
|
step2ButtonText="Confirm">
|
||||||
|
<x-slot:trigger>
|
||||||
|
<button id="application-mobile-restart-trigger" type="button" class="hidden">Restart</button>
|
||||||
|
</x-slot:trigger>
|
||||||
|
</x-modal-confirmation>
|
||||||
|
<x-modal-confirmation title="Confirm Application Force Deployment?" buttonTitle="Force deploy"
|
||||||
|
submitAction="force_deploy_without_cache" :actions="[
|
||||||
|
'This application will be force deployed without build cache.',
|
||||||
|
]" :confirmWithText="false" :confirmWithPassword="false"
|
||||||
|
step2ButtonText="Confirm">
|
||||||
|
<x-slot:trigger>
|
||||||
|
<button id="application-mobile-force-deploy-trigger" type="button" class="hidden">Force deploy</button>
|
||||||
|
</x-slot:trigger>
|
||||||
|
</x-modal-confirmation>
|
||||||
|
<x-modal-confirmation title="Confirm Application Force Deployment?" buttonTitle="Force deploy"
|
||||||
|
submitAction="deploy(true)" :actions="[
|
||||||
|
'This application will be force deployed without build cache.',
|
||||||
|
]" :confirmWithText="false" :confirmWithPassword="false"
|
||||||
|
step2ButtonText="Confirm">
|
||||||
|
<x-slot:trigger>
|
||||||
|
<button id="application-mobile-deploy-force-trigger" type="button" class="hidden">Force deploy</button>
|
||||||
|
</x-slot:trigger>
|
||||||
|
</x-modal-confirmation>
|
||||||
|
</div>
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
class="scrollbar flex min-h-10 w-full flex-nowrap items-center gap-6 overflow-x-scroll overflow-y-hidden pb-1 whitespace-nowrap md:w-auto md:overflow-visible">
|
class="scrollbar hidden min-h-10 w-full flex-nowrap items-center gap-6 overflow-x-scroll overflow-y-hidden pb-1 whitespace-nowrap md:flex md:w-auto md:overflow-visible">
|
||||||
<a class="shrink-0 {{ request()->routeIs('project.application.configuration') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
|
<a class="hidden md:block shrink-0 {{ request()->routeIs('project.application.configuration') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
|
||||||
href="{{ route('project.application.configuration', $parameters) }}">
|
href="{{ route('project.application.configuration', $parameters) }}">
|
||||||
Configuration
|
Configuration
|
||||||
</a>
|
</a>
|
||||||
<a class="shrink-0 {{ request()->routeIs('project.application.deployment.index') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
|
<a class="hidden md:block shrink-0 {{ request()->routeIs('project.application.deployment.index') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
|
||||||
href="{{ route('project.application.deployment.index', $parameters) }}">
|
href="{{ route('project.application.deployment.index', $parameters) }}">
|
||||||
Deployments
|
Deployments
|
||||||
</a>
|
</a>
|
||||||
<a class="shrink-0 {{ request()->routeIs('project.application.logs') ? 'dark:text-white' : '' }}"
|
<a class="hidden md:block shrink-0 {{ request()->routeIs('project.application.logs') ? 'dark:text-white' : '' }}"
|
||||||
href="{{ route('project.application.logs', $parameters) }}">
|
href="{{ route('project.application.logs', $parameters) }}">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
Logs
|
Logs
|
||||||
|
|
@ -24,13 +460,13 @@ class="scrollbar flex min-h-10 w-full flex-nowrap items-center gap-6 overflow-x-
|
||||||
</a>
|
</a>
|
||||||
@if (!$application->destination->server->isSwarm())
|
@if (!$application->destination->server->isSwarm())
|
||||||
@can('canAccessTerminal')
|
@can('canAccessTerminal')
|
||||||
<a class="shrink-0 {{ request()->routeIs('project.application.command') ? 'dark:text-white' : '' }}"
|
<a class="hidden md:block shrink-0 {{ request()->routeIs('project.application.command') ? 'dark:text-white' : '' }}"
|
||||||
href="{{ route('project.application.command', $parameters) }}">
|
href="{{ route('project.application.command', $parameters) }}">
|
||||||
Terminal
|
Terminal
|
||||||
</a>
|
</a>
|
||||||
@endcan
|
@endcan
|
||||||
@endif
|
@endif
|
||||||
<div class="shrink-0">
|
<div class="hidden shrink-0 md:block">
|
||||||
<x-applications.links :application="$application" />
|
<x-applications.links :application="$application" />
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -38,62 +474,6 @@ class="scrollbar flex min-h-10 w-full flex-nowrap items-center gap-6 overflow-x-
|
||||||
@if ($application->build_pack === 'dockercompose' && is_null($application->docker_compose_raw))
|
@if ($application->build_pack === 'dockercompose' && is_null($application->docker_compose_raw))
|
||||||
<div>Please load a Compose file.</div>
|
<div>Please load a Compose file.</div>
|
||||||
@else
|
@else
|
||||||
<div class="md:hidden">
|
|
||||||
<x-dropdown>
|
|
||||||
<x-slot:title>
|
|
||||||
Actions
|
|
||||||
</x-slot>
|
|
||||||
@if (!str($application->status)->startsWith('exited'))
|
|
||||||
@if (!$application->destination->server->isSwarm())
|
|
||||||
<div class="dropdown-item dropdown-item-touch" wire:click='deploy'>
|
|
||||||
Redeploy
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
@if ($application->build_pack !== 'dockercompose')
|
|
||||||
@if ($application->destination->server->isSwarm())
|
|
||||||
<div class="dropdown-item dropdown-item-touch" wire:click='deploy'>
|
|
||||||
Update Service
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<div class="dropdown-item dropdown-item-touch" wire:click='restart'>
|
|
||||||
Restart
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
@endif
|
|
||||||
<x-modal-confirmation title="Confirm Application Stopping?" buttonTitle="Stop"
|
|
||||||
submitAction="stop" :checkboxes="$checkboxes" :actions="[
|
|
||||||
'This application will be stopped.',
|
|
||||||
'All non-persistent data of this application will be deleted.',
|
|
||||||
]" :confirmWithText="false" :confirmWithPassword="false"
|
|
||||||
step1ButtonText="Continue" step2ButtonText="Confirm">
|
|
||||||
<x-slot:trigger>
|
|
||||||
<div class="dropdown-item dropdown-item-touch text-error">
|
|
||||||
Stop
|
|
||||||
</div>
|
|
||||||
</x-slot:trigger>
|
|
||||||
</x-modal-confirmation>
|
|
||||||
@else
|
|
||||||
<div class="dropdown-item dropdown-item-touch" wire:click='deploy'>
|
|
||||||
Deploy
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if (!$application->destination->server->isSwarm())
|
|
||||||
<div class="mx-2 my-1 border-t border-neutral-200 dark:border-coolgray-300"></div>
|
|
||||||
|
|
||||||
@if ($application->status === 'running')
|
|
||||||
<div class="dropdown-item dropdown-item-touch" wire:click='force_deploy_without_cache'>
|
|
||||||
Force deploy (without cache)
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<div class="dropdown-item dropdown-item-touch" wire:click='deploy(true)'>
|
|
||||||
Force deploy (without cache)
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
@endif
|
|
||||||
</x-dropdown>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hidden flex-wrap items-center gap-2 md:flex">
|
<div class="hidden flex-wrap items-center gap-2 md:flex">
|
||||||
@if (!$application->destination->server->isSwarm())
|
@if (!$application->destination->server->isSwarm())
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -103,46 +483,72 @@ class="scrollbar flex min-h-10 w-full flex-nowrap items-center gap-6 overflow-x-
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
@if (!str($application->status)->startsWith('exited'))
|
@if (!str($application->status)->startsWith('exited'))
|
||||||
@if (!$application->destination->server->isSwarm())
|
@if (!$application->destination->server->isSwarm())
|
||||||
<x-forms.button canGate="deploy" :canResource="$application" title="With rolling update if possible" wire:click='deploy'>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-orange-400"
|
<x-modal-confirmation title="Confirm Application Deployment?" buttonTitle="Redeploy"
|
||||||
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
|
submitAction="deploy" :actions="[
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
'This application will be redeployed.',
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
]" :confirmWithText="false" :confirmWithPassword="false"
|
||||||
<path
|
step2ButtonText="Confirm">
|
||||||
d="M10.09 4.01l.496 -.495a2 2 0 0 1 2.828 0l7.071 7.07a2 2 0 0 1 0 2.83l-7.07 7.07a2 2 0 0 1 -2.83 0l-7.07 -7.07a2 2 0 0 1 0 -2.83l3.535 -3.535h-3.988">
|
<x-slot:content>
|
||||||
</path>
|
<x-forms.button canGate="deploy" :canResource="$application" title="With rolling update if possible">
|
||||||
<path d="M7.05 11.038v-3.988"></path>
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-orange-400"
|
||||||
</svg>
|
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
|
||||||
Redeploy
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
</x-forms.button>
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
|
<path
|
||||||
|
d="M10.09 4.01l.496 -.495a2 2 0 0 1 2.828 0l7.071 7.07a2 2 0 0 1 0 2.83l-7.07 7.07a2 2 0 0 1 -2.83 0l-7.07 -7.07a2 2 0 0 1 0 -2.83l3.535 -3.535h-3.988">
|
||||||
|
</path>
|
||||||
|
<path d="M7.05 11.038v-3.988"></path>
|
||||||
|
</svg>
|
||||||
|
Redeploy
|
||||||
|
</x-forms.button>
|
||||||
|
</x-slot:content>
|
||||||
|
</x-modal-confirmation>
|
||||||
@endif
|
@endif
|
||||||
@if ($application->build_pack !== 'dockercompose')
|
@if ($application->build_pack !== 'dockercompose')
|
||||||
@if ($application->destination->server->isSwarm())
|
@if ($application->destination->server->isSwarm())
|
||||||
<x-forms.button canGate="deploy" :canResource="$application" title="Redeploy Swarm Service (rolling update)" wire:click='deploy'>
|
<x-modal-confirmation title="Confirm Application Deployment?" buttonTitle="Update Service"
|
||||||
<svg class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24"
|
submitAction="deploy" :actions="[
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
'This Swarm service will be updated with a rolling deployment.',
|
||||||
<g fill="none" stroke="currentColor" stroke-linecap="round"
|
]" :confirmWithText="false" :confirmWithPassword="false"
|
||||||
stroke-linejoin="round" stroke-width="2">
|
step2ButtonText="Confirm">
|
||||||
<path
|
<x-slot:content>
|
||||||
d="M19.933 13.041a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747" />
|
<x-forms.button canGate="deploy" :canResource="$application" title="Redeploy Swarm Service (rolling update)">
|
||||||
<path d="M20 4v5h-5" />
|
<svg class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24"
|
||||||
</g>
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
</svg>
|
<g fill="none" stroke="currentColor" stroke-linecap="round"
|
||||||
Update Service
|
stroke-linejoin="round" stroke-width="2">
|
||||||
</x-forms.button>
|
<path
|
||||||
|
d="M19.933 13.041a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747" />
|
||||||
|
<path d="M20 4v5h-5" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
Update Service
|
||||||
|
</x-forms.button>
|
||||||
|
</x-slot:content>
|
||||||
|
</x-modal-confirmation>
|
||||||
@else
|
@else
|
||||||
<x-forms.button canGate="deploy" :canResource="$application" title="Restart without rebuilding" wire:click='restart'>
|
<x-modal-confirmation title="Confirm Application Restart?" buttonTitle="Restart"
|
||||||
<svg class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24"
|
submitAction="restart" :actions="[
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
'This application will be restarted without rebuilding.',
|
||||||
<g fill="none" stroke="currentColor" stroke-linecap="round"
|
]" :confirmWithText="false" :confirmWithPassword="false"
|
||||||
stroke-linejoin="round" stroke-width="2">
|
step2ButtonText="Confirm">
|
||||||
<path
|
<x-slot:content>
|
||||||
d="M19.933 13.041a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747" />
|
<x-forms.button canGate="deploy" :canResource="$application" title="Restart without rebuilding">
|
||||||
<path d="M20 4v5h-5" />
|
<svg class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24"
|
||||||
</g>
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
</svg>
|
<g fill="none" stroke="currentColor" stroke-linecap="round"
|
||||||
Restart
|
stroke-linejoin="round" stroke-width="2">
|
||||||
</x-forms.button>
|
<path
|
||||||
|
d="M19.933 13.041a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747" />
|
||||||
|
<path d="M20 4v5h-5" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
Restart
|
||||||
|
</x-forms.button>
|
||||||
|
</x-slot:content>
|
||||||
|
</x-modal-confirmation>
|
||||||
|
|
||||||
@endif
|
@endif
|
||||||
@endif
|
@endif
|
||||||
<x-modal-confirmation :disabled="!auth()->user()->can('deploy', $application)" :authDisabled="!auth()->user()->can('deploy', $application)" title="Confirm Application Stopping?" buttonTitle="Stop"
|
<x-modal-confirmation :disabled="!auth()->user()->can('deploy', $application)" :authDisabled="!auth()->user()->can('deploy', $application)" title="Confirm Application Stopping?" buttonTitle="Stop"
|
||||||
|
|
@ -167,15 +573,25 @@ class="scrollbar flex min-h-10 w-full flex-nowrap items-center gap-6 overflow-x-
|
||||||
</x-slot:button-title>
|
</x-slot:button-title>
|
||||||
</x-modal-confirmation>
|
</x-modal-confirmation>
|
||||||
@else
|
@else
|
||||||
<x-forms.button canGate="deploy" :canResource="$application" wire:click='deploy'>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-warning"
|
<x-modal-confirmation title="Confirm Application Deployment?" buttonTitle="Deploy"
|
||||||
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none"
|
submitAction="deploy" :actions="[
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
'This application will be deployed.',
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
]" :confirmWithText="false" :confirmWithPassword="false"
|
||||||
<path d="M7 4v16l13 -8z" />
|
step2ButtonText="Confirm">
|
||||||
</svg>
|
<x-slot:content>
|
||||||
Deploy
|
<x-forms.button canGate="deploy" :canResource="$application">
|
||||||
</x-forms.button>
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-warning"
|
||||||
|
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M7 4v16l13 -8z" />
|
||||||
|
</svg>
|
||||||
|
Deploy
|
||||||
|
</x-forms.button>
|
||||||
|
</x-slot:content>
|
||||||
|
</x-modal-confirmation>
|
||||||
|
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@
|
||||||
<h1>Configuration</h1>
|
<h1>Configuration</h1>
|
||||||
<livewire:project.shared.configuration-checker :resource="$database" />
|
<livewire:project.shared.configuration-checker :resource="$database" />
|
||||||
<livewire:project.database.heading :database="$database" />
|
<livewire:project.database.heading :database="$database" />
|
||||||
<div class="flex flex-col h-full gap-8 sm:flex-row">
|
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
|
||||||
<div class="sub-menu-wrapper">
|
<div class="sub-menu-wrapper hidden md:flex">
|
||||||
<a class='sub-menu-item' {{ wireNavigate() }} wire:current.exact="menu-item-active"
|
<a class='sub-menu-item' {{ wireNavigate() }} wire:current.exact="menu-item-active"
|
||||||
href="{{ route('project.database.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'database_uuid' => $database->uuid]) }}"><span class="menu-item-label">General</span></a>
|
href="{{ route('project.database.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'database_uuid' => $database->uuid]) }}"><span class="menu-item-label">General</span></a>
|
||||||
<a class='sub-menu-item' {{ wireNavigate() }} wire:current.exact="menu-item-active"
|
<a class='sub-menu-item' {{ wireNavigate() }} wire:current.exact="menu-item-active"
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,190 @@
|
||||||
<nav wire:poll.10000ms="checkStatus" class="pb-6">
|
<nav wire:poll.10000ms="checkStatus" class="pb-6">
|
||||||
|
@php
|
||||||
|
$databasePageItems = [
|
||||||
|
['label' => 'Configuration', 'route' => 'project.database.configuration', 'active' => request()->routeIs('project.database.configuration')],
|
||||||
|
['label' => 'Logs', 'route' => 'project.database.logs', 'active' => request()->routeIs('project.database.logs')],
|
||||||
|
['label' => 'Terminal', 'route' => 'project.database.command', 'active' => request()->routeIs('project.database.command'), 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')],
|
||||||
|
[
|
||||||
|
'label' => 'Backups',
|
||||||
|
'route' => 'project.database.backup.index',
|
||||||
|
'active' => request()->routeIs('project.database.backup.index', 'project.database.backup.execution'),
|
||||||
|
'visible' => in_array($database->getMorphClass(), [
|
||||||
|
'App\Models\StandalonePostgresql',
|
||||||
|
'App\Models\StandaloneMongodb',
|
||||||
|
'App\Models\StandaloneMysql',
|
||||||
|
'App\Models\StandaloneMariadb',
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$databaseConfigurationItems = [
|
||||||
|
['label' => 'General', 'route' => 'project.database.configuration', 'active' => request()->routeIs('project.database.configuration')],
|
||||||
|
['label' => 'Environment Variables', 'route' => 'project.database.environment-variables', 'active' => request()->routeIs('project.database.environment-variables')],
|
||||||
|
['label' => 'Servers', 'route' => 'project.database.servers', 'active' => request()->routeIs('project.database.servers')],
|
||||||
|
['label' => 'Persistent Storage', 'route' => 'project.database.persistent-storage', 'active' => request()->routeIs('project.database.persistent-storage')],
|
||||||
|
['label' => 'Import Backup', 'route' => 'project.database.import-backup', 'active' => request()->routeIs('project.database.import-backup'), 'visible' => auth()->user()?->can('update', $database)],
|
||||||
|
['label' => 'Webhooks', 'route' => 'project.database.webhooks', 'active' => request()->routeIs('project.database.webhooks')],
|
||||||
|
['label' => 'Healthcheck', 'route' => 'project.database.healthcheck', 'active' => request()->routeIs('project.database.healthcheck')],
|
||||||
|
['label' => 'Resource Limits', 'route' => 'project.database.resource-limits', 'active' => request()->routeIs('project.database.resource-limits')],
|
||||||
|
['label' => 'Resource Operations', 'route' => 'project.database.resource-operations', 'active' => request()->routeIs('project.database.resource-operations')],
|
||||||
|
['label' => 'Metrics', 'route' => 'project.database.metrics', 'active' => request()->routeIs('project.database.metrics')],
|
||||||
|
['label' => 'Tags', 'route' => 'project.database.tags', 'active' => request()->routeIs('project.database.tags')],
|
||||||
|
['label' => 'Danger Zone', 'route' => 'project.database.danger', 'active' => request()->routeIs('project.database.danger')],
|
||||||
|
];
|
||||||
|
|
||||||
|
$databasePageItems = array_values(array_filter($databasePageItems, fn (array $item): bool => $item['visible'] ?? true));
|
||||||
|
$databaseConfigurationItems = array_values(array_filter($databaseConfigurationItems, fn (array $item): bool => $item['visible'] ?? true));
|
||||||
|
$activeDatabaseConfigurationItem = collect($databaseConfigurationItems)->firstWhere('active', true);
|
||||||
|
$activeDatabasePageItem = collect($databasePageItems)->firstWhere('active', true);
|
||||||
|
$activeDatabaseMobileItem = $activeDatabaseConfigurationItem ?? $activeDatabasePageItem ?? $databasePageItems[0];
|
||||||
|
$activeDatabaseMobileGroup = $activeDatabaseConfigurationItem ? 'configuration' : 'database';
|
||||||
|
$activeDatabaseMobileNavigation = ($activeDatabaseMobileItem['navigate'] ?? true) ? 'navigate' : 'location';
|
||||||
|
$activeDatabaseMobileValue = $activeDatabaseMobileNavigation.'|'.$activeDatabaseMobileGroup.'|'.route($activeDatabaseMobileItem['route'], $parameters);
|
||||||
|
$databaseMobileMenuChangeHandler = <<<'JS'
|
||||||
|
const value = $event.target.value;
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.startsWith('navigate|')) {
|
||||||
|
const url = value.split('|').slice(2).join('|');
|
||||||
|
window.Livewire?.navigate ? window.Livewire.navigate(url) : window.location.href = url;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.startsWith('location|')) {
|
||||||
|
const url = value.split('|').slice(2).join('|');
|
||||||
|
window.location.href = url;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetToCurrent();
|
||||||
|
|
||||||
|
if (value.startsWith('external:')) {
|
||||||
|
window.open(value.slice(9), '_blank', 'noopener');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById(`database-${value.slice(7)}-trigger`)?.click();
|
||||||
|
JS;
|
||||||
|
@endphp
|
||||||
<x-resources.breadcrumbs :resource="$database" :parameters="$parameters" />
|
<x-resources.breadcrumbs :resource="$database" :parameters="$parameters" />
|
||||||
<x-slide-over @startdatabase.window="slideOverOpen = true" closeWithX fullScreen>
|
<x-slide-over @startdatabase.window="slideOverOpen = true" closeWithX fullScreen>
|
||||||
<x-slot:title>Database Startup</x-slot:title>
|
<x-slot:title>Database Startup</x-slot:title>
|
||||||
<x-slot:content>
|
<x-slot:content>
|
||||||
<div wire:ignore>
|
<div wire:ignore class="h-full min-h-0 min-w-0 max-w-full">
|
||||||
<livewire:activity-monitor header="Logs" fullHeight />
|
<livewire:activity-monitor header="Logs" fullHeight />
|
||||||
</div>
|
</div>
|
||||||
</x-slot:content>
|
</x-slot:content>
|
||||||
</x-slide-over>
|
</x-slide-over>
|
||||||
<div class="navbar-main">
|
<div class="navbar-main">
|
||||||
|
<div class="w-full md:hidden">
|
||||||
|
@if ($database->destination->server->isFunctional())
|
||||||
|
<div id="database-mobile-actions" class="mt-2 mb-3 md:hidden">
|
||||||
|
<div class="mb-1 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Actions</div>
|
||||||
|
<div class="flex flex-nowrap items-center gap-2 overflow-x-auto">
|
||||||
|
@if (!str($database->status)->startsWith('exited'))
|
||||||
|
<button type="button" class="button shrink-0"
|
||||||
|
@click="document.getElementById('database-restart-trigger')?.click()">
|
||||||
|
<svg class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
stroke-width="2">
|
||||||
|
<path d="M19.933 13.041a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747" />
|
||||||
|
<path d="M20 4v5h-5" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
Restart
|
||||||
|
</button>
|
||||||
|
<x-forms.button isError class="shrink-0"
|
||||||
|
@click="document.getElementById('database-stop-trigger')?.click()">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" viewBox="0 0 24 24"
|
||||||
|
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
|
<path d="M6 5m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z">
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
d="M14 5m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
Stop
|
||||||
|
</x-forms.button>
|
||||||
|
@else
|
||||||
|
<button type="button" class="button shrink-0"
|
||||||
|
@click="document.getElementById('database-start-trigger')?.click()">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M7 4v16l13 -8z" />
|
||||||
|
</svg>
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<label id="database-mobile-section-label" for="database-mobile-section" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Section</label>
|
||||||
|
<select id="database-mobile-section" class="select w-full" aria-label="Database menu"
|
||||||
|
data-current-value="{{ $activeDatabaseMobileValue }}"
|
||||||
|
x-data="{
|
||||||
|
init() {
|
||||||
|
this.syncFromLocation();
|
||||||
|
window.Livewire?.hook?.('morphed', ({ el }) => {
|
||||||
|
if (el.contains(this.$el)) {
|
||||||
|
queueMicrotask(() => this.syncFromLocation());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
selected: $el.dataset.currentValue,
|
||||||
|
current: $el.dataset.currentValue,
|
||||||
|
syncFromLocation() {
|
||||||
|
const currentUrl = new URL(window.location.href);
|
||||||
|
const matchingOptions = Array.from(this.$el.options).filter((option) => {
|
||||||
|
if (!option.value.startsWith('navigate|') && !option.value.startsWith('location|')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionUrl = new URL(option.value.split('|').slice(2).join('|'), window.location.origin);
|
||||||
|
|
||||||
|
return optionUrl.pathname === currentUrl.pathname;
|
||||||
|
});
|
||||||
|
const selectedOption = matchingOptions.find((option) => {
|
||||||
|
return option.value.startsWith('navigate|configuration|') || option.value.startsWith('navigate|resource|');
|
||||||
|
}) || matchingOptions[0];
|
||||||
|
|
||||||
|
if (selectedOption) {
|
||||||
|
this.current = selectedOption.value;
|
||||||
|
this.selected = selectedOption.value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resetToCurrent() {
|
||||||
|
this.selected = this.current;
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
x-on:livewire:navigated.window="syncFromLocation()"
|
||||||
|
x-model="selected"
|
||||||
|
x-on:change="{{ $databaseMobileMenuChangeHandler }}">
|
||||||
|
<optgroup label="Database">
|
||||||
|
@foreach ($databasePageItems as $menuItem)
|
||||||
|
<option value="{{ ($menuItem['navigate'] ?? true) ? 'navigate' : 'location' }}|database|{{ route($menuItem['route'], $parameters) }}">
|
||||||
|
{{ $menuItem['label'] }}
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Configuration">
|
||||||
|
@foreach ($databaseConfigurationItems as $menuItem)
|
||||||
|
<option value="navigate|configuration|{{ route($menuItem['route'], $parameters) }}">
|
||||||
|
{{ $menuItem['label'] }}
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<nav
|
<nav
|
||||||
class="scrollbar flex min-h-10 w-full flex-nowrap items-center gap-6 overflow-x-scroll overflow-y-hidden pb-1 whitespace-nowrap md:w-auto md:overflow-visible">
|
class="scrollbar hidden min-h-10 w-full flex-nowrap items-center gap-6 overflow-x-scroll overflow-y-hidden pb-1 whitespace-nowrap md:flex md:w-auto md:overflow-visible">
|
||||||
<a class="shrink-0 {{ request()->routeIs('project.database.configuration') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
|
<a class="shrink-0 {{ request()->routeIs('project.database.configuration') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
|
||||||
href="{{ route('project.database.configuration', $parameters) }}">
|
href="{{ route('project.database.configuration', $parameters) }}">
|
||||||
Configuration
|
Configuration
|
||||||
|
|
@ -37,89 +211,39 @@ class="scrollbar flex min-h-10 w-full flex-nowrap items-center gap-6 overflow-x-
|
||||||
</a>
|
</a>
|
||||||
@endif
|
@endif
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@if ($database->destination->server->isFunctional())
|
@if ($database->destination->server->isFunctional())
|
||||||
<div class="flex flex-wrap gap-2 items-center">
|
<div class="flex flex-wrap gap-2 items-center">
|
||||||
<div class="md:hidden">
|
|
||||||
<x-dropdown>
|
|
||||||
<x-slot:title>
|
|
||||||
Actions
|
|
||||||
</x-slot>
|
|
||||||
@if (!str($database->status)->startsWith('exited'))
|
|
||||||
<x-modal-confirmation title="Confirm Database Restart?" buttonTitle="Restart" submitAction="restart"
|
|
||||||
:actions="[
|
|
||||||
'This database will be unavailable during the restart.',
|
|
||||||
'If the database is currently in use data could be lost.',
|
|
||||||
]" :confirmWithText="false" :confirmWithPassword="false" step2ButtonText="Restart Database"
|
|
||||||
:dispatchEvent="true" dispatchEventType="restartEvent">
|
|
||||||
<x-slot:trigger>
|
|
||||||
<div class="dropdown-item dropdown-item-touch">
|
|
||||||
Restart
|
|
||||||
</div>
|
|
||||||
</x-slot:trigger>
|
|
||||||
</x-modal-confirmation>
|
|
||||||
<x-modal-confirmation title="Confirm Database Stopping?" buttonTitle="Stop" submitAction="stop"
|
|
||||||
:checkboxes="$checkboxes" :actions="[
|
|
||||||
'This database will be stopped.',
|
|
||||||
'If the database is currently in use data could be lost.',
|
|
||||||
'All non-persistent data of this database (containers, networks, unused images) will be deleted (don\'t worry, no data is lost and you can start the database again).',
|
|
||||||
]" :confirmWithText="false" :confirmWithPassword="false"
|
|
||||||
step1ButtonText="Continue" step2ButtonText="Confirm">
|
|
||||||
<x-slot:trigger>
|
|
||||||
<div class="dropdown-item dropdown-item-touch text-error">
|
|
||||||
Stop
|
|
||||||
</div>
|
|
||||||
</x-slot:trigger>
|
|
||||||
</x-modal-confirmation>
|
|
||||||
@else
|
|
||||||
<div class="dropdown-item dropdown-item-touch" @click="$wire.dispatch('startEvent')">
|
|
||||||
Start
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</x-dropdown>
|
|
||||||
</div>
|
|
||||||
<div class="hidden flex-wrap items-center gap-2 md:flex">
|
<div class="hidden flex-wrap items-center gap-2 md:flex">
|
||||||
@if (!str($database->status)->startsWith('exited'))
|
@if (!str($database->status)->startsWith('exited'))
|
||||||
<x-modal-confirmation :disabled="!auth()->user()->can('manage', $database)" :authDisabled="!auth()->user()->can('manage', $database)" title="Confirm Database Restart?" buttonTitle="Restart" submitAction="restart"
|
|
||||||
:actions="[
|
<x-forms.button canGate="manage" :canResource="$database" title="Restart" @click="document.getElementById('database-restart-trigger')?.click()">
|
||||||
'This database will be unavailable during the restart.',
|
<svg class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24"
|
||||||
'If the database is currently in use data could be lost.',
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
]" :confirmWithText="false" :confirmWithPassword="false" step2ButtonText="Restart Database"
|
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
:dispatchEvent="true" dispatchEventType="restartEvent">
|
stroke-width="2">
|
||||||
<x-slot:button-title>
|
<path d="M19.933 13.041a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747" />
|
||||||
<svg class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24"
|
<path d="M20 4v5h-5" />
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
</g>
|
||||||
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
</svg>
|
||||||
stroke-width="2">
|
Restart
|
||||||
<path d="M19.933 13.041a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747" />
|
</x-forms.button>
|
||||||
<path d="M20 4v5h-5" />
|
<x-forms.button canGate="manage" :canResource="$database" isError title="Stop" @click="document.getElementById('database-stop-trigger')?.click()">
|
||||||
</g>
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" viewBox="0 0 24 24"
|
||||||
</svg>
|
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
|
||||||
Restart
|
stroke-linejoin="round">
|
||||||
</x-slot:button-title>
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
</x-modal-confirmation>
|
<path d="M6 5m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z">
|
||||||
<x-modal-confirmation :disabled="!auth()->user()->can('manage', $database)" :authDisabled="!auth()->user()->can('manage', $database)" title="Confirm Database Stopping?" buttonTitle="Stop" submitAction="stop"
|
</path>
|
||||||
:checkboxes="$checkboxes" :actions="[
|
<path
|
||||||
'This database will be stopped.',
|
d="M14 5m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z">
|
||||||
'If the database is currently in use data could be lost.',
|
</path>
|
||||||
'All non-persistent data of this database (containers, networks, unused images) will be deleted (don\'t worry, no data is lost and you can start the database again).',
|
</svg>
|
||||||
]" :confirmWithText="false" :confirmWithPassword="false"
|
Stop
|
||||||
step1ButtonText="Continue" step2ButtonText="Confirm">
|
</x-forms.button>
|
||||||
<x-slot:button-title>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" viewBox="0 0 24 24"
|
|
||||||
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
|
|
||||||
stroke-linejoin="round">
|
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
|
||||||
<path d="M6 5m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z">
|
|
||||||
</path>
|
|
||||||
<path
|
|
||||||
d="M14 5m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z">
|
|
||||||
</path>
|
|
||||||
</svg>
|
|
||||||
Stop
|
|
||||||
</x-slot:button-title>
|
|
||||||
</x-modal-confirmation>
|
|
||||||
@else
|
@else
|
||||||
<x-forms.button canGate="manage" :canResource="$database" @click="$wire.dispatch('startEvent')" class="gap-2">
|
<x-forms.button canGate="manage" :canResource="$database" @click="document.getElementById('database-start-trigger')?.click()" class="gap-2">
|
||||||
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24"
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24"
|
||||||
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round"
|
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round"
|
||||||
stroke-linejoin="round">
|
stroke-linejoin="round">
|
||||||
|
|
@ -148,4 +272,37 @@ class="scrollbar flex min-h-10 w-full flex-nowrap items-center gap-6 overflow-x-
|
||||||
<div class="text-error">Underlying server is not functional.</div>
|
<div class="text-error">Underlying server is not functional.</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
@if ($database->destination->server->isFunctional())
|
||||||
|
<x-modal-confirmation title="Confirm Database Restart?" buttonTitle="Restart" submitAction="restartEvent"
|
||||||
|
:actions="[
|
||||||
|
'This database will be unavailable during the restart.',
|
||||||
|
'If the database is currently in use data could be lost.',
|
||||||
|
]" :confirmWithText="false" :confirmWithPassword="false" step2ButtonText="Restart Database"
|
||||||
|
:dispatchAction="true">
|
||||||
|
<x-slot:trigger>
|
||||||
|
<button id="database-restart-trigger" type="button" class="hidden">Restart</button>
|
||||||
|
</x-slot:trigger>
|
||||||
|
</x-modal-confirmation>
|
||||||
|
<x-modal-confirmation title="Confirm Database Stopping?" buttonTitle="Stop" submitAction="stop"
|
||||||
|
:checkboxes="$checkboxes" :actions="[
|
||||||
|
'This database will be stopped.',
|
||||||
|
'If the database is currently in use data could be lost.',
|
||||||
|
'All non-persistent data of this database (containers, networks, unused images) will be deleted (don\'t worry, no data is lost and you can start the database again).',
|
||||||
|
]" :confirmWithText="false" :confirmWithPassword="false"
|
||||||
|
step1ButtonText="Continue" step2ButtonText="Confirm">
|
||||||
|
<x-slot:trigger>
|
||||||
|
<button id="database-stop-trigger" type="button" class="hidden">Stop</button>
|
||||||
|
</x-slot:trigger>
|
||||||
|
</x-modal-confirmation>
|
||||||
|
<x-modal-confirmation title="Confirm Database Start?" buttonTitle="Start" submitAction="startEvent"
|
||||||
|
:actions="[
|
||||||
|
'This database will be started.',
|
||||||
|
]" :confirmWithText="false" :confirmWithPassword="false" step2ButtonText="Start Database"
|
||||||
|
:dispatchAction="true">
|
||||||
|
<x-slot:trigger>
|
||||||
|
<button id="database-start-trigger" type="button" class="hidden">Start</button>
|
||||||
|
</x-slot:trigger>
|
||||||
|
</x-modal-confirmation>
|
||||||
|
@endif
|
||||||
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
|
|
@ -11,38 +11,55 @@
|
||||||
@endcan
|
@endcan
|
||||||
</div>
|
</div>
|
||||||
<div class="subtitle">All your projects are here.</div>
|
<div class="subtitle">All your projects are here.</div>
|
||||||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2 -mt-1">
|
@if ($projects->count() > 0)
|
||||||
@foreach ($projects as $project)
|
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2 -mt-1">
|
||||||
<div class="relative gap-2 cursor-pointer coolbox group">
|
@foreach ($projects as $project)
|
||||||
<a href="{{ $project->navigateTo() }}" {{ wireNavigate() }} class="absolute inset-0"></a>
|
<div class="relative gap-2 cursor-pointer coolbox group">
|
||||||
<div class="flex flex-1 mx-6">
|
<a href="{{ $project->navigateTo() }}" {{ wireNavigate() }} class="absolute inset-0"></a>
|
||||||
<div class="flex flex-col justify-center flex-1">
|
<div class="flex flex-1 mx-6">
|
||||||
<div class="box-title">{{ $project->name }}</div>
|
<div class="flex flex-col justify-center flex-1">
|
||||||
<div class="box-description">
|
<div class="box-title">{{ $project->name }}</div>
|
||||||
{{ $project->description }}
|
<div class="box-description">
|
||||||
|
{{ $project->description }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="relative z-10 flex items-center justify-center gap-4 text-xs font-bold">
|
||||||
<div class="relative z-10 flex items-center justify-center gap-4 text-xs font-bold">
|
@if ($project->environments->first())
|
||||||
@if ($project->environments->first())
|
@can('createAnyResource')
|
||||||
@can('createAnyResource')
|
<a class="hover:underline" {{ wireNavigate() }}
|
||||||
|
href="{{ route('project.resource.create', [
|
||||||
|
'project_uuid' => $project->uuid,
|
||||||
|
'environment_uuid' => $project->environments->first()->uuid,
|
||||||
|
]) }}">
|
||||||
|
+ Add Resource
|
||||||
|
</a>
|
||||||
|
@endcan
|
||||||
|
@endif
|
||||||
|
@can('update', $project)
|
||||||
<a class="hover:underline" {{ wireNavigate() }}
|
<a class="hover:underline" {{ wireNavigate() }}
|
||||||
href="{{ route('project.resource.create', [
|
href="{{ route('project.edit', ['project_uuid' => $project->uuid]) }}">
|
||||||
'project_uuid' => $project->uuid,
|
Settings
|
||||||
'environment_uuid' => $project->environments->first()->uuid,
|
|
||||||
]) }}">
|
|
||||||
+ Add Resource
|
|
||||||
</a>
|
</a>
|
||||||
@endcan
|
@endcan
|
||||||
@endif
|
</div>
|
||||||
@can('update', $project)
|
|
||||||
<a class="hover:underline" {{ wireNavigate() }}
|
|
||||||
href="{{ route('project.edit', ['project_uuid' => $project->uuid]) }}">
|
|
||||||
Settings
|
|
||||||
</a>
|
|
||||||
@endcan
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class='font-bold dark:text-warning'>No projects found.</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
@can('createAnyResource')
|
||||||
|
<x-modal-input buttonTitle="Add" title="New Project">
|
||||||
|
<livewire:project.add-empty />
|
||||||
|
</x-modal-input> your first project or
|
||||||
|
@else
|
||||||
|
Create your first project or
|
||||||
|
@endcan
|
||||||
|
go to the <a class="underline dark:text-white" href="{{ route('onboarding') }}"
|
||||||
|
{{ wireNavigate() }}>onboarding</a> page.
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
</div>
|
||||||
</div>
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@
|
||||||
</x-slot>
|
</x-slot>
|
||||||
<livewire:project.service.heading :service="$service" :parameters="$parameters" :query="$query" />
|
<livewire:project.service.heading :service="$service" :parameters="$parameters" :query="$query" />
|
||||||
|
|
||||||
<div class="flex flex-col h-full gap-8 sm:flex-row">
|
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
|
||||||
<div class="sub-menu-wrapper">
|
<div class="sub-menu-wrapper hidden md:flex">
|
||||||
<a class="sub-menu-item" target="_blank" href="{{ $service->documentation() }}"><span class="menu-item-label">Documentation</span>
|
<a class="sub-menu-item" target="_blank" href="{{ $service->documentation() }}"><span class="menu-item-label">Documentation</span>
|
||||||
<x-external-link /></a>
|
<x-external-link /></a>
|
||||||
<a class='sub-menu-item' wire:current.exact="menu-item-active" {{ wireNavigate() }}
|
<a class='sub-menu-item' wire:current.exact="menu-item-active" {{ wireNavigate() }}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<div>
|
<div>
|
||||||
<livewire:project.service.heading :service="$service" :parameters="$parameters" :query="$query" />
|
<livewire:project.service.heading :service="$service" :parameters="$parameters" :query="$query" />
|
||||||
<div class="flex flex-col h-full gap-8 sm:flex-row">
|
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
|
||||||
<x-service-database.sidebar :parameters="$parameters" :serviceDatabase="$serviceDatabase" :isImportSupported="$isImportSupported" />
|
<x-service-database.sidebar :parameters="$parameters" :serviceDatabase="$serviceDatabase" :isImportSupported="$isImportSupported" />
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<x-slot:title>
|
<x-slot:title>
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue