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:
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"),
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user