feat(Navigation, UserRights, Localization): add worker schedules feature and enhance access control
All checks were successful
Deploy to production / deploy (push) Successful in 1m52s

- Updated navigation structure to include a new section for worker schedules, accessible to specific user roles.
- Introduced a new user right type for 'worker_schedule_read' to manage access permissions effectively.
- Added localization entries for worker schedules in multiple languages, ensuring consistent user experience across the application.
- Created a new route and component for managing worker schedules in the admin panel.
This commit is contained in:
Torsten Schulz (local)
2026-05-08 08:54:17 +02:00
parent 0f7220d0b1
commit 008cd7ae86
21 changed files with 354 additions and 11 deletions

View File

@@ -295,7 +295,7 @@ const menuStructure = {
path: "/admin/interests" path: "/admin/interests"
}, },
falukant: { falukant: {
visible: ["mainadmin", "falukant"], visible: ["mainadmin", "falukant", "worker_schedule_read"],
children: { children: {
logentries: { logentries: {
visible: ["mainadmin", "falukant"], visible: ["mainadmin", "falukant"],
@@ -317,6 +317,10 @@ const menuStructure = {
visible: ["mainadmin", "falukant"], visible: ["mainadmin", "falukant"],
path: "/admin/falukant/create-npc" path: "/admin/falukant/create-npc"
}, },
workerSchedules: {
visible: ["mainadmin", "worker_schedule_read"],
path: "/admin/falukant/worker-schedules"
},
} }
}, },
minigames: { minigames: {

View File

@@ -0,0 +1,9 @@
-- Adds user right required for Falukant worker schedule read access.
INSERT INTO "type".user_right (title)
SELECT 'worker_schedule_read'
WHERE NOT EXISTS (
SELECT 1
FROM "type".user_right
WHERE title = 'worker_schedule_read'
);

View File

@@ -38,6 +38,10 @@ const initializeUserRights = async() => {
where: { title: "match3"}, where: { title: "match3"},
defaults: { title: "match3"} defaults: { title: "match3"}
}); });
await UserRightType.findOrCreate({
where: { title: "worker_schedule_read"},
defaults: { title: "worker_schedule_read"}
});
}; };
export default initializeUserRights; export default initializeUserRights;

View File

@@ -80,6 +80,7 @@ const TITLE_MAP = {
AdminFalukantEditUserView: 'sectionBar.titles.adminFalukantUsers', AdminFalukantEditUserView: 'sectionBar.titles.adminFalukantUsers',
AdminFalukantMapRegionsView: 'sectionBar.titles.adminFalukantMap', AdminFalukantMapRegionsView: 'sectionBar.titles.adminFalukantMap',
AdminFalukantCreateNPCView: 'sectionBar.titles.adminCreateNpc', AdminFalukantCreateNPCView: 'sectionBar.titles.adminCreateNpc',
AdminFalukantWorkerSchedulesView: 'sectionBar.titles.adminWorkerSchedules',
AdminMinigames: 'sectionBar.titles.adminMinigames', AdminMinigames: 'sectionBar.titles.adminMinigames',
AdminTaxiTools: 'sectionBar.titles.adminTaxiTools', AdminTaxiTools: 'sectionBar.titles.adminTaxiTools',
AdminServicesStatus: 'sectionBar.titles.adminServicesStatus' AdminServicesStatus: 'sectionBar.titles.adminServicesStatus'

View File

@@ -329,6 +329,25 @@
"timeRemainingMinutes": "Nahibilin nga oras: {minutes} ka minuto {seconds} ka segundo", "timeRemainingMinutes": "Nahibilin nga oras: {minutes} ka minuto {seconds} ka segundo",
"almostDone": "Hapit na …", "almostDone": "Hapit na …",
"jobNotFound": "Wala makit-i ang trabaho o na-expire na." "jobNotFound": "Wala makit-i ang trabaho o na-expire na."
},
"workerSchedules": {
"title": "Mga dagan sa daemon",
"description": "Kinatibuk-ang tan-aw sa naka-iskedyul nga dagan sa worker gikan sa daemon registry.",
"accessDenied": "Walay access ani nga view.",
"detailed": "Detalyadong tan-aw",
"autoRefresh": "Auto-refresh",
"refresh": "I-refresh",
"generatedAt": "Nabuhat sa",
"empty": "Walay schedules nga anaa.",
"notConnected": "Walay daemon connection.",
"sendError": "Napakyas ang request sa daemon.",
"responseError": "Dili maproseso ang tubag sa daemon.",
"currentStep": "Karon nga lakang",
"task": "Task",
"cadence": "Interbal",
"nextRun": "Pinakataas nga sunod nga dagan",
"remaining": "Nahibilin",
"noTasks": "Walay tasks para ani nga worker."
} }
}, },
"chatrooms": { "chatrooms": {

View File

@@ -187,7 +187,8 @@
"adminCreateNpc": "Paghimo og NPC", "adminCreateNpc": "Paghimo og NPC",
"adminMinigames": "Pagdumala sa Match3", "adminMinigames": "Pagdumala sa Match3",
"adminTaxiTools": "Mga himan sa taxi", "adminTaxiTools": "Mga himan sa taxi",
"adminServicesStatus": "Status sa serbisyo" "adminServicesStatus": "Status sa serbisyo",
"adminWorkerSchedules": "Mga dagan sa daemon"
} }
} }
} }

