Device Operations Workspace Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Ship the owner/admin telemetry-first device operations workspace at /devices/operations, backed by farm-scoped reads and the existing device control write contract.

Architecture: Implement this as a dedicated vertical slice under the devices domain. Backend gets a small DeviceOperationsController + DeviceOperationsService that expose GET /api/v1/devices/operations/farms and GET /api/v1/devices/operations/workspace, while control and telemetry detail reuse the existing /devices/:id/control, /devices/:id/telemetry, and latest telemetry APIs. Frontend adds a dedicated page, navigation entries for owner/admin, and local workspace state for farm selection, pinned telemetry sources, and the telemetry/control drawer without persisting workspace state server-side.

Tech Stack: NestJS, TypeORM, Jest, React 19, TanStack Query v5, Ant Design, Vitest, Testing Library, Bun, Biome

Important context: The current worktree already contains interrupted test-only edits in frontend/src/navigation/nav-resolver.spec.ts and frontend/src/pages/devices/DeviceOperationsPage.spec.tsx. Do not revert them blindly. Either adapt them to this plan or supersede them intentionally during execution.


File Structure

Create

  • backend/src/devices/controllers/device-operations.controller.ts
    Dedicated backend route surface for workspace farm access and farm-scoped workspace reads.
  • backend/src/devices/controllers/device-operations.controller.spec.ts
    Controller delegation and guard-facing contract tests.
  • backend/src/devices/device-operations.service.ts
    Farm access resolution, farm-scoped device query, alert-count aggregation, and workspace DTO mapping.
  • backend/src/devices/device-operations.service.spec.ts
    Authorization, scoping, controllable/telemetry flag mapping, and alert-count coverage.
  • backend/src/devices/dto/device-operations.dto.ts
    Query DTOs and response DTOs for farms and workspace rows.
  • frontend/src/types/device-operations.types.ts
    Shared frontend types for workspace farms, rows, filters, and API responses.
  • frontend/src/services/device-operations.service.ts
    Frontend client for /devices/operations/farms and /devices/operations/workspace.
  • frontend/src/hooks/useDeviceOperations.ts
    TanStack Query hooks for farm access and workspace dataset loading.
  • frontend/src/pages/devices/DeviceOperationsPage.tsx
    The new owner/admin operations workspace page.

Modify

  • backend/src/devices/devices.module.ts
    Register the new controller, service, and DTO provider dependencies.
  • frontend/src/App.tsx
    Add the /devices/operations route behind owner/admin + device-read access.
  • frontend/src/navigation/menus/admin.menu.tsx
    Add Vận hành thiết bị beside the existing device entries.
  • frontend/src/navigation/menus/owner.menu.tsx
    Add the same workspace entry for owners.
  • frontend/src/navigation/nav-resolver.spec.ts
    Lock navigation exposure for owner/admin.
  • frontend/src/App.redirect.spec.tsx
    Add an App-level route regression test for /devices/operations.
  • frontend/src/pages/devices/DeviceOperationsPage.spec.tsx
    Verify farm auto-select, multi-farm empty state, farm-switch reset, telemetry pinning, and control permission behavior.

Reuse Without Modifying First

  • frontend/src/components/devices/GatewayControlPanel.tsx
    Reuse as the phase 1 control surface inside the control drawer.
  • frontend/src/components/devices/TelemetryChart.tsx
    Reuse for short-range telemetry visualization.
  • frontend/src/services/device.service.ts
    Reuse existing latest telemetry, telemetry history, and control write methods.
  • backend/src/devices/controllers/devices.controller.ts
    Keep the current POST /devices/:id/control contract unchanged.

Task 1: Add Backend Device Operations Controller Surface

Files:

  • Create: backend/src/devices/controllers/device-operations.controller.ts

  • Create: backend/src/devices/controllers/device-operations.controller.spec.ts

  • Create: backend/src/devices/dto/device-operations.dto.ts

  • Modify: backend/src/devices/devices.module.ts

  • Step 1: Write the failing controller tests

Create backend/src/devices/controllers/device-operations.controller.spec.ts with the route contract locked before implementation:

import { Test } from '@nestjs/testing';
import type { CurrentUserData } from '@/auth/decorators/current-user.decorator';
import { DeviceOperationsController } from '@/devices/controllers/device-operations.controller';
import { DeviceOperationsService } from '@/devices/device-operations.service';

const currentUser: CurrentUserData = {
  userId: 'user-1',
  organizationId: 'org-1',
  role: 'ADMIN',
  roles: ['ADMIN'],
  permissions: ['device:read:all', 'device:control:all'],
};

describe('DeviceOperationsController', () => {
  let controller: DeviceOperationsController;
  let service: jest.Mocked<DeviceOperationsService>;

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      controllers: [DeviceOperationsController],
      providers: [
        {
          provide: DeviceOperationsService,
          useValue: {
            listAccessibleFarms: jest.fn(),
            getWorkspace: jest.fn(),
          },
        },
      ],
    }).compile();

    controller = moduleRef.get(DeviceOperationsController);
    service = moduleRef.get(DeviceOperationsService);
  });

  it('delegates farm access reads to the device operations service', async () => {
    service.listAccessibleFarms.mockResolvedValue({
      data: [{ id: 'farm-1', name: 'Trại A', code: 'TRAI-A' }],
    });

    await expect(controller.listAccessibleFarms(currentUser)).resolves.toEqual({
      data: [{ id: 'farm-1', name: 'Trại A', code: 'TRAI-A' }],
    });
    expect(service.listAccessibleFarms).toHaveBeenCalledWith(currentUser);
  });

  it('delegates workspace reads with farmId to the service', async () => {
    service.getWorkspace.mockResolvedValue({
      data: [],
      meta: { total: 0, page: 1, limit: 20, totalPages: 0 },
    });

    await expect(
      controller.getWorkspace(currentUser, {
        farmId: 'farm-1',
        page: 1,
        limit: 20,
      }),
    ).resolves.toEqual({
      data: [],
      meta: { total: 0, page: 1, limit: 20, totalPages: 0 },
    });

    expect(service.getWorkspace).toHaveBeenCalledWith(currentUser, {
      farmId: 'farm-1',
      page: 1,
      limit: 20,
    });
  });
});
  • Step 2: Run the controller test to verify it fails

Run: cd backend && bun test -- src/devices/controllers/device-operations.controller.spec.ts

Expected: FAIL because the controller and service do not exist yet.

  • Step 3: Implement the DTOs and controller

Create backend/src/devices/dto/device-operations.dto.ts:

