From 6563a38a59e1247634e1ff9392000521aeb09574 Mon Sep 17 00:00:00 2001 From: Lewis Carhart Date: Sun, 8 Feb 2026 17:47:27 +0000 Subject: [PATCH 01/14] feat(device-agent): new device agent package and CI workflow for releases --- .github/workflows/device-agent-release.yml | 179 ++++ apps/api/src/devices/devices.service.ts | 374 ++++++--- .../src/devices/dto/device-responses.dto.ts | 8 + apps/api/src/lib/fleet.service.ts | 6 + apps/api/src/people/people.service.ts | 22 + .../[employeeId]/components/Employee.tsx | 72 +- .../components/EmployeeDetails.tsx | 57 +- .../[employeeId]/components/EmployeeTasks.tsx | 388 +++++---- .../components/Fields/Department.tsx | 58 +- .../[employeeId]/components/Fields/Email.tsx | 34 +- .../components/Fields/JoinDate.tsx | 96 +-- .../[employeeId]/components/Fields/Name.tsx | 27 +- .../[employeeId]/components/Fields/Status.tsx | 84 +- .../[orgId]/people/[employeeId]/page.tsx | 112 ++- .../people/all/components/MemberRow.tsx | 92 +-- .../all/components/PendingInvitationRow.tsx | 21 +- .../people/all/components/TeamMembers.tsx | 64 ++ .../all/components/TeamMembersClient.tsx | 9 + .../people/components/PeoplePageTabs.tsx | 10 - .../components/EmployeeCompletionChart.tsx | 395 ++++----- .../components/EmployeesOverview.tsx | 15 +- .../components/DeviceComplianceChart.tsx | 216 +++-- .../devices/components/DeviceDetails.tsx | 167 ++++ .../components/EmployeeDevicesColumns.tsx | 149 ++-- .../components/EmployeeDevicesList.tsx | 217 ++++- .../[orgId]/people/devices/data/index.ts | 279 +++++-- .../[orgId]/people/devices/types/index.ts | 36 +- .../app/src/app/(app)/[orgId]/people/page.tsx | 35 +- apps/portal/next.config.ts | 7 +- apps/portal/package.json | 1 + .../[orgId]/components/EmployeeTasksList.tsx | 120 +-- .../components/OrganizationDashboard.tsx | 25 +- .../components/policy/AdvancedEditor.tsx | 4 +- .../[orgId]/components/policy/PolicyCard.tsx | 11 +- .../components/policy/PolicyCarousel.tsx | 8 +- .../components/policy/PolicyContainer.tsx | 12 +- .../[orgId]/components/policy/PolicyGrid.tsx | 4 +- .../components/policy/PortalPdfViewer.tsx | 7 +- .../tasks/DeviceAgentAccordionItem.tsx | 399 ++++----- .../components/tasks/FleetPolicyItem.tsx | 53 +- .../tasks/GeneralTrainingAccordionItem.tsx | 17 +- .../tasks/PoliciesAccordionItem.tsx | 19 +- .../tasks/PolicyImageUploadModal.tsx | 49 +- .../components/video/CarouselControls.tsx | 8 +- .../[orgId]/components/video/YoutubeEmbed.tsx | 30 +- .../src/app/(app)/(home)/[orgId]/page.tsx | 37 +- .../policy/[policyId]/PolicyAcceptButton.tsx | 21 +- .../(home)/[orgId]/policy/[policyId]/page.tsx | 83 +- .../(home)/components/NoAccessMessage.tsx | 4 +- apps/portal/src/app/(app)/layout.tsx | 10 +- .../app/api/device-agent/check-in/route.ts | 112 +++ .../device-agent/my-organizations/route.ts | 48 ++ .../app/api/device-agent/register/route.ts | 89 ++ .../src/app/api/device-agent/status/route.ts | 60 ++ .../src/app/api/download-agent/fleet-label.ts | 127 --- .../src/app/api/download-agent/scripts.ts | 4 - .../app/api/download-agent/scripts/common.ts | 46 -- .../app/api/download-agent/scripts/index.ts | 2 - .../src/app/api/download-agent/scripts/mac.ts | 120 --- apps/portal/src/app/components/header.tsx | 2 +- apps/portal/src/app/components/user-menu.tsx | 6 - apps/portal/src/app/layout.tsx | 6 +- apps/portal/src/app/lib/auth.ts | 3 +- apps/portal/src/app/portal.css | 11 + apps/portal/src/app/providers.tsx | 15 +- apps/portal/src/utils/os.ts | 6 +- bun.lock | 763 +++++++++++++++-- .../migration.sql | 64 ++ packages/db/prisma/schema/auth.prisma | 1 + packages/db/prisma/schema/device.prisma | 55 ++ packages/db/prisma/schema/organization.prisma | 3 + packages/device-agent/.gitignore | 9 + packages/device-agent/BUILD.md | 172 ++++ packages/device-agent/SPEC.md | 780 ++++++++++++++++++ .../device-agent/assets/16x16-default.png | Bin 0 -> 697 bytes packages/device-agent/assets/16x16-fail.png | Bin 0 -> 736 bytes packages/device-agent/assets/16x16-pass.png | Bin 0 -> 777 bytes .../assets/entitlements.mac.plist | 16 + packages/device-agent/assets/icon.icns | Bin 0 -> 202145 bytes packages/device-agent/assets/icon.png | Bin 0 -> 35616 bytes packages/device-agent/assets/logo.png | Bin 0 -> 20280 bytes packages/device-agent/electron-builder.yml | 51 ++ packages/device-agent/electron.vite.config.ts | 41 + packages/device-agent/package.json | 41 + .../device-agent/scripts/generate-icons.ts | 27 + packages/device-agent/src/checks/index.ts | 91 ++ .../src/checks/linux/antivirus.ts | 131 +++ .../src/checks/linux/disk-encryption.ts | 151 ++++ .../src/checks/linux/password-policy.ts | 130 +++ .../src/checks/linux/screen-lock.ts | 154 ++++ .../src/checks/macos/antivirus.ts | 94 +++ .../src/checks/macos/disk-encryption.ts | 47 ++ .../src/checks/macos/password-policy.ts | 176 ++++ .../src/checks/macos/screen-lock.ts | 165 ++++ packages/device-agent/src/checks/types.ts | 16 + .../src/checks/windows/antivirus.ts | 96 +++ .../src/checks/windows/disk-encryption.ts | 81 ++ .../src/checks/windows/password-policy.ts | 111 +++ .../src/checks/windows/screen-lock.ts | 126 +++ packages/device-agent/src/main/auth.ts | 255 ++++++ packages/device-agent/src/main/auto-launch.ts | 36 + packages/device-agent/src/main/device-info.ts | 135 +++ packages/device-agent/src/main/index.ts | 235 ++++++ packages/device-agent/src/main/logger.ts | 59 ++ packages/device-agent/src/main/reporter.ts | 82 ++ packages/device-agent/src/main/scheduler.ts | 109 +++ packages/device-agent/src/main/store.ts | 77 ++ packages/device-agent/src/main/tray.ts | 268 ++++++ packages/device-agent/src/preload/index.ts | 42 + .../device-agent/src/remediations/index.ts | 98 +++ .../src/remediations/instructions.ts | 162 ++++ .../src/remediations/linux/antivirus.ts | 33 + .../src/remediations/linux/disk-encryption.ts | 34 + .../src/remediations/linux/password-policy.ts | 65 ++ .../src/remediations/linux/screen-lock.ts | 75 ++ .../src/remediations/macos/antivirus.ts | 51 ++ .../src/remediations/macos/disk-encryption.ts | 51 ++ .../src/remediations/macos/password-policy.ts | 62 ++ .../src/remediations/macos/screen-lock.ts | 62 ++ .../device-agent/src/remediations/types.ts | 16 + .../src/remediations/windows/antivirus.ts | 66 ++ .../remediations/windows/disk-encryption.ts | 62 ++ .../remediations/windows/password-policy.ts | 65 ++ .../src/remediations/windows/screen-lock.ts | 65 ++ packages/device-agent/src/renderer/App.tsx | 439 ++++++++++ packages/device-agent/src/renderer/index.html | 13 + packages/device-agent/src/renderer/main.tsx | 13 + packages/device-agent/src/renderer/styles.css | 12 + packages/device-agent/src/shared/constants.ts | 22 + packages/device-agent/src/shared/types.ts | 124 +++ packages/device-agent/tsconfig.json | 21 + packages/docs/openapi.json | 9 + turbo.json | 5 + 133 files changed, 9414 insertions(+), 1909 deletions(-) create mode 100644 .github/workflows/device-agent-release.yml create mode 100644 apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.tsx create mode 100644 apps/portal/src/app/api/device-agent/check-in/route.ts create mode 100644 apps/portal/src/app/api/device-agent/my-organizations/route.ts create mode 100644 apps/portal/src/app/api/device-agent/register/route.ts create mode 100644 apps/portal/src/app/api/device-agent/status/route.ts delete mode 100644 apps/portal/src/app/api/download-agent/fleet-label.ts delete mode 100644 apps/portal/src/app/api/download-agent/scripts.ts delete mode 100644 apps/portal/src/app/api/download-agent/scripts/common.ts delete mode 100644 apps/portal/src/app/api/download-agent/scripts/index.ts delete mode 100644 apps/portal/src/app/api/download-agent/scripts/mac.ts create mode 100644 apps/portal/src/app/portal.css create mode 100644 packages/db/prisma/migrations/20260208120000_add_device_agent_models/migration.sql create mode 100644 packages/db/prisma/schema/device.prisma create mode 100644 packages/device-agent/.gitignore create mode 100644 packages/device-agent/BUILD.md create mode 100644 packages/device-agent/SPEC.md create mode 100644 packages/device-agent/assets/16x16-default.png create mode 100644 packages/device-agent/assets/16x16-fail.png create mode 100644 packages/device-agent/assets/16x16-pass.png create mode 100644 packages/device-agent/assets/entitlements.mac.plist create mode 100644 packages/device-agent/assets/icon.icns create mode 100644 packages/device-agent/assets/icon.png create mode 100644 packages/device-agent/assets/logo.png create mode 100644 packages/device-agent/electron-builder.yml create mode 100644 packages/device-agent/electron.vite.config.ts create mode 100644 packages/device-agent/package.json create mode 100644 packages/device-agent/scripts/generate-icons.ts create mode 100644 packages/device-agent/src/checks/index.ts create mode 100644 packages/device-agent/src/checks/linux/antivirus.ts create mode 100644 packages/device-agent/src/checks/linux/disk-encryption.ts create mode 100644 packages/device-agent/src/checks/linux/password-policy.ts create mode 100644 packages/device-agent/src/checks/linux/screen-lock.ts create mode 100644 packages/device-agent/src/checks/macos/antivirus.ts create mode 100644 packages/device-agent/src/checks/macos/disk-encryption.ts create mode 100644 packages/device-agent/src/checks/macos/password-policy.ts create mode 100644 packages/device-agent/src/checks/macos/screen-lock.ts create mode 100644 packages/device-agent/src/checks/types.ts create mode 100644 packages/device-agent/src/checks/windows/antivirus.ts create mode 100644 packages/device-agent/src/checks/windows/disk-encryption.ts create mode 100644 packages/device-agent/src/checks/windows/password-policy.ts create mode 100644 packages/device-agent/src/checks/windows/screen-lock.ts create mode 100644 packages/device-agent/src/main/auth.ts create mode 100644 packages/device-agent/src/main/auto-launch.ts create mode 100644 packages/device-agent/src/main/device-info.ts create mode 100644 packages/device-agent/src/main/index.ts create mode 100644 packages/device-agent/src/main/logger.ts create mode 100644 packages/device-agent/src/main/reporter.ts create mode 100644 packages/device-agent/src/main/scheduler.ts create mode 100644 packages/device-agent/src/main/store.ts create mode 100644 packages/device-agent/src/main/tray.ts create mode 100644 packages/device-agent/src/preload/index.ts create mode 100644 packages/device-agent/src/remediations/index.ts create mode 100644 packages/device-agent/src/remediations/instructions.ts create mode 100644 packages/device-agent/src/remediations/linux/antivirus.ts create mode 100644 packages/device-agent/src/remediations/linux/disk-encryption.ts create mode 100644 packages/device-agent/src/remediations/linux/password-policy.ts create mode 100644 packages/device-agent/src/remediations/linux/screen-lock.ts create mode 100644 packages/device-agent/src/remediations/macos/antivirus.ts create mode 100644 packages/device-agent/src/remediations/macos/disk-encryption.ts create mode 100644 packages/device-agent/src/remediations/macos/password-policy.ts create mode 100644 packages/device-agent/src/remediations/macos/screen-lock.ts create mode 100644 packages/device-agent/src/remediations/types.ts create mode 100644 packages/device-agent/src/remediations/windows/antivirus.ts create mode 100644 packages/device-agent/src/remediations/windows/disk-encryption.ts create mode 100644 packages/device-agent/src/remediations/windows/password-policy.ts create mode 100644 packages/device-agent/src/remediations/windows/screen-lock.ts create mode 100644 packages/device-agent/src/renderer/App.tsx create mode 100644 packages/device-agent/src/renderer/index.html create mode 100644 packages/device-agent/src/renderer/main.tsx create mode 100644 packages/device-agent/src/renderer/styles.css create mode 100644 packages/device-agent/src/shared/constants.ts create mode 100644 packages/device-agent/src/shared/types.ts create mode 100644 packages/device-agent/tsconfig.json diff --git a/.github/workflows/device-agent-release.yml b/.github/workflows/device-agent-release.yml new file mode 100644 index 000000000..f32df85d6 --- /dev/null +++ b/.github/workflows/device-agent-release.yml @@ -0,0 +1,179 @@ +name: Device Agent Release + +on: + push: + tags: + - 'device-agent-v*' + +permissions: + contents: write + +jobs: + build-macos: + name: Build macOS (.dmg) + runs-on: macos-latest + defaults: + run: + working-directory: packages/device-agent + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build + run: bun run build + + - name: Package macOS + env: + CSC_LINK: ${{ secrets.MAC_CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bun run package:mac + + - name: Upload macOS artifact + uses: actions/upload-artifact@v4 + with: + name: device-agent-macos + path: packages/device-agent/release/*.dmg + if-no-files-found: error + + build-windows: + name: Build Windows (.exe) + runs-on: windows-latest + defaults: + run: + working-directory: packages/device-agent + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build + run: bun run build + + - name: Package Windows + env: + CSC_LINK: ${{ secrets.WIN_CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bun run package:win + + - name: Upload Windows artifact + uses: actions/upload-artifact@v4 + with: + name: device-agent-windows + path: packages/device-agent/release/*.exe + if-no-files-found: error + + build-linux: + name: Build Linux (.AppImage, .deb) + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/device-agent + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build + run: bun run build + + - name: Package Linux + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bun run package:linux + + - name: Upload Linux artifact + uses: actions/upload-artifact@v4 + with: + name: device-agent-linux + path: | + packages/device-agent/release/*.AppImage + packages/device-agent/release/*.deb + if-no-files-found: error + + release: + name: Create GitHub Release + needs: [build-macos, build-windows, build-linux] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download macOS artifact + uses: actions/download-artifact@v4 + with: + name: device-agent-macos + path: artifacts/ + + - name: Download Windows artifact + uses: actions/download-artifact@v4 + with: + name: device-agent-windows + path: artifacts/ + + - name: Download Linux artifact + uses: actions/download-artifact@v4 + with: + name: device-agent-linux + path: artifacts/ + + - name: Extract version from tag + id: version + run: echo "version=${GITHUB_REF_NAME#device-agent-v}" >> $GITHUB_OUTPUT + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: "Device Agent v${{ steps.version.outputs.version }}" + body: | + ## Comp AI Device Agent v${{ steps.version.outputs.version }} + + ### Downloads + - **macOS**: Download the `.dmg` file below (universal binary, Apple Silicon + Intel) + - **Windows**: Download the `.exe` installer below + - **Linux**: Download the `.AppImage` (portable) or `.deb` (Debian/Ubuntu) below + + ### What's included + - Disk encryption check (FileVault / BitLocker / LUKS) + - Antivirus detection (XProtect / Windows Defender / ClamAV + AppArmor/SELinux) + - Password policy enforcement (minimum 8 characters) + - Screen lock verification (5 minutes or less) + - Auto-remediation for fixable settings with guided instructions + + ### Installation + 1. Download the installer for your operating system + 2. Run the installer and follow the prompts + 3. Sign in with your Comp AI portal credentials + 4. The agent will run in your system tray and check compliance automatically + draft: false + prerelease: false + files: artifacts/* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/apps/api/src/devices/devices.service.ts b/apps/api/src/devices/devices.service.ts index 0d977c9f0..1a60a0a56 100644 --- a/apps/api/src/devices/devices.service.ts +++ b/apps/api/src/devices/devices.service.ts @@ -1,9 +1,14 @@ import { Injectable, NotFoundException, Logger } from '@nestjs/common'; import { db } from '@trycompai/db'; import { FleetService } from '../lib/fleet.service'; -import type { DeviceResponseDto } from './dto/device-responses.dto'; +import { DeviceResponseDto } from './dto/device-responses.dto'; import type { MemberResponseDto } from './dto/member-responses.dto'; +/** + * Hybrid device service that fetches from both FleetDM and the Device Agent database. + * FleetDM is the legacy system; Device Agent is the new system. + * Results are merged and deduplicated by serial number / hostname. + */ @Injectable() export class DevicesService { private readonly logger = new Logger(DevicesService.name); @@ -13,86 +18,86 @@ export class DevicesService { async findAllByOrganization( organizationId: string, ): Promise { - try { - // Get organization and its FleetDM label ID - const organization = await db.organization.findUnique({ - where: { id: organizationId }, - select: { - id: true, - name: true, - fleetDmLabelId: true, - }, - }); + const organization = await db.organization.findUnique({ + where: { id: organizationId }, + select: { + id: true, + name: true, + fleetDmLabelId: true, + }, + }); - if (!organization) { - throw new NotFoundException( - `Organization with ID ${organizationId} not found`, - ); - } + if (!organization) { + throw new NotFoundException( + `Organization with ID ${organizationId} not found`, + ); + } - if (!organization.fleetDmLabelId) { - this.logger.warn( - `Organization ${organizationId} does not have FleetDM label configured`, - ); - return []; - } + // Fetch from both sources in parallel + const [fleetDevices, agentDevices] = await Promise.all([ + this.getFleetDevicesForOrg(organization.fleetDmLabelId, organizationId), + this.getAgentDevicesForOrg(organizationId), + ]); - // Get all hosts for the organization's label - const labelHosts = await this.fleetService.getHostsByLabel( - organization.fleetDmLabelId, - ); + // Merge and deduplicate (agent devices take priority) + return this.mergeDevices(agentDevices, fleetDevices); + } - if (!labelHosts.hosts || labelHosts.hosts.length === 0) { - this.logger.log(`No devices found for organization ${organizationId}`); - return []; - } + async findAllByMember( + organizationId: string, + memberId: string, + ): Promise { + // Verify organization exists + const organization = await db.organization.findUnique({ + where: { id: organizationId }, + select: { id: true, name: true }, + }); - // Extract host IDs - const hostIds = labelHosts.hosts.map((host: { id: number }) => host.id); - this.logger.log( - `Found ${hostIds.length} devices for organization ${organizationId}`, + if (!organization) { + throw new NotFoundException( + `Organization with ID ${organizationId} not found`, ); + } - // Get detailed information for each host - const devices = await this.fleetService.getMultipleHosts(hostIds); + // Verify the member exists and belongs to the organization + const member = await db.member.findFirst({ + where: { + id: memberId, + organizationId: organizationId, + deactivated: false, + }, + select: { + id: true, + userId: true, + role: true, + department: true, + isActive: true, + fleetDmLabelId: true, + organizationId: true, + createdAt: true, + }, + }); - this.logger.log( - `Retrieved ${devices.length} device details for organization ${organizationId}`, + if (!member) { + throw new NotFoundException( + `Member with ID ${memberId} not found in organization ${organizationId}`, ); - return devices; - } catch (error) { - if (error instanceof NotFoundException) { - throw error; - } - this.logger.error( - `Failed to retrieve devices for organization ${organizationId}:`, - error, - ); - throw new Error(`Failed to retrieve devices: ${error.message}`); } + + // Fetch from both sources in parallel + const [fleetDevices, agentDevices] = await Promise.all([ + this.getFleetDevicesForMember(member.fleetDmLabelId, memberId), + this.getAgentDevicesForUser(member.userId, organizationId), + ]); + + return this.mergeDevices(agentDevices, fleetDevices); } - async findAllByMember( + async getMemberById( organizationId: string, memberId: string, - ): Promise { + ): Promise { try { - // First verify the organization exists - const organization = await db.organization.findUnique({ - where: { id: organizationId }, - select: { - id: true, - name: true, - }, - }); - - if (!organization) { - throw new NotFoundException( - `Organization with ID ${organizationId} not found`, - ); - } - - // Verify the member exists and belongs to the organization const member = await db.member.findFirst({ where: { id: memberId, @@ -117,85 +122,214 @@ export class DevicesService { ); } - if (!member.fleetDmLabelId) { - this.logger.warn( - `Member ${memberId} does not have FleetDM label configured`, - ); + return member; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + this.logger.error( + `Failed to retrieve member ${memberId} in organization ${organizationId}:`, + error, + ); + throw new Error(`Failed to retrieve member: ${error.message}`); + } + } + + // --- Private helpers --- + + private async getFleetDevicesForOrg( + fleetDmLabelId: number | null, + organizationId: string, + ): Promise { + if (!fleetDmLabelId) { + return []; + } + + try { + const labelHosts = + await this.fleetService.getHostsByLabel(fleetDmLabelId); + + if (!labelHosts.hosts || labelHosts.hosts.length === 0) { return []; } - // Get devices for the member's specific FleetDM label - const labelHosts = await this.fleetService.getHostsByLabel( - member.fleetDmLabelId, + const hostIds = labelHosts.hosts.map((host: { id: number }) => host.id); + const devices = await this.fleetService.getMultipleHosts(hostIds); + + // Tag each device with source + return devices.map((d: DeviceResponseDto) => ({ + ...d, + source: 'fleet' as const, + })); + } catch (error) { + this.logger.warn( + `Failed to fetch FleetDM devices for org ${organizationId}: ${error instanceof Error ? error.message : error}`, ); + return []; + } + } + + private async getFleetDevicesForMember( + fleetDmLabelId: number | null, + memberId: string, + ): Promise { + if (!fleetDmLabelId) { + return []; + } + + try { + const labelHosts = + await this.fleetService.getHostsByLabel(fleetDmLabelId); if (!labelHosts.hosts || labelHosts.hosts.length === 0) { - this.logger.log(`No devices found for member ${memberId}`); return []; } - // Extract host IDs const hostIds = labelHosts.hosts.map((host: { id: number }) => host.id); - this.logger.log(`Found ${hostIds.length} devices for member ${memberId}`); - - // Get detailed information for each host const devices = await this.fleetService.getMultipleHosts(hostIds); - this.logger.log( - `Retrieved ${devices.length} device details for member ${memberId} in organization ${organizationId}`, - ); - return devices; + return devices.map((d: DeviceResponseDto) => ({ + ...d, + source: 'fleet' as const, + })); } catch (error) { - if (error instanceof NotFoundException) { - throw error; - } - this.logger.error( - `Failed to retrieve devices for member ${memberId} in organization ${organizationId}:`, - error, + this.logger.warn( + `Failed to fetch FleetDM devices for member ${memberId}: ${error instanceof Error ? error.message : error}`, ); - throw new Error(`Failed to retrieve member devices: ${error.message}`); + return []; } } - async getMemberById( + private async getAgentDevicesForOrg( organizationId: string, - memberId: string, - ): Promise { + ): Promise { try { - const member = await db.member.findFirst({ - where: { - id: memberId, - organizationId: organizationId, - deactivated: false, - }, - select: { - id: true, - userId: true, - role: true, - department: true, - isActive: true, - fleetDmLabelId: true, - organizationId: true, - createdAt: true, + const devices = await db.device.findMany({ + where: { organizationId }, + include: { + checks: { + orderBy: { checkedAt: 'desc' }, + }, + user: { + select: { name: true, email: true }, + }, }, }); - if (!member) { - throw new NotFoundException( - `Member with ID ${memberId} not found in organization ${organizationId}`, - ); - } + return devices.map((device) => this.mapAgentDeviceToDto(device)); + } catch (error) { + this.logger.warn( + `Failed to fetch agent devices for org ${organizationId}: ${error instanceof Error ? error.message : error}`, + ); + return []; + } + } - return member; + private async getAgentDevicesForUser( + userId: string, + organizationId: string, + ): Promise { + try { + const devices = await db.device.findMany({ + where: { userId, organizationId }, + include: { + checks: { + orderBy: { checkedAt: 'desc' }, + }, + user: { + select: { name: true, email: true }, + }, + }, + }); + + return devices.map((device) => this.mapAgentDeviceToDto(device)); } catch (error) { - if (error instanceof NotFoundException) { - throw error; - } - this.logger.error( - `Failed to retrieve member ${memberId} in organization ${organizationId}:`, - error, + this.logger.warn( + `Failed to fetch agent devices for user ${userId}: ${error instanceof Error ? error.message : error}`, ); - throw new Error(`Failed to retrieve member: ${error.message}`); + return []; } } + + private mapAgentDeviceToDto(device: { + id: string; + name: string; + hostname: string; + platform: string; + osVersion: string; + serialNumber: string | null; + hardwareModel: string | null; + isCompliant: boolean; + lastCheckIn: Date | null; + agentVersion: string | null; + installedAt: Date; + user: { name: string; email: string }; + checks: Array<{ + id: string; + checkType: string; + passed: boolean; + details: unknown; + checkedAt: Date; + }>; + }): DeviceResponseDto { + // Construct a partial DTO with device-agent fields; consumers should handle + // missing FleetDM-specific fields gracefully via the `source` field. + const dto = new DeviceResponseDto(); + dto.id = device.id as unknown as number; + dto.computer_name = device.name; + dto.hostname = device.hostname; + dto.platform = device.platform === 'macos' ? 'darwin' : device.platform; + dto.os_version = device.osVersion; + dto.hardware_serial = device.serialNumber ?? ''; + dto.hardware_model = device.hardwareModel ?? ''; + dto.seen_time = device.lastCheckIn?.toISOString() ?? ''; + dto.created_at = device.installedAt.toISOString(); + dto.updated_at = device.installedAt.toISOString(); + dto.display_name = device.name; + dto.display_text = device.name; + dto.status = device.isCompliant ? 'compliant' : 'non-compliant'; + dto.disk_encryption_enabled = device.checks.some( + (c) => c.checkType === 'disk_encryption' && c.passed, + ); + dto.source = 'device_agent'; + // Default empty values for FleetDM-specific fields + dto.software = []; + dto.pack_stats = []; + dto.users = []; + dto.labels = []; + dto.packs = []; + dto.batteries = []; + dto.end_users = []; + dto.policies = []; + dto.issues = {}; + dto.mdm = {}; + return dto; + } + + private mergeDevices( + agentDevices: DeviceResponseDto[], + fleetDevices: DeviceResponseDto[], + ): DeviceResponseDto[] { + // Build sets of known identifiers from agent devices + const knownSerials = new Set(); + const knownHostnames = new Set(); + + for (const device of agentDevices) { + const serial = (device as { hardware_serial?: string }).hardware_serial; + const hostname = (device as { hostname?: string }).hostname; + if (serial) knownSerials.add(serial.toLowerCase()); + if (hostname) knownHostnames.add(hostname.toLowerCase()); + } + + // Filter out fleet devices that overlap with agent devices + const uniqueFleetDevices = fleetDevices.filter((device) => { + const serial = (device as { hardware_serial?: string }).hardware_serial; + const hostname = (device as { hostname?: string }).hostname; + if (serial && knownSerials.has(serial.toLowerCase())) return false; + if (hostname && knownHostnames.has(hostname.toLowerCase())) return false; + return true; + }); + + return [...agentDevices, ...uniqueFleetDevices]; + } } diff --git a/apps/api/src/devices/dto/device-responses.dto.ts b/apps/api/src/devices/dto/device-responses.dto.ts index 70bc4c072..a21695392 100644 --- a/apps/api/src/devices/dto/device-responses.dto.ts +++ b/apps/api/src/devices/dto/device-responses.dto.ts @@ -319,4 +319,12 @@ export class DeviceResponseDto { @ApiProperty({ description: 'Display name', example: "John's MacBook Pro" }) display_name: string; + + @ApiProperty({ + description: 'Source system that reported this device', + example: 'fleet', + enum: ['fleet', 'device_agent'], + required: false, + }) + source?: 'fleet' | 'device_agent'; } diff --git a/apps/api/src/lib/fleet.service.ts b/apps/api/src/lib/fleet.service.ts index f23c8245e..cfa472920 100644 --- a/apps/api/src/lib/fleet.service.ts +++ b/apps/api/src/lib/fleet.service.ts @@ -1,6 +1,12 @@ import { Injectable, Logger } from '@nestjs/common'; import axios, { AxiosInstance } from 'axios'; +/** + * @deprecated FleetDM integration is being replaced by the custom Comp AI Device Agent. + * See packages/device-agent for the new implementation. + * Device data is now stored in the Device and DeviceCheck database models. + * This service will be removed in a future release. + */ @Injectable() export class FleetService { private readonly logger = new Logger(FleetService.name); diff --git a/apps/api/src/people/people.service.ts b/apps/api/src/people/people.service.ts index 19728361e..27e646b35 100644 --- a/apps/api/src/people/people.service.ts +++ b/apps/api/src/people/people.service.ts @@ -4,6 +4,7 @@ import { Logger, BadRequestException, } from '@nestjs/common'; +import { db } from '@trycompai/db'; import { FleetService } from '../lib/fleet.service'; import type { PeopleResponseDto } from './dto/people-responses.dto'; import type { CreatePeopleDto } from './dto/create-people.dto'; @@ -318,6 +319,27 @@ export class PeopleService { } } + // Also delete device-agent Device records for this member's user + organization + try { + const deleteResult = await db.device.deleteMany({ + where: { + userId: existingMember.user.id, + organizationId, + }, + }); + if (deleteResult.count > 0) { + this.logger.log( + `Deleted ${deleteResult.count} device-agent device(s) for user ${existingMember.user.id} in org ${organizationId}`, + ); + } + } catch (deviceError) { + // Log but don't fail the operation + this.logger.error( + `Failed to delete device-agent devices for user ${existingMember.user.id}:`, + deviceError, + ); + } + const updatedMember = await MemberQueries.unlinkDevice(memberId); this.logger.log( diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx index a263922fc..ee3c43006 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx @@ -2,12 +2,13 @@ import type { TrainingVideo } from '@/lib/data/training-videos'; import type { EmployeeTrainingVideoCompletion, Member, Organization, Policy, User } from '@db'; -import { Stack } from '@trycompai/design-system'; -import type { FleetPolicy, Host } from '../../devices/types'; -import { EmployeeDetails } from './EmployeeDetails'; +import { Button, PageHeader, PageLayout, Stack } from '@trycompai/design-system'; +import { useCallback, useState } from 'react'; +import type { DeviceWithChecks, FleetPolicy, Host } from '../../devices/types'; +import { EMPLOYEE_FORM_ID, EmployeeDetails } from './EmployeeDetails'; import { EmployeeTasks } from './EmployeeTasks'; -interface EmployeeDetailsProps { +interface EmployeeProps { employee: Member & { user: User; }; @@ -19,6 +20,8 @@ interface EmployeeDetailsProps { host: Host; canEdit: boolean; organization: Organization; + memberDevice: DeviceWithChecks | null; + orgId: string; } export function Employee({ @@ -29,18 +32,55 @@ export function Employee({ host, canEdit, organization, -}: EmployeeDetailsProps) { + memberDevice, + orgId, +}: EmployeeProps) { + const [formState, setFormState] = useState({ isDirty: false, isLoading: false }); + + const handleFormStateChange = useCallback((state: { isDirty: boolean; isLoading: boolean }) => { + setFormState(state); + }, []); + return ( - - - - + + Save + + ) : undefined + } + /> + } + > + + + + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx index d9a16b72e..d9f9733d2 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx @@ -1,12 +1,11 @@ 'use client'; -import { Button } from '@comp/ui/button'; import { Form } from '@comp/ui/form'; import type { Departments, Member, User } from '@db'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Section, Stack } from '@trycompai/design-system'; -import { Save } from 'lucide-react'; +import { Grid } from '@trycompai/design-system'; import { useAction } from 'next-safe-action/hooks'; +import { useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; import { z } from 'zod'; @@ -28,14 +27,18 @@ const employeeFormSchema = z.object({ export type EmployeeFormValues = z.infer; +export const EMPLOYEE_FORM_ID = 'employee-details-form'; + export const EmployeeDetails = ({ employee, canEdit, + onFormStateChange, }: { employee: Member & { user: User; }; canEdit: boolean; + onFormStateChange?: (state: { isDirty: boolean; isLoading: boolean }) => void; }) => { const form = useForm({ resolver: zodResolver(employeeFormSchema), @@ -101,36 +104,24 @@ export const EmployeeDetails = ({ } }; + const isLoading = form.formState.isSubmitting || actionStatus === 'executing'; + const { isDirty } = form.formState; + + useEffect(() => { + onFormStateChange?.({ isDirty, isLoading }); + }, [isDirty, isLoading, onFormStateChange]); + return ( -
-
- - -
- - - - - -
-
- -
-
-
- -
+
+ + + + + + + + +
+ ); }; diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx index cf66e3446..685989fb0 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx @@ -3,21 +3,41 @@ import type { TrainingVideo } from '@/lib/data/training-videos'; import type { EmployeeTrainingVideoCompletion, Member, Organization, Policy, User } from '@db'; -import { Card, CardContent, CardHeader, CardTitle } from '@comp/ui/card'; import { - Section, + Badge, + Button, + Card, + CardContent, + CardHeader, Stack, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, Tabs, TabsContent, TabsList, TabsTrigger, Text, } from '@trycompai/design-system'; -import { AlertCircle, Award, CheckCircle2, Download } from 'lucide-react'; -import type { FleetPolicy, Host } from '../../devices/types'; +import { Download } from '@trycompai/design-system/icons'; import { PolicyItem } from '../../devices/components/PolicyItem'; +import type { DeviceWithChecks, FleetPolicy, Host } from '../../devices/types'; import { downloadTrainingCertificate } from '../actions/download-training-certificate'; -import { cn } from '@/lib/utils'; + +const CHECK_NAMES: Record = { + disk_encryption: 'Disk Encryption', + antivirus: 'Antivirus', + password_policy: 'Password Policy', + screen_lock: 'Screen Lock', +}; + +const PLATFORM_LABELS: Record = { + macos: 'macOS', + windows: 'Windows', +}; export const EmployeeTasks = ({ employee, @@ -26,6 +46,7 @@ export const EmployeeTasks = ({ host, fleetPolicies, organization, + memberDevice, }: { employee: Member & { user: User; @@ -37,6 +58,7 @@ export const EmployeeTasks = ({ host: Host; fleetPolicies: FleetPolicy[]; organization: Organization; + memberDevice: DeviceWithChecks | null; }) => { // Calculate training completion status const completedVideos = trainingVideos.filter((v) => v.completedAt !== null); @@ -79,159 +101,235 @@ export const EmployeeTasks = ({ window.URL.revokeObjectURL(url); } }; + return ( -
- - - - Policies - Training Videos - Device - - - - - {policies.length === 0 ? ( -
- No policies required to sign. -
- ) : ( - policies.map((policy) => { - const isCompleted = policy.signedBy.includes(employee.id); + + + + Policies + Training Videos + Device + + + {policies.length === 0 ? ( +
+ No policies required to sign. +
+ ) : ( + + + + Policy + Status + + + + {policies.map((policy) => { + const isCompleted = policy.signedBy.includes(employee.id); return ( -
-
- {isCompleted ? ( - - ) : ( - - )} - {policy.name} -
-
- ); - }) - )} - - - - - - {/* Training Completion Summary */} - {trainingVideos.length > 0 && ( -
-
-
- -
-
- - {allTrainingComplete - ? 'All Training Complete' - : `${completedVideos.length}/${trainingVideos.length} Videos Completed`} - - {trainingCompletionDate && ( - - Completed on{' '} - {new Date(trainingCompletionDate).toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - })} + + + + {policy.name} - )} -
-
- {allTrainingComplete && ( - - )} -
- )} + + + + {isCompleted ? 'Signed' : 'Pending'} + + + + ); + })} +
+
+ )} +
- - {trainingVideos.length === 0 ? ( -
- No training videos required to watch. -
- ) : ( - trainingVideos.map((video) => { + + + {trainingVideos.length === 0 ? ( +
+ No training videos required to watch. +
+ ) : ( + + + + Training Video + Status + Completed + + + + {trainingVideos.map((video) => { const isCompleted = video.completedAt !== null; - return ( -
- -
- {isCompleted ? ( - - ) : ( - - )} - {video.metadata.title} -
- {isCompleted && ( - - Completed -{' '} - {video.completedAt && - new Date(video.completedAt).toLocaleDateString()} - - )} -
-
+ + + + {video.metadata.title} + + + + + {isCompleted ? 'Complete' : 'Incomplete'} + + + + + {isCompleted && video.completedAt + ? new Date(video.completedAt).toLocaleDateString() + : '—'} + + + ); - }) - )} - - - + })} +
+
+ )} + + {allTrainingComplete && ( +
+ +
+ )} +
+
- - {host ? ( + + {memberDevice ? ( + - {host.computer_name}'s Policies +
+
+ + {memberDevice.name} + + + {PLATFORM_LABELS[memberDevice.platform] ?? memberDevice.platform}{' '} + {memberDevice.osVersion} + {memberDevice.hardwareModel ? ` \u2022 ${memberDevice.hardwareModel}` : ''} + +
+ + {memberDevice.isCompliant ? 'Compliant' : 'Non-Compliant'} + +
- - {fleetPolicies.map((policy) => )} + +
+
+ + Hostname + + + {memberDevice.hostname} + +
+
+ + Serial Number + + + {memberDevice.serialNumber ?? 'N/A'} + +
+
+ + Last Check-in + + + {memberDevice.lastCheckIn + ? new Date(memberDevice.lastCheckIn).toLocaleString() + : 'Never'} + +
+
+ + Agent Version + + + {memberDevice.agentVersion ?? 'N/A'} + +
+
- ) : ( -
- No device found. -
- )} -
-
-
-
+ + {memberDevice.checks.length > 0 ? ( + + + + Check + Details + Result + + + + {memberDevice.checks.map((check) => ( + + + + {CHECK_NAMES[check.checkType] ?? check.checkType} + + + + + {check.details && + typeof check.details === 'object' && + 'message' in check.details + ? String(check.details.message) + : '—'} + + + + + {check.passed ? 'Pass' : 'Fail'} + + + + ))} + +
+ ) : ( + + No compliance checks have been run yet. + + )} + + ) : host ? ( + + + + {host.computer_name}'s Policies + + + +
+ {fleetPolicies.map((policy) => ( + + ))} +
+
+
+ ) : ( +
+ No device found. +
+ )} + + + ); }; diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Department.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Department.tsx index c08004b8e..34cd058f2 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Department.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Department.tsx @@ -1,6 +1,14 @@ -import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@comp/ui/form'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select'; +import { FormControl, FormField, FormItem, FormMessage } from '@comp/ui/form'; import type { Departments } from '@db'; +import { + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Stack, +} from '@trycompai/design-system'; import type { Control } from 'react-hook-form'; import type { EmployeeFormValues } from '../EmployeeDetails'; @@ -26,30 +34,32 @@ export const Department = ({ control={control} name="department" render={({ field }) => ( - - - Department - - + + + {DEPARTMENTS.find((d) => d.value === field.value)?.label ?? field.value} + + + + {DEPARTMENTS.map((dept) => ( + + {dept.label} + + ))} + + - - {DEPARTMENTS.map((dept) => ( - - {dept.label} - - ))} - - - + + )} /> diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Email.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Email.tsx index af31cdd29..47d814bd6 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Email.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Email.tsx @@ -1,5 +1,5 @@ -import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@comp/ui/form'; -import { Input } from '@comp/ui/input'; +import { FormControl, FormField, FormItem, FormMessage } from '@comp/ui/form'; +import { InputGroup, InputGroupInput, Label, Stack } from '@trycompai/design-system'; import type { Control } from 'react-hook-form'; import type { EmployeeFormValues } from '../EmployeeDetails'; @@ -15,20 +15,22 @@ export const Email = ({ control={control} name="email" render={({ field }) => ( - - - EMAIL - - - - - + + + + + + + + + + )} /> diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/JoinDate.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/JoinDate.tsx index da6f2c223..07527b49d 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/JoinDate.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/JoinDate.tsx @@ -1,11 +1,18 @@ 'use client'; -import { Button } from '@comp/ui/button'; -import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@comp/ui/form'; +import { FormControl, FormField, FormItem, FormMessage } from '@comp/ui/form'; import { Popover, PopoverContent, PopoverTrigger } from '@comp/ui/popover'; -import { Calendar } from '@trycompai/design-system'; +import { + Button, + Calendar, + InputGroup, + InputGroupAddon, + InputGroupInput, + Label, + Stack, +} from '@trycompai/design-system'; +import { Calendar as CalendarIcon } from '@trycompai/design-system/icons'; import { format } from 'date-fns'; -import { ChevronDown } from 'lucide-react'; import { useState } from 'react'; import type { Control } from 'react-hook-form'; import type { EmployeeFormValues } from '../EmployeeDetails'; @@ -25,49 +32,46 @@ export const JoinDate = ({ name="createdAt" render={({ field }) => { return ( - - - - + + + + - Join Date - - - - - - date && field.onChange(date)} - captionLayout="dropdown" - disabled={(date) => date > new Date()} - /> -
- -
-
-
-
- + + + + + + + + + + date && field.onChange(date)} + captionLayout="dropdown" + disabled={(date) => date > new Date()} + /> +
+ +
+
+ + + +
); }} diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Name.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Name.tsx index 38dc6c952..32ac066fa 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Name.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Name.tsx @@ -1,5 +1,5 @@ -import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@comp/ui/form'; -import { Input } from '@comp/ui/input'; +import { FormControl, FormField, FormItem, FormMessage } from '@comp/ui/form'; +import { InputGroup, InputGroupInput, Label, Stack } from '@trycompai/design-system'; import type { Control } from 'react-hook-form'; import type { EmployeeFormValues } from '../EmployeeDetails'; @@ -15,14 +15,21 @@ export const Name = ({ control={control} name="name" render={({ field }) => ( - - - NAME - - - - - + + + + + + + + + + )} /> diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Status.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Status.tsx index 7368291df..fe59092b8 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Status.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Status.tsx @@ -1,7 +1,14 @@ import type { EmployeeStatusType } from '@/components/tables/people/employee-status'; -import { cn } from '@comp/ui/cn'; -import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@comp/ui/form'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select'; +import { FormControl, FormField, FormItem, FormMessage } from '@comp/ui/form'; +import { + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Stack, +} from '@trycompai/design-system'; import type { Control } from 'react-hook-form'; import type { EmployeeFormValues } from '../EmployeeDetails'; @@ -28,38 +35,49 @@ export const Status = ({ control={control} name="status" render={({ field }) => ( - - - Status - - + + +
+
+ {STATUS_OPTIONS.find((o) => o.value === field.value)?.label ?? field.value} +
+ + + + {STATUS_OPTIONS.map((option) => ( + +
+
+ {option.label} +
+ + ))} + + - - {STATUS_OPTIONS.map((option) => ( - -
-
- {option.label} -
- - ))} - - - + + )} /> diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx index 807a57078..d1155c76e 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx @@ -7,10 +7,10 @@ import { import { getFleetInstance } from '@/lib/fleet'; import type { EmployeeTrainingVideoCompletion, Member, User } from '@db'; import { db } from '@db'; -import { PageHeader, PageLayout } from '@trycompai/design-system'; import type { Metadata } from 'next'; import { headers } from 'next/headers'; import { notFound, redirect } from 'next/navigation'; +import type { DeviceWithChecks } from '../devices/types'; import { Employee } from './components/Employee'; const MDM_POLICY_ID = -9999; @@ -59,29 +59,20 @@ export default async function EmployeeDetailsPage({ } const { fleetPolicies, device } = await getFleetPolicies(employee); + const memberDevice = await getMemberDevice(employee.userId, orgId); return ( - - } - > - - + ); } @@ -232,16 +223,27 @@ const getFleetPolicies = async (member: Member & { user: User }) => { return { fleetPolicies: [ ...(host.policies || []), - ...(isMacOS ? [{ id: MDM_POLICY_ID, name: 'MDM Enabled', response: host.mdm.connected_to_fleet ? 'pass' : 'fail' }] : []), + ...(isMacOS + ? [ + { + id: MDM_POLICY_ID, + name: 'MDM Enabled', + response: host.mdm.connected_to_fleet ? 'pass' : 'fail', + }, + ] + : []), ].map((policy) => { const policyResult = results.find((result) => result.fleetPolicyId === policy.id); return { ...policy, - response: policy.response === 'pass' || policyResult?.fleetPolicyResponse === 'pass' ? 'pass' : 'fail', + response: + policy.response === 'pass' || policyResult?.fleetPolicyResponse === 'pass' + ? 'pass' + : 'fail', attachments: policyResult?.attachments || [], }; }), - device: host + device: host, }; } catch (error) { console.error( @@ -251,3 +253,63 @@ const getFleetPolicies = async (member: Member & { user: User }) => { return { fleetPolicies: [], device: null }; } }; + +const getMemberDevice = async ( + userId: string, + organizationId: string, +): Promise => { + const device = await db.device.findFirst({ + where: { userId, organizationId }, + include: { + checks: { + orderBy: { checkedAt: 'desc' }, + }, + user: { + select: { name: true, email: true }, + }, + }, + orderBy: { installedAt: 'desc' }, + }); + + if (!device) { + return null; + } + + // Keep only the latest check per type + const latestChecks = new Map(); + for (const check of device.checks) { + if (!latestChecks.has(check.checkType)) { + latestChecks.set(check.checkType, check); + } + } + + return { + id: device.id, + name: device.name, + hostname: device.hostname, + platform: device.platform as 'macos' | 'windows', + osVersion: device.osVersion, + serialNumber: device.serialNumber, + hardwareModel: device.hardwareModel, + isCompliant: device.isCompliant, + lastCheckIn: device.lastCheckIn?.toISOString() ?? null, + agentVersion: device.agentVersion, + installedAt: device.installedAt.toISOString(), + user: { + name: device.user.name, + email: device.user.email, + }, + checks: Array.from(latestChecks.values()).map((check) => ({ + id: check.id, + checkType: check.checkType as + | 'disk_encryption' + | 'antivirus' + | 'password_policy' + | 'screen_lock', + passed: check.passed, + details: check.details as Record | null, + checkedAt: check.checkedAt.toISOString(), + })), + source: 'device_agent' as const, + }; +}; diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx index 3c700371b..2f0a222a4 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx @@ -5,18 +5,6 @@ import Link from 'next/link'; import { useParams } from 'next/navigation'; import { useState } from 'react'; -import { - Avatar, - AvatarFallback, - AvatarImage, - Badge, - HStack, - Label, - TableCell, - TableRow, - Text, -} from '@trycompai/design-system'; -import { Edit, OverflowMenuVertical, TrashCan } from '@trycompai/design-system/icons'; import { Button } from '@comp/ui/button'; import { Dialog, @@ -33,6 +21,8 @@ import { DropdownMenuTrigger, } from '@comp/ui/dropdown-menu'; import type { Role } from '@db'; +import { Badge, Label, TableCell, TableRow, Text } from '@trycompai/design-system'; +import { Edit, OverflowMenuVertical, TrashCan } from '@trycompai/design-system/icons'; import { toast } from 'sonner'; import { MultiRoleCombobox } from './MultiRoleCombobox'; @@ -47,20 +37,8 @@ interface MemberRowProps { onUpdateRole: (memberId: string, roles: Role[]) => void; canEdit: boolean; isCurrentUserOwner: boolean; -} - -function getInitials(name?: string | null, email?: string | null): string { - if (name) { - return name - .split(' ') - .map((n) => n[0]) - .join('') - .toUpperCase(); - } - if (email) { - return email.substring(0, 2).toUpperCase(); - } - return '??'; + taskCompletion?: { completed: number; total: number }; + hasDeviceAgentDevice?: boolean; } function getRoleLabel(role: string): string { @@ -95,6 +73,8 @@ export function MemberRow({ onUpdateRole, canEdit, isCurrentUserOwner, + taskCompletion, + hasDeviceAgentDevice, }: MemberRowProps) { const { orgId } = useParams<{ orgId: string }>(); @@ -109,7 +89,6 @@ export function MemberRow({ const memberName = member.user.name || member.user.email || 'Member'; const memberEmail = member.user.email || ''; - const memberAvatar = member.user.image; const memberId = member.id; const currentRoles = parseRoles(member.role); @@ -168,25 +147,19 @@ export function MemberRow({ {/* NAME */} - -
- - - {getInitials(member.user.name, member.user.email)} - -
-
- - {memberName} - - {memberEmail} -
-
+
+ + {memberName} + + + {memberEmail} + +
{/* STATUS */} @@ -194,7 +167,7 @@ export function MemberRow({ {isDeactivated ? ( Inactive ) : ( - Active + Active )} @@ -202,13 +175,30 @@ export function MemberRow({
{currentRoles.map((role) => ( - + {getRoleLabel(role)} ))}
+ {/* TASKS */} + + {taskCompletion ? ( + + {taskCompletion.completed}/{taskCompletion.total} + + ) : ( + + — + + )} + + {/* ACTIONS */} {!isDeactivated && ( @@ -226,7 +216,7 @@ export function MemberRow({ Edit Roles )} - {member.fleetDmLabelId && isCurrentUserOwner && ( + {(member.fleetDmLabelId || hasDeviceAgentDevice) && isCurrentUserOwner && ( { setDropdownOpen(false); @@ -268,8 +258,8 @@ export function MemberRow({ title="Remove Device" description={ <> - Are you sure you want to remove all devices for this user{' '} - {memberName}? This will disconnect all devices from the organization. + Are you sure you want to remove all devices for this user {memberName}? + This will disconnect all devices from the organization. } onOpenChange={setIsRemoveDeviceAlertOpen} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/PendingInvitationRow.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/PendingInvitationRow.tsx index ea5c8ab89..16f5d5045 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/PendingInvitationRow.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/PendingInvitationRow.tsx @@ -10,14 +10,11 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, - Avatar, - AvatarFallback, Badge, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, - HStack, TableCell, TableRow, Text, @@ -80,14 +77,9 @@ export function PendingInvitationRow({ {/* NAME */} - - - {invitation.email.charAt(0).toUpperCase()} - -
- {invitation.email} -
-
+
+ {invitation.email} +
{/* STATUS */} @@ -106,6 +98,13 @@ export function PendingInvitationRow({
+ {/* TASKS */} + + + — + + + {/* ACTIONS */}
diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx index c04899ab9..08be40dca 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx @@ -1,5 +1,6 @@ 'use server'; +import { trainingVideos as trainingVideosData } from '@/lib/data/training-videos'; import { auth } from '@/utils/auth'; import type { Invitation, Member, User } from '@db'; import { db } from '@db'; @@ -75,6 +76,67 @@ export async function TeamMembers(props: TeamMembersProps) { // Fetch employee sync connections server-side const employeeSyncData = await getEmployeeSyncConnections(organizationId); + // Build task completion map for employees/contractors + const taskCompletionMap: Record = {}; + + const employeeMembers = members.filter((member) => { + const roles = member.role.includes(',') + ? member.role.split(',').map((r) => r.trim()) + : [member.role]; + return roles.includes('employee') || roles.includes('contractor'); + }); + + if (employeeMembers.length > 0) { + // Fetch required policies + const policies = await db.policy.findMany({ + where: { + organizationId, + isRequiredToSign: true, + status: 'published', + isArchived: false, + }, + }); + + // Fetch training video completions + const employeeIds = employeeMembers.map((m) => m.id); + const trainingCompletions = await db.employeeTrainingVideoCompletion.findMany({ + where: { + memberId: { in: employeeIds }, + }, + }); + + const totalTrainingVideos = trainingVideosData.length; + const totalPolicies = policies.length; + const totalTasks = totalPolicies + totalTrainingVideos; + + for (const employee of employeeMembers) { + const policiesCompleted = policies.filter((p) => p.signedBy.includes(employee.id)).length; + + const trainingsCompleted = trainingCompletions.filter( + (tc) => tc.memberId === employee.id && tc.completedAt !== null, + ).length; + + taskCompletionMap[employee.id] = { + completed: policiesCompleted + trainingsCompleted, + total: totalTasks, + }; + } + } + + // Build a set of member IDs whose users have device-agent devices + const memberUserIds = members.map((m) => m.userId); + const devicesForMembers = await db.device.findMany({ + where: { + organizationId, + userId: { in: memberUserIds }, + }, + select: { userId: true }, + }); + const userIdsWithDevice = new Set(devicesForMembers.map((d) => d.userId)); + const memberIdsWithDeviceAgent = members + .filter((m) => userIdsWithDevice.has(m.userId)) + .map((m) => m.id); + return ( ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx index 001fa1080..aa3877d91 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx @@ -54,6 +54,8 @@ interface TeamMembersClientProps { isAuditor: boolean; isCurrentUserOwner: boolean; employeeSyncData: EmployeeSyncConnectionsData; + taskCompletionMap: Record; + memberIdsWithDeviceAgent: string[]; } // Define a simplified type for merged list items @@ -78,6 +80,8 @@ export function TeamMembersClient({ isAuditor, isCurrentUserOwner, employeeSyncData, + taskCompletionMap, + memberIdsWithDeviceAgent, }: TeamMembersClientProps) { const router = useRouter(); const [searchQuery, setSearchQuery] = useState(''); @@ -491,6 +495,7 @@ export function TeamMembersClient({ NAME STATUS ROLE + TASKS ACTIONS @@ -505,6 +510,10 @@ export function TeamMembersClient({ onUpdateRole={handleUpdateRole} canEdit={canManageMembers} isCurrentUserOwner={isCurrentUserOwner} + taskCompletion={taskCompletionMap[(item as MemberWithUser).id]} + hasDeviceAgentDevice={memberIdsWithDeviceAgent.includes( + (item as MemberWithUser).id, + )} /> ) : ( People - {showEmployeeTasks && ( - Employee Tasks - )} Employee Devices } @@ -63,9 +56,6 @@ export function PeoplePageTabs({ } > {peopleContent} - {showEmployeeTasks && ( - {employeeTasksContent} - )} {devicesContent} diff --git a/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeeCompletionChart.tsx b/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeeCompletionChart.tsx index 6c42f12ea..b4b8303cc 100644 --- a/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeeCompletionChart.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeeCompletionChart.tsx @@ -1,16 +1,45 @@ 'use client'; -import { Card, CardContent, CardHeader, CardTitle } from '@comp/ui/card'; -import { InputGroup, InputGroupAddon, InputGroupInput } from '@trycompai/design-system'; -import { ExternalLink, Search } from 'lucide-react'; +import { + Avatar, + AvatarFallback, + Badge, + Empty, + EmptyDescription, + EmptyHeader, + EmptyTitle, + HStack, + InputGroup, + InputGroupAddon, + InputGroupInput, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Text, +} from '@trycompai/design-system'; +import { Search } from '@trycompai/design-system/icons'; import Link from 'next/link'; import { useParams } from 'next/navigation'; -import type { CSSProperties } from 'react'; import * as React from 'react'; -// Use correct types from the database -import { TrainingVideo } from '@/lib/data/training-videos'; -import { EmployeeTrainingVideoCompletion, Member, Policy, User } from '@db'; +import type { TrainingVideo } from '@/lib/data/training-videos'; +import type { EmployeeTrainingVideoCompletion, Member, Policy, User } from '@db'; + +function getInitials(name: string, email: string): string { + if (name) { + return name + .split(' ') + .map((n) => n[0]) + .join('') + .toUpperCase() + .slice(0, 2); + } + return email.slice(0, 2).toUpperCase(); +} interface EmployeeCompletionChartProps { employees: (Member & { @@ -23,12 +52,6 @@ interface EmployeeCompletionChartProps { showAll?: boolean; } -// Define colors for the chart using DS semantic colors -const taskColors = { - completed: 'bg-success', // Green - completed/good state - incomplete: 'bg-warning', // Yellow - needs action -}; - interface EmployeeTaskStats { id: string; name: string; @@ -38,8 +61,6 @@ interface EmployeeTaskStats { trainingsCompleted: number; policiesTotal: number; trainingsTotal: number; - policyPercentage: number; - trainingPercentage: number; overallPercentage: number; } @@ -52,39 +73,26 @@ export function EmployeeCompletionChart({ const params = useParams(); const orgId = params.orgId as string; const [searchTerm, setSearchTerm] = React.useState(''); - const [displayedItems, setDisplayedItems] = React.useState(showAll ? 20 : 5); - const [isLoading, setIsLoading] = React.useState(false); + const [page, setPage] = React.useState(1); + const perPage = 25; + // Calculate completion data for each employee const employeeStats: EmployeeTaskStats[] = React.useMemo(() => { return employees.map((employee) => { - // Count policies completed by this employee const policiesCompletedCount = policies.filter((policy) => policy.signedBy.includes(employee.id), ).length; - // Calculate policy completion percentage - const policyCompletionPercentage = policies.length - ? Math.round((policiesCompletedCount / policies.length) * 100) - : 0; - - // Count training videos completed by this employee const employeeTrainingVideos = trainingVideos.filter( (video) => video.memberId === employee.id && video.completedAt !== null, ); const trainingsCompletedCount = employeeTrainingVideos.length; - // Get the total unique training videos available const uniqueTrainingVideosIds = [ ...new Set(trainingVideos.map((video) => video.metadata.id)), ]; const trainingVideosTotal = uniqueTrainingVideosIds.length; - // Calculate training completion percentage - const trainingCompletionPercentage = trainingVideosTotal - ? Math.round((trainingsCompletedCount / trainingVideosTotal) * 100) - : 0; - - // Calculate total completion percentage const totalItems = policies.length + trainingVideosTotal; const totalCompletedItems = policiesCompletedCount + trainingsCompletedCount; @@ -101,8 +109,6 @@ export function EmployeeCompletionChart({ trainingsCompleted: trainingsCompletedCount, policiesTotal: policies.length, trainingsTotal: trainingVideosTotal, - policyPercentage: policyCompletionPercentage, - trainingPercentage: trainingCompletionPercentage, overallPercentage, }; }); @@ -119,231 +125,142 @@ export function EmployeeCompletionChart({ ); }, [employeeStats, searchTerm]); - // Sort and limit employees + // Sort employees by completion percentage const sortedStats = React.useMemo(() => { - const sorted = [...filteredStats].sort((a, b) => b.overallPercentage - a.overallPercentage); - return showAll ? sorted.slice(0, displayedItems) : sorted.slice(0, 5); - }, [filteredStats, displayedItems, showAll]); + return [...filteredStats].sort((a, b) => b.overallPercentage - a.overallPercentage); + }, [filteredStats]); - // Load more function for infinite scroll - const loadMore = React.useCallback(async () => { - if (isLoading || !showAll) return; - - setIsLoading(true); - // Simulate loading delay - await new Promise((resolve) => setTimeout(resolve, 300)); - setDisplayedItems((prev) => prev + 20); - setIsLoading(false); - }, [isLoading, showAll]); - - // Infinite scroll effect - React.useEffect(() => { - if (!showAll) return; - - const handleScroll = () => { - if ( - window.innerHeight + document.documentElement.scrollTop >= - document.documentElement.offsetHeight - 1000 - ) { - loadMore(); - } - }; - - window.addEventListener('scroll', handleScroll); - return () => window.removeEventListener('scroll', handleScroll); - }, [loadMore, showAll]); + const pageCount = Math.max(1, Math.ceil(sortedStats.length / perPage)); + const paginatedStats = React.useMemo(() => { + if (!showAll) return sortedStats.slice(0, 5); + const start = (page - 1) * perPage; + return sortedStats.slice(start, start + perPage); + }, [sortedStats, page, perPage, showAll]); // Check for empty data scenarios if (!employees.length) { return ( - - - {'Employee Task Completion'} - - -

- {'No employee data available'} -

-
-
+ + + No employee data available + + Employees will appear here once they are added to the organization. + + + ); } - // Check if there are any tasks to complete if (policies.length === 0 && !trainingVideos.length) { return ( - - - {'Employee Task Completion'} - - -

- {'No tasks available to complete'} -

-
-
+ + + No tasks available + + Create policies or add training videos to track employee completion. + + + ); } return ( - - - {'Employee Task Completion'} - {showAll && ( -
- - - - - setSearchTerm(e.target.value)} - /> - -
- )} -
- - {filteredStats.length === 0 ? ( -
-

- {searchTerm ? 'No employees found matching your search' : 'No employees available'} -

-
- ) : ( - <> -
- {sortedStats.map((stat) => ( -
-
-
-
-

{stat.name}

+ + {showAll && ( +
+ + + + + { + setSearchTerm(e.target.value); + setPage(1); + }} + /> + +
+ )} + + {filteredStats.length === 0 ? ( + + + {searchTerm ? 'No employees found' : 'No employees available'} + + {searchTerm + ? 'Try adjusting your search.' + : 'Employees will appear here once they are added.'} + + + + ) : ( + setPage(1), + } + : undefined + } + > + + + Employee + Policies + Training + Completion + + + + {paginatedStats.map((stat) => { + const allComplete = stat.overallPercentage === 100; + return ( + + + + + {getInitials(stat.name, stat.email)} + +
- View Profile - + {stat.name} + {stat.email}
-

{stat.email}

- -
-
- {stat.policiesCompleted + stat.trainingsCompleted} / {stat.totalTasks} tasks -
-
- {stat.policiesCompleted}/{stat.policiesTotal} policies •{' '} - {stat.trainingsCompleted}/{stat.trainingsTotal} training -
-
- - - - -
-
-
- {'Completed'} -
-
-
- {'Not Completed'} -
-
-
- ))} -
- - {showAll && sortedStats.length < filteredStats.length && ( -
- {isLoading ? ( -
Loading more employees...
- ) : ( - - )} -
- )} - - {showAll && ( -
- Showing {sortedStats.length} of {filteredStats.length} employees -
- )} - - )} - - - ); -} - -function TaskBarChart({ stat }: { stat: EmployeeTaskStats }) { - const totalCompleted = stat.policiesCompleted + stat.trainingsCompleted; - const totalIncomplete = stat.totalTasks - totalCompleted; - const barHeight = 12; - - // Empty chart for no tasks - if (stat.totalTasks === 0) { - return
; - } - - return ( -
-
- {/* Completed segment */} - {totalCompleted > 0 && ( -
-
-
- )} - - {/* Incomplete segment */} - {totalIncomplete > 0 && ( -
-
-
- )} -
-
+ + + + + {stat.policiesCompleted}/{stat.policiesTotal} + + + + + {stat.trainingsCompleted}/{stat.trainingsTotal} + + + + + {stat.overallPercentage}% + + + + ); + })} + +
+ )} +
); } diff --git a/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeesOverview.tsx b/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeesOverview.tsx index ca9537d3c..afebd4ad9 100644 --- a/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeesOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeesOverview.tsx @@ -79,7 +79,6 @@ export async function EmployeesOverview() { ); if (videoMetadata) { - // Push the object matching the updated ProcessedTrainingVideo interface processedTrainingVideos.push({ id: dbVideo.id, memberId: dbVideo.memberId, @@ -93,13 +92,11 @@ export async function EmployeesOverview() { } return ( -
- -
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.tsx index 13d2cec06..95e56ae1c 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.tsx @@ -1,170 +1,158 @@ 'use client'; -import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@comp/ui/card'; import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, } from '@comp/ui/chart'; +import { Card, HStack, Stack, Text } from '@trycompai/design-system'; +import { Information } from '@trycompai/design-system/icons'; import * as React from 'react'; -import { Cell, Label, Pie, PieChart } from 'recharts'; -import type { Host } from '../types'; +import { Label, Pie, PieChart } from 'recharts'; +import type { DeviceWithChecks } from '../types'; interface DeviceComplianceChartProps { - devices: Host[]; + devices: DeviceWithChecks[]; } const CHART_COLORS = { - compliant: 'hsl(var(--chart-positive))', - nonCompliant: 'hsl(var(--chart-destructive))', + compliant: 'var(--primary)', + nonCompliant: 'var(--destructive)', }; export function DeviceComplianceChart({ devices }: DeviceComplianceChartProps) { - const { pieDisplayData, legendDisplayData } = React.useMemo(() => { - if (!devices || devices.length === 0) { - return { pieDisplayData: [], legendDisplayData: [] }; - } + const allStatuses = React.useMemo(() => { + if (!devices || devices.length === 0) return []; + let compliantCount = 0; let nonCompliantCount = 0; for (const device of devices) { - const isCompliant = device.policies.every((policy) => policy.response === 'pass'); - if (isCompliant) { + if (device.isCompliant) { compliantCount++; } else { nonCompliantCount++; } } - const allItems = [ + + return [ { + key: 'compliant', name: 'Compliant', value: compliantCount, fill: CHART_COLORS.compliant, }, { + key: 'nonCompliant', name: 'Non-Compliant', value: nonCompliantCount, fill: CHART_COLORS.nonCompliant, }, ]; - return { - pieDisplayData: allItems.filter((item) => item.value > 0), - legendDisplayData: allItems, - }; }, [devices]); - const totalDevices = React.useMemo(() => { - return devices?.length || 0; - }, [devices]); + const chartData = React.useMemo(() => { + return allStatuses.filter((item) => item.value > 0); + }, [allStatuses]); + + const totalDevices = devices?.length || 0; const chartConfig = { - devices: { - label: 'Devices', - }, - compliant: { - label: 'Compliant', - color: CHART_COLORS.compliant, - }, - nonCompliant: { - label: 'Non-Compliant', - color: CHART_COLORS.nonCompliant, + value: { + label: 'Count', }, } satisfies ChartConfig; if (!devices || devices.length === 0) { return ( - - - Device Compliance - - -
-

- No device data available. Please make sure your employees access the portal and - install the device agent. -

-
-
- -
- + +
+ + + No device data available + +
); } return ( - - - Device Compliance - {/* Optional: Add a subtitle or small description here if needed */} - - - - - } /> - - {pieDisplayData.map((entry: { name: string; value: number; fill: string }) => ( - - ))} -