diff --git a/tavern/internal/www/src/components/create-shell-button/CreateShellButton.tsx b/tavern/internal/www/src/components/create-shell-button/CreateShellButton.tsx new file mode 100644 index 000000000..c906b632f --- /dev/null +++ b/tavern/internal/www/src/components/create-shell-button/CreateShellButton.tsx @@ -0,0 +1,144 @@ +import React, { useMemo, useCallback } from 'react'; +import { useMutation, useQuery } from '@apollo/client'; +import { Tooltip, useToast } from '@chakra-ui/react'; +import { Terminal } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { add } from 'date-fns'; + +import Button from '../tavern-base-ui/button/Button'; +import { checkIfBeaconOffline } from '../../utils/utils'; +import { PrincipalAdminTypes } from '../../utils/enums'; +import { CREATE_SHELL_MUTATION, GET_BEACONS_FOR_HOST_QUERY } from './queries'; + +interface CreateShellButtonProps { + hostId?: string; + beaconId?: string; +} + +interface BeaconCandidate { + id: string; + principal: string; + interval: number; + lastSeenAt: string; + nextSeenAt?: string; +} + +export const CreateShellButton: React.FC = ({ hostId, beaconId }) => { + const navigate = useNavigate(); + const toast = useToast(); + + // If hostId is provided, fetch beacons + const { data: beaconsData, loading: beaconsLoading } = useQuery(GET_BEACONS_FOR_HOST_QUERY, { + variables: { hostId }, + skip: !hostId || !!beaconId, + fetchPolicy: 'cache-and-network', + }); + + const [createShell, { loading: mutationLoading }] = useMutation(CREATE_SHELL_MUTATION, { + onCompleted: (data) => { + const shellId = data.createShell.id; + navigate(`/shellv2/${shellId}`); + }, + onError: (error) => { + toast({ + title: "Error creating shell", + description: error.message, + status: "error", + duration: 5000, + isClosable: true, + }); + }, + }); + + const selectedBeaconId = useMemo(() => { + if (beaconId) return beaconId; + if (!hostId || !beaconsData?.beacons?.edges) return null; + + const beacons: BeaconCandidate[] = beaconsData.beacons.edges.map((edge: any) => edge.node); + + // Filter active beacons + const activeBeacons = beacons.filter(beacon => !checkIfBeaconOffline({ + lastSeenAt: beacon.lastSeenAt, + interval: beacon.interval + })); + + if (activeBeacons.length === 0) return null; + + // Sort candidates + // 1. Principal priority: root, administrator, system + // 2. Shortest interval + // 3. Soonest nextSeenAt (or lastSeenAt + interval) + + const highPriorityPrincipals = [ + PrincipalAdminTypes.root, + PrincipalAdminTypes.Administrator, + PrincipalAdminTypes.SYSTEM + ] as string[]; + + // Clone array before sorting to avoid mutating read-only objects from Apollo + return [...activeBeacons].sort((a, b) => { + // 1. Principal Priority + const aIsHigh = highPriorityPrincipals.includes(a.principal); + const bIsHigh = highPriorityPrincipals.includes(b.principal); + + if (aIsHigh && !bIsHigh) return -1; + if (!aIsHigh && bIsHigh) return 1; + + // 2. Shortest Interval + if (a.interval !== b.interval) { + return a.interval - b.interval; + } + + // 3. Soonest Next Check-in + const aNext = a.nextSeenAt + ? new Date(a.nextSeenAt).getTime() + : add(new Date(a.lastSeenAt), { seconds: a.interval }).getTime(); + + const bNext = b.nextSeenAt + ? new Date(b.nextSeenAt).getTime() + : add(new Date(b.lastSeenAt), { seconds: b.interval }).getTime(); + + return aNext - bNext; + })[0]?.id || null; + + }, [beaconId, hostId, beaconsData]); + + const handleCreateShell = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + if (selectedBeaconId) { + createShell({ variables: { beaconId: selectedBeaconId } }); + } + }, [createShell, selectedBeaconId]); + + if (!selectedBeaconId) { + if (hostId && beaconsLoading) { + return ( + + ); + } + return null; + } + + return ( + + + + + + ); +}; diff --git a/tavern/internal/www/src/components/create-shell-button/queries.ts b/tavern/internal/www/src/components/create-shell-button/queries.ts new file mode 100644 index 000000000..5523833fe --- /dev/null +++ b/tavern/internal/www/src/components/create-shell-button/queries.ts @@ -0,0 +1,29 @@ +import { gql } from "@apollo/client"; + +export const GET_BEACONS_FOR_HOST_QUERY = gql` + query GetBeaconsForHost($hostId: ID!) { + beacons( + where: { hasHostWith: { id: $hostId } }, + first: 100, + orderBy: [{ direction: DESC, field: LAST_SEEN_AT }] + ) { + edges { + node { + id + principal + interval + lastSeenAt + nextSeenAt + } + } + } + } +`; + +export const CREATE_SHELL_MUTATION = gql` + mutation CreateShell($beaconId: ID!) { + createShell(input: { beaconID: $beaconId }) { + id + } + } +`; diff --git a/tavern/internal/www/src/pages/host-details/beacon-tab/BeaconsTable.tsx b/tavern/internal/www/src/pages/host-details/beacon-tab/BeaconsTable.tsx index 60a298fbc..80177a069 100644 --- a/tavern/internal/www/src/pages/host-details/beacon-tab/BeaconsTable.tsx +++ b/tavern/internal/www/src/pages/host-details/beacon-tab/BeaconsTable.tsx @@ -10,6 +10,7 @@ import { BeaconDetailQueryResponse } from "./types"; import { PrincipalAdminTypes, SupportedTransports } from "../../../utils/enums"; import { BeaconNode } from "../../../utils/interfacesQuery"; import { checkIfBeaconOffline, getEnumKey } from "../../../utils/utils"; +import { CreateShellButton } from "../../../components/create-shell-button/CreateShellButton"; interface BeaconsTableProps { beaconIds: string[]; @@ -144,23 +145,26 @@ export const BeaconsTable = ({ beaconIds, hasMore = false, onLoadMore }: Beacons const isOffline = checkIfBeaconOffline(beacon); const id = beacon.id; return ( -
- {!isOffline && - - } +
+ {!isOffline && ( + <> + + + + )}
); }, diff --git a/tavern/internal/www/src/pages/hosts/HostsTable.tsx b/tavern/internal/www/src/pages/hosts/HostsTable.tsx index 7a16c54cd..0b284351c 100644 --- a/tavern/internal/www/src/pages/hosts/HostsTable.tsx +++ b/tavern/internal/www/src/pages/hosts/HostsTable.tsx @@ -10,6 +10,7 @@ import { HostDetailQueryResponse } from "./types"; import { getOfflineOnlineStatus, getFormatForPrincipal } from "../../utils/utils"; import { PrincipalAdminTypes } from "../../utils/enums"; import { HostNode } from "../../utils/interfacesQuery"; +import { CreateShellButton } from "../../components/create-shell-button/CreateShellButton"; interface HostsTableProps { hostIds: string[]; @@ -125,6 +126,21 @@ export const HostsTable = ({ hostIds, hasMore = false, onLoadMore }: HostsTableP
), }, + { + key: 'actions', + label: '', + width: 'minmax(40px,1fr)', + render: (host) => ( +
+ +
+ ), + renderSkeleton: () => ( +
+
+
+ ), + }, ], [currentDate, principalColors]); return (