import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsBooleanString, IsEnum, IsOptional, IsString, IsUUID, Min } from 'class-validator';

export class QueryDeviceOperationsWorkspaceDto {
  @ApiProperty({ example: 'farm-uuid' })
  @IsUUID()
  farmId: string;

  @ApiPropertyOptional({ example: 1 })
  @IsOptional()
  @Type(() => Number)
  @Min(1)
  page?: number;

  @ApiPropertyOptional({ example: 20 })
  @IsOptional()
  @Type(() => Number)
  @Min(1)
  limit?: number;

  @ApiPropertyOptional({ example: 'GW-001' })
  @IsOptional()
  @IsString()
  search?: string;

  @ApiPropertyOptional({ enum: ['online', 'offline', 'error'] })
  @IsOptional()
  @IsEnum(['online', 'offline', 'error'])
  status?: 'online' | 'offline' | 'error';

  @ApiPropertyOptional({ enum: ['sensor', 'gateway', 'inverter', 'feeder'] })
  @IsOptional()
  @IsEnum(['sensor', 'gateway', 'inverter', 'feeder'])
  type?: 'sensor' | 'gateway' | 'inverter' | 'feeder';

  @ApiPropertyOptional({ example: 'pond-uuid' })
  @IsOptional()
  @IsUUID()
  pondId?: string;

  @ApiPropertyOptional({ example: 'true' })
  @IsOptional()
  @IsBooleanString()
  controllableOnly?: string;
}

export class DeviceOperationsFarmDto {
  @ApiProperty()
  id: string;

  @ApiProperty()
  name: string;

  @ApiPropertyOptional()
  code?: string | null;
}

export class DeviceOperationsWorkspaceRowDto {
  @ApiProperty()
  id: string;

  @ApiProperty()
  name: string | null;

  @ApiProperty()
  serialNumber: string;

  @ApiProperty()
  type: 'sensor' | 'gateway' | 'inverter' | 'feeder';

  @ApiPropertyOptional()
  pond?: { id: string; name: string; code: string } | null;

  @ApiProperty()
  status: 'online' | 'offline' | 'error';

  @ApiPropertyOptional()
  lastSeenAt?: Date | null;

  @ApiProperty()
  activeAlertCount: number;

  @ApiProperty()
  telemetrySupport: boolean;

  @ApiProperty()
  controllable: boolean;

  @ApiProperty()
  controlCapabilitySummary: string | null;
}

Create backend/src/devices/controllers/device-operations.controller.ts:

import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CurrentUser, type CurrentUserData } from '@/auth/decorators/current-user.decorator';
import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard';
import { RequirePermissions } from '@/common/decorators/permissions.decorator';
import { PermissionsGuard } from '@/common/guards/permissions.guard';
import { DeviceOperationsService } from '@/devices/device-operations.service';
import { QueryDeviceOperationsWorkspaceDto } from '@/devices/dto/device-operations.dto';

@ApiTags('Device Operations')
@UseGuards(JwtAuthGuard, PermissionsGuard)
@ApiBearerAuth()
@Controller('devices/operations')
export class DeviceOperationsController {
  constructor(private readonly service: DeviceOperationsService) {}

  @Get('farms')
  @ApiOperation({ summary: 'List accessible farms for the device operations workspace' })
  @ApiResponse({ status: 200, description: 'Accessible farms list' })
  @RequirePermissions('device:read:all', 'device:read:own', 'device:read:assigned')
  listAccessibleFarms(@CurrentUser() currentUser: CurrentUserData) {
    return this.service.listAccessibleFarms(currentUser);
  }

  @Get('workspace')
  @ApiOperation({ summary: 'List farm-scoped devices for the device operations workspace' })
  @ApiResponse({ status: 200, description: 'Workspace device dataset' })
  @RequirePermissions('device:read:all', 'device:read:own', 'device:read:assigned')
  getWorkspace(
    @CurrentUser() currentUser: CurrentUserData,
    @Query() query: QueryDeviceOperationsWorkspaceDto,
  ) {
    return this.service.getWorkspace(currentUser, query);
  }
}

Update backend/src/devices/devices.module.ts:

import { DeviceOperationsController } from './controllers/device-operations.controller';
import { DeviceOperationsService } from './device-operations.service';

controllers: [
  DevicesController,
  DeviceOperationsController,
  SystemDeviceInventoryController,
  DeviceProfilesController,
  DeviceTypesController,
  SystemDeviceEndpointsController,
],
providers: [
  DevicesService,
  DeviceOperationsService,
  AlertConfigService,
  AlertService,
  SystemDeviceInventoryService,
  DeviceProfileService,
  DeviceEndpointMaterializerService,
  DeviceCommandDispatchService,
  { provide: 'DeviceCommandDispatchService', useExisting: DeviceCommandDispatchService },
],
  • Step 4: Run the controller test to verify it passes

Run: cd backend && bun test -- src/devices/controllers/device-operations.controller.spec.ts

Expected: PASS with 2 passing controller tests.

  • Step 5: Commit
git add backend/src/devices/controllers/device-operations.controller.ts \
  backend/src/devices/controllers/device-operations.controller.spec.ts \
  backend/src/devices/dto/device-operations.dto.ts \
  backend/src/devices/devices.module.ts
git commit -m "feat(backend): add device operations controller surface"

Task 2: Implement Farm Access Resolution And Farm-Scoped Workspace Reads

Files:

  • Create: backend/src/devices/device-operations.service.ts

  • Create: backend/src/devices/device-operations.service.spec.ts

  • Step 1: Write the failing backend service tests

Create backend/src/devices/device-operations.service.spec.ts:

import { ForbiddenException } from '@nestjs/common';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Test } from '@nestjs/testing';
import type { Repository, SelectQueryBuilder } from 'typeorm';
import type { CurrentUserData } from '@/auth/decorators/current-user.decorator';
import { DeviceOperationsService } from '@/devices/device-operations.service';
import { DeviceAlert } from '@/devices/entities/device-alert.entity';
import { Device } from '@/devices/entities/device.entity';
import { Area } from '@/farm/entities/area.entity';
import { Pond } from '@/farm/entities/pond.entity';

const adminUser: CurrentUserData = {
  userId: 'user-1',
  organizationId: 'org-1',
  role: 'ADMIN',
  roles: ['ADMIN'],
  permissions: ['device:read:all', 'device:control:all'],
};

