Skip to content

Commit

Permalink
truncate execution output in default view (#1025)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
fopina committed Apr 4, 2022
1 parent efd93c5 commit 4dda46b
Show file tree
Hide file tree
Showing 4 changed files with 204 additions and 4 deletions.
58 changes: 56 additions & 2 deletions dkron/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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")

Expand All @@ -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 {
Expand All @@ -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"`
}

Expand Down
94 changes: 94 additions & 0 deletions dkron/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions ui/src/dataProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`;

Expand Down
55 changes: 53 additions & 2 deletions ui/src/jobs/JobShow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
<TopToolbar>
Expand All @@ -32,6 +36,53 @@ const SuccessField = (props: any) => {
return (props.record["finished_at"] === null ? <Tooltip title="Running"><JobIcon /></Tooltip> : <BooleanField {...props} />);
};

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 (
<Button
label="Load full output"
onClick={handleClick}
disabled={loading}
>
<FullIcon/>
</Button>
);
};

const SpecialOutputPanel = ({ id, record, resource }: any) => {
return (
<div className="execution-output">
{record.output_truncated ? <div><FullButton record={record} /></div> : ""}
{record.output || "Nothing to show"}
</div>
);
};

const JobShow = (props: any) => (
<Show actions={<JobShowActions {...props}/>} {...props}>
<TabbedShowLayout>
Expand Down Expand Up @@ -82,7 +133,7 @@ const JobShow = (props: any) => (
</Tab>
<Tab label="executions" path="executions">
<ReferenceManyField reference="executions" target="jobs" label="Executions">
<Datagrid rowClick="expand" isRowSelectable={ record => false } expand={<OutputPanel {...props} />}>
<Datagrid rowClick="expand" isRowSelectable={ record => false } expand={<SpecialOutputPanel {...props} />}>
<TextField source="id" />
<TextField source="group" sortable={false} />
<TextField source="job_name" sortable={false} />
Expand Down

0 comments on commit 4dda46b

Please sign in to comment.