This is an automated email from the ASF dual-hosted git repository.

bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 29b3a303f4 Add Dag Audit Log to React (#37682)
29b3a303f4 is described below

commit 29b3a303f48c0345c136add49035caea66fc6dd6
Author: Brent Bovenzi <[email protected]>
AuthorDate: Sun Feb 25 10:38:10 2024 -0500

    Add Dag Audit Log to React (#37682)
    
    * Initial audit log page
    
    * Improve tab logic, add filters to audit log
---
 airflow/www/static/js/api/index.ts             |   2 +
 airflow/www/static/js/api/useEventLogs.tsx     |  59 +++++++++
 airflow/www/static/js/dag/details/AuditLog.tsx | 170 +++++++++++++++++++++++++
 airflow/www/static/js/dag/details/index.tsx    |  44 +++++--
 airflow/www/templates/airflow/dag.html         |   2 +
 5 files changed, 266 insertions(+), 11 deletions(-)

diff --git a/airflow/www/static/js/api/index.ts 
b/airflow/www/static/js/api/index.ts
index d703feaef9..4ab90292a4 100644
--- a/airflow/www/static/js/api/index.ts
+++ b/airflow/www/static/js/api/index.ts
@@ -50,6 +50,7 @@ import useDags from "./useDags";
 import useDagRuns from "./useDagRuns";
 import useHistoricalMetricsData from "./useHistoricalMetricsData";
 import { useTaskXcomEntry, useTaskXcomCollection } from "./useTaskXcom";
+import useEventLogs from "./useEventLogs";
 
 axios.interceptors.request.use((config) => {
   config.paramsSerializer = {
@@ -96,4 +97,5 @@ export {
   useHistoricalMetricsData,
   useTaskXcomEntry,
   useTaskXcomCollection,
+  useEventLogs,
 };
diff --git a/airflow/www/static/js/api/useEventLogs.tsx 
b/airflow/www/static/js/api/useEventLogs.tsx
new file mode 100644
index 0000000000..b63befd21b
--- /dev/null
+++ b/airflow/www/static/js/api/useEventLogs.tsx
@@ -0,0 +1,59 @@
+/*!
+ * 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 axios, { AxiosResponse } from "axios";
+import { useQuery } from "react-query";
+
+import { getMetaValue } from "src/utils";
+import type { API } from "src/types";
+import { useAutoRefresh } from "src/context/autorefresh";
+
+export default function useEventLogs({
+  dagId,
+  taskId,
+  limit,
+  offset,
+  orderBy,
+  after,
+  before,
+  owner,
+}: API.GetEventLogsVariables) {
+  const { isRefreshOn } = useAutoRefresh();
+  return useQuery(
+    ["eventLogs", dagId, taskId, limit, offset, orderBy, after, before, owner],
+    () => {
+      const eventsLogUrl = getMetaValue("event_logs_api");
+      const orderParam = orderBy ? { order_by: orderBy } : {};
+      return axios.get<AxiosResponse, API.EventLogCollection>(eventsLogUrl, {
+        params: {
+          offset,
+          limit,
+          ...{ dag_id: dagId },
+          ...{ task_id: taskId },
+          ...orderParam,
+          after,
+          before,
+        },
+      });
+    },
+    {
+      refetchInterval: isRefreshOn && (autoRefreshInterval || 1) * 1000,
+    }
+  );
+}
diff --git a/airflow/www/static/js/dag/details/AuditLog.tsx 
b/airflow/www/static/js/dag/details/AuditLog.tsx
new file mode 100644
index 0000000000..69123ccc9f
--- /dev/null
+++ b/airflow/www/static/js/dag/details/AuditLog.tsx
@@ -0,0 +1,170 @@
+/*!
+ * 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.
+ */
+
+/* global moment */
+
+import React, { useMemo, useRef, useState } from "react";
+import {
+  Box,
+  Flex,
+  FormControl,
+  FormHelperText,
+  FormLabel,
+  Input,
+  HStack,
+} from "@chakra-ui/react";
+import type { SortingRule } from "react-table";
+import { snakeCase } from "lodash";
+
+import { CodeCell, Table, TimeCell } from "src/components/Table";
+import { useEventLogs } from "src/api";
+import { getMetaValue, useOffsetTop } from "src/utils";
+import type { DagRun } from "src/types";
+import LinkButton from "src/components/LinkButton";
+
+interface Props {
+  taskId?: string;
+  run?: DagRun;
+}
+
+const dagId = getMetaValue("dag_id") || undefined;
+
+const AuditLog = ({ taskId, run }: Props) => {
+  const logRef = useRef<HTMLDivElement>(null);
+  const offsetTop = useOffsetTop(logRef);
+  const limit = 25;
+  const [offset, setOffset] = useState(0);
+  const [sortBy, setSortBy] = useState<SortingRule<object>[]>([
+    { id: "when", desc: true },
+  ]);
+
+  const sort = sortBy[0];
+  const orderBy = sort ? `${sort.desc ? "-" : ""}${snakeCase(sort.id)}` : "";
+
+  const { data, isLoading } = useEventLogs({
+    dagId,
+    taskId,
+    before: run?.lastSchedulingDecision || undefined,
+    after: run?.queuedAt || undefined,
+    orderBy,
+    limit,
+    offset,
+  });
+
+  const columns = useMemo(() => {
+    const when = {
+      Header: "When",
+      accessor: "when",
+      Cell: TimeCell,
+    };
+    const task = {
+      Header: "Task ID",
+      accessor: "taskId",
+    };
+    const rest = [
+      {
+        Header: "Event",
+        accessor: "event",
+      },
+      {
+        Header: "Owner",
+        accessor: "owner",
+      },
+      {
+        Header: "Extra",
+        accessor: "extra",
+        Cell: CodeCell,
+      },
+    ];
+    return !taskId ? [when, task, ...rest] : [when, ...rest];
+  }, [taskId]);
+
+  const memoData = useMemo(() => data?.eventLogs, [data?.eventLogs]);
+  const memoSort = useMemo(() => sortBy, [sortBy]);
+
+  return (
+    <Box
+      height="100%"
+      maxHeight={`calc(100% - ${offsetTop}px)`}
+      ref={logRef}
+      overflowY="auto"
+    >
+      <Flex justifyContent="right">
+        <LinkButton href={getMetaValue("audit_log_url")}>
+          View full cluster Audit Log
+        </LinkButton>
+      </Flex>
+      <HStack spacing={2} alignItems="flex-start">
+        <FormControl>
+          <FormLabel>Show Logs After</FormLabel>
+          <Input
+            type="datetime"
+            // @ts-ignore
+            placeholder={run?.queuedAt ? moment(run?.queuedAt).format() : ""}
+            isDisabled
+          />
+          {!!run && run?.queuedAt && (
+            <FormHelperText>After selected DAG Run Queued At</FormHelperText>
+          )}
+        </FormControl>
+        <FormControl>
+          <FormLabel>Show Logs Before</FormLabel>
+          <Input
+            type="datetime"
+            placeholder={
+              run?.lastSchedulingDecision
+                ? // @ts-ignore
+                  moment(run?.lastSchedulingDecision).format()
+                : ""
+            }
+            isDisabled
+          />
+          {!!run && run?.lastSchedulingDecision && (
+            <FormHelperText>
+              Before selected DAG Run Last Scheduling Decision
+            </FormHelperText>
+          )}
+        </FormControl>
+        <FormControl>
+          <FormLabel>Filter by Task ID</FormLabel>
+          <Input placeholder={taskId} isDisabled />
+          <FormHelperText />
+        </FormControl>
+      </HStack>
+      <Table
+        data={memoData || []}
+        columns={columns}
+        isLoading={isLoading}
+        manualPagination={{
+          offset,
+          setOffset,
+          totalEntries: data?.totalEntries || 0,
+        }}
+        manualSort={{
+          setSortBy,
+          sortBy,
+          initialSortBy: memoSort,
+        }}
+        pageSize={limit}
+      />
+    </Box>
+  );
+};
+
+export default AuditLog;
diff --git a/airflow/www/static/js/dag/details/index.tsx 
b/airflow/www/static/js/dag/details/index.tsx
index 8e5301af9e..5f474b0b4a 100644
--- a/airflow/www/static/js/dag/details/index.tsx
+++ b/airflow/www/static/js/dag/details/index.tsx
@@ -42,6 +42,7 @@ import {
   MdOutlineViewTimeline,
   MdSyncAlt,
   MdHourglassBottom,
+  MdPlagiarism,
 } from "react-icons/md";
 import { BiBracket } from "react-icons/bi";
 import URLSearchParamsWrapper from "src/utils/URLSearchParamWrapper";
@@ -63,6 +64,7 @@ import ClearInstance from 
"./taskInstance/taskActions/ClearInstance";
 import MarkInstanceAs from "./taskInstance/taskActions/MarkInstanceAs";
 import XcomCollection from "./taskInstance/Xcom";
 import TaskDetails from "./task";
+import AuditLog from "./AuditLog";
 
 const dagId = getMetaValue("dag_id")!;
 
@@ -82,11 +84,13 @@ const tabToIndex = (tab?: string) => {
       return 2;
     case "code":
       return 3;
+    case "audit_log":
+      return 4;
     case "logs":
     case "mapped_tasks":
-      return 4;
-    case "xcom":
       return 5;
+    case "xcom":
+      return 6;
     case "details":
     default:
       return 0;
@@ -99,6 +103,8 @@ const indexToTab = (
   isMappedTaskSummary: boolean
 ) => {
   switch (index) {
+    case 0:
+      return "details";
     case 1:
       return "graph";
     case 2:
@@ -106,13 +112,14 @@ const indexToTab = (
     case 3:
       return "code";
     case 4:
+      return "audit_log";
+    case 5:
       if (isMappedTaskSummary) return "mapped_tasks";
       if (isTaskInstance) return "logs";
       return undefined;
-    case 5:
+    case 6:
       if (isTaskInstance) return "xcom";
       return undefined;
-    case 0:
     default:
       return undefined;
   }
@@ -173,13 +180,16 @@ const Details = ({
   );
 
   useEffect(() => {
-    // Default to graph tab when navigating from a task instance to a 
group/dag/dagrun
-    const tabCount = runId && taskId && !isGroup ? 5 : 4;
-    if (tabCount === 4 && tabIndex > 3) {
-      if (!runId && taskId) onChangeTab(0);
-      else onChangeTab(1);
-    }
-  }, [runId, taskId, tabIndex, isGroup, onChangeTab]);
+    // Change to graph or task duration tab if the tab is no longer defined
+    if (indexToTab(tabIndex, isTaskInstance, isMappedTaskSummary) === 
undefined)
+      onChangeTab(showTaskDetails ? 0 : 1);
+  }, [
+    tabIndex,
+    isTaskInstance,
+    isMappedTaskSummary,
+    showTaskDetails,
+    onChangeTab,
+  ]);
 
   const run = dagRuns.find((r) => r.runId === runId);
   const { data: mappedTaskInstance } = useTaskInstance({
@@ -275,6 +285,12 @@ const Details = ({
               Code
             </Text>
           </Tab>
+          <Tab>
+            <MdPlagiarism size={16} />
+            <Text as="strong" ml={1}>
+              Audit Log
+            </Text>
+          </Tab>
           {isTaskInstance && (
             <Tab>
               <MdReorder size={16} />
@@ -359,6 +375,12 @@ const Details = ({
           <TabPanel height="100%">
             <DagCode />
           </TabPanel>
+          <TabPanel height="100%">
+            <AuditLog
+              taskId={isGroup || !taskId ? undefined : taskId}
+              run={run}
+            />
+          </TabPanel>
           {isTaskInstance && run && (
             <TabPanel
               pt={mapIndex !== undefined ? "0px" : undefined}
diff --git a/airflow/www/templates/airflow/dag.html 
b/airflow/www/templates/airflow/dag.html
index 5d854abe6e..939809ce81 100644
--- a/airflow/www/templates/airflow/dag.html
+++ b/airflow/www/templates/airflow/dag.html
@@ -80,6 +80,8 @@
   <meta name="dag_source_api" content="{{ 
url_for('/api/v1.airflow_api_connexion_endpoints_dag_source_endpoint_get_dag_source',
 file_token='_FILE_TOKEN_') }}">
   <meta name="dag_details_api" content="{{ 
url_for('/api/v1.airflow_api_connexion_endpoints_dag_endpoint_get_dag_details', 
dag_id=dag.dag_id) }}">
   <meta name="datasets_api" content="{{ 
url_for('/api/v1.airflow_api_connexion_endpoints_dataset_endpoint_get_datasets')
 }}">
+  <meta name="event_logs_api" content="{{ 
url_for('/api/v1.airflow_api_connexion_endpoints_event_log_endpoint_get_event_logs')
 }}">
+  <meta name="audit_log_url" content="{{ url_for('LogModelView.list') }}">
 
   <!-- End Urls -->
   <meta name="is_paused" content="{{ dag_is_paused }}">

Reply via email to