Compare commits

..
Author SHA1 Message Date
cacto 17af76c159 Вынос health-check-service в отдельный сервис
Тикер офлайн-детекции (internal/healthcheck) переехал из
device-control-service в новый самостоятельный Go-сервис — как и
планировалось с самого начала (см. изначальный README-заглушку).
Детекция online остаётся в device-control-service: она происходит
как побочный эффект уже имеющейся там MQTT-подписки на
devices/+/telemetry и devices/+/ack, заводить для этого отдельный
сервис с дублирующей MQTT-подпиской избыточно.

health-check-service — свой Go-модуль (без зависимости от proto/,
сборка из собственного контекста), читает только узкое
read+offline-подмножество Device Shadow keyspace в Redis
(devices:known, status, last_seen) — полный Shadow API с записью
desired/reported state остаётся только в device-control-service.

Метрика device_control_health_transitions_total переименована в
device_health_transitions_total и теперь публикуется с этим именем
из ДВУХ сервисов (device-control-service — direction=online,
health-check-service — direction=offline): Prometheus агрегирует
одноимённые метрики с разных таргетов прозрачно, поэтому Grafana-
дашборд адаптирован простой сменой имени метрики в запросе, без
переделки панели.

Проверено вживую через docker compose: реальный online→offline
переход (публикация тестовой телеметрии + ожидание таймаута)
корректно долетает до Redis и RabbitMQ (device.status_changed),
Prometheus видит новый scrape-таргет как up.
2026-08-13 11:24:25 +05:00
cacto be1f238f89 Перевод защищённой части приложения на Vue.js + Inertia
Дашборд, зоны, устройства, правила автоматизации и профиль теперь
Vue 3 + Inertia вместо Blade+Alpine — гостевые страницы Breeze
(логин/регистрация) сознательно оставлены на Blade, чтобы не трогать
уже протестированную аутентификацию. Контроллеры возвращают
Inertia::render() вместо view(), переиспользуют существующие
API Resources и FormRequest.

Ключевой нюанс: Resource/ResourceCollection, переданные напрямую в
проп Inertia, заворачиваются в ключ "data" (Inertia вызывает
toResponse() у Responsable-объектов) — везде используется ->resolve()
и явное резолвление вложенных ресурсов (zone/device_type внутри
Device и т.п.), иначе вложенные поля тоже задваивались бы обёрткой.