describe('DeviceOperationsService', () => {
  let service: DeviceOperationsService;
  let areaRepository: jest.Mocked<Repository<Area>>;
  let deviceRepository: jest.Mocked<Repository<Device>>;

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      providers: [
        DeviceOperationsService,
        { provide: getRepositoryToken(Area), useValue: { createQueryBuilder: jest.fn(), exist: jest.fn() } },
        { provide: getRepositoryToken(Device), useValue: { createQueryBuilder: jest.fn() } },
        { provide: getRepositoryToken(Pond), useValue: {} },
        { provide: getRepositoryToken(DeviceAlert), useValue: {} },
      ],
    }).compile();

    service = moduleRef.get(DeviceOperationsService);
    areaRepository = moduleRef.get(getRepositoryToken(Area));
    deviceRepository = moduleRef.get(getRepositoryToken(Device));
  });

  it('returns all organization farms for admin users', async () => {
    const qb = {
      where: jest.fn().mockReturnThis(),
      orderBy: jest.fn().mockReturnThis(),
      select: jest.fn().mockReturnThis(),
      getMany: jest.fn().mockResolvedValue([{ id: 'farm-1', name: 'Trại A', code: 'TRAI-A' }]),
    } as unknown as jest.Mocked<SelectQueryBuilder<Area>>;

    areaRepository.createQueryBuilder.mockReturnValue(qb);

    await expect(service.listAccessibleFarms(adminUser)).resolves.toEqual({
      data: [{ id: 'farm-1', name: 'Trại A', code: 'TRAI-A' }],
    });
  });

  it('rejects workspace reads when the requested farm is outside the organization', async () => {
    areaRepository.exist.mockResolvedValue(false);

    await expect(
      service.getWorkspace(adminUser, { farmId: 'farm-404', page: 1, limit: 20 }),
    ).rejects.toThrow(ForbiddenException);
  });

  it('maps workspace rows with alert counts and controllable flags', async () => {
    areaRepository.exist.mockResolvedValue(true);

    const listQb = {
      leftJoinAndSelect: jest.fn().mockReturnThis(),
      leftJoin: jest.fn().mockReturnThis(),
      addSelect: jest.fn().mockReturnThis(),
      where: jest.fn().mockReturnThis(),
      andWhere: jest.fn().mockReturnThis(),
      orderBy: jest.fn().mockReturnThis(),
      skip: jest.fn().mockReturnThis(),
      take: jest.fn().mockReturnThis(),
      getCount: jest.fn().mockResolvedValue(1),
      getRawAndEntities: jest.fn().mockResolvedValue({
        raw: [{ active_alert_count: '2' }],
        entities: [
          {
            id: 'device-1',
            name: 'Gateway A',
            serialNumber: 'GW-001',
            type: 'gateway',
            capabilities: { ioCount: 4, ioStates: [false, false, false, false] },
            status: 'online',
            lastSeenAt: new Date('2026-05-10T00:00:00.000Z'),
            pond: { id: 'pond-1', name: 'Ao 1', code: 'A1' },
          },
        ],
      }),
    } as unknown as jest.Mocked<SelectQueryBuilder<Device>>;

    deviceRepository.createQueryBuilder.mockReturnValue(listQb);

    await expect(
      service.getWorkspace(adminUser, { farmId: 'farm-1', page: 1, limit: 20 }),
    ).resolves.toEqual({
      data: [
        {
          id: 'device-1',
          name: 'Gateway A',
          serialNumber: 'GW-001',
          type: 'gateway',
          pond: { id: 'pond-1', name: 'Ao 1', code: 'A1' },
          status: 'online',
          lastSeenAt: new Date('2026-05-10T00:00:00.000Z'),
          activeAlertCount: 2,
          telemetrySupport: true,
          controllable: true,
          controlCapabilitySummary: '4 IO',
        },
      ],
      meta: { total: 1, page: 1, limit: 20, totalPages: 1 },
    });
  });
});
  • Step 2: Run the backend service test to verify it fails

Run: cd backend && bun test -- src/devices/device-operations.service.spec.ts

Expected: FAIL because the service does not exist yet.

  • Step 3: Implement DeviceOperationsService

Create backend/src/devices/device-operations.service.ts:

