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

@@ -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>