View File

@@ -85,7 +85,8 @@
"edituser": "Usba ang user", "edituser": "Usba ang user",
"database": "Database", "database": "Database",
"mapEditor": "Editor sa mapa", "mapEditor": "Editor sa mapa",
"createNPC": "Paghimo og NPC" "createNPC": "Paghimo og NPC",
"workerSchedules": "Mga dagan sa daemon"
}, },
"minigames": "Mga minidula", "minigames": "Mga minidula",
"m-minigames": { "m-minigames": {

View File

@@ -322,6 +322,25 @@
"timeRemainingMinutes": "Verbleibende Zeit: {minutes} Minuten {seconds} Sekunden", "timeRemainingMinutes": "Verbleibende Zeit: {minutes} Minuten {seconds} Sekunden",
"almostDone": "Fast fertig...", "almostDone": "Fast fertig...",
"jobNotFound": "Job nicht gefunden oder abgelaufen." "jobNotFound": "Job nicht gefunden oder abgelaufen."
},
"workerSchedules": {
"title": "Daemon-Läufe",
"description": "Übersicht geplanter Worker-Läufe aus der Daemon-Registry.",
"accessDenied": "Kein Zugriff auf diese Ansicht.",
"detailed": "Detaillierte Ansicht",
"autoRefresh": "Auto-Refresh",
"refresh": "Aktualisieren",
"generatedAt": "Erzeugt am",
"empty": "Keine Schedules vorhanden.",
"notConnected": "Keine Daemon-Verbindung verfügbar.",
"sendError": "Anfrage an den Daemon fehlgeschlagen.",
"responseError": "Antwort konnte nicht verarbeitet werden.",
"currentStep": "Aktueller Schritt",
"task": "Task",
"cadence": "Intervall",
"nextRun": "Spätester nächster Lauf",
"remaining": "Verbleibend",
"noTasks": "Keine Tasks für diesen Worker."
} }
}, },
"chatrooms": { "chatrooms": {

View File

@@ -187,7 +187,8 @@
"adminCreateNpc": "NPC erstellen", "adminCreateNpc": "NPC erstellen",
"adminMinigames": "Match3-Verwaltung", "adminMinigames": "Match3-Verwaltung",
"adminTaxiTools": "Taxi-Tools", "adminTaxiTools": "Taxi-Tools",
"adminServicesStatus": "Service-Status" "adminServicesStatus": "Service-Status",
"adminWorkerSchedules": "Daemon-Laeufe"
} }
} }
} }

View File

