Revived the dashboard.

This commit is contained in:
Leonid Nikitin 2024-04-28 01:26:30 +05:00
parent 4bc170ed00
commit 5703015874
Signed by: kor-elf
GPG Key ID: 3C0F720C170F6E1D
41 changed files with 2005 additions and 112 deletions

View File

@ -1,4 +1,5 @@
DOCKER_NGINX_PORT=8080 DOCKER_NGINX_PORT=8080
DOCKER_WEBSOCKET_PORT=8081
DOCKER_DB_PORT=3306 DOCKER_DB_PORT=3306
MYSQL_ROOT_PASSWORD=root_pass MYSQL_ROOT_PASSWORD=root_pass
DB_DATABASE=captcha DB_DATABASE=captcha

View File

@ -72,3 +72,21 @@ VITE_PUSHER_HOST="${PUSHER_HOST}"
VITE_PUSHER_PORT="${PUSHER_PORT}" VITE_PUSHER_PORT="${PUSHER_PORT}"
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}" VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
REVERB_APP_ID=
REVERB_APP_KEY=
REVERB_APP_SECRET=
REVERB_HOST="reverb"
REVERB_PORT=9000
REVERB_SCHEME=http
# * or localhost.com or localhost.com, localhost.net
REVERB_ALLOWED_ORIGINS="*"
REVERB_HOST_CLIENT="localhost"
REVERB_PORT_CLIENT=8081
REVERB_SCHEME_CLIENT=http
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"

View File

@ -0,0 +1,17 @@
<?php declare(strict_types=1);
namespace App\Broadcasting;
use App\Models\CaptchaToken;
use App\Models\User;
final readonly class CreatedCaptchaLog
{
/**
* Authenticate the user's access to the channel.
*/
public function join(User $user): bool
{
return $user->can('viewAny', CaptchaToken::class);
}
}

View File

@ -0,0 +1,38 @@
<?php declare(strict_types=1);
namespace App\Dto\Repository\CaptchaLogRepository;
use App\Enums\CaptchaLogType;
final class QuantityByDays
{
private array $days = [];
/**
* @param string $day ("Y-m-d")
* @param CaptchaLogType $type
* @param int $count
* @return void
*/
public function add(string $day, CaptchaLogType $type, int $count): void
{
if (!isset($this->days[$day])) {
$this->days[$day] = [];
}
$this->days[$day][$type->value] = $count;
}
/**
* @param string $day ("Y-m-d")
* @return array
*/
public function getDay(string $day): array
{
return $this->days[$day] ?? [];
}
public function getDays(): array
{
return $this->days;
}
}

View File

@ -8,4 +8,9 @@ enum CaptchaLogType: int
case Error = 2; case Error = 2;
case Verified = 3; case Verified = 3;
case ReadVerified = 4; case ReadVerified = 4;
public function getTitle(): string
{
return __('captcha_log_type.' . $this->name);
}
} }

View File

@ -0,0 +1,47 @@
<?php declare(strict_types=1);
namespace App\Events;
use App\Helpers\Helpers;
use App\Models\CaptchaLog;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
final class CreatedCaptchaLog implements ShouldBroadcast
{
use SerializesModels, Dispatchable;
public array $chartCaptchaActivity = [];
public array $captchaLog = [];
public function __construct(CaptchaLog $captchaLog) {
$this->chartCaptchaActivity = [
'date' => $captchaLog->created_at->format('Y-m-d'),
'meta' => $captchaLog->type->getTitle(),
'type' => $captchaLog->type->value,
];
$link = '';
$title = '';
if ($captchaLog->captcha && $captchaLog->captcha->captchaToken) {
$link = route('captcha-tokens.edit', ['captcha_token' => $captchaLog->captcha->captcha_token_id], false);
$title = $captchaLog->captcha->captchaToken->title;
}
$this->captchaLog = [
'created_at' => $captchaLog->created_at->format("d.m.Y H:i:s"),
'link' => $link,
'title' => $title,
'type' => $captchaLog->type->getTitle(),
'ip' => $captchaLog->ip,
'user_agent' => $captchaLog->user_agent,
'referer' => $captchaLog->referer,
];
}
public function broadcastOn(): Channel
{
return new PrivateChannel('chart-captcha-activity');
}
}

View File

@ -2,12 +2,42 @@
namespace App\Http\Controllers\Private; namespace App\Http\Controllers\Private;
use App\Http\Resources\Private\Dashboard\ChartCaptchaActivity;
use App\Services\Private\DashboardService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\View\View; use Illuminate\View\View;
final class DashboardController extends Controller final class DashboardController extends Controller
{ {
public function index(): View public function __construct(
private readonly DashboardService $dashboardService,
) { }
public function index(Request $request): View
{ {
return view('private/dashboard/index'); $user = $request->user();
$limit = 30;
$with = ['captcha', 'captcha.captchaToken'];
$result = $this->dashboardService->captchaLog($user, $limit, $with);
if ($result->isError()) {
$this->errors($result);
}
return view('private/dashboard/index', $result->getData());
}
public function chartCaptchaActivity(Request $request): JsonResponse
{
$user = $request->user();
$from = Carbon::now()->subDays(7);
$to = Carbon::now();
$result = $this->dashboardService->chartCaptchaActivity($user, $from, $to);
if (!$result->isSuccess()) {
return response()->json($result->getData())->setStatusCode($result->getCode());
}
return response()->json(new ChartCaptchaActivity($result));
} }
} }

View File

@ -0,0 +1,24 @@
<?php declare(strict_types=1);
namespace App\Http\Controllers\Private;
use App\Http\Resources\Private\Websockets\Setting;
use App\Services\Private\WebsocketService;
use Illuminate\Http\JsonResponse;
final class WebsocketsController extends Controller
{
public function __construct(
private readonly WebsocketService $websocketService,
) { }
public function settings(): JsonResponse
{
$result = $this->websocketService->settings();
if (!$result->isSuccess()) {
return response()->json($result->getData())->setStatusCode($result->getCode());
}
return response()->json(new Setting($result));
}
}

View File

@ -0,0 +1,53 @@
<?php declare(strict_types=1);
namespace App\Http\Resources\Private\Dashboard;
use App\Enums\CaptchaLogType;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
final class ChartCaptchaActivity extends JsonResource
{
/**
* @var \App\ServiceResults\Private\Dashboard\ChartCaptchaActivity
*/
public $resource;
/**
* Transform the resource into an array.
*
* @param Request $request
* @return array
*/
public function toArray(Request $request): array
{
$period = $this->resource->getFrom()->diff($this->resource->getTo())->stepBy('day');
$days = [];
$values = [];
$types = [];
foreach (CaptchaLogType::cases() as $type) {
$values[$type->value] = [];
$types[$type->value] = [
'meta' => $type->getTitle(),
'value' => 0,
];
}
foreach ($period as $item) {
$day = $item->format('Y-m-d');
$days[] = $day;
$quantity = $this->resource->getQuantityByDays()->getDay($day);
foreach (CaptchaLogType::cases() as $type) {
$values[$type->value][] = [
'meta' => $type->getTitle(),
'value' => $quantity[$type->value] ?? 0,
];
}
}
return [
'days' => $days,
'values' => $values,
'types' => $types,
];
}
}

View File

@ -0,0 +1,31 @@
<?php declare(strict_types=1);
namespace App\Http\Resources\Private\Websockets;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
final class Setting extends JsonResource
{
/**
* @var \App\ServiceResults\Private\Websocket\Setting
*/
public $resource;
/**
* Transform the resource into an array.
*
* @param Request $request
* @return array
*/
public function toArray(Request $request): array
{
return [
'key' => $this->resource->getKey(),
'wsHost' => $this->resource->getWsHost(),
'wsPort' => $this->resource->getWsPort(),
'wssPort' => $this->resource->getWssPort(),
'forceTLS' => $this->resource->isForceTLS(),
];
}
}

View File

@ -4,6 +4,7 @@
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\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
final class Captcha extends Model final class Captcha extends Model
@ -34,4 +35,9 @@ public function captchaLogs(): HasMany
{ {
return $this->hasMany(CaptchaLog::class); return $this->hasMany(CaptchaLog::class);
} }
public function captchaToken(): BelongsTo
{
return $this->belongsTo(CaptchaToken::class);
}
} }

View File

@ -6,6 +6,7 @@
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
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;
final class CaptchaLog extends Model final class CaptchaLog extends Model
{ {
@ -44,4 +45,9 @@ public function scopeLatest(Builder $query): Builder
{ {
return $query->orderBy('created_at', 'desc'); return $query->orderBy('created_at', 'desc');
} }
public function captcha(): BelongsTo
{
return $this->belongsTo(Captcha::class);
}
} }

View File

@ -2,13 +2,30 @@
namespace App\Repositories; namespace App\Repositories;
use App\Dto\Repository\CaptchaLogRepository\QuantityByDays;
use App\Enums\CaptchaLogType; use App\Enums\CaptchaLogType;
use App\Models\CaptchaLog; use App\Models\CaptchaLog;
use App\Services\Search\CreateSearchInstanceCommand;
use App\Services\Search\Search;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
final class CaptchaLogRepository final class CaptchaLogRepository
{ {
public function __construct(
private CreateSearchInstanceCommand $createSearchInstanceCommand,
) { }
public function getCaptchaLogs(array $with = []): Search
{
$query = CaptchaLog::query()
->with($with)
->latest();
return $this->createSearchInstanceCommand->execute($query);
}
public function countByType(CaptchaLogType $type, ?int $captchaId = null): int public function countByType(CaptchaLogType $type, ?int $captchaId = null): int
{ {
return CaptchaLog::query() return CaptchaLog::query()
@ -32,4 +49,22 @@ public function getCaptchaLogsByTypes(array $types, ?int $captchaId = null, ?int
->latest() ->latest()
->get(); ->get();
} }
public function countByDays(Carbon $from, Carbon $to): QuantityByDays
{
$count = CaptchaLog::query()
->selectRaw('DATE_FORMAT(created_at, \'%Y-%m-%d\') AS date, type, COUNT(id) AS count')
->where('created_at', '>=', $from)
->where('created_at', '<=', $to)
->groupBy('date', 'type')
->orderBy('date', 'asc')
->get();
$quantity = new QuantityByDays();
foreach ($count as $item) {
$quantity->add($item->date, $item->type, $item->count);
}
return $quantity;
}
} }

View File

@ -0,0 +1,31 @@
<?php declare(strict_types=1);
namespace App\ServiceResults\Private\Dashboard;
use App\Dto\Repository\CaptchaLogRepository\QuantityByDays;
use App\ServiceResults\ServiceResult;
use Illuminate\Support\Carbon;
final class ChartCaptchaActivity extends ServiceResult
{
public function __construct(
private readonly Carbon $from,
private readonly Carbon $to,
private readonly QuantityByDays $quantityByDays,
) { }
public function getFrom(): Carbon
{
return $this->from;
}
public function getTo(): Carbon
{
return $this->to;
}
public function getQuantityByDays(): QuantityByDays
{
return $this->quantityByDays;
}
}

View File

@ -0,0 +1,41 @@
<?php declare(strict_types=1);
namespace App\ServiceResults\Private\Websocket;
use App\ServiceResults\ServiceResult;
final class Setting extends ServiceResult
{
public function __construct(
private readonly string $key,
private readonly string $wsHost,
private readonly int $wsPort,
private readonly int $wssPort,
private readonly bool $forceTLS,
) { }
public function getKey(): string
{
return $this->key;
}
public function getWsHost(): string
{
return $this->wsHost;
}
public function getWsPort(): int
{
return $this->wsPort;
}
public function getWssPort(): int
{
return $this->wssPort;
}
public function isForceTLS(): bool
{
return $this->forceTLS;
}
}

View File

@ -4,6 +4,7 @@
use App\Dto\HttpUserData; use App\Dto\HttpUserData;
use App\Enums\CaptchaLogType; use App\Enums\CaptchaLogType;
use App\Events\CreatedCaptchaLog;
use App\Models\Captcha; use App\Models\Captcha;
use App\Models\CaptchaLog; use App\Models\CaptchaLog;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -22,12 +23,16 @@ public function handleStore(int $captchaId, CaptchaLogType $captchaLogType, Http
$referer = Str::limit($referer, 10000, ''); $referer = Str::limit($referer, 10000, '');
} }
return CaptchaLog::create([ $captchaLog = CaptchaLog::create([
'captcha_id' => $captchaId, 'captcha_id' => $captchaId,
'type' => $captchaLogType, 'type' => $captchaLogType,
'ip' => $httpUserData->getClientIp(), 'ip' => $httpUserData->getClientIp(),
'user_agent' => $userAgent, 'user_agent' => $userAgent,
'referer' => $referer, 'referer' => $referer,
]); ]);
CreatedCaptchaLog::dispatch($captchaLog);
return $captchaLog;
} }
} }

View File

@ -0,0 +1,54 @@
<?php declare(strict_types=1);
namespace App\Services\Private;
use App\Enums\CaptchaLogType;
use App\Models\CaptchaToken;
use App\Models\User;
use App\Repositories\CaptchaLogRepository;
use App\ServiceResults\Private\Dashboard\ChartCaptchaActivity;
use App\ServiceResults\ServiceResultArray;
use App\ServiceResults\ServiceResultError;
use App\Services\Service;
use Illuminate\Support\Carbon;
final class DashboardService extends Service
{
public function __construct(
private readonly CaptchaLogRepository $captchaLogRepository,
) { }
public function captchaLog(User $user, int $limit = 10, array $with = []): ServiceResultError | ServiceResultArray
{
if ($user->cannot('viewAny', CaptchaToken::class)) {
return $this->result([
'items' => collect([]),
'user' => $user,
]);
}
return $this->result([
'items' => $this->captchaLogRepository->getCaptchaLogs($with)->get($limit),
'user' => $user,
]);
}
public function chartCaptchaActivity(User $user, Carbon $from, Carbon $to): ServiceResultError | ChartCaptchaActivity
{
if ($user->cannot('viewAny', CaptchaToken::class)) {
return $this->errFobidden(__('Access is denied'));
}
if ($to < $from) {
return $this->errValidate(__('From cannot be greater than To'));
}
$quantity = $this->captchaLogRepository->countByDays($from, $to);
return new ChartCaptchaActivity(
from: $from,
to: $to,
quantityByDays: $quantity,
);
}
}

View File

@ -0,0 +1,24 @@
<?php declare(strict_types=1);
namespace App\Services\Private;
use App\ServiceResults\Private\Websocket\Setting;
use App\ServiceResults\ServiceResultError;
use App\Services\Service;
final class WebsocketService extends Service
{
public function settings(): ServiceResultError | Setting
{
$config = config('reverb.apps');
$config = $config['apps'][0] ?? [];
return new Setting(
key: $config['key'] ?? '',
wsHost: $config['options_for_client']['host'] ?? '',
wsPort: (int) $config['options_for_client']['port'] ?? 80,
wssPort: 443,
forceTLS: $config['options_for_client']['useTLS'] ?? false,
);
}
}

View File

@ -9,6 +9,7 @@
"guzzlehttp/guzzle": "^7.2", "guzzlehttp/guzzle": "^7.2",
"kor-elf/captcha-rule-for-laravel": "^1.0", "kor-elf/captcha-rule-for-laravel": "^1.0",
"laravel/framework": "^11.0", "laravel/framework": "^11.0",
"laravel/reverb": "@beta",
"laravel/sanctum": "^4.0", "laravel/sanctum": "^4.0",
"laravel/tinker": "^2.8" "laravel/tinker": "^2.8"
}, },

File diff suppressed because it is too large Load Diff

View File

@ -215,7 +215,7 @@
*/ */
App\Providers\AppServiceProvider::class, App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class, App\Providers\AuthServiceProvider::class,
// App\Providers\BroadcastServiceProvider::class, App\Providers\BroadcastServiceProvider::class,
App\Providers\EventServiceProvider::class, App\Providers\EventServiceProvider::class,
App\Providers\RouteServiceProvider::class, App\Providers\RouteServiceProvider::class,

View File

@ -0,0 +1,97 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Reverb Server
|--------------------------------------------------------------------------
|
| This option controls the default server used by Reverb to handle
| incoming messages as well as broadcasting message to all your
| connected clients. At this time only "reverb" is supported.
|
*/
'default' => env('REVERB_SERVER', 'reverb'),
/*
|--------------------------------------------------------------------------
| Reverb Servers
|--------------------------------------------------------------------------
|
| Here you may define details for each of the supported Reverb servers.
| Each server has its own configuration options that are defined in
| the array below. You should ensure all the options are present.
|
*/
'servers' => [
'reverb' => [
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
'port' => env('REVERB_SERVER_PORT', 8080),
'hostname' => env('REVERB_HOST'),
'options' => [
'tls' => [],
],
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
'scaling' => [
'enabled' => env('REVERB_SCALING_ENABLED', false),
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
'server' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'port' => env('REDIS_PORT', '6379'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'database' => env('REDIS_DB', '0'),
],
],
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
],
],
/*
|--------------------------------------------------------------------------
| Reverb Applications
|--------------------------------------------------------------------------
|
| Here you may define how Reverb applications are managed. If you choose
| to use the "config" provider, you may define an array of apps which
| your server will support, including their connection credentials.
|
*/
'apps' => [
'provider' => 'config',
'apps' => [
[
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'allowed_origins' => explode(',', env('REVERB_ALLOWED_ORIGINS', '*')),
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
'options_for_client' => [
'host' => env('REVERB_HOST_CLIENT'),
'port' => env('REVERB_PORT_CLIENT', 443),
'scheme' => env('REVERB_SCHEME_CLIENT', 'https'),
'useTLS' => env('REVERB_SCHEME_CLIENT', 'https') === 'https',
],
],
],
],
];

View File

@ -73,5 +73,6 @@
"The time for captcha verification has passed": "The time for captcha verification has passed.", "The time for captcha verification has passed": "The time for captcha verification has passed.",
"Captcha does not pass verification": "Captcha does not pass verification.", "Captcha does not pass verification": "Captcha does not pass verification.",
"Add code to the site": "Add code to the site", "Add code to the site": "Add code to the site",
"Demo Mode": "!!! Demo Mode !!!" "Demo Mode": "!!! Demo Mode !!!",
"From cannot be greater than To": "From cannot be greater than To."
} }

View File

@ -0,0 +1,7 @@
<?php
return [
'Created' => 'Creating a captcha',
'Error' => 'Error in captcha validation',
'Verified' => 'Successfully verified',
'ReadVerified' => 'Completed captcha verification',
];

View File

@ -73,5 +73,6 @@
"The time for captcha verification has passed": "Время верификации капчи прошло.", "The time for captcha verification has passed": "Время верификации капчи прошло.",
"Captcha does not pass verification": "Капча не проходит проверку.", "Captcha does not pass verification": "Капча не проходит проверку.",
"Add code to the site": "Добавьте код на сайт", "Add code to the site": "Добавьте код на сайт",
"Demo Mode": "!!! Включён демо режим !!!" "Demo Mode": "!!! Включён демо режим !!!",
"From cannot be greater than To": "From не может быть больше To."
} }

View File

@ -0,0 +1,7 @@
<?php
return [
'Created' => 'Создание капчи',
'Error' => 'Ошибка в валидации капчи',
'Verified' => 'Успешно проверено',
'ReadVerified' => 'Завершена проверка капчи',
];

View File

@ -21,7 +21,9 @@
}, },
"devDependencies": { "devDependencies": {
"axios": "^1.1.2", "axios": "^1.1.2",
"laravel-echo": "^1.16.1",
"laravel-vite-plugin": "^0.7.2", "laravel-vite-plugin": "^0.7.2",
"pusher-js": "^8.4.0-rc2",
"sass-loader": "^13.3.2", "sass-loader": "^13.3.2",
"vite": "^4.0.0" "vite": "^4.0.0"
} }
@ -1251,6 +1253,15 @@
"dev": true, "dev": true,
"peer": true "peer": true
}, },
"node_modules/laravel-echo": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-1.16.1.tgz",
"integrity": "sha512-++Ylb6M3ariC9Rk5WE5gZjj6wcEV5kvLF8b+geJ5/rRIfdoOA+eG6b9qJPrarMD9rY28Apx+l3eelIrCc2skVg==",
"dev": true,
"engines": {
"node": ">=10"
}
},
"node_modules/laravel-vite-plugin": { "node_modules/laravel-vite-plugin": {
"version": "0.7.8", "version": "0.7.8",
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-0.7.8.tgz", "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-0.7.8.tgz",
@ -1436,6 +1447,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/pusher-js": {
"version": "8.4.0-rc2",
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.4.0-rc2.tgz",
"integrity": "sha512-d87GjOEEl9QgO5BWmViSqW0LOzPvybvX6WA9zLUstNdB57jVJuR27zHkRnrav2a3+zAMlHbP2Og8wug+rG8T+g==",
"dev": true,
"dependencies": {
"tweetnacl": "^1.0.3"
}
},
"node_modules/randombytes": { "node_modules/randombytes": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@ -1723,6 +1743,12 @@
"node": ">=8.0" "node": ">=8.0"
} }
}, },
"node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
"dev": true
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.0.11", "version": "1.0.11",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz",
@ -2755,6 +2781,12 @@
"dev": true, "dev": true,
"peer": true "peer": true
}, },
"laravel-echo": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-1.16.1.tgz",
"integrity": "sha512-++Ylb6M3ariC9Rk5WE5gZjj6wcEV5kvLF8b+geJ5/rRIfdoOA+eG6b9qJPrarMD9rY28Apx+l3eelIrCc2skVg==",
"dev": true
},
"laravel-vite-plugin": { "laravel-vite-plugin": {
"version": "0.7.8", "version": "0.7.8",
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-0.7.8.tgz", "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-0.7.8.tgz",
@ -2883,6 +2915,15 @@
"dev": true, "dev": true,
"peer": true "peer": true
}, },
"pusher-js": {
"version": "8.4.0-rc2",
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.4.0-rc2.tgz",
"integrity": "sha512-d87GjOEEl9QgO5BWmViSqW0LOzPvybvX6WA9zLUstNdB57jVJuR27zHkRnrav2a3+zAMlHbP2Og8wug+rG8T+g==",
"dev": true,
"requires": {
"tweetnacl": "^1.0.3"
}
},
"randombytes": { "randombytes": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@ -3056,6 +3097,12 @@
"is-number": "^7.0.0" "is-number": "^7.0.0"
} }
}, },
"tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
"dev": true
},
"update-browserslist-db": { "update-browserslist-db": {
"version": "1.0.11", "version": "1.0.11",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz",

View File

@ -21,7 +21,9 @@
}, },
"devDependencies": { "devDependencies": {
"axios": "^1.1.2", "axios": "^1.1.2",
"laravel-echo": "^1.16.1",
"laravel-vite-plugin": "^0.7.2", "laravel-vite-plugin": "^0.7.2",
"pusher-js": "^8.4.0-rc2",
"sass-loader": "^13.3.2", "sass-loader": "^13.3.2",
"vite": "^4.0.0" "vite": "^4.0.0"
} }

View File

@ -1,5 +1,40 @@
@section('meta_title', __('sections.Dashboard')) @section('meta_title', __('sections.Dashboard'))
@section('h1', __('sections.Dashboard')) @section('h1', __('sections.Dashboard'))
<x-private.layout> <x-private.layout>
<div class="card border-0 shadow mb-4">
<div class="card-body">
@can('viewAny', \App\Models\CaptchaToken::class)
<div id="chart-сaptcha-activity"></div>
<div class="mt-4 table-responsive">
<table class="table table-centered table-nowrap mb-0 rounded">
<thead class="thead-light">
<tr>
<th class="border-0">{{ __('validation.attributes.date') }}</th>
<th class="border-0">Capctha</th>
<th class="border-0"></th>
</tr>
</thead>
<tbody id="captcha-log">
@foreach($items as $item)
<tr>
<td>{{ $item->created_at->timezone(\App\Helpers\Helpers::getUserTimeZone())->format("d.m.Y H:i:s") }}</td>
<td>
@if($item->captcha && $item->captcha->captchaToken)
<a href="{{ route('captcha-tokens.edit', ['captcha_token' => $item->captcha->captcha_token_id]) }}">{{ $item->captcha->captchaToken->title }}</a>
@endif
<p><strong>{{ $item->type->getTitle() }}</strong></p>
</td>
<td>
<p><strong>IP:</strong> {{ $item->ip }}</p>
<p><strong>User Agent:</strong> {{ $item->user_agent }}</p>
<p><strong>referer:</strong> {{ $item->referer }}</p>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endcan
</div>
</div>
</x-private.layout> </x-private.layout>

View File

@ -2,4 +2,6 @@ import.meta.glob([
'../images/**', '../images/**',
]); ]);
import './bootstrap'; import './bootstrap';
import './volt.js'; import './echo';
import './volt';
import './dashboard'

View File

@ -0,0 +1,80 @@
"use strict";
import Chartist from "chartist";
import 'chartist-plugin-tooltips';
if(document.querySelector('#chart-сaptcha-activity')) {
let chartCaptchaActivity = null;
axios.get('/dashboard/chart-captcha-activity')
.then(response => {
chartCaptchaActivity = new Chartist.Line('#chart-сaptcha-activity', {
labels: response.data.days,
series: response.data.values,
}, {
low: 0,
showArea: true,
fullWidth: true,
plugins: [
Chartist.plugins.tooltip()
],
axisX: {
// On the x-axis start means top and end means bottom
position: 'end',
showGrid: true,
},
axisY: {
// On the y-axis start means left and end means right
showGrid: true,
showLabel: true,
}
});
let currentDate = response.data.days[response.data.days.length - 1];
window.websocket.add(function (echo) {
echo.private('chart-captcha-activity')
.listen('CreatedCaptchaLog', (e) => {
let options = chartCaptchaActivity.data;
if (currentDate !== e.chartCaptchaActivity.date) {
currentDate = e.chartCaptchaActivity.date;
options['labels'].push(currentDate);
options['series'].forEach(function(series, i) {
series.push(response.data.types[i]);
options['series'].push(series);
});
}
let values = options['series'][e.chartCaptchaActivity['type'] - 1];
options['series'][e.chartCaptchaActivity['type'] - 1][values.length - 1]['value']++;
chartCaptchaActivity.update(options);
let captchaLog = document.querySelector('#captcha-log');
if (captchaLog) {
let tr = document.createElement("tr");
let link = '';
if (e.captchaLog.link !== '' && e.captchaLog.title !== '') {
link = `<a href="${e.captchaLog.link}">${e.captchaLog.title}</a>`;
}
tr.innerHTML = `
<td>${e.captchaLog.created_at}</td>
<td>
${link}
<p><strong>${e.captchaLog.type}</strong></p>
</td>
<td>
<p><strong>IP:</strong> ${e.captchaLog.ip}</p>
<p><strong>User Agent:</strong> ${e.captchaLog.user_agent}</p>
<p><strong>referer:</strong> ${e.captchaLog.referer}</p>
</td>
`;
captchaLog.prepend(tr);
}
});
});
})
.catch(error => {
});
}

View File

@ -0,0 +1,49 @@
"use strict";
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
class websocket {
_echo = null;
pusher = Pusher;
callbacks = []
constructor() {
let _this = this;
axios.get('/websockets/settings')
.then(response => {
_this._echo = new Echo({
broadcaster: 'reverb',
key: response.data.key,
wsHost: response.data.wsHost,
wsPort: response.data.wsPort,
wssPort: response.data.wssPort,
forceTLS: response.data.forceTLS,
enabledTransports: ['ws', 'wss'],
});
_this.startCallbacks();
})
.catch(error => {
});
}
add(callback) {
if (this._echo !== null) {
callback(this._echo)
return ;
}
this.callbacks.push(callback);
}
startCallbacks() {
let _this = this;
this.callbacks.forEach(function(callback) {
callback(_this._echo)
});
}
}
window.websocket = new websocket();

View File

@ -21,8 +21,6 @@ const d = document;
import * as bootstrap from 'bootstrap'; import * as bootstrap from 'bootstrap';
import Swal from 'sweetalert2'; import Swal from 'sweetalert2';
import SmoothScroll from 'smooth-scroll'; import SmoothScroll from 'smooth-scroll';
import Chartist from 'chartist';
import 'chartist-plugin-tooltips';
d.addEventListener("DOMContentLoaded", function(event) { d.addEventListener("DOMContentLoaded", function(event) {
@ -176,102 +174,6 @@ d.addEventListener("DOMContentLoaded", function(event) {
}); });
} }
//Chartist
if(d.querySelector('.ct-chart-sales-value')) {
//Chart 5
new Chartist.Line('.ct-chart-sales-value', {
labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
series: [
[0, 10, 30, 40, 80, 60, 100]
]
}, {
low: 0,
showArea: true,
fullWidth: true,
plugins: [
Chartist.plugins.tooltip()
],
axisX: {
// On the x-axis start means top and end means bottom
position: 'end',
showGrid: true
},
axisY: {
// On the y-axis start means left and end means right
showGrid: false,
showLabel: false,
labelInterpolationFnc: function(value) {
return '$' + (value / 1) + 'k';
}
}
});
}
if(d.querySelector('.ct-chart-ranking')) {
var chart = new Chartist.Bar('.ct-chart-ranking', {
labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
series: [
[1, 5, 2, 5, 4, 3],
[2, 3, 4, 8, 1, 2],
]
}, {
low: 0,
showArea: true,
plugins: [
Chartist.plugins.tooltip()
],
axisX: {
// On the x-axis start means top and end means bottom
position: 'end'
},
axisY: {
// On the y-axis start means left and end means right
showGrid: false,
showLabel: false,
offset: 0
}
});
chart.on('draw', function(data) {
if(data.type === 'line' || data.type === 'area') {
data.element.animate({
d: {
begin: 2000 * data.index,
dur: 2000,
from: data.path.clone().scale(1, 0).translate(0, data.chartRect.height()).stringify(),
to: data.path.clone().stringify(),
easing: Chartist.Svg.Easing.easeOutQuint
}
});
}
});
}
if(d.querySelector('.ct-chart-traffic-share')) {
var data = {
series: [70, 20, 10]
};
var sum = function(a, b) { return a + b };
new Chartist.Pie('.ct-chart-traffic-share', data, {
labelInterpolationFnc: function(value) {
return Math.round(value / data.series.reduce(sum) * 100) + '%';
},
low: 0,
high: 8,
donut: true,
donutWidth: 20,
donutSolid: true,
fullWidth: false,
showLabel: false,
plugins: [
Chartist.plugins.tooltip()
],
});
}
if (d.getElementById('loadOnClick')) { if (d.getElementById('loadOnClick')) {
d.getElementById('loadOnClick').addEventListener('click', function () { d.getElementById('loadOnClick').addEventListener('click', function () {
var button = this; var button = this;

View File

@ -12,3 +12,5 @@
| used to check if an authenticated user can listen to the channel. | used to check if an authenticated user can listen to the channel.
| |
*/ */
Broadcast::channel('chart-captcha-activity', \App\Broadcasting\CreatedCaptchaLog::class);

View File

@ -20,6 +20,7 @@
Route::middleware(['auth', 'verified', 'user.locale'])->group(function () { Route::middleware(['auth', 'verified', 'user.locale'])->group(function () {
Route::post('logout', [\App\Http\Controllers\AuthController::class, 'logout'])->name('logout'); Route::post('logout', [\App\Http\Controllers\AuthController::class, 'logout'])->name('logout');
Route::get('/', [\App\Http\Controllers\Private\DashboardController::class, 'index'])->name('home'); Route::get('/', [\App\Http\Controllers\Private\DashboardController::class, 'index'])->name('home');
Route::get('/dashboard/chart-captcha-activity', [\App\Http\Controllers\Private\DashboardController::class, 'chartCaptchaActivity']);
Route::prefix('profile')->as('profile.') Route::prefix('profile')->as('profile.')
->group(function () { ->group(function () {
Route::get('/', [\App\Http\Controllers\Private\ProfileController::class, 'profile'])->name('edit'); Route::get('/', [\App\Http\Controllers\Private\ProfileController::class, 'profile'])->name('edit');
@ -34,4 +35,6 @@
Route::resource('roles', \App\Http\Controllers\Private\RolesController::class)->only(['index', 'create', 'store', 'edit', 'update', 'destroy'])->where(['role' => '[0-9]+']); Route::resource('roles', \App\Http\Controllers\Private\RolesController::class)->only(['index', 'create', 'store', 'edit', 'update', 'destroy'])->where(['role' => '[0-9]+']);
Route::resource('captcha-tokens', \App\Http\Controllers\Private\CaptchaTokensController::class)->only(['index', 'create', 'store', 'edit', 'update', 'destroy'])->where(['captcha_token' => '[0-9]+']); Route::resource('captcha-tokens', \App\Http\Controllers\Private\CaptchaTokensController::class)->only(['index', 'create', 'store', 'edit', 'update', 'destroy'])->where(['captcha_token' => '[0-9]+']);
Route::get('websockets/settings', [\App\Http\Controllers\Private\WebsocketsController::class, 'settings']);
}); });

View File

@ -73,7 +73,8 @@ STOPSIGNAL SIGTERM
ENTRYPOINT ["/home/unit/docker-entrypoint.sh"] ENTRYPOINT ["/home/unit/docker-entrypoint.sh"]
EXPOSE 9000 EXPOSE 9000
CMD ["unitd", "--no-daemon", "--control", "unix:/var/run/control.unit.sock", "--user", "unit", "--group", "unit"] COPY docker/start_prod.sh /usr/local/bin/start
CMD ["/usr/local/bin/start"]
FROM BUILD AS DEVELOP FROM BUILD AS DEVELOP
@ -91,9 +92,9 @@ RUN chmod 755 /home/unit/docker-entrypoint.sh \
ENTRYPOINT ["/home/unit/docker-entrypoint.sh"] ENTRYPOINT ["/home/unit/docker-entrypoint.sh"]
EXPOSE 9000 EXPOSE 9000
CMD ["unitd", "--no-daemon", "--control", "unix:/var/run/control.unit.sock", "--user", "unit", "--group", "unit"]
COPY docker/start_dev.sh /usr/local/bin/start
CMD ["/usr/local/bin/start"]
FROM BUILD AS ARTISAN FROM BUILD AS ARTISAN
WORKDIR /var/www/html WORKDIR /var/www/html

21
app/docker/start_dev.sh Executable file
View File

@ -0,0 +1,21 @@
#!/usr/bin/env sh
set -e
role=${CONTAINER_ROLE:-app}
if [ "$role" = "app" ]; then
exec unitd --no-daemon --control unix:/var/run/control.unit.sock --user unit --group unit
elif [ "$role" = "queue" ]; then
echo "Running the queue..."
php /var/www/html/artisan queue:work --verbose --sleep=5 --tries=10
elif [ "$role" = "websockets" ]; then
echo "Running the websockets..."
php /var/www/html/artisan reverb:start --port=9000
elif [ "$role" = "scheduler" ]; then
while [ true ]
do
php /var/www/html/artisan schedule:run --verbose --no-interaction &
sleep 60
done
else
echo "Could not match the container role \"$role\""
exit 1
fi

21
app/docker/start_prod.sh Executable file
View File

@ -0,0 +1,21 @@
#!/usr/bin/env sh
set -e
role=${CONTAINER_ROLE:-app}
if [ "$role" = "app" ]; then
exec unitd --no-daemon --control unix:/var/run/control.unit.sock --user unit --group unit
elif [ "$role" = "queue" ]; then
echo "Running the queue..."
php /var/www/html/artisan queue:work --verbose --sleep=5 --tries=10 --max-time=3600
elif [ "$role" = "websockets" ]; then
echo "Running the websockets..."
php /var/www/html/artisan reverb:start --port=9000
elif [ "$role" = "scheduler" ]; then
while [ true ]
do
php /var/www/html/artisan schedule:run --verbose --no-interaction &
sleep 60
done
else
echo "Could not match the container role \"$role\""
exit 1
fi

View File

@ -26,9 +26,52 @@ services:
depends_on: depends_on:
- db - db
- redis - redis
- reverb
env_file: app/application/.env env_file: app/application/.env
queue:
build:
context: app
dockerfile: docker/Dockerfile
target: PRODUCTION
# restart: always
depends_on:
- db
- redis
environment:
CONTAINER_ROLE: queue
volumes:
- ./app/application:/var/www/html
reverb:
build:
context: app
dockerfile: docker/Dockerfile
target: PRODUCTION
# restart: always
depends_on:
- db
- redis
environment:
CONTAINER_ROLE: websockets
ports:
- ${DOCKER_WEBSOCKET_PORT}:9000
volumes:
- ./app/application:/var/www/html
scheduler:
build:
context: app
dockerfile: docker/Dockerfile
target: PRODUCTION
# restart: always
depends_on:
- db
- redis
environment:
CONTAINER_ROLE: scheduler
volumes:
- ./app/application:/var/www/html
swagger: swagger:
image: swaggerapi/swagger-ui image: swaggerapi/swagger-ui
# restart: always
depends_on: depends_on:
- app - app
environment: environment:
@ -36,6 +79,7 @@ services:
BASE_URL: /api-docs BASE_URL: /api-docs
redis: redis:
image: redis:3.0-alpine image: redis:3.0-alpine
# restart: always
db: db:
image: docker.io/mysql:8.0.33 image: docker.io/mysql:8.0.33
command: --default-authentication-plugin=mysql_native_password command: --default-authentication-plugin=mysql_native_password

View File

@ -11,11 +11,41 @@ services:
ports: ports:
- ${DOCKER_NGINX_PORT}:80 - ${DOCKER_NGINX_PORT}:80
app: app:
image: korelf/service-captcha:0.7.1 image: korelf/service-captcha:0.8.0
# restart: always # restart: always
depends_on: depends_on:
- db - db
- redis - redis
- reverb
env_file: app/.env
queue:
image: korelf/service-captcha:0.8.0
# restart: always
depends_on:
- db
- redis
environment:
CONTAINER_ROLE: queue
env_file: app/.env
reverb:
image: korelf/service-captcha:0.8.0
# restart: always
depends_on:
- db
- redis
environment:
CONTAINER_ROLE: websockets
env_file: app/.env
ports:
- ${DOCKER_WEBSOCKET_PORT}:9000
scheduler:
image: korelf/service-captcha:0.8.0
# restart: always
depends_on:
- db
- redis
environment:
CONTAINER_ROLE: scheduler
env_file: app/.env env_file: app/.env
redis: redis:
image: redis:3.0-alpine image: redis:3.0-alpine

View File

@ -10,11 +10,54 @@ services:
- db - db
- redis - redis
- app - app
- reverb
app: app:
build: build:
context: app context: app
dockerfile: docker/Dockerfile dockerfile: docker/Dockerfile
target: DEVELOP target: DEVELOP
depends_on:
- db
- redis
- reverb
volumes:
- ./app/application:/var/www/html
queue:
build:
context: app
dockerfile: docker/Dockerfile
target: DEVELOP
depends_on:
- db
- redis
environment:
CONTAINER_ROLE: queue
volumes:
- ./app/application:/var/www/html
reverb:
build:
context: app
dockerfile: docker/Dockerfile
target: DEVELOP
depends_on:
- db
- redis
environment:
CONTAINER_ROLE: websockets
ports:
- ${DOCKER_WEBSOCKET_PORT}:9000
volumes:
- ./app/application:/var/www/html
scheduler:
build:
context: app
dockerfile: docker/Dockerfile
target: DEVELOP
depends_on:
- db
- redis
environment:
CONTAINER_ROLE: scheduler
volumes: volumes:
- ./app/application:/var/www/html - ./app/application:/var/www/html
redis: redis: