From 4dda46b6983441f09b1d6aa441038ce231dfd61c Mon Sep 17 00:00:00 2001 From: Filipe Pina <636320+fopina@users.noreply.github.com> Date: Mon, 4 Apr 2022 20:39:01 +0100 Subject: [PATCH] truncate execution output in default view (#1025) * truncate execution output in default view * rename output_size to output_size_limit as per review comment * rename _s to size per review * fix URL route for `/executions/:id` details for consistency * dirty attempt to get `output_truncated` bool into the UI * neatly hide button to load full output when it makes no sense * api test for output truncation * useless check removed (add a comment next time, if it is really required) * text that makes more sense for both scenarios --- dkron/api.go | 58 ++++++++++++++++++++++++- dkron/api_test.go | 94 +++++++++++++++++++++++++++++++++++++++++ ui/src/dataProvider.ts | 1 + ui/src/jobs/JobShow.tsx | 55 +++++++++++++++++++++++- 4 files changed, 204 insertions(+), 4 deletions(-) diff --git a/dkron/api.go b/dkron/api.go index 7823299fa..6b2db4219 100644 --- a/dkron/api.go +++ b/dkron/api.go @@ -119,6 +119,7 @@ func (h *HTTPTransport) APIRoutes(r *gin.RouterGroup, middleware ...gin.HandlerF // Place fallback routes last jobs.GET("/:job", h.jobGetHandler) jobs.GET("/:job/executions", h.executionsHandler) + jobs.GET("/:job/executions/:execution", h.executionHandler) } // MetaMiddleware adds middleware to the gin Context. @@ -328,6 +329,11 @@ func (h *HTTPTransport) restoreHandler(c *gin.Context) { renderJSON(c, http.StatusOK, string(resp)) } +type apiExecution struct { + *Execution + OutputTruncated bool `json:"output_truncated"` +} + func (h *HTTPTransport) executionsHandler(c *gin.Context) { jobName := c.Param("job") @@ -336,6 +342,10 @@ func (h *HTTPTransport) executionsHandler(c *gin.Context) { sort = "started_at" } order := c.DefaultQuery("_order", "DESC") + outputSizeLimit, err := strconv.Atoi(c.DefaultQuery("output_size_limit", "")) + if err != nil { + outputSizeLimit = -1 + } job, err := h.agent.Store.GetJob(jobName, nil) if err != nil { @@ -357,14 +367,58 @@ func (h *HTTPTransport) executionsHandler(c *gin.Context) { return } + apiExecutions := make([]*apiExecution, len(executions)) + for j, execution := range executions { + apiExecutions[j] = &apiExecution{execution, false} + if outputSizeLimit > -1 { + // truncate execution output + size := len(execution.Output) + if size > outputSizeLimit { + apiExecutions[j].Output = apiExecutions[j].Output[size-outputSizeLimit:] + apiExecutions[j].OutputTruncated = true + } + } + } + c.Header("X-Total-Count", strconv.Itoa(len(executions))) - renderJSON(c, http.StatusOK, executions) + renderJSON(c, http.StatusOK, apiExecutions) +} + +func (h *HTTPTransport) executionHandler(c *gin.Context) { + jobName := c.Param("job") + executionName := c.Param("execution") + + job, err := h.agent.Store.GetJob(jobName, nil) + if err != nil { + c.AbortWithError(http.StatusNotFound, err) + return + } + + executions, err := h.agent.Store.GetExecutions(job.Name, + &ExecutionOptions{ + Sort: "", + Order: "", + Timezone: job.GetTimeLocation(), + }, + ) + + if err != nil { + h.logger.Error(err) + return + } + + for _, execution := range executions { + if execution.Id == executionName { + renderJSON(c, http.StatusOK, execution) + return + } + } } type MId struct { serf.Member - Id string `json:"id"` + Id string `json:"id"` StatusText string `json:"statusText"` } diff --git a/dkron/api_test.go b/dkron/api_test.go index 99d8c7d82..2c0d19a1c 100644 --- a/dkron/api_test.go +++ b/dkron/api_test.go @@ -327,6 +327,100 @@ func TestAPIJobRestore(t *testing.T) { } +func TestAPIJobOutputTruncate(t *testing.T) { + port := "8190" + baseURL := fmt.Sprintf("http://localhost:%s/v1", port) + dir, a := setupAPITest(t, port) + defer os.RemoveAll(dir) + + jsonStr := []byte(`{ + "name": "test_job", + "schedule": "@every 1m", + "executor": "shell", + "executor_config": {"command": "date"}, + "owner": "mec", + "owner_email": "foo@bar.com", + "disabled": true + }`) + + resp, err := http.Post(baseURL+"/jobs", "encoding/json", bytes.NewBuffer(jsonStr)) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + resp, _ = http.Get(baseURL + "/jobs/test_job/executions") + body, _ := ioutil.ReadAll(resp.Body) + resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, string(body), "[]") + + testExecution1 := &Execution{ + JobName: "test_job", + StartedAt: time.Now().UTC(), + FinishedAt: time.Now().UTC(), + Success: true, + Output: "test", + NodeName: "testNode", + } + testExecution2 := &Execution{ + JobName: "test_job", + StartedAt: time.Now().UTC(), + FinishedAt: time.Now().UTC(), + Success: true, + Output: "test " + strings.Repeat("longer output... ", 100), + NodeName: "testNode2", + } + _, err = a.Store.SetExecution(testExecution1) + if err != nil { + t.Fatal(err) + } + _, err = a.Store.SetExecution(testExecution2) + if err != nil { + t.Fatal(err) + } + + // no truncation + resp, _ = http.Get(baseURL + "/jobs/test_job/executions") + body, _ = ioutil.ReadAll(resp.Body) + resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + var executions []apiExecution + if err := json.Unmarshal(body, &executions); err != nil { + t.Fatal(err) + } + assert.Equal(t, 2, len(executions)) + assert.False(t, executions[0].OutputTruncated) + assert.Equal(t, 1705, len(executions[0].Output)) + assert.False(t, executions[1].OutputTruncated) + assert.Equal(t, 4, len(executions[1].Output)) + + // truncate limit to 200 + resp, _ = http.Get(baseURL + "/jobs/test_job/executions?output_size_limit=200") + body, _ = ioutil.ReadAll(resp.Body) + resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + if err := json.Unmarshal(body, &executions); err != nil { + t.Fatal(err) + } + assert.Equal(t, 2, len(executions)) + assert.True(t, executions[0].OutputTruncated) + assert.Equal(t, 200, len(executions[0].Output)) + assert.False(t, executions[1].OutputTruncated) + assert.Equal(t, 4, len(executions[1].Output)) + + // test single execution endpoint + resp, _ = http.Get(baseURL + "/jobs/test_job/executions/" + executions[0].Id) + body, _ = ioutil.ReadAll(resp.Body) + resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + var execution Execution + if err := json.Unmarshal(body, &execution); err != nil { + t.Fatal(err) + } + assert.Equal(t, 1705, len(execution.Output)) +} + // postJob POSTs the given json to the jobs endpoint and returns the response func postJob(t *testing.T, port string, jsonStr []byte) *http.Response { baseURL := fmt.Sprintf("http://localhost:%s/v1", port) diff --git a/ui/src/dataProvider.ts b/ui/src/dataProvider.ts index 1d76b7d84..771b8eea9 100644 --- a/ui/src/dataProvider.ts +++ b/ui/src/dataProvider.ts @@ -18,6 +18,7 @@ const myDataProvider = { _order: order, _start: (page - 1) * perPage, _end: page * perPage, + output_size_limit: 200, }; const url = `${apiUrl}/${params.target}/${params.id}/${resource}?${stringify(query)}`; diff --git a/ui/src/jobs/JobShow.tsx b/ui/src/jobs/JobShow.tsx index 8203e84d9..0091b2751 100644 --- a/ui/src/jobs/JobShow.tsx +++ b/ui/src/jobs/JobShow.tsx @@ -11,14 +11,18 @@ import { TabbedShowLayout, Tab, ReferenceManyField, + useNotify, useRedirect, fetchStart, fetchEnd, Button, } from 'react-admin'; -import { OutputPanel } from "../executions/BusyList"; import ToggleButton from "./ToggleButton" import RunButton from "./RunButton" import { JsonField } from "react-admin-json-view"; import ZeroDateField from "./ZeroDateField"; import JobIcon from '@material-ui/icons/Update'; +import FullIcon from '@material-ui/icons/BatteryFull'; import { Tooltip } from '@material-ui/core'; +import { useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { apiUrl } from '../dataProvider'; const JobShowActions = ({ basePath, data, resource }: any) => ( @@ -32,6 +36,53 @@ const SuccessField = (props: any) => { return (props.record["finished_at"] === null ? : ); }; +const FullButton = ({record}: any) => { + const dispatch = useDispatch(); + const notify = useNotify(); + const [loading, setLoading] = useState(false); + const handleClick = () => { + setLoading(true); + dispatch(fetchStart()); // start the global loading indicator + fetch(`${apiUrl}/jobs/${record.job_name}/executions/${record.id}`) + .then((response) => { + if (response.ok) { + notify('Success loading full output'); + return response.json() + } + throw response + }) + .then((data) => { + record.output_truncated = false + record.output = data.output + }) + .catch((e) => { + notify('Error on loading full output', 'warning') + }) + .finally(() => { + setLoading(false); + dispatch(fetchEnd()); // stop the global loading indicator + }); + }; + return ( + + ); +}; + +const SpecialOutputPanel = ({ id, record, resource }: any) => { + return ( +
+ {record.output_truncated ?
: ""} + {record.output || "Nothing to show"} +
+ ); +}; + const JobShow = (props: any) => ( } {...props}> @@ -82,7 +133,7 @@ const JobShow = (props: any) => ( - false } expand={}> + false } expand={}>