@@ -85,7 +85,8 @@
"edituser": "Benutzer bearbeiten", "edituser": "Benutzer bearbeiten",
"database": "Datenbank", "database": "Datenbank",
"mapEditor": "Karteneditor", "mapEditor": "Karteneditor",
"createNPC": "NPCs erstellen" "createNPC": "NPCs erstellen",
"workerSchedules": "Daemon-Läufe"
}, },
"minigames": "Minispiele", "minigames": "Minispiele",
"m-minigames": { "m-minigames": {

View File

@@ -377,6 +377,25 @@
"timeRemainingMinutes": "Time remaining: {minutes} minutes {seconds} seconds", "timeRemainingMinutes": "Time remaining: {minutes} minutes {seconds} seconds",
"almostDone": "Almost done...", "almostDone": "Almost done...",
"jobNotFound": "Job not found or expired." "jobNotFound": "Job not found or expired."
},
"workerSchedules": {
"title": "Daemon runs",
"description": "Overview of scheduled worker runs from the daemon registry.",
"accessDenied": "No access to this view.",
"detailed": "Detailed view",
"autoRefresh": "Auto refresh",
"refresh": "Refresh",
"generatedAt": "Generated at",
"empty": "No schedules available.",
"notConnected": "No daemon connection available.",
"sendError": "Request to daemon failed.",
"responseError": "Could not process daemon response.",
"currentStep": "Current step",
"task": "Task",
"cadence": "Cadence",
"nextRun": "Latest next run",
"remaining": "Remaining",
"noTasks": "No tasks for this worker."
} }
}, },
"chatrooms": { "chatrooms": {

View File

@@ -187,7 +187,8 @@
"adminCreateNpc": "Create NPC", "adminCreateNpc": "Create NPC",
"adminMinigames": "Match3 administration", "adminMinigames": "Match3 administration",
"adminTaxiTools": "Taxi tools", "adminTaxiTools": "Taxi tools",
"adminServicesStatus": "Service status" "adminServicesStatus": "Service status",
"adminWorkerSchedules": "Daemon runs"
} }
} }
} }

View File

@@ -85,7 +85,8 @@
"edituser": "Edit user", "edituser": "Edit user",
"database": "Database", "database": "Database",
"mapEditor": "Map editor", "mapEditor": "Map editor",
"createNPC": "Create NPCs" "createNPC": "Create NPCs",
"workerSchedules": "Daemon runs"
}, },
"minigames": "Mini games", "minigames": "Mini games",
"m-minigames": { "m-minigames": {

View File

@@ -302,6 +302,25 @@
"timeRemainingMinutes": "Tiempo restante: {minutes} minutos {seconds} segundos", "timeRemainingMinutes": "Tiempo restante: {minutes} minutos {seconds} segundos",
"almostDone": "Casi listo...", "almostDone": "Casi listo...",
"jobNotFound": "Trabajo no encontrado o caducado." "jobNotFound": "Trabajo no encontrado o caducado."
},
"workerSchedules": {
"title": "Ejecuciones del daemon",
"description": "Resumen de ejecuciones planificadas de workers desde el registro del daemon.",
"accessDenied": "No tienes acceso a esta vista.",
"detailed": "Vista detallada",
"autoRefresh": "Actualización automática",
"refresh": "Actualizar",
"generatedAt": "Generado el",
"empty": "No hay schedules disponibles.",
"notConnected": "No hay conexión con el daemon.",
"sendError": "Falló la solicitud al daemon.",
"responseError": "No se pudo procesar la respuesta del daemon.",
"currentStep": "Paso actual",
"task": "Tarea",
"cadence": "Intervalo",
"nextRun": "Próxima ejecución máxima",
"remaining": "Restante",
"noTasks": "No hay tareas para este worker."
} }
}, },
"chatrooms": { "chatrooms": {

View File

@@ -187,7 +187,8 @@
"adminCreateNpc": "Crear NPC", "adminCreateNpc": "Crear NPC",
"adminMinigames": "Administración de Match3", "adminMinigames": "Administración de Match3",
"adminTaxiTools": "Herramientas de taxi", "adminTaxiTools": "Herramientas de taxi",
"adminServicesStatus": "Estado del servicio" "adminServicesStatus": "Estado del servicio",
"adminWorkerSchedules": "Ejecuciones del daemon"
} }
} }
} }

View File

