Skip to content

Commit dc3f9f2

Browse files
committed
feat: #11 allow to retry tasks
1 parent d36bd24 commit dc3f9f2

File tree

15 files changed

+189
-28
lines changed

15 files changed

+189
-28
lines changed

Diff for: app/Casts/TaskMetaCast.php

+9-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@ public function get(Model $model, string $key, mixed $value, array $attributes):
2929
throw new InvalidArgumentException('Model must be an instance of NodeTask');
3030
}
3131

32-
return self::META_BY_TYPE[$model->type]::from($value);
32+
// if (!isset($attributes['type'])) {
33+
// return null;
34+
// }
35+
36+
return self::META_BY_TYPE[$attributes['type']]::from($value);
3337
}
3438

3539
/**
@@ -43,6 +47,10 @@ public function set(Model $model, string $key, mixed $value, array $attributes):
4347
throw new InvalidArgumentException('Model must be an instance of NodeTask');
4448
}
4549

50+
if (is_string($value)) {
51+
return $value;
52+
}
53+
4654
return $value->toJson();
4755
}
4856
}

Diff for: app/Casts/TaskPayloadCast.php

+9-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ public function get(Model $model, string $key, mixed $value, array $attributes):
3232
throw new InvalidArgumentException('Model must be an instance of NodeTask');
3333
}
3434

35-
return self::PAYLOAD_BY_TYPE[$model->type]::from($value);
35+
// if (!isset($attributes['type'])) {
36+
// return null;
37+
// }
38+
//dd($model->type, $attributes);
39+
return self::PAYLOAD_BY_TYPE[$attributes['type']]::from($value);
3640
}
3741

3842
/**
@@ -46,6 +50,10 @@ public function set(Model $model, string $key, mixed $value, array $attributes):
4650
throw new InvalidArgumentException('Model must be an instance of NodeTask');
4751
}
4852

53+
if (is_string($value)) {
54+
return $value;
55+
}
56+
4957
return $value->toJson();
5058
}
5159
}

Diff for: app/Casts/TaskResultCast.php

+9-1
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,12 @@ public function get(Model $model, string $key, mixed $value, array $attributes):
3232
return ErrorResult::from($value);
3333
}
3434

35+
// if (!isset($attributes['type'])) {
36+
// return null;
37+
// }
38+
3539
if ($value != null) {
36-
return self::RESULT_BY_TYPE[$model->type]::from($value);
40+
return self::RESULT_BY_TYPE[$attributes['type']]::from($value);
3741
}
3842

3943
return null;
@@ -50,6 +54,10 @@ public function set(Model $model, string $key, mixed $value, array $attributes):
5054
throw new InvalidArgumentException('Model must be an instance of NodeTask');
5155
}
5256

57+
if (is_string($value)) {
58+
return $value;
59+
}
60+
5361
return $value->toJson();
5462
}
5563
}

Diff for: app/Http/Controllers/Controller.php

+10-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,16 @@
22

33
namespace App\Http\Controllers;
44

5+
use App\Models\User;
6+
57
abstract class Controller
68
{
7-
//
9+
protected function authorizeOr403(string $ability, ...$arguments)
10+
{
11+
$user = auth()->user();
12+
13+
if ($user->cannot($ability, ...$arguments)) {
14+
abort(403);
15+
}
16+
}
817
}

Diff for: app/Http/Controllers/NodeController.php

+6-3
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,12 @@ public function store(StoreNodeRequest $request)
4242
*/
4343
public function show(Node $node)
4444
{
45-
$initTaskGroup = $node->tasks()->inProgress()->ofType(InitSwarmTaskPayload::class)->first()?->taskGroup->with('tasks')->first();
46-
if (!$initTaskGroup) {
47-
$initTaskGroup = $node->tasks()->unsuccessful()->ofType(InitSwarmTaskPayload::class)->first()?->taskGroup->with('tasks')->first();
45+
$initTaskGroup = null;
46+
if (is_null($node->swarm_id)) {
47+
$initTaskGroup = $node->tasks()->inProgress()->ofType(InitSwarmTaskPayload::class)->first()?->taskGroup->load(['tasks', 'invoker']);
48+
if (!$initTaskGroup) {
49+
$initTaskGroup = $node->tasks()->unsuccessful()->ofType(InitSwarmTaskPayload::class)->latest('id')->first()?->taskGroup->load(['tasks', 'invoker']);
50+
}
4851
}
4952

5053
return Inertia::render('Nodes/Show', ['node' => $node, 'initTaskGroup' => $initTaskGroup]);

Diff for: app/Http/Controllers/NodeTaskGroupController.php

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use App\Models\Node;
6+
use App\Models\NodeTaskGroup;
7+
use App\Models\User;
8+
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
9+
use Illuminate\Http\Request;
10+
11+
class NodeTaskGroupController extends Controller
12+
{
13+
public function retry(Request $request, NodeTaskGroup $taskGroup)
14+
{
15+
$this->authorizeOr403('retry', $taskGroup);
16+
17+
$attrs = $request->validate([
18+
'node_id' => 'required|exists:nodes,id',
19+
]);
20+
21+
$node = Node::whereId($attrs['node_id'])->first();
22+
23+
$this->authorizeOr403('view', $node);
24+
25+
$taskGroup->retry($node);
26+
}
27+
}

Diff for: app/Models/NodeTask.php

+9-9
Original file line numberDiff line numberDiff line change
@@ -52,15 +52,15 @@ class NodeTask extends Model
5252

5353
protected static function booted()
5454
{
55-
self::creating(function (NodeTask $nodeTask) {
56-
$payload = $nodeTask->payload;
57-
58-
if (!($payload instanceof AbstractTaskPayload)) {
59-
throw new IllegalArgumentException('Payload must be an instance of AbstractTaskPayload');
60-
}
61-
62-
$nodeTask->type = TaskPayloadCast::TYPE_BY_PAYLOAD[get_class($payload)];
63-
});
55+
// self::creating(function (NodeTask $nodeTask) {
56+
// $payload = $nodeTask->payload;
57+
//
58+
// if (!($payload instanceof AbstractTaskPayload)) {
59+
// throw new IllegalArgumentException('Payload must be an instance of AbstractTaskPayload');
60+
// }
61+
//
62+
// $nodeTask->type = TaskPayloadCast::TYPE_BY_PAYLOAD[get_class($payload)];
63+
// });
6464
}
6565

6666
public function taskGroup(): BelongsTo

Diff for: app/Models/NodeTaskGroup.php

+42
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
use Illuminate\Database\Eloquent\Model;
1111
use Illuminate\Database\Eloquent\Relations\BelongsTo;
1212
use Illuminate\Database\Eloquent\Relations\HasMany;
13+
use Symfony\Component\VarDumper\VarDumper;
14+
use function Psy\debug;
1315

1416
class NodeTaskGroup extends Model
1517
{
@@ -43,11 +45,51 @@ public function node(): BelongsTo
4345
return $this->belongsTo(Node::class);
4446
}
4547

48+
public function invoker(): BelongsTo
49+
{
50+
return $this->belongsTo(User::class, 'invoker_id');
51+
}
52+
4653
public function start(Node $node): void
4754
{
4855
$this->status = TaskStatus::Running;
4956
$this->node_id = $node->id;
5057
$this->started_at = now();
5158
$this->save();
5259
}
60+
61+
public function retry(Node|null $node): void
62+
{
63+
$nodeId = is_null($node) ? null : $node->id;
64+
65+
$taskGroup = new NodeTaskGroup();
66+
$taskGroup->node_id = $nodeId;
67+
$taskGroup->forceFill(collect($this->attributes)->only([
68+
'swarm_id',
69+
'invoker_id',
70+
])->toArray());
71+
$taskGroup->save();
72+
73+
74+
$taskGroup->tasks()->saveMany($this->tasks->map(function (NodeTask $task) use ($node) {
75+
$dataAttrs = $task->is_completed
76+
? [
77+
'status',
78+
'started_at',
79+
'ended_at',
80+
'result'
81+
]
82+
: [
83+
];
84+
85+
$attrs = collect($task->attributes);
86+
87+
$attributes = $attrs
88+
->only($dataAttrs)
89+
->merge($attrs->only(['type', 'meta', 'payload']))
90+
->toArray();
91+
92+
return (new NodeTask($attributes))->forceFill($attributes);
93+
}));
94+
}
5395
}

Diff for: app/Policies/NodeTaskGroupPolicy.php

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
4+
namespace App\Policies;
5+
6+
use App\Models\Node;
7+
use App\Models\NodeTaskGroup;
8+
use App\Models\User;
9+
use Illuminate\Auth\Access\Response;
10+
11+
class NodeTaskGroupPolicy
12+
{
13+
public function retry(User $user, NodeTaskGroup $taskGroup): bool
14+
{
15+
return $user->belongsToTeam($taskGroup->node->team);
16+
}
17+
}

Diff for: app/Traits/HasTaskStatus.php

+5
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ public function getIsRunningAttribute() : bool
4747
return $this->status === TaskStatus::Running;
4848
}
4949