import { ForbiddenException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import type { Repository } from 'typeorm';
import type { CurrentUserData } from '@/auth/decorators/current-user.decorator';
import { RoleName, toRoleName } from '@/common/enums';
import {
  type DeviceOperationsFarmDto,
  type DeviceOperationsWorkspaceRowDto,
  QueryDeviceOperationsWorkspaceDto,
} from '@/devices/dto/device-operations.dto';
import { DeviceAlert } from '@/devices/entities/device-alert.entity';
import { Device } from '@/devices/entities/device.entity';
import { Area } from '@/farm/entities/area.entity';
import { Pond } from '@/farm/entities/pond.entity';

@Injectable()
export class DeviceOperationsService {
  constructor(
    @InjectRepository(Area) private areaRepository: Repository<Area>,
    @InjectRepository(Device) private deviceRepository: Repository<Device>,
    @InjectRepository(Pond) private pondRepository: Repository<Pond>,
    @InjectRepository(DeviceAlert) private alertRepository: Repository<DeviceAlert>,
  ) {}

  async listAccessibleFarms(
    currentUser: CurrentUserData,
  ): Promise<{ data: DeviceOperationsFarmDto[] }> {
    const organizationId = this.getOrganizationIdOrThrow(currentUser);
    const role = this.getSupportedRole(currentUser);
    const qb = this.areaRepository
      .createQueryBuilder('area')
      .where('area.organization_id = :organizationId', { organizationId })
      .select(['area.id', 'area.name', 'area.code'])
      .orderBy('area.name', 'ASC');

    if (role === RoleName.OWNER) {
      qb.innerJoin('area.assignedOwners', 'owner', 'owner.id = :userId', {
        userId: currentUser.userId,
      });
    }

    const farms = await qb.getMany();
    return {
      data: farms.map((farm) => ({ id: farm.id, name: farm.name, code: farm.code ?? null })),
    };
  }

  async getWorkspace(currentUser: CurrentUserData, query: QueryDeviceOperationsWorkspaceDto) {
    const organizationId = this.getOrganizationIdOrThrow(currentUser);
    await this.assertFarmAccessible(currentUser, query.farmId, organizationId);

    const page = query.page ?? 1;
    const limit = Math.min(query.limit ?? 20, 100);
    const qb = this.deviceRepository
      .createQueryBuilder('device')
      .leftJoinAndSelect('device.pond', 'pond')
      .leftJoin(
        (subQb) =>
          subQb
            .from(DeviceAlert, 'alert')
            .select('alert.deviceId', 'device_id')
            .addSelect('COUNT(*)', 'active_alert_count')
            .where(`alert.status = 'active'`)
            .groupBy('alert.deviceId'),
        'active_alerts',
        'active_alerts.device_id = device.id',
      )
      .addSelect('COALESCE(active_alerts.active_alert_count, 0)', 'active_alert_count')
      .where('device.organizationId = :organizationId', { organizationId })
      .andWhere('(device.areaId = :farmId OR pond.areaId = :farmId)', { farmId: query.farmId });

    if (query.search) {
      qb.andWhere('(device.name ILIKE :search OR device.serialNumber ILIKE :search)', {
        search: `%${query.search}%`,
      });
    }
    if (query.status) qb.andWhere('device.status = :status', { status: query.status });
    if (query.type) qb.andWhere('device.type = :type', { type: query.type });
    if (query.pondId) qb.andWhere('pond.id = :pondId', { pondId: query.pondId });
    if (query.controllableOnly === 'true') {
      qb.andWhere("device.type = 'gateway'");
    }

    qb.orderBy('device.lastSeenAt', 'DESC');

    const total = await qb.getCount();
    const { entities, raw } = await qb.skip((page - 1) * limit).take(limit).getRawAndEntities();

    const data: DeviceOperationsWorkspaceRowDto[] = entities.map((device, index) => {
      const activeAlertCount = Number(raw[index]?.active_alert_count ?? 0);
      const gatewayCapabilities = this.getGatewayCapabilities(device.capabilities);
      const controllable = gatewayCapabilities !== null && gatewayCapabilities.ioCount > 0;

      return {
        id: device.id,
        name: device.name,
        serialNumber: device.serialNumber,
        type: device.type as DeviceOperationsWorkspaceRowDto['type'],
        pond: device.pond
          ? { id: device.pond.id, name: device.pond.name, code: device.pond.code }
          : null,
        status: this.resolveStatus(device.status, device.lastSeenAt),
        lastSeenAt: device.lastSeenAt,
        activeAlertCount,
        telemetrySupport: this.hasTelemetrySupport(device),
        controllable,
        controlCapabilitySummary: controllable ? `${gatewayCapabilities?.ioCount ?? 0} IO` : null,
      };
    });

    return {
      data,
      meta: {
        total,
        page,
        limit,
        totalPages: Math.ceil(total / limit),
      },
    };
  }

  private async assertFarmAccessible(
    currentUser: CurrentUserData,
    farmId: string,
    organizationId: string,
  ) {
    const role = this.getSupportedRole(currentUser);
    if (role === RoleName.ADMIN) {
      const exists = await this.areaRepository.exist({ where: { id: farmId, organizationId } });
      if (!exists) throw new ForbiddenException('Farm is out of organization scope');
      return;
    }

    const assignedFarm = await this.areaRepository
      .createQueryBuilder('area')
      .innerJoin('area.assignedOwners', 'owner', 'owner.id = :userId', {
        userId: currentUser.userId,
      })
      .where('area.organization_id = :organizationId', { organizationId })
      .andWhere('area.id = :farmId', { farmId })
      .select(['area.id'])
      .getOne();

    if (!assignedFarm) throw new ForbiddenException('Farm is out of organization scope');
  }

  private getOrganizationIdOrThrow(currentUser: CurrentUserData): string {
    if (!currentUser.organizationId) {
      throw new ForbiddenException('Organization scope is required');
    }
    return currentUser.organizationId;
  }

  private getSupportedRole(currentUser: CurrentUserData): RoleName {
    const role = currentUser.role ?? toRoleName(currentUser.roles[0] ?? '');
    if (role !== RoleName.ADMIN && role !== RoleName.OWNER) {
      throw new ForbiddenException('Role is not allowed to access device operations workspace');
    }
    return role;
  }

  private hasTelemetrySupport(device: Device): boolean {
    return device.type === 'sensor' || device.type === 'gateway';
  }

  private getGatewayCapabilities(capabilities: unknown): { ioCount: number } | null {
    if (!capabilities || typeof capabilities !== 'object') return null;
    const candidate = capabilities as { ioCount?: unknown };
    return typeof candidate.ioCount === 'number' ? { ioCount: candidate.ioCount } : null;
  }

  private resolveStatus(
    status: Device['status'],
    lastSeenAt: Date | null,
  ): 'online' | 'offline' | 'error' {
    if (!lastSeenAt || status !== 'online') return status as 'online' | 'offline' | 'error';
    const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
    return lastSeenAt < fiveMinutesAgo ? 'offline' : 'online';
  }
}
  • Step 4: Run the backend service test to verify it passes

Run: cd backend && bun test -- src/devices/device-operations.service.spec.ts

Expected: PASS with 3 passing service tests.

  • Step 5: Run the controller test again

Run: cd backend && bun test -- src/devices/controllers/device-operations.controller.spec.ts

Expected: PASS and still green after the service implementation lands.

  • Step 6: Commit
git add backend/src/devices/device-operations.service.ts \
  backend/src/devices/device-operations.service.spec.ts
git commit -m "feat(backend): add farm-scoped device operations workspace reads"

Task 3: Add Frontend Types, Service, And Query Hooks For The Workspace

Files:

  • Create: frontend/src/types/device-operations.types.ts

  • Create: frontend/src/services/device-operations.service.ts

  • Create: frontend/src/hooks/useDeviceOperations.ts

  • Step 1: Write the failing query-hook test

Append a focused query-key + enabled-state test to frontend/src/pages/devices/DeviceOperationsPage.spec.tsx:

it('waits for explicit farm selection when multiple farms are accessible', async () => {
  renderWithProviders(<DeviceOperationsPage />, {
    initialEntries: ['/devices/operations'],
  });

  expect(await screen.findByText('Chọn trang trại để bắt đầu')).toBeInTheDocument();
  expect(screen.queryByRole('table')).not.toBeInTheDocument();
});

Run: cd frontend && bun test src/pages/devices/DeviceOperationsPage.spec.tsx --run

Expected: FAIL because the page and hooks do not exist yet.

  • Step 2: Implement the frontend workspace types

Create frontend/src/types/device-operations.types.ts:

export interface DeviceOperationsFarm {
  id: string;
  name: string;
  code?: string | null;
}

export interface DeviceOperationsWorkspaceRow {
  id: string;
  name: string | null;
  serialNumber: string;
  type: 'sensor' | 'gateway' | 'inverter' | 'feeder';
  pond?: { id: string; name: string; code: string } | null;
  status: 'online' | 'offline' | 'error';
  lastSeenAt?: string | null;
  activeAlertCount: number;
  telemetrySupport: boolean;
  controllable: boolean;
  controlCapabilitySummary: string | null;
}

export interface DeviceOperationsWorkspaceQuery {
  farmId: string;
  page?: number;
  limit?: number;
  search?: string;
  status?: 'online' | 'offline' | 'error';
  type?: 'sensor' | 'gateway' | 'inverter' | 'feeder';
  pondId?: string;
  controllableOnly?: boolean;
}

export interface DeviceOperationsWorkspaceResponse {
  data: DeviceOperationsWorkspaceRow[];
  meta: { total: number; page: number; limit: number; totalPages: number };
}
  • Step 3: Implement the service and hooks

Create frontend/src/services/device-operations.service.ts:

import { api } from '@/lib/api';
import type {
  DeviceOperationsFarm,
  DeviceOperationsWorkspaceQuery,
  DeviceOperationsWorkspaceResponse,
} from '@/types/device-operations.types';

export const deviceOperationsService = {
  async getAccessibleFarms(): Promise<{ data: DeviceOperationsFarm[] }> {
    const response = await api.get<{ data: DeviceOperationsFarm[] }>('/devices/operations/farms');
    return response.data;
  },

  async getWorkspace(
    query: DeviceOperationsWorkspaceQuery,
  ): Promise<DeviceOperationsWorkspaceResponse> {
    const response = await api.get<DeviceOperationsWorkspaceResponse>(
      '/devices/operations/workspace',
      {
        params: {
          ...query,
          controllableOnly: query.controllableOnly ? 'true' : undefined,
        },
      },
    );
    return response.data;
  },
};

Create frontend/src/hooks/useDeviceOperations.ts:

import { useQuery } from '@tanstack/react-query';
import { deviceOperationsService } from '@/services/device-operations.service';
import type { DeviceOperationsWorkspaceQuery } from '@/types/device-operations.types';

export const useAccessibleDeviceOperationFarms = () =>
  useQuery({
    queryKey: ['device-operations', 'farms'],
    queryFn: () => deviceOperationsService.getAccessibleFarms(),
    staleTime: 60_000,
  });

export const useDeviceOperationsWorkspace = (
  query: DeviceOperationsWorkspaceQuery | null,
) =>
  useQuery({
    queryKey: ['device-operations', 'workspace', query],
    queryFn: () => deviceOperationsService.getWorkspace(query as DeviceOperationsWorkspaceQuery),
    enabled: query !== null,
    placeholderData: (previous) => previous,
  });
  • Step 4: Run the page spec again to verify the page is still missing but the service layer is ready

Run: cd frontend && bun test src/pages/devices/DeviceOperationsPage.spec.tsx --run

Expected: FAIL because DeviceOperationsPage.tsx does not exist yet, not because the service layer is malformed.

  • Step 5: Commit
git add frontend/src/types/device-operations.types.ts \
  frontend/src/services/device-operations.service.ts \
  frontend/src/hooks/useDeviceOperations.ts
git commit -m "feat(frontend): add device operations data client"

Task 4: Expose The Route And Navigation Entry

Files:

  • Modify: frontend/src/App.tsx

  • Modify: frontend/src/navigation/menus/admin.menu.tsx

  • Modify: frontend/src/navigation/menus/owner.menu.tsx

  • Modify: frontend/src/navigation/nav-resolver.spec.ts

  • Modify: frontend/src/App.redirect.spec.tsx

  • Step 1: Add the failing route and navigation tests

Update frontend/src/navigation/nav-resolver.spec.ts:

it.each([
  ['ADMIN', 'admin-device-operations'],
  ['OWNER', 'owner-device-operations'],
] as const)('exposes the device operations workspace in %s navigation', (role, key) => {
  const items = flatten(resolveNavigation(role, fullPermissions));
  const target = items.find((item) => item.key === key);

  expect(target?.path).toBe('/devices/operations');
});

Update frontend/src/App.redirect.spec.tsx:

vi.mock('@/pages/devices/DeviceOperationsPage', () => ({
  DeviceOperationsPage: () => <div>device-operations-page</div>,
}));

it('renders the device operations route when visiting /devices/operations', async () => {
  window.history.pushState({}, '', '/devices/operations');
  render(React.createElement(App));
  expect(await screen.findByText('device-operations-page')).toBeInTheDocument();
});
  • Step 2: Run the route/navigation tests to verify they fail

Run: cd frontend && bun test src/navigation/nav-resolver.spec.ts src/App.redirect.spec.tsx --run

Expected: FAIL because the new route and menu items do not exist yet.

  • Step 3: Add the route and menu entries

Update frontend/src/navigation/menus/admin.menu.tsx:

{
  key: 'admin-device-operations',
  icon: <ToolOutlined />,
  label: 'Vận hành thiết bị',
  path: '/devices/operations',
  requiredPermission: { resource: 'device' },
},

Place it immediately after admin-device-management.

Update frontend/src/navigation/menus/owner.menu.tsx:

{
  key: 'owner-device-operations',
  icon: <ExperimentOutlined />,
  label: 'Vận hành thiết bị',
  path: '/devices/operations',
  requiredPermission: { resource: 'device' },
},

Place it immediately after owner-devices.

Update frontend/src/App.tsx imports:

import { DeviceOperationsPage } from '@/pages/devices/DeviceOperationsPage';

Update the protected device routes in frontend/src/App.tsx:

<Route
  element={
    <PermissionRoute
      resource="device"
      roles={[RoleEnum.ADMIN, RoleEnum.OWNER]}
    />
  }
>
  <Route path="/devices/operations" element={<DeviceOperationsPage />} />
</Route>

Place it between /devices/alert-configs and /devices/:id so it never conflicts with the :id route.

  • Step 4: Run the route/navigation tests to verify they pass

Run: cd frontend && bun test src/navigation/nav-resolver.spec.ts src/App.redirect.spec.tsx --run

Expected: PASS with the new owner/admin navigation checks and the route render regression green.

  • Step 5: Commit
git add frontend/src/App.tsx \
  frontend/src/navigation/menus/admin.menu.tsx \
  frontend/src/navigation/menus/owner.menu.tsx \
  frontend/src/navigation/nav-resolver.spec.ts \
  frontend/src/App.redirect.spec.tsx
git commit -m "feat(frontend): expose device operations route and navigation"

Task 5: Build The Device Operations Workspace Page

Files:

  • Create: frontend/src/pages/devices/DeviceOperationsPage.tsx

  • Modify: frontend/src/pages/devices/DeviceOperationsPage.spec.tsx

  • Step 1: Write the full failing page behavior tests

Create or replace frontend/src/pages/devices/DeviceOperationsPage.spec.tsx with these core behaviors:

import '@testing-library/jest-dom/vitest';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DeviceOperationsPage } from '@/pages/devices/DeviceOperationsPage';
import { renderWithProviders } from '@/test/renderWithProviders';

const navigateMock = vi.fn();
const mockPermissionsState = {
  permissions: ['device:read:all', 'device:control:all'],
  user: { organizationId: 'org-1' },
};

vi.mock('react-router-dom', async () => {
  const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
  return { ...actual, useNavigate: () => navigateMock };
});

vi.mock('@/stores/auth.store', () => ({
  useAuthStore: (
    selector: (state: {
      permissions: string[];
      user: { organizationId: string | null } | null;
    }) => unknown,
  ) => selector(mockPermissionsState),
}));

vi.mock('@/hooks/useDeviceOperations', () => ({
  useAccessibleDeviceOperationFarms: vi.fn(),
  useDeviceOperationsWorkspace: vi.fn(),
}));

vi.mock('@/services/device.service', () => ({
  deviceService: {
    getLatestTelemetry: vi.fn(),
    getTelemetryHistory: vi.fn(),
  },
}));

describe('DeviceOperationsPage', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('auto-selects the only accessible farm and loads its workspace rows', async () => {
    // mock farms hook => one farm
    // mock workspace hook => one row
    renderWithProviders(<DeviceOperationsPage />, {
      initialEntries: ['/devices/operations'],
    });

    expect(await screen.findByDisplayValue('Trại A')).toBeInTheDocument();
    expect(screen.getByText('Gateway A')).toBeInTheDocument();
  });

  it('shows an uninitialized prompt when multiple farms exist and no farm has been selected', async () => {
    renderWithProviders(<DeviceOperationsPage />, {
      initialEntries: ['/devices/operations'],
    });

    expect(await screen.findByText('Chọn trang trại để bắt đầu')).toBeInTheDocument();
  });

  it('clears pinned telemetry and closes the control drawer when switching farms', async () => {
    renderWithProviders(<DeviceOperationsPage />, {
      initialEntries: ['/devices/operations'],
    });

    fireEvent.click(await screen.findByRole('button', { name: /xem telemetry/i }));
    fireEvent.click(await screen.findByRole('button', { name: /ghim nguồn/i }));
    fireEvent.click(screen.getByRole('button', { name: /điều khiển/i }));
    fireEvent.mouseDown(screen.getByLabelText(/trang trại/i));
    fireEvent.click(await screen.findByTitle('Trại B'));

    await waitFor(() => {
      expect(screen.queryByText(/control-panel/i)).not.toBeInTheDocument();
    });
    expect(screen.queryByRole('button', { name: /bỏ ghim nguồn/i })).not.toBeInTheDocument();
  });

  it('disables control for users without device control permission', async () => {
    mockPermissionsState.permissions = ['device:read:all'];

    renderWithProviders(<DeviceOperationsPage />, {
      initialEntries: ['/devices/operations'],
    });

    expect(await screen.findByRole('button', { name: /điều khiển/i })).toBeDisabled();
  });
});
  • Step 2: Run the page spec to verify it fails

Run: cd frontend && bun test src/pages/devices/DeviceOperationsPage.spec.tsx --run

Expected: FAIL because the page does not exist yet.

  • Step 3: Implement the page with explicit local workspace state

Create frontend/src/pages/devices/DeviceOperationsPage.tsx:

import {
  EyeOutlined,
  LinkOutlined,
  ThunderboltOutlined,
} from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import {
  Alert,
  Button,
  Card,
  Drawer,
  Empty,
  Grid,
  Input,
  Select,
  Space,
  Switch,
  Table,
  Tag,
  Typography,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { startTransition, useDeferredValue, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { PageHeader } from '@/components/common/PageHeader';
import { GatewayControlPanel } from '@/components/devices/GatewayControlPanel';
import { TelemetryChart } from '@/components/devices/TelemetryChart';
import { useAccessibleDeviceOperationFarms, useDeviceOperationsWorkspace } from '@/hooks/useDeviceOperations';
import dayjs from '@/lib/dayjs';
import { hasPermission } from '@/lib/permissions';
import { deviceService } from '@/services/device.service';
import { useAuthStore } from '@/stores/auth.store';
import type { DeviceOperationsWorkspaceRow } from '@/types/device-operations.types';

type DrawerMode = 'closed' | 'telemetry' | 'control';

const DEFAULT_TELEMETRY_PARAMETERS = ['temperature', 'ph'];

export const DeviceOperationsPage = () => {
  const navigate = useNavigate();
  const { md } = Grid.useBreakpoint();
  const permissions = useAuthStore((state) => state.permissions);
  const canControlDevices = hasPermission(permissions, 'device', 'control');

  const [selectedFarmId, setSelectedFarmId] = useState<string | null>(null);
  const [search, setSearch] = useState('');
  const deferredSearch = useDeferredValue(search);
  const [statusFilter, setStatusFilter] = useState<'online' | 'offline' | 'error' | undefined>();
  const [typeFilter, setTypeFilter] = useState<'sensor' | 'gateway' | 'inverter' | 'feeder' | undefined>();
  const [pondFilter, setPondFilter] = useState<string | undefined>();
  const [controllableOnly, setControllableOnly] = useState(false);
  const [drawerMode, setDrawerMode] = useState<DrawerMode>('closed');
  const [activeTelemetrySourceId, setActiveTelemetrySourceId] = useState<string | null>(null);
  const [activeControlTargetId, setActiveControlTargetId] = useState<string | null>(null);
  const [pinnedTelemetrySourceIds, setPinnedTelemetrySourceIds] = useState<string[]>([]);

  const farmsQuery = useAccessibleDeviceOperationFarms();

  useEffect(() => {
    if (selectedFarmId || !farmsQuery.data) return;
    if (farmsQuery.data.data.length === 1) {
      setSelectedFarmId(farmsQuery.data.data[0].id);
    }
  }, [selectedFarmId, farmsQuery.data]);

  const workspaceQuery = useDeviceOperationsWorkspace(
    selectedFarmId
      ? {
          farmId: selectedFarmId,
          page: 1,
          limit: 100,
          search: deferredSearch || undefined,
          status: statusFilter,
          type: typeFilter,
          pondId: pondFilter,
          controllableOnly,
        }
      : null,
  );

  const rows = workspaceQuery.data?.data ?? [];
  const activeTelemetrySource =
    rows.find((row) => row.id === activeTelemetrySourceId) ?? null;
  const activeControlTarget =
    rows.find((row) => row.id === activeControlTargetId) ?? null;

  const latestTelemetryQuery = useQuery({
    queryKey: ['device-operations', 'latest-telemetry', activeTelemetrySourceId],
    queryFn: () => deviceService.getLatestTelemetry(activeTelemetrySourceId as string),
    enabled: activeTelemetrySourceId !== null,
  });

  const telemetryHistoryQuery = useQuery({
    queryKey: ['device-operations', 'telemetry-history', activeTelemetrySourceId],
    queryFn: () =>
      deviceService.getTelemetryHistory(activeTelemetrySourceId as string, {
        from: dayjs().subtract(6, 'hour').toISOString(),
        to: dayjs().toISOString(),
        interval: '1h',
        parameters: DEFAULT_TELEMETRY_PARAMETERS,
      }),
    enabled: activeTelemetrySourceId !== null,
  });

  const pondOptions = useMemo(() => {
    const map = new Map<string, { value: string; label: string }>();
    rows.forEach((row) => {
      if (!row.pond) return;
      map.set(row.pond.id, {
        value: row.pond.id,
        label: `${row.pond.name} (${row.pond.code})`,
      });
    });
    return Array.from(map.values());
  }, [rows]);

  const handleFarmChange = (nextFarmId: string) => {
    startTransition(() => {
      setSelectedFarmId(nextFarmId);
      setDrawerMode('closed');
      setActiveTelemetrySourceId(null);
      setActiveControlTargetId(null);
      setPinnedTelemetrySourceIds([]);
      setPondFilter(undefined);
    });
  };

  const pinActiveTelemetrySource = () => {
    if (!activeTelemetrySourceId) return;
    setPinnedTelemetrySourceIds((current) =>
      current.includes(activeTelemetrySourceId)
        ? current
        : [...current, activeTelemetrySourceId],
    );
  };

  const unpinActiveTelemetrySource = () => {
    if (!activeTelemetrySourceId) return;
    setPinnedTelemetrySourceIds((current) =>
      current.filter((id) => id !== activeTelemetrySourceId),
    );
  };

  const columns: ColumnsType<DeviceOperationsWorkspaceRow> = [
    { title: 'Thiết bị', dataIndex: 'name', key: 'name' },
    { title: 'Serial', dataIndex: 'serialNumber', key: 'serialNumber' },
    {
      title: 'Ao',
      key: 'pond',
      render: (_, row) => (row.pond ? `${row.pond.name} (${row.pond.code})` : 'Chưa gán'),
    },
    {
      title: 'Trạng thái',
      dataIndex: 'status',
      key: 'status',
      render: (status) => <Tag>{status.toUpperCase()}</Tag>,
    },
    {
      title: 'Cảnh báo',
      dataIndex: 'activeAlertCount',
      key: 'activeAlertCount',
      render: (count) => <Tag color={count > 0 ? 'red' : 'default'}>{count}</Tag>,
    },
    {
      title: 'Khả năng',
      key: 'capabilities',
      render: (_, row) => (
        <Space wrap>
          <Tag color={row.telemetrySupport ? 'blue' : 'default'}>Telemetry</Tag>
          <Tag color={row.controllable ? 'green' : 'default'}>
            {row.controlCapabilitySummary ?? 'Không điều khiển'}
          </Tag>
        </Space>
      ),
    },
    {
      title: 'Hành động',
      key: 'actions',
      render: (_, row) => (
        <Space wrap>
          <Button
            icon={<EyeOutlined />}
            onClick={() => {
              setActiveTelemetrySourceId(row.id);
              setDrawerMode('telemetry');
            }}
            disabled={!row.telemetrySupport}
          >
            Xem telemetry
          </Button>
          <Button
            icon={<ThunderboltOutlined />}
            onClick={() => {
              setActiveControlTargetId(row.id);
              setDrawerMode('control');
            }}
            disabled={!row.controllable || !canControlDevices}
          >
            Điều khiển
          </Button>
          <Button icon={<LinkOutlined />} onClick={() => navigate(`/devices/${row.id}`)}>
            Chi tiết
          </Button>
        </Space>
      ),
    },
  ];

  return (
    <div>
      <PageHeader
        title="Vận hành thiết bị"
        subtitle="Quan sát telemetry trước khi gửi lệnh điều khiển"
      />

      <Card style={{ marginBottom: 16 }}>
        <Space wrap size="middle">
          <Select
            aria-label="Trang trại"
            placeholder="Chọn trang trại"
            value={selectedFarmId ?? undefined}
            onChange={handleFarmChange}
            style={{ minWidth: 220 }}
            options={(farmsQuery.data?.data ?? []).map((farm) => ({
              value: farm.id,
              label: farm.name,
              title: farm.name,
            }))}
          />
          <Input.Search
            placeholder="Tìm theo tên hoặc serial"
            value={search}
            onChange={(event) => setSearch(event.target.value)}
            style={{ width: 240 }}
          />
          <Select
            placeholder="Trạng thái"
            allowClear
            value={statusFilter}
            onChange={setStatusFilter}
            style={{ width: 140 }}
            options={[
              { value: 'online', label: 'Online' },
              { value: 'offline', label: 'Offline' },
              { value: 'error', label: 'Error' },
            ]}
          />
          <Select
            placeholder="Loại"
            allowClear
            value={typeFilter}
            onChange={setTypeFilter}
            style={{ width: 160 }}
            options={[
              { value: 'sensor', label: 'Sensor' },
              { value: 'gateway', label: 'Gateway' },
              { value: 'inverter', label: 'Inverter' },
              { value: 'feeder', label: 'Feeder' },
            ]}
          />
          <Select
            placeholder="Ao"
            allowClear
            value={pondFilter}
            onChange={setPondFilter}
            style={{ width: 200 }}
            options={pondOptions}
          />
          <Space>
            <Switch checked={controllableOnly} onChange={setControllableOnly} />
            <Typography.Text>Chỉ thiết bị điều khiển được</Typography.Text>
          </Space>
        </Space>
      </Card>

      {pinnedTelemetrySourceIds.length > 0 && (
        <Card style={{ marginBottom: 16 }}>
          <Space wrap>
            {pinnedTelemetrySourceIds.map((id) => {
              const row = rows.find((item) => item.id === id);
              if (!row) return null;
              return (
                <Tag
                  key={id}
                  color={id === activeTelemetrySourceId ? 'blue' : 'default'}
                  onClick={() => {
                    setActiveTelemetrySourceId(id);
                    setDrawerMode('telemetry');
                  }}
                >
                  {row.name ?? row.serialNumber}
                </Tag>
              );
            })}
          </Space>
        </Card>
      )}

      {!selectedFarmId && (farmsQuery.data?.data.length ?? 0) > 1 ? (
        <Card>
          <Empty description="Chọn trang trại để bắt đầu" />
        </Card>
      ) : (
        <Card>
          <Table
            rowKey="id"
            columns={columns}
            dataSource={rows}
            loading={workspaceQuery.isLoading}
            pagination={false}
            scroll={{ x: 980 }}
          />
        </Card>
      )}

      <Drawer
        title={drawerMode === 'telemetry' ? 'Nguồn telemetry' : 'Điều khiển thiết bị'}
        open={drawerMode !== 'closed'}
        placement={md ? 'right' : 'bottom'}
        height={md ? undefined : '82vh'}
        width={md ? 520 : undefined}
        onClose={() => setDrawerMode('closed')}
      >
        {drawerMode === 'telemetry' && activeTelemetrySource && (
          <Space direction="vertical" size="middle" style={{ width: '100%' }}>
            <Space direction="vertical" size={0}>
              <Typography.Title level={5} style={{ margin: 0 }}>
                {activeTelemetrySource.name ?? activeTelemetrySource.serialNumber}
              </Typography.Title>
              <Typography.Text type="secondary">
                {activeTelemetrySource.serialNumber}
              </Typography.Text>
            </Space>
            {latestTelemetryQuery.data ? (
              <Card size="small">
                <pre>{JSON.stringify(latestTelemetryQuery.data.data, null, 2)}</pre>
              </Card>
            ) : (
              <Alert type="info" message="Chưa có telemetry gần đây" />
            )}
            <TelemetryChart
              data={telemetryHistoryQuery.data?.data ?? []}
              parameters={DEFAULT_TELEMETRY_PARAMETERS}
              height={240}
            />
            <Space wrap>
              <Button onClick={pinActiveTelemetrySource}>Ghim nguồn</Button>
              <Button onClick={unpinActiveTelemetrySource}>Bỏ ghim nguồn</Button>
              <Button onClick={() => navigate(`/devices/${activeTelemetrySource.id}`)}>
                Mở chi tiết
              </Button>
            </Space>
          </Space>
        )}

        {drawerMode === 'control' && activeControlTarget && (
          <Space direction="vertical" size="middle" style={{ width: '100%' }}>
            <Space direction="vertical" size={0}>
              <Typography.Title level={5} style={{ margin: 0 }}>
                {activeControlTarget.name ?? activeControlTarget.serialNumber}
              </Typography.Title>
              <Typography.Text type="secondary">
                {activeControlTarget.controlCapabilitySummary ?? 'Không có capability điều khiển'}
              </Typography.Text>
            </Space>
            {activeControlTarget.status !== 'online' && (
              <Alert
                type="warning"
                message="Thiết bị đang offline, không thể gửi lệnh lúc này"
              />
            )}
            <GatewayControlPanel
              deviceId={activeControlTarget.id}
              capabilities={{ ioCount: 4, ioStates: [false, false, false, false] }}
              disabled={activeControlTarget.status !== 'online'}
            />
          </Space>
        )}
      </Drawer>
    </div>
  );
};
  • Step 4: Run the page test to verify it passes

Run: cd frontend && bun test src/pages/devices/DeviceOperationsPage.spec.tsx --run

Expected: PASS with the farm-selection, farm-switch reset, and control permission cases green.

  • Step 5: Commit
git add frontend/src/pages/devices/DeviceOperationsPage.tsx \
  frontend/src/pages/devices/DeviceOperationsPage.spec.tsx
git commit -m "feat(frontend): add device operations workspace page"

Task 6: Run Focused Verification And Repo Checks

Files:

  • Modify only if verification exposes real defects.

  • Step 1: Run backend workspace tests

Run:

cd backend
bun test -- src/devices/controllers/device-operations.controller.spec.ts
bun test -- src/devices/device-operations.service.spec.ts

Expected: PASS for the new backend controller and service suites.

  • Step 2: Run frontend workspace tests

Run:

cd frontend
bun test src/navigation/nav-resolver.spec.ts src/App.redirect.spec.tsx src/pages/devices/DeviceOperationsPage.spec.tsx --run

Expected: PASS for navigation exposure, route render, and page behavior tests.

  • Step 3: Run repo-wide checks required by AGENTS.md

Run:

cd /Users/hieunguyen/Documents/projects/iot_platform
bun run check

Expected: PASS for Biome + type/lint checks across touched JS/TS workspaces.

  • Step 4: Smoke-check the runtime flows manually

Run:

cd frontend
bun run dev

Then verify manually:

1. Login as ADMIN with device read/control permissions.
2. Open /devices/operations.
3. Confirm single-farm auto-select or multi-farm empty state.
4. Open telemetry drawer, pin a source, and switch between pinned chips.
5. Open control drawer on an online controllable device and submit a command.
6. Open control drawer on an offline device and confirm the control surface is disabled.
7. Switch farms and confirm pinned telemetry sources + active drawer state reset.
  • Step 5: Commit the final integrated slice
git add backend/src/devices frontend/src docs/superpowers/plans/2026-05-10-device-operations-workspace.md
git commit -m "feat: add owner admin device operations workspace"

Spec Coverage Check

  • Backend farm access read path: covered by Task 1 and Task 2 through /devices/operations/farms.
  • Backend farm-scoped workspace dataset: covered by Task 1 and Task 2 through /devices/operations/workspace.
  • Reuse existing control contract: preserved explicitly in Task 5 by continuing to use GatewayControlPanel + existing device service control write flow.
  • Route and owner/admin navigation entry: covered by Task 4.
  • Table + drawer workspace UX: covered by Task 5.
  • Telemetry pinning and one-target-at-a-time control: covered by Task 5.
  • Auto-select one farm, empty state for multiple farms, reset state on farm switch: covered by Task 5.
  • Responsive drawer behavior: covered by Task 5 via right drawer on desktop and bottom drawer on mobile.
  • Verification requirement from AGENTS.md: covered by Task 6.

Placeholder Scan

  • No TODO, TBD, or “implement later” placeholders remain.
  • The plan intentionally chooses dedicated backend workspace endpoints instead of a temporary frontend-only fan-out composition.
  • Phase 2 items from the specs stay out of this plan: command history, generalized command DTO, technical assignment, and role expansion beyond owner/admin.