#591 - monitoring

This commit is contained in:
Saeed Vaziry
2025-05-31 00:18:04 +02:00
parent 857319025f
commit c09c7a63fa
32 changed files with 1692 additions and 117 deletions

View File

@ -7,6 +7,7 @@
use Illuminate\Contracts\Database\Query\Expression;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
class GetMetrics
@ -17,8 +18,13 @@ class GetMetrics
*/
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';
}
if (isset($input['to'])) {
$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') {
$rules['from'] = ['required', 'date', 'before:to'];
$rules['to'] = ['required', 'date', 'after:from'];
$rules['from'] = ['required', 'date', 'before_or_equal:to'];
$rules['to'] = ['required', 'date', 'after_or_equal:from'];
}
return $rules;

View File

@ -5,6 +5,7 @@
use App\Models\Server;
use App\Models\Service;
use App\SSH\Services\ServiceInterface;
use Illuminate\Support\Facades\Validator;
class UpdateMetricSettings
{
@ -13,6 +14,8 @@ class UpdateMetricSettings
*/
public function update(Server $server, array $input): void
{
Validator::make($input, self::rules())->validate();
/** @var Service $service */
$service = $server->monitoring();
/** @var ServiceInterface $handler */

View 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!');
}
}

View File

@ -3,6 +3,7 @@
namespace App\Models;
use Carbon\Carbon;
use Database\Factories\MetricFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -29,7 +30,7 @@
*/
class Metric extends Model
{
/** @use HasFactory<\Database\Factories\MetricFactory> */
/** @use HasFactory<MetricFactory> */
use HasFactory;
protected $fillable = [

View File

@ -2,6 +2,7 @@
namespace App\Models;
use Database\Factories\ServerLogFactory;
use Exception;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -25,7 +26,7 @@
*/
class ServerLog extends AbstractModel
{
/** @use HasFactory<\Database\Factories\ServerLogFactory> */
/** @use HasFactory<ServerLogFactory> */
use HasFactory;
protected $fillable = [
@ -103,6 +104,10 @@ public function download(): StreamedResponse
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);
}
@ -114,7 +119,7 @@ public static function getRemote(Builder $query, bool $active = true, ?Site $sit
{
$query->where('is_remote', $active);
if ($site instanceof \App\Models\Site) {
if ($site instanceof Site) {
$query->where('name', 'like', $site->path.'%');
}

View File

@ -10,6 +10,7 @@
use App\SSH\Services\ProcessManager\ProcessManager;
use App\SSH\Services\ServiceInterface;
use App\SSH\Services\Webserver\Webserver;
use Database\Factories\ServiceFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
@ -28,7 +29,7 @@
*/
class Service extends AbstractModel
{
/** @use HasFactory<\Database\Factories\ServiceFactory> */
/** @use HasFactory<ServiceFactory> */
use HasFactory;
protected $fillable = [

View File

@ -14,7 +14,6 @@ class MetricPolicy
public function viewAny(User $user, Server $server): bool
{
return ($user->isAdmin() || $server->project->users->contains($user)) &&
$server->service('monitoring') &&
$server->isReady();
}
@ -22,28 +21,24 @@ public function view(User $user, Metric $metric): bool
{
return ($user->isAdmin() || $metric->server->project->users->contains($user)) &&
$metric->server->service('monitoring') &&
$metric->server->isReady();
}
public function create(User $user, Server $server): bool
{
return ($user->isAdmin() || $server->project->users->contains($user)) &&
$server->service('monitoring') &&
$server->isReady();
}
public function update(User $user, Metric $metric): bool
{
return ($user->isAdmin() || $metric->server->project->users->contains($user)) &&
$metric->server->service('monitoring') &&
$metric->server->isReady();
}
public function delete(User $user, Metric $metric): bool
{
return ($user->isAdmin() || $metric->server->project->users->contains($user)) &&
$metric->server->service('monitoring') &&
$metric->server->isReady();
}
}

View File

@ -122,4 +122,13 @@ protected function addUfw(): void
'version' => 'latest',
]);
}
protected function addMonitoring(): void
{
$this->server->services()->create([
'type' => 'monitoring',
'name' => 'remote-monitor',
'version' => 'latest',
]);
}
}

View File

@ -39,5 +39,6 @@ public function createServices(array $input): void
$this->addSupervisor();
$this->addRedis();
$this->addUfw();
$this->addMonitoring();
}
}

