Merge remote-tracking branch 'origin/next' into api-sensitive-data-scrubber
This commit is contained in:
commit
2ebe2e8dbb
187 changed files with 4319 additions and 952 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
|
|
@ -120,6 +120,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
|
||||||
|
|
@ -128,29 +129,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
|
||||||
|
|
||||||
|
|
@ -210,7 +198,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
|
||||||
|
|
||||||
|
|
@ -225,10 +212,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
|
||||||
|
|
@ -312,6 +305,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...');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
use Visus\Cuid2\Cuid2;
|
use Visus\Cuid2\Cuid2;
|
||||||
|
|
||||||
class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
|
class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
|
||||||
|
|
@ -71,16 +72,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
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
|
||||||
|
|
@ -637,7 +637,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([
|
||||||
|
|
@ -1391,6 +1391,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"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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,6 +11,7 @@
|
||||||
*/
|
*/
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
|
// SQLite (testing) uses type affinity, so json columns already accept text
|
||||||
if (DB::connection()->getDriverName() !== 'pgsql') {
|
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"
|
||||||
|
|
|
||||||
|
|
@ -3,34 +3,50 @@
|
||||||
Advanced
|
Advanced
|
||||||
</x-slot>
|
</x-slot>
|
||||||
@if ($application->status === 'running')
|
@if ($application->status === 'running')
|
||||||
<div class="dropdown-iteme" wire:click='force_deploy_without_cache'>
|
<x-modal-confirmation title="Confirm Application Force Deployment?" buttonTitle="Force deploy"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
|
submitAction="force_deploy_without_cache" :actions="[
|
||||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
'This application will be force deployed without build cache.',
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
]" :confirmWithText="false" :confirmWithPassword="false"
|
||||||
<path
|
step2ButtonText="Confirm">
|
||||||
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" />
|
<x-slot:content>
|
||||||
<path
|
<div class="dropdown-iteme">
|
||||||
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" />
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
|
||||||
<path d="M4 12v6c0 1.657 3.582 3 8 3c3.217 0 5.991 -.712 7.261 -1.74m.739 -3.26v-4" />
|
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M3 3l18 18" />
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
</svg>
|
<path
|
||||||
Force deploy (without
|
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" />
|
||||||
cache)
|
<path
|
||||||
</div>
|
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
|
@else
|
||||||
<div class="dropdown-item" wire:click='deploy(true)'>
|
<x-modal-confirmation title="Confirm Application Force Deployment?" buttonTitle="Force deploy"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" stroke-width="1.5"
|
submitAction="deploy(true)" :actions="[
|
||||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
'This application will be force deployed without build cache.',
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
]" :confirmWithText="false" :confirmWithPassword="false"
|
||||||
<path
|
step2ButtonText="Confirm">
|
||||||
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" />
|
<x-slot:content>
|
||||||
<path
|
<div class="dropdown-item">
|
||||||
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" />
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" stroke-width="1.5"
|
||||||
<path d="M4 12v6c0 1.657 3.582 3 8 3c3.217 0 5.991 -.712 7.261 -1.74m.739 -3.26v-4" />
|
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M3 3l18 18" />
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
</svg>
|
<path
|
||||||
Force deploy (without
|
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" />
|
||||||
cache)
|
<path
|
||||||
</div>
|
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>
|
||||||
@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" @click="$wire.dispatch('pullAndRestartEvent')">
|
<div class="dropdown-item" @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" @click="$wire.dispatch('forceDeployEvent')">
|
<div class="dropdown-item" @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" @click="$wire.dispatch('forceDeployEvent')">
|
<div class="dropdown-item" @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" wire:click='stop(true)''>
|
<div class="dropdown-item" @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)
|
||||||
|
|
|
||||||
|
|
@ -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,70 @@ 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 title="With rolling update if possible" wire:click='deploy'>
|
<x-modal-confirmation title="Confirm Application Deployment?" buttonTitle="Redeploy"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-orange-400"
|
submitAction="deploy" :actions="[
|
||||||
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
|
'This application will be redeployed.',
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
]" :confirmWithText="false" :confirmWithPassword="false"
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
step2ButtonText="Confirm">
|
||||||
<path
|
<x-slot:content>
|
||||||
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-forms.button title="With rolling update if possible">
|
||||||
</path>
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-orange-400"
|
||||||
<path d="M7.05 11.038v-3.988"></path>
|
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
|
||||||
</svg>
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
Redeploy
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
</x-forms.button>
|
<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 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 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 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 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 title="Confirm Application Stopping?" buttonTitle="Stop"
|
<x-modal-confirmation title="Confirm Application Stopping?" buttonTitle="Stop"
|
||||||
|
|
@ -167,15 +571,23 @@ 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 wire:click='deploy'>
|
<x-modal-confirmation title="Confirm Application Deployment?" buttonTitle="Deploy"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-warning"
|
submitAction="deploy" :actions="[
|
||||||
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none"
|
'This application will be deployed.',
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
]" :confirmWithText="false" :confirmWithPassword="false"
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
step2ButtonText="Confirm">
|
||||||
<path d="M7 4v16l13 -8z" />
|
<x-slot:content>
|
||||||
</svg>
|
<x-forms.button>
|
||||||
Deploy
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-warning"
|
||||||
</x-forms.button>
|
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,37 @@ 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 title="Confirm Database Restart?" buttonTitle="Restart" submitAction="restart"
|
<x-forms.button title="Restart" @click="document.getElementById('database-restart-trigger')?.click()">
|
||||||
:actions="[
|
<svg class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24"
|
||||||
'This database will be unavailable during the restart.',
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
'If the database is currently in use data could be lost.',
|
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
]" :confirmWithText="false" :confirmWithPassword="false" step2ButtonText="Restart Database"
|
stroke-width="2">
|
||||||
:dispatchEvent="true" dispatchEventType="restartEvent">
|
<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-slot:button-title>
|
<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" stroke-linejoin="round"
|
Restart
|
||||||
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" />
|
<x-forms.button isError title="Stop" @click="document.getElementById('database-stop-trigger')?.click()">
|
||||||
<path d="M20 4v5h-5" />
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" viewBox="0 0 24 24"
|
||||||
</g>
|
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
|
||||||
</svg>
|
stroke-linejoin="round">
|
||||||
Restart
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
</x-slot:button-title>
|
<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>
|
</path>
|
||||||
<x-modal-confirmation title="Confirm Database Stopping?" buttonTitle="Stop" submitAction="stop"
|
<path
|
||||||
:checkboxes="$checkboxes" :actions="[
|
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">
|
||||||
'This database will be stopped.',
|
</path>
|
||||||
'If the database is currently in use data could be lost.',
|
</svg>
|
||||||
'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).',
|
Stop
|
||||||
]" :confirmWithText="false" :confirmWithPassword="false"
|
</x-forms.button>
|
||||||
step1ButtonText="Continue" step2ButtonText="Confirm">
|
|
||||||
<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
|
||||||
<button @click="$wire.dispatch('startEvent')" class="gap-2 button">
|
<button @click="document.getElementById('database-start-trigger')?.click()" class="gap-2 button">
|
||||||
<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 +270,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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,94 @@
|
||||||
<div wire:poll.10000ms="checkStatus" class="pb-6">
|
<div wire:poll.10000ms="checkStatus" class="pb-6">
|
||||||
|
@php
|
||||||
|
$servicePageItems = [
|
||||||
|
['label' => 'Configuration', 'route' => 'project.service.configuration', 'active' => request()->routeIs('project.service.configuration')],
|
||||||
|
['label' => 'Logs', 'route' => 'project.service.logs', 'active' => request()->routeIs('project.service.logs')],
|
||||||
|
['label' => 'Terminal', 'route' => 'project.service.command', 'active' => request()->routeIs('project.service.command'), 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')],
|
||||||
|
];
|
||||||
|
|
||||||
|
$serviceConfigurationItems = [
|
||||||
|
['label' => 'General', 'route' => 'project.service.configuration', 'active' => request()->routeIs('project.service.configuration')],
|
||||||
|
['label' => 'Environment Variables', 'route' => 'project.service.environment-variables', 'active' => request()->routeIs('project.service.environment-variables')],
|
||||||
|
['label' => 'Persistent Storages', 'route' => 'project.service.storages', 'active' => request()->routeIs('project.service.storages')],
|
||||||
|
['label' => 'Scheduled Tasks', 'route' => 'project.service.scheduled-tasks.show', 'active' => request()->routeIs('project.service.scheduled-tasks.show', 'project.service.scheduled-tasks')],
|
||||||
|
['label' => 'Webhooks', 'route' => 'project.service.webhooks', 'active' => request()->routeIs('project.service.webhooks')],
|
||||||
|
['label' => 'Resource Operations', 'route' => 'project.service.resource-operations', 'active' => request()->routeIs('project.service.resource-operations')],
|
||||||
|
['label' => 'Tags', 'route' => 'project.service.tags', 'active' => request()->routeIs('project.service.tags')],
|
||||||
|
['label' => 'Danger Zone', 'route' => 'project.service.danger', 'active' => request()->routeIs('project.service.danger')],
|
||||||
|
];
|
||||||
|
$serviceResourceItems = [];
|
||||||
|
|
||||||
|
if (filled(data_get($parameters, 'stack_service_uuid'))) {
|
||||||
|
$serviceResourceItems = [
|
||||||
|
['label' => 'Back', 'route' => 'project.service.configuration', 'active' => false, 'parameters' => [...$parameters, 'stack_service_uuid' => null]],
|
||||||
|
['label' => 'General', 'route' => 'project.service.index', 'active' => request()->routeIs('project.service.index')],
|
||||||
|
['label' => 'Advanced', 'route' => 'project.service.index.advanced', 'active' => request()->routeIs('project.service.index.advanced')],
|
||||||
|
['label' => 'Backups', 'route' => 'project.service.database.backups', 'active' => request()->routeIs('project.service.database.backups')],
|
||||||
|
['label' => 'Import Backup', 'route' => 'project.service.database.import', 'active' => request()->routeIs('project.service.database.import')],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$servicePageItems = array_values(array_filter($servicePageItems, fn (array $item): bool => $item['visible'] ?? true));
|
||||||
|
$serviceResourceItems = array_values(array_filter($serviceResourceItems, fn (array $item): bool => $item['visible'] ?? true));
|
||||||
|
$activeServiceResourceItem = collect($serviceResourceItems)->firstWhere('active', true);
|
||||||
|
$activeServiceConfigurationItem = collect($serviceConfigurationItems)->firstWhere('active', true);
|
||||||
|
$activeServicePageItem = collect($servicePageItems)->firstWhere('active', true);
|
||||||
|
$activeServiceMobileItem = $activeServiceResourceItem ?? $activeServiceConfigurationItem ?? $activeServicePageItem ?? $servicePageItems[0];
|
||||||
|
$activeServiceMobileGroup = $activeServiceResourceItem ? 'resource' : ($activeServiceConfigurationItem ? 'configuration' : 'service');
|
||||||
|
$activeServiceMobileNavigation = ($activeServiceMobileItem['navigate'] ?? true) ? 'navigate' : 'location';
|
||||||
|
$activeServiceMobileValue = $activeServiceMobileNavigation.'|'.$activeServiceMobileGroup.'|'.route($activeServiceMobileItem['route'], $activeServiceMobileItem['parameters'] ?? $parameters);
|
||||||
|
$serviceLinks = collect([]);
|
||||||
|
$service->applications()->get()->each(function ($application) use ($serviceLinks) {
|
||||||
|
$type = $application->serviceType();
|
||||||
|
if ($type) {
|
||||||
|
generateServiceSpecificFqdns($application)
|
||||||
|
->map(fn ($link) => getFqdnWithoutPort($link))
|
||||||
|
->each(fn ($link) => $serviceLinks->push($link));
|
||||||
|
} else {
|
||||||
|
if ($application->fqdn) {
|
||||||
|
collect(str($application->fqdn)->explode(','))
|
||||||
|
->each(fn ($fqdn) => $serviceLinks->push(getFqdnWithoutPort($fqdn)));
|
||||||
|
}
|
||||||
|
if ($application->ports) {
|
||||||
|
collect(str($application->ports)->explode(','))
|
||||||
|
->each(function ($port) use ($serviceLinks) {
|
||||||
|
$hostPort = str($port)->contains(':') ? str($port)->before(':') : $port;
|
||||||
|
$serviceLinks->push(base_url(withPort: false).":{$hostPort}");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$serviceMobileMenuChangeHandler = <<<'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(`service-${action}-trigger`)?.click();
|
||||||
|
JS;
|
||||||
|
@endphp
|
||||||
<livewire:project.shared.configuration-checker :resource="$service" />
|
<livewire:project.shared.configuration-checker :resource="$service" />
|
||||||
<x-slide-over @startservice.window="slideOverOpen = true" closeWithX fullScreen>
|
<x-slide-over @startservice.window="slideOverOpen = true" closeWithX fullScreen>
|
||||||
<x-slot:title>Service Startup</x-slot:title>
|
<x-slot:title>Service Startup</x-slot:title>
|
||||||
|
|
@ -9,8 +99,252 @@
|
||||||
<h1>{{ $title }}</h1>
|
<h1>{{ $title }}</h1>
|
||||||
<x-resources.breadcrumbs :resource="$service" :parameters="$parameters" />
|
<x-resources.breadcrumbs :resource="$service" :parameters="$parameters" />
|
||||||
<div class="navbar-main" x-data">
|
<div class="navbar-main" x-data">
|
||||||
|
<div class="mb-4 w-full md:mb-0 md:hidden">
|
||||||
|
@if ($service->isDeployable)
|
||||||
|
<div id="service-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($service->status)->contains('running'))
|
||||||
|
<button type="button" class="button shrink-0"
|
||||||
|
@click="document.getElementById('service-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.041 a8 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('service-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>
|
||||||
|
<button type="button" class="button shrink-0"
|
||||||
|
@click="document.getElementById('service-pullAndRestart-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.041 a8 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>
|
||||||
|
Pull Latest Images & Restart
|
||||||
|
</button>
|
||||||
|
@elseif (str($service->status)->contains('degraded'))
|
||||||
|
<button type="button" class="button shrink-0"
|
||||||
|
@click="document.getElementById('service-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('service-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>
|
||||||
|
<button type="button" class="button shrink-0"
|
||||||
|
@click="document.getElementById('service-forceDeploy-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 Restart
|
||||||
|
</button>
|
||||||
|
@elseif (str($service->status)->contains('exited'))
|
||||||
|
<button type="button" class="button shrink-0"
|
||||||
|
@click="document.getElementById('service-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>
|
||||||
|
Deploy
|
||||||
|
</button>
|
||||||
|
<button type="button" class="button shrink-0"
|
||||||
|
@click="document.getElementById('service-forceDeploy-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
|
||||||
|
</button>
|
||||||
|
<button type="button" class="button shrink-0"
|
||||||
|
@click="document.getElementById('service-cleanup-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>
|
||||||
|
Force Cleanup Containers
|
||||||
|
</button>
|
||||||
|
@else
|
||||||
|
<x-forms.button isError class="shrink-0"
|
||||||
|
@click="document.getElementById('service-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>
|
||||||
|
<button type="button" class="button shrink-0"
|
||||||
|
@click="document.getElementById('service-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>
|
||||||
|
Deploy
|
||||||
|
</button>
|
||||||
|
<button type="button" class="button shrink-0"
|
||||||
|
@click="document.getElementById('service-forceDeploy-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
|
||||||
|
</button>
|
||||||
|
<button type="button" class="button shrink-0"
|
||||||
|
@click="document.getElementById('service-cleanup-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>
|
||||||
|
Force Cleanup Containers
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<label id="service-mobile-section-label" for="service-mobile-section" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Section</label>
|
||||||
|
<select id="service-mobile-section" class="select w-full" aria-label="Service menu"
|
||||||
|
data-current-value="{{ $activeServiceMobileValue }}"
|
||||||
|
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="{{ $serviceMobileMenuChangeHandler }}">
|
||||||
|
<optgroup label="Service">
|
||||||
|
@foreach ($servicePageItems as $menuItem)
|
||||||
|
<option value="{{ ($menuItem['navigate'] ?? true) ? 'navigate' : 'location' }}|service|{{ route($menuItem['route'], $parameters) }}">
|
||||||
|
{{ $menuItem['label'] }}
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Configuration">
|
||||||
|
@foreach ($serviceConfigurationItems as $menuItem)
|
||||||
|
<option value="navigate|configuration|{{ route($menuItem['route'], $parameters) }}">
|
||||||
|
{{ $menuItem['label'] }}
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</optgroup>
|
||||||
|
@if (count($serviceResourceItems) > 0)
|
||||||
|
<optgroup label="Resource">
|
||||||
|
@foreach ($serviceResourceItems as $menuItem)
|
||||||
|
<option value="navigate|resource|{{ route($menuItem['route'], $menuItem['parameters'] ?? $parameters) }}">
|
||||||
|
{{ $menuItem['label'] }}
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</optgroup>
|
||||||
|
@endif
|
||||||
|
<optgroup label="Links">
|
||||||
|
@if (filled($service->documentation()))
|
||||||
|
<option value="external:{{ $service->documentation() }}">Documentation</option>
|
||||||
|
@endif
|
||||||
|
@forelse ($serviceLinks as $link)
|
||||||
|
<option value="external:{{ $link }}">{{ $link }}</option>
|
||||||
|
@empty
|
||||||
|
@if (blank($service->documentation()))
|
||||||
|
<option disabled>No links available</option>
|
||||||
|
@endif
|
||||||
|
@endforelse
|
||||||
|
</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.service.configuration') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
|
<a class="shrink-0 {{ request()->routeIs('project.service.configuration') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
|
||||||
href="{{ route('project.service.configuration', $parameters) }}">
|
href="{{ route('project.service.configuration', $parameters) }}">
|
||||||
<button>Configuration</button>
|
<button>Configuration</button>
|
||||||
|
|
@ -29,85 +363,13 @@ class="scrollbar flex min-h-10 w-full flex-nowrap items-center gap-6 overflow-x-
|
||||||
<x-services.links :service="$service" />
|
<x-services.links :service="$service" />
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@if ($service->isDeployable)
|
@if ($service->isDeployable)
|
||||||
<div class="order-first flex flex-wrap items-center gap-2 sm:order-last">
|
<div class="hidden flex-wrap items-center gap-2 md:flex">
|
||||||
<div class="md:hidden">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<x-dropdown>
|
|
||||||
<x-slot:title>
|
|
||||||
Actions
|
|
||||||
</x-slot>
|
|
||||||
@if (str($service->status)->contains('running'))
|
|
||||||
<div class="dropdown-item dropdown-item-touch" @click="$wire.dispatch('restartEvent')">
|
|
||||||
Restart
|
|
||||||
</div>
|
|
||||||
<x-modal-confirmation title="Confirm Service Stopping?" buttonTitle="Stop" :dispatchEvent="true"
|
|
||||||
submitAction="stop" dispatchEventType="stopEvent" :checkboxes="$checkboxes" :actions="[__('service.stop'), __('resource.non_persistent')]"
|
|
||||||
: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>
|
|
||||||
<div class="mx-2 my-1 border-t border-neutral-200 dark:border-coolgray-300"></div>
|
|
||||||
<div class="dropdown-item dropdown-item-touch" @click="$wire.dispatch('pullAndRestartEvent')">
|
|
||||||
Pull Latest Images & Restart
|
|
||||||
</div>
|
|
||||||
@elseif (str($service->status)->contains('degraded'))
|
|
||||||
<div class="dropdown-item dropdown-item-touch" @click="$wire.dispatch('restartEvent')">
|
|
||||||
Restart
|
|
||||||
</div>
|
|
||||||
<x-modal-confirmation title="Confirm Service Stopping?" buttonTitle="Stop" :dispatchEvent="true"
|
|
||||||
submitAction="stop" dispatchEventType="stopEvent" :checkboxes="$checkboxes" :actions="[__('service.stop'), __('resource.non_persistent')]"
|
|
||||||
: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>
|
|
||||||
<div class="mx-2 my-1 border-t border-neutral-200 dark:border-coolgray-300"></div>
|
|
||||||
<div class="dropdown-item dropdown-item-touch" @click="$wire.dispatch('forceDeployEvent')">
|
|
||||||
Force Restart
|
|
||||||
</div>
|
|
||||||
@elseif (str($service->status)->contains('exited'))
|
|
||||||
<div class="dropdown-item dropdown-item-touch" @click="$wire.dispatch('startEvent')">
|
|
||||||
Deploy
|
|
||||||
</div>
|
|
||||||
<div class="mx-2 my-1 border-t border-neutral-200 dark:border-coolgray-300"></div>
|
|
||||||
<div class="dropdown-item dropdown-item-touch" @click="$wire.dispatch('forceDeployEvent')">
|
|
||||||
Force Deploy
|
|
||||||
</div>
|
|
||||||
<div class="dropdown-item dropdown-item-touch" wire:click='stop(true)'>
|
|
||||||
Force Cleanup Containers
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<x-modal-confirmation title="Confirm Service Stopping?" buttonTitle="Stop" :dispatchEvent="true"
|
|
||||||
submitAction="stop" dispatchEventType="stopEvent" :checkboxes="$checkboxes" :actions="[__('service.stop'), __('resource.non_persistent')]"
|
|
||||||
: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>
|
|
||||||
<div class="dropdown-item dropdown-item-touch" @click="$wire.dispatch('startEvent')">
|
|
||||||
Deploy
|
|
||||||
</div>
|
|
||||||
<div class="mx-2 my-1 border-t border-neutral-200 dark:border-coolgray-300"></div>
|
|
||||||
<div class="dropdown-item dropdown-item-touch" @click="$wire.dispatch('forceDeployEvent')">
|
|
||||||
Force Deploy
|
|
||||||
</div>
|
|
||||||
<div class="dropdown-item dropdown-item-touch" wire:click='stop(true)'>
|
|
||||||
Force Cleanup Containers
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</x-dropdown>
|
|
||||||
</div>
|
|
||||||
<div class="hidden flex-wrap items-center gap-2 md:flex">
|
|
||||||
<x-services.advanced :service="$service" />
|
<x-services.advanced :service="$service" />
|
||||||
@if (str($service->status)->contains('running'))
|
@if (str($service->status)->contains('running'))
|
||||||
<x-forms.button title="Restart" @click="$wire.dispatch('restartEvent')">
|
<x-forms.button title="Restart" @click="document.getElementById('service-restart-trigger')?.click()">
|
||||||
<svg class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
<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"
|
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
stroke-width="2">
|
stroke-width="2">
|
||||||
|
|
@ -117,25 +379,21 @@ class="scrollbar flex min-h-10 w-full flex-nowrap items-center gap-6 overflow-x-
|
||||||
</svg>
|
</svg>
|
||||||
Restart
|
Restart
|
||||||
</x-forms.button>
|
</x-forms.button>
|
||||||
<x-modal-confirmation title="Confirm Service Stopping?" buttonTitle="Stop" :dispatchEvent="true"
|
<x-forms.button isError title="Stop" @click="document.getElementById('service-stop-trigger')?.click()">
|
||||||
submitAction="stop" dispatchEventType="stopEvent" :checkboxes="$checkboxes" :actions="[__('service.stop'), __('resource.non_persistent')]"
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" viewBox="0 0 24 24"
|
||||||
:confirmWithText="false" :confirmWithPassword="false" step1ButtonText="Continue" step2ButtonText="Confirm">
|
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
|
||||||
<x-slot:button-title>
|
stroke-linejoin="round">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" viewBox="0 0 24 24"
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
|
<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">
|
||||||
stroke-linejoin="round">
|
</path>
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
<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">
|
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>
|
</path>
|
||||||
<path
|
</svg>
|
||||||
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">
|
Stop
|
||||||
</path>
|
</x-forms.button>
|
||||||
</svg>
|
|
||||||
Stop
|
|
||||||
</x-slot:button-title>
|
|
||||||
</x-modal-confirmation>
|
|
||||||
@elseif (str($service->status)->contains('degraded'))
|
@elseif (str($service->status)->contains('degraded'))
|
||||||
<x-forms.button title="Restart" @click="$wire.dispatch('restartEvent')">
|
<x-forms.button title="Restart" @click="document.getElementById('service-restart-trigger')?.click()">
|
||||||
<svg class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
<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"
|
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
stroke-width="2">
|
stroke-width="2">
|
||||||
|
|
@ -145,25 +403,21 @@ class="scrollbar flex min-h-10 w-full flex-nowrap items-center gap-6 overflow-x-
|
||||||
</svg>
|
</svg>
|
||||||
Restart
|
Restart
|
||||||
</x-forms.button>
|
</x-forms.button>
|
||||||
<x-modal-confirmation title="Confirm Service Stopping?" buttonTitle="Stop" :dispatchEvent="true"
|
<x-forms.button isError title="Stop" @click="document.getElementById('service-stop-trigger')?.click()">
|
||||||
submitAction="stop" dispatchEventType="stopEvent" :checkboxes="$checkboxes" :actions="[__('service.stop'), __('resource.non_persistent')]"
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" viewBox="0 0 24 24"
|
||||||
:confirmWithText="false" :confirmWithPassword="false" step1ButtonText="Continue" step2ButtonText="Confirm">
|
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
|
||||||
<x-slot:button-title>
|
stroke-linejoin="round">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" viewBox="0 0 24 24"
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
|
<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">
|
||||||
stroke-linejoin="round">
|
</path>
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
<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">
|
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>
|
</path>
|
||||||
<path
|
</svg>
|
||||||
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">
|
Stop
|
||||||
</path>
|
</x-forms.button>
|
||||||
</svg>
|
|
||||||
Stop
|
|
||||||
</x-slot:button-title>
|
|
||||||
</x-modal-confirmation>
|
|
||||||
@elseif (str($service->status)->contains('exited'))
|
@elseif (str($service->status)->contains('exited'))
|
||||||
<button @click="$wire.dispatch('startEvent')" class="gap-2 button">
|
<button @click="document.getElementById('service-start-trigger')?.click()" class="gap-2 button">
|
||||||
<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">
|
||||||
|
|
@ -173,24 +427,20 @@ class="scrollbar flex min-h-10 w-full flex-nowrap items-center gap-6 overflow-x-
|
||||||
Deploy
|
Deploy
|
||||||
</button>
|
</button>
|
||||||
@else
|
@else
|
||||||
<x-modal-confirmation title="Confirm Service Stopping?" buttonTitle="Stop" :dispatchEvent="true"
|
<x-forms.button isError title="Stop" @click="document.getElementById('service-stop-trigger')?.click()">
|
||||||
submitAction="stop" dispatchEventType="stopEvent" :checkboxes="$checkboxes" :actions="[__('service.stop'), __('resource.non_persistent')]"
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" viewBox="0 0 24 24"
|
||||||
:confirmWithText="false" :confirmWithPassword="false" step1ButtonText="Continue" step2ButtonText="Confirm">
|
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
|
||||||
<x-slot:button-title>
|
stroke-linejoin="round">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" viewBox="0 0 24 24"
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
|
<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">
|
||||||
stroke-linejoin="round">
|
</path>
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
<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">
|
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>
|
</path>
|
||||||
<path
|
</svg>
|
||||||
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">
|
Stop
|
||||||
</path>
|
</x-forms.button>
|
||||||
</svg>
|
<button @click="document.getElementById('service-start-trigger')?.click()" class="gap-2 button">
|
||||||
Stop
|
|
||||||
</x-slot:button-title>
|
|
||||||
</x-modal-confirmation>
|
|
||||||
<button @click="$wire.dispatch('startEvent')" class="gap-2 button">
|
|
||||||
<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">
|
||||||
|
|
@ -212,6 +462,51 @@ class="scrollbar flex min-h-10 w-full flex-nowrap items-center gap-6 overflow-x-
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
@if ($service->isDeployable)
|
||||||
|
<x-modal-confirmation title="Confirm Service Deployment?" buttonTitle="Deploy"
|
||||||
|
submitAction="startEvent" :dispatchAction="true" :actions="['This service will be deployed.']"
|
||||||
|
:confirmWithText="false" :confirmWithPassword="false" step2ButtonText="Confirm">
|
||||||
|
<x-slot:trigger>
|
||||||
|
<button id="service-start-trigger" type="button" class="hidden">Deploy</button>
|
||||||
|
</x-slot:trigger>
|
||||||
|
</x-modal-confirmation>
|
||||||
|
<x-modal-confirmation title="Confirm Service Restart?" buttonTitle="Restart"
|
||||||
|
submitAction="restartEvent" :dispatchAction="true" :actions="['This service will be restarted.']"
|
||||||
|
:confirmWithText="false" :confirmWithPassword="false" step2ButtonText="Confirm">
|
||||||
|
<x-slot:trigger>
|
||||||
|
<button id="service-restart-trigger" type="button" class="hidden">Restart</button>
|
||||||
|
</x-slot:trigger>
|
||||||
|
</x-modal-confirmation>
|
||||||
|
<x-modal-confirmation title="Confirm Service Stopping?" buttonTitle="Stop"
|
||||||
|
submitAction="stop" :checkboxes="$checkboxes" :actions="[__('service.stop'), __('resource.non_persistent')]"
|
||||||
|
:confirmWithText="false" :confirmWithPassword="false" step1ButtonText="Continue" step2ButtonText="Confirm">
|
||||||
|
<x-slot:trigger>
|
||||||
|
<button id="service-stop-trigger" type="button" class="hidden">Stop</button>
|
||||||
|
</x-slot:trigger>
|
||||||
|
</x-modal-confirmation>
|
||||||
|
<x-modal-confirmation title="Confirm Service Force Deployment?" buttonTitle="Force Deploy"
|
||||||
|
submitAction="forceDeployEvent" :dispatchAction="true" :actions="['This service will be force deployed.']"
|
||||||
|
:confirmWithText="false" :confirmWithPassword="false" step2ButtonText="Confirm">
|
||||||
|
<x-slot:trigger>
|
||||||
|
<button id="service-forceDeploy-trigger" type="button" class="hidden">Force Deploy</button>
|
||||||
|
</x-slot:trigger>
|
||||||
|
</x-modal-confirmation>
|
||||||
|
<x-modal-confirmation title="Confirm Pull Latest Images & Restart?" buttonTitle="Pull Latest Images & Restart"
|
||||||
|
submitAction="pullAndRestartEvent" :dispatchAction="true" :actions="['Latest images will be pulled and the service will be restarted.']"
|
||||||
|
:confirmWithText="false" :confirmWithPassword="false" step2ButtonText="Confirm">
|
||||||
|
<x-slot:trigger>
|
||||||
|
<button id="service-pullAndRestart-trigger" type="button" class="hidden">Pull Latest Images & Restart</button>
|
||||||
|
</x-slot:trigger>
|
||||||
|
</x-modal-confirmation>
|
||||||
|
<x-modal-confirmation title="Confirm Force Cleanup Containers?" buttonTitle="Force Cleanup Containers"
|
||||||
|
submitAction="cleanupEvent" :dispatchAction="true" :actions="['Service containers will be force cleaned up.']"
|
||||||
|
:confirmWithText="false" :confirmWithPassword="false" step2ButtonText="Confirm">
|
||||||
|
<x-slot:trigger>
|
||||||
|
<button id="service-cleanup-trigger" type="button" class="hidden">Force Cleanup Containers</button>
|
||||||
|
</x-slot:trigger>
|
||||||
|
</x-modal-confirmation>
|
||||||
|
@endif
|
||||||
|
|
||||||
@script
|
@script
|
||||||
<script>
|
<script>
|
||||||
$wire.$on('stopEvent', () => {
|
$wire.$on('stopEvent', () => {
|
||||||
|
|
@ -252,6 +547,9 @@ class="scrollbar flex min-h-10 w-full flex-nowrap items-center gap-6 overflow-x-
|
||||||
window.dispatchEvent(new CustomEvent('startservice'));
|
window.dispatchEvent(new CustomEvent('startservice'));
|
||||||
$wire.$call('pullAndRestartEvent');
|
$wire.$call('pullAndRestartEvent');
|
||||||
});
|
});
|
||||||
|
$wire.$on('cleanupEvent', () => {
|
||||||
|
$wire.$call('stop', true);
|
||||||
|
});
|
||||||
$wire.on('imagePulled', () => {
|
$wire.on('imagePulled', () => {
|
||||||
window.dispatchEvent(new CustomEvent('startservice'));
|
window.dispatchEvent(new CustomEvent('startservice'));
|
||||||
$wire.$dispatch('info', 'Restarting service.');
|
$wire.$dispatch('info', 'Restarting service.');
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
<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">
|
||||||
@if ($resourceType === 'database')
|
@if ($resourceType === 'database')
|
||||||
<x-service-database.sidebar :parameters="$parameters" :serviceDatabase="$serviceDatabase" :isImportSupported="$isImportSupported" />
|
<x-service-database.sidebar :parameters="$parameters" :serviceDatabase="$serviceDatabase" :isImportSupported="$isImportSupported" />
|
||||||
@else
|
@else
|
||||||
<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() }}
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,15 @@ class="w-4 h-4 dark:text-warning text-coollabs"
|
||||||
@endcan
|
@endcan
|
||||||
</span>
|
</span>
|
||||||
@endif
|
@endif
|
||||||
<div @class(['pt-2' => $isApplication, 'text-xs'])>{{ formatContainerStatus($resource->status) }}</div>
|
<div @class(['pt-2' => $isApplication])>
|
||||||
|
@if (str($resource->status)->contains('running'))
|
||||||
|
<x-status-badge status="{{ formatContainerStatus($resource->status) }}" type="success" />
|
||||||
|
@elseif (str($resource->status)->contains(['starting', 'restarting', 'degraded']))
|
||||||
|
<x-status-badge status="{{ formatContainerStatus($resource->status) }}" type="warning" />
|
||||||
|
@else
|
||||||
|
<x-status-badge status="{{ formatContainerStatus($resource->status) }}" type="error" />
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center px-4">
|
<div class="flex items-center px-4">
|
||||||
@if ($isDatabase && ($resource->isBackupSolutionAvailable() || $resource->is_migrated))
|
@if ($isDatabase && ($resource->isBackupSolutionAvailable() || $resource->is_migrated))
|
||||||
|
|
|
||||||
|
|
@ -12,65 +12,50 @@
|
||||||
<livewire:activity-monitor header="Logs" fullHeight />
|
<livewire:activity-monitor header="Logs" fullHeight />
|
||||||
</x-slot:content>
|
</x-slot:content>
|
||||||
</x-slide-over>
|
</x-slide-over>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex flex-col gap-1.5">
|
||||||
<h1>Server</h1>
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
@if ($server->proxySet())
|
<h1>Server</h1>
|
||||||
<div class="flex">
|
@if ($server->proxySet() || $server->isSentinelEnabled())
|
||||||
<div class="flex items-center">
|
<div data-testid="server-status-summary" class="flex flex-wrap items-center gap-2">
|
||||||
@if ($proxyStatus === 'running')
|
@if ($server->proxySet())
|
||||||
<x-status.running status="Proxy Running" noLoading />
|
<div class="flex items-center gap-1">
|
||||||
@elseif ($proxyStatus === 'restarting')
|
@if ($proxyStatus === 'running')
|
||||||
<x-status.restarting status="Proxy Restarting" noLoading />
|
<x-status-badge label="Proxy" status="Running" type="success" />
|
||||||
@elseif ($proxyStatus === 'stopping')
|
@elseif ($proxyStatus === 'restarting')
|
||||||
<x-status.restarting status="Proxy Stopping" noLoading />
|
<x-status-badge label="Proxy" status="Restarting" type="warning" />
|
||||||
@elseif ($proxyStatus === 'starting')
|
@elseif ($proxyStatus === 'stopping')
|
||||||
<x-status.restarting status="Proxy Starting" noLoading />
|
<x-status-badge label="Proxy" status="Stopping" type="warning" />
|
||||||
@elseif (data_get($server, 'proxy.force_stop'))
|
@elseif ($proxyStatus === 'starting')
|
||||||
<div wire:loading.remove wire:target="checkProxy">
|
<x-status-badge label="Proxy" status="Starting" type="warning" />
|
||||||
<x-status.stopped status="Proxy Stopped (Force Stop)" noLoading />
|
@elseif (data_get($server, 'proxy.force_stop'))
|
||||||
</div>
|
<x-status-badge wire:loading.remove wire:target="checkProxy" label="Proxy"
|
||||||
@elseif ($proxyStatus === 'exited')
|
status="Force stopped" type="error" />
|
||||||
<div wire:loading.remove wire:target="checkProxy">
|
@elseif ($proxyStatus === 'exited')
|
||||||
<x-status.stopped status="Proxy Exited" noLoading />
|
<x-status-badge wire:loading.remove wire:target="checkProxy" label="Proxy" status="Exited"
|
||||||
|
type="error" />
|
||||||
|
@endif
|
||||||
|
<x-status-badge wire:loading wire:target="checkProxy" label="Proxy" status="Checking..."
|
||||||
|
type="warning" />
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
<div wire:loading wire:target="checkProxy" class="badge badge-warning"></div>
|
@if ($server->isSentinelEnabled())
|
||||||
<div wire:loading wire:target="checkProxy"
|
@if ($server->isSentinelLive())
|
||||||
class="pl-2 pr-1 text-xs font-bold tracking-wider dark:text-warning">
|
<x-status-badge label="Sentinel" status="In sync" type="success" />
|
||||||
Checking Ports Availability...
|
@else
|
||||||
</div>
|
<x-status-badge label="Sentinel" status="Out of sync" type="error" />
|
||||||
@if ($proxyStatus !== 'exited')
|
@endif
|
||||||
<button wire:loading.remove title="Refresh Status" wire:click='checkProxyStatus'
|
@endif
|
||||||
class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
|
@if ($server->proxySet())
|
||||||
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
<x-status-badge as="button" wire:target="checkProxyStatus" wire:loading.attr="disabled"
|
||||||
<path
|
wire:click='checkProxyStatus' status="Refresh" type="neutral" title="Refresh Status"
|
||||||
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" />
|
aria-label="Refresh proxy status"
|
||||||
</svg>
|
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" />
|
||||||
</button>
|
|
||||||
<button wire:loading title="Refreshing Status" wire:click='checkProxyStatus'
|
|
||||||
class="mx-1 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>
|
||||||
</div>
|
@endif
|
||||||
@endif
|
</div>
|
||||||
@if ($server->isSentinelEnabled())
|
<div class="subtitle">{{ data_get($server, 'name') }}</div>
|
||||||
<div class="flex">
|
|
||||||
<div class="flex items-center">
|
|
||||||
@if ($server->isSentinelLive())
|
|
||||||
<x-status.running status="Sentinel In Sync" noLoading />
|
|
||||||
@else
|
|
||||||
<x-status.stopped status="Sentinel Out of Sync" noLoading />
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
</div>
|
||||||
<div class="subtitle">{{ data_get($server, 'name') }}</div>
|
|
||||||
<div class="navbar-main">
|
<div class="navbar-main">
|
||||||
<nav
|
<nav
|
||||||
class="flex items-center gap-6 overflow-x-scroll sm:overflow-x-hidden scrollbar min-h-10 whitespace-nowrap pt-2">
|
class="flex items-center gap-6 overflow-x-scroll sm:overflow-x-hidden scrollbar min-h-10 whitespace-nowrap pt-2">
|
||||||
|
|
|
||||||
|
|
@ -230,15 +230,37 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
|
||||||
]" confirmationText="{{ currentTeam()->name }}"
|
]" confirmationText="{{ currentTeam()->name }}"
|
||||||
confirmationLabel="Enter your team name to confirm"
|
confirmationLabel="Enter your team name to confirm"
|
||||||
shortConfirmationLabel="Team Name" step2ButtonText="Confirm Cancellation" />
|
shortConfirmationLabel="Team Name" step2ButtonText="Confirm Cancellation" />
|
||||||
<x-modal-confirmation title="Cancel Immediately?" buttonTitle="Cancel Immediately"
|
@if ($isRefundEligible)
|
||||||
isErrorButton submitAction="cancelImmediately"
|
<div wire:key="cancel-immediately-refundable">
|
||||||
:actions="[
|
<x-modal-confirmation title="Cancel Immediately?" buttonTitle="Cancel Immediately"
|
||||||
'Your subscription will be cancelled immediately.',
|
isErrorButton submitAction="cancelImmediately"
|
||||||
'All servers will be deactivated.',
|
:checkboxes="[
|
||||||
'No refund will be issued for the remaining period.',
|
[
|
||||||
]" confirmationText="{{ currentTeam()->name }}"
|
'id' => 'refundLatestPayment',
|
||||||
confirmationLabel="Enter your team name to confirm"
|
'label' => 'Refund my latest payment (eligible for '.$refundDaysRemaining.' more days).',
|
||||||
shortConfirmationLabel="Team Name" step2ButtonText="Permanently Cancel" />
|
'default_warning' => 'No refund will be issued for the remaining period.',
|
||||||
|
],
|
||||||
|
]"
|
||||||
|
:actions="[
|
||||||
|
'Your subscription will be cancelled immediately.',
|
||||||
|
'All servers will be deactivated.',
|
||||||
|
]" confirmationText="{{ currentTeam()->name }}"
|
||||||
|
confirmationLabel="Enter your team name to confirm"
|
||||||
|
shortConfirmationLabel="Team Name" step2ButtonText="Permanently Cancel" />
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div wire:key="cancel-immediately-standard">
|
||||||
|
<x-modal-confirmation title="Cancel Immediately?" buttonTitle="Cancel Immediately"
|
||||||
|
isErrorButton submitAction="cancelImmediately"
|
||||||
|
:actions="[
|
||||||
|
'Your subscription will be cancelled immediately.',
|
||||||
|
'All servers will be deactivated.',
|
||||||
|
'No refund will be issued for the remaining period.',
|
||||||
|
]" confirmationText="{{ currentTeam()->name }}"
|
||||||
|
confirmationLabel="Enter your team name to confirm"
|
||||||
|
shortConfirmationLabel="Team Name" step2ButtonText="Permanently Cancel" />
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@if (currentTeam()->subscription->stripe_cancel_at_period_end)
|
@if (currentTeam()->subscription->stripe_cancel_at_period_end)
|
||||||
|
|
@ -249,7 +271,7 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
|
||||||
{{-- Refund --}}
|
{{-- Refund --}}
|
||||||
<section>
|
<section>
|
||||||
<h3 class="pb-2">Refund</h3>
|
<h3 class="pb-2">Refund</h3>
|
||||||
@if ($refundCheckLoading || ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end))
|
@if ($refundCheckLoading || $isRefundEligible)
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
@if ($refundCheckLoading)
|
@if ($refundCheckLoading)
|
||||||
<x-forms.button disabled>Request Full Refund</x-forms.button>
|
<x-forms.button disabled>Request Full Refund</x-forms.button>
|
||||||
|
|
@ -269,7 +291,7 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
|
||||||
<p class="mt-2 text-sm text-neutral-500">
|
<p class="mt-2 text-sm text-neutral-500">
|
||||||
@if ($refundCheckLoading)
|
@if ($refundCheckLoading)
|
||||||
Checking refund eligibility...
|
Checking refund eligibility...
|
||||||
@elseif ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end)
|
@elseif ($isRefundEligible)
|
||||||
Eligible for a full refund — <strong class="dark:text-warning">{{ $refundDaysRemaining }}</strong> days remaining.
|
Eligible for a full refund — <strong class="dark:text-warning">{{ $refundDaysRemaining }}</strong> days remaining.
|
||||||
@elseif ($refundAlreadyUsed)
|
@elseif ($refundAlreadyUsed)
|
||||||
Refund already processed. Each team is eligible for one refund only.
|
Refund already processed. Each team is eligible for one refund only.
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,25 @@
|
||||||
->toContain('class="flex flex-col font-logs"')
|
->toContain('class="flex flex-col font-logs"')
|
||||||
->toContain('class="font-logs text-neutral-400 mb-2"')
|
->toContain('class="font-logs text-neutral-400 mb-2"')
|
||||||
->and($activityMonitorView)
|
->and($activityMonitorView)
|
||||||
->toContain('<pre class="font-logs whitespace-pre-wrap"')
|
->toContain('<pre class="font-logs min-w-0 max-w-full whitespace-pre-wrap wrap-anywhere"')
|
||||||
->and($dockerCleanupView)
|
->and($dockerCleanupView)
|
||||||
->toContain('class="flex-1 text-sm font-logs text-gray-700 dark:text-gray-300"')
|
->toContain('class="flex-1 text-sm font-logs text-gray-700 dark:text-gray-300"')
|
||||||
->toContain('class="font-logs text-sm text-gray-600 dark:text-gray-300 whitespace-pre-wrap"')
|
->toContain('class="font-logs text-sm text-gray-600 dark:text-gray-300 whitespace-pre-wrap"')
|
||||||
->and($terminalClient)
|
->and($terminalClient)
|
||||||
->toContain('"Geist Mono"');
|
->toContain('"Geist Mono"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('constrains activity monitor logs inside the available viewport', function () {
|
||||||
|
$activityMonitorView = file_get_contents(resource_path('views/livewire/activity-monitor.blade.php'));
|
||||||
|
|
||||||
|
expect($activityMonitorView)
|
||||||
|
->toContain('flex flex-col w-full min-w-0 max-w-full')
|
||||||
|
->toContain('<pre class="font-logs min-w-0 max-w-full whitespace-pre-wrap wrap-anywhere"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounds database startup activity monitor to the slide over height', function () {
|
||||||
|
$databaseHeadingView = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php'));
|
||||||
|
|
||||||
|
expect($databaseHeadingView)
|
||||||
|
->toContain('<div wire:ignore class="h-full min-h-0 min-w-0 max-w-full">');
|
||||||
|
});
|
||||||
|
|
|
||||||
146
tests/Feature/MobileResourceMenuTest.php
Normal file
146
tests/Feature/MobileResourceMenuTest.php
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
it('uses native mobile menus for databases and services', function () {
|
||||||
|
$applicationHeading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
|
||||||
|
$databaseHeading = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php'));
|
||||||
|
$serviceHeading = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
|
||||||
|
$applicationMobileActions = mobileActionsMarkup($applicationHeading, 'application-mobile-actions', 'application-mobile-section');
|
||||||
|
$databaseMobileActions = mobileActionsMarkup($databaseHeading, 'database-mobile-actions', 'database-mobile-section');
|
||||||
|
$serviceMobileActions = mobileActionsMarkup($serviceHeading, 'service-mobile-actions', 'service-mobile-section');
|
||||||
|
|
||||||
|
expect(mobileActionsAreBeforeSelect($applicationHeading, 'application-mobile-actions', 'application-mobile-section'))->toBeTrue();
|
||||||
|
expect(mobileActionsAreBeforeSelect($databaseHeading, 'database-mobile-actions', 'database-mobile-section'))->toBeTrue();
|
||||||
|
expect(mobileActionsAreBeforeSelect($serviceHeading, 'service-mobile-actions', 'service-mobile-section'))->toBeTrue();
|
||||||
|
|
||||||
|
expect($applicationHeading)
|
||||||
|
->toContain('application-mobile-actions')
|
||||||
|
->toContain("'route' => 'project.application.command'")
|
||||||
|
->toContain("'navigate' => false")
|
||||||
|
->toContain("value.startsWith('location|')")
|
||||||
|
->toContain('window.location.href = url')
|
||||||
|
->toContain('application-mobile-stop-trigger')
|
||||||
|
->toContain('application-mobile-deploy-trigger')
|
||||||
|
->toContain('application-mobile-restart-trigger')
|
||||||
|
->toContain('application-mobile-force-deploy-trigger')
|
||||||
|
->not->toContain('<optgroup label="Actions">');
|
||||||
|
|
||||||
|
expect($applicationMobileActions)
|
||||||
|
->toContain('mb-3')
|
||||||
|
->toContain('Actions')
|
||||||
|
->toContain('<x-forms.button isError class="shrink-0"')
|
||||||
|
->not->toContain('button type="button" class="button shrink-0 text-error"')
|
||||||
|
->toContain('M7 4v16l13 -8z')
|
||||||
|
->toContain('M19.933 13.041a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747')
|
||||||
|
->toContain('M6 5m0 1a1 1 0 0 1 1 -1h2');
|
||||||
|
|
||||||
|
expect($applicationHeading)
|
||||||
|
->toContain('application-mobile-section-label')
|
||||||
|
->toContain('Section');
|
||||||
|
|
||||||
|
expect($databaseHeading)
|
||||||
|
->toContain('database-mobile-section')
|
||||||
|
->toContain('database-mobile-actions')
|
||||||
|
->toContain('<optgroup label="Database">')
|
||||||
|
->toContain('<optgroup label="Configuration">')
|
||||||
|
->toContain("'route' => 'project.database.command'")
|
||||||
|
->toContain("'navigate' => false")
|
||||||
|
->toContain("value.startsWith('location|')")
|
||||||
|
->toContain('window.location.href = url')
|
||||||
|
->toContain('window.Livewire?.navigate ? window.Livewire.navigate(url) : window.location.href = url')
|
||||||
|
->toContain("window.Livewire?.hook?.('morphed'")
|
||||||
|
->toContain('x-model="selected"')
|
||||||
|
->toContain('database-restart-trigger')
|
||||||
|
->toContain('database-stop-trigger')
|
||||||
|
->toContain('database-start-trigger')
|
||||||
|
->toContain('scrollbar hidden min-h-10')
|
||||||
|
->not->toContain('<optgroup label="Links">')
|
||||||
|
->not->toContain('<optgroup label="Actions">')
|
||||||
|
->not->toContain('@selected');
|
||||||
|
|
||||||
|
expect($databaseMobileActions)
|
||||||
|
->toContain('mb-3')
|
||||||
|
->toContain('Actions')
|
||||||
|
->toContain('<x-forms.button isError class="shrink-0"')
|
||||||
|
->not->toContain('button type="button" class="button shrink-0 text-error"')
|
||||||
|
->toContain('M7 4v16l13 -8z')
|
||||||
|
->toContain('M19.933 13.041a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747')
|
||||||
|
->toContain('M6 5m0 1a1 1 0 0 1 1 -1h2');
|
||||||
|
|
||||||
|
expect($databaseHeading)
|
||||||
|
->toContain('database-mobile-section-label')
|
||||||
|
->toContain('Section');
|
||||||
|
|
||||||
|
expect($serviceHeading)
|
||||||
|
->toContain('service-mobile-section')
|
||||||
|
->toContain('service-mobile-actions')
|
||||||
|
->toContain('<optgroup label="Service">')
|
||||||
|
->toContain('<optgroup label="Configuration">')
|
||||||
|
->toContain('<optgroup label="Resource">')
|
||||||
|
->toContain('<optgroup label="Links">')
|
||||||
|
->toContain("'route' => 'project.service.command'")
|
||||||
|
->toContain("'navigate' => false")
|
||||||
|
->toContain("value.startsWith('location|')")
|
||||||
|
->toContain('window.location.href = url')
|
||||||
|
->toContain('window.Livewire?.navigate ? window.Livewire.navigate(url) : window.location.href = url')
|
||||||
|
->toContain("window.Livewire?.hook?.('morphed'")
|
||||||
|
->toContain('x-model="selected"')
|
||||||
|
->toContain('service-restart-trigger')
|
||||||
|
->toContain('service-stop-trigger')
|
||||||
|
->toContain('service-forceDeploy-trigger')
|
||||||
|
->toContain('service-pullAndRestart-trigger')
|
||||||
|
->toContain('scrollbar hidden min-h-10')
|
||||||
|
->toContain('mb-4 w-full md:mb-0 md:hidden')
|
||||||
|
->toContain('hidden flex-wrap items-center gap-2 md:flex')
|
||||||
|
->toContain('flex flex-nowrap')
|
||||||
|
->toContain('overflow-x-auto')
|
||||||
|
->not->toContain('<optgroup label="Actions">')
|
||||||
|
->not->toContain('order-first flex flex-wrap items-center gap-2 sm:order-last')
|
||||||
|
->not->toContain('@selected');
|
||||||
|
|
||||||
|
expect($serviceMobileActions)
|
||||||
|
->toContain('mb-3')
|
||||||
|
->toContain('Actions')
|
||||||
|
->toContain('<x-forms.button isError class="shrink-0"')
|
||||||
|
->not->toContain('button type="button" class="button shrink-0 text-error"')
|
||||||
|
->toContain('M7 4v16l13 -8z')
|
||||||
|
->toContain('M19.933 13.041a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747')
|
||||||
|
->toContain('M6 5m0 1a1 1 0 0 1 1 -1h2');
|
||||||
|
|
||||||
|
expect($serviceHeading)
|
||||||
|
->toContain('service-mobile-section-label')
|
||||||
|
->toContain('Section');
|
||||||
|
});
|
||||||
|
|
||||||
|
function mobileActionsMarkup(string $heading, string $actionsId, string $selectId): string
|
||||||
|
{
|
||||||
|
$actionsPosition = strpos($heading, 'id="'.$actionsId.'"');
|
||||||
|
$selectPosition = strpos($heading, 'id="'.$selectId.'"');
|
||||||
|
|
||||||
|
if ($actionsPosition === false || $selectPosition === false || $actionsPosition > $selectPosition) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return substr($heading, $actionsPosition, $selectPosition - $actionsPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mobileActionsAreBeforeSelect(string $heading, string $actionsId, string $selectId): bool
|
||||||
|
{
|
||||||
|
$actionsPosition = strpos($heading, 'id="'.$actionsId.'"');
|
||||||
|
$selectPosition = strpos($heading, 'id="'.$selectId.'"');
|
||||||
|
|
||||||
|
return $actionsPosition !== false && $selectPosition !== false && $actionsPosition < $selectPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('keeps configuration sidebars hidden until desktop breakpoint', function () {
|
||||||
|
expect(file_get_contents(resource_path('views/livewire/project/database/configuration.blade.php')))
|
||||||
|
->toContain('sub-menu-wrapper hidden md:flex');
|
||||||
|
|
||||||
|
expect(file_get_contents(resource_path('views/livewire/project/service/configuration.blade.php')))
|
||||||
|
->toContain('sub-menu-wrapper hidden md:flex');
|
||||||
|
|
||||||
|
expect(file_get_contents(resource_path('views/livewire/project/service/index.blade.php')))
|
||||||
|
->toContain('sub-menu-wrapper hidden md:flex');
|
||||||
|
|
||||||
|
expect(file_get_contents(resource_path('views/components/service-database/sidebar.blade.php')))
|
||||||
|
->toContain('sub-menu-wrapper hidden md:flex');
|
||||||
|
});
|
||||||
60
tests/Feature/ProcessGithubPullRequestWebhookTest.php
Normal file
60
tests/Feature/ProcessGithubPullRequestWebhookTest.php
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Actions\Application\CleanupPreviewDeployment;
|
||||||
|
use App\Jobs\ProcessGithubPullRequestWebhook;
|
||||||
|
use App\Models\Application;
|
||||||
|
use App\Models\ApplicationPreview;
|
||||||
|
use App\Models\Environment;
|
||||||
|
use App\Models\InstanceSettings;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Models\StandaloneDocker;
|
||||||
|
use App\Models\Team;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
|
||||||
|
|
||||||
|
$this->team = Team::factory()->create();
|
||||||
|
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||||
|
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||||
|
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||||
|
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||||
|
|
||||||
|
$this->application = Application::factory()->create([
|
||||||
|
'environment_id' => $this->environment->id,
|
||||||
|
'destination_id' => $this->destination->id,
|
||||||
|
'destination_type' => $this->destination->getMorphClass(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleans up a closed pull request preview when pull request comment cleanup fails', function () {
|
||||||
|
$preview = ApplicationPreview::create([
|
||||||
|
'application_id' => $this->application->id,
|
||||||
|
'pull_request_id' => 42,
|
||||||
|
'pull_request_html_url' => 'https://github.com/example/repo/pull/42',
|
||||||
|
]);
|
||||||
|
|
||||||
|
CleanupPreviewDeployment::shouldRun()
|
||||||
|
->once()
|
||||||
|
->withArgs(fn (Application $application, int $pullRequestId, ApplicationPreview $applicationPreview): bool => $application->is($this->application)
|
||||||
|
&& $pullRequestId === 42
|
||||||
|
&& $applicationPreview->is($preview))
|
||||||
|
->andReturn([
|
||||||
|
'cancelled_deployments' => 0,
|
||||||
|
'killed_containers' => 0,
|
||||||
|
'status' => 'success',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$job = new class(applicationId: $this->application->id, githubAppId: null, action: 'closed', pullRequestId: 42, pullRequestHtmlUrl: 'https://github.com/example/repo/pull/42', pullRequestTitle: null, beforeSha: null, afterSha: null, commitSha: 'HEAD', authorAssociation: 'OWNER', fullName: 'example/repo') extends ProcessGithubPullRequestWebhook
|
||||||
|
{
|
||||||
|
protected function dispatchPullRequestClosedUpdate(Application $application, ApplicationPreview $preview): void
|
||||||
|
{
|
||||||
|
throw new RuntimeException('GitHub comment cleanup failed.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$job->handle();
|
||||||
|
});
|
||||||
|
|
@ -87,6 +87,35 @@
|
||||||
|
|
||||||
$response->assertSuccessful();
|
$response->assertSuccessful();
|
||||||
$response->assertSee('Use a Build Server?');
|
$response->assertSee('Use a Build Server?');
|
||||||
|
$response->assertSee('application-mobile-section');
|
||||||
|
$response->assertSee('Application menu');
|
||||||
|
$response->assertSee('<optgroup label="Application">', false);
|
||||||
|
$response->assertSee('<optgroup label="Configuration">', false);
|
||||||
|
$response->assertSee('<optgroup label="Links">', false);
|
||||||
|
$response->assertSee('<optgroup label="Actions">', false);
|
||||||
|
$response->assertSee('value="navigate|application|', false);
|
||||||
|
$response->assertSee('value="navigate|configuration|', false);
|
||||||
|
$response->assertSee('window.Livewire?.navigate ? window.Livewire.navigate(url) : window.location.href = url', false);
|
||||||
|
$response->assertSee('value="action:force-deploy"', false);
|
||||||
|
$response->assertSee('application-mobile-stop-trigger');
|
||||||
|
$response->assertSee('application-mobile-deploy-trigger');
|
||||||
|
$response->assertSee('application-mobile-restart-trigger');
|
||||||
|
$response->assertSee('application-mobile-force-deploy-trigger');
|
||||||
|
$response->assertSee('Confirm Application Deployment?');
|
||||||
|
$response->assertSee('Confirm Application Restart?');
|
||||||
|
$response->assertSee('Confirm Application Force Deployment?');
|
||||||
|
$response->assertSee('sub-menu-wrapper hidden md:flex', false);
|
||||||
|
$response->assertSee('scrollbar hidden min-h-10', false);
|
||||||
|
$response->assertSee(route('project.application.deployment.index', [
|
||||||
|
'project_uuid' => $this->project->uuid,
|
||||||
|
'environment_uuid' => $this->environment->uuid,
|
||||||
|
'application_uuid' => $this->application->uuid,
|
||||||
|
]));
|
||||||
|
$response->assertSee(route('project.application.environment-variables', [
|
||||||
|
'project_uuid' => $this->project->uuid,
|
||||||
|
'environment_uuid' => $this->environment->uuid,
|
||||||
|
'application_uuid' => $this->application->uuid,
|
||||||
|
]));
|
||||||
$response->assertSee('form-control flex max-w-full flex-row items-center gap-4 py-1 pr-2', false);
|
$response->assertSee('form-control flex max-w-full flex-row items-center gap-4 py-1 pr-2', false);
|
||||||
$response->assertSee('label flex w-full max-w-full min-w-0 items-center gap-4 px-0', false);
|
$response->assertSee('label flex w-full max-w-full min-w-0 items-center gap-4 px-0', false);
|
||||||
$response->assertSee('flex min-w-0 grow gap-2 break-words', false);
|
$response->assertSee('flex min-w-0 grow gap-2 break-words', false);
|
||||||
|
|
|
||||||
27
tests/Feature/ServerNavbarStatusLayoutTest.php
Normal file
27
tests/Feature/ServerNavbarStatusLayoutTest.php
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
it('uses left aligned single color status badges in the server header', function () {
|
||||||
|
$navbarView = file_get_contents(resource_path('views/livewire/server/navbar.blade.php'));
|
||||||
|
$badgeView = file_get_contents(resource_path('views/components/status-badge.blade.php'));
|
||||||
|
|
||||||
|
expect($navbarView)
|
||||||
|
->toContain('data-testid="server-status-summary"')
|
||||||
|
->toContain('<x-status-badge')
|
||||||
|
->toContain('label="Proxy"')
|
||||||
|
->toContain('label="Sentinel"')
|
||||||
|
->toContain('as="button"')
|
||||||
|
->toContain("wire:click='checkProxyStatus'")
|
||||||
|
->toContain('status="Refresh"')
|
||||||
|
->toContain('border-transparent')
|
||||||
|
->toContain('wire:loading.attr="disabled"')
|
||||||
|
->not->toContain('status="Refreshing..."')
|
||||||
|
->not->toContain('justify-between')
|
||||||
|
->not->toContain('<x-status.stopped status="Proxy Exited" noLoading />')
|
||||||
|
->not->toContain('<x-status.running status="Sentinel In Sync" noLoading />');
|
||||||
|
|
||||||
|
expect($badgeView)
|
||||||
|
->not->toContain('text-neutral-500')
|
||||||
|
->not->toContain('dark:text-neutral-400')
|
||||||
|
->toContain('<button')
|
||||||
|
->toContain("merge(['type' => 'button'])");
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue