2026.06.8
This commit is contained in:
12
Dockerfile
12
Dockerfile
@@ -117,9 +117,13 @@ RUN apk update && apk add --no-cache \
|
||||
arm64) architecture="arm64" ;; \
|
||||
*) echo "Unsupported architecture"; exit 1 ;; \
|
||||
esac \
|
||||
&& curl -O https://download.microsoft.com/download/0b3d5518-b4a7-4a2b-afc7-7ee9e967f93c/msodbcsql18_18.6.2.1-1_${architecture}.apk \
|
||||
&& ACCEPT_EULA=Y apk add --allow-untrusted msodbcsql18_18.6.2.1-1_${architecture}.apk \
|
||||
&& rm msodbcsql18_18.6.2.1-1_${architecture}.apk \
|
||||
&& echo "Downloading ODBC Driver for architecture: ${architecture}" \
|
||||
&& curl -fkSLo msodbcsql.apk "https://download.microsoft.com/download/0b3d5518-b4a7-4a2b-afc7-7ee9e967f93c/msodbcsql18_18.6.2.1-1_${architecture}.apk" \
|
||||
&& curl -fkSLo msodbcsql.sig "https://download.microsoft.com/download/0b3d5518-b4a7-4a2b-afc7-7ee9e967f93c/msodbcsql18_18.6.2.1-1_${architecture}.sig" \
|
||||
&& curl https://packages.microsoft.com/keys/microsoft.asc | gpg --import - \
|
||||
&& gpg --verify msodbcsql.sig msodbcsql.apk \
|
||||
&& ACCEPT_EULA=Y apk add --allow-untrusted msodbcsql.apk \
|
||||
&& rm msodbcsql.apk msodbcsql.sig \
|
||||
&& pecl install sqlsrv pdo_sqlsrv \
|
||||
&& docker-php-ext-enable sqlsrv pdo_sqlsrv \
|
||||
&& apk del autoconf make g++ \
|
||||
@@ -150,7 +154,7 @@ RUN chown -R application:application /var/www/html && \
|
||||
RUN php artisan storage:link
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost/health || exit 1
|
||||
CMD curl -f http://localhost/up || exit 1
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
|
||||
99
app/Console/Commands/AutofillDutyReports.php
Normal file
99
app/Console/Commands/AutofillDutyReports.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Department;
|
||||
use App\Models\ReportDuty;
|
||||
use App\Models\User;
|
||||
use App\Services\DateRangeService;
|
||||
use App\Services\DutyReportService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\Console\Command\Command as CommandAlias;
|
||||
use Throwable;
|
||||
|
||||
class AutofillDutyReports extends Command
|
||||
{
|
||||
protected $signature = 'duty:autofill
|
||||
{--date= : Дата смены (YYYY-MM-DD). По умолчанию сегодня (смена вчера 09:00 → сегодня 09:00)}
|
||||
{--user= : ID пользователя для аудита. По умолчанию DUTY_AUTOFILL_USER_ID или первый пользователь}
|
||||
{--dry-run : Не писать в БД, только показать, для каких отделений нет отчёта}';
|
||||
|
||||
protected $description = 'Создаёт суточные отчёты для отделений, у которых нет отчёта за текущую смену';
|
||||
|
||||
public function __construct(
|
||||
protected DutyReportService $reportService,
|
||||
protected DateRangeService $dateRangeService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$tz = config('app.timezone', 'Europe/Moscow');
|
||||
$date = Carbon::parse($this->option('date') ?: now($tz)->format('Y-m-d'), $tz);
|
||||
|
||||
// Пользователь для аудита
|
||||
$userId = $this->option('user') ?: env('DUTY_AUTOFILL_USER_ID');
|
||||
$user = $userId ? User::find($userId) : User::orderBy('id')->first();
|
||||
if (! $user) {
|
||||
$this->error('Не найден пользователь для аудита (укажите --user или DUTY_AUTOFILL_USER_ID).');
|
||||
return CommandAlias::FAILURE;
|
||||
}
|
||||
|
||||
// Смена: вчера 09:00 → сегодня 09:00 (для date = сегодня)
|
||||
$dateRange = $this->dateRangeService->createDateRangeForDate($date, $user);
|
||||
$periodStart = $dateRange->startDateRaw;
|
||||
$periodEnd = $dateRange->endDateRaw;
|
||||
|
||||
$departments = Department::orderBy('department_id')->get();
|
||||
|
||||
$this->info("Смена: {$periodStart} → {$periodEnd} ({$tz})");
|
||||
$this->info("Аудит: пользователь #{$user->id}");
|
||||
if ($this->option('dry-run')) {
|
||||
$this->warn('Режим DRY RUN — без записи');
|
||||
}
|
||||
|
||||
$created = 0; $skipped = 0; $errors = 0;
|
||||
|
||||
foreach ($departments as $dept) {
|
||||
$exists = ReportDuty::where('rf_department_id', $dept->department_id)
|
||||
->where('period_start', $periodStart)
|
||||
->where('period_end', $periodEnd)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->line(" нет отчёта: #{$dept->department_id} ".($dept->name_short ?? ''));
|
||||
$created++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$report = $this->reportService->saveReport($dateRange, $user->id, 0, $dept->department_id);
|
||||
$stats = $this->reportService->saveSnapshot($dateRange, $report, $dept->rf_mis_department_id, $user->id);
|
||||
$this->reportService->saveMetrics($stats, $report);
|
||||
$created++;
|
||||
} catch (Throwable $e) {
|
||||
$errors++;
|
||||
Log::error('DutyAutofill', [
|
||||
'department_id' => $dept->department_id,
|
||||
'period_start' => $periodStart,
|
||||
'period_end' => $periodEnd,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$this->error("[#{$dept->department_id}] {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Готово. Создано: {$created} | Уже было: {$skipped} | Ошибок: {$errors}");
|
||||
|
||||
return $errors > 0 ? CommandAlias::FAILURE : CommandAlias::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ namespace App\Http\Controllers\Api\Syncio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\RefreshMaterializedViews;
|
||||
use App\Models\ReplicationLog;
|
||||
use App\Models\ReplicationSchedule;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SyncioWebhookController extends Controller
|
||||
@@ -20,6 +22,24 @@ class SyncioWebhookController extends Controller
|
||||
// $data['tables']['success'] кол-во успешных
|
||||
// $data['errors'] массив ошибок
|
||||
|
||||
$scheduleId = $data['schedule_id'] ?? ($data['schedule']['id'] ?? null);
|
||||
|
||||
// Обрабатываем только расписания, заведённые в таблице (связанные с проектом)
|
||||
if (! ReplicationSchedule::active()->where('schedule_id', $scheduleId)->exists()) {
|
||||
return response()->noContent(); // 204, но игнорируем чужое расписание
|
||||
}
|
||||
|
||||
// Сохраняем информацию о репликации из вебхука
|
||||
ReplicationLog::create([
|
||||
'status' => $data['status'] ?? null,
|
||||
'schedule_id' => $scheduleId,
|
||||
'tables_success' => (int) ($data['tables']['success'] ?? 0),
|
||||
'tables_failed' => (int) ($data['tables']['failed'] ?? 0),
|
||||
'errors' => $data['errors'] ?? null,
|
||||
'payload' => $data,
|
||||
'received_at' => now(),
|
||||
]);
|
||||
|
||||
// При успешной репликации обновляем материализованные представления
|
||||
if (($data['status'] ?? null) === 'success') {
|
||||
RefreshMaterializedViews::dispatch();
|
||||
|
||||
126
app/Http/Controllers/Web/Admin/ReplicationController.php
Normal file
126
app/Http/Controllers/Web/Admin/ReplicationController.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ReplicationLog;
|
||||
use App\Models\ReplicationSchedule;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ReplicationController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
abort_unless(auth()->user()->isAdmin(), 403);
|
||||
|
||||
$logs = ReplicationLog::query()
|
||||
->latest('id')
|
||||
->limit(50)
|
||||
->get()
|
||||
->map(fn (ReplicationLog $l) => [
|
||||
'id' => $l->id,
|
||||
'status' => $l->status,
|
||||
'schedule_id' => $l->schedule_id,
|
||||
'tables_success' => $l->tables_success,
|
||||
'tables_failed' => $l->tables_failed,
|
||||
'errors' => $l->errors,
|
||||
'received_at' => optional($l->received_at)->toIso8601String(),
|
||||
]);
|
||||
|
||||
$schedules = ReplicationSchedule::orderBy('name')->get()->map(fn (ReplicationSchedule $s) => [
|
||||
'id' => $s->id,
|
||||
'schedule_id' => $s->schedule_id,
|
||||
'name' => $s->name,
|
||||
'is_active' => $s->is_active,
|
||||
]);
|
||||
|
||||
return Inertia::render('Admin/Replication/Index', [
|
||||
'logs' => $logs,
|
||||
'schedules' => $schedules,
|
||||
'configured' => (bool) config('services.syncio.base_url'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeSchedule(Request $request)
|
||||
{
|
||||
abort_unless(auth()->user()->isAdmin(), 403);
|
||||
|
||||
$data = $request->validate([
|
||||
'schedule_id' => ['required', 'string', 'max:255', 'unique:replication_schedules,schedule_id'],
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
ReplicationSchedule::create([
|
||||
'schedule_id' => $data['schedule_id'],
|
||||
'name' => $data['name'] ?? null,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Расписание добавлено.');
|
||||
}
|
||||
|
||||
public function updateSchedule(Request $request, ReplicationSchedule $schedule)
|
||||
{
|
||||
abort_unless(auth()->user()->isAdmin(), 403);
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
'is_active' => ['required', 'boolean'],
|
||||
]);
|
||||
|
||||
$schedule->update($data);
|
||||
|
||||
return back()->with('success', 'Расписание обновлено.');
|
||||
}
|
||||
|
||||
public function destroySchedule(ReplicationSchedule $schedule)
|
||||
{
|
||||
abort_unless(auth()->user()->isAdmin(), 403);
|
||||
|
||||
$schedule->delete();
|
||||
|
||||
return back()->with('success', 'Расписание удалено.');
|
||||
}
|
||||
|
||||
public function run(Request $request)
|
||||
{
|
||||
abort_unless(auth()->user()->isAdmin(), 403);
|
||||
|
||||
$data = $request->validate([
|
||||
'schedule_id' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$baseUrl = rtrim((string) config('services.syncio.base_url'), '/');
|
||||
if ($baseUrl === '') {
|
||||
return back()->with('error', 'Не задан SYNCIO_BASE_URL — запуск репликации недоступен.');
|
||||
}
|
||||
|
||||
$scheduleId = $data['schedule_id'];
|
||||
$url = "{$baseUrl}/migrations/schedules/{$scheduleId}/run";
|
||||
|
||||
try {
|
||||
$response = Http::acceptJson()
|
||||
->when(
|
||||
config('services.syncio.token'),
|
||||
fn ($http) => $http->withToken(config('services.syncio.token'))
|
||||
)
|
||||
->timeout(30)
|
||||
->post($url);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::warning('Syncio run failed', ['status' => $response->status(), 'body' => $response->body()]);
|
||||
|
||||
return back()->with('error', "Репликатор вернул ошибку ({$response->status()}).");
|
||||
}
|
||||
|
||||
return back()->with('success', 'Репликация запущена. Результат придёт по вебхуку.');
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Syncio run exception', ['message' => $e->getMessage()]);
|
||||
|
||||
return back()->with('error', 'Не удалось связаться с репликатором: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
24
app/Models/ReplicationLog.php
Normal file
24
app/Models/ReplicationLog.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ReplicationLog extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'status',
|
||||
'schedule_id',
|
||||
'tables_success',
|
||||
'tables_failed',
|
||||
'errors',
|
||||
'payload',
|
||||
'received_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'errors' => 'array',
|
||||
'payload' => 'array',
|
||||
'received_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
23
app/Models/ReplicationSchedule.php
Normal file
23
app/Models/ReplicationSchedule.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ReplicationSchedule extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'schedule_id',
|
||||
'name',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,9 @@ return [
|
||||
|
||||
'syncio' => [
|
||||
'secret' => env('SYNCIO_SECRET'),
|
||||
]
|
||||
'base_url' => env('SYNCIO_BASE_URL'), // напр. https://host/syncio-api
|
||||
'token' => env('SYNCIO_TOKEN'), // Bearer-токен для запуска репликации
|
||||
// Разрешённые расписания и шорткаты хранятся в таблице replication_schedules
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('replication_logs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('status')->nullable(); // success | partial_success | failed
|
||||
$table->string('schedule_id')->nullable(); // какое расписание запускалось (если известно)
|
||||
$table->unsignedInteger('tables_success')->default(0);
|
||||
$table->unsignedInteger('tables_failed')->default(0);
|
||||
$table->json('errors')->nullable(); // массив ошибок из вебхука
|
||||
$table->json('payload')->nullable(); // сырой payload вебхука
|
||||
$table->timestamp('received_at')->nullable(); // когда пришёл вебхук
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('status');
|
||||
$table->index('created_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('replication_logs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('replication_schedules', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('schedule_id')->unique(); // ID расписания в репликаторе (syncio)
|
||||
$table->string('name')->nullable(); // человекочитаемое имя (для кнопки)
|
||||
$table->boolean('is_active')->default(true); // участвует в фильтре вебхука и шорткатах
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
// Расписания, связанные с проектом
|
||||
DB::table('replication_schedules')->insert([
|
||||
[
|
||||
'schedule_id' => '4969c65e-6824-45c8-b221-8cfca5c29m12',
|
||||
'name' => 'Основная (m12)',
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'schedule_id' => '4969c65e-6824-45c8-b221-8cfca5c29m06',
|
||||
'name' => 'Основная (m06)',
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('replication_schedules');
|
||||
}
|
||||
};
|
||||
@@ -44,6 +44,28 @@ services:
|
||||
networks:
|
||||
- aokb-onboard-network
|
||||
|
||||
#Scheduler (одиночный — планировщик Laravel)
|
||||
scheduler:
|
||||
image: registry.brusoff.su/aokb-onboard:2026.06-dev
|
||||
build: .
|
||||
container_name: aokb_onboard_scheduler
|
||||
restart: unless-stopped
|
||||
working_dir: /var/www/html
|
||||
command: php artisan schedule:work
|
||||
environment:
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
- QUEUE_CONNECTION=redis
|
||||
depends_on:
|
||||
- redis
|
||||
- app
|
||||
volumes:
|
||||
- ./.env:/var/www/html/.env
|
||||
- ./docker/php.ini:/usr/local/etc/php/conf.d/app.ini
|
||||
- ./storage/logs:/var/www/storage/logs
|
||||
networks:
|
||||
- aokb-onboard-network
|
||||
|
||||
redis:
|
||||
image: redis:8-alpine
|
||||
container_name: aokb_onboard_redis
|
||||
|
||||
22
package-lock.json
generated
22
package-lock.json
generated
@@ -1082,7 +1082,8 @@
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@tailwindcss/language-server": {
|
||||
"version": "0.14.29",
|
||||
@@ -1897,8 +1898,7 @@
|
||||
"version": "5.13.0",
|
||||
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.13.0.tgz",
|
||||
"integrity": "sha512-PJuXT6zdiCbv0IkX5cqkKFVIIh+9v3kqP9zsOHEGpIWi7DfTgzvfOKc8icw6G3/ulR3V1alDDUtOVH0zWCWGEQ==",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"peer": true
|
||||
"license": "SEE LICENSE IN LICENSE"
|
||||
},
|
||||
"node_modules/async-validator": {
|
||||
"version": "4.2.5",
|
||||
@@ -2129,7 +2129,6 @@
|
||||
"integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emotion/hash": "~0.8.0",
|
||||
"csstype": "~3.0.5"
|
||||
@@ -2205,7 +2204,6 @@
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@@ -2259,7 +2257,6 @@
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
@@ -2280,6 +2277,7 @@
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
@@ -2354,6 +2352,7 @@
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.5.tgz",
|
||||
"integrity": "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
@@ -2367,6 +2366,7 @@
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
@@ -3143,7 +3143,8 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/muggle-string": {
|
||||
"version": "0.4.1",
|
||||
@@ -3261,7 +3262,6 @@
|
||||
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
||||
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^7.7.7"
|
||||
},
|
||||
@@ -3347,7 +3347,6 @@
|
||||
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.5.0.tgz",
|
||||
"integrity": "sha512-V7uzGi9bqOOOyM/6IkJdpFyjGZj7llz1v0oWnYkZKcYLvbz6VcHVLmzKqkvegjuMumpfIEKGLmWHwFb39XFCpw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tweetnacl": "^1.0.3"
|
||||
}
|
||||
@@ -3576,6 +3575,7 @@
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1"
|
||||
@@ -3788,7 +3788,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -4146,7 +4145,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
|
||||
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.26",
|
||||
"@vue/compiler-sfc": "3.5.26",
|
||||
@@ -4259,6 +4257,7 @@
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
@@ -4279,6 +4278,7 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import AppLayout from "../../Layouts/AppLayout.vue"
|
||||
import { useAuthStore } from "../../Stores/auth.js"
|
||||
import { NEl, NFlex, NText, NTag, NAvatar } from 'naive-ui'
|
||||
import ActionTile from "../../Components/ActionTile.vue"
|
||||
import { TbUsers, TbChartBar, TbLayoutDashboard } from "vue-icons-plus/tb"
|
||||
import { TbUsers, TbChartBar, TbLayoutDashboard, TbRefresh } from "vue-icons-plus/tb"
|
||||
import { Link } from "@inertiajs/vue3"
|
||||
import { computed } from "vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
@@ -23,7 +23,7 @@ const dividerColor = computed(() => themeVars.value.dividerColor)
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="flex flex-col justify-center items-center" style="min-height: calc(100vh - 48px);">
|
||||
<NFlex vertical :size="10" class="max-w-xl w-full" style="padding: 0 16px 24px;">
|
||||
<NFlex vertical :size="10" class="max-w-2xl w-full" style="padding: 0 16px 24px;">
|
||||
|
||||
<!-- Шапка -->
|
||||
<NEl class="panel-card rounded-2xl" style="padding: 18px 22px;">
|
||||
@@ -43,7 +43,7 @@ const dividerColor = computed(() => themeVars.value.dividerColor)
|
||||
<NAvatar
|
||||
round :size="40"
|
||||
style="
|
||||
background: color-mix(in srgb, var(--primary-color) 22%, transparent);
|
||||
background: transparent;
|
||||
color: var(--primary-color);
|
||||
font-size: 15px; font-weight: 700;
|
||||
"
|
||||
@@ -78,6 +78,13 @@ const dividerColor = computed(() => themeVars.value.dividerColor)
|
||||
:tag="Link"
|
||||
href="/admin/metrics"
|
||||
/>
|
||||
<ActionTile
|
||||
:icon="TbRefresh"
|
||||
title="Репликация"
|
||||
description="Запуск синхронизации и история вебхуков"
|
||||
:tag="Link"
|
||||
href="/admin/replication"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Назад -->
|
||||
|
||||
258
resources/js/Pages/Admin/Replication/Index.vue
Normal file
258
resources/js/Pages/Admin/Replication/Index.vue
Normal file
@@ -0,0 +1,258 @@
|
||||
<script setup>
|
||||
import {
|
||||
NFlex, NButton, NDataTable, NTag, NText, NEl, NIcon, NInput, NTooltip, NScrollbar, NGrid, NGi,
|
||||
NSwitch, NPopconfirm,
|
||||
} from 'naive-ui'
|
||||
import {
|
||||
TbRefresh, TbPlayerPlay, TbLayoutDashboard, TbAlertTriangle, TbBolt, TbTrash, TbPlus,
|
||||
} from 'vue-icons-plus/tb'
|
||||
import AppLayout from '../../../Layouts/AppLayout.vue'
|
||||
import AppContainer from '../../../Components/AppContainer.vue'
|
||||
import SectionCard from '../../../Components/SectionCard.vue'
|
||||
import PageBanner from '../../../Components/PageBanner.vue'
|
||||
import { Link, router, usePage } from '@inertiajs/vue3'
|
||||
import { ref, computed, watch, h } from 'vue'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
const props = defineProps({
|
||||
logs: { type: Array, default: () => [] },
|
||||
schedules: { type: Array, default: () => [] },
|
||||
configured: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const page = usePage()
|
||||
const running = ref(null)
|
||||
const customId = ref('')
|
||||
|
||||
watch(() => page.props.flash, (f) => {
|
||||
if (f?.success) window.$message?.success(f.success)
|
||||
if (f?.error) window.$message?.error(f.error)
|
||||
}, { deep: true })
|
||||
|
||||
function run(scheduleId) {
|
||||
const id = String(scheduleId ?? '').trim()
|
||||
if (!id) return
|
||||
running.value = id
|
||||
router.post('/admin/replication/run', { schedule_id: id }, {
|
||||
preserveScroll: true,
|
||||
onFinish: () => { running.value = null },
|
||||
})
|
||||
}
|
||||
|
||||
// --- Управление расписаниями (таблица replication_schedules) ---
|
||||
const newId = ref('')
|
||||
const newName = ref('')
|
||||
|
||||
function addSchedule() {
|
||||
const id = newId.value.trim()
|
||||
if (!id) return
|
||||
router.post('/admin/replication/schedules', { schedule_id: id, name: newName.value.trim() || null }, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => { newId.value = ''; newName.value = '' },
|
||||
})
|
||||
}
|
||||
|
||||
function toggleSchedule(s, value) {
|
||||
router.put(`/admin/replication/schedules/${s.id}`, { name: s.name, is_active: value }, {
|
||||
preserveScroll: true,
|
||||
})
|
||||
}
|
||||
|
||||
function removeSchedule(s) {
|
||||
router.delete(`/admin/replication/schedules/${s.id}`, { preserveScroll: true })
|
||||
}
|
||||
|
||||
const statusType = (s) => ({ success: 'success', partial_success: 'warning', failed: 'error' }[s] ?? 'default')
|
||||
const statusLabel = (s) => ({ success: 'Успешно', partial_success: 'Частично', failed: 'Ошибка' }[s] ?? (s ?? '—'))
|
||||
const fmt = (iso) => iso ? format(new Date(iso), 'dd.MM.yyyy HH:mm:ss') : '—'
|
||||
|
||||
const hasSchedules = computed(() => props.schedules.length > 0)
|
||||
const lastLog = computed(() => props.logs[0] ?? null)
|
||||
|
||||
const columns = computed(() => [
|
||||
{
|
||||
key: 'received_at',
|
||||
title: 'Время',
|
||||
width: 170,
|
||||
render: (row) => h(NText, { style: 'font-size: 13px;' }, () => fmt(row.received_at)),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Статус',
|
||||
width: 120,
|
||||
render: (row) => h(NTag, { type: statusType(row.status), size: 'small', round: true, bordered: false }, () => statusLabel(row.status)),
|
||||
},
|
||||
{
|
||||
key: 'schedule_id',
|
||||
title: 'Расписание',
|
||||
render: (row) => h(NText, { style: 'font-size: 13px; font-family: monospace;' }, () => row.schedule_id ?? '—'),
|
||||
},
|
||||
{
|
||||
key: 'tables_success',
|
||||
title: 'Таблиц ОК',
|
||||
width: 110,
|
||||
align: 'center',
|
||||
render: (row) => h(NTag, { size: 'small', round: true, bordered: false, type: 'success' }, () => `${row.tables_success}`),
|
||||
},
|
||||
{
|
||||
key: 'tables_failed',
|
||||
title: 'Ошибок',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (row) => h(NTag, {
|
||||
size: 'small', round: true, bordered: false,
|
||||
type: row.tables_failed > 0 ? 'error' : 'default',
|
||||
}, () => `${row.tables_failed}`),
|
||||
},
|
||||
{
|
||||
key: 'errors',
|
||||
title: 'Детали ошибок',
|
||||
width: 150,
|
||||
render: (row) => {
|
||||
if (!row.errors || !row.errors.length) return h(NText, { depth: 3 }, () => '—')
|
||||
return h(NTooltip, { style: { maxWidth: '440px' } }, {
|
||||
trigger: () => h(NTag, { type: 'error', size: 'small', round: true, bordered: false }, () => `${row.errors.length} ошиб.`),
|
||||
default: () => h(NScrollbar, { style: 'max-height: 240px;' },
|
||||
() => row.errors.map((e, i) => h('div', { key: i, style: 'font-size: 12px; padding: 2px 0;' },
|
||||
typeof e === 'string' ? e : JSON.stringify(e)))),
|
||||
})
|
||||
},
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<AppContainer>
|
||||
|
||||
<PageBanner
|
||||
title="Репликация"
|
||||
:icon="TbRefresh"
|
||||
:breadcrumbs="[{ label: 'Администратор', href: '/admin', icon: TbLayoutDashboard, tag: Link }]"
|
||||
>
|
||||
<template #meta>
|
||||
<NFlex align="center" :size="8">
|
||||
<NText depth="3" style="font-size: 13px;">{{ logs.length }} записей</NText>
|
||||
<template v-if="lastLog">
|
||||
<NEl style="width: 3px; height: 3px; border-radius: 50%; background: currentColor; opacity: .3;" />
|
||||
<NText depth="3" style="font-size: 13px;">последняя:</NText>
|
||||
<NTag :type="statusType(lastLog.status)" size="small" round :bordered="false">
|
||||
{{ statusLabel(lastLog.status) }}
|
||||
</NTag>
|
||||
</template>
|
||||
</NFlex>
|
||||
</template>
|
||||
</PageBanner>
|
||||
|
||||
<!-- Запуск: 2 колонки — ручной запуск и шорткаты -->
|
||||
<NGrid cols="2" :x-gap="16">
|
||||
<NGi>
|
||||
<SectionCard :icon="TbPlayerPlay" title="Запуск репликации">
|
||||
<div v-if="!configured" class="hint-error">
|
||||
<NIcon :component="TbAlertTriangle" /> Не задан <code>SYNCIO_BASE_URL</code> — запуск недоступен. Добавьте переменные окружения репликатора.
|
||||
</div>
|
||||
<NFlex v-else align="center" :size="8" :wrap="false">
|
||||
<NInput v-model:value="customId" placeholder="schedule_id" clearable />
|
||||
<NButton
|
||||
type="primary"
|
||||
:loading="running !== null && running === customId.trim() && customId.trim() !== ''"
|
||||
:disabled="running !== null || !customId.trim()"
|
||||
@click="run(customId)"
|
||||
>
|
||||
Запустить
|
||||
</NButton>
|
||||
</NFlex>
|
||||
</SectionCard>
|
||||
</NGi>
|
||||
|
||||
<NGi>
|
||||
<SectionCard :icon="TbBolt" title="Шорткаты (расписания проекта)">
|
||||
<NFlex vertical :size="8">
|
||||
<NFlex
|
||||
v-for="s in schedules" :key="s.id"
|
||||
align="center" :size="8" :wrap="false"
|
||||
>
|
||||
<NButton
|
||||
type="primary" secondary
|
||||
:loading="running === s.schedule_id"
|
||||
:disabled="!configured || running !== null || !s.is_active"
|
||||
style="flex: 1; justify-content: flex-start;"
|
||||
@click="run(s.schedule_id)"
|
||||
>
|
||||
<template #icon><NIcon><TbPlayerPlay /></NIcon></template>
|
||||
{{ s.name || s.schedule_id }}
|
||||
</NButton>
|
||||
<NTooltip>
|
||||
<template #trigger>
|
||||
<NSwitch :value="s.is_active" size="small" @update:value="v => toggleSchedule(s, v)" />
|
||||
</template>
|
||||
Учитывать вебхуки этого расписания
|
||||
</NTooltip>
|
||||
<NPopconfirm @positive-click="removeSchedule(s)">
|
||||
<template #trigger>
|
||||
<NButton text type="error" title="Удалить">
|
||||
<template #icon><NIcon><TbTrash /></NIcon></template>
|
||||
</NButton>
|
||||
</template>
|
||||
Удалить расписание «{{ s.name || s.schedule_id }}»?
|
||||
</NPopconfirm>
|
||||
</NFlex>
|
||||
|
||||
<NText v-if="!hasSchedules" depth="3" style="font-size: 12px;">
|
||||
Расписаний нет. Добавьте ID расписания из репликатора ниже.
|
||||
</NText>
|
||||
|
||||
<!-- Добавление -->
|
||||
<NFlex align="center" :size="8" :wrap="false" style="margin-top: 4px;">
|
||||
<NInput v-model:value="newId" placeholder="schedule_id" size="small" />
|
||||
<NInput v-model:value="newName" placeholder="Имя" size="small" style="max-width: 150px;" />
|
||||
<NButton size="small" :disabled="!newId.trim()" @click="addSchedule">
|
||||
<template #icon><NIcon><TbPlus /></NIcon></template>
|
||||
</NButton>
|
||||
</NFlex>
|
||||
</NFlex>
|
||||
</SectionCard>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
|
||||
<!-- История -->
|
||||
<SectionCard :icon="TbRefresh" title="История репликаций" no-padding style="margin-top: 12px;">
|
||||
<template #header-extra>
|
||||
<NButton text size="small" @click="router.reload({ only: ['logs'] })">
|
||||
<template #icon><NIcon><TbRefresh /></NIcon></template>
|
||||
Обновить
|
||||
</NButton>
|
||||
</template>
|
||||
<NDataTable
|
||||
:columns="columns"
|
||||
:data="logs"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
:max-height="520"
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
</AppContainer>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hint-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--error-color);
|
||||
font-size: 13px;
|
||||
}
|
||||
.hint-error code {
|
||||
background: color-mix(in srgb, var(--error-color) 12%, transparent);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:deep(.n-data-table-th) { background: transparent !important; }
|
||||
:deep(.n-data-table) { background: transparent; }
|
||||
:deep(.n-data-table-wrapper) { border-radius: 0; }
|
||||
:deep(.n-data-table-th .n-data-table-th__title) { font-size: 12px; }
|
||||
:deep(.n-data-table-td) { font-size: 13px; }
|
||||
</style>
|
||||
@@ -2,7 +2,14 @@
|
||||
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
|
||||
// Каждый день в 08:59 — досоздаём отчёты отделениям, у которых нет отчёта за текущую смену
|
||||
Schedule::command('duty:autofill')
|
||||
->dailyAt('08:59')
|
||||
->timezone(config('app.timezone', 'Europe/Moscow'))
|
||||
->withoutOverlapping();
|
||||
|
||||
@@ -52,6 +52,14 @@ Route::prefix('admin')->middleware(['auth'])->group(function () {
|
||||
Route::get('/items/{item}', [\App\Http\Controllers\Web\Admin\MetrikaController::class, 'showItem']);
|
||||
Route::put('/items/{item}', [\App\Http\Controllers\Web\Admin\MetrikaController::class, 'updateItem']);
|
||||
});
|
||||
|
||||
Route::prefix('replication')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\Web\Admin\ReplicationController::class, 'index']);
|
||||
Route::post('/run', [\App\Http\Controllers\Web\Admin\ReplicationController::class, 'run']);
|
||||
Route::post('/schedules', [\App\Http\Controllers\Web\Admin\ReplicationController::class, 'storeSchedule']);
|
||||
Route::put('/schedules/{schedule}', [\App\Http\Controllers\Web\Admin\ReplicationController::class, 'updateSchedule']);
|
||||
Route::delete('/schedules/{schedule}', [\App\Http\Controllers\Web\Admin\ReplicationController::class, 'destroySchedule']);
|
||||
});
|
||||
});
|
||||
|
||||
Route::prefix('statistic')->middleware(['auth'])->group(function () {
|
||||
|
||||
Reference in New Issue
Block a user