Show posture values and report history

Pass/fail was the main device UI signal, but operators need
the agent's observed value. Expose a formatted value per
check, show current postures on the device page, and replace
the Postures tab with paginated report history grouped by
agent push time. Status stays in the model for later rulesets.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
Ludovic Vielle
2026-07-28 16:21:30 +02:00
parent 6e2a2ff995
commit ad615a47a0
39 changed files with 4439 additions and 399 deletions

View File

@@ -2485,7 +2485,7 @@
"auth": { "actions": { "signIn": "Sign in" } },
"authError": { "enterpriseAccountRequired": { "title": "Enterprise account required", "description": "Personal Google and Microsoft accounts cannot be used to sign in. Please use your work or school account instead." }, "emailNotVerified": { "title": "Email not verified", "description": "Your email address is not verified with the identity provider. Please verify it, then try signing in again." }, "signInSessionExpired": { "title": "Sign-in session expired", "description": "This sign-in attempt is no longer valid. Please start again from the sign-in page." }, "magicLinkExpired": { "title": "Link Expired", "description": "This magic link has expired. Magic links are only valid for 15 minutes. Please request a new one." }, "magicLinkAlreadyUsed": { "title": "Link Already Used", "description": "This magic link has already been used. Please request a new one." }, "invalidLink": { "title": "Invalid link", "description": "This magic link is invalid. Please request a new one." }, "default": { "title": "Authentication failed", "description": "We could not complete your sign-in. Please try again." } },
"accessReviewSource": { "documentation": "Documentation", "regions": { "label": "Region", "placeholder": "Select a region", "unitedStates": "United States", "europe": "Europe" } },
"devices": { "title": "Devices", "description": "Manage computers enrolled with the Probo posture agent.", "empty": "No devices enrolled yet", "actions": { "add": "Add device", "new": "New device", "reassign": "Re-assign", "revoke": "Revoke" }, "fields": { "organization": "Organization", "hostname": "Hostname", "owner": "Owner", "state": "State", "platform": "Platform", "osVersion": "OS version", "hardwareUuid": "Hardware UUID", "serialNumber": "Serial number", "agentVersion": "Agent version", "enrolledAt": "Enrolled at", "lastSeen": "Last seen" }, "values": { "pending": "(pending)", "never": "Never", "unassigned": "Unassigned" }, "messages": { "ownerUpdated": "Device owner updated", "revoked": "Device revoked" }, "errors": { "create": "Failed to create device", "reassign": "Failed to re-assign device", "revoke": "Failed to revoke device" }, "confirmations": { "revoke": "Revoke device \"{{hostname}}\"? The agent on the device will stop reporting and must be re-enrolled." }, "postures": { "title": "Posture", "navigation": "Postures", "summaryHelp": "How posture summary is calculated", "summaryDescription": "Shown as pass / fail / total.", "empty": "No posture checks recorded", "columns": { "check": "Check", "status": "Status", "observedAt": "Observed at" }, "status": { "pass": "Pass", "fail": "Fail", "unknown": "Unknown", "notApplicable": "Not applicable" }, "checks": { "diskEncryption": "Disk encryption", "screenLock": "Screen lock", "firewallEnabled": "Firewall enabled", "timeSync": "Time sync", "osVersion": "OS version", "autoUpdate": "Auto update", "passwordPolicy": "Password policy", "remoteLogin": "Remote login", "malwareProtection": "Malware protection" } } },
"devices": { "title": "Devices", "description": "Manage computers enrolled with the Probo posture agent.", "empty": "No devices enrolled yet", "actions": { "add": "Add device", "new": "New device", "reassign": "Re-assign", "revoke": "Revoke" }, "fields": { "organization": "Organization", "hostname": "Hostname", "owner": "Owner", "state": "State", "platform": "Platform", "osVersion": "OS version", "hardwareUuid": "Hardware UUID", "serialNumber": "Serial number", "agentVersion": "Agent version", "enrolledAt": "Enrolled at", "lastSeen": "Last seen" }, "values": { "pending": "(pending)", "never": "Never", "unassigned": "Unassigned" }, "messages": { "ownerUpdated": "Device owner updated", "revoked": "Device revoked" }, "errors": { "create": "Failed to create device", "reassign": "Failed to re-assign device", "revoke": "Failed to revoke device" }, "confirmations": { "revoke": "Revoke device \"{{hostname}}\"? The agent on the device will stop reporting and must be re-enrolled." }, "postures": { "currentTitle": "Current postures", "empty": "No posture checks recorded", "values": { "on": "On", "off": "Off", "immediate": "Immediate", "seconds": "{{seconds}}s", "minPasswordLength": "Min length {{length}}", "configured": "Configured", "none": "None", "unknown": "Unknown" }, "checks": { "diskEncryption": "Disk encryption", "screenLock": "Screen lock", "firewallEnabled": "Firewall enabled", "timeSync": "Time sync", "osVersion": "OS version", "autoUpdate": "Auto update", "passwordPolicy": "Password policy", "remoteLogin": "Remote login", "malwareProtection": "Malware protection" } }, "history": { "title": "Report history", "empty": "No posture reports yet", "checkCount_one": "{{count}} check reported", "checkCount_other": "{{count}} checks reported", "columns": { "time": "Time", "checks": "Checks", "correlationId": "Correlation ID" }, "actions": { "copyCorrelationId": "Copy correlation ID", "correlationIdCopied": "Correlation ID copied" } } },
"employeeDevices": { "title": "Your devices" },
"deviceEnrollment": { "pageTitle": "Enroll device", "title": "Device enrollment", "setup": "Setup", "stepProgress": "Step {{current}} of {{total}}", "steps": { "privacy": { "title": "Privacy", "description": "Review collected data" }, "organization": { "title": "Organization", "description": "Choose destination workspace" }, "enroll": { "title": "Open and wait", "description": "Finish setup in the desktop agent" } }, "unavailable": { "title": "Enrollment unavailable", "description": "You do not have permission to enroll devices in any organization." }, "intro": { "title": "Before you start", "description": "Probo collects the following device metadata for inventory and posture reporting:" }, "privacy": { "identity": "Device identity: hardware UUID, hostname, and serial number (when available).", "systemDetails": "System details: platform, OS version, and Probo agent version.", "activitySignals": "Activity signals: enrollment time, heartbeats, and posture check results." }, "organization": { "title": "Choose organization", "description": "Pick which organization will own and manage this device." }, "openAgent": { "title": "Open the Probo agent", "description": "Open the desktop agent to finish setup, then keep this page open until enrollment is confirmed." }, "actions": { "backToOrganizations": "Back to organizations", "enrollNew": "Enroll new device", "openAgent": "Open Probo agent", "preparing": "Preparing…" }, "status": { "enrolledWithHostname": "{{hostname}} is enrolled.", "enrolled": "This device is enrolled.", "closeWindow": "You can close this window.", "waitingForCheckIn": "Waiting for the agent's first check-in…", "timedOut": "We haven't heard from the agent yet. Make sure the desktop agent is installed and running, then try again." }, "manual": { "cannotEnroll": "Can't enroll new device?", "tryCreating": "Try creating it manually", "title": "Manual enrollment", "creating": "Creating device…" }, "token": { "title": "Enrollment token generated", "description": "Share this enrollment token only with the device owner through a secure channel. It can be used once and expires after seven days.", "manualInstall": "Manual install (CLI / MDM)", "installUnix": "Install on macOS or Linux (run from a shell with sudo access)", "installWindows": "Install on Windows (run from an elevated PowerShell session)", "securityNotice": "The token is passed as a CLI flag (not via curl-piped-to-shell or sudo env vars). Once installed, the agent self-updates from GitHub Releases with cosign signature verification." }, "messages": { "created": "Device created. Copy the enrollment token now — it will not be shown again." }, "errors": { "copyToClipboard": "Failed to copy to clipboard" } },
"thirdPartyRiskAssessmentRow": {

View File

@@ -5960,21 +5960,17 @@
"revoke": "Révoquer l’appareil « {{hostname}} » ? L’agent sur l’appareil arrêtera de transmettre des données et devra être réenrôlé."
},
"postures": {
"title": "Posture",
"navigation": "Postures",
"summaryHelp": "Comment le résumé de posture est calculé",
"summaryDescription": "Affiché sous la forme réussite / échec / total.",
"currentTitle": "Postures actuelles",
"empty": "Aucun contrôle de posture enregistré",
"columns": {
"check": "Contrôle",
"status": "Statut",
"observedAt": "Observé le"
},
"status": {
"pass": "Réussite",
"fail": "Échec",
"unknown": "Inconnu",
"notApplicable": "Non applicable"
"values": {
"on": "Activé",
"off": "Désactivé",
"immediate": "Immédiat",
"seconds": "{{seconds}} s",
"minPasswordLength": "Longueur min. {{length}}",
"configured": "Configurée",
"none": "Aucune",
"unknown": "Inconnu"
},
"checks": {
"diskEncryption": "Chiffrement du disque",
@@ -5987,6 +5983,21 @@
"remoteLogin": "Connexion à distance",
"malwareProtection": "Protection contre les logiciels malveillants"
}
},
"history": {
"title": "Historique des rapports",
"empty": "Aucun rapport de posture pour le moment",
"checkCount_one": "{{count}} contrôle rapporté",
"checkCount_other": "{{count}} contrôles rapportés",
"columns": {
"time": "Heure",
"checks": "Contrôles",
"correlationId": "ID de corrélation"
},
"actions": {
"copyCorrelationId": "Copier l’ID de corrélation",
"correlationIdCopied": "ID de corrélation copié"
}
}
},
"employeeDevices": {

View File

@@ -19,13 +19,7 @@
// SOFTWARE.
import { usePageTitle } from "@probo/hooks";
import {
Breadcrumb,
Button,
PageHeader,
TabLink,
Tabs,
} from "@probo/ui";
import { Breadcrumb, Button, PageHeader } from "@probo/ui";
import { useTranslation } from "react-i18next";
import {
type PreloadedQuery,
@@ -37,6 +31,7 @@ import { graphql } from "relay-runtime";
import type { DeviceLayoutQuery } from "#/__generated__/core/DeviceLayoutQuery.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { DeviceCurrentPostures } from "./_components/DeviceCurrentPostures";
import { DeviceDetailsCard } from "./_components/DeviceDetailsCard";
import { displayValue } from "./_lib/deviceDisplay";
import { useRevokeDevice } from "./_lib/useRevokeDevice";
@@ -49,8 +44,8 @@ export const deviceLayoutQuery = graphql`
id
state
hostname
platform
...DeviceDetailsCard_deviceFragment
...DeviceCurrentPostures_deviceFragment
}
}
organization: node(id: $organizationId) @required(action: THROW) {
@@ -102,10 +97,7 @@ export function DeviceLayout({ queryRef }: DeviceLayoutProps) {
{ label: hostnameLabel },
]}
/>
<PageHeader
title={hostnameLabel}
description={displayValue(device.platform, pendingLabel)}
>
<PageHeader title={hostnameLabel}>
{!isRevoked && canRevokeDevice && (
<Button
variant="danger"
@@ -120,15 +112,14 @@ export function DeviceLayout({ queryRef }: DeviceLayoutProps) {
<DeviceDetailsCard deviceFragmentRef={device} />
<Tabs>
<TabLink
to={`/organizations/${organizationId}/devices/${device.id}/postures`}
>
{t("devices.postures.navigation")}
</TabLink>
</Tabs>
<DeviceCurrentPostures deviceFragmentRef={device} />
<Outlet />
<div className="space-y-4">
<h2 className="text-base font-medium">
{t("devices.history.title")}
</h2>
<Outlet />
</div>
</div>
);
}

View File

@@ -36,7 +36,6 @@ import { SortableTable, SortableTh } from "#/components/SortableTable";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { DeviceRow } from "./_components/DeviceRow";
import { PostureColumnHeader } from "./_components/PostureColumnHeader";
import { CreateDeviceDialog } from "./dialogs/CreateDeviceDialog";
export const devicesPageQuery = graphql`
@@ -141,9 +140,6 @@ export function DevicesPage({ queryRef }: DevicesPageProps) {
<Th>{t("devices.fields.platform")}</Th>
<Th>{t("devices.fields.osVersion")}</Th>
<SortableTh field="LAST_SEEN_AT">{t("devices.fields.lastSeen")}</SortableTh>
<Th>
<PostureColumnHeader />
</Th>
<Th></Th>
</Tr>
</Thead>

View File

@@ -0,0 +1,81 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { Card } from "@probo/ui";
import { useTranslation } from "react-i18next";
import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type { DeviceCurrentPostures_deviceFragment$key } from "#/__generated__/core/DeviceCurrentPostures_deviceFragment.graphql";
import { getPostureCheckLabel } from "../_lib/getPostureCheckLabel";
import { PostureValueBadge } from "./PostureValueBadge";
const deviceFragment = graphql`
fragment DeviceCurrentPostures_deviceFragment on Device {
latestPostures {
id
checkKey
...PostureValueBadge_postureFragment
}
}
`;
interface DeviceCurrentPosturesProps {
deviceFragmentRef: DeviceCurrentPostures_deviceFragment$key;
}
export function DeviceCurrentPostures({
deviceFragmentRef,
}: DeviceCurrentPosturesProps) {
const { t } = useTranslation();
const device = useFragment(deviceFragment, deviceFragmentRef);
return (
<div className="space-y-4">
<h2 className="text-base font-medium">
{t("devices.postures.currentTitle")}
</h2>
<Card className="space-y-4" padded>
{device.latestPostures.length === 0
? (
<div className="text-sm text-txt-secondary">
{t("devices.postures.empty")}
</div>
)
: (
<div className="grid grid-cols-3 gap-4">
{device.latestPostures.map(posture => (
<div key={posture.id}>
<div className="text-xs text-txt-tertiary font-semibold mb-1">
{getPostureCheckLabel(t, posture.checkKey)}
</div>
<div className="text-sm text-txt-primary">
<PostureValueBadge postureFragmentRef={posture} />
</div>
</div>
))}
</div>
)}
</Card>
</div>
);
}

View File

@@ -18,9 +18,8 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { dateFormat } from "@probo/i18n";
import { dateFormat, dateTimeFormat } from "@probo/i18n";
import { Badge, Card } from "@probo/ui";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
@@ -55,64 +54,83 @@ export function DeviceDetailsCard(props: {
return (
<Card className="space-y-4" padded>
<div className="grid grid-cols-3 gap-4">
<DetailField
label={t("devices.fields.state")}
value={
<div>
<div className="text-xs text-txt-tertiary font-semibold mb-1">
{t("devices.fields.state")}
</div>
<div className="text-sm text-txt-primary">
<Badge variant={stateVariant(device.state)}>{device.state}</Badge>
}
/>
<DetailField
label={t("devices.fields.owner")}
value={device.owner?.fullName ?? t("devices.values.unassigned")}
/>
<DetailField
label={t("devices.fields.hardwareUuid")}
value={displayValue(device.hardwareUuid, pendingLabel)}
/>
<DetailField
label={t("devices.fields.serialNumber")}
value={displayValue(device.serialNumber, pendingLabel)}
/>
<DetailField
label={t("devices.fields.platform")}
value={displayValue(device.platform, pendingLabel)}
/>
<DetailField
label={t("devices.fields.osVersion")}
value={displayValue(device.osVersion, pendingLabel)}
/>
<DetailField
label={t("devices.fields.agentVersion")}
value={displayValue(device.agentVersion, pendingLabel)}
/>
<DetailField
label={t("devices.fields.enrolledAt")}
value={
device.enrolledAt
</div>
</div>
<div>
<div className="text-xs text-txt-tertiary font-semibold mb-1">
{t("devices.fields.owner")}
</div>
<div className="text-sm text-txt-primary">
{device.owner?.fullName ?? t("devices.values.unassigned")}
</div>
</div>
<div>
<div className="text-xs text-txt-tertiary font-semibold mb-1">
{t("devices.fields.hardwareUuid")}
</div>
<div className="text-sm text-txt-primary">
{displayValue(device.hardwareUuid, pendingLabel)}
</div>
</div>
<div>
<div className="text-xs text-txt-tertiary font-semibold mb-1">
{t("devices.fields.serialNumber")}
</div>
<div className="text-sm text-txt-primary">
{displayValue(device.serialNumber, pendingLabel)}
</div>
</div>
<div>
<div className="text-xs text-txt-tertiary font-semibold mb-1">
{t("devices.fields.platform")}
</div>
<div className="text-sm text-txt-primary">
{displayValue(device.platform, pendingLabel)}
</div>
</div>
<div>
<div className="text-xs text-txt-tertiary font-semibold mb-1">
{t("devices.fields.osVersion")}
</div>
<div className="text-sm text-txt-primary">
{displayValue(device.osVersion, pendingLabel)}
</div>
</div>
<div>
<div className="text-xs text-txt-tertiary font-semibold mb-1">
{t("devices.fields.agentVersion")}
</div>
<div className="text-sm text-txt-primary">
{displayValue(device.agentVersion, pendingLabel)}
</div>
</div>
<div>
<div className="text-xs text-txt-tertiary font-semibold mb-1">
{t("devices.fields.enrolledAt")}
</div>
<div className="text-sm text-txt-primary">
{device.enrolledAt
? dateFormat(i18n.language, device.enrolledAt)
: pendingLabel
}
/>
<DetailField
label={t("devices.fields.lastSeen")}
value={
device.lastSeenAt
? dateFormat(i18n.language, device.lastSeenAt)
: t("devices.values.never")
}
/>
: pendingLabel}
</div>
</div>
<div>
<div className="text-xs text-txt-tertiary font-semibold mb-1">
{t("devices.fields.lastSeen")}
</div>
<div className="text-sm text-txt-primary">
{device.lastSeenAt
? dateTimeFormat(i18n.language, device.lastSeenAt)
: t("devices.values.never")}
</div>
</div>
</div>
</Card>
);
}
function DetailField(props: { label: string; value: ReactNode }) {
return (
<div>
<div className="text-xs text-txt-tertiary font-semibold mb-1">
{props.label}
</div>
<div className="text-sm text-txt-primary">{props.value}</div>
</div>
);
}

View File

@@ -18,7 +18,7 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { dateFormat } from "@probo/i18n";
import { dateTimeFormat } from "@probo/i18n";
import {
ActionDropdown,
Badge,
@@ -52,10 +52,6 @@ const deviceRowFragment = graphql`
id
fullName
}
latestPostures {
id
status
}
...ReassignDeviceDialog_device
}
`;
@@ -76,7 +72,6 @@ export function DeviceRow({ canAssignDevice, canRevoke, fKey }: DeviceRowProps)
const [confirmRevoke, isRevoking] = useRevokeDevice();
const summary = postureSummary(device.latestPostures);
const isRevoked = device.state === "REVOKED";
const hasActions = !isRevoked && (canRevoke || canAssignDevice);
@@ -97,18 +92,9 @@ export function DeviceRow({ canAssignDevice, canRevoke, fKey }: DeviceRowProps)
<Td>{displayValue(device.osVersion, pendingLabel)}</Td>
<Td>
{device.lastSeenAt
? dateFormat(i18n.language, device.lastSeenAt)
? dateTimeFormat(i18n.language, device.lastSeenAt)
: t("devices.values.never")}
</Td>
<Td>
<span className="text-txt-success">{summary.pass}</span>
{" / "}
<span className={summary.fail > 0 ? "text-txt-danger" : undefined}>
{summary.fail}
</span>
{" / "}
{summary.total}
</Td>
<Td noLink width={50} className="text-end">
{hasActions && (
<ActionDropdown>
@@ -138,24 +124,3 @@ export function DeviceRow({ canAssignDevice, canRevoke, fKey }: DeviceRowProps)
</>
);
}
function postureSummary(
postures: readonly { status: string }[],
): { pass: number; fail: number; total: number } {
let pass = 0;
let fail = 0;
for (const p of postures) {
switch (p.status) {
case "PASS":
pass += 1;
break;
case "FAIL":
fail += 1;
break;
default:
// UNKNOWN and NOT_APPLICABLE count toward total only.
break;
}
}
return { pass, fail, total: postures.length };
}

View File

@@ -1,4 +1,4 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
@@ -18,38 +18,42 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { IconCircleInfo } from "@probo/ui";
import * as Popover from "@radix-ui/react-popover";
import { Badge } from "@probo/ui";
import { useTranslation } from "react-i18next";
import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
export function PostureColumnHeader() {
const { t } = useTranslation();
import type { PostureValueBadge_postureFragment$key } from "#/__generated__/core/PostureValueBadge_postureFragment.graphql";
return (
<span className="inline-flex items-center gap-1">
{t("devices.postures.title")}
<Popover.Root>
<Popover.Trigger asChild>
<button
type="button"
className="inline-flex text-txt-tertiary hover:text-txt-secondary"
aria-label={t("devices.postures.summaryHelp")}
>
<IconCircleInfo size={14} />
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
className="z-50 max-w-xs rounded-md border bg-level-0 p-3 text-xs font-normal text-txt-secondary shadow-md"
sideOffset={4}
align="start"
>
<p>
{t("devices.postures.summaryDescription")}
</p>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</span>
);
import { postureValueLabel, postureValueVariant } from "../_lib/deviceDisplay";
const postureFragment = graphql`
fragment PostureValueBadge_postureFragment on DevicePosture {
checkKey
value {
kind
text
number
}
}
`;
interface PostureValueBadgeProps {
postureFragmentRef: PostureValueBadge_postureFragment$key;
}
export function PostureValueBadge({
postureFragmentRef,
}: PostureValueBadgeProps) {
const { t } = useTranslation();
const posture = useFragment(postureFragment, postureFragmentRef);
const label = postureValueLabel(t, posture.value);
const variant = postureValueVariant(posture.value.kind, posture.checkKey);
if (!variant) {
return label;
}
return <Badge variant={variant}>{label}</Badge>;
}

View File

@@ -38,17 +38,71 @@ export function stateVariant(
}
}
export function statusVariant(
status: string,
): "success" | "danger" | "warning" | "info" {
switch (status) {
case "PASS":
return "success";
case "FAIL":
return "danger";
case "NOT_APPLICABLE":
return "info";
type Translator = (key: string, options?: Record<string, unknown>) => string;
/** What a posture check observed, as returned by the DevicePostureValue type. */
export interface PostureValue {
readonly kind: string;
readonly text?: string | null;
readonly number?: number | null;
}
export function postureValueLabel(t: Translator, value: PostureValue): string {
switch (value.kind) {
case "ON":
return t("devices.postures.values.on");
case "OFF":
return t("devices.postures.values.off");
case "IMMEDIATE":
return t("devices.postures.values.immediate");
case "SECONDS":
return t("devices.postures.values.seconds", {
seconds: value.number ?? 0,
});
case "MIN_PASSWORD_LENGTH":
return t("devices.postures.values.minPasswordLength", {
length: value.number ?? 0,
});
case "CONFIGURED":
return t("devices.postures.values.configured");
case "NONE":
return t("devices.postures.values.none");
case "TEXT":
return value.text || t("devices.postures.values.unknown");
default:
return "warning";
return t("devices.postures.values.unknown");
}
}
/**
* Badge variant for a posture value. Kinds carrying a measurement render as
* plain text instead: whether a 15 second delay or an 8 character minimum is
* acceptable is a ruleset decision, not something the observation can say.
*/
export function postureValueVariant(
kind: string,
checkKey?: string,
): "success" | "danger" | "info" | undefined {
if (checkKey === "REMOTE_LOGIN") {
// Remote login reports reachability, so On is the exposed state.
switch (kind) {
case "ON":
return "danger";
case "OFF":
return "info";
default:
return undefined;
}
}
switch (kind) {
case "ON":
case "IMMEDIATE":
return "success";
case "OFF":
case "NONE":
return "danger";
default:
return undefined;
}
}

View File

@@ -1,4 +1,4 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
@@ -21,33 +21,33 @@
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
import { graphql } from "relay-runtime";
import type { DevicePosturesPageQuery } from "#/__generated__/core/DevicePosturesPageQuery.graphql";
import type { DeviceHistoryPageQuery } from "#/__generated__/core/DeviceHistoryPageQuery.graphql";
import { DevicePostureList } from "./_components/DevicePostureList";
import { DevicePostureReportList } from "./_components/DevicePostureReportList";
export const devicePosturesPageQuery = graphql`
query DevicePosturesPageQuery($deviceId: ID!) {
export const deviceHistoryPageQuery = graphql`
query DeviceHistoryPageQuery($deviceId: ID!) {
device: node(id: $deviceId) @required(action: THROW) {
__typename
... on Device {
...DevicePostureList_deviceFragment
...DevicePostureReportListFragment
}
}
}
`;
interface DevicePosturesPageProps {
queryRef: PreloadedQuery<DevicePosturesPageQuery>;
interface DeviceHistoryPageProps {
queryRef: PreloadedQuery<DeviceHistoryPageQuery>;
}
export function DevicePosturesPage({ queryRef }: DevicePosturesPageProps) {
const { device } = usePreloadedQuery<DevicePosturesPageQuery>(
devicePosturesPageQuery,
export function DeviceHistoryPage({ queryRef }: DeviceHistoryPageProps) {
const { device } = usePreloadedQuery<DeviceHistoryPageQuery>(
deviceHistoryPageQuery,
queryRef,
);
if (device.__typename !== "Device") {
throw new Error("invalid type for device node");
}
return <DevicePostureList deviceFragmentRef={device} />;
return <DevicePostureReportList fKey={device} />;
}

View File

@@ -1,4 +1,4 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
@@ -22,20 +22,20 @@ import { Suspense, useEffect } from "react";
import { useQueryLoader } from "react-relay";
import { useParams } from "react-router";
import type { DevicePosturesPageQuery } from "#/__generated__/core/DevicePosturesPageQuery.graphql";
import type { DeviceHistoryPageQuery } from "#/__generated__/core/DeviceHistoryPageQuery.graphql";
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
import { CoreRelayProvider } from "#/providers/CoreRelayProvider";
import { DevicePosturesPage, devicePosturesPageQuery } from "./DevicePosturesPage";
import { DeviceHistoryPage, deviceHistoryPageQuery } from "./DeviceHistoryPage";
function DevicePosturesPageQueryLoader() {
function DeviceHistoryPageQueryLoader() {
const { deviceId } = useParams();
if (!deviceId) {
throw new Error(":deviceId missing in route params");
}
const [queryRef, loadQuery] = useQueryLoader<DevicePosturesPageQuery>(
devicePosturesPageQuery,
const [queryRef, loadQuery] = useQueryLoader<DeviceHistoryPageQuery>(
deviceHistoryPageQuery,
);
useEffect(() => {
@@ -50,15 +50,15 @@ function DevicePosturesPageQueryLoader() {
return (
<Suspense fallback={<LinkCardSkeleton />}>
<DevicePosturesPage queryRef={queryRef} />
<DeviceHistoryPage queryRef={queryRef} />
</Suspense>
);
}
export default function DevicePosturesPageLoader() {
export default function DeviceHistoryPageLoader() {
return (
<CoreRelayProvider>
<DevicePosturesPageQueryLoader />
<DeviceHistoryPageQueryLoader />
</CoreRelayProvider>
);
}

View File

@@ -0,0 +1,110 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { Tbody, Td, Th, Thead, Tr } from "@probo/ui";
import { useTranslation } from "react-i18next";
import { graphql, usePaginationFragment } from "react-relay";
import type { DevicePostureReportListFragment$key } from "#/__generated__/core/DevicePostureReportListFragment.graphql";
import type { DevicePostureReportListPaginationQuery } from "#/__generated__/core/DevicePostureReportListPaginationQuery.graphql";
import { SortableTable } from "#/components/SortableTable";
import { DevicePostureReportListItem } from "./DevicePostureReportListItem";
const deviceFragment = graphql`
fragment DevicePostureReportListFragment on Device
@refetchable(queryName: "DevicePostureReportListPaginationQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 20 }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
) {
postureReports(
first: $first
after: $after
last: $last
before: $before
) @connection(key: "DevicePostureReportListFragment_postureReports") {
edges {
node {
createdAt
id
...DevicePostureReportListItemFragment
}
}
}
}
`;
interface DevicePostureReportListProps {
fKey: DevicePostureReportListFragment$key;
}
export function DevicePostureReportList({
fKey,
}: DevicePostureReportListProps) {
const { t } = useTranslation();
const reportsPagination = usePaginationFragment<
DevicePostureReportListPaginationQuery,
DevicePostureReportListFragment$key
>(deviceFragment, fKey);
const edges = reportsPagination.data.postureReports.edges;
return (
<SortableTable
{...reportsPagination}
refetch={() => {
reportsPagination.refetch({}, { fetchPolicy: "network-only" });
}}
pageSize={20}
>
<Thead>
<Tr>
<Th>{t("devices.history.columns.time")}</Th>
<Th className="text-txt-tertiary font-normal">
{t("devices.history.columns.correlationId")}
</Th>
<Th>{t("devices.history.columns.checks")}</Th>
</Tr>
</Thead>
<Tbody>
{edges.length === 0
? (
<Tr>
<Td colSpan={3} className="text-center text-txt-secondary">
{t("devices.history.empty")}
</Td>
</Tr>
)
: (
edges.map(({ node: report }) => (
<DevicePostureReportListItem
key={report.id}
fKey={report}
/>
))
)}
</Tbody>
</SortableTable>
);
}

View File

@@ -0,0 +1,133 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { useCopy } from "@probo/hooks";
import { dateTimeFormat } from "@probo/i18n";
import {
IconCheckmark1,
IconChevronDown,
IconChevronRight,
IconSquareBehindSquare2,
Td,
Tr,
} from "@probo/ui";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type { DevicePostureReportListItemFragment$key } from "#/__generated__/core/DevicePostureReportListItemFragment.graphql";
import { PostureValueBadge } from "../../_components/PostureValueBadge";
import { getPostureCheckLabel } from "../../_lib/getPostureCheckLabel";
const reportFragment = graphql`
fragment DevicePostureReportListItemFragment on DevicePostureReport {
id
createdAt
postures {
id
checkKey
...PostureValueBadge_postureFragment
}
}
`;
interface DevicePostureReportListItemProps {
fKey: DevicePostureReportListItemFragment$key;
}
export function DevicePostureReportListItem({
fKey,
}: DevicePostureReportListItemProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [isCopied, copy] = useCopy();
const { i18n, t } = useTranslation();
const report = useFragment(reportFragment, fKey);
return (
<>
<Tr
className="cursor-pointer hover:bg-bg-hover"
onClick={() => setIsExpanded(!isExpanded)}
>
<Td className="whitespace-nowrap">
<div className="flex items-center gap-2">
{isExpanded
? (
<IconChevronDown size={16} className="text-txt-secondary" />
)
: (
<IconChevronRight size={16} className="text-txt-secondary" />
)}
{dateTimeFormat(i18n.language, report.createdAt)}
</div>
</Td>
<Td>
<div className="flex items-center gap-1">
<code className="font-mono text-xs text-txt-tertiary">
{report.id}
</code>
<button
type="button"
className="p-1 rounded text-txt-tertiary hover:bg-bg-hover hover:text-txt-secondary transition-colors cursor-pointer"
aria-label={t("devices.history.actions.copyCorrelationId")}
title={
isCopied
? t("devices.history.actions.correlationIdCopied")
: t("devices.history.actions.copyCorrelationId")
}
onClick={(event) => {
event.stopPropagation();
copy(report.id);
}}
>
{isCopied
? <IconCheckmark1 size={14} />
: <IconSquareBehindSquare2 size={14} />}
</button>
</div>
</Td>
<Td>
{t("devices.history.checkCount", {
count: report.postures.length,
})}
</Td>
</Tr>
{isExpanded && (
<Tr>
<Td colSpan={3} className="bg-bg-subtle">
<div className="py-2 pl-6 grid grid-cols-2 gap-x-8 gap-y-2">
{report.postures.map(posture => (
<div key={posture.id} className="flex items-center gap-4 text-sm">
<span className="text-txt-secondary min-w-40">
{getPostureCheckLabel(t, posture.checkKey)}
</span>
<PostureValueBadge postureFragmentRef={posture} />
</div>
))}
</div>
</Td>
</Tr>
)}
</>
);
}

View File

@@ -1,70 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { Table, Tbody, Td, Th, Thead, Tr } from "@probo/ui";
import { useTranslation } from "react-i18next";
import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type { DevicePostureList_deviceFragment$key } from "#/__generated__/core/DevicePostureList_deviceFragment.graphql";
import { DevicePostureListItem } from "./DevicePostureListItem";
const deviceFragment = graphql`
fragment DevicePostureList_deviceFragment on Device {
latestPostures {
id
...DevicePostureListItem_postureFragment
}
}
`;
interface DevicePostureListProps {
deviceFragmentRef: DevicePostureList_deviceFragment$key;
}
export function DevicePostureList({ deviceFragmentRef }: DevicePostureListProps) {
const { t } = useTranslation();
const device = useFragment(deviceFragment, deviceFragmentRef);
return (
<Table>
<Thead>
<Tr>
<Th>{t("devices.postures.columns.check")}</Th>
<Th>{t("devices.postures.columns.status")}</Th>
<Th className="text-end">{t("devices.postures.columns.observedAt")}</Th>
</Tr>
</Thead>
<Tbody>
{device.latestPostures.length === 0 && (
<Tr>
<Td colSpan={3} className="text-center text-txt-secondary">
{t("devices.postures.empty")}
</Td>
</Tr>
)}
{device.latestPostures.map(posture => (
<DevicePostureListItem key={posture.id} postureKey={posture} />
))}
</Tbody>
</Table>
);
}

View File

@@ -1,64 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { dateTimeFormat } from "@probo/i18n/date";
import { Badge, Td, Tr } from "@probo/ui";
import { useTranslation } from "react-i18next";
import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type {
DevicePostureListItem_postureFragment$key,
} from "#/__generated__/core/DevicePostureListItem_postureFragment.graphql";
import { statusVariant } from "../../_lib/deviceDisplay";
import { getPostureCheckLabel } from "../_lib/getPostureCheckLabel";
import { getPostureStatusLabel } from "../_lib/getPostureStatusLabel";
const postureFragment = graphql`
fragment DevicePostureListItem_postureFragment on DevicePosture {
checkKey
status
observedAt
}
`;
interface DevicePostureListItemProps {
postureKey: DevicePostureListItem_postureFragment$key;
}
export function DevicePostureListItem({ postureKey }: DevicePostureListItemProps) {
const { i18n, t } = useTranslation();
const posture = useFragment(postureFragment, postureKey);
return (
<Tr>
<Td>{getPostureCheckLabel(t, posture.checkKey)}</Td>
<Td>
<Badge variant={statusVariant(posture.status)}>
{getPostureStatusLabel(t, posture.status)}
</Badge>
</Td>
<Td className="text-end whitespace-nowrap">
{dateTimeFormat(i18n.language, posture.observedAt)}
</Td>
</Tr>
);
}

View File

@@ -1,33 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
type Translator = (key: string) => string;
const statusLabels: Record<string, string> = {
PASS: "devices.postures.status.pass",
FAIL: "devices.postures.status.fail",
UNKNOWN: "devices.postures.status.unknown",
NOT_APPLICABLE: "devices.postures.status.notApplicable",
};
export function getPostureStatusLabel(t: Translator, status: string) {
const label = statusLabels[status];
return label ? t(label) : status;
}

View File

@@ -34,17 +34,17 @@ const deviceTabs = () => [
}: LoaderFunctionArgs) => {
// eslint-disable-next-line
throw redirect(
`/organizations/${organizationId}/devices/${deviceId}/postures`,
`/organizations/${organizationId}/devices/${deviceId}/history`,
);
},
Component: Fragment,
},
{
path: "postures",
path: "history",
Fallback: LinkCardSkeleton,
Component: lazy(
() =>
import("#/pages/organizations/devices/postures/DevicePosturesPageLoader"),
import("#/pages/organizations/devices/history/DeviceHistoryPageLoader"),
),
},
];