50+
public function getIsCompletedAttribute() : bool
51+
{
52+
return $this->status === TaskStatus::Completed;
53+
}
54+
5055
public function scopeOfType(Builder $query, string $typeClass): Builder
5156
{
5257
return $query->where('type', TaskPayloadCast::TYPE_BY_PAYLOAD[$typeClass]);

Diff for: resources/js/Components/NodeTasks/TaskGroup.vue

+15-1
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,29 @@
11
<script setup>
22
import {reactive} from "vue";
33
import TaskResult from "@/Components/NodeTasks/TaskResult.vue";
4+
import SecondaryButton from "@/Components/SecondaryButton.vue";
5+
import {router} from "@inertiajs/vue3";
46
5-
defineProps({
7+
const props = defineProps({
68
'taskGroup': Object,
79
});
810
11+
const retry = () => {
12+
router.post(route('node-task-groups.retry', {taskGroup: props.taskGroup.id, node_id: props.taskGroup.node_id}));
13+
}
14+
915
</script>
1016

1117
<template>
1218
<ul class="col-span-6 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600 dark:text-white">
1319
<TaskResult v-for="task in taskGroup.tasks" :key="task.id" :task="task" />
1420
</ul>
21+
<div class="flex">
22+
<div class="col-span-6 ms-4 mt-1 text-xs text-gray-900 dark:text-white grow">#{{ taskGroup.id }} Invoked by {{ taskGroup.invoker.name }}</div>
23+
<SecondaryButton v-if="taskGroup.status === 'failed' || taskGroup.status === 'canceled'"
24+
@click="retry"
25+
class="col-span-6 ms-4 mt-2 text-xs text-gray-900 dark:text-white">
26+
Retry Failed Tasks
27+
</SecondaryButton>
28+
</div>
1529
</template>

Diff for: resources/js/Components/NodeTasks/TaskResult.vue

+3
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ const classes = computed(() => {
6363

6464
<span v-html="task.formatted_payload" class="grow" />
6565

66+
<span class="text-xs me-2 text-gray-500">#{{ task.id }}</span>
67+
6668
<span v-auto-animate="{duration: 100}" v-if="task.result">
6769
<svg v-if="state.expanded"
6870
class="w-4 h-4 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
@@ -75,6 +77,7 @@ const classes = computed(() => {
7577
</svg>
7678

7779
</span>
80+
<span v-else class="w-4"></span>
7881
</div>
7982
<div v-if="state.expanded" class="px-4 py-2 border-t border-t-gray-100 bg-gray-50" v-html="task.formatted_result" />
8083
</li>

Diff for: resources/js/Pages/Nodes/Partials/SwarmDetauls.vue

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
2+
<script setup>
3+
import ActionSection from "@/Components/ActionSection.vue";
4+
</script>
5+
6+
<template>
7+
<ActionSection>
8+
<template #title>
9+
Swarm Cluster
10+
</template>
11+
12+
<template #description>
13+
Swarm Cluster has been initialized and is in a healthy state.
14+
</template>
15+
16+
<template #content>
17+
Sorry, we're still working on the Swarm Cluster details block.
18+
</template>
19+
</ActionSection>
20+
</template>

Diff for: resources/js/Pages/Nodes/Show.vue

+5-11
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,16 @@
11
<script setup>
2-
import AppLayout from '@/Layouts/AppLayout.vue';
3-
import PrimaryButton from "@/Components/PrimaryButton.vue";
4-
import FormSection from "@/Components/FormSection.vue";
52
import NewSwarmCluster from "@/Pages/Nodes/Partials/NewSwarmCluster.vue";
63
import ServerDetailsForm from "@/Pages/Nodes/Partials/ServerDetailsForm.vue";
7-
import LayoutTab from "@/Components/LayoutTab.vue";
84
import ShowLayout from "@/Pages/Nodes/ShowLayout.vue";
9-
import { CopyClipboard } from 'flowbite';
10-
import {onMounted, ref} from "vue";
11-
import AgentInstall from "@/Pages/Nodes/Partials/AgentInstall.vue";
125
import AgentStatus from "@/Pages/Nodes/Partials/AgentStatus.vue";
136
import SectionBorder from "@/Components/SectionBorder.vue";
147
import InitSwarmProgress from "@/Pages/Nodes/Partials/InitSwarmProgress.vue";
8+
import SwarmDetauls from "@/Pages/Nodes/Partials/SwarmDetauls.vue";
159
1610
defineProps([
1711
'node',
1812
'initTaskGroup',
1913
]);
20-
21-
22-
2314
</script>
2415

2516
<template>
@@ -32,7 +23,10 @@ defineProps([
3223

3324
<SectionBorder v-if="$props.node.online" />
3425

35-
<NewSwarmCluster v-if="$props.node.online && $props.node.swarm_id === null" :node="$props.node"/>
26+
<template v-if="$props.node.online">
27+
<NewSwarmCluster v-if="$props.node.swarm_id === null" :node="$props.node"/>
3628
<InitSwarmProgress v-if="$props.initTaskGroup" :taskGroup="$props.initTaskGroup" />
29+
<SwarmDetauls v-if="$props.node.swarm_id !== null" :node="$props.node"/>
30+
</template>
3731
</ShowLayout>
3832
</template>

Diff for: routes/web.php

+3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<?php
22

33
use App\Http\Controllers\NodeController;
4+
use App\Http\Controllers\NodeTaskGroupController;
45
use App\Http\Controllers\SwarmTaskController;
56
use Illuminate\Foundation\Application;
67
use Illuminate\Support\Facades\Route;
@@ -26,5 +27,7 @@
2627

2728
Route::post('/swarm-tasks/init-cluster', [SwarmTaskController::class, 'initCluster'])->name('swarm-tasks.init-cluster');
2829

30+
Route::post('/node-task-groups/{taskGroup}/retry', [NodeTaskGroupController::class, 'retry'])->name('node-task-groups.retry');
31+
2932
Route::resource("nodes", NodeController::class);
3033
});

0 commit comments

Comments
 (0)