Files
indiekit-endpoint-activitypub/views/activitypub-dashboard.njk
Ricardo 84122cc470 feat: batch re-follow system for imported AP accounts
After Mastodon migration, imported accounts exist only locally — no
Follow activities were sent. This adds a gradual background processor
that sends Follow activities to all source:"import" accounts so remote
servers start delivering Create activities to our inbox.

- New batch engine (lib/batch-refollow.js) processes 10 accounts per
  batch with 3s between follows and 30s between batches
- Accept(Follow) inbox listener transitions source to "federation"
  and cleans up tracking fields
- Admin API: pause, resume, and status JSON endpoints
- Dashboard progress bar with Alpine.js polling (10s interval)
- Following list badges for refollow:sent and refollow:failed states
- Restart recovery resets stale refollow:pending back to import
- 3 retries with 1-hour cooldown before permanent failure
2026-02-20 08:10:45 +01:00

146 lines
6.2 KiB
Plaintext

{% extends "document.njk" %}
{% from "heading/macro.njk" import heading with context %}
{% from "card/macro.njk" import card with context %}
{% from "card-grid/macro.njk" import cardGrid with context %}
{% from "prose/macro.njk" import prose with context %}
{% from "badge/macro.njk" import badge with context %}
{% block content %}
{{ heading({ text: title, level: 1 }) }}
{{ cardGrid({ cardSize: "16rem", items: [
{
title: followerCount + " " + __("activitypub.followers"),
url: mountPath + "/admin/followers"
},
{
title: followingCount + " " + __("activitypub.following"),
url: mountPath + "/admin/following"
},
{
title: __("activitypub.activities"),
url: mountPath + "/admin/activities"
},
{
title: __("activitypub.profile.title"),
url: mountPath + "/admin/profile"
},
{
title: __("activitypub.migrate.title"),
url: mountPath + "/admin/migrate"
}
]}) }}
{% if refollowStatus and refollowStatus.status !== "idle" %}
<section x-data="refollowProgress('{{ mountPath }}')" class="s-refollow" style="margin-block-end: var(--space-l);">
{{ heading({ text: __("activitypub.refollow.title"), level: 2 }) }}
{# Progress bar #}
<div style="background: var(--color-offset); border-radius: 4px; height: 1.5rem; margin-block-end: var(--space-m); overflow: hidden;">
<div
x-bind:style="'width:' + progress + '%; background: var(--color-accent); height: 100%; transition: width 0.5s ease;'"
style="width: {{ refollowStatus.progressPercent }}%; background: var(--color-accent); height: 100%; transition: width 0.5s ease;">
</div>
</div>
{# Stats grid #}
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: var(--space-s); margin-block-end: var(--space-m);">
<div style="padding: var(--space-s); background: var(--color-offset); border-radius: 4px; text-align: center;">
<div style="font-size: var(--font-size-xl);" x-text="remaining">{{ refollowStatus.remaining }}</div>
<div style="font-size: var(--font-size-s); color: var(--color-text-offset);">{{ __("activitypub.refollow.remaining") }}</div>
</div>
<div style="padding: var(--space-s); background: var(--color-offset); border-radius: 4px; text-align: center;">
<div style="font-size: var(--font-size-xl);" x-text="sent">{{ refollowStatus.sent }}</div>
<div style="font-size: var(--font-size-s); color: var(--color-text-offset);">{{ __("activitypub.refollow.awaitingAccept") }}</div>
</div>
<div style="padding: var(--space-s); background: var(--color-offset); border-radius: 4px; text-align: center;">
<div style="font-size: var(--font-size-xl);" x-text="federated">{{ refollowStatus.federated }}</div>
<div style="font-size: var(--font-size-s); color: var(--color-text-offset);">{{ __("activitypub.refollow.accepted") }}</div>
</div>
<div style="padding: var(--space-s); background: var(--color-offset); border-radius: 4px; text-align: center;">
<div style="font-size: var(--font-size-xl);" x-text="failed">{{ refollowStatus.failed }}</div>
<div style="font-size: var(--font-size-s); color: var(--color-text-offset);">{{ __("activitypub.refollow.failed") }}</div>
</div>
</div>
{# Status + controls #}
<div style="display: flex; align-items: center; gap: var(--space-s);">
{{ badge({ text: __("activitypub.refollow.status." + refollowStatus.status) }) }}
{% if refollowStatus.status === "running" %}
<form method="post" action="{{ mountPath }}/admin/refollow/pause" x-on:submit.prevent="pause">
<button type="submit" class="button" style="font-size: var(--font-size-s);">{{ __("activitypub.refollow.pause") }}</button>
</form>
{% elif refollowStatus.status === "paused" %}
<form method="post" action="{{ mountPath }}/admin/refollow/resume" x-on:submit.prevent="resume">
<button type="submit" class="button" style="font-size: var(--font-size-s);">{{ __("activitypub.refollow.resume") }}</button>
</form>
{% endif %}
</div>
</section>
<script>
function refollowProgress(mountPath) {
return {
progress: {{ refollowStatus.progressPercent }},
remaining: {{ refollowStatus.remaining }},
sent: {{ refollowStatus.sent }},
federated: {{ refollowStatus.federated }},
failed: {{ refollowStatus.failed }},
status: '{{ refollowStatus.status }}',
interval: null,
init() {
if (this.status === 'running' || this.status === 'paused') {
this.interval = setInterval(() => this.poll(), 10000);
}
},
destroy() {
if (this.interval) clearInterval(this.interval);
},
async poll() {
try {
const res = await fetch(mountPath + '/admin/refollow/status');
const data = await res.json();
this.progress = data.progressPercent;
this.remaining = data.remaining;
this.sent = data.sent;
this.federated = data.federated;
this.failed = data.failed;
this.status = data.status;
if (data.status === 'completed' || data.status === 'idle') {
clearInterval(this.interval);
}
} catch {}
},
async pause() {
await fetch(mountPath + '/admin/refollow/pause', { method: 'POST' });
this.status = 'paused';
},
async resume() {
await fetch(mountPath + '/admin/refollow/resume', { method: 'POST' });
this.status = 'running';
if (!this.interval) {
this.interval = setInterval(() => this.poll(), 10000);
}
}
};
}
</script>
{% endif %}
{{ heading({ text: __("activitypub.recentActivity"), level: 2 }) }}
{% if recentActivities.length > 0 %}
{% for activity in recentActivities %}
{{ card({
title: activity.actorName or activity.actorUrl,
description: { text: activity.summary },
published: activity.receivedAt,
badges: [{ text: activity.type }]
}) }}
{% endfor %}
{% else %}
{{ prose({ text: __("activitypub.noActivity") }) }}
{% endif %}
{% endblock %}