Перейти к содержанию

Руководство разработчика

Практическое руководство для инженеров, работающих с кодовой базой OKTO. Как развернуть dev-окружение, как устроен код, как добавлять фичи, тестировать и отлаживать.

Аудитория: backend-разработчики (Kotlin/JVM), frontend-разработчики (React/TS), интеграторы. Связанные документы: ARCHITECTURE.ru.md, API_REFERENCE.ru.md, SERVER_MANAGEMENT.ru.md.


Содержание

  1. Быстрый старт
  2. Дерево репозитория
  3. Сборка и запуск
  4. Локальное dev-окружение
  5. Тестирование
  6. Отладка
  7. Стиль кода
  8. Как добавить новую REST-ручку
  9. Как добавить новую команду устройству
  10. Как добавить таблицу и миграцию
  11. Как добавить страницу в центральной консоли
  12. Работа с i18n
  13. Процесс релиза
  14. Частые ошибки и грабли

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 args factory-server/config/application.yaml.
  • EdgeService.main с program args edge-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/ (по мере готовности) или:

./scripts/e2e/full-flow.sh

Скрипт запускает stack, прогоняет логин → enrollment → bottle → sync → cloud → command dispatch → firmware upload + deploy.

Frontend

cd management-dashboard
npx tsc --noEmit                    # type-check
npm run lint
npm run build                       # smoke-билд

Puppeteer-скрипты для визуальной проверки — в management-dashboard/scripts/smoke/*.mjs.


6. Отладка

Backend

  • IntelliJ Debugger: поставьте breakpoint, запустите target Run/Debug.
  • Remote debug:
    java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 \
         -jar factory-server/build/libs/factory-server.jar config.yaml
    
    В IDEA: Remote JVM Debug → host+5005.
  • Логирование DEBUG: в application.yamllogging.level: DEBUG, или per-logger в logback.xml.
  • Трейсинг: включите observability.tracingEnabled: true и запустите Jaeger локально:
    docker run -d --name jaeger -p 4317:4317 -p 16686:16686 jaegertracing/all-in-one:latest
    
    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), без классов.
  • Стили — через sx prop 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.mdGET /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. Регистрация в роутере

// management-dashboard/src/App.tsx
<Route path="tags" element={<DeviceTagsPage />} />

4. Пункт меню

management-dashboard/src/ui/components/AppShell.tsx:

const navItems = [
  // ...
  { to: '/tags', label: t('nav.tags'), icon: <Tag size={16} /> },
];

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"
  }
}

В компоненте:

const { t } = useTranslation();
<Button>{t('device.commands.ping_printer')}</Button>

Интерполяция и плюрализация

"overview": {
  "devices_online_one": "{{count}} устройство в сети",
  "devices_online_few": "{{count}} устройства в сети",
  "devices_online_many": "{{count}} устройств в сети"
}
t('overview.devices_online', { count: 5 })
// → "5 устройств в сети"

Правила

  • Никогда не хардкодьте 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 — багфиксы.

Шаги

  1. Feature freeze → ветка release/1.3.0 от main.
  2. Обновить CHANGELOG.md — перенести entries из Unreleased в 1.3.0.
  3. Обновить версии:
  4. build.gradle.ktsversion = "1.3.0" на корне.
  5. management-dashboard/package.json"version": "1.3.0".
  6. Тег: git tag -a v1.3.0 -m "Release 1.3.0" + git push --tags.
  7. CI соберёт артефакты и опубликует в releases.okto.ru (и Docker Hub).
  8. Canary deploy на тест-стенд (10% устройств).
  9. Polling 24 часа: ошибки, крэши, метрики.
  10. Full rollout через OTA.
  11. 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.