View File

@ -1,10 +1,8 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "resources/css/app.css",
"baseColor": "neutral",
"cssVariables": true,

View File

@ -641,4 +641,14 @@
'pink',
'rose',
],
'metrics_periods' => [
'10m',
'30m',
'1h',
'12h',
'1d',
'7d',
'custom',
],
];

531
package-lock.json generated
View File

@ -21,7 +21,7 @@
"@radix-ui/react-scroll-area": "^1.2.8",
"@radix-ui/react-select": "^2.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-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
@ -36,13 +36,16 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"concurrently": "^9.0.1",
"date-fns": "^3.6.0",
"globals": "^15.14.0",
"laravel-vite-plugin": "^1.0",
"lucide-react": "^0.475.0",
"moment": "^2.30.1",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-day-picker": "^8.10.1",
"react-dom": "^19.0.0",
"recharts": "^2.15.3",
"sonner": "^2.0.3",
"tailwind-merge": "^3.0.1",
"tailwindcss": "^4.0.0",
@ -293,6 +296,15 @@
"@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": {
"version": "7.27.2",
"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": {
"version": "1.1.6",
"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": {
"version": "1.1.2",
"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": {
"version": "1.1.1",
"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": {
"version": "1.2.12",
"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": {
"version": "1.2.6",
"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": {
"version": "1.1.6",
"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": {
"version": "1.1.6",
"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": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.2.tgz",
"integrity": "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==",
"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"
@ -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": {
"version": "1.1.8",
"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": {
"version": "1.1.1",
"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"
}
},
"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": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
@ -4048,6 +4249,127 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"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": {
"version": "1.0.2",
"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"
}
},
"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": {
"version": "4.4.1",
"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": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -4209,6 +4547,16 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -4743,6 +5091,12 @@
"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": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
@ -4832,6 +5186,15 @@
"dev": true,
"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": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@ -5442,6 +5805,15 @@
"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": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@ -6291,7 +6663,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@ -6533,7 +6904,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -7012,7 +7382,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@ -7121,6 +7490,20 @@
"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": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
@ -7154,7 +7537,6 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"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": {
"version": "2.2.3",
"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": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@ -8005,6 +8456,12 @@
"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": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
@ -8378,6 +8835,28 @@
"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": {
"version": "6.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",

View File

@ -42,7 +42,7 @@
"@radix-ui/react-scroll-area": "^1.2.8",
"@radix-ui/react-select": "^2.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-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
@ -57,13 +57,16 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"concurrently": "^9.0.1",
"date-fns": "^3.6.0",
"globals": "^15.14.0",
"laravel-vite-plugin": "^1.0",
"lucide-react": "^0.475.0",
"moment": "^2.30.1",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-day-picker": "^8.10.1",
"react-dom": "^19.0.0",
"recharts": "^2.15.3",
"sonner": "^2.0.3",
"tailwind-merge": "^3.0.1",
"tailwindcss": "^4.0.0",

View File

@ -1,4 +1,5 @@
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
@ -12,12 +13,11 @@ :root {
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 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-foreground: oklch(0.577 0.245 27.325);
--border: 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-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
@ -30,7 +30,7 @@ :root {
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 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);
--success: var(--color-lime-500);
@ -43,9 +43,9 @@ :root {
.dark {
--background: oklch(0.145 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);
--popover: oklch(0.145 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(51.1% 0.262 276.966);
--primary-foreground: oklch(0.985 0 0);
@ -55,11 +55,10 @@ .dark {
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
@ -67,12 +66,12 @@ .dark {
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 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-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@theme {

View File

@ -8,8 +8,8 @@ const alertVariants = cva(
{
variants: {
variant: {
default: 'bg-background text-foreground',
destructive: 'text-destructive-foreground [&>svg]:text-current *:data-[slot=alert-description]:text-destructive-foreground/80',
default: 'bg-card text-card-foreground',
destructive: 'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
},
},
defaultVariants: {

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

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

View File

@ -11,7 +11,7 @@ function Table({ className, ...props }: React.ComponentProps<'table'>) {
}
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'>) {

View File

@ -7,6 +7,7 @@ import { usePage } from '@inertiajs/react';
import { Toaster } from '@/components/ui/sonner';
import { toast } from 'sonner';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { CheckCircle2Icon, CircleXIcon, InfoIcon, TriangleAlertIcon } from 'lucide-react';
export default function Layout({
children,
@ -26,10 +27,38 @@ export default function Layout({
// 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.error) toast.error(page.props.flash.error);
if (page.props.flash && page.props.flash.info) toast.info(page.props.flash.info);
if (page.props.flash && page.props.flash.warning) toast.error(page.props.flash.warning);
if (page.props.flash && page.props.flash.success) {
toast(
<div className="flex items-center gap-2">
<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();

View File

@ -1,6 +1,7 @@
import { type NavItem } from '@/types';
import {
ArrowLeftIcon,
ChartPieIcon,
ClockIcon,
CloudIcon,
CloudUploadIcon,
@ -119,11 +120,12 @@ export default function ServerLayout({ children }: { children: ReactNode }) {
icon: CogIcon,
isDisabled: isMenuDisabled,
},
// {
// title: 'Metrics',
// href: '#',
// icon: ChartPieIcon,
// },
{
title: 'Monitoring',
href: route('monitoring', { server: page.props.server.id }),
icon: ChartPieIcon,
isDisabled: isMenuDisabled,
},
// {
// title: 'Console',
// href: '#',

View File

@ -4,3 +4,30 @@ import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
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;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -1,5 +1,5 @@
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 ServerActions from '@/pages/servers/components/actions';
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="space-y-2">
<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>
<TooltipTrigger asChild>
<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" />
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-1">
{ipCopied ? <ClipboardCheckIcon className="text-success size-4" /> : <MapPinIcon className="size-4" />}
<div className="hidden cursor-pointer lg:inline-flex" onClick={() => copyIp(server.ip)}>
{server.ip}
{ipCopied ? (
<CheckIcon className="text-success size-3" />
) : (
<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="bottom">
<span>{server.status}</span>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-pointer lg:inline-flex" onClick={() => copyIp(server.ip)}>
{server.ip}
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
<span className="lg:hidden">{server.ip}</span>
<span className="hidden lg:inline-flex">Server IP</span>
<span>Server IP</span>
</TooltipContent>
</Tooltip>
{['installing', 'installation_failed'].includes(server.status) && (

View File

@ -6,6 +6,7 @@ import { usePage } from '@inertiajs/react';
import Container from '@/components/container';
import Heading from '@/components/heading';
import { PaginatedData } from '@/types';
import MetricsCards from '@/pages/monitoring/components/metrics-cards';
export default function ServerOverview() {
const page = usePage<{
@ -16,6 +17,7 @@ export default function ServerOverview() {
return (
<Container className="max-w-5xl">
<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} />
</Container>
);

View File

@ -67,6 +67,7 @@ export interface Configs {
cronjob_intervals: {
[key: string]: string;
};
metrics_periods: string[];
[key: string]: unknown;
}

20
resources/js/types/metric.d.ts vendored Normal file
View 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;
}

View File

@ -4,9 +4,8 @@
use App\Enums\ServiceStatus;
use App\Models\Service;
use App\Web\Pages\Servers\Metrics\Index;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Inertia\Testing\AssertableInertia;
use Tests\TestCase;
class MetricsTest extends TestCase
@ -25,19 +24,9 @@ public function test_visit_metrics(): void
'status' => ServiceStatus::READY,
]);
$this->get(Index::getUrl(['server' => $this->server]))
$this->get(route('monitoring', $this->server))
->assertSuccessful()
->assertSee('CPU Load')
->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();
->assertInertia(fn (AssertableInertia $page) => $page->component('monitoring/index'));
}
public function test_update_data_retention(): void
@ -52,13 +41,10 @@ public function test_update_data_retention(): void
'status' => ServiceStatus::READY,
]);
Livewire::test(Index::class, [
'server' => $this->server,
])
->callAction('data-retention', [
$this->patch(route('monitoring.update', $this->server), [
'data_retention' => 365,
])
->assertSuccessful();
->assertSessionDoesntHaveErrors();
$this->assertDatabaseHas('services', [
'server_id' => $this->server->id,