Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<CreateShellButtonProps> = ({ 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 (
<Button
buttonVariant="ghost"
buttonStyle={{ color: "gray", size: 'xs' }}
disabled
aria-label="Loading shell options"
>
<Terminal className="w-4 h-4 animate-pulse" />
</Button>
);
}
return null;
}

return (
<Tooltip label="Create Shell" bg="white" color="black" hasArrow>
<span>
<Button
buttonVariant="ghost"
buttonStyle={{ color: "gray", size: 'xs' }}
onClick={handleCreateShell}
isLoading={mutationLoading}
aria-label="Create Shell"
>
<Terminal className="w-4 h-4" />
</Button>
</span>
</Tooltip>
);
};
29 changes: 29 additions & 0 deletions tavern/internal/www/src/components/create-shell-button/queries.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
`;
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -144,23 +145,26 @@ export const BeaconsTable = ({ beaconIds, hasMore = false, onLoadMore }: Beacons
const isOffline = checkIfBeaconOffline(beacon);
const id = beacon.id;
return (
<div className="flex flex-row justify-end">
{!isOffline &&
<Button
buttonStyle={{ color: "gray", size: 'md' }}
buttonVariant="ghost"
onClick={(e) => {
e.stopPropagation();
navigate("/createQuest", {
state: {
step: 1,
beacons: [id]
}
});
}}>
New quest
</Button>
}
<div className="flex flex-row justify-end items-center gap-2">
{!isOffline && (
<>
<CreateShellButton beaconId={id} />
<Button
buttonStyle={{ color: "gray", size: 'md' }}
buttonVariant="ghost"
onClick={(e) => {
e.stopPropagation();
navigate("/createQuest", {
state: {
step: 1,
beacons: [id]
}
});
}}>
New quest
</Button>
</>
)}
</div>
);
},
Expand Down
16 changes: 16 additions & 0 deletions tavern/internal/www/src/pages/hosts/HostsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -125,6 +126,21 @@ export const HostsTable = ({ hostIds, hasMore = false, onLoadMore }: HostsTableP
</div>
),
},
{
key: 'actions',
label: '',
width: 'minmax(40px,1fr)',
render: (host) => (
<div className="flex justify-end pr-4">
<CreateShellButton hostId={host.id} />
</div>
),
renderSkeleton: () => (
<div className="flex justify-end pr-4">
<div className="h-8 w-8 bg-gray-200 rounded animate-pulse"></div>
</div>
),
},
], [currentDate, principalColors]);

return (
Expand Down
Loading