@@ -85,7 +85,8 @@
"edituser": "Editar usuario", "edituser": "Editar usuario",
"database": "Datenbank", "database": "Datenbank",
"mapEditor": "Editor de mapas", "mapEditor": "Editor de mapas",
"createNPC": "Crear NPCs" "createNPC": "Crear NPCs",
"workerSchedules": "Ejecuciones del daemon"
}, },
"minigames": "Minispiele", "minigames": "Minispiele",
"m-minigames": { "m-minigames": {

View File

@@ -302,6 +302,25 @@
"timeRemainingMinutes": "Temps restant : {minutes} minutes {secondes} secondes", "timeRemainingMinutes": "Temps restant : {minutes} minutes {secondes} secondes",
"almostDone": "Presque fini...", "almostDone": "Presque fini...",
"jobNotFound": "Emploi introuvable ou expiré." "jobNotFound": "Emploi introuvable ou expiré."
},
"workerSchedules": {
"title": "Exécutions du daemon",
"description": "Vue d'ensemble des exécutions planifiées des workers depuis le registre du daemon.",
"accessDenied": "Aucun accès à cette vue.",
"detailed": "Vue détaillée",
"autoRefresh": "Rafraîchissement automatique",
"refresh": "Rafraîchir",
"generatedAt": "Généré à",
"empty": "Aucun schedule disponible.",
"notConnected": "Aucune connexion daemon disponible.",
"sendError": "Échec de la requête vers le daemon.",
"responseError": "Impossible de traiter la réponse du daemon.",
"currentStep": "Étape actuelle",
"task": "Tâche",
"cadence": "Intervalle",
"nextRun": "Prochaine exécution au plus tard",
"remaining": "Restant",
"noTasks": "Aucune tâche pour ce worker."
} }
}, },
"chatrooms": { "chatrooms": {

View File

@@ -187,7 +187,8 @@
"adminCreateNpc": "Créer un PNJ", "adminCreateNpc": "Créer un PNJ",
"adminMinigames": "Gestion des Match3", "adminMinigames": "Gestion des Match3",
"adminTaxiTools": "Outils de taxi", "adminTaxiTools": "Outils de taxi",
"adminServicesStatus": "Statut des services" "adminServicesStatus": "Statut des services",
"adminWorkerSchedules": "Executions du daemon"
} }
} }
} }

View File

