feat(Navigation, UserRights, Localization): add worker schedules feature and enhance access control
All checks were successful
Deploy to production / deploy (push) Successful in 1m52s
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:
213
frontend/src/views/admin/falukant/WorkerSchedulesView.vue
Normal file
213
frontend/src/views/admin/falukant/WorkerSchedulesView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user