feat: tasks hooked up to UI
This commit is contained in:
@@ -75,12 +75,36 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp dispatch("reindex_text", project, _params) do
|
defp dispatch("reindex_text", project, _params) do
|
||||||
queue_task(project, "reindex_text", "Reindex Text", "Search", fn report ->
|
group_id = task_group_id("reindex_text")
|
||||||
report.(0.2, "Clearing and rebuilding text indexes")
|
attrs = %{group_id: group_id, group_name: "Search"}
|
||||||
:ok = Search.reindex_project(project.id)
|
|
||||||
report.(1.0, "Text indexes rebuilt")
|
{:ok, posts_task} =
|
||||||
%{project_id: project.id}
|
Tasks.submit_task("Reindex Search Text", fn report ->
|
||||||
end)
|
report.(0.0, "Clearing and rebuilding post search indexes")
|
||||||
|
:ok = Search.reindex_posts(project.id)
|
||||||
|
report.(1.0, "Post search text reindexed")
|
||||||
|
%{project_id: project.id, entity: "posts"}
|
||||||
|
end, attrs)
|
||||||
|
|
||||||
|
{:ok, _media_task} =
|
||||||
|
Tasks.submit_task("Reindex Media Search Text", fn report ->
|
||||||
|
report.(0.0, "Clearing and rebuilding media search indexes")
|
||||||
|
:ok = Search.reindex_media(project.id)
|
||||||
|
report.(1.0, "Media search text reindexed")
|
||||||
|
%{project_id: project.id, entity: "media"}
|
||||||
|
end, attrs)
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
kind: "task_queued",
|
||||||
|
action: "reindex_text",
|
||||||
|
title: "Reindex Text",
|
||||||
|
message: "Search tasks queued",
|
||||||
|
project_id: project.id,
|
||||||
|
task_id: posts_task.id,
|
||||||
|
task_group_id: group_id,
|
||||||
|
panel_tab: "tasks"
|
||||||
|
}}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp dispatch("rebuild_embedding_index", project, _params) do
|
defp dispatch("rebuild_embedding_index", project, _params) do
|
||||||
@@ -93,30 +117,63 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp dispatch("rebuild_database", project, _params) do
|
defp dispatch("rebuild_database", project, _params) do
|
||||||
queue_task(project, "rebuild_database", "Rebuild Database", "Maintenance", fn report ->
|
group_id = task_group_id("rebuild_database")
|
||||||
report.(0.1, "Rebuilding posts")
|
attrs = %{group_id: group_id, group_name: "Maintenance"}
|
||||||
{:ok, posts} = Maintenance.rebuild_from_filesystem(project.id, "post")
|
|
||||||
report.(0.3, "Rebuilding media")
|
|
||||||
{:ok, media} = Maintenance.rebuild_from_filesystem(project.id, "media")
|
|
||||||
report.(0.5, "Rebuilding scripts")
|
|
||||||
{:ok, scripts} = Maintenance.rebuild_from_filesystem(project.id, "script")
|
|
||||||
report.(0.7, "Rebuilding templates")
|
|
||||||
{:ok, templates} = Maintenance.rebuild_from_filesystem(project.id, "template")
|
|
||||||
report.(0.9, "Rebuilding embeddings")
|
|
||||||
{:ok, embeddings} = Maintenance.rebuild_from_filesystem(project.id, "embedding")
|
|
||||||
report.(1.0, "Database rebuild complete")
|
|
||||||
|
|
||||||
%{
|
{:ok, posts_task} =
|
||||||
project_id: project.id,
|
Tasks.submit_task("Rebuild Posts From Files", fn report ->
|
||||||
counts: %{
|
report.(0.0, "Scanning post files")
|
||||||
posts: length(posts),
|
{:ok, posts} = Maintenance.rebuild_from_filesystem(project.id, "post")
|
||||||
media: length(media),
|
report.(1.0, "Post rebuild complete")
|
||||||
scripts: length(scripts),
|
%{project_id: project.id, counts: %{posts: length(posts)}}
|
||||||
templates: length(templates),
|
end, attrs)
|
||||||
embeddings: length(embeddings)
|
|
||||||
}
|
{:ok, _media_task} =
|
||||||
}
|
Tasks.submit_task("Rebuild Media From Files", fn report ->
|
||||||
|
report.(0.0, "Scanning media files")
|
||||||
|
{:ok, media} = Maintenance.rebuild_from_filesystem(project.id, "media")
|
||||||
|
report.(1.0, "Media rebuild complete")
|
||||||
|
%{project_id: project.id, counts: %{media: length(media)}}
|
||||||
|
end, attrs)
|
||||||
|
|
||||||
|
{:ok, _scripts_task} =
|
||||||
|
Tasks.submit_task("Rebuild Scripts From Files", fn report ->
|
||||||
|
report.(0.0, "Scanning script files")
|
||||||
|
{:ok, scripts} = Maintenance.rebuild_from_filesystem(project.id, "script")
|
||||||
|
report.(1.0, "Script rebuild complete")
|
||||||
|
%{project_id: project.id, counts: %{scripts: length(scripts)}}
|
||||||
|
end, attrs)
|
||||||
|
|
||||||
|
{:ok, _templates_task} =
|
||||||
|
Tasks.submit_task("Rebuild Templates From Files", fn report ->
|
||||||
|
report.(0.0, "Scanning template files")
|
||||||
|
{:ok, templates} = Maintenance.rebuild_from_filesystem(project.id, "template")
|
||||||
|
report.(1.0, "Template rebuild complete")
|
||||||
|
%{project_id: project.id, counts: %{templates: length(templates)}}
|
||||||
|
end, attrs)
|
||||||
|
|
||||||
|
Task.start(fn ->
|
||||||
|
wait_for_group_phase(group_id, [
|
||||||
|
"Rebuild Posts From Files",
|
||||||
|
"Rebuild Media From Files",
|
||||||
|
"Rebuild Scripts From Files",
|
||||||
|
"Rebuild Templates From Files"
|
||||||
|
])
|
||||||
|
|
||||||
|
submit_rebuild_followups(project, attrs)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
kind: "task_queued",
|
||||||
|
action: "rebuild_database",
|
||||||
|
title: "Rebuild Database",
|
||||||
|
message: "Maintenance tasks queued",
|
||||||
|
project_id: project.id,
|
||||||
|
task_id: posts_task.id,
|
||||||
|
task_group_id: group_id,
|
||||||
|
panel_tab: "tasks"
|
||||||
|
}}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp dispatch("generate_sitemap", project, _params) do
|
defp dispatch("generate_sitemap", project, _params) do
|
||||||
@@ -129,78 +186,39 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp dispatch("validate_site", project, _params) do
|
defp dispatch("validate_site", project, _params) do
|
||||||
with {:ok, report} <- Generation.validate_site(project.id, @site_sections) do
|
queue_task(project, "validate_site", "Validate Site", "Validation", fn report ->
|
||||||
{:ok,
|
report.(0.2, "Validating generated site output")
|
||||||
%{
|
{:ok, validation} = Generation.validate_site(project.id, @site_sections)
|
||||||
kind: "open_editor",
|
report.(1.0, "Site validation complete")
|
||||||
action: "validate_site",
|
site_validation_result(project.id, validation)
|
||||||
project_id: project.id,
|
end)
|
||||||
route: "site_validation",
|
|
||||||
title: "Site Validation",
|
|
||||||
subtitle: "Generated output checked against expected site files",
|
|
||||||
editorMeta: [
|
|
||||||
%{label: "Missing", value: Integer.to_string(length(report.missing_pages))},
|
|
||||||
%{label: "Extra", value: Integer.to_string(length(report.extra_pages))},
|
|
||||||
%{label: "Stale", value: Integer.to_string(length(report.stale_pages))}
|
|
||||||
],
|
|
||||||
payload: normalize_site_validation(report)
|
|
||||||
}}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp dispatch("metadata_diff", project, _params) do
|
defp dispatch("metadata_diff", project, _params) do
|
||||||
with {:ok, report} <- Maintenance.metadata_diff(project.id) do
|
queue_task(project, "metadata_diff", "Metadata Diff", "Maintenance", fn report ->
|
||||||
{:ok,
|
report.(0.2, "Comparing database and filesystem metadata")
|
||||||
%{
|
{:ok, metadata_diff} = Maintenance.metadata_diff(project.id)
|
||||||
kind: "open_editor",
|
report.(1.0, "Metadata diff complete")
|
||||||
action: "metadata_diff",
|
metadata_diff_result(project.id, metadata_diff)
|
||||||
project_id: project.id,
|
end)
|
||||||
route: "metadata_diff",
|
|
||||||
title: "Metadata Diff",
|
|
||||||
subtitle: "Database state compared against filesystem metadata",
|
|
||||||
editorMeta: [
|
|
||||||
%{label: "Diffs", value: Integer.to_string(length(report.diff_reports))},
|
|
||||||
%{label: "Orphans", value: Integer.to_string(length(report.orphan_reports))}
|
|
||||||
],
|
|
||||||
payload: normalize_metadata_diff(report)
|
|
||||||
}}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp dispatch("validate_translations", project, _params) do
|
defp dispatch("validate_translations", project, _params) do
|
||||||
with {:ok, report} <- Posts.validate_translations(project.id) do
|
queue_task(project, "validate_translations", "Validate Translations", "Validation", fn report ->
|
||||||
{:ok,
|
report.(0.2, "Checking published translations")
|
||||||
%{
|
{:ok, translation_report} = Posts.validate_translations(project.id)
|
||||||
kind: "open_editor",
|
report.(1.0, "Translation validation complete")
|
||||||
action: "validate_translations",
|
translation_validation_result(project.id, translation_report)
|
||||||
project_id: project.id,
|
end)
|
||||||
route: "translation_validation",
|
|
||||||
title: "Translation Validation",
|
|
||||||
subtitle: "Published posts checked against required blog languages",
|
|
||||||
editorMeta: [
|
|
||||||
%{label: "Missing", value: Integer.to_string(length(report.missing))},
|
|
||||||
%{label: "Orphans", value: Integer.to_string(length(report.orphan_files))},
|
|
||||||
%{label: "Skipped", value: Integer.to_string(length(report.do_not_translate_posts))}
|
|
||||||
],
|
|
||||||
payload: normalize_translation_validation(report)
|
|
||||||
}}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp dispatch("find_duplicates", project, _params) do
|
defp dispatch("find_duplicates", project, _params) do
|
||||||
with {:ok, pairs} <- Embeddings.find_duplicates(project.id) do
|
queue_task(project, "find_duplicates", "Find Duplicate Posts", "Embeddings", fn report ->
|
||||||
{:ok,
|
report.(0.2, "Checking for duplicate posts")
|
||||||
%{
|
{:ok, pairs} = Embeddings.find_duplicates(project.id)
|
||||||
kind: "open_editor",
|
report.(1.0, "Duplicate search complete")
|
||||||
action: "find_duplicates",
|
duplicate_search_result(project.id, pairs)
|
||||||
project_id: project.id,
|
end)
|
||||||
route: "find_duplicates",
|
|
||||||
title: "Find Duplicates",
|
|
||||||
subtitle: "Potential duplicate posts found via embeddings",
|
|
||||||
editorMeta: [%{label: "Pairs", value: Integer.to_string(length(pairs))}],
|
|
||||||
payload: normalize_duplicate_pairs(pairs)
|
|
||||||
}}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp dispatch("upload_site", project, _params) do
|
defp dispatch("upload_site", project, _params) do
|
||||||
@@ -244,6 +262,64 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
}}
|
}}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp submit_rebuild_followups(project, attrs) do
|
||||||
|
{:ok, _links_task} =
|
||||||
|
Tasks.submit_task("Rebuild Post Links", fn report ->
|
||||||
|
report.(0.0, "Rebuilding link graph")
|
||||||
|
:ok = Posts.rebuild_post_links(project.id)
|
||||||
|
report.(1.0, "Post links rebuilt")
|
||||||
|
%{project_id: project.id}
|
||||||
|
end, attrs)
|
||||||
|
|
||||||
|
{:ok, _thumbs_task} =
|
||||||
|
Tasks.submit_task("Regenerate Missing Thumbnails", fn report ->
|
||||||
|
report.(0.0, "Checking missing thumbnails")
|
||||||
|
result = BDS.Media.regenerate_missing_thumbnails(project.id)
|
||||||
|
report.(1.0, "Missing thumbnails regenerated")
|
||||||
|
Map.put(result, :project_id, project.id)
|
||||||
|
end, attrs)
|
||||||
|
|
||||||
|
{:ok, _embeddings_task} =
|
||||||
|
Tasks.submit_task("Rebuild Embedding Index", fn report ->
|
||||||
|
report.(0.0, "Rebuilding semantic index")
|
||||||
|
{:ok, rebuilt_post_ids} = Embeddings.rebuild_project(project.id)
|
||||||
|
report.(1.0, "Embedding index rebuilt")
|
||||||
|
%{project_id: project.id, rebuilt_post_ids: rebuilt_post_ids, rebuilt_count: length(rebuilt_post_ids)}
|
||||||
|
end, attrs)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp wait_for_group_phase(group_id, names, timeout \\ 30_000)
|
||||||
|
|
||||||
|
defp wait_for_group_phase(_group_id, _names, timeout) when timeout <= 0, do: :timeout
|
||||||
|
|
||||||
|
defp wait_for_group_phase(group_id, names, timeout) do
|
||||||
|
tasks =
|
||||||
|
BDS.Tasks.list_tasks()
|
||||||
|
|> Enum.filter(&(&1.group_id == group_id and &1.name in names))
|
||||||
|
|
||||||
|
cond do
|
||||||
|
length(tasks) < length(names) ->
|
||||||
|
Process.sleep(50)
|
||||||
|
wait_for_group_phase(group_id, names, timeout - 50)
|
||||||
|
|
||||||
|
Enum.any?(tasks, &(&1.status == :failed)) ->
|
||||||
|
:failed
|
||||||
|
|
||||||
|
Enum.all?(tasks, &(&1.status == :completed)) ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
true ->
|
||||||
|
Process.sleep(50)
|
||||||
|
wait_for_group_phase(group_id, names, timeout - 50)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp task_group_id(action) do
|
||||||
|
action <> "-" <> Integer.to_string(System.unique_integer([:positive, :monotonic]))
|
||||||
|
end
|
||||||
|
|
||||||
defp active_project do
|
defp active_project do
|
||||||
case Projects.get_active_project() do
|
case Projects.get_active_project() do
|
||||||
nil -> {:error, %{message: "No active project selected"}}
|
nil -> {:error, %{message: "No active project selected"}}
|
||||||
@@ -276,6 +352,23 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp site_validation_result(project_id, report) do
|
||||||
|
%{
|
||||||
|
kind: "open_editor",
|
||||||
|
action: "validate_site",
|
||||||
|
project_id: project_id,
|
||||||
|
route: "site_validation",
|
||||||
|
title: "Site Validation",
|
||||||
|
subtitle: "Generated output checked against expected site files",
|
||||||
|
editorMeta: [
|
||||||
|
%{label: "Missing", value: Integer.to_string(length(report.missing_pages))},
|
||||||
|
%{label: "Extra", value: Integer.to_string(length(report.extra_pages))},
|
||||||
|
%{label: "Stale", value: Integer.to_string(length(report.stale_pages))}
|
||||||
|
],
|
||||||
|
payload: normalize_site_validation(report)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
defp normalize_metadata_diff(report) do
|
defp normalize_metadata_diff(report) do
|
||||||
%{
|
%{
|
||||||
summary: %{
|
summary: %{
|
||||||
@@ -287,6 +380,22 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp metadata_diff_result(project_id, report) do
|
||||||
|
%{
|
||||||
|
kind: "open_editor",
|
||||||
|
action: "metadata_diff",
|
||||||
|
project_id: project_id,
|
||||||
|
route: "metadata_diff",
|
||||||
|
title: "Metadata Diff",
|
||||||
|
subtitle: "Database state compared against filesystem metadata",
|
||||||
|
editorMeta: [
|
||||||
|
%{label: "Diffs", value: Integer.to_string(length(report.diff_reports))},
|
||||||
|
%{label: "Orphans", value: Integer.to_string(length(report.orphan_reports))}
|
||||||
|
],
|
||||||
|
payload: normalize_metadata_diff(report)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
defp normalize_translation_validation(report) do
|
defp normalize_translation_validation(report) do
|
||||||
%{
|
%{
|
||||||
summary: %{
|
summary: %{
|
||||||
@@ -300,6 +409,23 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp translation_validation_result(project_id, report) do
|
||||||
|
%{
|
||||||
|
kind: "open_editor",
|
||||||
|
action: "validate_translations",
|
||||||
|
project_id: project_id,
|
||||||
|
route: "translation_validation",
|
||||||
|
title: "Translation Validation",
|
||||||
|
subtitle: "Published posts checked against required blog languages",
|
||||||
|
editorMeta: [
|
||||||
|
%{label: "Missing", value: Integer.to_string(length(report.missing))},
|
||||||
|
%{label: "Orphan Files", value: Integer.to_string(length(report.orphan_files))},
|
||||||
|
%{label: "Skipped", value: Integer.to_string(length(report.do_not_translate_posts))}
|
||||||
|
],
|
||||||
|
payload: normalize_translation_validation(report)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
defp normalize_duplicate_pairs(pairs) do
|
defp normalize_duplicate_pairs(pairs) do
|
||||||
%{
|
%{
|
||||||
summary: %{pair_count: length(pairs)},
|
summary: %{pair_count: length(pairs)},
|
||||||
@@ -307,6 +433,19 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp duplicate_search_result(project_id, pairs) do
|
||||||
|
%{
|
||||||
|
kind: "open_editor",
|
||||||
|
action: "find_duplicates",
|
||||||
|
project_id: project_id,
|
||||||
|
route: "find_duplicates",
|
||||||
|
title: "Find Duplicates",
|
||||||
|
subtitle: "Potential duplicate posts found via embeddings",
|
||||||
|
editorMeta: [%{label: "Pairs", value: Integer.to_string(length(pairs))}],
|
||||||
|
payload: normalize_duplicate_pairs(pairs)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
defp stringify_map(map) when is_map(map) do
|
defp stringify_map(map) when is_map(map) do
|
||||||
Map.new(map, fn {key, value} -> {to_string(key), stringify_value(value)} end)
|
Map.new(map, fn {key, value} -> {to_string(key), stringify_value(value)} end)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -122,19 +122,30 @@ defmodule BDS.Search do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def reindex_project(project_id) do
|
def reindex_project(project_id) do
|
||||||
|
:ok = reindex_posts(project_id)
|
||||||
|
:ok = reindex_media(project_id)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
def reindex_posts(project_id) do
|
||||||
Repo.query!(
|
Repo.query!(
|
||||||
"DELETE FROM posts_fts WHERE post_id IN (SELECT id FROM posts WHERE project_id = ?)",
|
"DELETE FROM posts_fts WHERE post_id IN (SELECT id FROM posts WHERE project_id = ?)",
|
||||||
[project_id]
|
[project_id]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Repo.all(from post in Post, where: post.project_id == ^project_id)
|
||||||
|
|> Enum.each(&sync_post/1)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
def reindex_media(project_id) do
|
||||||
Repo.query!(
|
Repo.query!(
|
||||||
"DELETE FROM media_fts WHERE media_id IN (SELECT id FROM media WHERE project_id = ?)",
|
"DELETE FROM media_fts WHERE media_id IN (SELECT id FROM media WHERE project_id = ?)",
|
||||||
[project_id]
|
[project_id]
|
||||||
)
|
)
|
||||||
|
|
||||||
Repo.all(from post in Post, where: post.project_id == ^project_id)
|
|
||||||
|> Enum.each(&sync_post/1)
|
|
||||||
|
|
||||||
Repo.all(from media in Media, where: media.project_id == ^project_id)
|
Repo.all(from media in Media, where: media.project_id == ^project_id)
|
||||||
|> Enum.each(&sync_media/1)
|
|> Enum.each(&sync_media/1)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ defmodule BDS.Tasks do
|
|||||||
|
|
||||||
@default_max_concurrent 3
|
@default_max_concurrent 3
|
||||||
@default_progress_throttle_ms 250
|
@default_progress_throttle_ms 250
|
||||||
|
@default_recent_finished_limit 10
|
||||||
|
|
||||||
def start_link(_opts) do
|
def start_link(_opts) do
|
||||||
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
|
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
|
||||||
@@ -35,6 +36,10 @@ defmodule BDS.Tasks do
|
|||||||
GenServer.call(__MODULE__, :clear_completed)
|
GenServer.call(__MODULE__, :clear_completed)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def clear_finished do
|
||||||
|
GenServer.call(__MODULE__, :clear_finished)
|
||||||
|
end
|
||||||
|
|
||||||
def cancel_task(task_id) when is_binary(task_id) do
|
def cancel_task(task_id) when is_binary(task_id) do
|
||||||
GenServer.call(__MODULE__, {:cancel_task, task_id})
|
GenServer.call(__MODULE__, {:cancel_task, task_id})
|
||||||
end
|
end
|
||||||
@@ -104,6 +109,15 @@ defmodule BDS.Tasks do
|
|||||||
{:reply, :ok, %{state | tasks: next_tasks}}
|
{:reply, :ok, %{state | tasks: next_tasks}}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_call(:clear_finished, _from, state) do
|
||||||
|
next_tasks =
|
||||||
|
state.tasks
|
||||||
|
|> Enum.reject(fn {_task_id, task} -> task.status in [:completed, :failed, :cancelled] end)
|
||||||
|
|> Map.new()
|
||||||
|
|
||||||
|
{:reply, :ok, %{state | tasks: next_tasks}}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_call({:cancel_task, task_id}, _from, state) do
|
def handle_call({:cancel_task, task_id}, _from, state) do
|
||||||
cond do
|
cond do
|
||||||
Map.has_key?(state.running, task_id) ->
|
Map.has_key?(state.running, task_id) ->
|
||||||
@@ -340,14 +354,15 @@ defmodule BDS.Tasks do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp build_status_snapshot(state) do
|
defp build_status_snapshot(state) do
|
||||||
tasks = active_tasks(state)
|
active = active_tasks(state)
|
||||||
|
tasks = active ++ recent_finished_tasks(state)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
active_count: length(tasks),
|
active_count: length(active),
|
||||||
running_count: Enum.count(tasks, &(&1.status == :running)),
|
running_count: Enum.count(active, &(&1.status == :running)),
|
||||||
pending_count: Enum.count(tasks, &(&1.status == :pending)),
|
pending_count: Enum.count(active, &(&1.status == :pending)),
|
||||||
running_task_message: running_task_message(tasks),
|
running_task_message: running_task_message(active),
|
||||||
running_task_overflow: running_task_overflow(tasks),
|
running_task_overflow: running_task_overflow(active),
|
||||||
tasks: Enum.map(tasks, &public_task/1)
|
tasks: Enum.map(tasks, &public_task/1)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -359,6 +374,14 @@ defmodule BDS.Tasks do
|
|||||||
|> Enum.sort_by(&task_sort_key/1)
|
|> Enum.sort_by(&task_sort_key/1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp recent_finished_tasks(state) do
|
||||||
|
state.tasks
|
||||||
|
|> Map.values()
|
||||||
|
|> Enum.filter(&(&1.status in [:completed, :failed, :cancelled]))
|
||||||
|
|> Enum.sort_by(&DateTime.to_unix(&1.finished_at || &1.created_at, :microsecond), :desc)
|
||||||
|
|> Enum.take(recent_finished_limit())
|
||||||
|
end
|
||||||
|
|
||||||
defp all_tasks(state) do
|
defp all_tasks(state) do
|
||||||
state.tasks
|
state.tasks
|
||||||
|> Map.values()
|
|> Map.values()
|
||||||
@@ -406,6 +429,11 @@ defmodule BDS.Tasks do
|
|||||||
|> Keyword.get(:progress_throttle_ms, @default_progress_throttle_ms)
|
|> Keyword.get(:progress_throttle_ms, @default_progress_throttle_ms)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp recent_finished_limit do
|
||||||
|
Application.get_env(:bds, :tasks, [])
|
||||||
|
|> Keyword.get(:recent_finished_limit, @default_recent_finished_limit)
|
||||||
|
end
|
||||||
|
|
||||||
defp attr(attrs, key) do
|
defp attr(attrs, key) do
|
||||||
cond do
|
cond do
|
||||||
Map.has_key?(attrs, key) -> Map.get(attrs, key)
|
Map.has_key?(attrs, key) -> Map.get(attrs, key)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const state = {
|
|||||||
projects: normalizeProjects(bootstrap.projects),
|
projects: normalizeProjects(bootstrap.projects),
|
||||||
projectMenuOpen: false,
|
projectMenuOpen: false,
|
||||||
taskStatus: normalizeTaskStatus(bootstrap.task_status),
|
taskStatus: normalizeTaskStatus(bootstrap.task_status),
|
||||||
|
handledTaskResults: {},
|
||||||
outputEntries: [],
|
outputEntries: [],
|
||||||
gitLogEntries: [],
|
gitLogEntries: [],
|
||||||
uiLanguage: readStoredUiLanguage(bootstrap.i18n?.ui_language || bootstrap.status.right.ui_language),
|
uiLanguage: readStoredUiLanguage(bootstrap.i18n?.ui_language || bootstrap.status.right.ui_language),
|
||||||
@@ -656,12 +657,40 @@ async function fetchTaskStatus() {
|
|||||||
state.taskStatus = next;
|
state.taskStatus = next;
|
||||||
state.status.left.running_task_message = next.running_task_message;
|
state.status.left.running_task_message = next.running_task_message;
|
||||||
state.status.left.running_task_overflow = next.running_task_overflow;
|
state.status.left.running_task_overflow = next.running_task_overflow;
|
||||||
|
applyCompletedTaskResults(next.tasks);
|
||||||
render();
|
render();
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
// Keep the shell usable if task polling is temporarily unavailable.
|
// Keep the shell usable if task polling is temporarily unavailable.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyCompletedTaskResults(tasks) {
|
||||||
|
pruneHandledTaskResults(tasks);
|
||||||
|
|
||||||
|
tasks.forEach((task) => {
|
||||||
|
if (task.status !== "completed" || state.handledTaskResults[task.id]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!task.result || typeof task.result !== "object" || typeof task.result.kind !== "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.handledTaskResults[task.id] = true;
|
||||||
|
applyShellCommandResult(task.result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneHandledTaskResults(tasks) {
|
||||||
|
const visibleTaskIds = new Set(tasks.map((task) => task.id));
|
||||||
|
|
||||||
|
Object.keys(state.handledTaskResults).forEach((taskId) => {
|
||||||
|
if (!visibleTaskIds.has(taskId)) {
|
||||||
|
delete state.handledTaskResults[taskId];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchProjects() {
|
async function fetchProjects() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/projects", {
|
const response = await fetch("/api/projects", {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ defmodule BDS.Desktop.ShellCommandsTest do
|
|||||||
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
|
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
|
||||||
:ok = Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), Process.whereis(BDS.Preview))
|
:ok = Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), Process.whereis(BDS.Preview))
|
||||||
:ok = Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), Process.whereis(BDS.Publishing))
|
:ok = Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), Process.whereis(BDS.Publishing))
|
||||||
|
:ok = BDS.Tasks.clear_finished()
|
||||||
|
|
||||||
temp_dir =
|
temp_dir =
|
||||||
Path.join(System.tmp_dir!(), "bds-shell-commands-#{System.unique_integer([:positive])}")
|
Path.join(System.tmp_dir!(), "bds-shell-commands-#{System.unique_integer([:positive])}")
|
||||||
@@ -17,6 +18,7 @@ defmodule BDS.Desktop.ShellCommandsTest do
|
|||||||
on_exit(fn ->
|
on_exit(fn ->
|
||||||
File.rm_rf(temp_dir)
|
File.rm_rf(temp_dir)
|
||||||
_ = BDS.Preview.stop_preview("default")
|
_ = BDS.Preview.stop_preview("default")
|
||||||
|
_ = BDS.Tasks.clear_finished()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
{:ok, project} = BDS.Projects.create_project(%{name: "Shell Commands", data_path: temp_dir})
|
{:ok, project} = BDS.Projects.create_project(%{name: "Shell Commands", data_path: temp_dir})
|
||||||
@@ -53,11 +55,83 @@ defmodule BDS.Desktop.ShellCommandsTest do
|
|||||||
|
|
||||||
assert {:ok, result} = ShellCommands.execute("validate_translations")
|
assert {:ok, result} = ShellCommands.execute("validate_translations")
|
||||||
|
|
||||||
assert result.kind == "open_editor"
|
assert result.kind == "task_queued"
|
||||||
assert result.route == "translation_validation"
|
assert result.action == "validate_translations"
|
||||||
assert result.payload.summary.missing_count == 1
|
assert is_binary(result.task_id)
|
||||||
|
|
||||||
|
completed = wait_for_task(result.task_id, &(&1.status == :completed and is_map(&1.result)))
|
||||||
|
|
||||||
|
assert completed.group_name == "Validation"
|
||||||
|
assert completed.result.kind == "open_editor"
|
||||||
|
assert completed.result.route == "translation_validation"
|
||||||
|
assert completed.result.payload.summary.missing_count == 1
|
||||||
post_id = post.id
|
post_id = post.id
|
||||||
assert [%{"language" => "de", "post_id" => ^post_id}] = result.payload.missing
|
assert [%{"language" => "de", "post_id" => ^post_id}] = completed.result.payload.missing
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validate_site queues a tracked validation task and returns the report as an editor payload" do
|
||||||
|
assert {:ok, result} = ShellCommands.execute("validate_site")
|
||||||
|
|
||||||
|
assert result.kind == "task_queued"
|
||||||
|
assert result.action == "validate_site"
|
||||||
|
assert is_binary(result.task_id)
|
||||||
|
|
||||||
|
completed = wait_for_task(result.task_id, &(&1.status == :completed and is_map(&1.result)))
|
||||||
|
|
||||||
|
assert completed.group_name == "Validation"
|
||||||
|
assert completed.result.kind == "open_editor"
|
||||||
|
assert completed.result.route == "site_validation"
|
||||||
|
assert is_map(completed.result.payload.summary)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "metadata_diff queues a tracked maintenance task and returns the report as an editor payload" do
|
||||||
|
assert {:ok, result} = ShellCommands.execute("metadata_diff")
|
||||||
|
|
||||||
|
assert result.kind == "task_queued"
|
||||||
|
assert result.action == "metadata_diff"
|
||||||
|
assert is_binary(result.task_id)
|
||||||
|
|
||||||
|
completed = wait_for_task(result.task_id, &(&1.status == :completed and is_map(&1.result)))
|
||||||
|
|
||||||
|
assert completed.group_name == "Maintenance"
|
||||||
|
assert completed.result.kind == "open_editor"
|
||||||
|
assert completed.result.route == "metadata_diff"
|
||||||
|
assert is_map(completed.result.payload.summary)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "find_duplicates queues a tracked embeddings task and returns the report as an editor payload" do
|
||||||
|
assert {:ok, result} = ShellCommands.execute("find_duplicates")
|
||||||
|
|
||||||
|
assert result.kind == "task_queued"
|
||||||
|
assert result.action == "find_duplicates"
|
||||||
|
assert is_binary(result.task_id)
|
||||||
|
|
||||||
|
completed = wait_for_task(result.task_id, &(&1.status == :completed and is_map(&1.result)))
|
||||||
|
|
||||||
|
assert completed.group_name == "Embeddings"
|
||||||
|
assert completed.result.kind == "open_editor"
|
||||||
|
assert completed.result.route == "find_duplicates"
|
||||||
|
assert is_map(completed.result.payload.summary)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rebuild_database fans out tracked maintenance tasks for the active project" do
|
||||||
|
assert {:ok, result} = ShellCommands.execute("rebuild_database")
|
||||||
|
|
||||||
|
assert result.kind == "task_queued"
|
||||||
|
assert result.action == "rebuild_database"
|
||||||
|
|
||||||
|
tasks = wait_for_tasks_by_name([
|
||||||
|
"Rebuild Posts From Files",
|
||||||
|
"Rebuild Media From Files",
|
||||||
|
"Rebuild Scripts From Files",
|
||||||
|
"Rebuild Templates From Files",
|
||||||
|
"Rebuild Post Links",
|
||||||
|
"Regenerate Missing Thumbnails",
|
||||||
|
"Rebuild Embedding Index"
|
||||||
|
], &(&1.status == :completed))
|
||||||
|
|
||||||
|
assert Enum.all?(tasks, &(&1.group_name == "Maintenance"))
|
||||||
|
assert Enum.all?(tasks, &(&1.status == :completed))
|
||||||
end
|
end
|
||||||
|
|
||||||
test "reindex_text queues a tracked background task for the active project", %{project: project} do
|
test "reindex_text queues a tracked background task for the active project", %{project: project} do
|
||||||
@@ -67,10 +141,13 @@ defmodule BDS.Desktop.ShellCommandsTest do
|
|||||||
assert result.action == "reindex_text"
|
assert result.action == "reindex_text"
|
||||||
assert result.project_id == project.id
|
assert result.project_id == project.id
|
||||||
assert is_binary(result.task_id)
|
assert is_binary(result.task_id)
|
||||||
|
assert is_binary(result.task_group_id)
|
||||||
|
|
||||||
assert task = BDS.Tasks.get_task(result.task_id)
|
tasks = wait_for_tasks_by_name(["Reindex Search Text", "Reindex Media Search Text"], &(&1.status == :completed))
|
||||||
assert task.group_name == "Search"
|
|
||||||
assert wait_for_task(result.task_id, &(&1.status in [:completed, :failed])).status == :completed
|
assert Enum.all?(tasks, &(&1.group_name == "Search"))
|
||||||
|
assert Enum.all?(tasks, &(&1.group_id == result.task_group_id))
|
||||||
|
assert Enum.all?(tasks, &(&1.status == :completed))
|
||||||
end
|
end
|
||||||
|
|
||||||
test "missing project schema returns a command error instead of raising" do
|
test "missing project schema returns a command error instead of raising" do
|
||||||
@@ -96,4 +173,23 @@ defmodule BDS.Desktop.ShellCommandsTest do
|
|||||||
wait_for_task(task_id, matcher, timeout - 50)
|
wait_for_task(task_id, matcher, timeout - 50)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp wait_for_tasks_by_name(names, matcher), do: wait_for_tasks_by_name(names, matcher, 2_000)
|
||||||
|
|
||||||
|
defp wait_for_tasks_by_name(_names, _matcher, timeout) when timeout <= 0 do
|
||||||
|
BDS.Tasks.list_tasks()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp wait_for_tasks_by_name(names, matcher, timeout) do
|
||||||
|
tasks = BDS.Tasks.list_tasks()
|
||||||
|
matching_tasks = Enum.filter(tasks, &(&1.name in names))
|
||||||
|
|
||||||
|
if Enum.all?(names, fn name -> Enum.any?(matching_tasks, &(&1.name == name)) end) and
|
||||||
|
Enum.all?(matching_tasks, matcher) do
|
||||||
|
matching_tasks
|
||||||
|
else
|
||||||
|
Process.sleep(50)
|
||||||
|
wait_for_tasks_by_name(names, matcher, timeout - 50)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ defmodule BDS.TasksTest do
|
|||||||
setup do
|
setup do
|
||||||
original = Application.get_env(:bds, :tasks, [])
|
original = Application.get_env(:bds, :tasks, [])
|
||||||
Application.put_env(:bds, :tasks, max_concurrent: 3, progress_throttle_ms: 250)
|
Application.put_env(:bds, :tasks, max_concurrent: 3, progress_throttle_ms: 250)
|
||||||
|
:ok = BDS.Tasks.clear_finished()
|
||||||
|
|
||||||
on_exit(fn ->
|
on_exit(fn ->
|
||||||
Application.put_env(:bds, :tasks, original)
|
Application.put_env(:bds, :tasks, original)
|
||||||
|
_ = BDS.Tasks.clear_finished()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
@@ -148,6 +150,31 @@ defmodule BDS.TasksTest do
|
|||||||
assert second_id == second.id
|
assert second_id == second.id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "status_snapshot retains recently finished tasks for desktop shell completion state" do
|
||||||
|
assert {:ok, task} =
|
||||||
|
BDS.Tasks.submit_task(
|
||||||
|
"rebuild database",
|
||||||
|
fn report ->
|
||||||
|
report.(0.4, "rebuilding")
|
||||||
|
{:ok, %{counts: %{posts: 2}}}
|
||||||
|
end,
|
||||||
|
%{group_id: "maintenance", group_name: "Maintenance"}
|
||||||
|
)
|
||||||
|
|
||||||
|
completed = wait_for_task(task.id, &(&1.status == :completed and &1.result == %{counts: %{posts: 2}}))
|
||||||
|
|
||||||
|
snapshot = BDS.Tasks.status_snapshot()
|
||||||
|
|
||||||
|
assert snapshot.active_count == 0
|
||||||
|
assert snapshot.running_count == 0
|
||||||
|
assert snapshot.pending_count == 0
|
||||||
|
assert snapshot.running_task_message == nil
|
||||||
|
|
||||||
|
assert Enum.any?(snapshot.tasks, fn item ->
|
||||||
|
item.id == completed.id and item.status == :completed and item.result == %{counts: %{posts: 2}}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
defp receive_started do
|
defp receive_started do
|
||||||
receive do
|
receive do
|
||||||
{:started, name, pid} -> {name, pid}
|
{:started, name, pid} -> {name, pid}
|
||||||
|
|||||||
Reference in New Issue
Block a user