Skip to content

Commit

Permalink
Add basic task logs (#45054)
Browse files Browse the repository at this point in the history
  • Loading branch information
bbovenzi authored Dec 18, 2024
1 parent ba49469 commit 98cb038
Show file tree
Hide file tree
Showing 6 changed files with 355 additions and 5 deletions.
8 changes: 5 additions & 3 deletions airflow/ui/src/components/TaskInstanceTooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,22 @@
*/
import { Box, Text } from "@chakra-ui/react";

import type { TaskInstanceResponse } from "openapi/requests/types.gen";
import type {
TaskInstanceHistoryResponse,
TaskInstanceResponse,
} from "openapi/requests/types.gen";
import Time from "src/components/Time";
import { Tooltip, type TooltipProps } from "src/components/ui";

type Props = {
readonly taskInstance: TaskInstanceResponse;
readonly taskInstance: TaskInstanceHistoryResponse | TaskInstanceResponse;
} & Omit<TooltipProps, "content">;

const TaskInstanceTooltip = ({ children, taskInstance }: Props) => (
<Tooltip
content={
<Box>
<Text>Run ID: {taskInstance.dag_run_id}</Text>
<Text>Logical Date: {taskInstance.logical_date}</Text>
<Text>
Start Date: <Time datetime={taskInstance.start_date} />
</Text>
Expand Down
154 changes: 154 additions & 0 deletions airflow/ui/src/components/TaskTrySelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/*!
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
Button,
createListCollection,
HStack,
VStack,
Heading,
} from "@chakra-ui/react";

import { useTaskInstanceServiceGetMappedTaskInstanceTries } from "openapi/queries";
import type {
TaskInstanceHistoryResponse,
TaskInstanceResponse,
} from "openapi/requests/types.gen";

import TaskInstanceTooltip from "./TaskInstanceTooltip";
import { Select, Status } from "./ui";

type Props = {
readonly onSelectTryNumber?: (tryNumber: number) => void;
readonly selectedTryNumber?: number;
readonly taskInstance: TaskInstanceResponse;
};

export const TaskTrySelect = ({
onSelectTryNumber,
selectedTryNumber,
taskInstance,
}: Props) => {
const {
dag_id: dagId,
dag_run_id: dagRunId,
map_index: mapIndex,
task_id: taskId,
try_number: finalTryNumber,
} = taskInstance;

const { data: tiHistory } = useTaskInstanceServiceGetMappedTaskInstanceTries(
{
dagId,
dagRunId,
mapIndex,
taskId,
},
undefined,
{
enabled: Boolean(finalTryNumber && finalTryNumber > 1), // Only try to look up task tries if try number > 1
},
);

if (!finalTryNumber || finalTryNumber <= 1) {
return undefined;
}

const logAttemptDropdownLimit = 10;
const showDropdown = finalTryNumber > logAttemptDropdownLimit;

const tryOptions = createListCollection({
items: (tiHistory?.task_instances ?? []).map((ti) => ({
task_instance: ti,
value: ti.try_number.toString(),
})),
});

return (
<VStack alignItems="flex-start" gap={1} my={3}>
<Heading size="md">Task Tries</Heading>
{showDropdown ? (
<Select.Root
collection={tryOptions}
data-testid="select-task-try"
defaultValue={[finalTryNumber.toString()]}
onValueChange={(details) => {
if (onSelectTryNumber) {
onSelectTryNumber(
details.value[0] === undefined
? finalTryNumber
: parseInt(details.value[0], 10),
);
}
}}
width="200px"
>
<Select.Trigger>
<Select.ValueText placeholder="Task Try">
{(
items: Array<{
task_instance: TaskInstanceHistoryResponse;
value: number;
}>,
) => (
<Status
// eslint-disable-next-line unicorn/no-null
state={items[0]?.task_instance.state ?? null}
>
{items[0]?.value}
</Status>
)}
</Select.ValueText>
</Select.Trigger>
<Select.Content>
{tryOptions.items.map((option) => (
<Select.Item item={option} key={option.value}>
<Status state={option.task_instance.state}>
{option.value}
</Status>
</Select.Item>
))}
</Select.Content>
</Select.Root>
) : (
<HStack>
{tiHistory?.task_instances.map((ti) => (
<TaskInstanceTooltip key={ti.try_number} taskInstance={ti}>
<Button
colorPalette="blue"
data-testid={`log-attempt-select-button-${ti.try_number}`}
key={ti.try_number}
onClick={() => {
if (onSelectTryNumber && ti.try_number) {
onSelectTryNumber(ti.try_number);
}
}}
variant={
selectedTryNumber === ti.try_number ? "surface" : "outline"
}
>
{ti.try_number}
<Status state={ti.state} />
</Button>
</TaskInstanceTooltip>
))}
</HStack>
)}
</VStack>
);
};
113 changes: 113 additions & 0 deletions airflow/ui/src/pages/TaskInstance/Logs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/*!
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Box, Code, HStack, Skeleton, VStack } from "@chakra-ui/react";
import { useState } from "react";
import { useParams, useSearchParams } from "react-router-dom";

import { useTaskInstanceServiceGetMappedTaskInstance } from "openapi/queries";
import { ErrorAlert } from "src/components/ErrorAlert";
import { TaskTrySelect } from "src/components/TaskTrySelect";
import { Button, ProgressBar } from "src/components/ui";
import { useConfig } from "src/queries/useConfig";
import { useLogs } from "src/queries/useLogs";

export const Logs = () => {
const { dagId = "", runId = "", taskId = "" } = useParams();
const [searchParams, setSearchParams] = useSearchParams();

const mapIndexParam = searchParams.get("map_index");
const tryNumberParam = searchParams.get("try_number");
const mapIndex = parseInt(mapIndexParam ?? "-1", 10);

const {
data: taskInstance,
error,
isLoading,
} = useTaskInstanceServiceGetMappedTaskInstance({
dagId,
dagRunId: runId,
mapIndex,
taskId,
});

const onSelectTryNumber = (newTryNumber: number) => {
if (newTryNumber === taskInstance?.try_number) {
searchParams.delete("try_number");
} else {
searchParams.set("try_number", newTryNumber.toString());
}
setSearchParams(searchParams);
};

const tryNumber =
tryNumberParam === null
? taskInstance?.try_number
: parseInt(tryNumberParam, 10);

const defaultWrap = Boolean(useConfig("default_wrap"));

const [wrap, setWrap] = useState(defaultWrap);

const toggleWrap = () => setWrap(!wrap);

const {
data,
error: logError,
isLoading: isLoadingLogs,
} = useLogs({
dagId,
mapIndex,
runId,
taskId,
tryNumber: tryNumber ?? 1,
});

return (
<Box p={2}>
<HStack justifyContent="space-between" mb={2}>
{taskInstance === undefined ? (
<div />
) : (
<TaskTrySelect
onSelectTryNumber={onSelectTryNumber}
selectedTryNumber={tryNumber}
taskInstance={taskInstance}
/>
)}
<Button
aria-label={wrap ? "Unwrap" : "Wrap"}
bg="bg.panel"
onClick={toggleWrap}
variant="outline"
>
{wrap ? "Unwrap" : "Wrap"}
</Button>
</HStack>
<ErrorAlert error={error ?? logError} />
<Skeleton />
<ProgressBar
size="xs"
visibility={isLoading || isLoadingLogs ? "visible" : "hidden"}
/>
<Code overflow="auto" py={3} textWrap={wrap ? "pre" : "nowrap"}>
<VStack alignItems="flex-start">{data.parsedLogs}</VStack>
</Code>
</Box>
);
};
1 change: 1 addition & 0 deletions airflow/ui/src/pages/TaskInstance/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@
*/

export * from "./TaskInstance";
export * from "./Logs";
80 changes: 80 additions & 0 deletions airflow/ui/src/queries/useLogs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*!
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useTaskInstanceServiceGetLog } from "openapi/queries";

type Props = {
dagId: string;
mapIndex?: number;
runId: string;
taskId: string;
tryNumber?: number;
};

type ParseLogsProps = {
data: string | undefined;
};

// TODO: add support for log groups, colors, formats, filters
const parseLogs = ({ data }: ParseLogsProps) => {
if (data === undefined) {
return {};
}
let lines;

let warning;

try {
lines = data.split("\\n");
} catch {
warning = "Unable to show logs. There was an error parsing logs.";

return { data, warning };
}

// eslint-disable-next-line react/no-array-index-key
const parsedLines = lines.map((line, index) => <p key={index}>{line}</p>);

return {
fileSources: [],
parsedLogs: parsedLines,
warning,
};
};

export const useLogs = ({
dagId,
mapIndex = -1,
runId,
taskId,
tryNumber = 1,
}: Props) => {
const { data, ...rest } = useTaskInstanceServiceGetLog({
dagId,
dagRunId: runId,
mapIndex,
taskId,
tryNumber,
});

const parsedData = parseLogs({
data: data?.content,
});

return { data: parsedData, ...rest };
};
4 changes: 2 additions & 2 deletions airflow/ui/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { Events } from "src/pages/Events";
import { Run } from "src/pages/Run";
import { TaskInstances } from "src/pages/Run/TaskInstances";
import { Task, Instances } from "src/pages/Task";
import { TaskInstance } from "src/pages/TaskInstance";
import { TaskInstance, Logs } from "src/pages/TaskInstance";
import { XCom } from "src/pages/XCom";

import { Variables } from "./pages/Variables";
Expand Down Expand Up @@ -82,7 +82,7 @@ export const router = createBrowserRouter(
},
{
children: [
{ element: <div>Logs</div>, index: true },
{ element: <Logs />, index: true },
{ element: <Events />, path: "events" },
{ element: <XCom />, path: "xcom" },
{ element: <Code />, path: "code" },
Expand Down

0 comments on commit 98cb038

Please sign in to comment.