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:
@@ -295,7 +295,7 @@ const menuStructure = {
|
||||
path: "/admin/interests"
|
||||
},
|
||||
falukant: {
|
||||
visible: ["mainadmin", "falukant"],
|
||||
visible: ["mainadmin", "falukant", "worker_schedule_read"],
|
||||
children: {
|
||||
logentries: {
|
||||
visible: ["mainadmin", "falukant"],
|
||||
@@ -317,6 +317,10 @@ const menuStructure = {
|
||||
visible: ["mainadmin", "falukant"],
|
||||
path: "/admin/falukant/create-npc"
|
||||
},
|
||||
workerSchedules: {
|
||||
visible: ["mainadmin", "worker_schedule_read"],
|
||||
path: "/admin/falukant/worker-schedules"
|
||||
},
|
||||
}
|
||||
},
|
||||
minigames: {
|
||||
|
||||
9
backend/sql/add_worker_schedule_read_right.sql
Normal file
9
backend/sql/add_worker_schedule_read_right.sql
Normal 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'
|
||||
);
|
||||
@@ -38,6 +38,10 @@ const initializeUserRights = async() => {
|
||||
where: { title: "match3"},
|
||||
defaults: { title: "match3"}
|
||||
});
|
||||
await UserRightType.findOrCreate({
|
||||
where: { title: "worker_schedule_read"},
|
||||
defaults: { title: "worker_schedule_read"}
|
||||
});
|
||||
};
|
||||
|
||||
export default initializeUserRights;
|
||||
|
||||
@@ -80,6 +80,7 @@ const TITLE_MAP = {
|
||||
AdminFalukantEditUserView: 'sectionBar.titles.adminFalukantUsers',
|
||||
AdminFalukantMapRegionsView: 'sectionBar.titles.adminFalukantMap',
|
||||
AdminFalukantCreateNPCView: 'sectionBar.titles.adminCreateNpc',
|
||||
AdminFalukantWorkerSchedulesView: 'sectionBar.titles.adminWorkerSchedules',
|
||||
AdminMinigames: 'sectionBar.titles.adminMinigames',
|
||||
AdminTaxiTools: 'sectionBar.titles.adminTaxiTools',
|
||||
AdminServicesStatus: 'sectionBar.titles.adminServicesStatus'
|
||||
|
||||
@@ -329,6 +329,25 @@
|
||||
"timeRemainingMinutes": "Nahibilin nga oras: {minutes} ka minuto {seconds} ka segundo",
|
||||
"almostDone": "Hapit 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": {
|
||||
|
||||
@@ -187,7 +187,8 @@
|
||||
"adminCreateNpc": "Paghimo og NPC",
|
||||
"adminMinigames": "Pagdumala sa Match3",
|
||||
"adminTaxiTools": "Mga himan sa taxi",
|
||||
"adminServicesStatus": "Status sa serbisyo"
|
||||
"adminServicesStatus": "Status sa serbisyo",
|
||||
"adminWorkerSchedules": "Mga dagan sa daemon"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,8 @@
|
||||
"edituser": "Usba ang user",
|
||||
"database": "Database",
|
||||
"mapEditor": "Editor sa mapa",
|
||||
"createNPC": "Paghimo og NPC"
|
||||
"createNPC": "Paghimo og NPC",
|
||||
"workerSchedules": "Mga dagan sa daemon"
|
||||
},
|
||||
"minigames": "Mga minidula",
|
||||
"m-minigames": {
|
||||
|
||||
@@ -322,6 +322,25 @@
|
||||
"timeRemainingMinutes": "Verbleibende Zeit: {minutes} Minuten {seconds} Sekunden",
|
||||
"almostDone": "Fast fertig...",
|
||||
"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": {
|
||||
|
||||
@@ -187,7 +187,8 @@
|
||||
"adminCreateNpc": "NPC erstellen",
|
||||
"adminMinigames": "Match3-Verwaltung",
|
||||
"adminTaxiTools": "Taxi-Tools",
|
||||
"adminServicesStatus": "Service-Status"
|
||||
"adminServicesStatus": "Service-Status",
|
||||
"adminWorkerSchedules": "Daemon-Laeufe"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,8 @@
|
||||
"edituser": "Benutzer bearbeiten",
|
||||
"database": "Datenbank",
|
||||
"mapEditor": "Karteneditor",
|
||||
"createNPC": "NPCs erstellen"
|
||||
"createNPC": "NPCs erstellen",
|
||||
"workerSchedules": "Daemon-Läufe"
|
||||
},
|
||||
"minigames": "Minispiele",
|
||||
"m-minigames": {
|
||||
|
||||
@@ -377,6 +377,25 @@
|
||||
"timeRemainingMinutes": "Time remaining: {minutes} minutes {seconds} seconds",
|
||||
"almostDone": "Almost done...",
|
||||
"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": {
|
||||
|
||||
@@ -187,7 +187,8 @@
|
||||
"adminCreateNpc": "Create NPC",
|
||||
"adminMinigames": "Match3 administration",
|
||||
"adminTaxiTools": "Taxi tools",
|
||||
"adminServicesStatus": "Service status"
|
||||
"adminServicesStatus": "Service status",
|
||||
"adminWorkerSchedules": "Daemon runs"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,8 @@
|
||||
"edituser": "Edit user",
|
||||
"database": "Database",
|
||||
"mapEditor": "Map editor",
|
||||
"createNPC": "Create NPCs"
|
||||
"createNPC": "Create NPCs",
|
||||
"workerSchedules": "Daemon runs"
|
||||
},
|
||||
"minigames": "Mini games",
|
||||
"m-minigames": {
|
||||
|
||||
@@ -302,6 +302,25 @@
|
||||
"timeRemainingMinutes": "Tiempo restante: {minutes} minutos {seconds} segundos",
|
||||
"almostDone": "Casi listo...",
|
||||
"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": {
|
||||
|
||||
@@ -187,7 +187,8 @@
|
||||
"adminCreateNpc": "Crear NPC",
|
||||
"adminMinigames": "Administración de Match3",
|
||||
"adminTaxiTools": "Herramientas de taxi",
|
||||
"adminServicesStatus": "Estado del servicio"
|
||||
"adminServicesStatus": "Estado del servicio",
|
||||
"adminWorkerSchedules": "Ejecuciones del daemon"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,8 @@
|
||||
"edituser": "Editar usuario",
|
||||
"database": "Datenbank",
|
||||
"mapEditor": "Editor de mapas",
|
||||
"createNPC": "Crear NPCs"
|
||||
"createNPC": "Crear NPCs",
|
||||
"workerSchedules": "Ejecuciones del daemon"
|
||||
},
|
||||
"minigames": "Minispiele",
|
||||
"m-minigames": {
|
||||
|
||||
@@ -302,6 +302,25 @@
|
||||
"timeRemainingMinutes": "Temps restant : {minutes} minutes {secondes} secondes",
|
||||
"almostDone": "Presque fini...",
|
||||
"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": {
|
||||
|
||||
@@ -187,7 +187,8 @@
|
||||
"adminCreateNpc": "Créer un PNJ",
|
||||
"adminMinigames": "Gestion des Match3",
|
||||
"adminTaxiTools": "Outils de taxi",
|
||||
"adminServicesStatus": "Statut des services"
|
||||
"adminServicesStatus": "Statut des services",
|
||||
"adminWorkerSchedules": "Executions du daemon"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,8 @@
|
||||
"edituser": "Modifier l'utilisateur",
|
||||
"database": "base de données",
|
||||
"mapEditor": "Editeur de cartes",
|
||||
"createNPC": "NPCs erstellen"
|
||||
"createNPC": "NPCs erstellen",
|
||||
"workerSchedules": "Exécutions du daemon"
|
||||
},
|
||||
"minigames": "Minispiele",
|
||||
"m-minigames": {
|
||||
|
||||
@@ -6,6 +6,7 @@ const ForumAdminView = () => import('../dialogues/admin/ForumAdminView.vue');
|
||||
const AdminFalukantEditUserView = () => import('../views/admin/falukant/EditUserView.vue');
|
||||
const AdminFalukantMapRegionsView = () => import('../views/admin/falukant/MapRegionsView.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 AdminTaxiToolsView = () => import('../views/admin/TaxiToolsView.vue');
|
||||
const AdminUsersView = () => import('../views/admin/UsersView.vue');
|
||||
@@ -94,6 +95,12 @@ const adminRoutes = [
|
||||
component: AdminFalukantCreateNPCView,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/admin/falukant/worker-schedules',
|
||||
name: 'AdminFalukantWorkerSchedulesView',
|
||||
component: AdminFalukantWorkerSchedulesView,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/admin/minigames/match3',
|
||||
name: 'AdminMinigames',
|
||||
|
||||
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