runsummary: tasks graph implementation
Add a tasks graph display in addition to the list display. A button will let the user choose between the two display formats (defaulting to the tasks graph). Since the graph is a DAG with many edges it's quite difficult to organize the tasks and the edges without any overlap. The current implementation uses a simpler approach where tasks are distributed horizontally by their level and tasks at the same level are distributed in a way that there's always space for an edge also if the child is some levels below. In this way edges may overlap and in this case they will appear as a single edge. The user, when hovering over a task, will see the connections with the other tasks since the related edges becomes bolder.
This commit is contained in:
parent
f244ce02c8
commit
8e9d52b8e2
@ -7,21 +7,33 @@
|
||||
>
|
||||
<div>{{ fetchRunError }}</div>
|
||||
</div>
|
||||
<rundetail :run="run" :ownertype="ownertype" :ownername="ownername" :projectref="projectref"/>
|
||||
<rundetail :run="run" :ownertype="ownertype" :ownername="ownername" :projectref="projectref" />
|
||||
<div v-if="run">
|
||||
<div v-if="run.phase != 'setuperror'">
|
||||
<div class="m-4 text-xl font-bold">Tasks</div>
|
||||
<div class="flex items-center my-6 justify-between">
|
||||
<span class="ml-4 text-xl font-bold">Tasks</span>
|
||||
|
||||
<ul v-if="run">
|
||||
<li v-for="task in run.sortedTasks" v-bind:key="task.id">
|
||||
<task
|
||||
v-bind:task="task"
|
||||
v-bind:link="runTaskLink(task)"
|
||||
v-bind:waiting-approval="run.tasks_waiting_approval.includes(task.id)"
|
||||
v-bind:parents="parents(task)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="flex">
|
||||
<button
|
||||
@click="tasksDisplay = 'graph'"
|
||||
class="relative flex items-center focus:outline-none bg-blue-500 hover:bg-blue-600 text-white font-semibold hover:text-white py-2 px-4 border border-blue-700 rounded rounded-r-none"
|
||||
:class="{ 'bg-blue-600': tasksDisplay=='graph'}"
|
||||
title="Tasks Graph"
|
||||
>
|
||||
<i class="mr-1 mdi mdi-file-tree" />
|
||||
</button>
|
||||
<button
|
||||
@click="tasksDisplay = 'list'"
|
||||
class="relative flex items-center focus:outline-none bg-blue-500 hover:bg-blue-600 text-white font-semibold hover:text-white py-2 px-4 border border-l-0 border-blue-700 rounded rounded-l-none"
|
||||
title="Tasks List"
|
||||
:class="{ 'bg-blue-600': tasksDisplay=='list'}"
|
||||
>
|
||||
<i class="mr-1 mdi mdi-format-list-bulleted-square" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<tasks v-if="tasksDisplay == 'list'" :tasks="run.tasks" />
|
||||
<tasksgraph v-if="tasksDisplay == 'graph'" :tasks="run.tasks" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<h2 class="my-4 text-2xl">Setup Errors</h2>
|
||||
@ -42,11 +54,12 @@ import { fetchRun } from "@/util/data.js";
|
||||
import { userDirectRunTaskLink, projectRunTaskLink } from "@/util/link.js";
|
||||
|
||||
import rundetail from "@/components/rundetail.vue";
|
||||
import task from "@/components/task.vue";
|
||||
import tasks from "@/components/tasks.vue";
|
||||
import tasksgraph from "@/components/tasksgraph.vue";
|
||||
|
||||
export default {
|
||||
name: "runsummary",
|
||||
components: { rundetail, task },
|
||||
components: { rundetail, tasks, tasksgraph },
|
||||
props: {
|
||||
ownertype: String,
|
||||
ownername: String,
|
||||
@ -57,7 +70,15 @@ export default {
|
||||
return {
|
||||
fetchRunError: null,
|
||||
run: null,
|
||||
polling: null
|
||||
polling: null,
|
||||
|
||||
taskWidth: 200,
|
||||
taskHeight: 40,
|
||||
taskXSpace: 60,
|
||||
taskYSpace: 20,
|
||||
hoverTask: null,
|
||||
|
||||
tasksDisplay: "graph"
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
@ -95,18 +116,17 @@ export default {
|
||||
this.fetchRunError = null;
|
||||
this.run = data;
|
||||
|
||||
// sort tasks by level
|
||||
let tasks = this.run.tasks;
|
||||
let sortedTasks = Object.keys(this.run.tasks)
|
||||
.sort((a, b) =>
|
||||
tasks[a].level > tasks[b].level
|
||||
? 1
|
||||
: tasks[b].level > tasks[a].level
|
||||
? -1
|
||||
: 0
|
||||
)
|
||||
.map(k => this.run.tasks[k]);
|
||||
this.run.sortedTasks = sortedTasks;
|
||||
|
||||
// add additional properties to every task
|
||||
for (let taskID in tasks) {
|
||||
let task = tasks[taskID];
|
||||
task.link = this.runTaskLink(task);
|
||||
task.parents = this.parents(task);
|
||||
task.waiting_approval = this.run.tasks_waiting_approval.includes(
|
||||
taskID
|
||||
);
|
||||
}
|
||||
},
|
||||
pollData() {
|
||||
this.polling = setInterval(() => {
|
||||
|
@ -1,72 +0,0 @@
|
||||
<template>
|
||||
<div class="mb-2 border-l-5 rounded-l" :class="taskClass(task)">
|
||||
<div class="px-4 py-4 flex justify-between items-center border border-l-0 rounded-r">
|
||||
<router-link class="w-1/3 font-bold" tag="a" :to="link">
|
||||
<span class="w-1/3 font-bold">{{task.name}}</span>
|
||||
</router-link>
|
||||
<div class="w-1/4">
|
||||
<span class="tag" v-if="waitingApproval">Waiting approval</span>
|
||||
</div>
|
||||
<div class="w-1/4">
|
||||
<span class="block" v-if="parents.length > 0">depends on: </span>
|
||||
<span class="font-thin text-gray-600" v-for="dep in parents" v-bind:key="dep">{{dep}}</span>
|
||||
</div>
|
||||
<span class="w-16 text-right">{{ duration }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as moment from "moment";
|
||||
import momentDurationFormatSetup from "moment-duration-format";
|
||||
|
||||
momentDurationFormatSetup(moment);
|
||||
|
||||
export default {
|
||||
name: "task",
|
||||
components: {},
|
||||
data() {
|
||||
return {
|
||||
now: moment()
|
||||
};
|
||||
},
|
||||
props: {
|
||||
task: Object,
|
||||
link: Object,
|
||||
waitingApproval: Boolean,
|
||||
parents: Array
|
||||
},
|
||||
computed: {
|
||||
duration() {
|
||||
let formatString = "h:mm:ss[s]";
|
||||
let start = moment(this.task.start_time);
|
||||
let end = moment(this.task.end_time);
|
||||
|
||||
if (this.task.start_time === null) {
|
||||
return moment.duration(0).format(formatString);
|
||||
}
|
||||
if (this.task.end_time === null) {
|
||||
return moment.duration(this.now.diff(start)).format(formatString);
|
||||
}
|
||||
return moment.duration(end.diff(start)).format(formatString);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
taskClass(task) {
|
||||
if (task.status == "success") return "success";
|
||||
if (task.status == "failed") return "failed";
|
||||
if (task.status == "stopped") return "failed";
|
||||
if (task.status == "running") return "running";
|
||||
return "unknown";
|
||||
}
|
||||
},
|
||||
created() {
|
||||
window.setInterval(() => {
|
||||
this.now = moment();
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
</style>
|
99
src/components/tasks.vue
Normal file
99
src/components/tasks.vue
Normal file
@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<ul>
|
||||
<li v-for="task in sortedTasks" v-bind:key="task.id">
|
||||
<div class="mb-2 border-l-5 rounded-l" :class="taskClass(task)">
|
||||
<div class="px-4 py-4 flex justify-between items-center border border-l-0 rounded-r">
|
||||
<router-link class="w-1/3 font-bold" tag="a" :to="task.link">
|
||||
<span class="w-1/3 font-bold">{{task.name}}</span>
|
||||
</router-link>
|
||||
<div class="w-1/4">
|
||||
<span
|
||||
v-if="task.waiting_approval"
|
||||
class="w-2/12 bg-gray-200 rounded-full px-3 py-1 text-sm text-center font-semibold mr-2"
|
||||
>Waiting Approval</span>
|
||||
</div>
|
||||
<div class="w-1/4">
|
||||
<span class="block text-xs" v-if="task.parents.length > 0">depends on: </span>
|
||||
<ul>
|
||||
<li
|
||||
class="font-thin text-xs text-gray-600"
|
||||
v-for="dep in task.parents"
|
||||
v-bind:key="dep"
|
||||
>{{dep}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<span class="w-16 text-right">{{ task.duration }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as moment from "moment";
|
||||
import momentDurationFormatSetup from "moment-duration-format";
|
||||
|
||||
momentDurationFormatSetup(moment);
|
||||
|
||||
export default {
|
||||
name: "tasks",
|
||||
components: {},
|
||||
data() {
|
||||
return {
|
||||
now: moment()
|
||||
};
|
||||
},
|
||||
props: {
|
||||
tasks: Object
|
||||
},
|
||||
computed: {
|
||||
sortedTasks() {
|
||||
let tasks = this.tasks;
|
||||
|
||||
// sort tasks by level
|
||||
let sortedTasks = Object.keys(tasks)
|
||||
.sort((a, b) =>
|
||||
tasks[a].level > tasks[b].level
|
||||
? 1
|
||||
: tasks[b].level > tasks[a].level
|
||||
? -1
|
||||
: 0
|
||||
)
|
||||
.map(k => tasks[k]);
|
||||
|
||||
for (let task of sortedTasks) {
|
||||
task.duration = this.duration(task);
|
||||
}
|
||||
|
||||
return sortedTasks;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
duration(task) {
|
||||
let formatString = "h:mm:ss[s]";
|
||||
let start = moment(task.start_time);
|
||||
let end = moment(task.end_time);
|
||||
|
||||
if (task.start_time === null) {
|
||||
return moment.duration(0).format(formatString);
|
||||
}
|
||||
if (task.end_time === null) {
|
||||
return moment.duration(this.now.diff(start)).format(formatString);
|
||||
}
|
||||
return moment.duration(end.diff(start)).format(formatString);
|
||||
},
|
||||
taskClass(task) {
|
||||
if (task.status == "success") return "success";
|
||||
if (task.status == "failed") return "failed";
|
||||
if (task.status == "stopped") return "failed";
|
||||
if (task.status == "running") return "running";
|
||||
return "unknown";
|
||||
}
|
||||
},
|
||||
created() {
|
||||
window.setInterval(() => {
|
||||
this.now = moment();
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
</script>
|
378
src/components/tasksgraph.vue
Normal file
378
src/components/tasksgraph.vue
Normal file
@ -0,0 +1,378 @@
|
||||
<template>
|
||||
<div class="overflow-x-auto">
|
||||
<svg version="1.1" :width="width" :height="height" class="svg-content" scroll overflow="scroll">
|
||||
<g v-for="(segment, i) in segments" v-bind:key="segment + i">
|
||||
<line
|
||||
:x1="segment.x1"
|
||||
:y1="segment.y1"
|
||||
:x2="segment.x2"
|
||||
:y2="segment.y2"
|
||||
:stroke-width="segment.strokeWidth"
|
||||
:stroke="segment.stroke"
|
||||
stroke-linecap="round"
|
||||
:class="['stroke-current', segment.stroke]"
|
||||
/>
|
||||
</g>
|
||||
<g v-for="(task, idx) in outTasks" v-bind:key="idx">
|
||||
<foreignObject
|
||||
:x="(taskWidth + taskXSpace) * task.level"
|
||||
:y="(taskHeight + taskYSpace) * task.row"
|
||||
rx="3"
|
||||
ry="3"
|
||||
:width="taskWidth"
|
||||
:height="taskHeight"
|
||||
>
|
||||
<body>
|
||||
<div
|
||||
class="mb-2 border-l-5 rounded-l"
|
||||
:class="taskClass(task)"
|
||||
@mouseover="hoverTask = task"
|
||||
@mouseleave="hoverTask = null"
|
||||
>
|
||||
<router-link
|
||||
tag="a"
|
||||
:to="task.link"
|
||||
class="px-1 flex flex-col border border-l-0 rounded-r"
|
||||
:style="{ height: taskHeight +'px'}"
|
||||
:title="task.name"
|
||||
>
|
||||
<div class="flex justify-end">
|
||||
<div class="text-right text-xs">{{ task.duration }}</div>
|
||||
</div>
|
||||
<div class="font-bold truncate">{{task.name}}</div>
|
||||
<div class="flex justify-end">
|
||||
<span
|
||||
v-if="task.waiting_approval"
|
||||
class="bg-gray-200 rounded-full px-2 py-0 text-xs text-center font-semibold"
|
||||
>Waiting Approval</span>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</body>
|
||||
</foreignObject>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as moment from "moment";
|
||||
import momentDurationFormatSetup from "moment-duration-format";
|
||||
|
||||
momentDurationFormatSetup(moment);
|
||||
|
||||
export default {
|
||||
name: "tasksgraph",
|
||||
components: {},
|
||||
data() {
|
||||
return {
|
||||
now: moment(),
|
||||
|
||||
graphTasks: [],
|
||||
edges: [],
|
||||
|
||||
taskWidth: 240,
|
||||
taskHeight: 60,
|
||||
taskXSpace: 60,
|
||||
taskYSpace: 20,
|
||||
hoverTask: null,
|
||||
|
||||
height: "400px"
|
||||
};
|
||||
},
|
||||
props: {
|
||||
tasks: Object
|
||||
},
|
||||
computed: {
|
||||
segments: function() {
|
||||
let segments = [];
|
||||
for (let edge of this.edges) {
|
||||
for (let i = 0; i < edge.edgePoints.length - 1; i++) {
|
||||
let strokeWidth = 1;
|
||||
if (this.hoverTask) {
|
||||
if (
|
||||
edge.sourceTask.id == this.hoverTask.id ||
|
||||
edge.targetTask.id == this.hoverTask.id
|
||||
) {
|
||||
strokeWidth = 3;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(sgotti) set different colors to edges based on source task status???
|
||||
let stroke = "text-dark";
|
||||
segments.push({
|
||||
edge: edge,
|
||||
x1: edge.edgePoints[i].x,
|
||||
y1: edge.edgePoints[i].y,
|
||||
x2: edge.edgePoints[i + 1].x,
|
||||
y2: edge.edgePoints[i + 1].y,
|
||||
strokeWidth: strokeWidth,
|
||||
stroke: stroke
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// sort segments by color (first the ok)
|
||||
return segments;
|
||||
},
|
||||
outTasks() {
|
||||
// just augment this.graphTasks (without recalculating it) with the current duration.
|
||||
for (let task of this.graphTasks) {
|
||||
task.duration = this.duration(task);
|
||||
}
|
||||
|
||||
return this.graphTasks;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
tasks: function(tasks) {
|
||||
this.update(tasks);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
duration(task) {
|
||||
let formatString = "h:mm:ss[s]";
|
||||
let start = moment(task.start_time);
|
||||
let end = moment(task.end_time);
|
||||
|
||||
if (task.start_time === null) {
|
||||
return moment.duration(0).format(formatString);
|
||||
}
|
||||
if (task.end_time === null) {
|
||||
return moment.duration(this.now.diff(start)).format(formatString);
|
||||
}
|
||||
return moment.duration(end.diff(start)).format(formatString);
|
||||
},
|
||||
taskClass(task) {
|
||||
if (task.status == "success") return "success";
|
||||
if (task.status == "failed") return "failed";
|
||||
if (task.status == "stopped") return "failed";
|
||||
if (task.status == "running") return "running";
|
||||
return "unknown";
|
||||
},
|
||||
update(tasks) {
|
||||
// sort tasks by level
|
||||
let graphTasks = Object.keys(tasks)
|
||||
.sort((a, b) =>
|
||||
tasks[a].level > tasks[b].level
|
||||
? 1
|
||||
: tasks[b].level > tasks[a].level
|
||||
? -1
|
||||
: 0
|
||||
)
|
||||
.map(k => tasks[k]);
|
||||
|
||||
this.graphTasks = graphTasks;
|
||||
|
||||
let maxlevel = 0;
|
||||
for (let task of graphTasks) {
|
||||
if (task.level > maxlevel) {
|
||||
maxlevel = task.level;
|
||||
}
|
||||
}
|
||||
|
||||
let taskChilds = function(tasks, task) {
|
||||
let childs = [];
|
||||
for (let ot of tasks) {
|
||||
for (let depTaskID in ot.depends) {
|
||||
if (task.id == depTaskID) {
|
||||
childs.push(ot);
|
||||
}
|
||||
}
|
||||
}
|
||||
return childs;
|
||||
};
|
||||
|
||||
let taskMaxChildLevel = function(tasks, task) {
|
||||
let level = task.level;
|
||||
let childs = taskChilds(tasks, task);
|
||||
for (let child of childs) {
|
||||
if (child.level > level) {
|
||||
level = child.level;
|
||||
}
|
||||
}
|
||||
return level;
|
||||
};
|
||||
|
||||
let levelTasks = function(tasks, level) {
|
||||
let levelTasks = [];
|
||||
for (let task of tasks) {
|
||||
if (task.level != level) {
|
||||
continue;
|
||||
}
|
||||
levelTasks.push(task);
|
||||
}
|
||||
return levelTasks;
|
||||
};
|
||||
|
||||
let levelTasksByRow = function(tasks, level) {
|
||||
return levelTasks(tasks, level).sort((a, b) =>
|
||||
a.row > b.row ? 1 : b.row > a.row ? -1 : 0
|
||||
);
|
||||
};
|
||||
|
||||
let levelFreeRow = function(tasks, level) {
|
||||
let rows = [];
|
||||
for (let task of tasks) {
|
||||
if (task.level != level) {
|
||||
continue;
|
||||
}
|
||||
if (task.row !== undefined) {
|
||||
rows.push(task.row);
|
||||
}
|
||||
}
|
||||
|
||||
rows = rows.sort((a, b) => a - b);
|
||||
|
||||
let prevrow = 0;
|
||||
for (let row of rows) {
|
||||
if (row == prevrow) {
|
||||
prevrow++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return prevrow;
|
||||
};
|
||||
|
||||
let levelsMaxRow = function(tasks, level) {
|
||||
let row = 0;
|
||||
for (let task of tasks) {
|
||||
if (level >= 0 && task.level > level) {
|
||||
continue;
|
||||
}
|
||||
if (task.row > row) {
|
||||
row = task.row;
|
||||
}
|
||||
}
|
||||
return row;
|
||||
};
|
||||
|
||||
for (let l = maxlevel; l >= 0; l--) {
|
||||
let seenTasks = new Map();
|
||||
|
||||
let row = 0;
|
||||
// if not at the last level fetch parents by their childs
|
||||
if (l < maxlevel) {
|
||||
for (let curTask of levelTasksByRow(graphTasks, l + 1)) {
|
||||
for (let depTaskID in curTask.depends) {
|
||||
for (let parent of levelTasks(graphTasks, l)) {
|
||||
if (seenTasks.has(parent.id)) {
|
||||
continue;
|
||||
}
|
||||
if (parent.id == depTaskID) {
|
||||
seenTasks.set(parent.id, true);
|
||||
|
||||
let maxChildLevel = taskMaxChildLevel(graphTasks, parent);
|
||||
if (maxChildLevel > parent.level + 1) {
|
||||
// put parent in a row greater than the max row the next levels until the child level
|
||||
let mrow = levelsMaxRow(graphTasks, maxChildLevel) + 1;
|
||||
parent.row = mrow;
|
||||
row = mrow + 1;
|
||||
} else {
|
||||
parent.row = row;
|
||||
row++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// arrange tasks of this level
|
||||
for (let curTask of levelTasks(graphTasks, l)) {
|
||||
if (seenTasks.has(curTask.id)) {
|
||||
continue;
|
||||
}
|
||||
seenTasks.set(curTask.id, true);
|
||||
let crow = levelFreeRow(graphTasks, l);
|
||||
curTask.row = crow;
|
||||
row = crow + 1;
|
||||
|
||||
// group tasks with common parent
|
||||
for (let nextTask of levelTasks(graphTasks, l)) {
|
||||
if (seenTasks.has(nextTask.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let hasCommonParents = false;
|
||||
for (let nextParentID in nextTask.depends) {
|
||||
for (let curParentID in curTask.depends) {
|
||||
if (nextParentID == curParentID) {
|
||||
hasCommonParents = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasCommonParents) {
|
||||
seenTasks.set(nextTask.id, true);
|
||||
let crow = levelFreeRow(graphTasks, l);
|
||||
nextTask.row = crow;
|
||||
row = crow + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let edges = [];
|
||||
|
||||
for (let curTask of graphTasks) {
|
||||
for (let depTaskID in curTask.depends) {
|
||||
for (let pTask of graphTasks) {
|
||||
if (pTask.id == depTaskID) {
|
||||
edges.push({
|
||||
sourceTask: pTask,
|
||||
targetTask: curTask,
|
||||
source: { level: pTask.level, row: pTask.row },
|
||||
target: { level: curTask.level, row: curTask.row }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.edges = edges;
|
||||
|
||||
for (let edge of edges) {
|
||||
edge.edgePoints = [];
|
||||
let taskWidth = this.taskWidth;
|
||||
let taskHeight = this.taskHeight;
|
||||
let taskXSpace = this.taskXSpace;
|
||||
let taskYSpace = this.taskYSpace;
|
||||
|
||||
edge.edgePoints.push({
|
||||
x: (taskWidth + taskXSpace) * edge.source.level + taskWidth,
|
||||
y: (taskHeight + taskYSpace) * edge.source.row + taskHeight / 2
|
||||
});
|
||||
edge.edgePoints.push({
|
||||
x: (taskWidth + taskXSpace) * edge.target.level - taskXSpace / 2,
|
||||
y: (taskHeight + taskYSpace) * edge.source.row + taskHeight / 2
|
||||
});
|
||||
edge.edgePoints.push({
|
||||
x: (taskWidth + taskXSpace) * edge.target.level - taskXSpace / 2,
|
||||
y: (taskHeight + taskYSpace) * edge.target.row + taskHeight / 2
|
||||
});
|
||||
edge.edgePoints.push({
|
||||
x: (taskWidth + taskXSpace) * edge.target.level,
|
||||
y: (taskHeight + taskYSpace) * edge.target.row + taskHeight / 2
|
||||
});
|
||||
}
|
||||
|
||||
let width = (maxlevel + 1) * (this.taskWidth + this.taskXSpace);
|
||||
this.width = width + "px";
|
||||
|
||||
let height =
|
||||
(levelsMaxRow(graphTasks, -1) + 1) *
|
||||
(this.taskHeight + this.taskYSpace);
|
||||
this.height = height + "px";
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.update(this.tasks);
|
||||
|
||||
window.setInterval(() => {
|
||||
this.now = moment();
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
</script>
|
Loading…
Reference in New Issue
Block a user