230 lines
8.5 KiB
Vue
Executable File
230 lines
8.5 KiB
Vue
Executable File
<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', 'user']),
|
|
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: {
|
|
getDaemonUserId() {
|
|
return this.user?.hashedId || this.user?.id || this.user?.username || '';
|
|
},
|
|
sendSetUserId() {
|
|
const userId = this.getDaemonUserId();
|
|
if (!userId) return false;
|
|
this.daemonSocket.send(JSON.stringify({
|
|
event: 'setUserId',
|
|
data: { userId },
|
|
}));
|
|
return true;
|
|
},
|
|
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;
|
|
}
|
|
if (!this.sendSetUserId()) {
|
|
this.error = this.$t('admin.falukant.workerSchedules.missingUserId');
|
|
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>
|