@@ -85,7 +85,8 @@
"edituser": "Modifier l'utilisateur", "edituser": "Modifier l'utilisateur",
"database": "base de données", "database": "base de données",
"mapEditor": "Editeur de cartes", "mapEditor": "Editeur de cartes",
"createNPC": "NPCs erstellen" "createNPC": "NPCs erstellen",
"workerSchedules": "Exécutions du daemon"
}, },
"minigames": "Minispiele", "minigames": "Minispiele",
"m-minigames": { "m-minigames": {

View File

@@ -6,6 +6,7 @@ const ForumAdminView = () => import('../dialogues/admin/ForumAdminView.vue');
const AdminFalukantEditUserView = () => import('../views/admin/falukant/EditUserView.vue'); const AdminFalukantEditUserView = () => import('../views/admin/falukant/EditUserView.vue');
const AdminFalukantMapRegionsView = () => import('../views/admin/falukant/MapRegionsView.vue'); const AdminFalukantMapRegionsView = () => import('../views/admin/falukant/MapRegionsView.vue');
const AdminFalukantCreateNPCView = () => import('../views/admin/falukant/CreateNPCView.vue'); const AdminFalukantCreateNPCView = () => import('../views/admin/falukant/CreateNPCView.vue');
const AdminFalukantWorkerSchedulesView = () => import('../views/admin/falukant/WorkerSchedulesView.vue');
const AdminMinigamesView = () => import('../views/admin/MinigamesView.vue'); const AdminMinigamesView = () => import('../views/admin/MinigamesView.vue');
const AdminTaxiToolsView = () => import('../views/admin/TaxiToolsView.vue'); const AdminTaxiToolsView = () => import('../views/admin/TaxiToolsView.vue');
const AdminUsersView = () => import('../views/admin/UsersView.vue'); const AdminUsersView = () => import('../views/admin/UsersView.vue');
@@ -94,6 +95,12 @@ const adminRoutes = [
component: AdminFalukantCreateNPCView, component: AdminFalukantCreateNPCView,
meta: { requiresAuth: true } meta: { requiresAuth: true }
}, },
{
path: '/admin/falukant/worker-schedules',
name: 'AdminFalukantWorkerSchedulesView',
component: AdminFalukantWorkerSchedulesView,
meta: { requiresAuth: true }
},
{ {
path: '/admin/minigames/match3', path: '/admin/minigames/match3',
name: 'AdminMinigames', name: 'AdminMinigames',

View File

@@ -0,0 +1,213 @@
<template>
<div class="worker-schedules-view">
<div class="admin-header">
<h1>{{ $t('admin.falukant.workerSchedules.title') }}</h1>
<p>{{ $t('admin.falukant.workerSchedules.description') }}</p>
</div>
<div v-if="!hasAccess" class="access-denied">
{{ $t('admin.falukant.workerSchedules.accessDenied') }}
</div>
<template v-else>
<div class="toolbar">
<label class="toggle">
<input v-model="detailed" type="checkbox" />
<span>{{ $t('admin.falukant.workerSchedules.detailed') }}</span>
</label>
<label class="toggle">
<input v-model="autoRefresh" type="checkbox" />
<span>{{ $t('admin.falukant.workerSchedules.autoRefresh') }}</span>
</label>
<button type="button" @click="requestSchedules">
{{ $t('admin.falukant.workerSchedules.refresh') }}
</button>
</div>
<p v-if="generatedAt" class="generated-at">
{{ $t('admin.falukant.workerSchedules.generatedAt') }}: {{ formatTs(generatedAt) }}
</p>
<div v-if="error" class="error">{{ error }}</div>
<div v-else-if="loading" class="loading">{{ $t('general.loading') }}</div>
<p v-else-if="!schedules.length" class="empty">{{ $t('admin.falukant.workerSchedules.empty') }}</p>
<div v-else class="workers">
<article v-for="worker in schedules" :key="worker.worker" class="worker-card">
<header class="worker-card__header">
<h2>{{ worker.worker }}</h2>
<div v-if="detailed" class="runtime">
<span :class="['badge', worker.running_worker ? 'ok' : 'off']">
worker: {{ worker.running_worker ? 'on' : 'off' }}
</span>
<span :class="['badge', worker.running_watchdog ? 'ok' : 'off']">
watchdog: {{ worker.running_watchdog ? 'on' : 'off' }}
</span>
</div>
</header>
<p v-if="detailed && worker.current_step" class="step">
{{ $t('admin.falukant.workerSchedules.currentStep') }}: {{ worker.current_step }}
<span v-if="worker.last_step_change_ts">
({{ formatTs(worker.last_step_change_ts) }})
</span>
</p>
<table v-if="Array.isArray(worker.tasks) && worker.tasks.length" class="tasks">
<thead>
<tr>
<th>{{ $t('admin.falukant.workerSchedules.task') }}</th>
<th>{{ $t('admin.falukant.workerSchedules.cadence') }}</th>
<th>{{ $t('admin.falukant.workerSchedules.nextRun') }}</th>
<th>{{ $t('admin.falukant.workerSchedules.remaining') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="task in worker.tasks" :key="`${worker.worker}-${task.task}`">
<td>{{ task.task }}</td>
<td>{{ task.cadence_label || `${task.cadence_seconds}s` }}</td>
<td>{{ task.next_run_latest_ts ? formatTs(task.next_run_latest_ts) : '—' }}</td>
<td>{{ formatRemaining(task.next_run_latest_in_seconds) }}</td>
</tr>
</tbody>
</table>
<p v-else class="empty-tasks">{{ $t('admin.falukant.workerSchedules.noTasks') }}</p>
</article>
</div>
</template>
</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
name: 'AdminFalukantWorkerSchedulesView',
data() {
return {
detailed: false,
autoRefresh: true,
schedules: [],
generatedAt: null,
loading: false,
error: null,
timer: null,
};
},
computed: {
...mapState(['daemonSocket', 'daemonConnectionStatus', 'menu']),
hasAccess() {
const path = '/admin/falukant/worker-schedules';
const walk = (node) => {
if (!node || typeof node !== 'object') return false;
if (Array.isArray(node)) return node.some(walk);
if (node.path === path) return true;
if (node.children) return walk(node.children);
return Object.values(node).some(walk);
};
return walk(this.menu);
},
daemonConnected() {
return this.daemonSocket && this.daemonConnectionStatus === 'connected' && this.daemonSocket.readyState === WebSocket.OPEN;
},
},
watch: {
autoRefresh: {
immediate: true,
handler() {
this.setupTimer();
},
},
detailed() {
this.requestSchedules();
},
daemonSocket(newSocket, oldSocket) {
if (oldSocket) oldSocket.removeEventListener('message', this.handleDaemonMessage);
if (newSocket) newSocket.addEventListener('message', this.handleDaemonMessage);
},
daemonConnectionStatus(status) {
if (status === 'connected') this.requestSchedules();
},
},
mounted() {
if (this.daemonSocket) this.daemonSocket.addEventListener('message', this.handleDaemonMessage);
this.requestSchedules();
},
beforeUnmount() {
if (this.daemonSocket) this.daemonSocket.removeEventListener('message', this.handleDaemonMessage);
if (this.timer) clearInterval(this.timer);
},
methods: {
setupTimer() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
if (!this.autoRefresh) return;
this.timer = setInterval(() => this.requestSchedules(), 15000);
},
requestSchedules() {
if (!this.hasAccess) return;
if (!this.daemonConnected) {
this.error = this.$t('admin.falukant.workerSchedules.notConnected');
return;
}
this.loading = true;
this.error = null;
const event = this.detailed ? 'getWorkerSchedulesDetailed' : 'getWorkerSchedules';
try {
this.daemonSocket.send(JSON.stringify({ event, data: {} }));
} catch (err) {
this.loading = false;
this.error = this.$t('admin.falukant.workerSchedules.sendError');
}
},
handleDaemonMessage(evt) {
try {
const payload = JSON.parse(evt.data);
if (payload.event !== 'getWorkerSchedulesResponse' && payload.event !== 'getWorkerSchedulesDetailedResponse') return;
this.loading = false;
if (payload.ok === false) {
this.error = payload.error || this.$t('admin.falukant.workerSchedules.responseError');
return;
}
this.generatedAt = payload.generated_at || null;
this.schedules = Array.isArray(payload.schedules) ? payload.schedules : [];
this.error = null;
} catch (_) {
// ignore unrelated messages
}
},
formatTs(unixTs) {
if (!unixTs) return '—';
return new Date(Number(unixTs) * 1000).toLocaleString();
},
formatRemaining(seconds) {
if (seconds == null) return '—';
const s = Number(seconds);
if (!Number.isFinite(s)) return '—';
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`;
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
},
},
};
</script>
<style scoped>
.worker-schedules-view { padding: 16px; }
.toolbar { display: flex; gap: 12px; align-items: center; margin: 12px 0; flex-wrap: wrap; }
.toggle { display: inline-flex; gap: 6px; align-items: center; }
.generated-at { color: #666; margin-bottom: 8px; }
.workers { display: grid; gap: 12px; }
.worker-card { border: 1px solid #ddd; border-radius: 8px; padding: 12px; background: #fff; }
.worker-card__header { display: flex; justify-content: space-between; gap: 12px; align-items: center; flex-wrap: wrap; }
.runtime { display: inline-flex; gap: 8px; }
.badge { font-size: .75rem; padding: 3px 8px; border-radius: 999px; }
.badge.ok { background: #e7f7ea; color: #216b2f; }
.badge.off { background: #f8eceb; color: #8d2f2a; }
.tasks { width: 100%; border-collapse: collapse; margin-top: 8px; }
.tasks th, .tasks td { border: 1px solid #e5e5e5; padding: 6px 8px; text-align: left; }
.empty, .empty-tasks { color: #666; }
.error { color: #a1261c; background: #ffecea; border: 1px solid #f0b6b0; padding: 8px; border-radius: 6px; }
.access-denied { color: #a1261c; font-weight: 600; }
</style>