Alpine.js и все Blade-вьюхи защищённой части удалены как мёртвый код.
Тесты (ZoneControllerTest/DeviceControllerTest) переведены на
assertInertia(). UserFactory теперь явно задаёт role=owner — без
этого Eloquent не подтягивает DB-default обратно в модель после
create(), что уронило бы общий auth.user проп на любой странице.
2026-08-12 10:52:04 +05:00
91 changed files with 2644 additions and 1290 deletions
+5 -2
View File
@@ -34,8 +34,11 @@ MQTT_PORT=1883
DEVICE_CONTROL_GRPC_PORT=50051
DEVICE_CONTROL_HTTP_PORT=8090
DEVICE_CONTROL_MQTT_CLIENT_ID=device-control-service
DEVICE_CONTROL_HEALTHCHECK_TIMEOUT=60s
DEVICE_CONTROL_HEALTHCHECK_INTERVAL=15s
# --- health-check-service ---
HEALTH_CHECK_TIMEOUT=60s
HEALTH_CHECK_INTERVAL=15s
HEALTH_CHECK_METRICS_PORT=9103
# --- ingest-service ---
INGEST_MQTT_CLIENT_ID=ingest-service
+28 -4
View File
@@ -140,8 +140,6 @@ services:
RABBITMQ_PORT: 5672
RABBITMQ_USER: ${RABBITMQ_USER}
RABBITMQ_PASSWORD: ${RABBITMQ_PASSWORD}
DEVICE_CONTROL_HEALTHCHECK_TIMEOUT: ${DEVICE_CONTROL_HEALTHCHECK_TIMEOUT}
DEVICE_CONTROL_HEALTHCHECK_INTERVAL: ${DEVICE_CONTROL_HEALTHCHECK_INTERVAL}
depends_on:
mosquitto:
condition: service_started
@@ -153,6 +151,33 @@ services:
- home-automation
restart: unless-stopped
health-check-service:
build: ./services/health-check-service
ports:
- "${HEALTH_CHECK_METRICS_PORT}:9103"
environment:
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: ${REDIS_PASSWORD}
REDIS_DB: ${REDIS_DB}
RABBITMQ_HOST: rabbitmq
RABBITMQ_PORT: 5672
RABBITMQ_USER: ${RABBITMQ_USER}
RABBITMQ_PASSWORD: ${RABBITMQ_PASSWORD}
HEALTH_CHECK_TIMEOUT: ${HEALTH_CHECK_TIMEOUT}
HEALTH_CHECK_INTERVAL: ${HEALTH_CHECK_INTERVAL}
HEALTH_CHECK_METRICS_PORT: 9103
depends_on:
redis:
condition: service_started
rabbitmq:
condition: service_healthy
device-control-service:
condition: service_started
networks:
- home-automation
restart: unless-stopped
rule-engine-service:
build:
context: .
@@ -258,5 +283,4 @@ services:
- home-automation
restart: unless-stopped
# Remaining app services (health-check-service, esp32-emulator) are added
# here as they get implemented — see services/*/README.md for the plan.
# esp32-emulator is added here once implemented — see its README for the plan.
@@ -2,12 +2,18 @@
namespace App\Http\Controllers;
use App\Enums\ConditionOperator;
use App\Enums\DeviceCategory;
use App\Http\Requests\AutomationRuleRequest;
use App\Http\Resources\AutomationRuleResource;
use App\Http\Resources\DeviceResource;
use App\Http\Resources\ZoneResource;
use App\Models\AutomationRule;
use App\Models\Device;
use App\Models\DeviceType;
use App\Models\Zone;
use Illuminate\Support\Collection;
use Inertia\Inertia;
class AutomationRuleController extends Controller
{
@@ -19,14 +25,16 @@ class AutomationRuleController extends Controller
->orderByDesc('id')
->get();
return view('automation-rules.index', ['rules' => $rules]);
return Inertia::render('AutomationRules/Index', [
'rules' => AutomationRuleResource::collection($rules)->resolve(),
]);
}
public function create()
{
$this->authorize('create', AutomationRule::class);
return view('automation-rules.create', $this->formOptions());
return Inertia::render('AutomationRules/Create', $this->formOptions());
}
public function store(AutomationRuleRequest $request)
@@ -42,8 +50,19 @@ class AutomationRuleController extends Controller
{
$this->authorize('update', $automation_rule);
return view('automation-rules.edit', [
'rule' => $automation_rule,
return Inertia::render('AutomationRules/Edit', [
'rule' => [
'id' => $automation_rule->id,
'zone_id' => $automation_rule->zone_id,
'condition_source_device_id' => $automation_rule->condition_source_device_id,
'condition_sensor_type' => $automation_rule->condition_sensor_type,
'condition_operator' => $automation_rule->condition_operator->value,
'condition_value' => $automation_rule->condition_value,
'target_device_id' => $automation_rule->target_device_id,
'action_type' => $automation_rule->action_type,
'level' => $automation_rule->action_params['level'] ?? '',
'is_active' => $automation_rule->is_active,
],
...$this->formOptions(),
]);
}
@@ -91,10 +110,11 @@ class AutomationRuleController extends Controller
$devices = Device::with(['zone', 'deviceType'])->orderBy('name')->get();
return [
'zones' => Zone::orderBy('name')->get(),
'devices' => $devices,
'zones' => ZoneResource::collection(Zone::orderBy('name')->get())->resolve(),
'devices' => DeviceResource::collection($devices)->resolve(),
'actionTypeOptions' => $this->capabilityOptions(DeviceCategory::Actuator),
'sensorTypeOptions' => $this->capabilityOptions(DeviceCategory::Sensor),
'conditionOperatorOptions' => array_map(fn ($case) => $case->value, ConditionOperator::cases()),
];
}
@@ -102,7 +122,7 @@ class AutomationRuleController extends Controller
* Distinct capability values across device_types of the given category
* used as friendly select/datalist suggestions, not a hardcoded list.
*
* @return \Illuminate\Support\Collection<int, string>
* @return Collection<int, string>
*/
private function capabilityOptions(DeviceCategory $category)
{
@@ -3,6 +3,9 @@
namespace App\Http\Controllers;
use App\Http\Requests\DeviceRequest;
use App\Http\Resources\DeviceResource;
use App\Http\Resources\DeviceTypeResource;
use App\Http\Resources\ZoneResource;
use App\Models\Device;
use App\Models\DeviceType;
use App\Models\Zone;
@@ -10,6 +13,7 @@ use App\Services\ClickHouseClient;
use App\Services\DeviceControlClient;
use App\Services\DeviceShadow;
use Illuminate\Http\Request;
use Inertia\Inertia;
class DeviceController extends Controller
{
@@ -19,8 +23,11 @@ class DeviceController extends Controller
$devices = Device::with(['zone', 'deviceType'])->orderBy('name')->get();
$statuses = $shadow->statuses($devices->pluck('external_id')->all());
$devices->each(fn (Device $d) => $d->live_status = $statuses[$d->external_id] ?? 'unknown');
return view('devices.index', compact('devices', 'statuses'));
return Inertia::render('Devices/Index', [
'devices' => DeviceResource::collection($devices)->resolve(),
]);
}
public function show(Device $device, DeviceShadow $shadow, ClickHouseClient $clickHouse)
@@ -28,6 +35,8 @@ class DeviceController extends Controller
$this->authorize('view', $device);
$device->load(['zone', 'deviceType']);
$snapshot = $shadow->snapshot($device->external_id);
$device->live_status = $snapshot['status'];
$telemetry = [];
if ($device->deviceType->category->value === 'sensor') {
@@ -39,9 +48,13 @@ class DeviceController extends Controller
);
}
return view('devices.show', [
'device' => $device,
'shadow' => $shadow->snapshot($device->external_id),
return Inertia::render('Devices/Show', [
'device' => (new DeviceResource($device))->resolve(),
'shadow' => [
'last_seen' => $snapshot['last_seen'],
'desired_state' => $snapshot['desired_state'],
'reported_state' => $snapshot['reported_state'],
],
'telemetry' => $telemetry,
]);
}
@@ -50,7 +63,7 @@ class DeviceController extends Controller
{
$this->authorize('create', Device::class);
return view('devices.create', $this->formOptions());
return Inertia::render('Devices/Create', $this->formOptions());
}
public function store(DeviceRequest $request)
@@ -66,7 +79,10 @@ class DeviceController extends Controller
{
$this->authorize('update', $device);
return view('devices.edit', ['device' => $device, ...$this->formOptions()]);
return Inertia::render('Devices/Edit', [
'device' => $device->only(['id', 'name', 'zone_id', 'device_type_id', 'external_id', 'protocol']),
...$this->formOptions(),
]);
}
public function update(DeviceRequest $request, Device $device)
@@ -137,8 +153,8 @@ class DeviceController extends Controller
private function formOptions(): array
{
return [
'zones' => Zone::orderBy('name')->get(),
'deviceTypes' => DeviceType::orderBy('code')->get(),
'zones' => ZoneResource::collection(Zone::orderBy('name')->get())->resolve(),
'deviceTypes' => DeviceTypeResource::collection(DeviceType::orderBy('code')->get())->resolve(),
];
}
}
@@ -7,17 +7,18 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Illuminate\View\View;
use Inertia\Inertia;
use Inertia\Response;
class ProfileController extends Controller
{
/**
* Display the user's profile form.
*/
public function edit(Request $request): View
public function edit(Request $request): Response
{
return view('profile.edit', [
'user' => $request->user(),
return Inertia::render('Profile/Edit', [
'user' => $request->user()->only(['id', 'name', 'email']),
]);
}
@@ -3,7 +3,9 @@
namespace App\Http\Controllers;
use App\Http\Requests\ZoneRequest;
use App\Http\Resources\ZoneResource;
use App\Models\Zone;
use Inertia\Inertia;
class ZoneController extends Controller
{
@@ -13,14 +15,16 @@ class ZoneController extends Controller
$zones = Zone::withCount('devices')->orderBy('name')->get();
return view('zones.index', compact('zones'));
return Inertia::render('Zones/Index', [
'zones' => ZoneResource::collection($zones)->resolve(),
]);
}
public function create()
{
$this->authorize('create', Zone::class);
return view('zones.create');
return Inertia::render('Zones/Create');
}
public function store(ZoneRequest $request)
@@ -34,7 +38,9 @@ class ZoneController extends Controller
{
$this->authorize('update', $zone);
return view('zones.edit', compact('zone'));
return Inertia::render('Zones/Edit', [
'zone' => (new ZoneResource($zone))->resolve(),
]);
}
public function update(ZoneRequest $request, Zone $zone)
@@ -0,0 +1,36 @@
<?php
namespace App\Http\Middleware;
use App\Http\Resources\UserResource;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
protected $rootView = 'app';
public function version(Request $request): ?string
{
return parent::version($request);
}
/** @return array<string, mixed> */
public function share(Request $request): array
{
return [
...parent::share($request),
'auth' => [
// ->resolve() instead of passing the Resource directly: Inertia
// calls ->toResponse() on Responsable props (Resources are
// Responsable), which triggers Laravel's `data`-key wrapping —
// resolve() returns the plain array Inertia pages actually want.
'user' => $request->user() ? (new UserResource($request->user()))->resolve() : null,
],
'flash' => [
'status' => fn () => $request->session()->get('status'),
'error' => fn () => $request->session()->get('error'),
],
];
}
}
@@ -14,9 +14,11 @@ class AutomationRuleResource extends JsonResource
{
return [
'id' => $this->id,
'zone' => new ZoneResource($this->whenLoaded('zone')),
'target_device' => new DeviceResource($this->whenLoaded('targetDevice')),
'condition_source_device' => new DeviceResource($this->whenLoaded('conditionSourceDevice')),
// See DeviceResource for why nested resources are ->resolve()'d
// rather than returned as bare Resource instances.
'zone' => $this->whenLoaded('zone', fn ($zone) => (new ZoneResource($zone))->resolve()),
'target_device' => $this->whenLoaded('targetDevice', fn ($device) => (new DeviceResource($device))->resolve()),
'condition_source_device' => $this->whenLoaded('conditionSourceDevice', fn ($device) => (new DeviceResource($device))->resolve()),
'condition_sensor_type' => $this->condition_sensor_type,
'condition_operator' => $this->condition_operator->value,
'condition_value' => $this->condition_value,
@@ -25,8 +25,13 @@ class DeviceResource extends JsonResource
'external_id' => $this->external_id,
'protocol' => $this->protocol,
'status' => $this->live_status ?? $this->status,
'zone' => new ZoneResource($this->whenLoaded('zone')),
'device_type' => new DeviceTypeResource($this->whenLoaded('deviceType')),
// Nested resources are resolved (not returned as bare Resource
// instances) — a Resource embedded as a value gets wrapped in its
// own `data` key when the parent response goes through Laravel's
// Responsable pipeline (Inertia props, ->response() in the API),
// which would double up with the outer collection's own wrapping.
'zone' => $this->whenLoaded('zone', fn ($zone) => (new ZoneResource($zone))->resolve()),
'device_type' => $this->whenLoaded('deviceType', fn ($type) => (new DeviceTypeResource($type))->resolve()),
'created_at' => $this->created_at,
];
}
@@ -2,6 +2,7 @@
namespace App\Services;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@@ -43,7 +44,7 @@ class DeviceControlClient
{
try {
$response = Http::timeout(5)->post("{$this->baseUrl}{$path}", $body);
} catch (\Illuminate\Http\Client\ConnectionException $e) {
} catch (ConnectionException $e) {
Log::error('device-control-service unreachable', ['path' => $path, 'error' => $e->getMessage()]);
return ['success' => false, 'error' => 'device-control-service недоступен'];
@@ -1,17 +0,0 @@
<?php
namespace App\View\Components;
use Illuminate\View\Component;
use Illuminate\View\View;
class AppLayout extends Component
{
/**
* Get the view / contents that represents the component.
*/
public function render(): View
{
return view('layouts.app');
}
}
+4 -1
View File
@@ -1,5 +1,6 @@
<?php
use App\Http\Middleware\HandleInertiaRequests;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
@@ -13,7 +14,9 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
$middleware->web(append: [
HandleInertiaRequests::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
+1
View File
@@ -7,6 +7,7 @@
"license": "MIT",
"require": {
"php": "^8.3",
"inertiajs/inertia-laravel": "^3.3",
"laravel/framework": "^13.8",
"laravel/sanctum": "^4.0",
"laravel/tinker": "^3.0",
+73 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "483f1bf17688fcc178b8c1849ff31ddc",
"content-hash": "3c4f6c9d2bddd0693e70d6396ea65c43",
"packages": [
{
"name": "brick/math",
@@ -1057,6 +1057,78 @@
],
"time": "2026-07-17T13:53:03+00:00"
},
{
"name": "inertiajs/inertia-laravel",
"version": "v3.3.1",
"source": {
"type": "git",
"url": "https://github.com/inertiajs/inertia-laravel.git",
"reference": "7bfd75e352938b703180574b943d81963555a0aa"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/7bfd75e352938b703180574b943d81963555a0aa",
"reference": "7bfd75e352938b703180574b943d81963555a0aa",
"shasum": ""
},
"require": {
"ext-json": "*",
"laravel/framework": "^11.35|^12.0|^13.0",
"php": "^8.2.0",
"symfony/console": "^7.0|^8.0"
},
"conflict": {
"laravel/boost": "<2.5.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.15.2|^8.0",
"larastan/larastan": "^3.0",
"laravel/pint": "^1.16",
"mockery/mockery": "^1.3.3",
"orchestra/testbench": "^9.2|^10.0|^11.0",
"phpunit/phpunit": "^11.5|^12.0"
},
"suggest": {
"ext-pcntl": "Recommended when running the Inertia SSR server via the `inertia:start-ssr` artisan command."
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Inertia\\ServiceProvider"
]
}
},
"autoload": {
"files": [
"./helpers.php"
],
"psr-4": {
"Inertia\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jonathan Reinink",
"email": "jonathan@reinink.ca",
"homepage": "https://reinink.ca"
}
],
"description": "The Laravel adapter for Inertia.js.",
"keywords": [
"inertia",
"laravel"
],
"support": {
"issues": "https://github.com/inertiajs/inertia-laravel/issues",
"source": "https://github.com/inertiajs/inertia-laravel/tree/v3.3.1"
},
"time": "2026-08-04T21:58:51+00:00"
},
{
"name": "laravel/framework",
"version": "v13.22.0",
@@ -2,6 +2,7 @@
namespace Database\Factories;
use App\Enums\UserRole;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
@@ -30,6 +31,10 @@ class UserFactory extends Factory
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
// Matches the `role` column's DB default — create() doesn't
// hydrate DB-side defaults back into the in-memory model, so an
// omitted role here would leave $user->role null in-process.
'role' => UserRole::Owner,
];
}
+317 -73
View File
@@ -4,10 +4,14 @@
"requires": true,
"packages": {
"": {
"dependencies": {
"@inertiajs/vue3": "^3.6.1",
"@vitejs/plugin-vue": "^6.0.8",
"vue": "^3.5.41"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.2",
"@tailwindcss/vite": "^4.0.0",
"alpinejs": "^3.4.2",
"autoprefixer": "^10.4.2",
"concurrently": "^9.0.1",
"laravel-vite-plugin": "^3.1",
@@ -29,11 +33,56 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.8"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -45,7 +94,6 @@
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -56,13 +104,45 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@inertiajs/core": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-3.6.1.tgz",
"integrity": "sha512-h6+qqkKfpcoZvxWENy/F5yyiD00PIm8lK+ruQkt6HGk4d3N0LMS9GeUbWVQ4+TDZzIxP/vQLurPktnYvDhYeew==",
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"es-toolkit": "^1.33.0",
"laravel-precognition": "^2.0.0"
},
"peerDependencies": {
"axios": "^1.15.2"
},
"peerDependenciesMeta": {
"axios": {
"optional": true
}
}
},
"node_modules/@inertiajs/vue3": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/@inertiajs/vue3/-/vue3-3.6.1.tgz",
"integrity": "sha512-7M76W7uw1DqxWR5O+OV7Yg23yITTDcRxuO7KCdQhI58rp9+OTv2BZId7ePSBmqahYOeXUZbTfZfVoS2O6/hijA==",
"license": "MIT",
"dependencies": {
"@inertiajs/core": "3.6.1",
"es-toolkit": "^1.33.0",
"laravel-precognition": "^2.0.0"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -89,7 +169,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -99,14 +178,12 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -117,7 +194,6 @@
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -174,7 +250,6 @@
"version": "0.139.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
@@ -187,7 +262,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -204,7 +278,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -221,7 +294,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -238,7 +310,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -255,7 +326,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -272,7 +342,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -289,7 +358,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -306,7 +374,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -323,7 +390,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -340,7 +406,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -357,7 +422,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -374,7 +438,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -391,7 +454,6 @@
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -410,7 +472,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -427,7 +488,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -441,7 +501,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT"
},
"node_modules/@tailwindcss/forms": {
@@ -747,40 +806,171 @@
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@vue/reactivity": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
"dev": true,
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.8",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz",
"integrity": "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.1.5"
"@rolldown/pluginutils": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
"vue": "^3.2.25"
}
},
"node_modules/@vue/shared": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
"dev": true,
"node_modules/@vue/compiler-core": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.41.tgz",
"integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.8",
"@vue/shared": "3.5.41",
"entities": "^7.0.1",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-core/node_modules/@vue/shared": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz",
"integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==",
"license": "MIT"
},
"node_modules/alpinejs": {
"version": "3.15.12",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz",
"integrity": "sha512-nJvPAQVNPdZZ0NrExJ/kzQco3ijR8LwvCOadQecllESiqT4NyZ/57sN9V2XyvhlBGAbmlKYgeWZvYdKq99ij/Q==",
"dev": true,
"node_modules/@vue/compiler-dom": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz",
"integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "~3.1.1"
"@vue/compiler-core": "3.5.41",
"@vue/shared": "3.5.41"
}
},
"node_modules/@vue/compiler-dom/node_modules/@vue/shared": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz",
"integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==",
"license": "MIT"
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz",
"integrity": "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.8",
"@vue/compiler-core": "3.5.41",
"@vue/compiler-dom": "3.5.41",
"@vue/compiler-ssr": "3.5.41",
"@vue/shared": "3.5.41",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.19",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-sfc/node_modules/@vue/shared": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz",
"integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==",
"license": "MIT"
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz",
"integrity": "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.41",
"@vue/shared": "3.5.41"
}
},
"node_modules/@vue/compiler-ssr/node_modules/@vue/shared": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz",
"integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==",
"license": "MIT"
},
"node_modules/@vue/runtime-core": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.41.tgz",
"integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.41",
"@vue/shared": "3.5.41"
}
},
"node_modules/@vue/runtime-core/node_modules/@vue/reactivity": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz",
"integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.41"
}
},
"node_modules/@vue/runtime-core/node_modules/@vue/shared": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz",
"integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==",
"license": "MIT"
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz",
"integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.41",
"@vue/runtime-core": "3.5.41",
"@vue/shared": "3.5.41",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/runtime-dom/node_modules/@vue/reactivity": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz",
"integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.41"
}
},
"node_modules/@vue/runtime-dom/node_modules/@vue/shared": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz",
"integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==",
"license": "MIT"
},
"node_modules/@vue/server-renderer": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.41.tgz",
"integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.41",
"@vue/runtime-dom": "3.5.41",
"@vue/shared": "3.5.41"
}
},
"node_modules/@vue/server-renderer/node_modules/@vue/shared": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz",
"integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==",
"license": "MIT"
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -1127,11 +1317,16 @@
"node": ">=4"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -1179,6 +1374,18 @@
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
@@ -1189,6 +1396,17 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.50.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz",
"integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks",
"tests/types"
]
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -1199,6 +1417,12 @@
"node": ">=6"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -1270,7 +1494,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -1420,12 +1643,29 @@
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/laravel-precognition": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-2.0.0.tgz",
"integrity": "sha512-dmA4HGc9m+TsVNsJs9/XQBI8u6j7coilN+qKkBuhuXQzH3HypwS/c5dFQ4UqUGjBbcxIM7zdk91kM/SRZwIvWQ==",
"license": "MIT",
"dependencies": {
"es-toolkit": "^1.32.0"
},
"peerDependencies": {
"axios": "^1.4.0"
},
"peerDependenciesMeta": {
"axios": {
"optional": true
}
}
},
"node_modules/laravel-vite-plugin": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.1.3.tgz",
@@ -1457,7 +1697,6 @@
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
@@ -1490,7 +1729,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1511,7 +1749,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1532,7 +1769,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1553,7 +1789,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1574,7 +1809,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1595,7 +1829,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1616,7 +1849,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1637,7 +1869,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1658,7 +1889,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1679,7 +1909,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1700,7 +1929,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1738,7 +1966,6 @@
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
@@ -1791,10 +2018,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"dev": true,
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [
{
"type": "github",
@@ -1860,7 +2086,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
@@ -1900,7 +2125,6 @@
"version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -2150,7 +2374,6 @@
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.139.0",
@@ -2231,7 +2454,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -2406,7 +2628,6 @@
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@@ -2423,7 +2644,6 @@
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
@@ -2441,7 +2661,6 @@
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -2484,7 +2703,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"devOptional": true,
"license": "0BSD"
},
"node_modules/update-browserslist-db": {
@@ -2529,7 +2748,6 @@
"version": "8.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
@@ -2618,7 +2836,6 @@
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -2627,6 +2844,33 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vue": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz",
"integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.41",
"@vue/compiler-sfc": "3.5.41",
"@vue/runtime-dom": "3.5.41",
"@vue/server-renderer": "3.5.41",
"@vue/shared": "3.5.41"
},
"peerDependencies": {
"typescript": "*"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/vue/node_modules/@vue/shared": {
"version": "3.5.41",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz",
"integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==",
"license": "MIT"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+5 -1
View File
@@ -9,12 +9,16 @@
"devDependencies": {
"@tailwindcss/forms": "^0.5.2",
"@tailwindcss/vite": "^4.0.0",
"alpinejs": "^3.4.2",
"autoprefixer": "^10.4.2",
"concurrently": "^9.0.1",
"laravel-vite-plugin": "^3.1",
"postcss": "^8.4.31",
"tailwindcss": "^3.1.0",
"vite": "^8.0.0"
},
"dependencies": {
"@inertiajs/vue3": "^3.6.1",
"@vitejs/plugin-vue": "^6.0.8",
"vue": "^3.5.41"
}
}
@@ -0,0 +1,11 @@
<script setup>
defineProps({
class: { type: String, default: '' },
});
</script>
<template>
<svg viewBox="0 0 316 316" xmlns="http://www.w3.org/2000/svg" :class="class">
<path d="M305.8 81.125C305.77 80.995 305.69 80.885 305.65 80.755C305.56 80.525 305.49 80.285 305.37 80.075C305.29 79.935 305.17 79.815 305.07 79.685C304.94 79.515 304.83 79.325 304.68 79.175C304.55 79.045 304.39 78.955 304.25 78.845C304.09 78.715 303.95 78.575 303.77 78.475L251.32 48.275C249.97 47.495 248.31 47.495 246.96 48.275L194.51 78.475C194.33 78.575 194.19 78.725 194.03 78.845C193.89 78.955 193.73 79.045 193.6 79.175C193.45 79.325 193.34 79.515 193.21 79.685C193.11 79.815 192.99 79.935 192.91 80.075C192.79 80.285 192.71 80.525 192.63 80.755C192.58 80.875 192.51 80.995 192.48 81.125C192.38 81.495 192.33 81.875 192.33 82.265V139.625L148.62 164.795V52.575C148.62 52.185 148.57 51.805 148.47 51.435C148.44 51.305 148.36 51.195 148.32 51.065C148.23 50.835 148.16 50.595 148.04 50.385C147.96 50.245 147.84 50.125 147.74 49.995C147.61 49.825 147.5 49.635 147.35 49.485C147.22 49.355 147.06 49.265 146.92 49.155C146.76 49.025 146.62 48.885 146.44 48.785L93.99 18.585C92.64 17.805 90.98 17.805 89.63 18.585L37.18 48.785C37 48.885 36.86 49.035 36.7 49.155C36.56 49.265 36.4 49.355 36.27 49.485C36.12 49.635 36.01 49.825 35.88 49.995C35.78 50.125 35.66 50.245 35.58 50.385C35.46 50.595 35.38 50.835 35.3 51.065C35.25 51.185 35.18 51.305 35.15 51.435C35.05 51.805 35 52.185 35 52.575V232.235C35 233.795 35.84 235.245 37.19 236.025L142.1 296.425C142.33 296.555 142.58 296.635 142.82 296.725C142.93 296.765 143.04 296.835 143.16 296.865C143.53 296.965 143.9 297.015 144.28 297.015C144.66 297.015 145.03 296.965 145.4 296.865C145.5 296.835 145.59 296.775 145.69 296.745C145.95 296.655 146.21 296.565 146.45 296.435L251.36 236.035C252.72 235.255 253.55 233.815 253.55 232.245V174.885L303.81 145.945C305.17 145.165 306 143.725 306 142.155V82.265C305.95 81.875 305.89 81.495 305.8 81.125ZM144.2 227.205L100.57 202.515L146.39 176.135L196.66 147.195L240.33 172.335L208.29 190.625L144.2 227.205ZM244.75 114.995V164.795L226.39 154.225L201.03 139.625V89.825L219.39 100.395L244.75 114.995ZM249.12 57.105L292.81 82.265L249.12 107.425L205.43 82.265L249.12 57.105ZM114.49 184.425L96.13 194.995V85.305L121.49 70.705L139.85 60.135V169.815L114.49 184.425ZM91.76 27.425L135.45 52.585L91.76 77.745L48.07 52.585L91.76 27.425ZM43.67 60.135L62.03 70.705L87.39 85.305V202.545V202.555V202.565C87.39 202.735 87.44 202.895 87.46 203.055C87.49 203.265 87.49 203.485 87.55 203.695V203.705C87.6 203.875 87.69 204.035 87.76 204.195C87.84 204.375 87.89 204.575 87.99 204.745C87.99 204.745 87.99 204.755 88 204.755C88.09 204.905 88.22 205.035 88.33 205.175C88.45 205.335 88.55 205.495 88.69 205.635L88.7 205.645C88.82 205.765 88.98 205.855 89.12 205.965C89.28 206.085 89.42 206.225 89.59 206.325C89.6 206.325 89.6 206.325 89.61 206.335C89.62 206.335 89.62 206.345 89.63 206.345L139.87 234.775V285.065L43.67 229.705V60.135ZM244.75 229.705L148.58 285.075V234.775L219.8 194.115L244.75 179.875V229.705ZM297.2 139.625L253.49 164.795V114.995L278.85 100.395L297.21 89.825V139.625H297.2Z" />
</svg>
</template>
@@ -0,0 +1,29 @@
<script setup>
import { Link } from '@inertiajs/vue3';
defineProps({
href: { type: String, required: true },
active: { type: Boolean, default: false },
mobile: { type: Boolean, default: false },
});
</script>
<template>
<Link
:href="href"
:class="[
mobile
? 'block w-full ps-3 pe-4 py-2 border-l-4 text-start text-base font-medium transition duration-150 ease-in-out focus:outline-none'
: 'inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium leading-5 transition duration-150 ease-in-out focus:outline-none',
active
? mobile
? 'border-indigo-400 text-indigo-700 bg-indigo-50 focus:text-indigo-800 focus:bg-indigo-100 focus:border-indigo-700'
: 'border-indigo-400 text-gray-900 focus:border-indigo-700'
: mobile
? 'border-transparent text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300 focus:text-gray-800 focus:bg-gray-50 focus:border-gray-300'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:text-gray-700 focus:border-gray-300',
]"
>
<slot />
</Link>
</template>
@@ -0,0 +1,15 @@
<script setup>
defineProps({
status: { type: String, required: true },
});
const classes = {
online: 'bg-green-100 text-green-800',
offline: 'bg-gray-100 text-gray-600',
unknown: 'bg-yellow-100 text-yellow-800',
};
</script>
<template>
<span class="inline-block px-2 py-0.5 rounded text-xs" :class="classes[status] ?? classes.unknown">{{ status }}</span>
</template>
@@ -0,0 +1,143 @@
<script setup>
import { ref, computed } from 'vue';
import { Link, router, usePage } from '@inertiajs/vue3';
import ApplicationLogo from '@/Components/ApplicationLogo.vue';
import NavLink from '@/Components/NavLink.vue';
import { routes } from '@/routes';
const page = usePage();
const user = computed(() => page.props.auth.user);
const flash = computed(() => page.props.flash);
const currentPath = computed(() => new URL(page.url, window.location.origin).pathname);
const isActive = (prefix) => currentPath.value === prefix || currentPath.value.startsWith(`${prefix}/`);
const showingMobileMenu = ref(false);
const showingUserDropdown = ref(false);
function logout() {
router.post(routes.logout);
}
const navLinks = [
{ href: routes.dashboard, label: 'Dashboard', prefix: routes.dashboard },
{ href: routes.zones.index, label: 'Зоны', prefix: routes.zones.index },
{ href: routes.devices.index, label: 'Устройства', prefix: routes.devices.index },
{ href: routes.automationRules.index, label: 'Правила', prefix: routes.automationRules.index },
];
</script>
<template>
<div class="min-h-screen bg-gray-100">
<nav class="bg-white border-b border-gray-100">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="shrink-0 flex items-center">
<Link :href="routes.dashboard">
<ApplicationLogo class="block h-9 w-auto fill-current text-gray-800" />
</Link>
</div>
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<NavLink
v-for="link in navLinks"
:key="link.href"
:href="link.href"
:active="isActive(link.prefix)"
>
{{ link.label }}
</NavLink>
</div>
</div>
<div class="hidden sm:flex sm:items-center sm:ms-6">
<div class="relative">
<button
type="button"
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150"
@click="showingUserDropdown = !showingUserDropdown"
>
<div>{{ user?.name }}</div>
<svg class="ms-1 fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
<div
v-show="showingUserDropdown"
class="absolute end-0 z-50 mt-2 w-48 rounded-md shadow-lg"
@click="showingUserDropdown = false"
>
<div class="rounded-md ring-1 ring-black ring-opacity-5 py-1 bg-white">
<Link :href="routes.profile.edit" class="block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 hover:bg-gray-100">
Профиль
</Link>
<button type="button" class="block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 hover:bg-gray-100" @click="logout">
Выйти
</button>
</div>
</div>
</div>
</div>
<div class="-me-2 flex items-center sm:hidden">
<button
type="button"
class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none transition duration-150 ease-in-out"
@click="showingMobileMenu = !showingMobileMenu"
>
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
<path v-if="!showingMobileMenu" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
<div v-show="showingMobileMenu" class="sm:hidden">
<div class="pt-2 pb-3 space-y-1">
<NavLink
v-for="link in navLinks"
:key="link.href"
:href="link.href"
:active="isActive(link.prefix)"
mobile
>
{{ link.label }}
</NavLink>
</div>
<div class="pt-4 pb-1 border-t border-gray-200">
<div class="px-4">
<div class="font-medium text-base text-gray-800">{{ user?.name }}</div>
<div class="font-medium text-sm text-gray-500">{{ user?.email }}</div>
</div>
<div class="mt-3 space-y-1">
<NavLink :href="routes.profile.edit" mobile>Профиль</NavLink>
<button type="button" class="block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" @click="logout">
Выйти
</button>
</div>
</div>
</div>
</nav>
<header v-if="$slots.header" class="bg-white shadow">
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<slot name="header" />
</div>
</header>
<main>
<div v-if="flash.status || flash.error" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-4">
<div v-if="flash.status" class="rounded-md bg-green-50 p-4 text-sm text-green-700">{{ flash.status }}</div>
<div v-if="flash.error" class="rounded-md bg-red-50 p-4 text-sm text-red-700">{{ flash.error }}</div>
</div>
<slot />
</main>
</div>
</template>
@@ -0,0 +1,34 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import Form from '@/Pages/AutomationRules/Form.vue';
defineProps({
zones: { type: Array, required: true },
devices: { type: Array, required: true },
actionTypeOptions: { type: Array, required: true },
sensorTypeOptions: { type: Array, required: true },
conditionOperatorOptions: { type: Array, required: true },
});
</script>
<template>
<AppLayout>
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Новое правило</h2>
</template>
<div class="py-12">
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<Form
:zones="zones"
:devices="devices"
:action-type-options="actionTypeOptions"
:sensor-type-options="sensorTypeOptions"
:condition-operator-options="conditionOperatorOptions"
/>
</div>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,36 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import Form from '@/Pages/AutomationRules/Form.vue';
defineProps({
rule: { type: Object, required: true },
zones: { type: Array, required: true },
devices: { type: Array, required: true },
actionTypeOptions: { type: Array, required: true },
sensorTypeOptions: { type: Array, required: true },
conditionOperatorOptions: { type: Array, required: true },
});
</script>
<template>
<AppLayout>
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Изменить правило</h2>
</template>
<div class="py-12">
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<Form
:rule="rule"
:zones="zones"
:devices="devices"
:action-type-options="actionTypeOptions"
:sensor-type-options="sensorTypeOptions"
:condition-operator-options="conditionOperatorOptions"
/>
</div>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,176 @@
<script setup>
import { useForm } from '@inertiajs/vue3';
import { routes } from '@/routes';
const props = defineProps({
rule: { type: Object, default: null },
zones: { type: Array, required: true },
devices: { type: Array, required: true },
actionTypeOptions: { type: Array, required: true },
sensorTypeOptions: { type: Array, required: true },
conditionOperatorOptions: { type: Array, required: true },
});
const isEdit = !!props.rule;
const form = useForm({
zone_id: props.rule?.zone_id ?? '',
condition_source_device_id: props.rule?.condition_source_device_id ?? '',
condition_sensor_type: props.rule?.condition_sensor_type ?? '',
condition_operator: props.rule?.condition_operator ?? '',
condition_value: props.rule?.condition_value ?? '',
target_device_id: props.rule?.target_device_id ?? '',
action_type: props.rule?.action_type ?? '',
level: props.rule?.level ?? '',
is_active: props.rule?.is_active ?? true,
});
function submit() {
if (isEdit) {
form.put(routes.automationRules.update(props.rule.id));
} else {
form.post(routes.automationRules.store);
}
}
</script>
<template>
<form @submit.prevent="submit">
<div>
<label for="zone_id" class="block font-medium text-sm text-gray-700">Зона</label>
<select
id="zone_id"
v-model="form.zone_id"
required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<option value=""> выбрать </option>
<option v-for="zone in zones" :key="zone.id" :value="zone.id">{{ zone.name }}</option>
</select>
<div v-if="form.errors.zone_id" class="mt-2 text-sm text-red-600">{{ form.errors.zone_id }}</div>
</div>
<div class="mt-4">
<label for="condition_source_device_id" class="block font-medium text-sm text-gray-700">Устройство-источник показания</label>
<select
id="condition_source_device_id"
v-model="form.condition_source_device_id"
required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<option value=""> выбрать </option>
<option v-for="device in devices" :key="device.id" :value="device.id">
{{ device.zone.name }} {{ device.name }} ({{ device.device_type.code }})
</option>
</select>
<div v-if="form.errors.condition_source_device_id" class="mt-2 text-sm text-red-600">{{ form.errors.condition_source_device_id }}</div>
</div>
<div class="mt-4 grid grid-cols-3 gap-4">
<div>
<label for="condition_sensor_type" class="block font-medium text-sm text-gray-700">Тип показания</label>
<input
id="condition_sensor_type"
v-model="form.condition_sensor_type"
list="sensor-types"
type="text"
required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<datalist id="sensor-types">
<option v-for="option in sensorTypeOptions" :key="option" :value="option" />
</datalist>
<div v-if="form.errors.condition_sensor_type" class="mt-2 text-sm text-red-600">{{ form.errors.condition_sensor_type }}</div>
</div>
<div>
<label for="condition_operator" class="block font-medium text-sm text-gray-700">Оператор</label>
<select
id="condition_operator"
v-model="form.condition_operator"
required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<option value=""> выбрать </option>
<option v-for="op in conditionOperatorOptions" :key="op" :value="op">{{ op }}</option>
</select>
<div v-if="form.errors.condition_operator" class="mt-2 text-sm text-red-600">{{ form.errors.condition_operator }}</div>
</div>
<div>
<label for="condition_value" class="block font-medium text-sm text-gray-700">Порог</label>
<input
id="condition_value"
v-model="form.condition_value"
type="number"
step="any"
required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<div v-if="form.errors.condition_value" class="mt-2 text-sm text-red-600">{{ form.errors.condition_value }}</div>
</div>
</div>
<div class="mt-4">
<label for="target_device_id" class="block font-medium text-sm text-gray-700">Устройство-исполнитель</label>
<select
id="target_device_id"
v-model="form.target_device_id"
required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<option value=""> выбрать </option>
<option v-for="device in devices" :key="device.id" :value="device.id">
{{ device.zone.name }} {{ device.name }} ({{ device.device_type.code }})
</option>
</select>
<div v-if="form.errors.target_device_id" class="mt-2 text-sm text-red-600">{{ form.errors.target_device_id }}</div>
</div>
<div class="mt-4 grid grid-cols-2 gap-4">
<div>
<label for="action_type" class="block font-medium text-sm text-gray-700">Действие</label>
<select
id="action_type"
v-model="form.action_type"
required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<option value=""> выбрать </option>
<option v-for="option in actionTypeOptions" :key="option" :value="option">{{ option }}</option>
</select>
<div v-if="form.errors.action_type" class="mt-2 text-sm text-red-600">{{ form.errors.action_type }}</div>
</div>
<div v-show="form.action_type === 'set_level'">
<label for="level" class="block font-medium text-sm text-gray-700">Уровень</label>
<input
id="level"
v-model="form.level"
type="number"
step="any"
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<div v-if="form.errors.level" class="mt-2 text-sm text-red-600">{{ form.errors.level }}</div>
</div>
</div>
<div class="mt-4">
<label class="inline-flex items-center">
<input v-model="form.is_active" type="checkbox" class="rounded border-gray-300 text-indigo-600 shadow-sm">
<span class="ms-2 text-sm text-gray-600">Правило активно</span>
</label>
</div>
<div class="mt-4 flex items-center gap-4">
<button
type="submit"
:disabled="form.processing"
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 disabled:opacity-50"
>
Сохранить
</button>
<a :href="routes.automationRules.index" class="text-sm text-gray-600 hover:underline">Отмена</a>
</div>
</form>
</template>
@@ -0,0 +1,78 @@
<script setup>
import { computed } from 'vue';
import { Link, router, usePage } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
import { routes } from '@/routes';
defineProps({
rules: { type: Array, required: true },
});
const isOwner = computed(() => usePage().props.auth.user?.role === 'owner');
function destroy(rule) {
if (confirm('Удалить правило?')) {
router.delete(routes.automationRules.destroy(rule.id));
}
}
</script>
<template>
<AppLayout>
<template #header>
<div class="flex justify-between items-center">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Правила автоматизации</h2>
<Link
v-if="isOwner"
:href="routes.automationRules.create"
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700"
>
Добавить правило
</Link>
</div>
</template>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b">
<th class="py-2">Зона</th>
<th class="py-2">Условие</th>
<th class="py-2">Действие</th>
<th class="py-2">Активно</th>
<th class="py-2" />
</tr>
</thead>
<tbody>
<tr v-if="rules.length === 0">
<td colspan="5" class="py-4 text-gray-500">Правил пока нет.</td>
</tr>
<tr v-for="rule in rules" :key="rule.id" class="border-b">
<td class="py-2">{{ rule.zone.name }}</td>
<td class="py-2">
{{ rule.condition_source_device.name }}:
{{ rule.condition_sensor_type }}
{{ rule.condition_operator }}
{{ rule.condition_value }}
</td>
<td class="py-2">
{{ rule.target_device.name }}: {{ rule.action_type }}
<template v-if="Object.keys(rule.action_params ?? {}).length">
({{ JSON.stringify(rule.action_params) }})
</template>
</td>
<td class="py-2">{{ rule.is_active ? 'да' : 'нет' }}</td>
<td class="py-2 text-right space-x-2">
<Link v-if="isOwner" :href="routes.automationRules.edit(rule.id)" class="text-indigo-600 hover:underline">Изменить</Link>
<button v-if="isOwner" type="button" class="text-red-600 hover:underline" @click="destroy(rule)">Удалить</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,41 @@
<script setup>
import { Link } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
import { routes } from '@/routes';
defineProps({
zonesCount: Number,
devicesCount: Number,
onlineDevicesCount: Number,
activeRulesCount: Number,
});
</script>
<template>
<AppLayout>
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Dashboard</h2>
</template>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 grid grid-cols-1 sm:grid-cols-4 gap-6">
<Link :href="routes.zones.index" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6 hover:shadow-md transition">
<div class="text-sm text-gray-500">Зоны</div>
<div class="text-3xl font-semibold text-gray-900">{{ zonesCount }}</div>
</Link>
<Link :href="routes.devices.index" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6 hover:shadow-md transition">
<div class="text-sm text-gray-500">Устройства</div>
<div class="text-3xl font-semibold text-gray-900">{{ devicesCount }}</div>
</Link>
<Link :href="routes.devices.index" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6 hover:shadow-md transition">
<div class="text-sm text-gray-500">Онлайн</div>
<div class="text-3xl font-semibold text-green-600">{{ onlineDevicesCount }}</div>
</Link>
<Link :href="routes.automationRules.index" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6 hover:shadow-md transition">
<div class="text-sm text-gray-500">Активных правил</div>
<div class="text-3xl font-semibold text-gray-900">{{ activeRulesCount }}</div>
</Link>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,25 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import Form from '@/Pages/Devices/Form.vue';
defineProps({
zones: { type: Array, required: true },
deviceTypes: { type: Array, required: true },
});
</script>
<template>
<AppLayout>
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Новое устройство</h2>
</template>
<div class="py-12">
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<Form :zones="zones" :device-types="deviceTypes" />
</div>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,26 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import Form from '@/Pages/Devices/Form.vue';
defineProps({
device: { type: Object, required: true },
zones: { type: Array, required: true },
deviceTypes: { type: Array, required: true },
});
</script>
<template>
<AppLayout>
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Изменить устройство</h2>
</template>
<div class="py-12">
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<Form :device="device" :zones="zones" :device-types="deviceTypes" />
</div>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,108 @@
<script setup>
import { useForm } from '@inertiajs/vue3';
import { routes } from '@/routes';
const props = defineProps({
device: { type: Object, default: null },
zones: { type: Array, required: true },
deviceTypes: { type: Array, required: true },
});
const isEdit = !!props.device;
const form = useForm({
name: props.device?.name ?? '',
zone_id: props.device?.zone_id ?? '',
device_type_id: props.device?.device_type_id ?? '',
external_id: props.device?.external_id ?? '',
protocol: props.device?.protocol ?? 'mqtt',
});
function submit() {
if (isEdit) {
form.put(routes.devices.update(props.device.id));
} else {
form.post(routes.devices.store);
}
}
</script>
<template>
<form @submit.prevent="submit">
<div>
<label for="name" class="block font-medium text-sm text-gray-700">Название</label>
<input
id="name"
v-model="form.name"
type="text"
required
autofocus
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<div v-if="form.errors.name" class="mt-2 text-sm text-red-600">{{ form.errors.name }}</div>
</div>
<div class="mt-4">
<label for="zone_id" class="block font-medium text-sm text-gray-700">Зона</label>
<select
id="zone_id"
v-model="form.zone_id"
required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<option value=""> выбрать </option>
<option v-for="zone in zones" :key="zone.id" :value="zone.id">{{ zone.name }}</option>
</select>
<div v-if="form.errors.zone_id" class="mt-2 text-sm text-red-600">{{ form.errors.zone_id }}</div>
</div>
<div class="mt-4">
<label for="device_type_id" class="block font-medium text-sm text-gray-700">Тип устройства</label>
<select
id="device_type_id"
v-model="form.device_type_id"
required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<option value=""> выбрать </option>
<option v-for="type in deviceTypes" :key="type.id" :value="type.id">{{ type.code }} ({{ type.category }})</option>
</select>
<div v-if="form.errors.device_type_id" class="mt-2 text-sm text-red-600">{{ form.errors.device_type_id }}</div>
</div>
<div class="mt-4">
<label for="external_id" class="block font-medium text-sm text-gray-700">External ID (идентификатор физического устройства)</label>
<input
id="external_id"
v-model="form.external_id"
type="text"
required
class="mt-1 block w-full font-mono border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<div v-if="form.errors.external_id" class="mt-2 text-sm text-red-600">{{ form.errors.external_id }}</div>
</div>
<div class="mt-4">
<label for="protocol" class="block font-medium text-sm text-gray-700">Протокол</label>
<input
id="protocol"
v-model="form.protocol"
type="text"
required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<div v-if="form.errors.protocol" class="mt-2 text-sm text-red-600">{{ form.errors.protocol }}</div>
</div>
<div class="mt-4 flex items-center gap-4">
<button
type="submit"
:disabled="form.processing"
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 disabled:opacity-50"
>
Сохранить
</button>
<a :href="routes.devices.index" class="text-sm text-gray-600 hover:underline">Отмена</a>
</div>
</form>
</template>
@@ -0,0 +1,73 @@
<script setup>
import { computed } from 'vue';
import { Link, router, usePage } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
import StatusBadge from '@/Components/StatusBadge.vue';
import { routes } from '@/routes';
defineProps({
devices: { type: Array, required: true },
});
const isOwner = computed(() => usePage().props.auth.user?.role === 'owner');
function destroy(device) {
if (confirm('Удалить устройство?')) {
router.delete(routes.devices.destroy(device.id));
}
}
</script>
<template>
<AppLayout>
<template #header>
<div class="flex justify-between items-center">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Устройства</h2>
<Link
v-if="isOwner"
:href="routes.devices.create"
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700"
>
Добавить устройство
</Link>
</div>
</template>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b">
<th class="py-2">Название</th>
<th class="py-2">Зона</th>
<th class="py-2">Тип</th>
<th class="py-2">Статус</th>
<th class="py-2">External ID</th>
<th class="py-2" />
</tr>
</thead>
<tbody>
<tr v-if="devices.length === 0">
<td colspan="6" class="py-4 text-gray-500">Устройств пока нет.</td>
</tr>
<tr v-for="device in devices" :key="device.id" class="border-b">
<td class="py-2">
<Link :href="routes.devices.show(device.id)" class="text-indigo-600 hover:underline">{{ device.name }}</Link>
</td>
<td class="py-2">{{ device.zone.name }}</td>
<td class="py-2">{{ device.device_type.code }}</td>
<td class="py-2"><StatusBadge :status="device.status" /></td>
<td class="py-2 font-mono text-xs">{{ device.external_id }}</td>
<td class="py-2 text-right space-x-2">
<Link v-if="isOwner" :href="routes.devices.edit(device.id)" class="text-indigo-600 hover:underline">Изменить</Link>
<button v-if="isOwner" type="button" class="text-red-600 hover:underline" @click="destroy(device)">Удалить</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,132 @@
<script setup>
import { computed } from 'vue';
import { useForm, usePage } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
import StatusBadge from '@/Components/StatusBadge.vue';
import { routes } from '@/routes';
const props = defineProps({
device: { type: Object, required: true },
shadow: { type: Object, required: true },
telemetry: { type: Array, required: true },
});
const isOwner = computed(() => usePage().props.auth.user?.role === 'owner');
const capabilities = computed(() => props.device.device_type.capabilities ?? []);
const hasControls = computed(() => capabilities.value.some((c) => ['turn_on', 'turn_off', 'set_level'].includes(c)));
const lastSeenLabel = computed(() => (
props.shadow.last_seen ? new Date(props.shadow.last_seen).toLocaleString('ru-RU') : 'нет данных'
));
const levelForm = useForm({ level: '' });
function turnOn() {
useForm({}).post(routes.devices.turnOn(props.device.id));
}
function turnOff() {
useForm({}).post(routes.devices.turnOff(props.device.id));
}
function setLevel() {
levelForm.post(routes.devices.setLevel(props.device.id));
}
</script>
<template>
<AppLayout>
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ device.name }}</h2>
</template>
<div class="py-12">
<div class="max-w-4xl mx-auto sm:px-6 lg:px-8 space-y-6">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<h3 class="font-semibold mb-4">Живое состояние (Redis device shadow)</h3>
<dl class="grid grid-cols-2 gap-4 text-sm">
<div>
<dt class="text-gray-500">Статус</dt>
<dd class="mt-1"><StatusBadge :status="device.status" /></dd>
</div>
<div>
<dt class="text-gray-500">Последняя активность</dt>
<dd>{{ lastSeenLabel }}</dd>
</div>
<div>
<dt class="text-gray-500">Желаемое состояние (desired)</dt>
<dd class="font-mono text-xs">{{ JSON.stringify(shadow.desired_state) }}</dd>
</div>
<div>
<dt class="text-gray-500">Подтверждённое состояние (reported)</dt>
<dd class="font-mono text-xs">{{ JSON.stringify(shadow.reported_state) }}</dd>
</div>
</dl>
</div>
<div v-if="isOwner && hasControls" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<h3 class="font-semibold mb-4">Ручное управление</h3>
<div class="flex items-center gap-4 flex-wrap">
<button
v-if="capabilities.includes('turn_on')"
type="button"
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700"
@click="turnOn"
>
Включить
</button>
<button
v-if="capabilities.includes('turn_off')"
type="button"
class="inline-flex items-center px-4 py-2 bg-white border border-gray-300 rounded-md font-semibold text-xs text-gray-700 uppercase tracking-widest hover:bg-gray-50"
@click="turnOff"
>
Выключить
</button>
<form v-if="capabilities.includes('set_level')" class="flex items-center gap-2" @submit.prevent="setLevel">
<input
v-model="levelForm.level"
type="number"
step="any"
required
placeholder="уровень"
class="border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm text-sm"
>
<button
type="submit"
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700"
>
Установить уровень
</button>
</form>
</div>
</div>
<div v-if="device.device_type.category === 'sensor'" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<h3 class="font-semibold mb-4">Последние показания (ClickHouse)</h3>
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b">
<th class="py-2">Тип</th>
<th class="py-2">Значение</th>
<th class="py-2">Время</th>
</tr>
</thead>
<tbody>
<tr v-if="telemetry.length === 0">
<td colspan="3" class="py-4 text-gray-500">Показаний пока нет.</td>
</tr>
<tr v-for="(row, i) in telemetry" :key="i" class="border-b">
<td class="py-2">{{ row.sensor_type }}</td>
<td class="py-2">{{ row.value }}</td>
<td class="py-2">{{ row.recorded_at }}</td>
</tr>
</tbody>
</table>
</div>
<a :href="routes.devices.index" class="text-sm text-gray-600 hover:underline"> Назад к устройствам</a>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,40 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import UpdateProfileInformationForm from '@/Pages/Profile/Partials/UpdateProfileInformationForm.vue';
import UpdatePasswordForm from '@/Pages/Profile/Partials/UpdatePasswordForm.vue';
import DeleteUserForm from '@/Pages/Profile/Partials/DeleteUserForm.vue';
defineProps({
user: { type: Object, required: true },
});
</script>
<template>
<AppLayout>
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Профиль</h2>
</template>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
<div class="max-w-xl">
<UpdateProfileInformationForm :user="user" />
</div>
</div>
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
<div class="max-w-xl">
<UpdatePasswordForm />
</div>
</div>
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
<div class="max-w-xl">
<DeleteUserForm />
</div>
</div>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,88 @@
<script setup>
import { ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import { routes } from '@/routes';
const confirmingDeletion = ref(false);
const form = useForm({ password: '' });
function confirmDeletion() {
confirmingDeletion.value = true;
}
function closeModal() {
confirmingDeletion.value = false;
form.reset();
form.clearErrors();
}
function submit() {
form.delete(routes.profile.destroy, {
preserveScroll: true,
errorBag: 'userDeletion',
onSuccess: () => closeModal(),
onError: () => {},
onFinish: () => form.reset('password'),
});
}
</script>
<template>
<section class="space-y-6">
<header>
<h2 class="text-lg font-medium text-gray-900">Удаление аккаунта</h2>
<p class="mt-1 text-sm text-gray-600">
После удаления аккаунта все связанные данные будут безвозвратно удалены.
</p>
</header>
<button
type="button"
class="inline-flex items-center px-4 py-2 bg-red-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-red-500"
@click="confirmDeletion"
>
Удалить аккаунт
</button>
<div v-if="confirmingDeletion" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full p-6">
<form @submit.prevent="submit">
<h2 class="text-lg font-medium text-gray-900">Точно удалить аккаунт?</h2>
<p class="mt-1 text-sm text-gray-600">
Это необратимо. Введите пароль, чтобы подтвердить удаление.
</p>
<div class="mt-6">
<label for="delete-password" class="sr-only">Пароль</label>
<input
id="delete-password"
v-model="form.password"
type="password"
placeholder="Пароль"
class="mt-1 block w-3/4 border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<div v-if="form.errors.password" class="mt-2 text-sm text-red-600">{{ form.errors.password }}</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
class="inline-flex items-center px-4 py-2 bg-white border border-gray-300 rounded-md font-semibold text-xs text-gray-700 uppercase tracking-widest hover:bg-gray-50"
@click="closeModal"
>
Отмена
</button>
<button
type="submit"
:disabled="form.processing"
class="inline-flex items-center px-4 py-2 bg-red-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-red-500 disabled:opacity-50"
>
Удалить аккаунт
</button>
</div>
</form>
</div>
</div>
</section>
</template>
@@ -0,0 +1,82 @@
<script setup>
import { computed } from 'vue';
import { useForm, usePage } from '@inertiajs/vue3';
import { routes } from '@/routes';
const form = useForm({
current_password: '',
password: '',
password_confirmation: '',
});
const flashStatus = computed(() => usePage().props.flash.status);
function submit() {
form.put(routes.password.update, {
preserveScroll: true,
onSuccess: () => form.reset(),
onError: () => {
if (form.errors.password) form.reset('password', 'password_confirmation');
if (form.errors.current_password) form.reset('current_password');
},
});
}
</script>
<template>
<section>
<header>
<h2 class="text-lg font-medium text-gray-900">Смена пароля</h2>
<p class="mt-1 text-sm text-gray-600">Длинный случайный пароль надёжнее короткого запоминающегося.</p>
</header>
<form class="mt-6 space-y-6" @submit.prevent="submit">
<div>
<label for="current_password" class="block font-medium text-sm text-gray-700">Текущий пароль</label>
<input
id="current_password"
v-model="form.current_password"
type="password"
autocomplete="current-password"
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<div v-if="form.errors.current_password" class="mt-2 text-sm text-red-600">{{ form.errors.current_password }}</div>
</div>
<div>
<label for="password" class="block font-medium text-sm text-gray-700">Новый пароль</label>
<input
id="password"
v-model="form.password"
type="password"
autocomplete="new-password"
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<div v-if="form.errors.password" class="mt-2 text-sm text-red-600">{{ form.errors.password }}</div>
</div>
<div>
<label for="password_confirmation" class="block font-medium text-sm text-gray-700">Подтверждение пароля</label>
<input
id="password_confirmation"
v-model="form.password_confirmation"
type="password"
autocomplete="new-password"
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<div v-if="form.errors.password_confirmation" class="mt-2 text-sm text-red-600">{{ form.errors.password_confirmation }}</div>
</div>
<div class="flex items-center gap-4">
<button
type="submit"
:disabled="form.processing"
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 disabled:opacity-50"
>
Сохранить
</button>
<p v-if="flashStatus === 'password-updated'" class="text-sm text-gray-600">Сохранено.</p>
</div>
</form>
</section>
</template>
@@ -0,0 +1,69 @@
<script setup>
import { computed } from 'vue';
import { useForm, usePage } from '@inertiajs/vue3';
import { routes } from '@/routes';
const props = defineProps({
user: { type: Object, required: true },
});
const form = useForm({
name: props.user.name,
email: props.user.email,
});
const flashStatus = computed(() => usePage().props.flash.status);
function submit() {
form.patch(routes.profile.update);
}
</script>
<template>
<section>
<header>
<h2 class="text-lg font-medium text-gray-900">Данные профиля</h2>
<p class="mt-1 text-sm text-gray-600">Имя и email аккаунта.</p>
</header>
<form class="mt-6 space-y-6" @submit.prevent="submit">
<div>
<label for="name" class="block font-medium text-sm text-gray-700">Имя</label>
<input
id="name"
v-model="form.name"
type="text"
required
autofocus
autocomplete="name"
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<div v-if="form.errors.name" class="mt-2 text-sm text-red-600">{{ form.errors.name }}</div>
</div>
<div>
<label for="email" class="block font-medium text-sm text-gray-700">Email</label>
<input
id="email"
v-model="form.email"
type="email"
required
autocomplete="username"
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<div v-if="form.errors.email" class="mt-2 text-sm text-red-600">{{ form.errors.email }}</div>
</div>
<div class="flex items-center gap-4">
<button
type="submit"
:disabled="form.processing"
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 disabled:opacity-50"
>
Сохранить
</button>
<p v-if="flashStatus === 'profile-updated'" class="text-sm text-gray-600">Сохранено.</p>
</div>
</form>
</section>
</template>
@@ -0,0 +1,20 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import Form from '@/Pages/Zones/Form.vue';
</script>
<template>
<AppLayout>
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Новая зона</h2>
</template>
<div class="py-12">
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<Form />
</div>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,24 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import Form from '@/Pages/Zones/Form.vue';
defineProps({
zone: { type: Object, required: true },
});
</script>
<template>
<AppLayout>
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Изменить зону</h2>
</template>
<div class="py-12">
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<Form :zone="zone" />
</div>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,62 @@
<script setup>
import { useForm } from '@inertiajs/vue3';
import { routes } from '@/routes';
const props = defineProps({
zone: { type: Object, default: null },
});
const isEdit = !!props.zone;
const form = useForm({
name: props.zone?.name ?? '',
description: props.zone?.description ?? '',
});
function submit() {
if (isEdit) {
form.put(routes.zones.update(props.zone.id));
} else {
form.post(routes.zones.store);
}
}
</script>
<template>
<form @submit.prevent="submit">
<div>
<label for="name" class="block font-medium text-sm text-gray-700">Название</label>
<input
id="name"
v-model="form.name"
type="text"
required
autofocus
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<div v-if="form.errors.name" class="mt-2 text-sm text-red-600">{{ form.errors.name }}</div>
</div>
<div class="mt-4">
<label for="description" class="block font-medium text-sm text-gray-700">Описание</label>
<textarea
id="description"
v-model="form.description"
rows="3"
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
/>
<div v-if="form.errors.description" class="mt-2 text-sm text-red-600">{{ form.errors.description }}</div>
</div>
<div class="mt-4 flex items-center gap-4">
<button
type="submit"
:disabled="form.processing"
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 disabled:opacity-50"
>
Сохранить
</button>
<a :href="routes.zones.index" class="text-sm text-gray-600 hover:underline">Отмена</a>
</div>
</form>
</template>
@@ -0,0 +1,66 @@
<script setup>
import { computed } from 'vue';
import { Link, router, usePage } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
import { routes } from '@/routes';
defineProps({
zones: { type: Array, required: true },
});
const isOwner = computed(() => usePage().props.auth.user?.role === 'owner');
function destroy(zone) {
if (confirm('Удалить зону?')) {
router.delete(routes.zones.destroy(zone.id));
}
}
</script>
<template>
<AppLayout>
<template #header>
<div class="flex justify-between items-center">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Зоны</h2>
<Link
v-if="isOwner"
:href="routes.zones.create"
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700"
>
Добавить зону
</Link>
</div>
</template>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b">
<th class="py-2">Название</th>
<th class="py-2">Описание</th>
<th class="py-2">Устройств</th>
<th class="py-2" />
</tr>
</thead>
<tbody>
<tr v-if="zones.length === 0">
<td colspan="4" class="py-4 text-gray-500">Зон пока нет.</td>
</tr>
<tr v-for="zone in zones" :key="zone.id" class="border-b">
<td class="py-2">{{ zone.name }}</td>
<td class="py-2 text-gray-500">{{ zone.description }}</td>
<td class="py-2">{{ zone.devices_count }}</td>
<td class="py-2 text-right space-x-2">
<Link v-if="isOwner" :href="routes.zones.edit(zone.id)" class="text-indigo-600 hover:underline">Изменить</Link>
<button v-if="isOwner" type="button" class="text-red-600 hover:underline" @click="destroy(zone)">Удалить</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</AppLayout>
</template>
+15 -5
View File
@@ -1,7 +1,17 @@
import '../css/app.css';
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
createInertiaApp({
title: (title) => (title ? `${title} — Домашняя автоматизация` : 'Домашняя автоматизация'),
resolve: (name) => {
const pages = import.meta.glob('./Pages/**/*.vue', { eager: true });
import Alpine from 'alpinejs';
window.Alpine = Alpine;
Alpine.start();
return pages[`./Pages/${name}.vue`];
},
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.mount(el);
},
});
+44
View File
@@ -0,0 +1,44 @@
// Single source of truth for the app's URLs on the JS side — mirrors
// routes/web.php. The route list here is small and stable enough that a
// full Ziggy (route-name-to-JS) integration would be more machinery than
// the app needs; if the route count grows a lot, revisit that.
export const routes = {
dashboard: '/dashboard',
zones: {
index: '/zones',
create: '/zones/create',
store: '/zones',
edit: (id) => `/zones/${id}/edit`,
update: (id) => `/zones/${id}`,
destroy: (id) => `/zones/${id}`,
},
devices: {
index: '/devices',
show: (id) => `/devices/${id}`,
create: '/devices/create',
store: '/devices',
edit: (id) => `/devices/${id}/edit`,
update: (id) => `/devices/${id}`,
destroy: (id) => `/devices/${id}`,
turnOn: (id) => `/devices/${id}/turn-on`,
turnOff: (id) => `/devices/${id}/turn-off`,
setLevel: (id) => `/devices/${id}/set-level`,
},
automationRules: {
index: '/automation-rules',
create: '/automation-rules/create',
store: '/automation-rules',
edit: (id) => `/automation-rules/${id}/edit`,
update: (id) => `/automation-rules/${id}`,
destroy: (id) => `/automation-rules/${id}`,
},
profile: {
edit: '/profile',
update: '/profile',
destroy: '/profile',
},
password: {
update: '/password',
},
logout: '/logout',
};
@@ -7,30 +7,13 @@
<title>{{ config('app.name', 'Laravel') }}</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
<!-- Scripts -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
@inertiaHead
</head>
<body class="font-sans antialiased">
<div class="min-h-screen bg-gray-100">
@include('layouts.navigation')
<!-- Page Heading -->
@isset($header)
<header class="bg-white shadow">
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
{{ $header }}
</div>
</header>
@endisset
<!-- Page Content -->
<main>
{{ $slot }}
</main>
</div>
@inertia
</body>
</html>
@@ -1,116 +0,0 @@
@csrf
@isset($rule)
@method('PUT')
@endisset
<div x-data="{ actionType: '{{ old('action_type', $rule->action_type ?? '') }}' }">
<div>
<x-input-label for="zone_id" :value="__('Зона')" />
<select id="zone_id" name="zone_id" required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">
<option value="">{{ __('— выбрать —') }}</option>
@foreach ($zones as $zone)
<option value="{{ $zone->id }}" @selected(old('zone_id', $rule->zone_id ?? '') == $zone->id)>{{ $zone->name }}</option>
@endforeach
</select>
<x-input-error :messages="$errors->get('zone_id')" class="mt-2" />
</div>
<div class="mt-4">
<x-input-label for="condition_source_device_id" :value="__('Устройство-источник показания')" />
<select id="condition_source_device_id" name="condition_source_device_id" required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">
<option value="">{{ __('— выбрать —') }}</option>
@foreach ($devices as $device)
<option value="{{ $device->id }}" @selected(old('condition_source_device_id', $rule->condition_source_device_id ?? '') == $device->id)>
{{ $device->zone->name }} {{ $device->name }} ({{ $device->deviceType->code }})
</option>
@endforeach
</select>
<x-input-error :messages="$errors->get('condition_source_device_id')" class="mt-2" />
</div>
<div class="mt-4 grid grid-cols-3 gap-4">
<div>
<x-input-label for="condition_sensor_type" :value="__('Тип показания')" />
<input list="sensor-types" id="condition_sensor_type" name="condition_sensor_type" type="text"
value="{{ old('condition_sensor_type', $rule->condition_sensor_type ?? '') }}" required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm" />
<datalist id="sensor-types">
@foreach ($sensorTypeOptions as $option)
<option value="{{ $option }}"></option>
@endforeach
</datalist>
<x-input-error :messages="$errors->get('condition_sensor_type')" class="mt-2" />
</div>
<div>
<x-input-label for="condition_operator" :value="__('Оператор')" />
<select id="condition_operator" name="condition_operator" required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">
@foreach (\App\Enums\ConditionOperator::cases() as $operator)
<option value="{{ $operator->value }}" @selected(old('condition_operator', $rule->condition_operator->value ?? '') == $operator->value)>
{{ $operator->value }}
</option>
@endforeach
</select>
<x-input-error :messages="$errors->get('condition_operator')" class="mt-2" />
</div>
<div>
<x-input-label for="condition_value" :value="__('Порог')" />
<x-text-input id="condition_value" name="condition_value" type="number" step="any" class="mt-1 block w-full"
:value="old('condition_value', $rule->condition_value ?? '')" required />
<x-input-error :messages="$errors->get('condition_value')" class="mt-2" />
</div>
</div>
<div class="mt-4">
<x-input-label for="target_device_id" :value="__('Устройство-исполнитель')" />
<select id="target_device_id" name="target_device_id" required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">
<option value="">{{ __('— выбрать —') }}</option>
@foreach ($devices as $device)
<option value="{{ $device->id }}" @selected(old('target_device_id', $rule->target_device_id ?? '') == $device->id)>
{{ $device->zone->name }} {{ $device->name }} ({{ $device->deviceType->code }})
</option>
@endforeach
</select>
<x-input-error :messages="$errors->get('target_device_id')" class="mt-2" />
</div>
<div class="mt-4 grid grid-cols-2 gap-4">
<div>
<x-input-label for="action_type" :value="__('Действие')" />
<select id="action_type" name="action_type" required x-model="actionType"
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">
<option value="">{{ __('— выбрать —') }}</option>
@foreach ($actionTypeOptions as $option)
<option value="{{ $option }}">{{ $option }}</option>
@endforeach
</select>
<x-input-error :messages="$errors->get('action_type')" class="mt-2" />
</div>
<div x-show="actionType === 'set_level'">
<x-input-label for="level" :value="__('Уровень')" />
<x-text-input id="level" name="level" type="number" step="any" class="mt-1 block w-full"
:value="old('level', $rule->action_params['level'] ?? '')" />
<x-input-error :messages="$errors->get('level')" class="mt-2" />
</div>
</div>
<div class="mt-4">
<label class="inline-flex items-center">
<input type="hidden" name="is_active" value="0">
<input type="checkbox" name="is_active" value="1" class="rounded border-gray-300 text-indigo-600 shadow-sm"
@checked(old('is_active', $rule->is_active ?? true))>
<span class="ms-2 text-sm text-gray-600">{{ __('Правило активно') }}</span>
</label>
</div>
<div class="mt-4 flex items-center gap-4">
<x-primary-button>{{ __('Сохранить') }}</x-primary-button>
<a href="{{ route('automation-rules.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('Отмена') }}</a>
</div>
</div>
@@ -1,15 +0,0 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ __('Новое правило автоматизации') }}</h2>
</x-slot>
<div class="py-12">
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<form method="POST" action="{{ route('automation-rules.store') }}">
@include('automation-rules._form')
</form>
</div>
</div>
</div>
</x-app-layout>
@@ -1,15 +0,0 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ __('Изменить правило автоматизации') }}</h2>
</x-slot>
<div class="py-12">
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<form method="POST" action="{{ route('automation-rules.update', $rule) }}">
@include('automation-rules._form')
</form>
</div>
</div>
</div>
</x-app-layout>
@@ -1,72 +0,0 @@
<x-app-layout>
<x-slot name="header">
<div class="flex justify-between items-center">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Правила автоматизации') }}
</h2>
@can('create', \App\Models\AutomationRule::class)
<x-primary-button onclick="window.location='{{ route('automation-rules.create') }}'">
{{ __('Добавить правило') }}
</x-primary-button>
@endcan
</div>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
@if (session('status'))
<div class="mb-4 text-sm text-green-600">{{ session('status') }}</div>
@endif
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b">
<th class="py-2">{{ __('Зона') }}</th>
<th class="py-2">{{ __('Условие') }}</th>
<th class="py-2">{{ __('Действие') }}</th>
<th class="py-2">{{ __('Активно') }}</th>
<th class="py-2"></th>
</tr>
</thead>
<tbody>
@forelse ($rules as $rule)
<tr class="border-b">
<td class="py-2">{{ $rule->zone->name }}</td>
<td class="py-2">
{{ $rule->conditionSourceDevice->name }}:
{{ $rule->condition_sensor_type }}
{{ $rule->condition_operator->value }}
{{ $rule->condition_value }}
</td>
<td class="py-2">
{{ $rule->targetDevice->name }}: {{ $rule->action_type }}
@if (!empty($rule->action_params))
({{ json_encode($rule->action_params) }})
@endif
</td>
<td class="py-2">{{ $rule->is_active ? __('да') : __('нет') }}</td>
<td class="py-2 text-right space-x-2">
@can('update', $rule)
<a href="{{ route('automation-rules.edit', $rule) }}" class="text-indigo-600 hover:underline">{{ __('Изменить') }}</a>
@endcan
@can('delete', $rule)
<form method="POST" action="{{ route('automation-rules.destroy', $rule) }}" class="inline" onsubmit="return confirm('{{ __('Удалить правило?') }}')">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:underline">{{ __('Удалить') }}</button>
</form>
@endcan
</td>
</tr>
@empty
<tr>
<td colspan="5" class="py-4 text-gray-500">{{ __('Правил пока нет.') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</x-app-layout>
@@ -1,3 +0,0 @@
<button {{ $attributes->merge(['type' => 'submit', 'class' => 'inline-flex items-center px-4 py-2 bg-red-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-red-500 active:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition ease-in-out duration-150']) }}>
{{ $slot }}
</button>
@@ -1 +0,0 @@
<a {{ $attributes->merge(['class' => 'block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 transition duration-150 ease-in-out']) }}>{{ $slot }}</a>
@@ -1,35 +0,0 @@
@props(['align' => 'right', 'width' => '48', 'contentClasses' => 'py-1 bg-white'])
@php
$alignmentClasses = match ($align) {
'left' => 'ltr:origin-top-left rtl:origin-top-right start-0',
'top' => 'origin-top',
default => 'ltr:origin-top-right rtl:origin-top-left end-0',
};
$width = match ($width) {
'48' => 'w-48',
default => $width,
};
@endphp
<div class="relative" x-data="{ open: false }" @click.outside="open = false" @close.stop="open = false">
<div @click="open = ! open">
{{ $trigger }}
</div>
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute z-50 mt-2 {{ $width }} rounded-md shadow-lg {{ $alignmentClasses }}"
style="display: none;"
@click="open = false">
<div class="rounded-md ring-1 ring-black ring-opacity-5 {{ $contentClasses }}">
{{ $content }}
</div>
</div>
</div>
@@ -1,78 +0,0 @@
@props([
'name',
'show' => false,
'maxWidth' => '2xl'
])
@php
$maxWidth = [
'sm' => 'sm:max-w-sm',
'md' => 'sm:max-w-md',
'lg' => 'sm:max-w-lg',
'xl' => 'sm:max-w-xl',
'2xl' => 'sm:max-w-2xl',
][$maxWidth];
@endphp
<div
x-data="{
show: @js($show),
focusables() {
// All focusable element types...
let selector = 'a, button, input:not([type=\'hidden\']), textarea, select, details, [tabindex]:not([tabindex=\'-1\'])'
return [...$el.querySelectorAll(selector)]
// All non-disabled elements...
.filter(el => ! el.hasAttribute('disabled'))
},
firstFocusable() { return this.focusables()[0] },
lastFocusable() { return this.focusables().slice(-1)[0] },
nextFocusable() { return this.focusables()[this.nextFocusableIndex()] || this.firstFocusable() },
prevFocusable() { return this.focusables()[this.prevFocusableIndex()] || this.lastFocusable() },
nextFocusableIndex() { return (this.focusables().indexOf(document.activeElement) + 1) % (this.focusables().length + 1) },
prevFocusableIndex() { return Math.max(0, this.focusables().indexOf(document.activeElement)) -1 },
}"
x-init="$watch('show', value => {
if (value) {
document.body.classList.add('overflow-y-hidden');
{{ $attributes->has('focusable') ? 'setTimeout(() => firstFocusable().focus(), 100)' : '' }}
} else {
document.body.classList.remove('overflow-y-hidden');
}
})"
x-on:open-modal.window="$event.detail == '{{ $name }}' ? show = true : null"
x-on:close-modal.window="$event.detail == '{{ $name }}' ? show = false : null"
x-on:close.stop="show = false"
x-on:keydown.escape.window="show = false"
x-on:keydown.tab.prevent="$event.shiftKey || nextFocusable().focus()"
x-on:keydown.shift.tab.prevent="prevFocusable().focus()"
x-show="show"
class="fixed inset-0 overflow-y-auto px-4 py-6 sm:px-0 z-50"
style="display: {{ $show ? 'block' : 'none' }};"
>
<div
x-show="show"
class="fixed inset-0 transform transition-all"
x-on:click="show = false"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
>
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
<div
x-show="show"
class="mb-6 bg-white rounded-lg overflow-hidden shadow-xl transform transition-all sm:w-full {{ $maxWidth }} sm:mx-auto"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
{{ $slot }}
</div>
</div>
@@ -1,11 +0,0 @@
@props(['active'])
@php
$classes = ($active ?? false)
? 'inline-flex items-center px-1 pt-1 border-b-2 border-indigo-400 text-sm font-medium leading-5 text-gray-900 focus:outline-none focus:border-indigo-700 transition duration-150 ease-in-out'
: 'inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium leading-5 text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none focus:text-gray-700 focus:border-gray-300 transition duration-150 ease-in-out';
@endphp
<a {{ $attributes->merge(['class' => $classes]) }}>
{{ $slot }}
</a>
@@ -1,11 +0,0 @@
@props(['active'])
@php
$classes = ($active ?? false)
? 'block w-full ps-3 pe-4 py-2 border-l-4 border-indigo-400 text-start text-base font-medium text-indigo-700 bg-indigo-50 focus:outline-none focus:text-indigo-800 focus:bg-indigo-100 focus:border-indigo-700 transition duration-150 ease-in-out'
: 'block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300 focus:outline-none focus:text-gray-800 focus:bg-gray-50 focus:border-gray-300 transition duration-150 ease-in-out';
@endphp
<a {{ $attributes->merge(['class' => $classes]) }}>
{{ $slot }}
</a>
@@ -1,3 +0,0 @@
<button {{ $attributes->merge(['type' => 'button', 'class' => 'inline-flex items-center px-4 py-2 bg-white border border-gray-300 rounded-md font-semibold text-xs text-gray-700 uppercase tracking-widest shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-25 transition ease-in-out duration-150']) }}>
{{ $slot }}
</button>
@@ -1,28 +0,0 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Dashboard') }}
</h2>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 grid grid-cols-1 sm:grid-cols-4 gap-6">
<a href="{{ route('zones.index') }}" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6 hover:shadow-md transition">
<div class="text-sm text-gray-500">{{ __('Зоны') }}</div>
<div class="text-3xl font-semibold text-gray-900">{{ $zonesCount }}</div>
</a>
<a href="{{ route('devices.index') }}" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6 hover:shadow-md transition">
<div class="text-sm text-gray-500">{{ __('Устройства') }}</div>
<div class="text-3xl font-semibold text-gray-900">{{ $devicesCount }}</div>
</a>
<a href="{{ route('devices.index') }}" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6 hover:shadow-md transition">
<div class="text-sm text-gray-500">{{ __('Онлайн') }}</div>
<div class="text-3xl font-semibold text-green-600">{{ $onlineDevicesCount }}</div>
</a>
<a href="{{ route('automation-rules.index') }}" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6 hover:shadow-md transition">
<div class="text-sm text-gray-500">{{ __('Активных правил') }}</div>
<div class="text-3xl font-semibold text-gray-900">{{ $activeRulesCount }}</div>
</a>
</div>
</div>
</x-app-layout>
@@ -1,56 +0,0 @@
@csrf
@isset($device)
@method('PUT')
@endisset
<div>
<x-input-label for="name" :value="__('Название')" />
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full"
:value="old('name', $device->name ?? '')" required autofocus />
<x-input-error :messages="$errors->get('name')" class="mt-2" />
</div>
<div class="mt-4">
<x-input-label for="zone_id" :value="__('Зона')" />
<select id="zone_id" name="zone_id" required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">
<option value="">{{ __('— выбрать —') }}</option>
@foreach ($zones as $zone)
<option value="{{ $zone->id }}" @selected(old('zone_id', $device->zone_id ?? '') == $zone->id)>{{ $zone->name }}</option>
@endforeach
</select>
<x-input-error :messages="$errors->get('zone_id')" class="mt-2" />
</div>
<div class="mt-4">
<x-input-label for="device_type_id" :value="__('Тип устройства')" />
<select id="device_type_id" name="device_type_id" required
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">
<option value="">{{ __('— выбрать —') }}</option>
@foreach ($deviceTypes as $deviceType)
<option value="{{ $deviceType->id }}" @selected(old('device_type_id', $device->device_type_id ?? '') == $deviceType->id)>
{{ $deviceType->code }} ({{ $deviceType->category->value }})
</option>
@endforeach
</select>
<x-input-error :messages="$errors->get('device_type_id')" class="mt-2" />
</div>
<div class="mt-4">
<x-input-label for="external_id" :value="__('External ID (идентификатор физического устройства)')" />
<x-text-input id="external_id" name="external_id" type="text" class="mt-1 block w-full font-mono"
:value="old('external_id', $device->external_id ?? '')" required />
<x-input-error :messages="$errors->get('external_id')" class="mt-2" />
</div>
<div class="mt-4">
<x-input-label for="protocol" :value="__('Протокол')" />
<x-text-input id="protocol" name="protocol" type="text" class="mt-1 block w-full"
:value="old('protocol', $device->protocol ?? 'mqtt')" required />
<x-input-error :messages="$errors->get('protocol')" class="mt-2" />
</div>
<div class="mt-4 flex items-center gap-4">
<x-primary-button>{{ __('Сохранить') }}</x-primary-button>
<a href="{{ route('devices.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('Отмена') }}</a>
</div>
@@ -1,15 +0,0 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ __('Новое устройство') }}</h2>
</x-slot>
<div class="py-12">
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<form method="POST" action="{{ route('devices.store') }}">
@include('devices._form')
</form>
</div>
</div>
</div>
</x-app-layout>
@@ -1,15 +0,0 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ __('Изменить устройство') }}</h2>
</x-slot>
<div class="py-12">
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<form method="POST" action="{{ route('devices.update', $device) }}">
@include('devices._form')
</form>
</div>
</div>
</div>
</x-app-layout>
@@ -1,77 +0,0 @@
<x-app-layout>
<x-slot name="header">
<div class="flex justify-between items-center">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Устройства') }}
</h2>
@can('create', \App\Models\Device::class)
<x-primary-button onclick="window.location='{{ route('devices.create') }}'">
{{ __('Добавить устройство') }}
</x-primary-button>
@endcan
</div>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
@if (session('status'))
<div class="mb-4 text-sm text-green-600">{{ session('status') }}</div>
@endif
@if (session('error'))
<div class="mb-4 text-sm text-red-600">{{ session('error') }}</div>
@endif
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b">
<th class="py-2">{{ __('Название') }}</th>
<th class="py-2">{{ __('Зона') }}</th>
<th class="py-2">{{ __('Тип') }}</th>
<th class="py-2">{{ __('Статус') }}</th>
<th class="py-2">{{ __('External ID') }}</th>
<th class="py-2"></th>
</tr>
</thead>
<tbody>
@forelse ($devices as $device)
@php $status = $statuses[$device->external_id] ?? 'unknown'; @endphp
<tr class="border-b">
<td class="py-2">
<a href="{{ route('devices.show', $device) }}" class="text-indigo-600 hover:underline">{{ $device->name }}</a>
</td>
<td class="py-2">{{ $device->zone->name }}</td>
<td class="py-2">{{ $device->deviceType->code }}</td>
<td class="py-2">
<span @class([
'inline-block px-2 py-0.5 rounded text-xs',
'bg-green-100 text-green-800' => $status === 'online',
'bg-gray-100 text-gray-600' => $status === 'offline',
'bg-yellow-100 text-yellow-800' => $status === 'unknown',
])>{{ $status }}</span>
</td>
<td class="py-2 font-mono text-xs">{{ $device->external_id }}</td>
<td class="py-2 text-right space-x-2">
@can('update', $device)
<a href="{{ route('devices.edit', $device) }}" class="text-indigo-600 hover:underline">{{ __('Изменить') }}</a>
@endcan
@can('delete', $device)
<form method="POST" action="{{ route('devices.destroy', $device) }}" class="inline" onsubmit="return confirm('{{ __('Удалить устройство?') }}')">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:underline">{{ __('Удалить') }}</button>
</form>
@endcan
</td>
</tr>
@empty
<tr>
<td colspan="6" class="py-4 text-gray-500">{{ __('Устройств пока нет.') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</x-app-layout>
@@ -1,105 +0,0 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ $device->name }}</h2>
</x-slot>
<div class="py-12">
<div class="max-w-4xl mx-auto sm:px-6 lg:px-8 space-y-6">
@if (session('status'))
<div class="text-sm text-green-600">{{ session('status') }}</div>
@endif
@if (session('error'))
<div class="text-sm text-red-600">{{ session('error') }}</div>
@endif
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<h3 class="font-semibold mb-4">{{ __('Живое состояние (Redis device shadow)') }}</h3>
<dl class="grid grid-cols-2 gap-4 text-sm">
<div>
<dt class="text-gray-500">{{ __('Статус') }}</dt>
<dd @class([
'inline-block px-2 py-0.5 rounded text-xs mt-1',
'bg-green-100 text-green-800' => $shadow['status'] === 'online',
'bg-gray-100 text-gray-600' => $shadow['status'] === 'offline',
'bg-yellow-100 text-yellow-800' => $shadow['status'] === 'unknown',
])>{{ $shadow['status'] }}</dd>
</div>
<div>
<dt class="text-gray-500">{{ __('Последняя активность') }}</dt>
<dd>{{ $shadow['last_seen']?->diffForHumans() ?? __('нет данных') }}</dd>
</div>
<div>
<dt class="text-gray-500">{{ __('Желаемое состояние (desired)') }}</dt>
<dd class="font-mono text-xs">{{ json_encode($shadow['desired_state']) }}</dd>
</div>
<div>
<dt class="text-gray-500">{{ __('Подтверждённое состояние (reported)') }}</dt>
<dd class="font-mono text-xs">{{ json_encode($shadow['reported_state']) }}</dd>
</div>
</dl>
</div>
@can('update', $device)
@php $capabilities = $device->deviceType->capabilities ?? []; @endphp
@if (!empty(array_intersect($capabilities, ['turn_on', 'turn_off', 'set_level'])))
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<h3 class="font-semibold mb-4">{{ __('Ручное управление') }}</h3>
<div class="flex items-center gap-4 flex-wrap">
@if (in_array('turn_on', $capabilities))
<form method="POST" action="{{ route('devices.turn-on', $device) }}">
@csrf
<x-primary-button>{{ __('Включить') }}</x-primary-button>
</form>
@endif
@if (in_array('turn_off', $capabilities))
<form method="POST" action="{{ route('devices.turn-off', $device) }}">
@csrf
<x-secondary-button>{{ __('Выключить') }}</x-secondary-button>
</form>
@endif
@if (in_array('set_level', $capabilities))
<form method="POST" action="{{ route('devices.set-level', $device) }}" class="flex items-center gap-2">
@csrf
<input type="number" step="any" name="level" required
class="border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm text-sm"
placeholder="{{ __('уровень') }}">
<x-primary-button>{{ __('Установить уровень') }}</x-primary-button>
</form>
@endif
</div>
</div>
@endif
@endcan
@if ($device->deviceType->category->value === 'sensor')
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<h3 class="font-semibold mb-4">{{ __('Последние показания (ClickHouse)') }}</h3>
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b">
<th class="py-2">{{ __('Тип') }}</th>
<th class="py-2">{{ __('Значение') }}</th>
<th class="py-2">{{ __('Время') }}</th>
</tr>
</thead>
<tbody>
@forelse ($telemetry as $row)
<tr class="border-b">
<td class="py-2">{{ $row['sensor_type'] }}</td>
<td class="py-2">{{ $row['value'] }}</td>
<td class="py-2">{{ $row['recorded_at'] }}</td>
</tr>
@empty
<tr>
<td colspan="3" class="py-4 text-gray-500">{{ __('Показаний пока нет.') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@endif
<a href="{{ route('devices.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('← Назад к устройствам') }}</a>
</div>
</div>
</x-app-layout>
@@ -1,118 +0,0 @@
<nav x-data="{ open: false }" class="bg-white border-b border-gray-100">
<!-- Primary Navigation Menu -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<!-- Logo -->
<div class="shrink-0 flex items-center">
<a href="{{ route('dashboard') }}">
<x-application-logo class="block h-9 w-auto fill-current text-gray-800" />
</a>
</div>
<!-- Navigation Links -->
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
{{ __('Dashboard') }}
</x-nav-link>
<x-nav-link :href="route('zones.index')" :active="request()->routeIs('zones.*')">
{{ __('Зоны') }}
</x-nav-link>
<x-nav-link :href="route('devices.index')" :active="request()->routeIs('devices.*')">
{{ __('Устройства') }}
</x-nav-link>
<x-nav-link :href="route('automation-rules.index')" :active="request()->routeIs('automation-rules.*')">
{{ __('Правила') }}
</x-nav-link>
</div>
</div>
<!-- Settings Dropdown -->
<div class="hidden sm:flex sm:items-center sm:ms-6">
<x-dropdown align="right" width="48">
<x-slot name="trigger">
<button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150">
<div>{{ Auth::user()->name }}</div>
<div class="ms-1">
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
</button>
</x-slot>
<x-slot name="content">
<x-dropdown-link :href="route('profile.edit')">
{{ __('Profile') }}
</x-dropdown-link>
<!-- Authentication -->
<form method="POST" action="{{ route('logout') }}">
@csrf
<x-dropdown-link :href="route('logout')"
onclick="event.preventDefault();
this.closest('form').submit();">
{{ __('Log Out') }}
</x-dropdown-link>
</form>
</x-slot>
</x-dropdown>
</div>
<!-- Hamburger -->
<div class="-me-2 flex items-center sm:hidden">
<button @click="open = ! open" class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 focus:text-gray-500 transition duration-150 ease-in-out">
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
<path :class="{'hidden': open, 'inline-flex': ! open }" class="inline-flex" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
<path :class="{'hidden': ! open, 'inline-flex': open }" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
<!-- Responsive Navigation Menu -->
<div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden">
<div class="pt-2 pb-3 space-y-1">
<x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
{{ __('Dashboard') }}
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('zones.index')" :active="request()->routeIs('zones.*')">
{{ __('Зоны') }}
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('devices.index')" :active="request()->routeIs('devices.*')">
{{ __('Устройства') }}
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('automation-rules.index')" :active="request()->routeIs('automation-rules.*')">
{{ __('Правила') }}
</x-responsive-nav-link>
</div>
<!-- Responsive Settings Options -->
<div class="pt-4 pb-1 border-t border-gray-200">
<div class="px-4">
<div class="font-medium text-base text-gray-800">{{ Auth::user()->name }}</div>
<div class="font-medium text-sm text-gray-500">{{ Auth::user()->email }}</div>
</div>
<div class="mt-3 space-y-1">
<x-responsive-nav-link :href="route('profile.edit')">
{{ __('Profile') }}
</x-responsive-nav-link>
<!-- Authentication -->
<form method="POST" action="{{ route('logout') }}">
@csrf
<x-responsive-nav-link :href="route('logout')"
onclick="event.preventDefault();
this.closest('form').submit();">
{{ __('Log Out') }}
</x-responsive-nav-link>
</form>
</div>
</div>
</div>
</nav>
@@ -1,29 +0,0 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Profile') }}
</h2>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
<div class="max-w-xl">
@include('profile.partials.update-profile-information-form')
</div>
</div>
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
<div class="max-w-xl">
@include('profile.partials.update-password-form')
</div>
</div>
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
<div class="max-w-xl">
@include('profile.partials.delete-user-form')
</div>
</div>
</div>
</div>
</x-app-layout>
@@ -1,55 +0,0 @@
<section class="space-y-6">
<header>
<h2 class="text-lg font-medium text-gray-900">
{{ __('Delete Account') }}
</h2>
<p class="mt-1 text-sm text-gray-600">
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.') }}
</p>
</header>
<x-danger-button
x-data=""
x-on:click.prevent="$dispatch('open-modal', 'confirm-user-deletion')"
>{{ __('Delete Account') }}</x-danger-button>
<x-modal name="confirm-user-deletion" :show="$errors->userDeletion->isNotEmpty()" focusable>
<form method="post" action="{{ route('profile.destroy') }}" class="p-6">
@csrf
@method('delete')
<h2 class="text-lg font-medium text-gray-900">
{{ __('Are you sure you want to delete your account?') }}
</h2>
<p class="mt-1 text-sm text-gray-600">
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }}
</p>
<div class="mt-6">
<x-input-label for="password" value="{{ __('Password') }}" class="sr-only" />
<x-text-input
id="password"
name="password"
type="password"
class="mt-1 block w-3/4"
placeholder="{{ __('Password') }}"
/>
<x-input-error :messages="$errors->userDeletion->get('password')" class="mt-2" />
</div>
<div class="mt-6 flex justify-end">
<x-secondary-button x-on:click="$dispatch('close')">
{{ __('Cancel') }}
</x-secondary-button>
<x-danger-button class="ms-3">
{{ __('Delete Account') }}
</x-danger-button>
</div>
</form>
</x-modal>
</section>
@@ -1,48 +0,0 @@
<section>
<header>
<h2 class="text-lg font-medium text-gray-900">
{{ __('Update Password') }}
</h2>
<p class="mt-1 text-sm text-gray-600">
{{ __('Ensure your account is using a long, random password to stay secure.') }}
</p>
</header>
<form method="post" action="{{ route('password.update') }}" class="mt-6 space-y-6">
@csrf
@method('put')
<div>
<x-input-label for="update_password_current_password" :value="__('Current Password')" />
<x-text-input id="update_password_current_password" name="current_password" type="password" class="mt-1 block w-full" autocomplete="current-password" />
<x-input-error :messages="$errors->updatePassword->get('current_password')" class="mt-2" />
</div>
<div>
<x-input-label for="update_password_password" :value="__('New Password')" />
<x-text-input id="update_password_password" name="password" type="password" class="mt-1 block w-full" autocomplete="new-password" />
<x-input-error :messages="$errors->updatePassword->get('password')" class="mt-2" />
</div>
<div>
<x-input-label for="update_password_password_confirmation" :value="__('Confirm Password')" />
<x-text-input id="update_password_password_confirmation" name="password_confirmation" type="password" class="mt-1 block w-full" autocomplete="new-password" />
<x-input-error :messages="$errors->updatePassword->get('password_confirmation')" class="mt-2" />
</div>
<div class="flex items-center gap-4">
<x-primary-button>{{ __('Save') }}</x-primary-button>
@if (session('status') === 'password-updated')
<p
x-data="{ show: true }"
x-show="show"
x-transition
x-init="setTimeout(() => show = false, 2000)"
class="text-sm text-gray-600"
>{{ __('Saved.') }}</p>
@endif
</div>
</form>
</section>
@@ -1,64 +0,0 @@
<section>
<header>
<h2 class="text-lg font-medium text-gray-900">
{{ __('Profile Information') }}
</h2>
<p class="mt-1 text-sm text-gray-600">
{{ __("Update your account's profile information and email address.") }}
</p>
</header>
<form id="send-verification" method="post" action="{{ route('verification.send') }}">
@csrf
</form>
<form method="post" action="{{ route('profile.update') }}" class="mt-6 space-y-6">
@csrf
@method('patch')
<div>
<x-input-label for="name" :value="__('Name')" />
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full" :value="old('name', $user->name)" required autofocus autocomplete="name" />
<x-input-error class="mt-2" :messages="$errors->get('name')" />
</div>
<div>
<x-input-label for="email" :value="__('Email')" />
<x-text-input id="email" name="email" type="email" class="mt-1 block w-full" :value="old('email', $user->email)" required autocomplete="username" />
<x-input-error class="mt-2" :messages="$errors->get('email')" />
@if ($user instanceof \Illuminate\Contracts\Auth\MustVerifyEmail && ! $user->hasVerifiedEmail())
<div>
<p class="text-sm mt-2 text-gray-800">
{{ __('Your email address is unverified.') }}
<button form="send-verification" class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
{{ __('Click here to re-send the verification email.') }}
</button>
</p>
@if (session('status') === 'verification-link-sent')
<p class="mt-2 font-medium text-sm text-green-600">
{{ __('A new verification link has been sent to your email address.') }}
</p>
@endif
</div>
@endif
</div>
<div class="flex items-center gap-4">
<x-primary-button>{{ __('Save') }}</x-primary-button>
@if (session('status') === 'profile-updated')
<p
x-data="{ show: true }"
x-show="show"
x-transition
x-init="setTimeout(() => show = false, 2000)"
class="text-sm text-gray-600"
>{{ __('Saved.') }}</p>
@endif
</div>
</form>
</section>
@@ -1,23 +0,0 @@
@csrf
@isset($zone)
@method('PUT')
@endisset
<div>
<x-input-label for="name" :value="__('Название')" />
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full"
:value="old('name', $zone->name ?? '')" required autofocus />
<x-input-error :messages="$errors->get('name')" class="mt-2" />
</div>
<div class="mt-4">
<x-input-label for="description" :value="__('Описание')" />
<textarea id="description" name="description" rows="3"
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm">{{ old('description', $zone->description ?? '') }}</textarea>
<x-input-error :messages="$errors->get('description')" class="mt-2" />
</div>
<div class="mt-4 flex items-center gap-4">
<x-primary-button>{{ __('Сохранить') }}</x-primary-button>
<a href="{{ route('zones.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('Отмена') }}</a>
</div>
@@ -1,15 +0,0 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ __('Новая зона') }}</h2>
</x-slot>
<div class="py-12">
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<form method="POST" action="{{ route('zones.store') }}">
@include('zones._form')
</form>
</div>
</div>
</div>
</x-app-layout>
@@ -1,15 +0,0 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ __('Изменить зону') }}</h2>
</x-slot>
<div class="py-12">
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
<form method="POST" action="{{ route('zones.update', $zone) }}">
@include('zones._form')
</form>
</div>
</div>
</div>
</x-app-layout>
@@ -1,60 +0,0 @@
<x-app-layout>
<x-slot name="header">
<div class="flex justify-between items-center">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Зоны') }}
</h2>
@can('create', \App\Models\Zone::class)
<x-primary-button onclick="window.location='{{ route('zones.create') }}'">
{{ __('Добавить зону') }}
</x-primary-button>
@endcan
</div>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
@if (session('status'))
<div class="mb-4 text-sm text-green-600">{{ session('status') }}</div>
@endif
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b">
<th class="py-2">{{ __('Название') }}</th>
<th class="py-2">{{ __('Описание') }}</th>
<th class="py-2">{{ __('Устройств') }}</th>
<th class="py-2"></th>
</tr>
</thead>
<tbody>
@forelse ($zones as $zone)
<tr class="border-b">
<td class="py-2">{{ $zone->name }}</td>
<td class="py-2 text-gray-500">{{ $zone->description }}</td>
<td class="py-2">{{ $zone->devices_count }}</td>
<td class="py-2 text-right space-x-2">
@can('update', $zone)
<a href="{{ route('zones.edit', $zone) }}" class="text-indigo-600 hover:underline">{{ __('Изменить') }}</a>
@endcan
@can('delete', $zone)
<form method="POST" action="{{ route('zones.destroy', $zone) }}" class="inline" onsubmit="return confirm('{{ __('Удалить зону?') }}')">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:underline">{{ __('Удалить') }}</button>
</form>
@endcan
</td>
</tr>
@empty
<tr>
<td colspan="4" class="py-4 text-gray-500">{{ __('Зон пока нет.') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</x-app-layout>
+2 -1
View File
@@ -9,6 +9,7 @@ use App\Models\Device;
use App\Models\Zone;
use App\Services\DeviceShadow;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
Route::get('/', function () {
return view('welcome');
@@ -20,7 +21,7 @@ Route::get('/dashboard', function (DeviceShadow $shadow) {
->filter(fn (string $status) => $status === 'online')
->count();
return view('dashboard', [
return Inertia::render('Dashboard', [
'zonesCount' => Zone::count(),
'devicesCount' => $devices->count(),
'onlineDevicesCount' => $onlineCount,
+1
View File
@@ -7,6 +7,7 @@ export default {
'./vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php',
'./storage/framework/views/*.php',
'./resources/views/**/*.blade.php',
'./resources/js/**/*.vue',
],
theme: {
@@ -12,6 +12,7 @@ use App\Services\ClickHouseClient;
use App\Services\DeviceControlClient;
use App\Services\DeviceShadow;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class DeviceControllerTest extends TestCase
@@ -123,7 +124,10 @@ class DeviceControllerTest extends TestCase
$response = $this->actingAs($owner)->get(route('devices.show', $device));
$response->assertOk();
$response->assertSee('online');
$response->assertSee('24.5');
$response->assertInertia(fn (Assert $page) => $page
->component('Devices/Show')
->where('device.status', 'online')
->where('telemetry.0.value', 24.5)
);
}
}
@@ -6,6 +6,7 @@ use App\Enums\UserRole;
use App\Models\User;
use App\Models\Zone;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class ZoneControllerTest extends TestCase
@@ -45,7 +46,10 @@ class ZoneControllerTest extends TestCase
$response = $this->actingAs($viewer)->get(route('zones.index'));
$response->assertOk();
$response->assertSee('Гроубокс');
$response->assertInertia(fn (Assert $page) => $page
->component('Zones/Index')
->where('zones.0.name', 'Гроубокс')
);
}
public function test_viewer_cannot_delete_zone(): void
+15
View File
@@ -1,11 +1,26 @@
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue';
import { fileURLToPath, URL } from 'node:url';
export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./resources/js', import.meta.url)),
},
},
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'],
refresh: true,
}),
vue({
template: {
transformAssetUrls: {
base: null,
includeAbsolute: false,
},
},
}),
],
});
@@ -59,7 +59,7 @@
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
"targets": [
{
"expr": "sum by (direction) (rate(device_control_health_transitions_total[5m]))",
"expr": "sum by (direction) (rate(device_health_transitions_total[5m]))",
"legendFormat": "{{direction}}",
"refId": "A"
}
+4
View File
@@ -13,3 +13,7 @@ scrape_configs:
- job_name: rule-engine-service
static_configs:
- targets: ["rule-engine-service:9102"]
- job_name: health-check-service
static_configs:
- targets: ["health-check-service:9103"]
+7 -5
View File
@@ -15,10 +15,10 @@
- Поддерживает паттерн Device Shadow в Redis: `desired_state` (чего хочет
пользователь) против `reported_state` (что подтвердило устройство), плюс
`status` и `last_seen`.
- Ведёт health-check горутиной-тикером: переводит устройство в `offline`,
когда `last_seen` превышает таймаут, и публикует событие в RabbitMQ
(`device.status_changed`) для будущего notification-service — как при
уходе в офлайн, так и при возврате online.
- Переводит устройство в `online` и публикует `device.status_changed` в
RabbitMQ при получении телеметрии/ack (см. «Архитектурные решения»).
Обратный переход, `offline` по таймауту `last_seen`, — зона
ответственности отдельного health-check-service (см. его README).
Не входит в зону ответственности: решение о том, *когда* отправлять команду
на основе показаний датчиков — это логика rule-engine-service. Этот сервис
@@ -54,7 +54,9 @@
`GET /metrics` на том же порту, что и команды (`DEVICE_CONTROL_HTTP_PORT`)
— не открывали отдельный порт ради этого. Счётчики/latency команд
(TurnOn/TurnOff/SetLevel по action+outcome), MQTT-событий (telemetry/ack),
переходов online/offline health-check.
переходов в online (`device_health_transitions_total{direction="online"}`
— та же метрика по имени, что публикует health-check-service для
`direction="offline"`, см. его README).
## Запуск
@@ -1,6 +1,8 @@
// Command device-control-service exposes a gRPC API for issuing device
// commands, dispatches them over MQTT, maintains the Device Shadow in
// Redis, and runs a health-check loop that flips stale devices offline.
// commands, dispatches them over MQTT, and maintains the Device Shadow in
// Redis. Offline detection lives in the separate health-check-service —
// this service only ever flips a device back to online, as a side effect
// of handling its telemetry/ack MQTT messages.
package main
import (
@@ -21,7 +23,6 @@ import (
devicecontrol "git.cactoz.su/cacto/home_automatization/proto/device_control"
"git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/config"
"git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/healthcheck"
"git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/httpapi"
"git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/metrics"
"git.cactoz.su/cacto/home_automatization/services/device-control-service/internal/mqttclient"
@@ -75,18 +76,6 @@ func run(logger *slog.Logger) error {
return fmt.Errorf("subscribe ack: %w", err)
}
checker := healthcheck.New(shadowStore, cfg.HealthCheckTimeout, cfg.HealthCheckInterval, func(deviceID string) {
metrics.HealthTransitionsTotal.WithLabelValues("offline").Inc()
pubCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := publisher.PublishStatusChanged(pubCtx, deviceID, shadow.StatusOffline); err != nil {
logger.Error("publish offline event failed", "device_id", deviceID, "error", err)
}
}, logger)
checker.Start()
defer checker.Stop()
srv := server.New(shadowStore, mqttClient, logger)
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.GRPCPort))
@@ -6,7 +6,6 @@ import (
"fmt"
"os"
"strconv"
"time"
)
type Config struct {
@@ -21,9 +20,6 @@ type Config struct {
RedisDB int
RabbitMQURL string
HealthCheckTimeout time.Duration
HealthCheckInterval time.Duration
}
func Load() (Config, error) {
@@ -52,12 +48,6 @@ func Load() (Config, error) {
if cfg.RedisDB, err = getEnvInt("REDIS_DB", 0); err != nil {
return Config{}, err
}
if cfg.HealthCheckTimeout, err = getEnvDuration("DEVICE_CONTROL_HEALTHCHECK_TIMEOUT", 60*time.Second); err != nil {
return Config{}, err
}
if cfg.HealthCheckInterval, err = getEnvDuration("DEVICE_CONTROL_HEALTHCHECK_INTERVAL", 15*time.Second); err != nil {
return Config{}, err
}
return cfg, nil
}
@@ -80,15 +70,3 @@ func getEnvInt(key string, fallback int) (int, error) {
}
return n, nil
}
func getEnvDuration(key string, fallback time.Duration) (time.Duration, error) {
v := os.Getenv(key)
if v == "" {
return fallback, nil
}
d, err := time.ParseDuration(v)
if err != nil {
return 0, fmt.Errorf("%s: %w", key, err)
}
return d, nil
}
@@ -25,8 +25,14 @@ var (
Help: "Total number of telemetry/ack messages received, by event type and outcome.",
}, []string{"event_type", "outcome"})
// Name matches health-check-service's own metric of the same purpose —
// this service only ever emits direction="online" (detected via MQTT
// telemetry/ack), health-check-service only ever emits "offline".
// Prometheus aggregates same-named metrics across scrape targets, so
// Grafana's `sum by (direction) (...)` panel works across both without
// a query change.
HealthTransitionsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "device_control_health_transitions_total",
Name: "device_health_transitions_total",
Help: "Total number of device online/offline transitions detected.",
}, []string{"direction"})
)
+12
View File
@@ -0,0 +1,12 @@
FROM golang:1.25-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/health-check-service ./cmd/health-check-service
FROM alpine:3.20
RUN adduser -D -u 10001 app
COPY --from=build /out/health-check-service /usr/local/bin/health-check-service
USER app
ENTRYPOINT ["/usr/local/bin/health-check-service"]
+47 -8
View File
@@ -1,12 +1,51 @@
# health-check-service (Go)
Статус: пока не реализован. Может начаться как горутина внутри
device-control-service и позже выделиться в отдельный контейнер — поэтому
с первого дня держим отдельную директорию, чтобы это разделение прошло
безболезненно.
Статус: реализован.
Изначально жил как горутина-тикер внутри device-control-service (см. его
README/git-историю) — выделен в отдельный сервис по мере роста проекта,
как и планировалось с самого начала.
Зона ответственности:
- Периодически (по тикеру) проверяет `last_seen` каждого устройства в Redis.
- Когда устройство превышает таймаут офлайна, переводит его `status` в
`offline` и публикует событие для notification-service.
- Хороший повод продемонстрировать горутины + graceful shutdown в Go.
- Периодически (по тикеру, `HEALTH_CHECK_INTERVAL`) обходит все известные
устройства из Redis-множества `devices:known` и проверяет `last_seen`
каждого.
- Когда устройство превышает таймаут офлайна (`HEALTH_CHECK_TIMEOUT`),
переводит его `status` в `offline` в том же Redis (Device Shadow
keyspace, который device-control-service читает/пишет) и публикует
`device.status_changed` в RabbitMQ.
Не входит в зону ответственности:
- **Детекция перехода в online.** Это происходит как побочный эффект
MQTT-подписки device-control-service на `devices/+/telemetry` и
`devices/+/ack` (там уже есть открытое соединение и подписка ради
reported_state) — заводить для этого отдельный сервис с собственной
MQTT-подпиской избыточно. health-check-service работает только "в одну
сторону": online → offline.
- Хранение/интерпретация телеметрии — это ingest-service.
- Знание о существовании устройств из PostgreSQL — этот сервис, как и
device-control-service, не подключается к Postgres: список устройств для
сканирования берётся из того же Redis-множества `devices:known`
(пополняется device-control-service).
## Метрики
`GET /metrics` на `HEALTH_CHECK_METRICS_PORT`. Счётчик
`device_health_transitions_total{direction="offline"}` — намеренно та же
метрика (по имени), что device-control-service публикует для
`direction="online"`: Prometheus агрегирует одноимённые метрики с разных
таргетов прозрачно, так что дашборд Grafana не пришлось переделывать под
разделение на два сервиса.
## Запуск
```bash
cd services/health-check-service
go test ./...
go build ./cmd/health-check-service
```
Конфигурация — через переменные окружения (секция health-check-service в
корневом `.env.example`). Не зависит от `proto/` — работает только с Redis
и RabbitMQ, поэтому в отличие от device-control-service/rule-engine-service
сборка Docker-образа идёт из собственного контекста (не корня репозитория).
@@ -0,0 +1,97 @@
// Command health-check-service periodically scans every known device in
// Redis and flips it to offline once its last_seen exceeds the configured
// timeout, publishing a device.status_changed event to RabbitMQ for each
// transition. It never marks a device online — that happens as a side
// effect of device-control-service's own MQTT telemetry/ack handling — this
// service only ever detects silence.
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/prometheus/client_golang/prometheus/promhttp"
"git.cactoz.su/cacto/home_automatization/services/health-check-service/internal/config"
"git.cactoz.su/cacto/home_automatization/services/health-check-service/internal/healthcheck"
"git.cactoz.su/cacto/home_automatization/services/health-check-service/internal/metrics"
"git.cactoz.su/cacto/home_automatization/services/health-check-service/internal/rabbitmq"
"git.cactoz.su/cacto/home_automatization/services/health-check-service/internal/shadow"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
if err := run(logger); err != nil {
logger.Error("health-check-service exited with error", "error", err)
os.Exit(1)
}
}
func run(logger *slog.Logger) error {
cfg, err := config.Load()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
store, err := shadow.Connect(ctx, cfg.RedisAddr, cfg.RedisPassword, cfg.RedisDB)
cancel()
if err != nil {
return err
}
defer store.Close()
publisher, err := rabbitmq.Connect(cfg.RabbitMQURL)
if err != nil {
return err
}
defer publisher.Close()
checker := healthcheck.New(store, cfg.HealthCheckTimeout, cfg.HealthCheckInterval, func(deviceID string) {
metrics.HealthTransitionsTotal.WithLabelValues("offline").Inc()
pubCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := publisher.PublishStatusChanged(pubCtx, deviceID, shadow.StatusOffline); err != nil {
logger.Error("publish offline event failed", "device_id", deviceID, "error", err)
}
}, logger)
checker.Start()
defer checker.Stop()
httpServer := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.MetricsPort),
Handler: promhttp.Handler(),
}
go func() {
logger.Info("metrics server started", "metrics_port", cfg.MetricsPort)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("metrics server stopped", "error", err)
}
}()
logger.Info("health-check-service started",
"timeout", cfg.HealthCheckTimeout, "interval", cfg.HealthCheckInterval)
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
logger.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
logger.Error("metrics server shutdown failed", "error", err)
}
return nil
}
+23
View File
@@ -0,0 +1,23 @@
module git.cactoz.su/cacto/home_automatization/services/health-check-service
go 1.25.0
require (
github.com/alicebob/miniredis/v2 v2.38.0
github.com/prometheus/client_golang v1.24.1
github.com/rabbitmq/amqp091-go v1.13.0
github.com/redis/go-redis/v9 v9.22.0
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/sys v0.47.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
+54
View File
@@ -0,0 +1,54 @@
github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw=
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/rabbitmq/amqp091-go v1.13.0 h1:L8NA1WtF76C6KA3LAoufjfLgbist/If1UQYcsOjtxXA=
github.com/rabbitmq/amqp091-go v1.13.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -0,0 +1,84 @@
// Package config loads health-check-service settings from environment
// variables, matching the names used in the repo-root .env.example.
package config
import (
"fmt"
"os"
"strconv"
"time"
)
type Config struct {
RedisAddr string
RedisPassword string
RedisDB int
RabbitMQURL string
HealthCheckTimeout time.Duration
HealthCheckInterval time.Duration
MetricsPort int
}
func Load() (Config, error) {
cfg := Config{
RedisAddr: fmt.Sprintf("%s:%s", getEnv("REDIS_HOST", "localhost"), getEnv("REDIS_PORT", "6379")),
RedisPassword: getEnv("REDIS_PASSWORD", ""),
RabbitMQURL: fmt.Sprintf("amqp://%s:%s@%s:%s/",
getEnv("RABBITMQ_USER", "guest"),
getEnv("RABBITMQ_PASSWORD", "guest"),
getEnv("RABBITMQ_HOST", "localhost"),
getEnv("RABBITMQ_PORT", "5672"),
),
}
var err error
if cfg.RedisDB, err = getEnvInt("REDIS_DB", 0); err != nil {
return Config{}, err
}
if cfg.HealthCheckTimeout, err = getEnvDuration("HEALTH_CHECK_TIMEOUT", 60*time.Second); err != nil {
return Config{}, err
}
if cfg.HealthCheckInterval, err = getEnvDuration("HEALTH_CHECK_INTERVAL", 15*time.Second); err != nil {
return Config{}, err
}
if cfg.MetricsPort, err = getEnvInt("HEALTH_CHECK_METRICS_PORT", 9103); err != nil {
return Config{}, err
}
return cfg, nil
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func getEnvInt(key string, fallback int) (int, error) {
v := os.Getenv(key)
if v == "" {
return fallback, nil
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, fmt.Errorf("%s: %w", key, err)
}
return n, nil
}
func getEnvDuration(key string, fallback time.Duration) (time.Duration, error) {
v := os.Getenv(key)
if v == "" {
return fallback, nil
}
d, err := time.ParseDuration(v)
if err != nil {
return 0, fmt.Errorf("%s: %w", key, err)
}
return d, nil
}
@@ -0,0 +1,21 @@
// Package metrics defines health-check-service's Prometheus metrics.
// Registered automatically (via promauto) into the default registry on
// import; served on /metrics.
//
// device_health_transitions_total is intentionally the same metric name
// device-control-service exposes for its "online" transitions (detected via
// MQTT touch) — this service only ever emits direction="offline". Prometheus
// aggregates same-named metrics across scrape targets/jobs transparently, so
// Grafana's `sum by (direction) (...)` panel keeps working across the split
// without a query change.
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var HealthTransitionsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "device_health_transitions_total",
Help: "Total number of device online/offline transitions detected.",
}, []string{"direction"})
@@ -0,0 +1,78 @@
// Package rabbitmq publishes device online/offline transitions for
// notification-service to consume. Delivery reliability matters more than
// latency here, so messages are persistent and the queue is durable.
package rabbitmq
import (
"context"
"encoding/json"
"fmt"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
const StatusChangedQueue = "device.status_changed"
type Publisher struct {
conn *amqp.Connection
ch *amqp.Channel
}
func Connect(url string) (*Publisher, error) {
conn, err := amqp.Dial(url)
if err != nil {
return nil, fmt.Errorf("dial rabbitmq: %w", err)
}
ch, err := conn.Channel()
if err != nil {
conn.Close()
return nil, fmt.Errorf("open channel: %w", err)
}
if _, err := ch.QueueDeclare(StatusChangedQueue, true, false, false, false, nil); err != nil {
ch.Close()
conn.Close()
return nil, fmt.Errorf("declare queue %s: %w", StatusChangedQueue, err)
}
return &Publisher{conn: conn, ch: ch}, nil
}
func (p *Publisher) Close() error {
if err := p.ch.Close(); err != nil {
p.conn.Close()
return err
}
return p.conn.Close()
}
type statusChangedEvent struct {
DeviceID string `json:"device_id"`
Status string `json:"status"`
At time.Time `json:"at"`
}
// PublishStatusChanged emits one event when a device transitions online or offline.
func (p *Publisher) PublishStatusChanged(ctx context.Context, deviceID, status string) error {
body, err := json.Marshal(statusChangedEvent{
DeviceID: deviceID,
Status: status,
At: time.Now(),
})
if err != nil {
return fmt.Errorf("marshal event: %w", err)
}
err = p.ch.PublishWithContext(ctx, "", StatusChangedQueue, false, false, amqp.Publishing{
ContentType: "application/json",
DeliveryMode: amqp.Persistent,
Timestamp: time.Now(),
Body: body,
})
if err != nil {
return fmt.Errorf("publish status change for device %s: %w", deviceID, err)
}
return nil
}
@@ -0,0 +1,91 @@
// Package shadow gives health-check-service read/offline-detection access
// to the Device Shadow keyspace in Redis. device-control-service owns the
// full Shadow API (desired/reported state patches, online-touch on
// telemetry/ack) — this is deliberately the narrow subset a periodic
// offline-scanner needs, kept as its own small copy rather than a shared
// module: the two services only overlap on reading/writing `status` and
// `last_seen`, and duplicating ~2 key-naming functions is cheaper than a
// fourth shared Go module for that.
package shadow
import (
"context"
"fmt"
"strconv"
"time"
"github.com/redis/go-redis/v9"
)
const knownDevicesKey = "devices:known"
func statusKey(deviceID string) string { return "device:" + deviceID + ":status" }
func lastSeenKey(deviceID string) string { return "device:" + deviceID + ":last_seen" }
const (
StatusOnline = "online"
StatusOffline = "offline"
)
type Store struct {
rdb *redis.Client
}
func New(rdb *redis.Client) *Store {
return &Store{rdb: rdb}
}
func Connect(ctx context.Context, addr, password string, db int) (*Store, error) {
rdb := redis.NewClient(&redis.Options{Addr: addr, Password: password, DB: db})
if err := rdb.Ping(ctx).Err(); err != nil {
return nil, fmt.Errorf("ping redis: %w", err)
}
return New(rdb), nil
}
func (s *Store) Close() error {
return s.rdb.Close()
}
// KnownDevices lists every device ID device-control-service has heard from.
func (s *Store) KnownDevices(ctx context.Context) ([]string, error) {
ids, err := s.rdb.SMembers(ctx, knownDevicesKey).Result()
if err != nil {
return nil, fmt.Errorf("list known devices: %w", err)
}
return ids, nil
}
// MarkOfflineIfStale flips deviceID to offline if it is currently online and
// its last_seen is older than timeout. It reports whether a change was made.
func (s *Store) MarkOfflineIfStale(ctx context.Context, deviceID string, timeout time.Duration) (changed bool, err error) {
status, err := s.rdb.Get(ctx, statusKey(deviceID)).Result()
if err == redis.Nil || status != StatusOnline {
return false, nil
}
if err != nil {
return false, fmt.Errorf("get status for %s: %w", deviceID, err)
}
lastSeenRaw, err := s.rdb.Get(ctx, lastSeenKey(deviceID)).Result()
if err != nil && err != redis.Nil {
return false, fmt.Errorf("get last_seen for %s: %w", deviceID, err)
}
var lastSeen time.Time
if lastSeenRaw != "" {
sec, err := strconv.ParseInt(lastSeenRaw, 10, 64)
if err != nil {
return false, fmt.Errorf("parse last_seen for %s: %w", deviceID, err)
}
lastSeen = time.Unix(sec, 0)
}
if time.Since(lastSeen) <= timeout {
return false, nil
}
if err := s.rdb.Set(ctx, statusKey(deviceID), StatusOffline, 0).Err(); err != nil {
return false, fmt.Errorf("set status offline for %s: %w", deviceID, err)
}
return true, nil
}
@@ -0,0 +1,93 @@
package shadow
import (
"context"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
)
func newTestStore(t *testing.T) *Store {
t.Helper()
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { rdb.Close() })
return New(rdb)
}
// markOnline seeds Redis the way device-control-service's Touch() would,
// without depending on that service's code.
func markOnline(t *testing.T, s *Store, deviceID string, lastSeen time.Time) {
t.Helper()
ctx := context.Background()
if err := s.rdb.SAdd(ctx, knownDevicesKey, deviceID).Err(); err != nil {
t.Fatalf("seed known device: %v", err)
}
if err := s.rdb.Set(ctx, statusKey(deviceID), StatusOnline, 0).Err(); err != nil {
t.Fatalf("seed status: %v", err)
}
if err := s.rdb.Set(ctx, lastSeenKey(deviceID), lastSeen.Unix(), 0).Err(); err != nil {
t.Fatalf("seed last_seen: %v", err)
}
}
func TestKnownDevices_ListsSeededDevices(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
markOnline(t, s, "d1", time.Now())
markOnline(t, s, "d2", time.Now())
got, err := s.KnownDevices(ctx)
if err != nil {
t.Fatalf("known devices: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d known devices, want 2 (%v)", len(got), got)
}
}
func TestMarkOfflineIfStale(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
markOnline(t, s, "d1", time.Now())
changed, err := s.MarkOfflineIfStale(ctx, "d1", time.Hour)
if err != nil {
t.Fatalf("mark offline (not stale): %v", err)
}
if changed {
t.Fatal("device just touched should not be considered stale")
}
changed, err = s.MarkOfflineIfStale(ctx, "d1", -time.Second) // any age counts as stale
if err != nil {
t.Fatalf("mark offline (stale): %v", err)
}
if !changed {
t.Fatal("expected status to flip to offline")
}
// Already offline: a second call should report no further change.
changed, err = s.MarkOfflineIfStale(ctx, "d1", -time.Second)
if err != nil {
t.Fatalf("mark offline (already offline): %v", err)
}
if changed {
t.Fatal("expected no change once already offline")
}
}
func TestMarkOfflineIfStale_UnknownDeviceIsNoop(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
changed, err := s.MarkOfflineIfStale(ctx, "ghost", time.Second)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if changed {
t.Fatal("unknown device should never be reported as changed")
}
}