mirror of
https://github.com/vitodeploy/vito.git
synced 2025-07-01 14:06:15 +00:00
#591 - monitoring
This commit is contained in:
@ -7,6 +7,7 @@
|
|||||||
use Illuminate\Contracts\Database\Query\Expression;
|
use Illuminate\Contracts\Database\Query\Expression;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class GetMetrics
|
class GetMetrics
|
||||||
@ -17,8 +18,13 @@ class GetMetrics
|
|||||||
*/
|
*/
|
||||||
public function filter(Server $server, array $input): Collection
|
public function filter(Server $server, array $input): Collection
|
||||||
{
|
{
|
||||||
if (isset($input['from']) && isset($input['to']) && $input['from'] === $input['to']) {
|
Validator::make($input, self::rules($input))->validate();
|
||||||
|
|
||||||
|
if (isset($input['from'])) {
|
||||||
$input['from'] = Carbon::parse($input['from'])->format('Y-m-d').' 00:00:00';
|
$input['from'] = Carbon::parse($input['from'])->format('Y-m-d').' 00:00:00';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($input['to'])) {
|
||||||
$input['to'] = Carbon::parse($input['to'])->format('Y-m-d').' 23:59:59';
|
$input['to'] = Carbon::parse($input['to'])->format('Y-m-d').' 23:59:59';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -145,8 +151,8 @@ public static function rules(array $input): array
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (isset($input['period']) && $input['period'] === 'custom') {
|
if (isset($input['period']) && $input['period'] === 'custom') {
|
||||||
$rules['from'] = ['required', 'date', 'before:to'];
|
$rules['from'] = ['required', 'date', 'before_or_equal:to'];
|
||||||
$rules['to'] = ['required', 'date', 'after:from'];
|
$rules['to'] = ['required', 'date', 'after_or_equal:from'];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $rules;
|
return $rules;
|
||||||
|
@ -5,6 +5,7 @@
|
|||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
use App\SSH\Services\ServiceInterface;
|
use App\SSH\Services\ServiceInterface;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
|
||||||
class UpdateMetricSettings
|
class UpdateMetricSettings
|
||||||
{
|
{
|
||||||
@ -13,6 +14,8 @@ class UpdateMetricSettings
|
|||||||
*/
|
*/
|
||||||
public function update(Server $server, array $input): void
|
public function update(Server $server, array $input): void
|
||||||
{
|
{
|
||||||
|
Validator::make($input, self::rules())->validate();
|
||||||
|
|
||||||
/** @var Service $service */
|
/** @var Service $service */
|
||||||
$service = $server->monitoring();
|
$service = $server->monitoring();
|
||||||
/** @var ServiceInterface $handler */
|
/** @var ServiceInterface $handler */
|
||||||
|
95
app/Http/Controllers/MonitoringController.php
Normal file
95
app/Http/Controllers/MonitoringController.php
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Actions\Monitoring\GetMetrics;
|
||||||
|
use App\Actions\Monitoring\UpdateMetricSettings;
|
||||||
|
use App\Enums\ServiceStatus;
|
||||||
|
use App\Models\Metric;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Models\Service;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
use Spatie\RouteAttributes\Attributes\Delete;
|
||||||
|
use Spatie\RouteAttributes\Attributes\Get;
|
||||||
|
use Spatie\RouteAttributes\Attributes\Middleware;
|
||||||
|
use Spatie\RouteAttributes\Attributes\Patch;
|
||||||
|
use Spatie\RouteAttributes\Attributes\Prefix;
|
||||||
|
|
||||||
|
#[Prefix('servers/{server}/monitoring')]
|
||||||
|
#[Middleware(['auth', 'has-project'])]
|
||||||
|
class MonitoringController extends Controller
|
||||||
|
{
|
||||||
|
#[Get('/', name: 'monitoring')]
|
||||||
|
public function index(Server $server): Response
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', [Metric::class, $server]);
|
||||||
|
|
||||||
|
return Inertia::render('monitoring/index', [
|
||||||
|
'lastMetric' => $server->metrics()->latest()->first(),
|
||||||
|
'dataRetention' => $server->monitoring()?->type_data['data_retention'] ?? 30,
|
||||||
|
'hasMonitoringService' => $server->monitoring()?->status === ServiceStatus::READY,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Get('/json', name: 'monitoring.json')]
|
||||||
|
public function json(Request $request, Server $server): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', [Metric::class, $server]);
|
||||||
|
|
||||||
|
$metrics = app(GetMetrics::class)->filter($server, $request->input());
|
||||||
|
|
||||||
|
return response()->json($metrics);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Get('/{metric}', name: 'monitoring.show')]
|
||||||
|
public function show(Server $server, string $metric): Response
|
||||||
|
{
|
||||||
|
if (! in_array($metric, ['load', 'memory', 'disk'])) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->authorize('viewAny', [Metric::class, $server]);
|
||||||
|
|
||||||
|
return Inertia::render('monitoring/show', [
|
||||||
|
'metric' => $metric,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Patch('/update', name: 'monitoring.update')]
|
||||||
|
public function update(Request $request, Server $server): RedirectResponse
|
||||||
|
{
|
||||||
|
/** @var ?Service $monitoring */
|
||||||
|
$monitoring = $server->monitoring();
|
||||||
|
|
||||||
|
if (! $monitoring) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->authorize('update', $monitoring);
|
||||||
|
|
||||||
|
app(UpdateMetricSettings::class)->update($server, $request->input());
|
||||||
|
|
||||||
|
return back()->with('success', 'Settings updated!');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Delete('/reset', name: 'monitoring.destroy')]
|
||||||
|
public function destroy(Server $server): RedirectResponse
|
||||||
|
{
|
||||||
|
/** @var ?Service $monitoring */
|
||||||
|
$monitoring = $server->monitoring();
|
||||||
|
|
||||||
|
if (! $monitoring) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->authorize('update', $monitoring);
|
||||||
|
|
||||||
|
$server->metrics()->delete();
|
||||||
|
|
||||||
|
return back()->with('success', 'All metrics deleted!');
|
||||||
|
}
|
||||||
|
}
|
@ -3,6 +3,7 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
use Database\Factories\MetricFactory;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
@ -29,7 +30,7 @@
|
|||||||
*/
|
*/
|
||||||
class Metric extends Model
|
class Metric extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\MetricFactory> */
|
/** @use HasFactory<MetricFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\ServerLogFactory;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@ -25,7 +26,7 @@
|
|||||||
*/
|
*/
|
||||||
class ServerLog extends AbstractModel
|
class ServerLog extends AbstractModel
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\ServerLogFactory> */
|
/** @use HasFactory<ServerLogFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
@ -103,6 +104,10 @@ public function download(): StreamedResponse
|
|||||||
return Storage::disk('local')->download($tmpName, str($this->name)->afterLast('/'));
|
return Storage::disk('local')->download($tmpName, str($this->name)->afterLast('/'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! Storage::disk($this->disk)->exists($this->name)) {
|
||||||
|
abort(404, "Log file doesn't exist or is empty!");
|
||||||
|
}
|
||||||
|
|
||||||
return Storage::disk($this->disk)->download($this->name);
|
return Storage::disk($this->disk)->download($this->name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,7 +119,7 @@ public static function getRemote(Builder $query, bool $active = true, ?Site $sit
|
|||||||
{
|
{
|
||||||
$query->where('is_remote', $active);
|
$query->where('is_remote', $active);
|
||||||
|
|
||||||
if ($site instanceof \App\Models\Site) {
|
if ($site instanceof Site) {
|
||||||
$query->where('name', 'like', $site->path.'%');
|
$query->where('name', 'like', $site->path.'%');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -10,6 +10,7 @@
|
|||||||
use App\SSH\Services\ProcessManager\ProcessManager;
|
use App\SSH\Services\ProcessManager\ProcessManager;
|
||||||
use App\SSH\Services\ServiceInterface;
|
use App\SSH\Services\ServiceInterface;
|
||||||
use App\SSH\Services\Webserver\Webserver;
|
use App\SSH\Services\Webserver\Webserver;
|
||||||
|
use Database\Factories\ServiceFactory;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
@ -28,7 +29,7 @@
|
|||||||
*/
|
*/
|
||||||
class Service extends AbstractModel
|
class Service extends AbstractModel
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\ServiceFactory> */
|
/** @use HasFactory<ServiceFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
@ -14,7 +14,6 @@ class MetricPolicy
|
|||||||
public function viewAny(User $user, Server $server): bool
|
public function viewAny(User $user, Server $server): bool
|
||||||
{
|
{
|
||||||
return ($user->isAdmin() || $server->project->users->contains($user)) &&
|
return ($user->isAdmin() || $server->project->users->contains($user)) &&
|
||||||
$server->service('monitoring') &&
|
|
||||||
$server->isReady();
|
$server->isReady();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -22,28 +21,24 @@ public function view(User $user, Metric $metric): bool
|
|||||||
{
|
{
|
||||||
|
|
||||||
return ($user->isAdmin() || $metric->server->project->users->contains($user)) &&
|
return ($user->isAdmin() || $metric->server->project->users->contains($user)) &&
|
||||||
$metric->server->service('monitoring') &&
|
|
||||||
$metric->server->isReady();
|
$metric->server->isReady();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(User $user, Server $server): bool
|
public function create(User $user, Server $server): bool
|
||||||
{
|
{
|
||||||
return ($user->isAdmin() || $server->project->users->contains($user)) &&
|
return ($user->isAdmin() || $server->project->users->contains($user)) &&
|
||||||
$server->service('monitoring') &&
|
|
||||||
$server->isReady();
|
$server->isReady();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(User $user, Metric $metric): bool
|
public function update(User $user, Metric $metric): bool
|
||||||
{
|
{
|
||||||
return ($user->isAdmin() || $metric->server->project->users->contains($user)) &&
|
return ($user->isAdmin() || $metric->server->project->users->contains($user)) &&
|
||||||
$metric->server->service('monitoring') &&
|
|
||||||
$metric->server->isReady();
|
$metric->server->isReady();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(User $user, Metric $metric): bool
|
public function delete(User $user, Metric $metric): bool
|
||||||
{
|
{
|
||||||
return ($user->isAdmin() || $metric->server->project->users->contains($user)) &&
|
return ($user->isAdmin() || $metric->server->project->users->contains($user)) &&
|
||||||
$metric->server->service('monitoring') &&
|
|
||||||
$metric->server->isReady();
|
$metric->server->isReady();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -122,4 +122,13 @@ protected function addUfw(): void
|
|||||||
'version' => 'latest',
|
'version' => 'latest',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function addMonitoring(): void
|
||||||
|
{
|
||||||
|
$this->server->services()->create([
|
||||||
|
'type' => 'monitoring',
|
||||||
|
'name' => 'remote-monitor',
|
||||||
|
'version' => 'latest',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -39,5 +39,6 @@ public function createServices(array $input): void
|
|||||||
$this->addSupervisor();
|
$this->addSupervisor();
|
||||||
$this->addRedis();
|
$this->addRedis();
|
||||||
$this->addUfw();
|
$this->addUfw();
|
||||||
|
$this->addMonitoring();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
|
||||||
"style": "default",
|
"style": "default",
|
||||||
"rsc": false,
|
"rsc": false,
|
||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "tailwind.config.js",
|
|
||||||
"css": "resources/css/app.css",
|
"css": "resources/css/app.css",
|
||||||
"baseColor": "neutral",
|
"baseColor": "neutral",
|
||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
|
@ -641,4 +641,14 @@
|
|||||||
'pink',
|
'pink',
|
||||||
'rose',
|
'rose',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'metrics_periods' => [
|
||||||
|
'10m',
|
||||||
|
'30m',
|
||||||
|
'1h',
|
||||||
|
'12h',
|
||||||
|
'1d',
|
||||||
|
'7d',
|
||||||
|
'custom',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
531
package-lock.json
generated
531
package-lock.json
generated
@ -21,7 +21,7 @@
|
|||||||
"@radix-ui/react-scroll-area": "^1.2.8",
|
"@radix-ui/react-scroll-area": "^1.2.8",
|
||||||
"@radix-ui/react-select": "^2.1.6",
|
"@radix-ui/react-select": "^2.1.6",
|
||||||
"@radix-ui/react-separator": "^1.1.6",
|
"@radix-ui/react-separator": "^1.1.6",
|
||||||
"@radix-ui/react-slot": "^1.2.2",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.5",
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
"@radix-ui/react-toggle": "^1.1.2",
|
"@radix-ui/react-toggle": "^1.1.2",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||||
@ -36,13 +36,16 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
"globals": "^15.14.0",
|
"globals": "^15.14.0",
|
||||||
"laravel-vite-plugin": "^1.0",
|
"laravel-vite-plugin": "^1.0",
|
||||||
"lucide-react": "^0.475.0",
|
"lucide-react": "^0.475.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"recharts": "^2.15.3",
|
||||||
"sonner": "^2.0.3",
|
"sonner": "^2.0.3",
|
||||||
"tailwind-merge": "^3.0.1",
|
"tailwind-merge": "^3.0.1",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
@ -293,6 +296,15 @@
|
|||||||
"@babel/core": "^7.0.0-0"
|
"@babel/core": "^7.0.0-0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.27.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.3.tgz",
|
||||||
|
"integrity": "sha512-7EYtGezsdiDMyY80+65EzwiGmcJqpmcZCojSXaRgdrBaGtWTgDZKq69cPIVped6MkIM78cTQ2GOiEYjwOlG4xw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.27.2",
|
"version": "7.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
||||||
@ -1233,6 +1245,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-arrow": {
|
"node_modules/@radix-ui/react-arrow": {
|
||||||
"version": "1.1.6",
|
"version": "1.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.6.tgz",
|
||||||
@ -1369,6 +1399,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-compose-refs": {
|
"node_modules/@radix-ui/react-compose-refs": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||||
@ -1435,6 +1483,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-direction": {
|
"node_modules/@radix-ui/react-direction": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
||||||
@ -1627,6 +1693,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-navigation-menu": {
|
"node_modules/@radix-ui/react-navigation-menu": {
|
||||||
"version": "1.2.12",
|
"version": "1.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.12.tgz",
|
||||||
@ -1700,6 +1784,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-popper": {
|
"node_modules/@radix-ui/react-popper": {
|
||||||
"version": "1.2.6",
|
"version": "1.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.6.tgz",
|
||||||
@ -1803,6 +1905,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-progress": {
|
"node_modules/@radix-ui/react-progress": {
|
||||||
"version": "1.1.6",
|
"version": "1.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.6.tgz",
|
||||||
@ -1932,6 +2052,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-separator": {
|
"node_modules/@radix-ui/react-separator": {
|
||||||
"version": "1.1.6",
|
"version": "1.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.6.tgz",
|
||||||
@ -1956,9 +2094,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-slot": {
|
"node_modules/@radix-ui/react-slot": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
"integrity": "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==",
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
@ -2025,24 +2163,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-toggle": {
|
"node_modules/@radix-ui/react-toggle": {
|
||||||
"version": "1.1.8",
|
"version": "1.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.8.tgz",
|
||||||
@ -2131,6 +2251,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||||
@ -3077,6 +3215,69 @@
|
|||||||
"@babel/types": "^7.20.7"
|
"@babel/types": "^7.20.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
|
||||||
|
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
|
||||||
@ -4048,6 +4249,127 @@
|
|||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/data-view-buffer": {
|
"node_modules/data-view-buffer": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
|
||||||
@ -4102,6 +4424,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||||
@ -4119,6 +4451,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/deep-is": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
@ -4209,6 +4547,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-helpers": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.8.7",
|
||||||
|
"csstype": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@ -4743,6 +5091,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "4.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||||
|
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/eventsource": {
|
"node_modules/eventsource": {
|
||||||
"version": "3.0.7",
|
"version": "3.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||||
@ -4832,6 +5186,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-equals": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||||
@ -5442,6 +5805,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@ -6291,7 +6663,6 @@
|
|||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||||
@ -6533,7 +6904,6 @@
|
|||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@ -7012,7 +7382,6 @@
|
|||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.4.0",
|
"loose-envify": "^1.4.0",
|
||||||
@ -7121,6 +7490,20 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-day-picker": {
|
||||||
|
"version": "8.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
|
||||||
|
"integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/gpbl"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"date-fns": "^2.28.0 || ^3.0.0",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.1.0",
|
"version": "19.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||||
@ -7154,7 +7537,6 @@
|
|||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
@ -7213,6 +7595,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-smooth": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-equals": "^5.0.1",
|
||||||
|
"prop-types": "^15.8.1",
|
||||||
|
"react-transition-group": "^4.4.5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-style-singleton": {
|
"node_modules/react-style-singleton": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||||
@ -7235,6 +7632,60 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-transition-group": {
|
||||||
|
"version": "4.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||||
|
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.5.5",
|
||||||
|
"dom-helpers": "^5.0.1",
|
||||||
|
"loose-envify": "^1.4.0",
|
||||||
|
"prop-types": "^15.6.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.6.0",
|
||||||
|
"react-dom": ">=16.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "2.15.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.3.tgz",
|
||||||
|
"integrity": "sha512-EdOPzTwcFSuqtvkDoaM5ws/Km1+WTAO2eizL7rqiG0V2UVhTnz0m7J2i0CjVPUCdEkZImaWvXLbZDS2H5t6GFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"eventemitter3": "^4.0.1",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"react-is": "^18.3.1",
|
||||||
|
"react-smooth": "^4.0.4",
|
||||||
|
"recharts-scale": "^0.4.4",
|
||||||
|
"tiny-invariant": "^1.3.1",
|
||||||
|
"victory-vendor": "^36.6.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/recharts-scale": {
|
||||||
|
"version": "0.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
|
||||||
|
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"decimal.js-light": "^2.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/recharts/node_modules/react-is": {
|
||||||
|
"version": "18.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||||
|
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@ -8005,6 +8456,12 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.13",
|
"version": "0.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
|
||||||
@ -8378,6 +8835,28 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "36.9.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||||
|
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.3.5",
|
"version": "6.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
|
||||||
|
@ -42,7 +42,7 @@
|
|||||||
"@radix-ui/react-scroll-area": "^1.2.8",
|
"@radix-ui/react-scroll-area": "^1.2.8",
|
||||||
"@radix-ui/react-select": "^2.1.6",
|
"@radix-ui/react-select": "^2.1.6",
|
||||||
"@radix-ui/react-separator": "^1.1.6",
|
"@radix-ui/react-separator": "^1.1.6",
|
||||||
"@radix-ui/react-slot": "^1.2.2",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.5",
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
"@radix-ui/react-toggle": "^1.1.2",
|
"@radix-ui/react-toggle": "^1.1.2",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||||
@ -57,13 +57,16 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
"globals": "^15.14.0",
|
"globals": "^15.14.0",
|
||||||
"laravel-vite-plugin": "^1.0",
|
"laravel-vite-plugin": "^1.0",
|
||||||
"lucide-react": "^0.475.0",
|
"lucide-react": "^0.475.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"recharts": "^2.15.3",
|
||||||
"sonner": "^2.0.3",
|
"sonner": "^2.0.3",
|
||||||
"tailwind-merge": "^3.0.1",
|
"tailwind-merge": "^3.0.1",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
:root {
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.145 0 0);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
@ -12,12 +13,11 @@ :root {
|
|||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.97 0 0);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
--accent: oklch(0.97 0 0);
|
--accent: oklch(0.97 0 0);
|
||||||
--accent-foreground: oklch(0.145 0 0);
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.922 0 0);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.922 0 0);
|
||||||
--ring: oklch(0.87 0 0);
|
--ring: oklch(0.708 0 0);
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
@ -30,7 +30,7 @@ :root {
|
|||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
--sidebar-ring: oklch(0.87 0 0);
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
|
||||||
--brand: oklch(58.5% 0.233 277.117);
|
--brand: oklch(58.5% 0.233 277.117);
|
||||||
--success: var(--color-lime-500);
|
--success: var(--color-lime-500);
|
||||||
@ -43,9 +43,9 @@ :root {
|
|||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
--background: oklch(0.145 0 0);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.145 0 0);
|
--card: oklch(0.205 0 0);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: oklch(0.145 0 0);
|
--popover: oklch(0.205 0 0);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(51.1% 0.262 276.966);
|
--primary: oklch(51.1% 0.262 276.966);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
@ -55,11 +55,10 @@ .dark {
|
|||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
--accent: oklch(0.269 0 0);
|
--accent: oklch(0.269 0 0);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--destructive: oklch(0.396 0.141 25.723);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--border: oklch(0.269 0 0);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--input: oklch(0.269 0 0);
|
--ring: oklch(0.556 0 0);
|
||||||
--ring: oklch(0.439 0 0);
|
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
@ -67,12 +66,12 @@ .dark {
|
|||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar: oklch(0.205 0 0);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.985 0 0);
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(0.269 0 0);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.439 0 0);
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
|
@ -8,8 +8,8 @@ const alertVariants = cva(
|
|||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: 'bg-background text-foreground',
|
default: 'bg-card text-card-foreground',
|
||||||
destructive: 'text-destructive-foreground [&>svg]:text-current *:data-[slot=alert-description]:text-destructive-foreground/80',
|
destructive: 'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
53
resources/js/components/ui/calendar.tsx
Normal file
53
resources/js/components/ui/calendar.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { DayPicker } from 'react-day-picker';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { buttonVariants } from '@/components/ui/button';
|
||||||
|
|
||||||
|
function Calendar({ className, classNames, showOutsideDays = true, ...props }: React.ComponentProps<typeof DayPicker>) {
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn('p-3', className)}
|
||||||
|
classNames={{
|
||||||
|
months: 'flex flex-col sm:flex-row gap-2',
|
||||||
|
month: 'flex flex-col gap-4',
|
||||||
|
caption: 'flex justify-center pt-1 relative items-center w-full',
|
||||||
|
caption_label: 'text-sm font-medium',
|
||||||
|
nav: 'flex items-center gap-1',
|
||||||
|
nav_button: cn(buttonVariants({ variant: 'outline' }), 'size-7 bg-transparent p-0 opacity-50 hover:opacity-100'),
|
||||||
|
nav_button_previous: 'absolute left-1',
|
||||||
|
nav_button_next: 'absolute right-1',
|
||||||
|
table: 'w-full border-collapse space-x-1',
|
||||||
|
head_row: 'flex',
|
||||||
|
head_cell: 'text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]',
|
||||||
|
row: 'flex w-full mt-2',
|
||||||
|
cell: cn(
|
||||||
|
'[&:has([aria-selected])]:bg-accent relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected].day-range-end)]:rounded-r-md',
|
||||||
|
props.mode === 'range'
|
||||||
|
? '[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md'
|
||||||
|
: '[&:has([aria-selected])]:rounded-md',
|
||||||
|
),
|
||||||
|
day: cn(buttonVariants({ variant: 'ghost' }), 'size-8 p-0 font-normal aria-selected:opacity-100'),
|
||||||
|
day_range_start: 'day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground',
|
||||||
|
day_range_end: 'day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground',
|
||||||
|
day_selected:
|
||||||
|
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
|
||||||
|
day_today: 'bg-accent text-accent-foreground',
|
||||||
|
day_outside: 'day-outside text-muted-foreground aria-selected:text-muted-foreground',
|
||||||
|
day_disabled: 'text-muted-foreground opacity-50',
|
||||||
|
day_range_middle: 'aria-selected:bg-accent aria-selected:text-accent-foreground',
|
||||||
|
day_hidden: 'invisible',
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
IconLeft: ({ className, ...props }) => <ChevronLeft className={cn('size-4', className)} {...props} />,
|
||||||
|
IconRight: ({ className, ...props }) => <ChevronRight className={cn('size-4', className)} {...props} />,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Calendar };
|
272
resources/js/components/ui/chart.tsx
Normal file
272
resources/js/components/ui/chart.tsx
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as RechartsPrimitive from 'recharts';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
|
const THEMES = { light: '', dark: '.dark' } as const;
|
||||||
|
|
||||||
|
export type ChartConfig = {
|
||||||
|
[k in string]: {
|
||||||
|
label?: React.ReactNode;
|
||||||
|
icon?: React.ComponentType;
|
||||||
|
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> });
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChartContextProps = {
|
||||||
|
config: ChartConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||||
|
|
||||||
|
function useChart() {
|
||||||
|
const context = React.useContext(ChartContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useChart must be used within a <ChartContainer />');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartContainer({
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
config,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & {
|
||||||
|
config: ChartConfig;
|
||||||
|
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children'];
|
||||||
|
}) {
|
||||||
|
const uniqueId = React.useId();
|
||||||
|
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
data-slot="chart"
|
||||||
|
data-chart={chartId}
|
||||||
|
className={cn(
|
||||||
|
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
|
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
|
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
|
||||||
|
|
||||||
|
if (!colorConfig.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: Object.entries(THEMES)
|
||||||
|
.map(
|
||||||
|
([theme, prefix]) => `
|
||||||
|
${prefix} [data-chart=${id}] {
|
||||||
|
${colorConfig
|
||||||
|
.map(([key, itemConfig]) => {
|
||||||
|
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
|
||||||
|
return color ? ` --color-${key}: ${color};` : null;
|
||||||
|
})
|
||||||
|
.join('\n')}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join('\n'),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||||
|
|
||||||
|
function ChartTooltipContent({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
className,
|
||||||
|
indicator = 'dot',
|
||||||
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
labelClassName,
|
||||||
|
formatter,
|
||||||
|
color,
|
||||||
|
nameKey,
|
||||||
|
labelKey,
|
||||||
|
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||||
|
React.ComponentProps<'div'> & {
|
||||||
|
hideLabel?: boolean;
|
||||||
|
hideIndicator?: boolean;
|
||||||
|
indicator?: 'line' | 'dot' | 'dashed';
|
||||||
|
nameKey?: string;
|
||||||
|
labelKey?: string;
|
||||||
|
}) {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
const tooltipLabel = React.useMemo(() => {
|
||||||
|
if (hideLabel || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = payload;
|
||||||
|
const key = `${labelKey || item?.dataKey || item?.name || 'value'}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const value = !labelKey && typeof label === 'string' ? config[label as keyof typeof config]?.label || label : itemConfig?.label;
|
||||||
|
|
||||||
|
if (labelFormatter) {
|
||||||
|
return <div className={cn('font-medium', labelClassName)}>{labelFormatter(value, payload)}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
|
||||||
|
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
|
||||||
|
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestLabel = payload.length === 1 && indicator !== 'dot';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!nestLabel ? tooltipLabel : null}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{payload.map((item, index) => {
|
||||||
|
const key = `${nameKey || item.name || item.dataKey || 'value'}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const indicatorColor = color || item.payload.fill || item.color;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.dataKey}
|
||||||
|
className={cn(
|
||||||
|
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
|
||||||
|
indicator === 'dot' && 'items-center',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatter && item?.value !== undefined && item.name ? (
|
||||||
|
formatter(item.value, item.name, item, index, item.payload)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{itemConfig?.icon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
!hideIndicator && (
|
||||||
|
<div
|
||||||
|
className={cn('shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)', {
|
||||||
|
'h-2.5 w-2.5': indicator === 'dot',
|
||||||
|
'w-1': indicator === 'line',
|
||||||
|
'w-0 border-[1.5px] border-dashed bg-transparent': indicator === 'dashed',
|
||||||
|
'my-0.5': nestLabel && indicator === 'dashed',
|
||||||
|
})}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--color-bg': indicatorColor,
|
||||||
|
'--color-border': indicatorColor,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div className={cn('flex flex-1 justify-between leading-none', nestLabel ? 'items-end' : 'items-center')}>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{nestLabel ? tooltipLabel : null}
|
||||||
|
<span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
|
||||||
|
</div>
|
||||||
|
{item.value && <span className="text-foreground font-mono font-medium tabular-nums">{item.value.toLocaleString()}</span>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartLegend = RechartsPrimitive.Legend;
|
||||||
|
|
||||||
|
function ChartLegendContent({
|
||||||
|
className,
|
||||||
|
hideIcon = false,
|
||||||
|
payload,
|
||||||
|
verticalAlign = 'bottom',
|
||||||
|
nameKey,
|
||||||
|
}: React.ComponentProps<'div'> &
|
||||||
|
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
|
||||||
|
hideIcon?: boolean;
|
||||||
|
nameKey?: string;
|
||||||
|
}) {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
if (!payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center justify-center gap-4', verticalAlign === 'top' ? 'pb-3' : 'pt-3', className)}>
|
||||||
|
{payload.map((item) => {
|
||||||
|
const key = `${nameKey || item.dataKey || 'value'}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.value} className={cn('[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3')}>
|
||||||
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{itemConfig?.label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to extract item config from a payload.
|
||||||
|
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
||||||
|
if (typeof payload !== 'object' || payload === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadPayload = 'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null ? payload.payload : undefined;
|
||||||
|
|
||||||
|
let configLabelKey: string = key;
|
||||||
|
|
||||||
|
if (key in payload && typeof payload[key as keyof typeof payload] === 'string') {
|
||||||
|
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||||
|
} else if (payloadPayload && key in payloadPayload && typeof payloadPayload[key as keyof typeof payloadPayload] === 'string') {
|
||||||
|
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };
|
@ -11,7 +11,7 @@ function Table({ className, ...props }: React.ComponentProps<'table'>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
|
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
|
||||||
return <thead data-slot="table-header" className={cn('[&_tr]:border-b', className)} {...props} />;
|
return <thead data-slot="table-header" className={cn('bg-card [&_tr]:border-b', className)} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
|
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
|
||||||
|
@ -7,6 +7,7 @@ import { usePage } from '@inertiajs/react';
|
|||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { CheckCircle2Icon, CircleXIcon, InfoIcon, TriangleAlertIcon } from 'lucide-react';
|
||||||
|
|
||||||
export default function Layout({
|
export default function Layout({
|
||||||
children,
|
children,
|
||||||
@ -26,10 +27,38 @@ export default function Layout({
|
|||||||
// localStorage.setItem('sidebar', String(open));
|
// localStorage.setItem('sidebar', String(open));
|
||||||
// };
|
// };
|
||||||
|
|
||||||
if (page.props.flash && page.props.flash.success) toast.success(page.props.flash.success);
|
if (page.props.flash && page.props.flash.success) {
|
||||||
if (page.props.flash && page.props.flash.error) toast.error(page.props.flash.error);
|
toast(
|
||||||
if (page.props.flash && page.props.flash.info) toast.info(page.props.flash.info);
|
<div className="flex items-center gap-2">
|
||||||
if (page.props.flash && page.props.flash.warning) toast.error(page.props.flash.warning);
|
<CheckCircle2Icon className="text-success size-5" />
|
||||||
|
{page.props.flash.success}
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (page.props.flash && page.props.flash.error) {
|
||||||
|
toast(
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CircleXIcon className="text-destructive size-5" />
|
||||||
|
{page.props.flash.error}
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (page.props.flash && page.props.flash.warning) {
|
||||||
|
toast(
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TriangleAlertIcon className="text-warning size-5" />
|
||||||
|
{page.props.flash.warning}
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (page.props.flash && page.props.flash.info) {
|
||||||
|
toast(
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<InfoIcon className="text-info size-5" />
|
||||||
|
{page.props.flash.info}
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { type NavItem } from '@/types';
|
import { type NavItem } from '@/types';
|
||||||
import {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
|
ChartPieIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
CloudIcon,
|
CloudIcon,
|
||||||
CloudUploadIcon,
|
CloudUploadIcon,
|
||||||
@ -119,11 +120,12 @@ export default function ServerLayout({ children }: { children: ReactNode }) {
|
|||||||
icon: CogIcon,
|
icon: CogIcon,
|
||||||
isDisabled: isMenuDisabled,
|
isDisabled: isMenuDisabled,
|
||||||
},
|
},
|
||||||
// {
|
{
|
||||||
// title: 'Metrics',
|
title: 'Monitoring',
|
||||||
// href: '#',
|
href: route('monitoring', { server: page.props.server.id }),
|
||||||
// icon: ChartPieIcon,
|
icon: ChartPieIcon,
|
||||||
// },
|
isDisabled: isMenuDisabled,
|
||||||
|
},
|
||||||
// {
|
// {
|
||||||
// title: 'Console',
|
// title: 'Console',
|
||||||
// href: '#',
|
// href: '#',
|
||||||
|
@ -4,3 +4,30 @@ import { twMerge } from 'tailwind-merge';
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// convert kb to gb
|
||||||
|
export function kbToGb(kb: number | string): number {
|
||||||
|
if (typeof kb === 'string') {
|
||||||
|
kb = parseFloat(kb);
|
||||||
|
}
|
||||||
|
return Math.round((kb / 1024 / 1024) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// convert mb to gb
|
||||||
|
export function mbToGb(mb: number | string): number {
|
||||||
|
if (typeof mb === 'string') {
|
||||||
|
mb = parseFloat(mb);
|
||||||
|
}
|
||||||
|
return Math.round((mb / 1024) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateString(dateString: string | Date): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
|
||||||
|
const year = date.toLocaleString('default', { year: 'numeric' });
|
||||||
|
const month = date.toLocaleString('default', { month: '2-digit' });
|
||||||
|
const day = date.toLocaleString('default', { day: '2-digit' });
|
||||||
|
|
||||||
|
// Generate yyyy-mm-dd date string
|
||||||
|
return year + '-' + month + '-' + day;
|
||||||
|
}
|
||||||
|
148
resources/js/pages/monitoring/components/actions.tsx
Normal file
148
resources/js/pages/monitoring/components/actions.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import { Server } from '@/types/server';
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { LoaderCircleIcon, MoreHorizontalIcon } from 'lucide-react';
|
||||||
|
import React, { FormEvent, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Form, FormField, FormFields } from '@/components/ui/form';
|
||||||
|
import { useForm, usePage } from '@inertiajs/react';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import FormSuccessful from '@/components/form-successful';
|
||||||
|
|
||||||
|
function DataRetention() {
|
||||||
|
const page = usePage<{
|
||||||
|
server: Server;
|
||||||
|
dataRetention: string;
|
||||||
|
}>();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const form = useForm({
|
||||||
|
data_retention: page.props.dataRetention || '30',
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
form.patch(route('monitoring.update', page.props.server.id), {
|
||||||
|
onSuccess: () => {
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
preserveScroll: true,
|
||||||
|
preserveState: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>Data retention</DropdownMenuItem>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Data retention</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">Data retention</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form id="data-retention-form" className="p-4" onSubmit={submit}>
|
||||||
|
<FormFields>
|
||||||
|
<FormField>
|
||||||
|
<Label htmlFor="data_retention">Data retention (days)</Label>
|
||||||
|
<Select value={form.data.data_retention} onValueChange={(value) => form.setData('data_retention', value)}>
|
||||||
|
<SelectTrigger id="data_retention">
|
||||||
|
<SelectValue placeholder="Select a period" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="7">7 Days</SelectItem>
|
||||||
|
<SelectItem value="14">14 Days</SelectItem>
|
||||||
|
<SelectItem value="30">30 Days</SelectItem>
|
||||||
|
<SelectItem value="60">60 Days</SelectItem>
|
||||||
|
<SelectItem value="90">90 Days</SelectItem>
|
||||||
|
<SelectItem value="180">180 Days</SelectItem>
|
||||||
|
<SelectItem value="365">365 Days</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormField>
|
||||||
|
</FormFields>
|
||||||
|
</Form>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline">Close</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button form="data-retention-form" disabled={form.processing}>
|
||||||
|
{form.processing && <LoaderCircleIcon className="animate-spin" />}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Reset({ server }: { server: Server }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const form = useForm();
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
form.delete(route('monitoring.destroy', { server: server.id }), {
|
||||||
|
onSuccess: () => {
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<DropdownMenuItem variant="destructive" onSelect={(e) => e.preventDefault()}>
|
||||||
|
Reset
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Reset metrics</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">Reset and delete metrics</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="p-4">
|
||||||
|
Are you sure you want to reset metrics? This will delete all existing monitoring metrics data for server <strong>{server.name}</strong> and
|
||||||
|
cannot be undone.
|
||||||
|
</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline">Cancel</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button variant="destructive" disabled={form.processing} onClick={submit}>
|
||||||
|
{form.processing && <LoaderCircleIcon className="animate-spin" />}
|
||||||
|
<FormSuccessful successful={form.recentlySuccessful} />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Actions({ server }: { server: Server }) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" className="p-0">
|
||||||
|
<span className="sr-only">Open menu</span>
|
||||||
|
<MoreHorizontalIcon />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DataRetention />
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<Reset server={server} />
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
100
resources/js/pages/monitoring/components/filter.tsx
Normal file
100
resources/js/pages/monitoring/components/filter.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { MetricsFilter } from '@/types/metric';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { CheckIcon, FilterIcon } from 'lucide-react';
|
||||||
|
import { useForm, usePage } from '@inertiajs/react';
|
||||||
|
import { SharedData } from '@/types';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Calendar } from '@/components/ui/calendar';
|
||||||
|
import { DateRange } from 'react-day-picker';
|
||||||
|
import { formatDateString } from '@/lib/utils';
|
||||||
|
|
||||||
|
export default function Filter({ value, onValueChange }: { value?: MetricsFilter; onValueChange?: (filter: MetricsFilter) => void }) {
|
||||||
|
const page = usePage<SharedData>();
|
||||||
|
const form = useForm<MetricsFilter>(
|
||||||
|
value || {
|
||||||
|
period: '',
|
||||||
|
from: '',
|
||||||
|
to: '',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const [range, setRange] = useState<DateRange>();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const setCustomFilter = () => {
|
||||||
|
if (!range || !range.from || !range.to) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.setData({
|
||||||
|
period: 'custom',
|
||||||
|
from: range.from.toISOString(),
|
||||||
|
to: range.to.toISOString(),
|
||||||
|
});
|
||||||
|
setOpen(false);
|
||||||
|
if (onValueChange) {
|
||||||
|
onValueChange({
|
||||||
|
period: 'custom',
|
||||||
|
from: formatDateString(range.from),
|
||||||
|
to: formatDateString(range.to),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleValueChange = (newValue: MetricsFilter) => {
|
||||||
|
if (newValue.period === 'custom') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.setData(newValue);
|
||||||
|
if (onValueChange) {
|
||||||
|
onValueChange(newValue);
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline">
|
||||||
|
<span className="sr-only">Open menu</span>
|
||||||
|
{form.data.period ? <FilterIcon className="text-foreground fill-current" /> : <FilterIcon />}
|
||||||
|
<span className="hidden lg:block">Filter</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{page.props.configs.metrics_periods.map((period) => {
|
||||||
|
return period === 'custom' ? (
|
||||||
|
<DropdownMenuSub key={period}>
|
||||||
|
<DropdownMenuSubTrigger inset>
|
||||||
|
{form.data.period === period && <CheckIcon className="absolute left-3 size-4" />}
|
||||||
|
Custom
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuPortal>
|
||||||
|
<DropdownMenuSubContent className="p-0">
|
||||||
|
<Calendar mode="range" selected={range} onSelect={setRange} />
|
||||||
|
<div className="p-2">
|
||||||
|
<Button onClick={setCustomFilter} variant="outline" className="w-full" disabled={!range || !range.from || !range.to}>
|
||||||
|
Filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuCheckboxItem key={period} onSelect={() => handleValueChange({ period })} checked={form.data.period === period}>
|
||||||
|
{period.charAt(0).toUpperCase() + period.slice(1)}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
88
resources/js/pages/monitoring/components/metrics-cards.tsx
Normal file
88
resources/js/pages/monitoring/components/metrics-cards.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { Server } from '@/types/server';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Metric, MetricsFilter } from '@/types/metric';
|
||||||
|
import { ResourceUsageChart } from '@/pages/monitoring/components/resource-usage-chart';
|
||||||
|
import { kbToGb, mbToGb } from '@/lib/utils';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
export default function MetricsCards({ server, filter, metric }: { server: Server; filter?: MetricsFilter; metric?: string }) {
|
||||||
|
if (!filter) {
|
||||||
|
filter = {
|
||||||
|
period: '10m',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = useQuery<Metric[]>({
|
||||||
|
queryKey: ['metrics', server.id, filter.period, filter.from, filter.to],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch(route('monitoring.json', { server: server.id, ...filter }));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch metrics');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
refetchInterval: 60000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={metric ? 'grid grid-cols-1 gap-4' : 'grid grid-cols-1 gap-6 lg:grid-cols-3'}>
|
||||||
|
{query.isLoading && (
|
||||||
|
<>
|
||||||
|
{metric ? (
|
||||||
|
<Skeleton className="h-[510px] w-full rounded-xl border shadow-xs" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Skeleton className="h-[210px] w-full rounded-xl border shadow-xs" />
|
||||||
|
<Skeleton className="h-[210px] w-full rounded-xl border shadow-xs" />
|
||||||
|
<Skeleton className="h-[210px] w-full rounded-xl border shadow-xs" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{query.data && (
|
||||||
|
<>
|
||||||
|
{(!metric || metric === 'load') && (
|
||||||
|
<ResourceUsageChart
|
||||||
|
title="CPU Load"
|
||||||
|
label="CPU load"
|
||||||
|
dataKey="load"
|
||||||
|
color="var(--color-chart-1)"
|
||||||
|
chartData={query.data}
|
||||||
|
link={route('monitoring.show', { server: server.id, metric: 'load' })}
|
||||||
|
single={metric !== undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(!metric || metric === 'memory') && (
|
||||||
|
<ResourceUsageChart
|
||||||
|
title="Memory Usage"
|
||||||
|
label="Memory usage"
|
||||||
|
dataKey="memory_used"
|
||||||
|
color="var(--color-chart-2)"
|
||||||
|
chartData={query.data}
|
||||||
|
link={route('monitoring.show', { server: server.id, metric: 'memory' })}
|
||||||
|
formatter={(value) => {
|
||||||
|
return `${kbToGb(value as string)} GB`;
|
||||||
|
}}
|
||||||
|
single={metric !== undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(!metric || metric === 'disk') && (
|
||||||
|
<ResourceUsageChart
|
||||||
|
title="Disk Usage"
|
||||||
|
label="Disk usage"
|
||||||
|
dataKey="disk_used"
|
||||||
|
color="var(--color-chart-3)"
|
||||||
|
chartData={query.data}
|
||||||
|
link={route('monitoring.show', { server: server.id, metric: 'disk' })}
|
||||||
|
formatter={(value) => {
|
||||||
|
return `${mbToGb(value as string)} GB`;
|
||||||
|
}}
|
||||||
|
single={metric !== undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,99 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Area, AreaChart, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart';
|
||||||
|
import { Metric } from '@/types/metric';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { router } from '@inertiajs/react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
color: string;
|
||||||
|
dataKey: 'load' | 'memory_used' | 'disk_used';
|
||||||
|
label: string;
|
||||||
|
chartData: Metric[];
|
||||||
|
link: string;
|
||||||
|
formatter?: (value: unknown, name: unknown) => string | number;
|
||||||
|
single?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResourceUsageChart({ title, color, dataKey, label, chartData, link, formatter, single }: Props) {
|
||||||
|
const chartConfig = {
|
||||||
|
[dataKey]: {
|
||||||
|
label: label,
|
||||||
|
color: color,
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="overflow-hidden p-0">
|
||||||
|
<div className="flex items-start justify-between p-4">
|
||||||
|
<div className="space-y-2 py-[7px]">
|
||||||
|
<h2 className="text-muted-foreground text-sm">{title}</h2>
|
||||||
|
<span className="text-3xl font-bold">
|
||||||
|
{chartData.length > 0
|
||||||
|
? formatter
|
||||||
|
? formatter(chartData[chartData.length - 1][dataKey], dataKey)
|
||||||
|
: chartData[chartData.length - 1][dataKey].toLocaleString()
|
||||||
|
: 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!single && (
|
||||||
|
<Button variant="ghost" onClick={() => router.visit(link)}>
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ChartContainer config={chartConfig} className={cn('aspect-auto w-full overflow-hidden rounded-b-xl', single ? 'h-[400px]' : 'h-[100px]')}>
|
||||||
|
<AreaChart accessibilityLayer data={chartData} margin={{ left: 0, right: 0, top: 0, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`fill-${dataKey}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor={color} stopOpacity={0.8} />
|
||||||
|
<stop offset="95%" stopColor={color} stopOpacity={0.1} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<YAxis dataKey={dataKey} hide />
|
||||||
|
<XAxis
|
||||||
|
hide={!single}
|
||||||
|
dataKey="date"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
minTickGap={32}
|
||||||
|
tickFormatter={(value) => {
|
||||||
|
const date = new Date(value);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
cursor={true}
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
labelFormatter={(value) => {
|
||||||
|
return new Date(value).toLocaleDateString('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
formatter={formatter}
|
||||||
|
indicator="dot"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Area dataKey={dataKey} type="monotone" fill={`url(#fill-${dataKey})`} stroke={color} />
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
108
resources/js/pages/monitoring/index.tsx
Normal file
108
resources/js/pages/monitoring/index.tsx
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { Head, Link, usePage } from '@inertiajs/react';
|
||||||
|
import { Server } from '@/types/server';
|
||||||
|
import ServerLayout from '@/layouts/server/layout';
|
||||||
|
import HeaderContainer from '@/components/header-container';
|
||||||
|
import Heading from '@/components/heading';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { BookOpenIcon, TriangleAlertIcon } from 'lucide-react';
|
||||||
|
import Container from '@/components/container';
|
||||||
|
import MetricsCards from '@/pages/monitoring/components/metrics-cards';
|
||||||
|
import Filter from '@/pages/monitoring/components/filter';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Metric, MetricsFilter } from '@/types/metric';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { kbToGb, mbToGb } from '@/lib/utils';
|
||||||
|
import Actions from '@/pages/monitoring/components/actions';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
|
||||||
|
export default function Monitoring() {
|
||||||
|
const page = usePage<{
|
||||||
|
server: Server;
|
||||||
|
lastMetric?: Metric;
|
||||||
|
hasMonitoringService: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const [filter, setFilter] = useState<MetricsFilter>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ServerLayout>
|
||||||
|
<Head title={`Monitoring - ${page.props.server.name}`} />
|
||||||
|
|
||||||
|
<Container className="max-w-5xl">
|
||||||
|
<HeaderContainer>
|
||||||
|
<Heading title="Monitoring" description="Here you can see your server's metrics" />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<a href="https://vitodeploy.com/docs/servers/monitoring" target="_blank">
|
||||||
|
<Button variant="outline">
|
||||||
|
<BookOpenIcon />
|
||||||
|
<span className="hidden lg:block">Docs</span>
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
<Filter onValueChange={setFilter} />
|
||||||
|
<Actions server={page.props.server} />
|
||||||
|
</div>
|
||||||
|
</HeaderContainer>
|
||||||
|
|
||||||
|
{!page.props.hasMonitoringService && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<TriangleAlertIcon />
|
||||||
|
<AlertDescription>
|
||||||
|
<p>
|
||||||
|
To monitor your server, you need to first install a{' '}
|
||||||
|
<Link href={route('services', { server: page.props.server })} className="font-bold underline">
|
||||||
|
monitoring service
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MetricsCards server={page.props.server} filter={filter} />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Memory details</CardTitle>
|
||||||
|
<CardDescription className="sr-only">Memory details</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between border-b p-4">
|
||||||
|
<span>{page.props.lastMetric ? kbToGb(page.props.lastMetric.memory_used) + ' GB' : 'N/A'}</span>
|
||||||
|
<span>Used</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-b p-4">
|
||||||
|
<span>{page.props.lastMetric ? kbToGb(page.props.lastMetric.memory_free) + ' GB' : 'N/A'}</span>
|
||||||
|
<span>Free</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-4">
|
||||||
|
<span>{page.props.lastMetric ? kbToGb(page.props.lastMetric.memory_total) + ' GB' : 'N/A'}</span>
|
||||||
|
<span>Total</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Disk details</CardTitle>
|
||||||
|
<CardDescription className="sr-only">Disk details</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between border-b p-4">
|
||||||
|
<span>{page.props.lastMetric ? mbToGb(page.props.lastMetric.disk_used) + ' GB' : 'N/A'}</span>
|
||||||
|
<span>Used</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-b p-4">
|
||||||
|
<span>{page.props.lastMetric ? mbToGb(page.props.lastMetric.disk_free) + ' GB' : 'N/A'}</span>
|
||||||
|
<span>Free</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-4">
|
||||||
|
<span>{page.props.lastMetric ? mbToGb(page.props.lastMetric.disk_total) + ' GB' : 'N/A'}</span>
|
||||||
|
<span>Total</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</ServerLayout>
|
||||||
|
);
|
||||||
|
}
|
47
resources/js/pages/monitoring/show.tsx
Normal file
47
resources/js/pages/monitoring/show.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { Head, usePage } from '@inertiajs/react';
|
||||||
|
import { Server } from '@/types/server';
|
||||||
|
import ServerLayout from '@/layouts/server/layout';
|
||||||
|
import HeaderContainer from '@/components/header-container';
|
||||||
|
import Heading from '@/components/heading';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { BookOpenIcon } from 'lucide-react';
|
||||||
|
import Container from '@/components/container';
|
||||||
|
import Filter from '@/pages/monitoring/components/filter';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { MetricsFilter } from '@/types/metric';
|
||||||
|
import MetricsCards from '@/pages/monitoring/components/metrics-cards';
|
||||||
|
|
||||||
|
export default function Show() {
|
||||||
|
const page = usePage<{
|
||||||
|
server: Server;
|
||||||
|
metric: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const [filter, setFilter] = useState<MetricsFilter>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ServerLayout>
|
||||||
|
<Head title={`Monitoring - ${page.props.metric} - ${page.props.server.name}`} />
|
||||||
|
|
||||||
|
<Container className="max-w-5xl">
|
||||||
|
<HeaderContainer>
|
||||||
|
<Heading
|
||||||
|
title={page.props.metric.charAt(0).toUpperCase() + page.props.metric.slice(1)}
|
||||||
|
description={`You're viewing ${page.props.metric}'s metrics`}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<a href="https://vitodeploy.com/docs/servers/monitoring" target="_blank">
|
||||||
|
<Button variant="outline">
|
||||||
|
<BookOpenIcon />
|
||||||
|
<span className="hidden lg:block">Docs</span>
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
<Filter onValueChange={setFilter} />
|
||||||
|
</div>
|
||||||
|
</HeaderContainer>
|
||||||
|
|
||||||
|
<MetricsCards server={page.props.server} filter={filter} metric={page.props.metric} />
|
||||||
|
</Container>
|
||||||
|
</ServerLayout>
|
||||||
|
);
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
import { Server } from '@/types/server';
|
import { Server } from '@/types/server';
|
||||||
import { ClipboardCheckIcon, CloudIcon, LoaderCircleIcon, MapPinIcon, MousePointerClickIcon, SlashIcon } from 'lucide-react';
|
import { CheckIcon, CloudIcon, LoaderCircleIcon, MousePointerClickIcon, SlashIcon } from 'lucide-react';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import ServerActions from '@/pages/servers/components/actions';
|
import ServerActions from '@/pages/servers/components/actions';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@ -34,29 +34,6 @@ export default function ServerHeader({ server, site }: { server: Server; site?:
|
|||||||
<div className="flex items-center justify-between border-b px-4 py-2">
|
<div className="flex items-center justify-between border-b px-4 py-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center space-x-2 text-xs">
|
<div className="flex items-center space-x-2 text-xs">
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div>
|
|
||||||
{statusForm.processing && <LoaderCircleIcon className="size-3 animate-spin" />}
|
|
||||||
{!statusForm.processing && <StatusRipple className="cursor-pointer" onClick={checkStatus} variant={server.status_color} />}
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="left">
|
|
||||||
<span>{server.status}</span>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="hidden lg:inline-flex">{server.name}</div>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom">
|
|
||||||
<span className="lg:hidden">{server.name}</span>
|
|
||||||
<span className="hidden lg:inline-flex">Server Name</span>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<SlashIcon className="size-3" />
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
@ -74,16 +51,27 @@ export default function ServerHeader({ server, site }: { server: Server; site?:
|
|||||||
<SlashIcon className="size-3" />
|
<SlashIcon className="size-3" />
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div className="flex items-center space-x-1">
|
{ipCopied ? (
|
||||||
{ipCopied ? <ClipboardCheckIcon className="text-success size-4" /> : <MapPinIcon className="size-4" />}
|
<CheckIcon className="text-success size-3" />
|
||||||
<div className="hidden cursor-pointer lg:inline-flex" onClick={() => copyIp(server.ip)}>
|
) : (
|
||||||
{server.ip}
|
<div>
|
||||||
|
{statusForm.processing && <LoaderCircleIcon className="size-3 animate-spin" />}
|
||||||
|
{!statusForm.processing && <StatusRipple className="cursor-pointer" onClick={checkStatus} variant={server.status_color} />}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
<span>{server.status}</span>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="cursor-pointer lg:inline-flex" onClick={() => copyIp(server.ip)}>
|
||||||
|
{server.ip}
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">
|
<TooltipContent side="bottom">
|
||||||
<span className="lg:hidden">{server.ip}</span>
|
<span>Server IP</span>
|
||||||
<span className="hidden lg:inline-flex">Server IP</span>
|
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{['installing', 'installation_failed'].includes(server.status) && (
|
{['installing', 'installation_failed'].includes(server.status) && (
|
||||||
|
@ -6,6 +6,7 @@ import { usePage } from '@inertiajs/react';
|
|||||||
import Container from '@/components/container';
|
import Container from '@/components/container';
|
||||||
import Heading from '@/components/heading';
|
import Heading from '@/components/heading';
|
||||||
import { PaginatedData } from '@/types';
|
import { PaginatedData } from '@/types';
|
||||||
|
import MetricsCards from '@/pages/monitoring/components/metrics-cards';
|
||||||
|
|
||||||
export default function ServerOverview() {
|
export default function ServerOverview() {
|
||||||
const page = usePage<{
|
const page = usePage<{
|
||||||
@ -16,6 +17,7 @@ export default function ServerOverview() {
|
|||||||
return (
|
return (
|
||||||
<Container className="max-w-5xl">
|
<Container className="max-w-5xl">
|
||||||
<Heading title="Overview" description="Here you can see an overview of your server" />
|
<Heading title="Overview" description="Here you can see an overview of your server" />
|
||||||
|
<MetricsCards server={page.props.server} />
|
||||||
<DataTable columns={columns} paginatedData={page.props.logs} />
|
<DataTable columns={columns} paginatedData={page.props.logs} />
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
1
resources/js/types/index.d.ts
vendored
1
resources/js/types/index.d.ts
vendored
@ -67,6 +67,7 @@ export interface Configs {
|
|||||||
cronjob_intervals: {
|
cronjob_intervals: {
|
||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
};
|
};
|
||||||
|
metrics_periods: string[];
|
||||||
|
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
20
resources/js/types/metric.d.ts
vendored
Normal file
20
resources/js/types/metric.d.ts
vendored
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
export interface Metric {
|
||||||
|
date: string;
|
||||||
|
load: number;
|
||||||
|
memory_total: number;
|
||||||
|
memory_used: number;
|
||||||
|
memory_free: number;
|
||||||
|
disk_total: number;
|
||||||
|
disk_used: number;
|
||||||
|
disk_free: number;
|
||||||
|
date_interval: string;
|
||||||
|
|
||||||
|
[key: string]: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricsFilter {
|
||||||
|
period: string;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
[key: string]: number | string;
|
||||||
|
}
|
@ -4,9 +4,8 @@
|
|||||||
|
|
||||||
use App\Enums\ServiceStatus;
|
use App\Enums\ServiceStatus;
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
use App\Web\Pages\Servers\Metrics\Index;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Livewire\Livewire;
|
use Inertia\Testing\AssertableInertia;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class MetricsTest extends TestCase
|
class MetricsTest extends TestCase
|
||||||
@ -25,19 +24,9 @@ public function test_visit_metrics(): void
|
|||||||
'status' => ServiceStatus::READY,
|
'status' => ServiceStatus::READY,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->get(Index::getUrl(['server' => $this->server]))
|
$this->get(route('monitoring', $this->server))
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('CPU Load')
|
->assertInertia(fn (AssertableInertia $page) => $page->component('monitoring/index'));
|
||||||
->assertSee('Memory Usage')
|
|
||||||
->assertSee('Disk Usage');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_cannot_visit_metrics(): void
|
|
||||||
{
|
|
||||||
$this->actingAs($this->user);
|
|
||||||
|
|
||||||
$this->get(Index::getUrl(['server' => $this->server]))
|
|
||||||
->assertForbidden();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_update_data_retention(): void
|
public function test_update_data_retention(): void
|
||||||
@ -52,13 +41,10 @@ public function test_update_data_retention(): void
|
|||||||
'status' => ServiceStatus::READY,
|
'status' => ServiceStatus::READY,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Livewire::test(Index::class, [
|
$this->patch(route('monitoring.update', $this->server), [
|
||||||
'server' => $this->server,
|
'data_retention' => 365,
|
||||||
])
|
])
|
||||||
->callAction('data-retention', [
|
->assertSessionDoesntHaveErrors();
|
||||||
'data_retention' => 365,
|
|
||||||
])
|
|
||||||
->assertSuccessful();
|
|
||||||
|
|
||||||
$this->assertDatabaseHas('services', [
|
$this->assertDatabaseHas('services', [
|
||||||
'server_id' => $this->server->id,
|
'server_id' => $this->server->id,
|
||||||
|
Reference in New Issue
Block a user