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
6 changes: 3 additions & 3 deletions tavern/internal/www/build/asset-manifest.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion tavern/internal/www/build/index.html

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion tavern/internal/www/src/components/task-card/TaskCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import UserImageAndName from "../UserImageAndName";
import TaskStatusBadge from "../TaskStatusBadge";
import TaskShells from "./components/TaskShells";
import TaskProcesses from "./components/TaskProcesses";
import TaskFiles from "./components/TaskFiles";
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@headlessui/react";
import { BookOpenIcon, CommandLineIcon, DocumentTextIcon, NoSymbolIcon } from "@heroicons/react/24/outline";
import TaskResults from "./components/TaskResults";
import { TaskNode } from "../../utils/interfacesQuery";
import BeaconTile from "../BeaconTile";
import TomeAccordion from "../TomeAccordion";
import { constructTomeParams } from "../../utils/utils";
import { ListVideo } from "lucide-react";
import { FileText, ListVideo } from "lucide-react";

interface TaskCardType {
task: TaskNode
Expand Down Expand Up @@ -45,6 +46,7 @@ const TaskCard: FC<TaskCardType> = (
{task?.error && <Tab className={({ selected }) => `flex flex-row gap-1 items-center py-2 px-4 ${selected && 'bg-white rounded border-t-2 border-gray-200'}`}><NoSymbolIcon className="w-4" /> Error</Tab>}
{task?.shells?.edges.length > 0 && <Tab className={({ selected }) => `flex flex-row gap-1 items-center py-2 px-4 ${selected && 'bg-white rounded border-t-2 border-gray-200'}`}><CommandLineIcon className="w-4" /> Shells</Tab>}
{task?.reportedProcesses?.totalCount > 0 && <Tab className={({ selected }) => `flex flex-row gap-1 items-center py-2 px-4 ${selected && 'bg-white rounded border-t-2 border-gray-200'}`}><ListVideo className="w-4" /> Processes ({task?.reportedProcesses?.totalCount})</Tab>}
{task?.reportedFiles?.totalCount > 0 && <Tab className={({ selected }) => `flex flex-row gap-1 items-center py-2 px-4 ${selected && 'bg-white rounded border-t-2 border-gray-200'}`}><FileText className="w-4" /> Files ({task?.reportedFiles?.totalCount})</Tab>}
</TabList>
<TabPanels className="px-4">
<TabPanel aria-label="output panel">
Expand All @@ -62,6 +64,11 @@ const TaskCard: FC<TaskCardType> = (
<TaskProcesses taskId={task.id} hostId={task.beacon.host?.id || ""} />
</TabPanel>
)}
{task?.reportedFiles?.totalCount > 0 && (
<TabPanel aria-label="files panel">
<TaskFiles taskId={task.id} hostId={task.beacon.host?.id || ""} />
</TabPanel>
)}
</TabPanels>
</TabGroup>
</div>
Expand Down
221 changes: 221 additions & 0 deletions tavern/internal/www/src/components/task-card/components/TaskFiles.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { FC, useCallback, useMemo } from "react";
import { gql, useQuery } from "@apollo/client";
import { Link } from "react-router-dom";
import { ArrowDownToLine, ArrowRight } from "lucide-react";
import Tooltip from "../../tavern-base-ui/Tooltip";
import Button from "../../tavern-base-ui/button/Button";
import { VirtualizedTable } from "../../tavern-base-ui/virtualized-table/VirtualizedTable";
import { VirtualizedTableColumn } from "../../tavern-base-ui/virtualized-table/types";
import { EmptyState, EmptyStateType } from "../../tavern-base-ui/EmptyState";
import { formatBytes } from "../../../utils/utils";

const GET_TASK_FILE_IDS_QUERY = gql`
query GetTaskFileIds($taskId: ID!) {
tasks(where: { id: $taskId }) {
edges {
node {
reportedFiles(orderBy: { field: NAME, direction: ASC }) {
edges {
node {
id
}
}
}
}
}
}
}
`;

const GET_TASK_FILE_DETAIL_QUERY = gql`
query GetTaskFileDetail($taskId: ID!, $fileId: ID!) {
tasks(where: { id: $taskId }) {
edges {
node {
reportedFiles(where: { id: $fileId }, first: 1) {
edges {
node {
id
path
owner
group
permissions
size
hash
}
}
}
}
}
}
}
`;

interface FileNode {
id: string;
path: string;
owner: string | null;
group: string | null;
permissions: string | null;
size: number;
hash: string | null;
}

interface TaskFileIdsQueryResponse {
tasks: {
edges: {
node: {
reportedFiles: {
edges: {
node: {
id: string;
};
}[];
};
};
}[];
};
}

interface TaskFileDetailQueryResponse {
tasks: {
edges: {
node: {
reportedFiles: {
edges: {
node: FileNode;
}[];
};
};
}[];
};
}

interface TaskFilesProps {
taskId: string;
hostId: string;
}

const TaskFiles: FC<TaskFilesProps> = ({ taskId, hostId }) => {
const { data, loading, error } = useQuery<TaskFileIdsQueryResponse>(
GET_TASK_FILE_IDS_QUERY,
{
variables: { taskId },
fetchPolicy: 'cache-and-network',
}
);

const fileIds = useMemo(
() => data?.tasks?.edges?.[0]?.node?.reportedFiles?.edges?.map(edge => edge.node.id) || [],
[data]
);

const getVariables = useCallback((id: string) => ({ taskId, fileId: id }), [taskId]);

const extractData = useCallback((response: TaskFileDetailQueryResponse): FileNode | null => {
return response?.tasks?.edges?.[0]?.node?.reportedFiles?.edges?.[0]?.node || null;
}, []);

const columns: VirtualizedTableColumn<FileNode>[] = useMemo(() => [
{
key: 'path',
label: 'Path',
width: 'minmax(80px,2fr)',
render: (file: FileNode) => (
<Tooltip label={file.path} isDisabled={!file.path}>
<div className="truncate text-sm">
{file.path}
</div>
</Tooltip>
),
},
{
key: 'owner',
label: 'Owner',
width: 'minmax(80px,0.75fr)',
render: (file: FileNode) => file.owner || "-",
},
{
key: 'permissions',
label: 'Permissions',
width: 'minmax(80px,0.75fr)',
render: (file: FileNode) => (
<code className="text-sm">{file.permissions || "-"}</code>
),
},
{
key: 'size',
label: 'Size',
width: 'minmax(60px,0.5fr)',
render: (file: FileNode) => (
<Tooltip label={`${file.size} bytes`}>
<span>{formatBytes(file.size)}</span>
</Tooltip>
),
},
{
key: 'actions',
label: '',
width: 'minmax(30px,0.3fr)',
render: (file: FileNode) => (
<Tooltip label="Download">
<a href={`/cdn/hostfiles/${file.id}`} download onClick={(e) => e.stopPropagation()}>
<Button
buttonVariant="ghost"
buttonStyle={{ color: "gray", size: "xs" }}
leftIcon={<ArrowDownToLine className="w-4 h-4" />}
aria-label="Download"
/>
</a>
</Tooltip>
),
},
], []);

if (loading && fileIds.length === 0) {
return (
<EmptyState
type={EmptyStateType.loading}
label="Loading files..."
/>
);
}

if (error) {
return (
<EmptyState
type={EmptyStateType.error}
label="Error loading files"
details={error.message}
/>
);
}

return (
<div className="flex flex-col gap-2">
<VirtualizedTable<FileNode, TaskFileDetailQueryResponse>
items={fileIds}
columns={columns}
query={GET_TASK_FILE_DETAIL_QUERY}
getVariables={getVariables}
extractData={extractData}
estimateRowSize={73}
overscan={2}
height="300px"
minHeight="200px"
minWidth="600px"
/>
<div className="flex justify-end py-1">
<Link
to={`/hosts/${hostId}?tab=files`}
className="inline-flex items-center gap-1 text-sm semi-bold text-gray-800 hover:text-purple-800"
>
View host files
<ArrowRight className="w-4 h-4" />
</Link>
</div>
</div>
);
};

export default TaskFiles;
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { SearchIcon } from "@chakra-ui/icons";
import { Input, InputGroup, InputLeftElement } from "@chakra-ui/react";
import React, { useEffect, useRef } from "react";
import React, { useEffect, useRef, useState } from "react";
import { debounce } from "lodash"

type Props = {
Expand All @@ -12,6 +12,7 @@ type Props = {
}
const FreeTextSearch = (props: Props) => {
const { placeholder, defaultValue, setSearch, isDisabled, labelVisible= true } = props;
const [inputValue, setInputValue] = useState(defaultValue || "");

const debouncedSearch = useRef(
debounce(async (criteria) => {
Expand All @@ -20,6 +21,7 @@ const FreeTextSearch = (props: Props) => {
).current;

async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setInputValue(e.target.value);
debouncedSearch(e.target.value);
}

Expand All @@ -29,14 +31,19 @@ const FreeTextSearch = (props: Props) => {
};
}, [debouncedSearch]);

// Sync input value when defaultValue changes externally (e.g., clear filters)
useEffect(() => {
setInputValue(defaultValue || "");
}, [defaultValue]);

return (
<div className="flex flex-col gap-1">
{labelVisible && <label className="text-gray-700"> {placeholder}</label>}
<InputGroup className=" border-gray-300">
<InputLeftElement pointerEvents='none'>
<SearchIcon color='gray.300' />
</InputLeftElement>
<Input aria-label={placeholder} type='text' defaultValue={defaultValue} placeholder={placeholder} onChange={handleChange} disabled={isDisabled} />
<Input aria-label={placeholder} type='text' value={inputValue} placeholder={placeholder} onChange={handleChange} disabled={isDisabled} />
</InputGroup>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export function VirtualizedTableRowInternal<TData, TResponse>({
)}
{columns.map((column) => (
<div key={column.key} className="text-sm text-gray-700">
{column.render(itemData)}
{column.render(itemData) ?? "-"}
</div>
))}
</div>
Expand Down
Loading
Loading