Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

truncate execution output in default view #1025

Merged
merged 9 commits into from
Apr 4, 2022
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
fopina marked this conversation as resolved.
Show resolved Hide resolved
}
}
}

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