feat(ui); foreachItem progress bar

This commit is contained in:
YannC
2023-11-10 12:47:58 +01:00
committed by Loïc Mathieu
parent 8143660381
commit 16d38a13ff
24 changed files with 705 additions and 399 deletions

View File

@@ -166,10 +166,9 @@ public class TaskRun implements TenantInterface {
}
public boolean isSame(TaskRun taskRun) {
return this.getId().equals(taskRun.getId()) && (
(this.getValue() == null && taskRun.getValue() == null) ||
(this.getValue() != null && this.getValue().equals(taskRun.getValue()))
);
return this.getId().equals(taskRun.getId()) &&
((this.getValue() == null && taskRun.getValue() == null) || (this.getValue() != null && this.getValue().equals(taskRun.getValue()))) &&
((this.getItems() == null && taskRun.getItems() == null) || (this.getItems() != null && this.getItems().equals(taskRun.getItems()))) ;
}
public String toString(boolean pretty) {

View File

@@ -9,11 +9,11 @@ import java.util.List;
@Getter
public class SubflowGraphTask extends AbstractGraphTask {
public SubflowGraphTask(ExecutableTask task, TaskRun taskRun, List<String> values, RelationType relationType) {
public SubflowGraphTask(ExecutableTask<?> task, TaskRun taskRun, List<String> values, RelationType relationType) {
super((Task) task, taskRun, values, relationType);
}
public ExecutableTask getExecutableTask() {
return (ExecutableTask) super.getTask();
public ExecutableTask<?> getExecutableTask() {
return (ExecutableTask<?>) super.getTask();
}
}

View File

@@ -15,7 +15,7 @@ import java.util.Optional;
/**
* Interface for tasks that generates subflow execution(s). Those tasks are handled in the Executor.
*/
public interface ExecutableTask {
public interface ExecutableTask<T extends Output>{
/**
* Creates a list of WorkerTaskExecution for this task definition.
* Each WorkerTaskExecution will generate a subflow execution.

View File

@@ -43,7 +43,7 @@ public final class ExecutableUtils {
.build();
}
public static <T extends Task & ExecutableTask> WorkerTaskExecution<?> workerTaskExecution(
public static <T extends Task & ExecutableTask<?>> WorkerTaskExecution<?> workerTaskExecution(
RunContext runContext,
FlowExecutorInterface flowExecutorInterface,
Execution currentExecution,
@@ -51,7 +51,8 @@ public final class ExecutableUtils {
T currentTask,
TaskRun currentTaskRun,
Map<String, Object> inputs,
List<Label> labels
List<Label> labels,
Integer iteration
) throws IllegalVariableEvaluationException {
String subflowNamespace = runContext.render(currentTask.subflowId().namespace());
String subflowId = runContext.render(currentTask.subflowId().flowId());
@@ -100,6 +101,7 @@ public final class ExecutableUtils {
.task(currentTask)
.taskRun(currentTaskRun)
.execution(execution)
.iteration(iteration)
.build();
}
@@ -117,7 +119,7 @@ public final class ExecutableUtils {
int currentStateIteration = getIterationCounter(iterations, currentState, maxIterations) + 1;
iterations.put(currentState.toString(), currentStateIteration);
if(previousState.isPresent() && previousState.get() != currentState) {
if (previousState.isPresent() && previousState.get() != currentState) {
int previousStateIterations = getIterationCounter(iterations, previousState.get(), maxIterations) - 1;
iterations.put(previousState.get().toString(), previousStateIterations);
}

View File

@@ -27,7 +27,7 @@ public class Executor {
private final List<WorkerTaskResult> workerTaskResults = new ArrayList<>();
private final List<ExecutionDelay> executionDelays = new ArrayList<>();
private WorkerTaskResult joined;
private final List<WorkerTaskExecution> workerTaskExecutions = new ArrayList<>();
private final List<WorkerTaskExecution<?>> workerTaskExecutions = new ArrayList<>();
private ExecutionsRunning executionsRunning;
private ExecutionQueued executionQueued;

View File

@@ -605,7 +605,7 @@ public class ExecutorService {
return false;
}
var executableTask = (Task & ExecutableTask) workerTask.getTask();
var executableTask = (Task & ExecutableTask<?>) workerTask.getTask();
try {
// mark taskrun as running to avoid multiple try for failed
TaskRun executableTaskRun = executor.getExecution()

View File

@@ -11,7 +11,7 @@ import javax.validation.constraints.NotNull;
@Data
@Builder
public class WorkerTaskExecution<T extends Task & ExecutableTask> {
public class WorkerTaskExecution<T extends Task & ExecutableTask<?>> {
@NotNull
private TaskRun taskRun;
@@ -20,4 +20,6 @@ public class WorkerTaskExecution<T extends Task & ExecutableTask> {
@NotNull
private Execution execution;
private Integer iteration;
}

View File

@@ -43,7 +43,7 @@ import java.util.*;
)
}
)
public class Flow extends Task implements ExecutableTask {
public class Flow extends Task implements ExecutableTask<Flow.Output> {
@NotNull
@Schema(
title = "The namespace of the subflow to be executed"
@@ -137,7 +137,8 @@ public class Flow extends Task implements ExecutableTask {
this,
currentTaskRun,
inputs,
labels
labels,
null
));
}

View File

@@ -8,7 +8,6 @@ import io.kestra.core.models.annotations.PluginProperty;
import io.kestra.core.models.executions.Execution;
import io.kestra.core.models.executions.TaskRun;
import io.kestra.core.models.flows.Flow;
import io.kestra.core.models.flows.State;
import io.kestra.core.models.tasks.ExecutableTask;
import io.kestra.core.models.tasks.Task;
import io.kestra.core.runners.ExecutableUtils;
@@ -70,7 +69,7 @@ import static io.kestra.core.utils.Rethrow.throwFunction;
)
}
)
public class ForEachItem extends Task implements ExecutableTask {
public class ForEachItem extends Task implements ExecutableTask<ForEachItem.Output> {
@NotEmpty
@PluginProperty(dynamic = true)
@Schema(title = "The items to be split into batches and processed. Make sure to set it to Kestra's internal storage URI, e.g. output from a previous task in the format `{{ outputs.task_id.uri }}` or an input parameter of FILE type e.g. `{{ inputs.myfile }}`.")
@@ -171,6 +170,7 @@ public class ForEachItem extends Task implements ExecutableTask {
}
int iteration = currentIteration.getAndIncrement();
var outputs = Output.builder().iterations(Map.of("max", splits.size())).build();
return ExecutableUtils.workerTaskExecution(
runContext,
flowExecutorInterface,
@@ -178,13 +178,11 @@ public class ForEachItem extends Task implements ExecutableTask {
currentFlow,
this,
currentTaskRun
.withValue(String.valueOf(iteration))
.withOutputs(Map.of(
"iterations", Map.of("max", splits.size())
))
.withOutputs(outputs.toMap())
.withItems(split.toString()),
inputs,
labels
labels,
iteration
);
}
))
@@ -231,4 +229,14 @@ public class ForEachItem extends Task implements ExecutableTask {
@Builder.Default
private String separator = "\n";
}
@Builder
@Getter
public static class Output implements io.kestra.core.models.tasks.Output {
@Schema(
title = "The iterations counter.",
description = "This output will be updated in real-time with the subflow executions.\n It will contains one counter by subflow execution state, plus a `max` counter that represent the maximum number of iterations (or the number of batches)."
)
private final Map<String, Integer> iterations;
}
}

View File

@@ -163,7 +163,7 @@ public class FlowTopologyService {
.allTasksWithChilds()
.stream()
.filter(t -> t instanceof ExecutableTask)
.map(t -> (ExecutableTask) t)
.map(t -> (ExecutableTask<?>) t)
.anyMatch(t ->
t.subflowId().namespace().equals(child.getNamespace()) && t.subflowId().flowId().equals(child.getId())
);

View File

@@ -271,7 +271,7 @@ public class GraphUtils {
// detect kids
if (currentTask instanceof FlowableTask<?> flowableTask) {
currentGraph = flowableTask.tasksTree(execution, currentTaskRun, parentValues);
} else if (currentTask instanceof ExecutableTask subflowTask) {
} else if (currentTask instanceof ExecutableTask<?> subflowTask) {
currentGraph = new SubflowGraphTask(subflowTask, currentTaskRun, parentValues, relationType);
} else {
currentGraph = new GraphTask(currentTask, currentTaskRun, parentValues, relationType);

View File

@@ -217,7 +217,7 @@ public class ValidationFactory {
.stream()
.forEach(
task -> {
if (task instanceof ExecutableTask executableTask
if (task instanceof ExecutableTask<?> executableTask
&& value.getId().equals(executableTask.subflowId().flowId())
&& value.getNamespace().equals(executableTask.subflowId().namespace())) {
violations.add("Recursive call to flow [" + value.getNamespace() + "." + value.getId() + "]");

View File

@@ -13,13 +13,13 @@ import java.util.Map;
import java.util.Optional;
public abstract class AbstractJdbcWorkerTaskExecutionStorage extends AbstractJdbcRepository {
protected io.kestra.jdbc.AbstractJdbcRepository<WorkerTaskExecution> jdbcRepository;
protected io.kestra.jdbc.AbstractJdbcRepository<WorkerTaskExecution<?>> jdbcRepository;
public AbstractJdbcWorkerTaskExecutionStorage(io.kestra.jdbc.AbstractJdbcRepository<WorkerTaskExecution> jdbcRepository) {
public AbstractJdbcWorkerTaskExecutionStorage(io.kestra.jdbc.AbstractJdbcRepository jdbcRepository) {
this.jdbcRepository = jdbcRepository;
}
public Optional<WorkerTaskExecution> get(String executionId) {
public Optional<WorkerTaskExecution<?>> get(String executionId) {
return this.jdbcRepository
.getDslContextWrapper()
.transactionResult(configuration -> {
@@ -35,12 +35,13 @@ public abstract class AbstractJdbcWorkerTaskExecutionStorage extends AbstractJdb
});
}
public void save(List<WorkerTaskExecution> workerTaskExecutions) {
public void save(List<WorkerTaskExecution<?>> workerTaskExecutions) {
this.jdbcRepository
.getDslContextWrapper()
.transaction(configuration -> {
DSLContext context = DSL.using(configuration);
// TODO batch insert
workerTaskExecutions.forEach(workerTaskExecution -> {
Map<Field<Object>, Object> fields = this.jdbcRepository.persistFields(workerTaskExecution);
this.jdbcRepository.persist(workerTaskExecution, context, fields);
@@ -48,7 +49,7 @@ public abstract class AbstractJdbcWorkerTaskExecutionStorage extends AbstractJdb
});
}
public void delete(WorkerTaskExecution workerTaskExecution) {
public void delete(WorkerTaskExecution<?> workerTaskExecution) {
this.jdbcRepository.delete(workerTaskExecution);
}
}

View File

@@ -379,10 +379,10 @@ public class JdbcExecutor implements ExecutorInterface {
if (!executor.getWorkerTaskExecutions().isEmpty()) {
workerTaskExecutionStorage.save(executor.getWorkerTaskExecutions());
List<WorkerTaskExecution> workerTasksExecutionDedup = executor
List<WorkerTaskExecution<?>> workerTasksExecutionDedup = executor
.getWorkerTaskExecutions()
.stream()
.filter(workerTaskExecution -> this.deduplicateWorkerTaskExecution(execution, executorState, workerTaskExecution.getTaskRun()))
.filter(workerTaskExecution -> this.deduplicateWorkerTaskExecution(execution, executorState, workerTaskExecution.getTaskRun(), workerTaskExecution.getIteration()))
.toList();
workerTasksExecutionDedup
@@ -405,7 +405,7 @@ public class JdbcExecutor implements ExecutorInterface {
executionQueue.emit(workerTaskExecution.getExecution());
// send a running worker task result to track running vs created status
if (((ExecutableTask) workerTaskExecution.getTask()).waitForExecution()) {
if (workerTaskExecution.getTask().waitForExecution()) {
sendWorkerTaskResultForWorkerTaskExecution(execution, workerTaskExecution, workerTaskExecution.getTaskRun().withState(State.Type.RUNNING));
}
});
@@ -425,7 +425,7 @@ public class JdbcExecutor implements ExecutorInterface {
workerTaskExecutionStorage.get(execution.getId())
.ifPresent(workerTaskExecution -> {
// If we didn't wait for the flow execution, the worker task execution has already been created by the Executor service.
if (((ExecutableTask) workerTaskExecution.getTask()).waitForExecution()) {
if (workerTaskExecution.getTask().waitForExecution()) {
sendWorkerTaskResultForWorkerTaskExecution(execution, workerTaskExecution, workerTaskExecution.getTaskRun().withState(State.Type.RUNNING).withState(execution.getState().getCurrent()));
}
@@ -456,7 +456,7 @@ public class JdbcExecutor implements ExecutorInterface {
private void sendWorkerTaskResultForWorkerTaskExecution(Execution execution, WorkerTaskExecution<?> workerTaskExecution, TaskRun taskRun) {
Flow workerTaskFlow = this.flowRepository.findByExecution(execution);
ExecutableTask executableTask = workerTaskExecution.getTask();
ExecutableTask<?> executableTask = workerTaskExecution.getTask();
RunContext runContext = runContextFactory.of(
workerTaskFlow,
@@ -693,9 +693,9 @@ public class JdbcExecutor implements ExecutorInterface {
}
}
private boolean deduplicateWorkerTaskExecution(Execution execution, ExecutorState executorState, TaskRun taskRun) {
// There can be multiple executions for the same task, so we need to deduplicated with the taskrun.value
String deduplicationKey = taskRun.getId() + "-" + taskRun.getValue();
private boolean deduplicateWorkerTaskExecution(Execution execution, ExecutorState executorState, TaskRun taskRun, Integer iteration) {
// There can be multiple executions for the same task, so we need to deduplicated with the worker task execution iteration
String deduplicationKey = taskRun.getId() + (iteration == null ? "" : "-" + iteration);
State.Type current = executorState.getWorkerTaskExecutionDeduplication().get(deduplicationKey);
if (current == taskRun.getState().getCurrent()) {

View File

@@ -28,7 +28,7 @@ public abstract class AbstractWorkerTaskExecutionTest {
@Test
void suite() throws Exception {
WorkerTaskExecution workerTaskExecution = WorkerTaskExecution.builder()
WorkerTaskExecution<?> workerTaskExecution = WorkerTaskExecution.builder()
.execution(Execution.builder().id(IdUtils.create()).build())
.task(Flow.builder().type(Flow.class.getName()).id(IdUtils.create()).build())
.taskRun(TaskRun.builder().id(IdUtils.create()).build())
@@ -37,7 +37,7 @@ public abstract class AbstractWorkerTaskExecutionTest {
workerTaskExecutionStorage.save(List.of(workerTaskExecution));
Optional<WorkerTaskExecution> find = workerTaskExecutionStorage.get(workerTaskExecution.getExecution().getId());
Optional<WorkerTaskExecution<?>> find = workerTaskExecutionStorage.get(workerTaskExecution.getExecution().getId());
assertThat(find.isPresent(), is(true));
assertThat(find.get().getExecution().getId(), is(workerTaskExecution.getExecution().getId()));

View File

@@ -236,7 +236,7 @@ public class MemoryExecutor implements ExecutorInterface {
executionQueue.emit(workerTaskExecution.getExecution());
// send a running worker task result to track running vs created status
if (((ExecutableTask) workerTaskExecution.getTask()).waitForExecution()) {
if (workerTaskExecution.getTask().waitForExecution()) {
sendWorkerTaskResultForWorkerTaskExecution(execution, workerTaskExecution, workerTaskExecution.getTaskRun().withState(State.Type.RUNNING));
}
});
@@ -271,7 +271,7 @@ public class MemoryExecutor implements ExecutorInterface {
try {
Flow workerTaskFlow = this.flowRepository.findByExecution(execution);
ExecutableTask executableTask = workerTaskExecution.getTask();
ExecutableTask<?> executableTask = workerTaskExecution.getTask();
RunContext runContext = runContextFactory.of(
workerTaskFlow,

View File

@@ -0,0 +1,143 @@
<template>
<div class="m-3">
<div class="progress">
<div
v-for="state in State.allStates()"
:key="state.key"
class="progress-bar"
role="progressbar"
:class="`bg-${state.colorClass}`"
:style="`width: ${getPercentage(state.key)}%`"
:aria-valuenow="getPercentage(state.key)"
aria-valuemin="0"
:aria-valuemax="localSubflowStatus.max"
/>
</div>
<div class="mt-2 d-flex">
<el-button @click="goToExecutionsList(null)" class="count-button">
{{ $t("all executions") }} <span class="counter">{{ localSubflowStatus.max }}</span>
</el-button>
<div v-for="state in State.allStates()" :key="state.key">
<el-button @click="goToExecutionsList(state.key)" class="count-button" v-if="localSubflowStatus[state.key] >= 0">
<div class="dot rounded-5" :class="`bg-${state.colorClass}`"></div>
{{ capitalizeFirstLetter(state.key) }}
<span class="counter">{{ localSubflowStatus[state.key] }}</span>
</el-button>
</div>
</div>
</div>
</template>
<script>
import {cssVariable} from "../../utils/global"
import State from "../../utils/state";
import throttle from "lodash/throttle"
export default {
computed: {
State() {
return State
}
},
data() {
return {
localSubflowStatus: {}
}
},
created() {
this.localSubflowStatus = this.subflowsStatus
},
props: {
subflowsStatus: {
type: Object,
required: true
},
taskRunId: {
type: String,
required: true
}
},
watch: {
subflowsStatus() {
this.updateThrottled();
}
},
methods: {
cssVariable,
getPercentage(state) {
if (!this.localSubflowStatus[state]) {
return 0;
}
return Math.round((this.localSubflowStatus[state] / this.localSubflowStatus.max) * 100);
},
capitalizeFirstLetter(str) {
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
},
goToExecutionsList(state) {
this.$router.push({
name: "executions/list",
query: {
parentId: this.taskRunId,
state: state
}
});
},
updateThrottled: throttle(function () {
this.localSubflowStatus = this.subflowsStatus
}, 1000)
}
}
</script>
<style scoped lang="scss">
.dot {
width: 6.413px;
height: 6.413px;
margin-right: 0.5rem;
}
.progress {
height: 5px;
}
.el-button {
padding: 0.5rem 1rem;
&:hover {
html.dark & {
border-color: #404559;
}
}
&:focus {
html.dark & {
border-color: #404559;
}
}
}
.count-button {
color: var(--text-color);
padding: 4px 8px;
margin-right: 0.5rem;
align-items: center;
gap: 8px;
border-radius: 2px;
background: #fff;
html.dark & {
background: #404559;
}
font-size: 0.75rem;
}
.counter {
padding: 0 4px;
margin-left: 0.5rem;
align-items: flex-start;
gap: 10px;
border-radius: 2px;
background: var(--bs-gray-300);
html.dark & {
background: #21242E;
}
font-size: 0.65rem;
line-height: 1.0625rem;
}
</style>

View File

@@ -2,71 +2,72 @@
<el-card shadow="never" v-if="execution">
<table>
<thead>
<tr>
<th>
<duration :histories="execution.state.histories" />
</th>
<td v-for="(date, i) in dates" :key="i">
{{ date }}
</td>
</tr>
<tr>
<th>
<duration :histories="execution.state.histories" />
</th>
<td v-for="(date, i) in dates" :key="i">
{{ date }}
</td>
</tr>
</thead>
<tbody v-for="currentTaskRun in partialSeries" :key="currentTaskRun.id">
<tr>
<th>
<el-tooltip placement="top-start" :persistent="false" transition="" :hide-after="0">
<template #content>
<code>{{ currentTaskRun.name }}</code>
<small v-if="currentTaskRun.task && currentTaskRun.task.value"><br>{{ currentTaskRun.task.value }}</small>
</template>
<span>
<code>{{ currentTaskRun.name }}</code>
<small v-if="currentTaskRun.task && currentTaskRun.task.value"> {{ currentTaskRun.task.value }}</small>
</span>
</el-tooltip>
</th>
<td :colspan="dates.length">
<el-tooltip placement="top" :persistent="false" transition="" :hide-after="0">
<template #content>
<span style="white-space: pre-wrap;">
{{ currentTaskRun.tooltip }}
<tr>
<th>
<el-tooltip placement="top-start" :persistent="false" transition="" :hide-after="0">
<template #content>
<code>{{ currentTaskRun.name }}</code>
<small v-if="currentTaskRun.task && currentTaskRun.task.value"><br>{{ currentTaskRun.task.value }}</small>
</template>
<span>
<code>{{ currentTaskRun.name }}</code>
<small v-if="currentTaskRun.task && currentTaskRun.task.value"> {{ currentTaskRun.task.value }}</small>
</span>
</template>
<div
:style="{left: currentTaskRun.start + '%', width: currentTaskRun.width + '%'}"
class="task-progress"
@click="onTaskSelect(currentTaskRun.task)"
>
<div class="progress">
<div
class="progress-bar"
:style="{left: currentTaskRun.left + '%', width: (100-currentTaskRun.left) + '%'}"
:class="'bg-' + currentTaskRun.color + (currentTaskRun.running ? ' progress-bar-striped progress-bar-animated' : '')"
role="progressbar"
/>
</el-tooltip>
</th>
<td :colspan="dates.length">
<el-tooltip placement="top" :persistent="false" transition="" :hide-after="0">
<template #content>
<span style="white-space: pre-wrap;">
{{ currentTaskRun.tooltip }}
</span>
</template>
<div
:style="{left: currentTaskRun.start + '%', width: currentTaskRun.width + '%'}"
class="task-progress"
@click="onTaskSelect(currentTaskRun.task)"
>
<div class="progress">
<div
class="progress-bar"
:style="{left: currentTaskRun.left + '%', width: (100-currentTaskRun.left) + '%'}"
:class="'bg-' + currentTaskRun.color + (currentTaskRun.running ? ' progress-bar-striped progress-bar-animated' : '')"
role="progressbar"
/>
</div>
</div>
</div>
</el-tooltip>
</td>
</tr>
<tr v-if="selectedTaskRun?.id === currentTaskRun.id">
<td :colspan="dates.length + 1" class="p-0 pb-2">
<log-list
:task-run-id="selectedTaskRun.id"
:exclude-metas="['namespace', 'flowId', 'taskId', 'executionId']"
level="TRACE"
@follow="forwardEvent('follow', $event)"
:target-execution="execution"
:target-flow="flow"
/>
</td>
</tr>
</el-tooltip>
</td>
</tr>
<tr v-if="selectedTaskRun?.id === currentTaskRun.id">
<td :colspan="dates.length + 1" class="p-0 pb-2">
<task-run-details
:task-run-id="selectedTaskRun.id"
:exclude-metas="['namespace', 'flowId', 'taskId', 'executionId']"
level="TRACE"
@follow="forwardEvent('follow', $event)"
:target-execution="execution"
:target-flow="flow"
:show-logs="!currentTaskRun?.task?.outputs?.iterations"
/>
</td>
</tr>
</tbody>
</table>
</el-card>
</template>
<script>
import LogList from "../logs/LogList.vue";
import TaskRunDetails from "../logs/TaskRunDetails.vue";
import {mapState} from "vuex";
import State from "../../utils/state";
import Duration from "../layout/Duration.vue";
@@ -75,7 +76,7 @@
const ts = date => new Date(date).getTime();
const TASKRUN_THRESHOLD = 50
export default {
components: {LogList, Duration},
components: {TaskRunDetails, Duration},
data() {
return {
colors: State.colorClass(),

View File

@@ -34,7 +34,7 @@
</el-form-item>
</collapse>
<log-list
<task-run-details
ref="logs"
:level="level"
:exclude-metas="['namespace', 'flowId', 'taskId', 'executionId']"
@@ -48,7 +48,7 @@
</template>
<script>
import LogList from "../logs/LogList.vue";
import TaskRunDetails from "../logs/TaskRunDetails.vue";
import {mapState} from "vuex";
import Download from "vue-material-design-icons/Download.vue";
import Magnify from "vue-material-design-icons/Magnify.vue";
@@ -59,7 +59,7 @@
export default {
components: {
LogList,
TaskRunDetails,
LogLevelSelector,
Kicon,
Download,

View File

@@ -0,0 +1,365 @@
<template>
<div class="attempt-header">
<div class="task-icon d-none d-md-inline-block me-1">
<task-icon
:cls="taskType(currentTaskRun)"
v-if="taskType(currentTaskRun)"
only-icon
:icons="icons"
/>
</div>
<div
class="task-id flex-grow-1"
:id="`attempt-${selectedAttemptNumberByTaskRunId[currentTaskRun.id]}-${currentTaskRun.id}`"
>
<el-tooltip :persistent="false" transition="" :hide-after="0">
<template #content>
{{ $t("from") }} :
{{ $filters.date(selectedAttempt(currentTaskRun).state.startDate) }}
<br>
{{ $t("to") }} :
{{ $filters.date(selectedAttempt(currentTaskRun).state.endDate) }}
<br>
<clock/>
<strong>{{ $t("duration") }}:</strong>
{{ $filters.humanizeDuration(selectedAttempt(currentTaskRun).state.duration) }}
</template>
<span>
<span class="me-1 fw-bold">{{ currentTaskRun.taskId }}</span>
<small v-if="currentTaskRun.value">
{{ currentTaskRun.value }}
</small>
</span>
</el-tooltip>
</div>
<div class="task-duration d-none d-md-inline-block">
<small class="me-1">
<duration :histories="selectedAttempt(currentTaskRun).state.histories"/>
</small>
</div>
<div class="task-status">
<status size="small" :status="selectedAttempt(currentTaskRun).state.current"/>
</div>
<el-select
class="d-none d-md-inline-block"
:model-value="selectedAttemptNumberByTaskRunId[currentTaskRun.id]"
@change="forwardEvent('swapDisplayedAttempt',(currentTaskRun.id, $event))"
:disabled="!currentTaskRun.attempts || currentTaskRun.attempts?.length <= 1"
>
<el-option
v-for="(_, index) in attempts(currentTaskRun)"
:key="`attempt-${index}-${currentTaskRun.id}`"
:value="index"
:label="`${$t('attempt')} ${index + 1}`"
/>
</el-select>
<el-dropdown trigger="click">
<el-button type="default" class="more-dropdown-button">
<DotsHorizontal title=""/>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<sub-flow-link
v-if="isSubflow(currentTaskRun)"
component="el-dropdown-item"
tab-execution="logs"
:execution-id="currentTaskRun.outputs.executionId"
/>
<metrics :task-run="currentTaskRun" :execution="followedExecution"/>
<outputs
:outputs="currentTaskRun.outputs"
:execution="followedExecution"
/>
<restart
component="el-dropdown-item"
:key="`restart-${selectedAttemptNumberByTaskRunId[currentTaskRun.id]}-${selectedAttempt(currentTaskRun).state.startDate}`"
is-replay
tooltip-position="left"
:execution="followedExecution"
:task-run="currentTaskRun"
:attempt-index="selectedAttemptNumberByTaskRunId[currentTaskRun.id]"
@follow="forwardEvent('follow', $event)"
/>
<change-status
component="el-dropdown-item"
:key="`change-status-${selectedAttemptNumberByTaskRunId[currentTaskRun.id]}-${selectedAttempt(currentTaskRun).state.startDate}`"
:execution="followedExecution"
:task-run="currentTaskRun"
:attempt-index="selectedAttemptNumberByTaskRunId[currentTaskRun.id]"
@follow="forwardEvent('follow', $event)"
/>
<task-edit
:read-only="true"
component="el-dropdown-item"
:task-id="currentTaskRun.taskId"
:section="SECTIONS.TASKS"
:flow-id="followedExecution.flowId"
:namespace="followedExecution.namespace"
:revision="followedExecution.flowRevision"
:flow-source="flow?.source"
/>
<el-dropdown-item
:icon="Download"
@click="downloadContent(currentTaskRun.id)"
>
{{ $t("download logs") }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button
v-if="!taskRunId && shouldDisplayChevron(currentTaskRun)"
class="border-0 expand-collapse"
type="default"
text
@click.stop="() => forwardEvent('toggleShowAttempt',(attemptUid(currentTaskRun.id, selectedAttemptNumberByTaskRunId[currentTaskRun.id])))"
>
<ChevronDown
v-if="shownAttemptsUid.includes(attemptUid(currentTaskRun.id, selectedAttemptNumberByTaskRunId[currentTaskRun.id]))"
/>
<ChevronUp v-else/>
</el-button>
</div>
</template>
<script>
import Restart from "./Restart.vue";
import ChevronUp from "vue-material-design-icons/ChevronUp.vue";
import Metrics from "./Metrics.vue";
import Status from "../Status.vue";
import ChangeStatus from "./ChangeStatus.vue";
import TaskEdit from "../flows/TaskEdit.vue";
import SubFlowLink from "../flows/SubFlowLink.vue";
import DotsHorizontal from "vue-material-design-icons/DotsHorizontal.vue";
import ChevronDown from "vue-material-design-icons/ChevronDown.vue";
import Clock from "vue-material-design-icons/Clock.vue";
import Outputs from "./Outputs.vue";
import State from "../../utils/state";
import FlowUtils from "../../utils/flowUtils";
import {mapState} from "vuex";
import {SECTIONS} from "../../utils/constants";
import Download from "vue-material-design-icons/Download.vue";
import _groupBy from "lodash/groupBy";
import TaskIcon from "@kestra-io/ui-libs/src/components/misc/TaskIcon.vue";
import Duration from "../layout/Duration.vue";
export default {
components: {
TaskIcon,
Outputs,
Clock,
ChevronDown,
DotsHorizontal,
SubFlowLink,
TaskEdit,
ChangeStatus,
Status,
Metrics,
ChevronUp,
Restart,
Duration
},
mounted() {
if (this.targetExecutionId) {
this.followExecution(this.targetExecutionId);
}
},
props: {
currentTaskRun: {
type: Object,
required: true
},
followedExecution: {
type: Object,
required: true
},
flow: {
type: Object,
default: null
},
forcedAttemptNumber: {
type: Number,
default: undefined
},
taskRunId: {
type: String,
default: undefined,
},
selectedAttemptNumberByTaskRunId: {
type: Object,
default: () => ({}),
},
shownAttemptsUid: {
type: Array,
default: () => [],
},
logs: {
type: Array,
default: () => [],
},
filter: {
type: String,
default: ""
}
},
computed: {
Download() {
return Download
},
...mapState("plugin", ["icons"]),
SECTIONS() {
return SECTIONS
},
currentTaskRuns() {
return this.followedExecution?.taskRunList?.filter(tr => this.taskRunId ? tr.id === this.taskRunId : true) ?? [];
},
taskRunById() {
return Object.fromEntries(this.currentTaskRuns.map(taskRun => [taskRun.id, taskRun]));
},
logsWithIndexByAttemptUid() {
const indexedLogs = this.logs
.filter(logLine => logLine.message.toLowerCase().includes(this.filter) || this.isSubflow(this.taskRunById[logLine.taskRunId]))
.map((logLine, index) => ({...logLine, index}));
return _groupBy(indexedLogs, indexedLog => this.attemptUid(indexedLog.taskRunId, indexedLog.attemptNumber));
}
},
methods: {
attempts(taskRun) {
if (this.followedExecution.state.current === State.RUNNING || this.forcedAttemptNumber === undefined) {
return taskRun.attempts ?? [{state: taskRun.state}];
}
return taskRun.attempts ? [taskRun.attempts[this.forcedAttemptNumber]] : [];
},
isSubflow(taskRun) {
return taskRun.outputs?.executionId;
},
selectedAttempt(taskRun) {
return this.attempts(taskRun)[this.selectedAttemptNumberByTaskRunId[taskRun.id] ?? 0];
},
taskType(taskRun) {
const task = FlowUtils.findTaskById(this.flow, taskRun.taskId);
const parentTaskRunId = taskRun.parentTaskRunId;
if (task === undefined && parentTaskRunId) {
return this.taskType(this.taskRunById[parentTaskRunId])
}
return task ? task.type : undefined;
},
downloadContent(currentTaskRunId) {
const params = this.params
this.$store.dispatch("execution/downloadLogs", {
executionId: this.followedExecution.id,
params: {...params, taskRunId: currentTaskRunId}
}).then((response) => {
const url = window.URL.createObjectURL(new Blob([response]));
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", this.downloadName(currentTaskRunId));
document.body.appendChild(link);
link.click();
});
},
forwardEvent(type, event) {
this.$emit(type, event);
},
attemptUid(taskRunId, attemptNumber) {
return `${taskRunId}-${attemptNumber}`
},
shouldDisplayChevron(taskRun) {
return this.shouldDisplayProgressBar(taskRun) || this.shouldDisplayLogs(taskRun.id)
},
shouldDisplayProgressBar(taskRun) {
return this.taskType(taskRun) === "io.kestra.core.tasks.flows.ForEachItem"
},
shouldDisplayLogs(taskRunId) {
return this.logsWithIndexByAttemptUid[this.attemptUid(taskRunId, this.selectedAttemptNumberByTaskRunId[taskRunId])]
}
}
}
</script>
<style scoped lang="scss">
@import "@kestra-io/ui-libs/src/scss/variables";
.attempt-header {
display: flex;
gap: calc(var(--spacer) / 2);
> * {
display: flex;
align-items: center;
}
.el-select {
width: 8rem;
}
.attempt-number {
background: var(--bs-gray-400);
padding: .375rem .75rem;
white-space: nowrap;
html.dark & {
color: var(--bs-gray-600);
}
}
.task-id, .task-duration {
padding: .375rem 0;
}
.task-id {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
span span {
color: var(--bs-tertiary-color);
html:not(.dark) & {
color: $black;
}
}
}
.task-icon {
width: 36px;
padding: 6px;
border-radius: $border-radius-lg;
}
small {
color: var(--bs-gray-600);
font-family: var(--bs-font-monospace);
font-size: var(--font-size-xs)
}
.task-duration small {
white-space: nowrap;
color: var(--bs-gray-800);
}
.more-dropdown-button {
padding: .5rem;
margin-bottom: .5rem;
border: 1px solid rgba($white, .05);
&:not(:hover) {
background: rgba($white, .10);
}
}
.expand-collapse {
background-color: transparent !important;
}
}
</style>

View File

@@ -7,7 +7,7 @@
import TaskEdit from "../flows/TaskEdit.vue";
import SearchField from "../layout/SearchField.vue";
import LogLevelSelector from "../logs/LogLevelSelector.vue";
import LogList from "../logs/LogList.vue";
import TaskRunDetails from "../logs/TaskRunDetails.vue";
import Collapse from "../layout/Collapse.vue";
// Topology
@@ -366,7 +366,7 @@
<log-level-selector :value="logLevel" @update:model-value="onLevelChange"/>
</el-form-item>
</collapse>
<log-list
<task-run-details
v-for="taskRun in selectedTask.taskRuns"
:key="taskRun.id"
:target-execution-id="selectedTask.execution?.id"

View File

@@ -5,133 +5,25 @@
v-if="uniqueTaskRunDisplayFilter(currentTaskRun)"
>
<el-card class="attempt-wrapper">
<div class="attempt-header">
<div class="task-icon d-none d-md-inline-block me-1">
<task-icon
:cls="taskIcon(currentTaskRun)"
v-if="taskIcon(currentTaskRun)"
only-icon
:icons="icons"
/>
</div>
<div class="task-id flex-grow-1"
:id="`attempt-${selectedAttemptNumberByTaskRunId[currentTaskRun.id]}-${currentTaskRun.id}`"
>
<el-tooltip :persistent="false" transition="" :hide-after="0">
<template #content>
{{ $t("from") }} :
{{ $filters.date(selectedAttempt(currentTaskRun).state.startDate) }}
<br>
{{ $t("to") }} :
{{ $filters.date(selectedAttempt(currentTaskRun).state.endDate) }}
<br>
<clock />
<strong>{{ $t("duration") }}:</strong>
{{ $filters.humanizeDuration(selectedAttempt(currentTaskRun).state.duration) }}
</template>
<span>
<span class="me-1 fw-bold">{{ currentTaskRun.taskId }}</span>
<small v-if="currentTaskRun.value">
{{ currentTaskRun.value }}
</small>
</span>
</el-tooltip>
</div>
<div class="task-duration d-none d-md-inline-block">
<small class="me-1">
<duration :histories="selectedAttempt(currentTaskRun).state.histories" />
</small>
</div>
<div class="task-status">
<status size="small" :status="selectedAttempt(currentTaskRun).state.current" />
</div>
<el-select
class="d-none d-md-inline-block"
v-model="selectedAttemptNumberByTaskRunId[currentTaskRun.id]"
@change="swapDisplayedAttempt(currentTaskRun.id, $event)"
:disabled="!currentTaskRun.attempts || currentTaskRun.attempts?.length <= 1"
>
<el-option v-for="(_, index) in attempts(currentTaskRun)"
:key="`attempt-${index}-${currentTaskRun.id}`"
:value="index"
:label="`${$t('attempt')} ${index + 1}`"
/>
</el-select>
<el-dropdown trigger="click">
<el-button type="default" class="more-dropdown-button">
<DotsHorizontal title="" />
</el-button>
<template #dropdown>
<el-dropdown-menu>
<sub-flow-link
v-if="isSubflow(currentTaskRun)"
component="el-dropdown-item"
tab-execution="logs"
:execution-id="currentTaskRun.outputs.executionId"
/>
<metrics :task-run="currentTaskRun" :execution="followedExecution" />
<outputs
:outputs="currentTaskRun.outputs"
:execution="followedExecution"
/>
<restart
component="el-dropdown-item"
:key="`restart-${selectedAttemptNumberByTaskRunId[currentTaskRun.id]}-${selectedAttempt(currentTaskRun).state.startDate}`"
is-replay
tooltip-position="left"
:execution="followedExecution"
:task-run="currentTaskRun"
:attempt-index="selectedAttemptNumberByTaskRunId[currentTaskRun.id]"
@follow="forwardEvent('follow', $event)"
/>
<change-status
component="el-dropdown-item"
:key="`change-status-${selectedAttemptNumberByTaskRunId[currentTaskRun.id]}-${selectedAttempt(currentTaskRun).state.startDate}`"
:execution="followedExecution"
:task-run="currentTaskRun"
:attempt-index="selectedAttemptNumberByTaskRunId[currentTaskRun.id]"
@follow="forwardEvent('follow', $event)"
/>
<task-edit
:read-only="true"
component="el-dropdown-item"
:task-id="currentTaskRun.taskId"
:section="SECTIONS.TASKS"
:flow-id="followedExecution.flowId"
:namespace="followedExecution.namespace"
:revision="followedExecution.flowRevision"
:flow-source="flow?.source"
/>
<el-dropdown-item
:icon="Download"
@click="downloadContent(currentTaskRun.id)"
>
{{ $t("download logs") }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button v-if="!taskRunId" class="border-0 expand-collapse" type="default" text
@click.stop="() => toggleShowAttempt(attemptUid(currentTaskRun.id, selectedAttemptNumberByTaskRunId[currentTaskRun.id]))"
>
<ChevronDown
v-if="!shownAttemptsUid.includes(attemptUid(currentTaskRun.id, selectedAttemptNumberByTaskRunId[currentTaskRun.id]))"
/>
<ChevronUp v-else />
</el-button>
</div>
<task-run-line
:current-task-run="currentTaskRun"
:followed-execution="followedExecution"
:flow="flow"
:forced-attempt-number="forcedAttemptNumber"
:task-run-id="taskRunId"
@toggle-show-attempt="toggleShowAttempt"
@swap-displayed-attempt="swapDisplayedAttempt"
:selected-attempt-number-by-task-run-id="selectedAttemptNumberByTaskRunId"
:shown-attempts-uid="shownAttemptsUid"
:logs="logs"
/>
<for-each-status
v-if="shouldDisplayProgressBar(currentTaskRun) && showProgressBar"
:task-run-id="currentTaskRun.id"
:subflows-status="currentTaskRun.outputs.iterations"
/>
<DynamicScroller
v-if="shouldDisplayLogs(currentTaskRun.id)"
v-if="shouldDisplayLogs(currentTaskRun)"
:items="logsWithIndexByAttemptUid[attemptUid(currentTaskRun.id, selectedAttemptNumberByTaskRunId[currentTaskRun.id])] ?? []"
:min-item-size="50"
key-field="index"
@@ -152,14 +44,15 @@
:exclude-metas="excludeMetas"
v-if="filter === '' || item.message.toLowerCase().includes(filter)"
/>
<log-list v-if="!taskRunId && isSubflow(currentTaskRun) && currentTaskRun.outputs?.executionId"
ref="subflows-logs"
:level="level"
:exclude-metas="['namespace', 'flowId', 'taskId', 'executionId']"
:filter="filter"
:allow-auto-expand-subflows="false"
:target-execution-id="currentTaskRun.outputs.executionId"
:class="$el.classList.contains('even') ? '' : 'even'"
<task-run-details
v-if="!taskRunId && isSubflow(currentTaskRun) && currentTaskRun.outputs?.executionId"
ref="subflows-logs"
:level="level"
:exclude-metas="['namespace', 'flowId', 'taskId', 'executionId']"
:filter="filter"
:allow-auto-expand-subflows="false"
:target-execution-id="currentTaskRun.outputs.executionId"
:class="$el.classList.contains('even') ? '' : 'even'"
/>
</DynamicScrollerItem>
</template>
@@ -172,49 +65,27 @@
<script>
import LogLine from "./LogLine.vue";
import Restart from "../executions/Restart.vue";
import ChangeStatus from "../executions/ChangeStatus.vue";
import Metrics from "../executions/Metrics.vue";
import Outputs from "../executions/Outputs.vue";
import Clock from "vue-material-design-icons/Clock.vue";
import ChevronDown from "vue-material-design-icons/ChevronDown.vue";
import ChevronUp from "vue-material-design-icons/ChevronUp.vue";
import State from "../../utils/state";
import Status from "../Status.vue";
import SubFlowLink from "../flows/SubFlowLink.vue"
import TaskEdit from "../flows/TaskEdit.vue";
import Duration from "../layout/Duration.vue";
import TaskIcon from "@kestra-io/ui-libs/src/components/misc/TaskIcon.vue";
import _xor from "lodash/xor";
import _groupBy from "lodash/groupBy";
import FlowUtils from "../../utils/flowUtils.js";
import moment from "moment";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css"
import {logDisplayTypes, SECTIONS} from "../../utils/constants";
import {logDisplayTypes} from "../../utils/constants";
import Download from "vue-material-design-icons/Download.vue";
import DotsHorizontal from "vue-material-design-icons/DotsHorizontal.vue";
import {DynamicScroller, DynamicScrollerItem} from "vue-virtual-scroller";
import {mapState} from "vuex";
import ForEachStatus from "../executions/ForEachStatus.vue";
import TaskRunLine from "../executions/TaskRunLine.vue";
import FlowUtils from "../../utils/flowUtils";
export default {
name: 'LogList',
name: "TaskRunDetails",
components: {
TaskRunLine,
ForEachStatus,
LogLine,
Restart,
ChangeStatus,
Clock,
Metrics,
Outputs,
ChevronDown,
ChevronUp,
Status,
SubFlowLink,
TaskEdit,
Duration,
TaskIcon,
DynamicScroller,
DynamicScrollerItem,
DotsHorizontal
},
emits: ["opened-taskruns-count", "follow", "reset-expand-collapse-all-switch"],
props: {
@@ -256,6 +127,14 @@
allowAutoExpandSubflows: {
type: Boolean,
default: true
},
showProgressBar: {
type: Boolean,
default: true
},
showLogs: {
type: Boolean,
default: true
}
},
data() {
@@ -361,9 +240,6 @@
},
computed: {
...mapState("plugin", ["icons"]),
SECTIONS() {
return SECTIONS
},
Download() {
return Download
},
@@ -411,11 +287,11 @@
this.shownAttemptsUid.length === 0 ? this.expandAll() : this.collapseAll();
},
autoExpandBasedOnSettings() {
if(this.autoExpandTaskrunStates.length === 0) {
if (this.autoExpandTaskrunStates.length === 0) {
return;
}
if(this.followedExecution === undefined) {
if (this.followedExecution === undefined) {
setTimeout(() => this.autoExpandBasedOnSettings(), 50);
return;
}
@@ -429,19 +305,15 @@
}
});
},
shouldDisplayLogs(taskRunId) {
return this.taskRunId || (this.shownAttemptsUid.includes(this.attemptUid(taskRunId, this.selectedAttemptNumberByTaskRunId[taskRunId])) &&
this.logsWithIndexByAttemptUid[this.attemptUid(taskRunId, this.selectedAttemptNumberByTaskRunId[taskRunId])])
shouldDisplayProgressBar(taskRun) {
return this.taskType(taskRun) === "io.kestra.core.tasks.flows.ForEachItem"
},
followExecution(executionId) {
this.$store
.dispatch("execution/followExecution", {id: executionId})
.then(sse => {
this.executionSSE = sse;
this.executionSSE.onmessage = async (event) => {
this.followedExecution = JSON.parse(event.data);
}
});
shouldDisplayLogs(taskRun) {
return (this.taskRunId ||
(this.shownAttemptsUid.includes(this.attemptUid(taskRun.id, this.selectedAttemptNumberByTaskRunId[taskRun.id])) &&
this.logsWithIndexByAttemptUid[this.attemptUid(taskRun.id, this.selectedAttemptNumberByTaskRunId[taskRun.id])])) &&
this.showLogs &&
this.taskType(taskRun) !== "io.kestra.core.tasks.flows.ForEachItem"
},
followLogs(executionId) {
this.$store
@@ -471,20 +343,11 @@
}
})
},
swapDisplayedAttempt(taskRunId, newDisplayedAttemptUid) {
this.shownAttemptsUid = this.shownAttemptsUid.map(attemptUid => attemptUid.startsWith(`${taskRunId}-`)
? this.attemptUid(taskRunId, newDisplayedAttemptUid)
: attemptUid
);
},
isSubflow(taskRun) {
return taskRun.outputs?.executionId;
},
selectedAttempt(taskRun) {
return this.attempts(taskRun)[this.selectedAttemptNumberByTaskRunId[taskRun.id] ?? 0];
},
expandAll() {
if(!this.followedExecution) {
if (!this.followedExecution) {
setTimeout(() => this.expandAll(), 50);
return;
}
@@ -498,9 +361,9 @@
this.expandSubflows();
},
expandSubflows() {
if(this.currentTaskRuns.some(taskRun => this.isSubflow(taskRun))){
if (this.currentTaskRuns.some(taskRun => this.isSubflow(taskRun))) {
const subflowLogsElements = this.$refs["subflows-logs"];
if(!subflowLogsElements || subflowLogsElements.length === 0) {
if (!subflowLogsElements || subflowLogsElements.length === 0) {
setTimeout(() => this.expandSubflows(), 50);
}
@@ -525,38 +388,16 @@
});
}
},
downloadContent(currentTaskRunId) {
const params = this.params
this.$store.dispatch("execution/downloadLogs", {
executionId: this.followedExecution.id,
params: {...params, taskRunId: currentTaskRunId}
}).then((response) => {
const url = window.URL.createObjectURL(new Blob([response]));
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", this.downloadName(currentTaskRunId));
document.body.appendChild(link);
link.click();
});
},
downloadName(currentTaskRunId) {
return `kestra-execution-${this.$moment().format("YYYYMMDDHHmmss")}-${this.followedExecution.id}-${currentTaskRunId}.log`
},
forwardEvent(type, event) {
this.$emit(type, event);
},
uniqueTaskRunDisplayFilter(currentTaskRun) {
return !(this.taskRunId && this.taskRunId !== currentTaskRun.id);
},
taskIcon(taskRun) {
const task = FlowUtils.findTaskById(this.flow, taskRun.taskId);
const parentTaskRunId = taskRun.parentTaskRunId;
if(task === undefined && parentTaskRunId) {
return this.taskIcon(this.taskRunById[parentTaskRunId])
}
return task ? task.type : undefined;
},
loadLogs(executionId) {
if(!this.showLogs) {
return;
}
this.$store.dispatch("execution/loadLogs", {
executionId,
params: {
@@ -576,6 +417,20 @@
},
toggleShowAttempt(attemptUid) {
this.shownAttemptsUid = _xor(this.shownAttemptsUid, [attemptUid])
},
swapDisplayedAttempt(taskRunId, newDisplayedAttemptUid) {
this.shownAttemptsUid = this.shownAttemptsUid.map(attemptUid => attemptUid.startsWith(`${taskRunId}-`)
? this.attemptUid(taskRunId, newDisplayedAttemptUid)
: attemptUid
);
},
taskType(taskRun) {
const task = FlowUtils.findTaskById(this.flow, taskRun.taskId);
const parentTaskRunId = taskRun.parentTaskRunId;
if (task === undefined && parentTaskRunId) {
return this.taskType(this.taskRunById[parentTaskRunId])
}
return task ? task.type : undefined;
}
},
beforeUnmount() {
@@ -609,80 +464,6 @@
padding-left: 0;
}
.attempt-header {
display: flex;
gap: calc(var(--spacer) / 2);
> * {
display: flex;
align-items: center;
}
.el-select {
width: 8rem;
}
.attempt-number {
background: var(--bs-gray-400);
padding: .375rem .75rem;
white-space: nowrap;
html.dark & {
color: var(--bs-gray-600);
}
}
.task-id, .task-duration {
padding: .375rem 0;
}
.task-id {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
span span {
color: var(--bs-tertiary-color);
html:not(.dark) & {
color: $black;
}
}
}
.task-icon {
width: 36px;
padding: 6px;
border-radius: $border-radius-lg;
}
small {
color: var(--bs-gray-600);
font-family: var(--bs-font-monospace);
font-size: var(--font-size-xs)
}
.task-duration small {
white-space: nowrap;
color: var(--bs-gray-800);
}
.more-dropdown-button {
padding: .5rem;
border: 1px solid rgba($white, .05);
&:not(:hover) {
background: rgba($white, .10);
}
}
.expand-collapse {
background-color: transparent !important;
padding-right: 0;
}
}
.attempt-wrapper {
margin-bottom: var(--spacer);
background-color: var(--bs-white);

View File

@@ -521,7 +521,8 @@
"row count": "Row count",
"encoding": "Encoding",
"show": "Show",
"advanced configuration": "Advanced configuration"
"advanced configuration": "Advanced configuration",
"all executions": "All executions"
},
"fr": {
"id": "Identifiant",
@@ -1035,7 +1036,8 @@
"show": "Afficher",
"advanced configuration": "Configuration avancée",
"row count": "Nombre de lignes",
"encoding": "Encodage"
"encoding": "Encodage",
"all executions": "Toutes les exécutions"
}
}

View File

@@ -169,7 +169,8 @@ export default class State {
return {
key: state.name,
icon: state.icon,
color: cssVariable("--bs-" + state.colorClass)
color: cssVariable("--bs-" + state.colorClass),
colorClass: state.colorClass
}
});
}