Руководство разработчика¶
Практическое руководство для инженеров, работающих с кодовой базой OKTO. Как развернуть dev-окружение, как устроен код, как добавлять фичи, тестировать и отлаживать.
Аудитория: backend-разработчики (Kotlin/JVM), frontend-разработчики (React/TS), интеграторы. Связанные документы: ARCHITECTURE.ru.md, API_REFERENCE.ru.md, SERVER_MANAGEMENT.ru.md.
Содержание¶
- Быстрый старт
- Дерево репозитория
- Сборка и запуск
- Локальное dev-окружение
- Тестирование
- Отладка
- Стиль кода
- Как добавить новую REST-ручку
- Как добавить новую команду устройству
- Как добавить таблицу и миграцию
- Как добавить страницу в центральной консоли
- Работа с i18n
- Процесс релиза
- Частые ошибки и грабли
1. Быстрый старт¶
# 1. Склонируйте репозиторий
git clone git@github.com:okto-ru/okto-android-to-linux.git
cd okto-android-to-linux
# 2. Запустите PostgreSQL в Docker
docker run -d --rm --name okto-dev-pg \
-e POSTGRES_USER=okto -e POSTGRES_PASSWORD=okto -e POSTGRES_DB=okto_factory \
-p 5432:5432 postgres:16
# 3. Соберите все модули
export JAVA_HOME=$(/usr/libexec/java_home -v 17) # macOS
./gradlew build -x test
# 4. Запустите factory-server
./gradlew factory-server:run --args="factory-server/config/application.yaml"
# 5. В другом терминале — edge-service
./gradlew edge-service:run --args="edge-service/config/application.yaml"
# 6. В третьем — management-dashboard
cd management-dashboard && npm install && npm run dev
Откройте http://localhost:3001 → логин admin / admin123.
2. Дерево репозитория¶
Подробное описание — в ARCHITECTURE.ru.md §5. Кратко:
common/ — общие домены и контракты (kotlinx.serialization)
edge-service/ — граничный сервис
factory-server/ — центральный локальный сервер
management-dashboard/— React SPA (Центральная консоль)
operator-ui/ — React SPA (оператор)
packaging/ — systemd + sudoers + scripts
docker/ — Dockerfile + compose
docs/ — документация
Gradle multi-module, root build.gradle.kts применяет jacoco и общие конвенции.
3. Сборка и запуск¶
Backend¶
# Все модули
./gradlew build
# Только тесты
./gradlew test
# Без тестов
./gradlew build -x test
# JAR-артефакт
./gradlew factory-server:shadowJar
./gradlew edge-service:shadowJar
# Артефакты появятся в factory-server/build/libs/factory-server.jar и edge-service/build/libs/edge-service.jar
Frontend¶
cd management-dashboard
npm ci # чистая установка (как в CI)
npm run dev # dev-сервер на 3001 с hot-reload
npm run build # production bundle в dist/
npm run typecheck # только tsc --noEmit
npm run lint # ESLint
Запуск из CLI¶
# Factory server
java -jar factory-server/build/libs/factory-server.jar factory-server/config/application.yaml
# Edge service
java -jar edge-service/build/libs/edge-service.jar edge-service/config/application.yaml
4. Локальное dev-окружение¶
Минимальный стек для разработки¶
# 1. Postgres
docker run -d --rm --name okto-dev-pg -e POSTGRES_USER=okto \
-e POSTGRES_PASSWORD=okto -e POSTGRES_DB=okto_factory -p 5432:5432 postgres:16
# 2. Mock OKTO Cloud (для тестов sync)
python3 scripts/mock_cloud.py 19000 &
# 3. Factory server
./gradlew factory-server:run
# 4. Edge service
./gradlew edge-service:run
# 5. Dashboard
cd management-dashboard && npm run dev
Тест-конфиги¶
factory-server/config/application.yaml— дефолт сcloudServerUrl: http://localhost:19000.edge-service/config/application.yaml— дефолт сfactoryServer.host=localhost.
Seed-данные¶
# Логин как admin
JWT=$(curl -sX POST http://localhost:8081/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"admin123"}' | jq -r .data.token)
# Создать тестовое устройство
curl -X POST http://localhost:8081/api/v1/devices \
-H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \
-d '{"identifier":"edge-test-001","name":"Test Edge 001","connectionMode":"VIA_LOCAL_SERVER"}'
# Прогнать несколько бутылок через edge
for i in $(seq 1 10); do
curl -s -X POST http://localhost:8080/api/v1/bottles \
-H 'Content-Type: application/json' \
-d "{\"bottles\":[{\"identifier\":\"b-$i\",\"exciseDutyNumber\":\"01046500012345672112${i}0pl01\",\"productionLineId\":\"line-1\"}],\"deviceId\":\"edge-test-001\"}"
done
IDE¶
- IntelliJ IDEA с Kotlin plugin (обычно идёт встроенно).
- «Open» → корень проекта → IntelliJ подхватит Gradle-импорт.
- Run-конфигурации:
FactoryServer.mainс program argsfactory-server/config/application.yaml.EdgeService.mainс program argsedge-service/config/application.yaml.- Для фронта — VSCode / Cursor с ESLint + Prettier плагинами.
5. Тестирование¶
Unit и integration¶
./gradlew test # все тесты
./gradlew factory-server:test # только factory
./gradlew edge-service:test # только edge
./gradlew test --tests '*AuthServiceTest'
Тесты используют:
- JUnit 5 (
org.junit.jupiter.api.*). - MockK (
io.mockk) — для моков. - Testcontainers (PostgreSQL) — для integration-тестов
factory-server. - H2 in-memory — для lightweight тестов edge-service.
Coverage¶
./gradlew jacocoTestReport # отчёт в build/reports/jacoco/html/index.html
./gradlew jacocoAggregateReport # агрегированный
CI требует ≥ 70% coverage на ключевые сервисы (AuthService, CommandDispatchService, EdgeSyncService, CloudSyncService).
Manual end-to-end¶
Для ручного E2E — набор bash-скриптов в scripts/e2e/ (по мере готовности) или:
Скрипт запускает stack, прогоняет логин → enrollment → bottle → sync → cloud → command dispatch → firmware upload + deploy.
Frontend¶
Puppeteer-скрипты для визуальной проверки — в management-dashboard/scripts/smoke/*.mjs.
6. Отладка¶
Backend¶
- IntelliJ Debugger: поставьте breakpoint, запустите target
Run/Debug. - Remote debug: В IDEA: Remote JVM Debug → host+5005.
- Логирование DEBUG: в
application.yaml→logging.level: DEBUG, или per-logger вlogback.xml. - Трейсинг: включите
observability.tracingEnabled: trueи запустите Jaeger локально: UI — http://localhost:16686.
PostgreSQL¶
# Подключение
psql -h localhost -U okto -d okto_factory
# Полезные запросы
SELECT identifier, status, last_heartbeat FROM devices ORDER BY last_heartbeat DESC NULLS LAST;
SELECT type, status, count(*) FROM cloud_sync_queue GROUP BY type, status;
SELECT device_id, type, status, created_at FROM device_commands ORDER BY created_at DESC LIMIT 20;
Frontend¶
- Chrome DevTools → Network → фильтр
Fetch/XHR/WS. - React DevTools для инспекции компонент.
localStorage.debug=okto:*— опциональные debug-логи.- Для фиктивных состояний: в URL добавьте
?mock=1(если поддерживается в компоненте).
WebSocket-отладка¶
# Подключение к /ws/device вручную
websocat -n "wss://factory.okto.ru/ws/device?token=$DEVICE_JWT"
# Отправьте DeviceHelloMessage:
{"type":"hello","deviceId":"edge-test-001","version":"dev","bootTs":"2026-04-17T22:00:00Z"}
7. Стиль кода¶
Kotlin¶
- Форматтер: ktlint через Gradle-плагин (если есть) или IDEA default.
- Conventions:
- 4 пробела, max line length 120.
- Нет trailing commas в аргументах функций.
data classдля доменных моделей и DTO.sealed interfaceдля полиморфных иерархий (сериализация через classDiscriminator).- Nullable-типы
Type?вместо Optional/magic values. suspendв сервисных методах с IO; избегайтеrunBlockingв production-коде.- Константы —
const valна top-level или в companion. - Naming:
PascalCaseдля классов.camelCaseдля методов/полей.SCREAMING_SNAKE_CASEдля констант.- Имена интерфейсов — без префикса
I.
TypeScript¶
- Форматтер: Prettier (дефолт + 2-space indent).
- Линтер: ESLint (
@typescript-eslint/recommended). - Conventions:
- Только функциональные компоненты + хуки.
- Строгая типизация props —
interface, неtypeдля объектов. - API-клиенты — чистые функции (
async fn), без классов. - Стили — через
sxprop MUI или emotion в рамках токенов (ui/tokens.ts). - i18n — все user-facing строки через
useTranslation().t(...). - Никогда
anyбез// eslint-disable-next-line @typescript-eslint/no-explicit-any+ комментарий-почему.
Commits¶
Conventional Commits:
feat: add device group bulk command endpoint
fix(firmware): reject artifacts with mismatching sha256
refactor: extract JwtService from AuthService
docs: expand SERVER_MANAGEMENT with RBAC matrix
test: cover CommandDispatchService timeout path
chore: bump exposed to 0.50.0
8. Как добавить новую REST-ручку¶
Пример: GET /api/v1/devices/{id}/uptime¶
1. Определите DTO в common¶
// common/src/main/kotlin/ru/okto/common/api/ApiContracts.kt
@Serializable
data class DeviceUptime(
val deviceId: String,
val uptimeSeconds: Long,
val lastRestartAt: Instant?,
)
2. Добавьте репозиторный метод¶
// factory-server/src/main/kotlin/ru/okto/factory/persistence/DeviceRepository.kt
suspend fun getUptime(deviceId: String): DeviceUptime? = db.dbQuery {
val row = DevicesTable.select { DevicesTable.identifier eq deviceId }.singleOrNull() ?: return@dbQuery null
DeviceUptime(
deviceId = deviceId,
uptimeSeconds = Duration.between(row[DevicesTable.lastHeartbeat] ?: Instant.now(), Instant.now()).seconds,
lastRestartAt = row[DevicesTable.lastHeartbeat],
)
}
3. Роут¶
// factory-server/src/main/kotlin/ru/okto/factory/api/Routes.kt
route("/devices") {
get("/{id}/uptime") {
val id = call.parameters["id"] ?: return@get call.respondBadRequest("id required")
val actor = call.authenticatedUser(authService) ?: return@get call.respondUnauthorized()
actor.requireRole(UserRole.VIEWER)
val uptime = deviceRepo.getUptime(id)
?: return@get call.respond(HttpStatusCode.NotFound, ApiResponse.error("DEVICE_NOT_FOUND"))
call.respond(ApiResponse.ok(uptime))
}
}
4. Тест¶
@Test
fun `GET uptime returns 200 for known device`() = testApplication {
application { configureApplication() }
val token = loginAndGetToken()
val response = client.get("/api/v1/devices/edge-1/uptime") {
header(HttpHeaders.Authorization, "Bearer $token")
}
assertEquals(HttpStatusCode.OK, response.status)
val body: ApiResponse<DeviceUptime> = Json.decodeFromString(response.bodyAsText())
assertEquals("edge-1", body.data?.deviceId)
}
5. Frontend-клиент¶
// management-dashboard/src/api/devices.ts
export interface DeviceUptime { deviceId: string; uptimeSeconds: number; lastRestartAt: string | null }
export async function getDeviceUptime(id: string): Promise<DeviceUptime> {
const res = await api.get(`/devices/${id}/uptime`);
return res.data.data as DeviceUptime;
}
6. Документация¶
Добавьте секцию в API_REFERENCE.ru.md → GET /devices/{id}/uptime.
9. Как добавить новую команду устройству¶
Пример: ping_printer — проверка связи с принтером¶
1. Определите команду в common¶
// common/src/main/kotlin/ru/okto/common/api/DeviceControl.kt
@Serializable
@SerialName("ping_printer")
data class PingPrinterCmd(
override val id: String,
val printerId: String,
val timeoutMs: Long = 2000,
) : DeviceCommand
2. Обработчик на edge¶
// edge-service/src/main/kotlin/ru/okto/edge/service/CommandHandlerService.kt
suspend fun handle(cmd: DeviceCommand): CommandResult = when (cmd) {
// ... existing branches ...
is PingPrinterCmd -> handlePingPrinter(cmd)
else -> CommandResult(cmd.id, false, error = "Unknown command type: ${cmd::class.simpleName}")
}
private suspend fun handlePingPrinter(cmd: PingPrinterCmd): CommandResult {
val printer = printerManager.get(cmd.printerId)
?: return CommandResult(cmd.id, false, error = "Printer not found: ${cmd.printerId}")
val latency = withTimeoutOrNull(cmd.timeoutMs) { printer.ping() }
?: return CommandResult(cmd.id, false, error = "Printer ping timeout")
return CommandResult(cmd.id, true, output = "Printer responded in ${latency}ms", data = mapOf("latencyMs" to latency.toString()))
}
3. RBAC на сервере¶
В ServerManagementRoutes.kt при dispatch проверьте, что роль ≥ OPERATOR (в requireRole таблице):
val cmd = req.command
val minRole = when (cmd) {
is RebootOsCmd, is ShutdownOsCmd, is DisableDeviceCmd -> UserRole.ADMIN
is RestartServiceCmd, is ClearQueueCmd, is UpdateFirmwareCmd, is PushConfigCmd -> UserRole.MANAGER
is PingPrinterCmd, is ForceSyncCmd, is PullLogsCmd, is EnableDeviceCmd -> UserRole.OPERATOR
else -> UserRole.MANAGER
}
actor.requireRole(minRole)
4. Тесты на edge¶
@Test
fun `ping_printer succeeds when printer responds fast`() = runBlocking {
val printer = mockk<Printer>()
coEvery { printer.ping() } returns 45L
val manager = mockk<PrinterManager>()
every { manager.get("p1") } returns printer
val svc = CommandHandlerService(printerManager = manager, /*...*/)
val result = svc.handle(PingPrinterCmd(id = "c1", printerId = "p1"))
assertTrue(result.success)
assertEquals("45", result.data?.get("latencyMs"))
}
5. UI¶
В management-dashboard/src/pages/DeviceDetail.tsx → «Команды» → добавьте кнопку:
<MenuItem onClick={() => dispatchCommand(device.identifier, { type: 'ping_printer', id: newCommandId(), printerId: 'videojet-1' })}>
{t('device.commands.ping_printer')}
</MenuItem>
И добавьте i18n-ключ device.commands.ping_printer в ru.json + en.json.
6. Документация¶
Добавьте строку в таблицу SERVER_MANAGEMENT.ru.md §4 Каталог команд.
10. Как добавить таблицу и миграцию¶
Пример: таблица device_tags (многие-ко-многим устройства ↔ теги)¶
1. Определение в Exposed¶
// factory-server/src/main/kotlin/ru/okto/factory/persistence/Tables.kt
object DeviceTagsTable : Table("device_tags") {
val deviceId = varchar("device_id", 255).references(DevicesTable.identifier)
val tag = varchar("tag", 100)
val addedAt = timestamp("added_at")
override val primaryKey = PrimaryKey(deviceId, tag)
init {
index(false, tag)
}
}
2. Зарегистрируйте в Database.init()¶
// factory-server/src/main/kotlin/ru/okto/factory/persistence/Database.kt
SchemaUtils.create(
// ... existing tables ...
DeviceTagsTable,
)
3. Flyway-миграция (для продакшна)¶
factory-server/src/main/resources/db/migration/V15__add_device_tags.sql:
CREATE TABLE device_tags (
device_id VARCHAR(255) NOT NULL REFERENCES devices(identifier) ON DELETE CASCADE,
tag VARCHAR(100) NOT NULL,
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (device_id, tag)
);
CREATE INDEX idx_device_tags_tag ON device_tags(tag);
4. Репозитарий¶
class DeviceTagRepository(private val db: DatabaseManager) {
suspend fun addTag(deviceId: String, tag: String) = db.dbQuery {
DeviceTagsTable.insertIgnore {
it[DeviceTagsTable.deviceId] = deviceId
it[DeviceTagsTable.tag] = tag
it[addedAt] = Instant.now()
}
}
suspend fun removeTag(deviceId: String, tag: String) = db.dbQuery {
DeviceTagsTable.deleteWhere { (DeviceTagsTable.deviceId eq deviceId) and (DeviceTagsTable.tag eq tag) }
}
suspend fun listTags(deviceId: String): List<String> = db.dbQuery {
DeviceTagsTable.select { DeviceTagsTable.deviceId eq deviceId }.map { it[DeviceTagsTable.tag] }
}
suspend fun devicesWithTag(tag: String): List<String> = db.dbQuery {
DeviceTagsTable.select { DeviceTagsTable.tag eq tag }.map { it[DeviceTagsTable.deviceId] }
}
}
5. REST + DI + тесты¶
Аналогично предыдущему разделу.
11. Как добавить страницу в центральной консоли¶
Пример: страница «Теги устройств»¶
1. Создайте компонент¶
management-dashboard/src/pages/DeviceTags.tsx:
import { Box } from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { PageHeader } from '../ui/components/PageHeader';
import { Card } from '../ui/components/Card';
import { EmptyState } from '../ui/components/EmptyState';
import { listTags } from '../api/tags';
export function DeviceTagsPage() {
const { t } = useTranslation();
const { data, isLoading } = useQuery({ queryKey: ['tags'], queryFn: listTags });
return (
<>
<PageHeader eyebrow={t('nav.groups.manage')} title={t('tags.title')} subtitle={t('tags.subtitle')} />
<Card>
{isLoading ? <Box>Loading…</Box> :
(data?.length ?? 0) === 0 ? <EmptyState illustration="groups" title={t('tags.empty')} /> :
data?.map(t => <Box key={t}>{t}</Box>)}
</Card>
</>
);
}
2. API-клиент¶
// management-dashboard/src/api/tags.ts
import { api } from './client';
export async function listTags(): Promise<string[]> {
const res = await api.get('/device-tags');
return res.data.data ?? [];
}
3. Регистрация в роутере¶
4. Пункт меню¶
management-dashboard/src/ui/components/AppShell.tsx:
5. i18n¶
// ru.json
"nav": { "tags": "Теги" },
"tags": {
"title": "Теги устройств",
"subtitle": "Логические метки для фильтрации и группировки",
"empty": "Пока нет тегов"
}
12. Работа с i18n¶
Источник истины¶
management-dashboard/src/i18n/locales/ru.json— русский (по умолчанию).management-dashboard/src/i18n/locales/en.json— английский (fallback).
Добавление ключа¶
// ru.json
"device": {
"commands": {
"ping_printer": "Проверить принтер"
}
}
// en.json
"device": {
"commands": {
"ping_printer": "Ping printer"
}
}
В компоненте:
Интерполяция и плюрализация¶
"overview": {
"devices_online_one": "{{count}} устройство в сети",
"devices_online_few": "{{count}} устройства в сети",
"devices_online_many": "{{count}} устройств в сети"
}
Правила¶
- Никогда не хардкодьте user-facing текст в JSX — только через
t(). - Плюрализация русская — используйте
_one/_few/_many. - При добавлении новой страницы создавайте вложенный namespace (
tags.*, а неtagsTitle). - Держите параллельность русского и английского — каждый ключ должен быть в обоих файлах.
13. Процесс релиза¶
Semantic versioning¶
MAJOR.MINOR.PATCH(1.2.3).- MAJOR — breaking changes в API или схеме БД.
- MINOR — новые фичи, обратно совместимы.
- PATCH — багфиксы.
Шаги¶
- Feature freeze → ветка
release/1.3.0отmain. - Обновить
CHANGELOG.md— перенести entries изUnreleasedв1.3.0. - Обновить версии:
build.gradle.kts→version = "1.3.0"на корне.management-dashboard/package.json→"version": "1.3.0".- Тег:
git tag -a v1.3.0 -m "Release 1.3.0"+git push --tags. - CI соберёт артефакты и опубликует в releases.okto.ru (и Docker Hub).
- Canary deploy на тест-стенд (10% устройств).
- Polling 24 часа: ошибки, крэши, метрики.
- Full rollout через OTA.
- Post-mortem при необходимости.
Checklist PR¶
- Тесты зелёные (
./gradlew test+npm run typecheck+npm run lint). - Coverage не упал (JaCoCo).
- Обновлена документация (ARCHITECTURE / SERVER_MANAGEMENT / API_REFERENCE / CHANGELOG).
- Миграция безопасна (rollback-скрипт при необходимости).
- Breaking changes явно упомянуты в PR-description.
- i18n-ключи добавлены в
ru.jsonиen.json. - Скриншоты UI-изменений.
14. Частые ошибки и грабли¶
Backend¶
- Циклическая зависимость Koin при инъекции
OfflineQueueService ↔ BottleService: используйте late-binding через setter (BottleService.offlineQueueService = ...вstartBackgroundServices()). - Блокирующий вызов в корутине: заворачивайте в
withContext(Dispatchers.IO). Exposed-операции уже заворачиваютсяdbQuery, но прямойThread.sleepв hot path — нет. - Sealed-иерархия с
classDiscriminator: убедитесь, что JSON-конфиг Ktor имеетclassDiscriminator = "type", иначе сериализация сломается. - Таймзона timestamp-ов: Exposed по умолчанию UTC. Не используйте
LocalDateTime, толькоInstant/OffsetDateTime. - Транзакция внутри транзакции: Exposed создаст savepoint, но лучше — один
dbQueryна логическое действие.
Frontend¶
borderRadius: 16даёт 128px блоб: MUI умножает наtheme.shape.borderRadius(у нас = 1, но если не указан — будет 8). Используйте'16px'или`${radius.xl}px`в sx.- TanStack Query не реагирует на WS события: вызывайте
queryClient.invalidateQueries(['devices'])в WS-handler для принудительного refetch. - CORS при локальной разработке: vite dev-server проксирует
/api/*наlocalhost:8081черезvite.config.ts. Проверьтеserver.proxyесли получаете CORS-ошибки. - JWT истёк: axios-interceptor должен ловить 401 и редиректить на
/login+clearAuth().
Сеть¶
- Device JWT в query-string попал в access.log: скройте
?token=…в log_format reverse-proxy (см. DEPLOYMENT.ru.md §7). - WebSocket отваливается через 60 с: увеличьте
proxy_read_timeoutдо 3600s в nginx илиread_timeoutв Caddy. - SHA-256 mismatch при OTA: reverse-proxy сжимает artifact (gzip/br). Отключите
proxy_bufferingиgzipдля/api/v1/firmware/releases/*/artifact.
Обновлено: апрель 2026. Связанные документы: ARCHITECTURE.ru.md, API_REFERENCE.ru.md, OPERATIONS.ru.md.