🆙 Add cms i using 🆙

This commit is contained in:
Remco
2025-11-25 22:42:56 +01:00
parent 94704e0925
commit d44196149e
35591 changed files with 3601123 additions and 0 deletions
@@ -0,0 +1,61 @@
import axios from 'axios';
import { createApp } from 'vue/dist/vue.esm-bundler.js';
import { createRouter, createWebHistory } from 'vue-router';
import VueJsonPretty from 'vue-json-pretty';
import 'vue-json-pretty/lib/styles.css';
import Base from './base';
import Routes from './routes';
import Alert from './components/Alert.vue';
import SchemeToggler from './components/SchemeToggler.vue';
import Poll from './components/Poll.vue';
let token = document.head.querySelector("meta[name='csrf-token']");
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
if (token) {
axios.defaults.headers.common['X-CSRF-TOKEN'] = token.content;
}
const app = createApp({
data() {
return {
alert: {
type: null,
autoClose: 0,
message: '',
confirmationProceed: null,
confirmationCancel: null,
},
autoLoadsNewEntries: localStorage.autoLoadsNewEntries === '1',
};
},
});
app.config.globalProperties.$http = axios.create();
let proxyPath = window.Horizon.proxy_path;
window.Horizon.basePath = proxyPath + '/' + window.Horizon.path;
let routerBasePath = window.Horizon.basePath + '/';
if (window.Horizon.path === '' || window.Horizon.path === '/') {
routerBasePath = proxyPath + '/';
window.Horizon.basePath = proxyPath;
}
const router = createRouter({
history: createWebHistory(routerBasePath),
routes: Routes,
});
app.use(router);
app.component('vue-json-pretty', VueJsonPretty);
app.component('alert', Alert);
app.component('scheme-toggler', SchemeToggler);
app.component('poll', Poll);
app.mixin(Base);
app.mount('#horizon');
@@ -0,0 +1,76 @@
import moment from 'moment-timezone';
export default {
computed: {
Horizon() {
return Horizon;
},
},
methods: {
/**
* Format the given date with respect to timezone.
*/
formatDate(unixTime) {
return moment(unixTime * 1000).add(new Date().getTimezoneOffset() / 60);
},
/**
* Format the given date with respect to timezone.
*/
formatDateIso(date) {
return moment(date).add(new Date().getTimezoneOffset() / 60);
},
/**
* Extract the job base name.
*/
jobBaseName(name) {
if (!name.includes('\\')) return name;
var parts = name.split('\\');
return parts[parts.length - 1];
},
/**
* Autoload new entries in listing screens.
*/
autoLoadNewEntries() {
if (!this.autoLoadsNewEntries) {
this.autoLoadsNewEntries = true;
localStorage.autoLoadsNewEntries = 1;
} else {
this.autoLoadsNewEntries = false;
localStorage.autoLoadsNewEntries = 0;
}
},
/**
* Convert to human readable timestamp.
*/
readableTimestamp(timestamp) {
return this.formatDate(timestamp).format('YYYY-MM-DD HH:mm:ss');
},
/**
* Uppercase the first character of the string.
*/
upperFirst(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
},
/**
* Group array entries by a given key.
*/
groupBy(array, key) {
return array.reduce(
(grouped, entry) => ({
...grouped,
[entry[key]]: [...(grouped[entry[key]] || []), entry],
}),
{}
);
},
},
};
@@ -0,0 +1,125 @@
<script type="text/ecmascript-6">
import { Modal } from 'bootstrap';
export default {
props: ['type', 'message', 'autoClose', 'confirmationProceed', 'confirmationCancel'],
data(){
return {
timeout: null,
alertModal: null,
anotherModalOpened: document.body.classList.contains('modal-open')
}
},
mounted() {
const alertModalElement = document.getElementById('alertModal');
this.alertModal = Modal.getOrCreateInstance(alertModalElement, {
backdrop: 'static',
})
this.alertModal.show();
alertModalElement.addEventListener('hidden.bs.modal', e => {
this.$root.alert.type = null;
this.$root.alert.autoClose = false;
this.$root.alert.message = '';
this.$root.alert.confirmationProceed = null;
this.$root.alert.confirmationCancel = null;
if (this.anotherModalOpened) {
document.body.classList.add('modal-open');
}
}, this);
if (this.autoClose) {
this.timeout = setTimeout(() => {
this.close();
}, this.autoClose);
}
},
methods: {
/**
* Close the modal.
*/
close(){
clearTimeout(this.timeout);
this.alertModal.hide();
},
/**
* Confirm and close the modal.
*/
confirm(){
this.confirmationProceed();
this.close();
},
/**
* Cancel and close the modal.
*/
cancel(){
if (this.confirmationCancel) {
this.confirmationCancel();
}
this.close();
}
}
}
</script>
<template>
<div class="modal" id="alertModal" tabindex="-1" role="dialog" aria-labelledby="alertModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-body">
<p class="m-0 py-4">{{message}}</p>
</div>
<div class="modal-footer justify-content-start flex-row-reverse">
<button v-if="type == 'error'" class="btn btn-primary" @click="close">
Close
</button>
<button v-if="type == 'success'" class="btn btn-primary" @click="close">
Okay
</button>
<button v-if="type == 'confirmation'" class="btn btn-danger" @click="confirm">
Yes
</button>
<button v-if="type == 'confirmation'" class="btn" @click="cancel">
Cancel
</button>
</div>
</div>
</div>
</div>
</template>
<style>
#alertModal {
z-index: 99999;
background: rgba(0, 0, 0, 0.5);
}
#alertModal svg {
display: block;
margin: 0 auto;
width: 4rem;
height: 4rem;
}
</style>
@@ -0,0 +1,75 @@
<script type="text/ecmascript-6">
import Chart from 'chart.js';
export default {
props: ['data'],
data(){
return {
context: null,
chart:null
}
},
mounted(){
this.context = this.$refs.canvas.getContext('2d');
this.chart = new Chart(this.context, {
type: 'line',
options: {
tooltips: {
intersect: false,
},
legend: {
display: false,
},
scales: {
yAxes: [
{
ticks: {
beginAtZero: true,
callback: (value, index, values) => {
return this.data.datasets[0].label === "Seconds"
? `${value} secs`
: value;
},
},
gridLines: {
display: true
},
beforeBuildTicks: function (scale) {
var max = scale.chart.data.datasets[0].data.reduce((max, value) => value > max ? value : max)
scale.max = parseFloat(max) + parseFloat(max * 0.25);
},
}
],
xAxes: [
{
gridLines: {
display: true
},
afterTickToLabelConversion: function (data) {
var xLabels = data.ticks;
xLabels.forEach(function (labels, i) {
if (i % 6 != 0 && (i + 1) != xLabels.length) {
xLabels[i] = '';
}
});
}
},
]
}
},
data: this.data
});
},
}
</script>
<template>
<div style="position: relative;">
<canvas ref="canvas" height="120"></canvas>
</div>
</template>
@@ -0,0 +1,118 @@
<script>
export default {
data() {
return {
loading: 0,
lastExecutionTime: 0,
pollingInterval: null,
}
},
props: {
interval: {
type: Number,
default: 3,
},
keepAlive: {
type: Boolean,
default: false,
},
immediate: {
type: Boolean,
default: true,
}
},
beforeMount() {
this.updatePollingInterval();
if (this.immediate) {
this.emitPoll();
}
},
mounted() {
this.createListener();
if (!this.keepAlive) {
document.addEventListener('visibilitychange', this.visibilitychangeListener = this.changedVisibility);
}
},
beforeUnmount() {
this.removeListener();
if (this.visibilitychangeListener) {
document.removeEventListener('visibilitychange', this.visibilitychangeListener);
}
},
methods: {
emitPoll() {
if (this.loading) {
return;
}
this.loading++;
this.$emit('poll');
this.loading--;
this.lastExecutionTime = Date.now();
},
removeListener() {
if (this.poll) {
clearInterval(this.poll);
this.poll = null;
}
},
createListener() {
this.poll = setInterval(() => {
this.emitPoll();
}, this.pollingInterval);
},
updatePollingInterval() {
if (this.keepAlive) {
this.pollingInterval = this.interval * 1000;
return;
}
if (document.visibilityState === 'visible') {
this.pollingInterval = 1000 * this.interval;
} else if (document.visibilityState === 'hidden') {
// One hour...
this.pollingInterval = 1000 * 60 * 60;
}
},
changedVisibility() {
this.updatePollingInterval();
this.removeListener();
this.createListener();
// throttling
if ((Date.now() - this.lastExecutionTime) >= this.pollingInterval) {
this.emitPoll();
}
},
},
render(h) {
return null;
}
}
</script>
@@ -0,0 +1,65 @@
<script type="text/ecmascript-6">
export default {
data () {
return {
scheme: 'system'
}
},
watch: {
scheme (value) {
localStorage.setItem('scheme', value);
}
},
mounted () {
this.scheme = localStorage.getItem('scheme') ?? 'system';
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', () => this.calculateScheme())
this.calculateScheme()
},
methods: {
toggleScheme () {
if (this.scheme == 'system') {
this.scheme = 'dark'
} else if (this.scheme == 'dark') {
this.scheme = 'light'
} else {
this.scheme = 'system'
}
this.calculateScheme()
},
calculateScheme () {
const dark = document.querySelector('style[data-scheme="dark"]');
if (this.scheme == 'system') {
const prefersDarkMode = window.matchMedia('(prefers-color-scheme: dark)');
dark.media = prefersDarkMode.matches ? "" : "max-width: 1px";
} else {
dark.media = this.scheme == 'dark' ? "" : "max-width: 1px";
}
}
}
}
</script>
<template>
<button class="btn btn-muted" title="Switch Theme" v-on:click.prevent="toggleScheme">
<svg v-if="scheme == 'system'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon" fill="currentColor">
<path fill-rule="evenodd" d="M2 4.25A2.25 2.25 0 014.25 2h11.5A2.25 2.25 0 0118 4.25v8.5A2.25 2.25 0 0115.75 15h-3.105a3.501 3.501 0 001.1 1.677A.75.75 0 0113.26 18H6.74a.75.75 0 01-.484-1.323A3.501 3.501 0 007.355 15H4.25A2.25 2.25 0 012 12.75v-8.5zm1.5 0a.75.75 0 01.75-.75h11.5a.75.75 0 01.75.75v7.5a.75.75 0 01-.75.75H4.25a.75.75 0 01-.75-.75v-7.5z" clip-rule="evenodd" />
</svg>
<svg v-if="scheme == 'dark'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon" fill="currentColor">
<path fill-rule="evenodd" d="M7.455 2.004a.75.75 0 01.26.77 7 7 0 009.958 7.967.75.75 0 011.067.853A8.5 8.5 0 116.647 1.921a.75.75 0 01.808.083z" clip-rule="evenodd" />
</svg>
<svg v-if="scheme == 'light'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon" fill="currentColor">
<path d="M10 2a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0v-1.5A.75.75 0 0110 2zM10 15a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0v-1.5A.75.75 0 0110 15zM10 7a3 3 0 100 6 3 3 0 000-6zM15.657 5.404a.75.75 0 10-1.06-1.06l-1.061 1.06a.75.75 0 001.06 1.06l1.06-1.06zM6.464 14.596a.75.75 0 10-1.06-1.06l-1.06 1.06a.75.75 0 001.06 1.06l1.06-1.06zM18 10a.75.75 0 01-.75.75h-1.5a.75.75 0 010-1.5h1.5A.75.75 0 0118 10zM5 10a.75.75 0 01-.75.75h-1.5a.75.75 0 010-1.5h1.5A.75.75 0 015 10zM14.596 15.657a.75.75 0 001.06-1.06l-1.06-1.061a.75.75 0 10-1.06 1.06l1.06 1.06zM5.404 6.464a.75.75 0 001.06-1.06l-1.06-1.06a.75.75 0 10-1.061 1.06l1.06 1.06z" />
</svg>
</button>
</template>
@@ -0,0 +1,41 @@
<script type="text/ecmascript-6">
export default {
props: ['trace'],
/**
* The component's data.
*/
data() {
return {
minimumLines: 5,
showAll: false,
};
},
computed: {
lines() {
return this.trace.slice(0, this.showAll ? 1000 : this.minimumLines);
}
}
}
</script>
<template>
<div class="table-responsive">
<table class="table mb-0">
<tbody>
<tr v-for="line in lines">
<td class="card-bg-secondary"><code>{{line}}</code></td>
</tr>
<tr v-if="! showAll">
<td class="card-bg-secondary"><a href="*" class="text-decoration-none" v-on:click.prevent="showAll = true">Show All</a></td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
</style>
@@ -0,0 +1,123 @@
import dashboard from './screens/dashboard.vue';
import monitoring from './screens/monitoring/index.vue';
import monitoringTag from './screens/monitoring/tag.vue';
import monitoringTagJobs from './screens/monitoring/tag-jobs.vue';
import metrics from './screens/metrics/index.vue';
import metricsJobs from './screens/metrics/jobs.vue';
import metricsQueues from './screens/metrics/queues.vue';
import metricsPreview from './screens/metrics/preview.vue';
import recentJobs from './screens/recentJobs/index.vue';
import recentJobsJob from './screens/recentJobs/job.vue';
import failedJobs from './screens/failedJobs/index.vue';
import failedJobsJob from './screens/failedJobs/job.vue';
import batches from './screens/batches/index.vue';
import batchesPreview from './screens/batches/preview.vue';
export default [
{ path: '/', redirect: '/dashboard' },
{
path: '/dashboard',
name: 'dashboard',
component: dashboard,
},
{
path: '/monitoring',
children: [
{
path: '',
name: 'monitoring',
component: monitoring,
},
{
path: ':tag',
component: monitoringTag,
children: [
{
path: 'jobs',
name: 'monitoring-jobs',
component: monitoringTagJobs,
props: { type: 'jobs' },
},
{
path: 'failed',
name: 'monitoring-failed',
component: monitoringTagJobs,
props: { type: 'failed' },
},
],
},
],
},
{
path: '/metrics',
redirect: '/metrics/jobs',
children: [
{
path: 'jobs',
component: metrics,
children: [{ path: '', name: 'metrics-jobs', component: metricsJobs }],
},
{
path: 'queues',
component: metrics,
children: [{ path: '', name: 'metrics-queues', component: metricsQueues }],
},
{
path: ':type/:slug',
name: 'metrics-preview',
component: metricsPreview,
},
],
},
{
path: '/jobs/:type',
children: [
{
path: '',
name: 'jobs',
component: recentJobs,
},
{
path: ':jobId',
name: 'job-preview',
component: recentJobsJob,
},
],
},
{
path: '/failed',
children: [
{
path: '',
name: 'failed-jobs',
component: failedJobs,
},
{
path: ':jobId',
name: 'failed-jobs-preview',
component: failedJobsJob,
},
],
},
{
path: '/batches',
children: [
{
path: '',
name: 'batches',
component: batches,
},
{
path: ':batchId',
name: 'batches-preview',
component: batchesPreview,
},
],
},
];
@@ -0,0 +1,200 @@
<script type="text/ecmascript-6">
export default {
/**
* The component's data.
*/
data() {
return {
ready: false,
loadingNewEntries: false,
hasNewEntries: false,
page: 1,
previousFirstId: null,
batches: [],
};
},
/**
* Prepare the component.
*/
mounted() {
document.title = "Horizon - Batches";
},
/**
* Watch these properties for changes.
*/
watch: {
'$route'() {
this.page = 1;
this.loadBatches();
},
'$root.autoLoadsNewEntries'(autoLoadsNewEntries) {
if (autoLoadsNewEntries && this.hasNewEntries) {
this.hasNewEntries = false;
}
}
},
methods: {
/**
* Load the batches.
*/
loadBatches(beforeId = '', refreshing = false) {
if (!refreshing) {
this.ready = false;
}
this.$http.get(Horizon.basePath + '/api/batches?before_id=' + beforeId)
.then(response => {
if (!this.$root.autoLoadsNewEntries && refreshing && !response.data.batches.length) {
this.ready = true;
return;
}
if (!this.$root.autoLoadsNewEntries && refreshing && this.batches.length && response.data.batches[0]?.id !== this.batches[0]?.id) {
this.hasNewEntries = true;
} else {
this.batches = response.data.batches;
}
this.ready = true;
});
},
loadNewEntries() {
this.batches = [];
this.loadBatches(0, false);
this.hasNewEntries = false;
},
/**
* Poll handler to refresh the batches at regular intervals.
*/
refreshBatchesPeriodically() {
if (this.page != 1) return;
this.loadBatches('', true);
},
/**
* Load the batches for the previous page.
*/
previous() {
this.loadBatches(
this.page == 2 ? '' : this.previousFirstId
);
this.page -= 1;
this.hasNewEntries = false;
},
/**
* Load the batches for the next page.
*/
next() {
this.previousFirstId = this.batches[0]?.id + '0';
this.loadBatches(
this.batches.slice(-1)[0]?.id
);
this.page += 1;
this.hasNewEntries = false;
}
}
}
</script>
<template>
<div>
<poll @poll="refreshBatchesPeriodically" />
<div class="card overflow-hidden">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Batches</h2>
</div>
<div v-if="!ready" class="d-flex align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon spin me-2 fill-text-color">
<path d="M12 10a2 2 0 0 1-3.41 1.41A2 2 0 0 1 10 8V0a9.97 9.97 0 0 1 10 10h-8zm7.9 1.41A10 10 0 1 1 8.59.1v2.03a8 8 0 1 0 9.29 9.29h2.02zm-4.07 0a6 6 0 1 1-7.25-7.25v2.1a3.99 3.99 0 0 0-1.4 6.57 4 4 0 0 0 6.56-1.42h2.1z"></path>
</svg>
<span>Loading...</span>
</div>
<div v-if="ready && batches.length == 0" class="d-flex flex-column align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<span>There aren't any batches.</span>
</div>
<table v-if="ready && batches.length > 0" class="table table-hover mb-0">
<thead>
<tr>
<th>Batch</th>
<th>Status</th>
<th class="text-end">Size</th>
<th class="text-end">Completion</th>
<th class="text-end">Created</th>
</tr>
</thead>
<tbody>
<tr v-if="hasNewEntries && !this.$root.autoLoadsNewEntries" key="newEntries" class="dontanimate">
<td colspan="100" class="text-center card-bg-secondary py-2">
<small><a href="#" v-on:click.prevent="loadNewEntries" v-if="!loadingNewEntries">Load New Entries</a></small>
<small v-if="loadingNewEntries">Loading...</small>
</td>
</tr>
<tr v-for="batch in batches" :key="batch.id">
<td>
<router-link :title="batch.id" :to="{ name: 'batches-preview', params: { batchId: batch.id }}">
{{ batch.name || batch.id }}
</router-link>
</td>
<td>
<small class="badge badge-danger badge-sm" v-if="!batch.cancelledAt && batch.failedJobs > 0 && batch.totalJobs - batch.pendingJobs < batch.totalJobs">
Failures
</small>
<small class="badge badge-success badge-sm" v-if="!batch.cancelledAt && batch.totalJobs - batch.pendingJobs == batch.totalJobs">
Finished
</small>
<small class="badge badge-secondary badge-sm" v-if="!batch.cancelledAt && batch.pendingJobs > 0 && !batch.failedJobs">
Pending
</small>
<small class="badge badge-warning badge-sm" v-if="batch.cancelledAt">
Cancelled
</small>
</td>
<td class="text-end text-muted">{{ batch.totalJobs }}</td>
<td class="text-end text-muted">{{ batch.progress }}%</td>
<td class="text-end table-fit">
{{ formatDateIso(batch.createdAt).format("YYYY-MM-DD HH:mm:ss") }}
</td>
</tr>
</tbody>
</table>
<div v-if="ready && batches.length" class="p-3 d-flex justify-content-between border-top">
<button @click="previous" class="btn btn-secondary btn-sm" :disabled="page==1">Previous</button>
<button @click="next" class="btn btn-secondary btn-sm" :disabled="batches.length < 50">Next</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,186 @@
<script type="text/ecmascript-6">
export default {
/**
* The component's data.
*/
data() {
return {
ready: false,
retrying: false,
batch: {},
failedJobs : []
};
},
/**
* Prepare the component.
*/
mounted() {
document.title = "Horizon - Batches";
},
methods: {
loadBatch(reload = true) {
if (reload) {
this.ready = false;
}
this.$http.get(Horizon.basePath + '/api/batches/' + this.$route.params.batchId)
.then(response => {
this.batch = response.data.batch;
this.failedJobs = response.data.failedJobs;
this.ready = true;
});
},
/**
* Retry the given failed job.
*/
retry(id) {
if (this.retrying) {
return;
}
this.retrying = true;
this.$http.post(Horizon.basePath + '/api/batches/retry/' + id)
.then(() => {
setTimeout(() => {
this.loadBatch(false);
this.retrying = false;
}, 3000);
});
},
}
}
</script>
<template>
<div>
<poll @poll="loadBatch(false)" />
<div class="card overflow-hidden">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0" v-if="!ready">Batch Preview</h2>
<h2 class="h6 m-0" v-if="ready">{{batch.name || batch.id}}</h2>
<button class="btn btn-primary" v-if="failedJobs.length > 0" v-on:click.prevent="retry(batch.id)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon" fill="currentColor" :class="{spin: retrying}">
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z" clip-rule="evenodd" />
</svg>
Retry Failed Jobs
</button>
</div>
<div v-if="!ready" class="d-flex align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon spin me-2 fill-text-color">
<path d="M12 10a2 2 0 0 1-3.41 1.41A2 2 0 0 1 10 8V0a9.97 9.97 0 0 1 10 10h-8zm7.9 1.41A10 10 0 1 1 8.59.1v2.03a8 8 0 1 0 9.29 9.29h2.02zm-4.07 0a6 6 0 1 1-7.25-7.25v2.1a3.99 3.99 0 0 0-1.4 6.57 4 4 0 0 0 6.56-1.42h2.1z"></path>
</svg>
<span>Loading...</span>
</div>
<div class="card-body card-bg-secondary" v-if="ready">
<div class="row mb-2">
<div class="col-md-2 text-muted">ID</div>
<div class="col">
{{batch.id}}
<small class="ms-1 badge badge-danger badge-sm" v-if="batch.failedJobs > 0 && batch.totalJobs - batch.pendingJobs < batch.totalJobs">
Failures
</small>
<small class="ms-1 badge badge-success badge-sm" v-if="batch.totalJobs - batch.pendingJobs == batch.totalJobs">
Finished
</small>
<small class="ms-1 badge badge-secondary badge-sm" v-if="batch.pendingJobs > 0 && !batch.failedJobs">
Pending
</small>
</div>
</div>
<div class="row mb-2" v-if="batch.name">
<div class="col-md-2 text-muted">Name</div>
<div class="col">{{batch.name}}</div>
</div>
<div class="row mb-2" v-if="batch.options.queue">
<div class="col-md-2 text-muted">Queue</div>
<div class="col">{{batch.options.queue}}</div>
</div>
<div class="row mb-2" v-if="batch.options.connection">
<div class="col-md-2 text-muted">Connection</div>
<div class="col">{{batch.options.connection}}</div>
</div>
<div class="row mb-2">
<div class="col-md-2 text-muted">Created</div>
<div class="col">{{ formatDateIso(batch.createdAt).format('YYYY-MM-DD HH:mm:ss') }}</div>
</div>
<div class="row mb-2" v-if="batch.finishedAt">
<div class="col-md-2 text-muted">Finished</div>
<div class="col">{{ formatDateIso(batch.finishedAt).format('YYYY-MM-DD HH:mm:ss') }}</div>
</div>
<div class="row mb-2" v-if="batch.cancelledAt">
<div class="col-md-2 text-muted">Cancelled</div>
<div class="col">{{ formatDateIso(batch.cancelledAt).format('YYYY-MM-DD HH:mm:ss') }}</div>
</div>
<div class="row mb-2">
<div class="col-md-2 text-muted">Total Jobs</div>
<div class="col">{{batch.totalJobs}}</div>
</div>
<div class="row mb-2">
<div class="col-md-2 text-muted">Pending Jobs</div>
<div class="col">{{batch.pendingJobs}}</div>
</div>
<div class="row mb-2">
<div class="col-md-2 text-muted">Failed Jobs</div>
<div class="col">{{batch.failedJobs}}</div>
</div>
<div class="row">
<div class="col-md-2 text-muted">Processed Jobs<br><small>(Including Failed)</small></div>
<div class="col">{{ (batch.processedJobs) }} ({{batch.progress}}%)</div>
</div>
</div>
</div>
<div class="card overflow-hidden mt-4" v-if="ready && failedJobs.length">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Failed Jobs</h2>
</div>
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Job</th>
<th class="text-end">Runtime</th>
<th class="text-end">Failed</th>
</tr>
</thead>
<tbody>
<tr v-for="failedJob in failedJobs">
<td>
<router-link :title="failedJob.name" :to="{ name: 'failed-jobs-preview', params: { jobId: failedJob.id }}">
{{ jobBaseName(failedJob.name) }}
</router-link>
</td>
<td class="text-end text-muted table-fit">
<span>{{ failedJob.failed_at && failedJob.reserved_at ? String(( failedJob.failed_at - failedJob.reserved_at ).toFixed(2))+'s' : '-' }}</span>
</td>
<td class="text-end text-muted table-fit">
{{ readableTimestamp(failedJob.failed_at) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
@@ -0,0 +1,349 @@
<script type="text/ecmascript-6">
import moment from 'moment';
export default {
components: {},
/**
* The component's data.
*/
data() {
return {
stats: {},
workers: [],
workload: [],
ready: false,
};
},
/**
* Prepare the component.
*/
mounted() {
document.title = "Horizon - Dashboard";
},
computed: {
/**
* Determine the recent job period label.
*/
recentJobsPeriod() {
return !this.ready
? 'Jobs Past Hour'
: `Jobs Past ${this.determinePeriod(this.stats.periods.recentJobs)}`;
},
/**
* Determine the recently failed job period label.
*/
failedJobsPeriod() {
return !this.ready
? 'Failed Jobs Past 7 Days'
: `Failed Jobs Past ${this.determinePeriod(this.stats.periods.failedJobs)}`;
},
},
methods: {
/**
* Load the general stats.
*/
loadStats() {
return this.$http.get(Horizon.basePath + '/api/stats')
.then(response => {
this.stats = response.data;
if (Object.values(response.data.wait)[0]) {
this.stats.max_wait_time = Object.values(response.data.wait)[0];
this.stats.max_wait_queue = Object.keys(response.data.wait)[0].split(':')[1];
}
});
},
/**
* Load the workers stats.
*/
loadWorkers() {
return this.$http.get(Horizon.basePath + '/api/masters')
.then(response => {
this.workers = response.data;
});
},
/**
* Load the workload stats.
*/
loadWorkload() {
return this.$http.get(Horizon.basePath + '/api/workload')
.then(response => {
this.workload = response.data;
});
},
/**
* Poll handler to refresh the stats at regular intervals.
*/
refreshStatsPeriodically() {
Promise.all([
this.loadStats(),
this.loadWorkers(),
this.loadWorkload(),
]).then(() => {
this.ready = true;
});
},
/**
* Count processes for the given supervisor.
*/
countProcesses(processes) {
return Object.values(processes).reduce((total, value) => total + value, 0).toLocaleString();
},
/**
* Format the Supervisor display name.
*/
superVisorDisplayName(supervisor, worker) {
return supervisor.replace(worker + ':', '');
},
/**
*
* @returns {string}
*/
humanTime(time) {
return moment.duration(time, "seconds").humanize().replace(/^(.)/g, function ($1) {
return $1.toUpperCase();
});
},
/**
* Determine the unit for the given timeframe.
*/
determinePeriod(minutes) {
return moment.duration(moment().diff(moment().subtract(minutes, "minutes"))).humanize().replace(/^An?\s/i, '').replace(/^(.)|\s(.)/g, function ($1) {
return $1.toUpperCase();
});
}
}
}
</script>
<template>
<div>
<poll @poll="refreshStatsPeriodically" :interval="5" />
<div class="card overflow-hidden">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Overview</h2>
</div>
<div class="card-bg-secondary">
<div class="d-flex">
<div class="w-25">
<div class="p-4">
<small class="text-muted fw-bold">Jobs Per Minute</small>
<p class="h4 mt-2 mb-0">
{{ stats.jobsPerMinute ? stats.jobsPerMinute.toLocaleString() : 0 }}
</p>
</div>
</div>
<div class="w-25">
<div class="p-4">
<small class="text-muted fw-bold" v-text="recentJobsPeriod"></small>
<p class="h4 mt-2 mb-0">
{{ stats.recentJobs ? stats.recentJobs.toLocaleString() : 0 }}
</p>
</div>
</div>
<div class="w-25">
<div class="p-4">
<small class="text-muted fw-bold" v-text="failedJobsPeriod"></small>
<p class="h4 mt-2 mb-0">
{{ stats.failedJobs ? stats.failedJobs.toLocaleString() : 0 }}
</p>
</div>
</div>
<div class="w-25">
<div class="p-4">
<small class="text-muted fw-bold">Status</small>
<div class="d-flex align-items-center mt-2">
<svg v-if="stats.status == 'running'" xmlns="http://www.w3.org/2000/svg" class="text-success" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="width: 1.5rem; height: 1.5rem;">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg v-if="stats.status == 'paused'" xmlns="http://www.w3.org/2000/svg" class="text-warning" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="width: 1.5rem; height: 1.5rem;">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.25 9v6m-4.5 0V9M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg v-if="stats.status == 'inactive'" xmlns="http://www.w3.org/2000/svg" class="text-danger" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="width: 1.5rem; height: 1.5rem;">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
<p class="h4 mb-0 ms-2">{{ {running: 'Active', paused: 'Paused', inactive: 'Inactive'}[stats.status] }}</p>
<small v-if="stats.status == 'running' && stats.pausedMasters > 0" class="mb-0 ms-2">({{ stats.pausedMasters }} paused)</small>
</div>
</div>
</div>
</div>
<div class="d-flex">
<div class="w-25">
<div class="p-4 mb-0">
<small class="text-muted fw-bold">Total Processes</small>
<p class="h4 mt-2">
{{ stats.processes ? stats.processes.toLocaleString() : 0 }}
</p>
</div>
</div>
<div class="w-25">
<div class="p-4 mb-0">
<small class="text-muted fw-bold">Max Wait Time</small>
<p class="mt-2 mb-0">
{{ stats.max_wait_time ? humanTime(stats.max_wait_time) : '-' }}
</p>
<small class="mt-1" v-if="stats.max_wait_queue">({{ stats.max_wait_queue }})</small>
</div>
</div>
<div class="w-25">
<div class="p-4 mb-0">
<small class="text-muted fw-bold">Max Runtime</small>
<p class="h4 mt-2">
{{ stats.queueWithMaxRuntime ? stats.queueWithMaxRuntime : '-' }}
</p>
</div>
</div>
<div class="w-25">
<div class="p-4 mb-0">
<small class="text-muted fw-bold">Max Throughput</small>
<p class="h4 mt-2">
{{ stats.queueWithMaxThroughput ? stats.queueWithMaxThroughput : '-' }}
</p>
</div>
</div>
</div>
</div>
</div>
<div class="card overflow-hidden mt-4" v-if="workload.length">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Current Workload</h2>
</div>
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Queue</th>
<th class="text-end" style="width: 120px;">Jobs</th>
<th class="text-end" style="width: 120px;">Processes</th>
<th class="text-end" style="width: 180px;">Wait</th>
</tr>
</thead>
<tbody>
<template v-for="queue in workload">
<tr>
<td :class="{ 'fw-bold': queue.split_queues }">
<span>{{ queue.name.replace(/,/g, ', ') }}</span>
</td>
<td class="text-end text-muted" :class="{ 'fw-bold': queue.split_queues }">{{ queue.length ? queue.length.toLocaleString() : 0 }}</td>
<td class="text-end text-muted" :class="{ 'fw-bold': queue.split_queues }">{{ queue.processes ? queue.processes.toLocaleString() : 0 }}</td>
<td class="text-end text-muted" :class="{ 'fw-bold': queue.split_queues }">{{ humanTime(queue.wait) }}</td>
</tr>
<tr v-for="split_queue in queue.split_queues">
<td>
<svg class="icon info-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" />
</svg>
<span>{{ split_queue.name.replace(/,/g, ', ') }}</span>
</td>
<td class="text-end text-muted">{{ split_queue.length ? split_queue.length.toLocaleString() : 0 }}</td>
<td class="text-end text-muted">-</td>
<td class="text-end text-muted">{{ humanTime(split_queue.wait) }}</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="card overflow-hidden mt-4" v-for="worker in workers" :key="worker.name">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">{{ worker.name }}</h2>
<svg v-if="worker.status == 'running'" xmlns="http://www.w3.org/2000/svg" class="text-success" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="width: 1.5rem; height: 1.5rem;">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg v-if="worker.status == 'paused'" xmlns="http://www.w3.org/2000/svg" class="text-warning" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="width: 1.5rem; height: 1.5rem;">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.25 9v6m-4.5 0V9M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Supervisor</th>
<th>Connection</th>
<th>Queues</th>
<th class="text-end" style="width: 120px;">Processes</th>
<th class="text-end" style="width: 180px;">Balancing</th>
</tr>
</thead>
<tbody>
<tr v-for="supervisor in worker.supervisors">
<td>
<svg v-if="supervisor.status == 'paused'" class="fill-warning me-1" viewBox="0 0 20 20" style="width: 1rem; height: 1rem;">
<path d="M2.93 17.07A10 10 0 1 1 17.07 2.93 10 10 0 0 1 2.93 17.07zm12.73-1.41A8 8 0 1 0 4.34 4.34a8 8 0 0 0 11.32 11.32zM7 6h2v8H7V6zm4 0h2v8h-2V6z" />
</svg>
<svg v-if="supervisor.status == 'inactive'" class="fill-danger me-1" viewBox="0 0 20 20" style="width: 1rem; height: 1rem;">
<path d="M2.93 17.07A10 10 0 1 1 17.07 2.93 10 10 0 0 1 2.93 17.07zm1.41-1.41A8 8 0 1 0 15.66 4.34 8 8 0 0 0 4.34 15.66zm9.9-8.49L11.41 10l2.83 2.83-1.41 1.41L10 11.41l-2.83 2.83-1.41-1.41L8.59 10 5.76 7.17l1.41-1.41L10 8.59l2.83-2.83 1.41 1.41z" />
</svg>
{{ superVisorDisplayName(supervisor.name, worker.name) }}
</td>
<td class="text-muted">{{ supervisor.options.connection }}</td>
<td class="text-muted">{{ supervisor.options.queue.replace(/,/g, ', ') }}</td>
<td class="text-end text-muted">{{ countProcesses(supervisor.processes) }}</td>
<td class="text-end text-muted" v-if="supervisor.options.balance">
{{ upperFirst(supervisor.options.balance) }}
</td>
<td class="text-end text-muted" v-else>
Disabled
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
@@ -0,0 +1,303 @@
<script type="text/ecmascript-6">
export default {
/**
* The component's data.
*/
data() {
return {
tagSearchPhrase: '',
searchTimeout: null,
ready: false,
loadingNewEntries: false,
hasNewEntries: false,
page: 1,
perPage: 50,
totalPages: 1,
jobs: [],
retryingJobs: [],
};
},
/**
* Prepare the component.
*/
mounted() {
document.title = "Horizon - Failed Jobs";
},
/**
* Watch these properties for changes.
*/
watch: {
'$route'() {
this.page = 1;
this.loadJobs();
},
tagSearchPhrase() {
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => {
this.loadJobs();
this.refreshJobsPeriodically();
}, 500);
},
'$root.autoLoadsNewEntries'(autoLoadsNewEntries) {
if (autoLoadsNewEntries && this.hasNewEntries) {
this.hasNewEntries = false;
}
}
},
methods: {
/**
* Load the jobs of the given tag.
*/
loadJobs(starting = 0, refreshing = false) {
if (!refreshing) {
this.ready = false;
}
var tagQuery = this.tagSearchPhrase ? 'tag=' + this.tagSearchPhrase + '&' : '';
this.$http.get(Horizon.basePath + '/api/jobs/failed?' + tagQuery + 'starting_at=' + starting)
.then(response => {
if (!this.$root.autoLoadsNewEntries && refreshing && !response.data.jobs.length) {
this.ready = true;
return;
}
if (!this.$root.autoLoadsNewEntries && refreshing && this.jobs.length && response.data.jobs[0]?.id !== this.jobs[0]?.id) {
this.hasNewEntries = true;
} else {
this.jobs = response.data.jobs;
this.totalPages = Math.ceil(response.data.total / this.perPage);
}
this.ready = true;
});
},
loadNewEntries() {
this.jobs = [];
this.loadJobs(0, false);
this.hasNewEntries = false;
},
/**
* Retry the given failed job.
*/
retry(id) {
if (this.isRetrying(id)) {
return;
}
this.retryingJobs.push(id);
this.$http.post(Horizon.basePath + '/api/jobs/retry/' + id)
.then((response) => {
setTimeout(() => {
this.retryingJobs = this.retryingJobs.filter(job => job != id);
}, 5000);
}).catch(error => {
this.retryingJobs = this.retryingJobs.filter(job => job != id);
});
},
/**
* Determine if the given job is currently retrying.
*/
isRetrying(id) {
return this.retryingJobs.includes(id);
},
/**
* Determine if the given job has completed.
*/
hasCompleted(job) {
return job.retried_by.find(retry => retry.status === 'completed');
},
/**
* Determine if the given job was retried.
*/
wasRetried(job) {
return job.retried_by && job.retried_by.length;
},
/**
* Determine if the given job is a retry.
*/
isRetry(job) {
return job.payload.retry_of;
},
/**
* Construct the tooltip label for a retried job.
*/
retriedJobTooltip(job) {
let lastRetry = job.retried_by[job.retried_by.length - 1];
return `Total retries: ${job.retried_by.length}, Last retry status: ${this.upperFirst(lastRetry.status)}`;
},
/**
* Poll handler to refresh the jobs at regular intervals.
*/
refreshJobsPeriodically() {
this.loadJobs((this.page - 1) * this.perPage, true);
},
/**
* Load the jobs for the previous page.
*/
previous() {
this.loadJobs(
(this.page - 2) * this.perPage - 1
);
this.page -= 1;
this.hasNewEntries = false;
},
/**
* Load the jobs for the next page.
*/
next() {
this.loadJobs(
this.page * this.perPage - 1
);
this.page += 1;
this.hasNewEntries = false;
}
}
}
</script>
<template>
<div>
<poll @poll="refreshJobsPeriodically" />
<div class="card overflow-hidden">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Failed Jobs</h2>
<div class="form-control-with-icon">
<div class="icon-wrapper">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon">
<path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clip-rule="evenodd" />
</svg>
</div>
<input type="text" class="form-control w-100" v-model="tagSearchPhrase" placeholder="Search Tags">
</div>
</div>
<div v-if="!ready" class="d-flex align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon spin me-2 fill-text-color">
<path d="M12 10a2 2 0 0 1-3.41 1.41A2 2 0 0 1 10 8V0a9.97 9.97 0 0 1 10 10h-8zm7.9 1.41A10 10 0 1 1 8.59.1v2.03a8 8 0 1 0 9.29 9.29h2.02zm-4.07 0a6 6 0 1 1-7.25-7.25v2.1a3.99 3.99 0 0 0-1.4 6.57 4 4 0 0 0 6.56-1.42h2.1z"></path>
</svg>
<span>Loading...</span>
</div>
<div v-if="ready && jobs.length == 0" class="d-flex flex-column align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<span>There aren't any failed jobs.</span>
</div>
<table v-if="ready && jobs.length > 0" class="table table-hover mb-0">
<thead>
<tr>
<th>Job</th>
<th class="text-end">Runtime</th>
<th>Failed</th>
<th class="text-end">Retry</th>
</tr>
</thead>
<tbody>
<tr v-if="hasNewEntries && !this.$root.autoLoadsNewEntries" key="newEntries" class="dontanimate">
<td colspan="100" class="text-center card-bg-secondary py-2">
<small><a href="#" v-on:click.prevent="loadNewEntries" v-if="!loadingNewEntries">Load New Entries</a></small>
<small v-if="loadingNewEntries">Loading...</small>
</td>
</tr>
<tr v-for="job in jobs" :key="job.id">
<td>
<router-link :title="job.name" :to="{ name: 'failed-jobs-preview', params: { jobId: job.id }}">{{ jobBaseName(job.name) }}</router-link>
<small class="ms-1 badge bg-secondary badge-sm"
:title="retriedJobTooltip(job)"
v-if="wasRetried(job)">
Retried
</small>
<br>
<small class="text-muted">
Queue: {{job.queue}}
| Attempts: {{ job.payload.attempts }}
<span v-if="isRetry(job)">
| Retry of
<router-link :title="job.name" :to="{ name: 'failed-jobs-preview', params: { jobId: job.payload.retry_of }}">
{{ job.payload.retry_of.split('-')[0] }}
</router-link>
</span>
<span v-if="job.payload.tags && job.payload.tags.length" class="text-break">
| Tags: {{ job.payload.tags && job.payload.tags.length ? job.payload.tags.join(', ') : '' }}
</span>
</small>
</td>
<td class="table-fit text-muted text-end">
<span>{{ job.failed_at ? String((job.failed_at - job.reserved_at).toFixed(2))+'s' : '-' }}</span>
</td>
<td class="table-fit text-muted">
{{ readableTimestamp(job.failed_at) }}
</td>
<td class="text-end table-fit">
<a href="#" title="Retry Job" @click.prevent="retry(job.id)" v-if="!hasCompleted(job)">
<svg class="fill-primary" viewBox="0 0 20 20" style="width: 1.25rem; height: 1.25rem;" :class="{spin: isRetrying(job.id)}">
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z" clip-rule="evenodd" />
</svg>
</a>
</td>
</tr>
</tbody>
</table>
<div v-if="ready && jobs.length" class="p-3 d-flex justify-content-between border-top">
<button @click="previous" class="btn btn-secondary btn-sm" :disabled="page==1">Previous</button>
<button @click="next" class="btn btn-secondary btn-sm" :disabled="page>=totalPages">Next</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,258 @@
<script type="text/ecmascript-6">
import phpunserialize from 'phpunserialize'
import StackTrace from '@/components/Stacktrace.vue'
export default {
components: {
'stack-trace': StackTrace,
},
/**
* The component's data.
*/
data() {
return {
ready: false,
retrying: false,
job: {}
};
},
/**
* Prepare the component.
*/
mounted() {
this.loadFailedJob(this.$route.params.jobId);
document.title = "Horizon - Failed Jobs";
},
methods: {
loadFailedJob(id) {
this.ready = false;
this.$http.get(Horizon.basePath + '/api/jobs/failed/' + id)
.then(response => {
this.job = response.data;
this.ready = true;
});
},
/**
* Reload the job retries.
*/
reloadRetries() {
this.$http.get(Horizon.basePath + '/api/jobs/failed/' + this.$route.params.jobId)
.then(response => {
this.job.retried_by = response.data.retried_by;
});
},
/**
* Retry the given failed job.
*/
retry(id) {
if (this.retrying) {
return;
}
this.retrying = true;
this.$http.post(Horizon.basePath + '/api/jobs/retry/' + id)
.then(() => {
setTimeout(() => {
this.reloadRetries();
this.retrying = false;
}, 3000);
});
},
/**
* Pretty print serialized job.
*
* @param data
* @returns {string}
*/
prettyPrintJob(data) {
try {
return data.command && !data.command.includes('CallQueuedClosure')
? phpunserialize(data.command) : data;
} catch (err) {
return data;
}
}
}
}
</script>
<template>
<div>
<poll @poll="reloadRetries" :immediate="false" />
<div class="card overflow-hidden">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0" v-if="!ready">Job Preview</h2>
<h2 class="h6 m-0" v-if="ready">{{job.name}}</h2>
<button class="btn btn-primary" v-on:click.prevent="retry(job.id)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon" fill="currentColor" :class="{spin: retrying}">
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z" clip-rule="evenodd" />
</svg>
Retry
</button>
</div>
<div v-if="!ready" class="d-flex align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon spin me-2 fill-text-color">
<path d="M12 10a2 2 0 0 1-3.41 1.41A2 2 0 0 1 10 8V0a9.97 9.97 0 0 1 10 10h-8zm7.9 1.41A10 10 0 1 1 8.59.1v2.03a8 8 0 1 0 9.29 9.29h2.02zm-4.07 0a6 6 0 1 1-7.25-7.25v2.1a3.99 3.99 0 0 0-1.4 6.57 4 4 0 0 0 6.56-1.42h2.1z"></path>
</svg>
<span>Loading...</span>
</div>
<div class="card-body card-bg-secondary" v-if="ready">
<div class="row mb-2">
<div class="col-md-2 text-muted">ID</div>
<div class="col">{{job.id}}</div>
</div>
<div class="row mb-2">
<div class="col-md-2 text-muted">Connection</div>
<div class="col">{{job.connection}}</div>
</div>
<div class="row mb-2">
<div class="col-md-2 text-muted">Queue</div>
<div class="col">{{job.queue}}</div>
</div>
<div class="row mb-2">
<div class="col-md-2 text-muted">Attempts</div>
<div class="col">{{job.payload.attempts}}</div>
</div>
<div class="row mb-2">
<div class="col-md-2 text-muted">Retries</div>
<div class="col">{{job.retried_by.length}}</div>
</div>
<div class="row mb-2" v-if="job.payload.retry_of">
<div class="col-md-2 text-muted">Retry of ID</div>
<div class="col">
<a :href="Horizon.basePath + '/failed/' + job.payload.retry_of">
{{ job.payload.retry_of }}
</a>
</div>
</div>
<div class="row mb-2">
<div class="col-md-2 text-muted">Tags</div>
<div class="col">{{ job.payload.tags && job.payload.tags.length ? job.payload.tags.join(', ') : '' }}</div>
</div>
<div class="row mb-2" v-if="prettyPrintJob(job.payload.data).batchId">
<div class="col-md-2 text-muted">Batch</div>
<div class="col">
<router-link :to="{ name: 'batches-preview', params: { batchId: prettyPrintJob(job.payload.data).batchId }}">
{{ prettyPrintJob(job.payload.data).batchId }}
</router-link>
</div>
</div>
<div class="row mb-2">
<div class="col-md-2 text-muted">Pushed</div>
<div class="col">{{ readableTimestamp(job.payload.pushedAt) }}</div>
</div>
<div class="row">
<div class="col-md-2 text-muted">Failed</div>
<div class="col">{{readableTimestamp(job.failed_at)}}</div>
</div>
</div>
</div>
<div class="card overflow-hidden mt-4" v-if="ready">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Exception</h2>
</div>
<div>
<stack-trace :trace="job.exception.split('\n')"></stack-trace>
</div>
</div>
<div class="card overflow-hidden mt-4" v-if="ready">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Exception Context</h2>
</div>
<div class="card-body code-bg text-white">
<vue-json-pretty :data="prettyPrintJob(job.context)"></vue-json-pretty>
</div>
</div>
<div class="card overflow-hidden mt-4" v-if="ready">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Data</h2>
</div>
<div class="card-body code-bg text-white">
<vue-json-pretty :data="prettyPrintJob(job.payload.data)"></vue-json-pretty>
</div>
</div>
<div class="card overflow-hidden mt-4" v-if="ready && job.retried_by.length">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Recent Retries</h2>
</div>
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Status</th>
<th>ID</th>
<th class="text-end">Retry Time</th>
</tr>
</thead>
<tbody>
<tr v-for="retry in job.retried_by">
<td>
<svg v-if="retry.status == 'completed'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="fill-success" style="width: 1.5rem; height: 1.5rem;">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" />
</svg>
<svg v-if="retry.status == 'reserved' || retry.status == 'pending'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="fill-warning" style="width: 1.5rem; height: 1.5rem;">
<path fill-rule="evenodd" d="M2 10a8 8 0 1116 0 8 8 0 01-16 0zm5-2.25A.75.75 0 017.75 7h.5a.75.75 0 01.75.75v4.5a.75.75 0 01-.75.75h-.5a.75.75 0 01-.75-.75v-4.5zm4 0a.75.75 0 01.75-.75h.5a.75.75 0 01.75.75v4.5a.75.75 0 01-.75.75h-.5a.75.75 0 01-.75-.75v-4.5z" clip-rule="evenodd" />
</svg>
<svg v-if="retry.status == 'failed'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="fill-danger" style="width: 1.5rem; height: 1.5rem;">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
</svg>
<span class="ms-2">{{ upperFirst(retry.status) }}</span>
</td>
<td class="table-fit">
<a v-if="retry.status == 'failed'" :href="Horizon.basePath + '/failed/'+retry.id">
{{ retry.id }}
</a>
<a v-if="retry.status == 'completed'" :href="Horizon.basePath + '/jobs/completed/'+retry.id">
{{ retry.id }}
</a>
<a v-if="retry.status == 'reserved' || retry.status == 'pending'" :href="Horizon.basePath + '/jobs/pending/'+retry.id">
{{ retry.id }}
</a>
</td>
<td class="text-end table-fit text-muted">
{{readableTimestamp(retry.retried_at)}}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
@@ -0,0 +1,37 @@
<script type="text/ecmascript-6">
export default {
/**
* Prepare the component.
*/
created() {
document.title = "Horizon - Metrics";
}
}
</script>
<template>
<div>
<div class="card overflow-hidden">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Metrics</h2>
</div>
<ul class="nav nav-pills card-bg-secondary">
<li class="nav-item">
<router-link class="nav-link text-decoration-none" active-class="active" :to="{ name: 'metrics-jobs'}" href="#">
Jobs
</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link text-decoration-none" active-class="active" :to="{ name: 'metrics-queues'}" href="#">
Queues
</router-link>
</li>
</ul>
<router-view/>
</div>
</div>
</template>
@@ -0,0 +1,79 @@
<script type="text/ecmascript-6">
export default {
components: {},
/**
* The component's data.
*/
data() {
return {
ready: false,
jobs: []
};
},
/**
* Prepare the component.
*/
mounted() {
this.loadJobs();
},
methods: {
/**
* Load the jobs.
*/
loadJobs() {
this.ready = false;
this.$http.get(Horizon.basePath + '/api/metrics/jobs')
.then(response => {
this.jobs = response.data;
this.ready = true;
});
}
}
}
</script>
<template>
<div>
<div v-if="!ready" class="d-flex align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon spin me-2 fill-text-color">
<path d="M12 10a2 2 0 0 1-3.41 1.41A2 2 0 0 1 10 8V0a9.97 9.97 0 0 1 10 10h-8zm7.9 1.41A10 10 0 1 1 8.59.1v2.03a8 8 0 1 0 9.29 9.29h2.02zm-4.07 0a6 6 0 1 1-7.25-7.25v2.1a3.99 3.99 0 0 0-1.4 6.57 4 4 0 0 0 6.56-1.42h2.1z"></path>
</svg>
<span>Loading...</span>
</div>
<div v-if="ready && jobs.length == 0" class="d-flex flex-column align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<span>There aren't any jobs.</span>
</div>
<table v-if="ready && jobs.length > 0" class="table table-hover mb-0">
<thead>
<tr>
<th>Job</th>
</tr>
</thead>
<tbody>
<tr v-for="job in jobs" :key="job">
<td>
<router-link class="text-decoration-none" :to="{ name: 'metrics-preview', params: { type: 'jobs', slug: job }}">
{{ job }}
</router-link>
</td>
</tr>
</tbody>
</table>
</div>
</template>
@@ -0,0 +1,139 @@
<script type="text/ecmascript-6">
import LineChart from '../../components/LineChart.vue';
export default {
components: {
LineChart
},
/**
* The component's data.
*/
data() {
return {
ready: false,
rawData: {},
metric: {}
};
},
/**
* Prepare the component.
*/
mounted() {
document.title = "Horizon - Metrics";
this.loadMetric();
},
methods: {
/**
* Load the metric.
*/
loadMetric() {
this.ready = false;
this.$http.get(Horizon.basePath + '/api/metrics/' + this.$route.params.type + '/' + encodeURIComponent(this.$route.params.slug))
.then(response => {
let data = this.prepareData(response.data);
this.rawData = response.data;
this.metric.throughPutChart = this.buildChartData(data, 'throughput', 'Times');
this.metric.runTimeChart = this.buildChartData(data, 'runtime', 'Seconds');
this.ready = true;
});
},
/**
* Prepare the response data for charts.
*/
prepareData(data) {
return Object.values(this.groupBy(data.map(value => ({
...value,
time: this.formatDate(value.time).format("MMM-D hh:mmA"),
})), 'time')).map(value => value.reduce((sum, value) => ({
runtime: parseFloat(sum.runtime) + parseFloat(value.runtime),
throughput: parseInt(sum.throughput) + parseInt(value.throughput),
time: value.time
})))
},
/**
* Build the given chart data.
*/
buildChartData(data, attribute, label) {
return {
labels: data.map(entry => entry.time),
datasets: [
{
label: label,
data: data.map(entry => entry[attribute]),
lineTension: 0,
backgroundColor: 'transparent',
pointBackgroundColor: '#fff',
pointBorderColor: '#7746ec',
borderColor: '#7746ec',
borderWidth: 2,
},
],
};
},
}
}
</script>
<template>
<div>
<div class="card overflow-hidden">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Throughput - {{$route.params.slug}}</h2>
</div>
<div v-if="!ready" class="d-flex align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon spin me-2 fill-text-color">
<path d="M12 10a2 2 0 0 1-3.41 1.41A2 2 0 0 1 10 8V0a9.97 9.97 0 0 1 10 10h-8zm7.9 1.41A10 10 0 1 1 8.59.1v2.03a8 8 0 1 0 9.29 9.29h2.02zm-4.07 0a6 6 0 1 1-7.25-7.25v2.1a3.99 3.99 0 0 0-1.4 6.57 4 4 0 0 0 6.56-1.42h2.1z"></path>
</svg>
<span>Loading...</span>
</div>
<div class="card-body card-bg-secondary" v-if="ready">
<p class="text-center m-0 p-5" v-if="ready && !rawData.length">
Not Enough Data
</p>
<line-chart v-if="ready && rawData.length" :data="metric.throughPutChart"/>
</div>
</div>
<div class="card overflow-hidden mt-4">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Runtime - {{$route.params.slug}}</h2>
</div>
<div v-if="!ready" class="d-flex align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon spin me-2 fill-text-color">
<path d="M12 10a2 2 0 0 1-3.41 1.41A2 2 0 0 1 10 8V0a9.97 9.97 0 0 1 10 10h-8zm7.9 1.41A10 10 0 1 1 8.59.1v2.03a8 8 0 1 0 9.29 9.29h2.02zm-4.07 0a6 6 0 1 1-7.25-7.25v2.1a3.99 3.99 0 0 0-1.4 6.57 4 4 0 0 0 6.56-1.42h2.1z"></path>
</svg>
<span>Loading...</span>
</div>
<div class="card-body card-bg-secondary" v-if="ready">
<p class="text-center m-0 p-5" v-if="ready && !rawData.length">
Not Enough Data
</p>
<line-chart v-if="ready && rawData.length" :data="metric.runTimeChart"/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,79 @@
<script type="text/ecmascript-6">
export default {
components: {},
/**
* The component's data.
*/
data() {
return {
ready: false,
queues: []
};
},
/**
* Prepare the component.
*/
mounted() {
this.loadQueues();
},
methods: {
/**
* Load the queues.
*/
loadQueues() {
this.ready = false;
this.$http.get(Horizon.basePath + '/api/metrics/queues')
.then(response => {
this.queues = response.data;
this.ready = true;
});
}
}
}
</script>
<template>
<div>
<div v-if="!ready" class="d-flex align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon spin me-2 fill-text-color">
<path d="M12 10a2 2 0 0 1-3.41 1.41A2 2 0 0 1 10 8V0a9.97 9.97 0 0 1 10 10h-8zm7.9 1.41A10 10 0 1 1 8.59.1v2.03a8 8 0 1 0 9.29 9.29h2.02zm-4.07 0a6 6 0 1 1-7.25-7.25v2.1a3.99 3.99 0 0 0-1.4 6.57 4 4 0 0 0 6.56-1.42h2.1z"></path>
</svg>
<span>Loading...</span>
</div>
<div v-if="ready && queues.length == 0" class="d-flex flex-column align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<span>There aren't any queues.</span>
</div>
<table v-if="ready && queues.length > 0" class="table table-hover mb-0">
<thead>
<tr>
<th>Queue</th>
</tr>
</thead>
<tbody>
<tr v-for="queue in queues" :key="queue">
<td>
<router-link class="text-decoration-none" :to="{ name: 'metrics-preview', params: { type: 'queues', slug: queue }}">
{{ queue }}
</router-link>
</td>
</tr>
</tbody>
</table>
</div>
</template>
@@ -0,0 +1,198 @@
<script type="text/ecmascript-6">
import { Modal } from 'bootstrap';
export default {
/**
* The component's data.
*/
data() {
return {
ready: false,
newTag: '',
addTagModal: null,
addTagModalOpened: false,
tags: []
};
},
/**
* Prepare the component.
*/
mounted() {
document.title = "Horizon - Monitoring";
},
methods: {
/**
* Load the monitored tags.
*/
loadTags() {
this.$http.get(Horizon.basePath + '/api/monitoring')
.then(response => {
this.tags = response.data;
this.ready = true;
});
},
/**
* Poll handler to refresh the tags at regular intervals.
*/
refreshTagsPeriodically() {
this.loadTags();
},
/**
* Open the modal for adding a new tag.
*/
openNewTagModal() {
this.addTagModal = Modal.getOrCreateInstance(document.getElementById('addTagModel'), {
backdrop: 'static',
});
this.addTagModal.show();
const newTagInput = document.getElementById('newTagInput');
if (newTagInput) {
newTagInput.focus();
}
},
/**
* Monitor the given tag.
*/
monitorNewTag() {
if (!this.newTag) {
const newTagInput = document.getElementById('newTagInput');
if (newTagInput) {
newTagInput.focus();
}
return;
}
this.$http.post(Horizon.basePath + '/api/monitoring', {'tag': this.newTag})
.then(response => {
if (this.addTagModal) {
this.addTagModal.hide();
}
this.tags.push({tag: this.newTag, count: 0});
this.newTag = '';
})
},
/**
* Cancel adding a new tag.
*/
cancelNewTag() {
if (this.addTagModal) {
this.addTagModal.hide();
this.addTagModal.dispose();
this.addTagModal = null;
}
this.newTag = '';
},
/**
* Stop monitoring the given tag.
*/
stopMonitoring(tag) {
this.$http.delete(Horizon.basePath + '/api/monitoring/' + encodeURIComponent(tag))
.then(() => {
this.tags = this.tags.filter(existing => existing.tag !== tag)
})
}
}
}
</script>
<template>
<div>
<poll @poll="refreshTagsPeriodically" />
<div class="card overflow-hidden">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Monitoring</h2>
<button @click="openNewTagModal" class="btn btn-primary btn-sm">Monitor Tag</button>
</div>
<div v-if="!ready" class="d-flex align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon spin me-2 fill-text-color">
<path d="M12 10a2 2 0 0 1-3.41 1.41A2 2 0 0 1 10 8V0a9.97 9.97 0 0 1 10 10h-8zm7.9 1.41A10 10 0 1 1 8.59.1v2.03a8 8 0 1 0 9.29 9.29h2.02zm-4.07 0a6 6 0 1 1-7.25-7.25v2.1a3.99 3.99 0 0 0-1.4 6.57 4 4 0 0 0 6.56-1.42h2.1z"></path>
</svg>
<span>Loading...</span>
</div>
<div v-if="ready && tags.length == 0" class="d-flex flex-column align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<span>You're not monitoring any tags.</span>
</div>
<table v-if="ready && tags.length > 0" class="table table-hover mb-0">
<thead>
<tr>
<th>Tag</th>
<th class="text-end">Jobs</th>
<th class="text-end"></th>
</tr>
</thead>
<tbody>
<tr v-for="tag in tags">
<td>
<router-link :to="{ name: 'monitoring-jobs', params: { tag:tag.tag }}" href="#">
{{ tag.tag }}
</router-link>
</td>
<td class="text-end text-muted">{{ tag.count }}</td>
<td class="text-end">
<a href="#" @click="stopMonitoring(tag.tag)" class="control-action" title="Stop Monitoring">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" />
</svg>
</a>
</td>
</tr>
</tbody>
</table>
</div>
<div class="modal" id="addTagModel" tabindex="-1" role="dialog" aria-labelledby="alertModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">Monitor New Tag</div>
<div class="modal-body">
<input type="text" class="form-control" placeholder="App\Models\User:6352"
v-on:keyup.enter="monitorNewTag"
v-model="newTag"
id="newTagInput">
</div>
<div class="modal-footer justify-content-start flex-row-reverse">
<button class="btn btn-primary" @click="monitorNewTag">
Monitor
</button>
<button class="btn" @click="cancelNewTag">
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,73 @@
<template>
<tr>
<td>
<router-link :title="job.name" :to="{ name: 'job-preview', params: { jobId: job.id, type: $parent.type }}">
{{ jobBaseName(job.name) }}
</router-link>
<small class="ms-1 badge bg-secondary badge-sm" :title="`Delayed for ${delayed}`"
v-if="delayed && (job.status == 'reserved' || job.status == 'pending')">
Delayed
</small>
<br>
<small class="text-muted">
Queue: {{job.queue}}
<span v-if="job.payload.tags.length">
| Tags: {{ job.payload.tags && job.payload.tags.length ? job.payload.tags.slice(0,3).join(', ') : '' }}<span class="text-secondary" v-if="job.payload.tags.length > 3"> +{{ job.payload.tags.length - 3 }} more</span>
</span>
</small>
</td>
<td class="table-fit text-muted">
{{ readableTimestamp(job.payload.pushedAt) }}
</td>
<td v-if="$parent.type == 'jobs'" class="table-fit text-muted">
{{ job.completed_at ? readableTimestamp(job.completed_at) : '-' }}
</td>
<td v-if="$parent.type == 'jobs'" class="table-fit text-muted">
<span>{{ job.completed_at ? (job.completed_at - job.reserved_at).toFixed(2)+'s' : '-' }}</span>
</td>
<td v-if="$parent.type == 'failed'" class="table-fit text-muted">
{{ readableTimestamp(job.failed_at) }}
</td>
</tr>
</template>
<script type="text/ecmascript-6">
import phpunserialize from 'phpunserialize'
import moment from 'moment-timezone';
export default {
props: {
job: {
type: Object,
required: true
}
},
computed: {
unserialized() {
try {
return phpunserialize(this.job.payload.data.command);
}catch(err){
//
}
},
delayed() {
if (this.unserialized && this.unserialized.delay) {
return moment.tz(this.unserialized.delay.date, this.unserialized.delay.timezone)
.fromNow(true);
}
return null;
},
},
}
</script>
@@ -0,0 +1,187 @@
<script type="text/ecmascript-6">
import JobRow from './job-row.vue';
export default {
props: ['type'],
/**
* The component's data.
*/
data() {
return {
ready: false,
loadingNewEntries: false,
hasNewEntries: false,
page: 1,
perPage: 50,
totalPages: 1,
jobs: []
};
},
/**
* Components
*/
components: {
JobRow,
},
/**
* Prepare the component.
*/
mounted() {
document.title = "Horizon - Monitoring";
this.loadJobs(this.$route.params.tag);
},
/**
* Watch these properties for changes.
*/
watch: {
'$route'() {
this.page = 1;
this.loadJobs(this.$route.params.tag);
},
'$root.autoLoadsNewEntries'(autoLoadsNewEntries) {
if (autoLoadsNewEntries && this.hasNewEntries) {
this.hasNewEntries = false;
}
}
},
methods: {
/**
* Load the jobs of the given tag.
*/
loadJobs(tag, starting = 0, refreshing = false) {
if (!refreshing) {
this.ready = false;
}
tag = this.type == 'failed' ? 'failed:' + tag : tag;
this.$http.get(Horizon.basePath + '/api/monitoring/' + encodeURIComponent(tag) + '?starting_at=' + starting + '&limit=' + this.perPage + '&tag=' + encodeURIComponent(tag))
.then(response => {
if (!this.$root.autoLoadsNewEntries && refreshing && this.jobs.length && response.data.jobs[0]?.id !== this.jobs[0]?.id) {
this.hasNewEntries = true;
} else {
this.jobs = response.data.jobs;
this.totalPages = Math.ceil(response.data.total / this.perPage);
}
this.ready = true;
});
},
/**
* Load new entries.
*/
loadNewEntries() {
this.jobs = [];
this.loadJobs(this.$route.params.tag, 0, false);
this.hasNewEntries = false;
},
/**
* Poll handler to refresh the jobs at regular intervals.
*/
refreshJobsPeriodically() {
if (this.page != 1) {
return;
}
this.loadJobs(this.$route.params.tag, 0, true);
},
/**
* Load the jobs for the previous page.
*/
previous() {
this.loadJobs(this.$route.params.tag,
(this.page - 2) * this.perPage
);
this.page -= 1;
this.hasNewEntries = false;
},
/**
* Load the jobs for the next page.
*/
next() {
this.loadJobs(this.$route.params.tag,
this.page * this.perPage
);
this.page += 1;
this.hasNewEntries = false;
}
}
}
</script>
<template>
<div>
<poll @poll="refreshJobsPeriodically" />
<div v-if="!ready" class="d-flex align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon spin me-2 fill-text-color">
<path d="M12 10a2 2 0 0 1-3.41 1.41A2 2 0 0 1 10 8V0a9.97 9.97 0 0 1 10 10h-8zm7.9 1.41A10 10 0 1 1 8.59.1v2.03a8 8 0 1 0 9.29 9.29h2.02zm-4.07 0a6 6 0 1 1-7.25-7.25v2.1a3.99 3.99 0 0 0-1.4 6.57 4 4 0 0 0 6.56-1.42h2.1z"></path>
</svg>
<span>Loading...</span>
</div>
<div v-if="ready && jobs.length == 0" class="d-flex flex-column align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<span>There aren't any jobs for this tag.</span>
</div>
<table v-if="ready && jobs.length > 0" class="table table-hover mb-0">
<thead>
<tr>
<th>Job</th>
<th>Queued</th>
<th v-if="type == 'jobs'">Completed</th>
<th class="text-end" v-if="type == 'jobs'">Runtime</th>
<th class="text-end" v-if="type == 'failed'">Failed</th>
</tr>
</thead>
<tbody>
<tr v-if="hasNewEntries && !this.$root.autoLoadsNewEntries" key="newEntries" class="dontanimate">
<td colspan="100" class="text-center card-bg-secondary py-2">
<small><a href="#" v-on:click.prevent="loadNewEntries" v-if="!loadingNewEntries">Load New Entries</a></small>
<small v-if="loadingNewEntries">Loading...</small>
</td>
</tr>
<component v-for="job in jobs" :key="job.id" :job="job" is="job-row">
</component>
</tbody>
</table>
<div v-if="ready && jobs.length" class="p-3 d-flex justify-content-between border-top">
<button @click="previous" class="btn btn-secondary btn-sm" :disabled="page==1">Previous</button>
<button @click="next" class="btn btn-secondary btn-sm" :disabled="page>=totalPages">Next</button>
</div>
</div>
</template>
@@ -0,0 +1,30 @@
<script type="text/ecmascript-6">
export default {}
</script>
<template>
<div>
<div class="card overflow-hidden">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Recent Jobs for "{{ $route.params.tag }}"</h2>
</div>
<ul class="nav nav-pills card-bg-secondary">
<li class="nav-item">
<router-link class="nav-link text-decoration-none" active-class="active" :to="{ name: 'monitoring-jobs', params: { tag:$route.params.tag }}" href="#">
Recent Jobs
</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link text-decoration-none" active-class="active" :to="{ name: 'monitoring-failed', params: { tag:$route.params.tag }}" href="#">
Failed Jobs
</router-link>
</li>
</ul>
<router-view/>
</div>
</div>
</template>
@@ -0,0 +1,208 @@
<script type="text/ecmascript-6">
import JobRow from './job-row.vue';
export default {
/**
* The component's data.
*/
data() {
return {
ready: false,
loadingNewEntries: false,
hasNewEntries: false,
page: 1,
perPage: 50,
totalPages: 1,
jobs: []
};
},
/**
* Components
*/
components: {
JobRow,
},
/**
* Prepare the component.
*/
mounted() {
this.updatePageTitle();
this.loadJobs();
},
/**
* Watch these properties for changes.
*/
watch: {
'$route'() {
this.updatePageTitle();
this.page = 1;
this.loadJobs();
},
'$root.autoLoadsNewEntries'(autoLoadsNewEntries) {
if (autoLoadsNewEntries && this.hasNewEntries) {
this.hasNewEntries = false;
}
}
},
methods: {
/**
* Load the jobs of the given tag.
*/
loadJobs(starting = -1, refreshing = false) {
if (!refreshing) {
this.ready = false;
}
this.$http.get(Horizon.basePath + '/api/jobs/' + this.$route.params.type + '?starting_at=' + starting + '&limit=' + this.perPage)
.then(response => {
if (!this.$root.autoLoadsNewEntries && refreshing && this.jobs.length && response.data.jobs[0]?.id !== this.jobs[0]?.id) {
this.hasNewEntries = true;
} else {
this.jobs = response.data.jobs;
this.totalPages = Math.ceil(response.data.total / this.perPage);
}
this.ready = true;
});
},
loadNewEntries() {
this.jobs = [];
this.loadJobs(-1, false);
this.hasNewEntries = false;
},
/**
* Poll handler to refresh the jobs at regular intervals.
*/
refreshJobsPeriodically() {
if (this.page != 1) {
return;
}
this.loadJobs(-1, true);
},
/**
* Load the jobs for the previous page.
*/
previous() {
this.loadJobs(
(this.page - 2) * this.perPage - 1
);
this.page -= 1;
this.hasNewEntries = false;
},
/**
* Load the jobs for the next page.
*/
next() {
this.loadJobs(
this.page * this.perPage - 1
);
this.page += 1;
this.hasNewEntries = false;
},
/**
* Update the page title.
*/
updatePageTitle() {
document.title = this.$route.params.type == 'pending'
? 'Horizon - Pending Jobs'
: (
this.$route.params.type == 'silenced'
? 'Horizon - Silenced Jobs'
: 'Horizon - Completed Jobs'
);
}
}
}
</script>
<template>
<div>
<poll @poll="refreshJobsPeriodically" />
<div class="card overflow-hidden">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0" v-if="$route.params.type == 'pending'">Pending Jobs</h2>
<h2 class="h6 m-0" v-if="$route.params.type == 'completed'">Completed Jobs</h2>
<h2 class="h6 m-0" v-if="$route.params.type == 'silenced'">Silenced Jobs</h2>
</div>
<div v-if="!ready"
class="d-flex align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon spin me-2 fill-text-color">
<path
d="M12 10a2 2 0 0 1-3.41 1.41A2 2 0 0 1 10 8V0a9.97 9.97 0 0 1 10 10h-8zm7.9 1.41A10 10 0 1 1 8.59.1v2.03a8 8 0 1 0 9.29 9.29h2.02zm-4.07 0a6 6 0 1 1-7.25-7.25v2.1a3.99 3.99 0 0 0-1.4 6.57 4 4 0 0 0 6.56-1.42h2.1z"></path>
</svg>
<span>Loading...</span>
</div>
<div v-if="ready && jobs.length == 0"
class="d-flex flex-column align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<span v-if="$route.params.type == 'pending'">There aren't any pending jobs.</span>
<span v-else-if="$route.params.type == 'completed'">There aren't any completed jobs.</span>
<span v-else-if="$route.params.type == 'silenced'">There aren't any silenced jobs.</span>
<span v-else>There aren't any jobs.</span>
</div>
<table v-if="ready && jobs.length > 0" class="table table-hover mb-0">
<thead>
<tr>
<th>Job</th>
<th v-if="$route.params.type=='pending'" class="text-end">Queued</th>
<th v-if="$route.params.type=='completed' || $route.params.type=='silenced'">Queued</th>
<th v-if="$route.params.type=='completed' || $route.params.type=='silenced'">Completed</th>
<th v-if="$route.params.type=='completed' || $route.params.type=='silenced'" class="text-end">Runtime</th>
</tr>
</thead>
<tbody>
<tr v-if="hasNewEntries && !this.$root.autoLoadsNewEntries" key="newEntries" class="dontanimate">
<td colspan="100" class="text-center card-bg-secondary py-1">
<small><a href="#" v-on:click.prevent="loadNewEntries" v-if="!loadingNewEntries">Load New Entries</a></small>
<small v-if="loadingNewEntries">Loading...</small>
</td>
</tr>
<component v-for="job in jobs" :key="job.id" :job="job" is="job-row">
</component>
</tbody>
</table>
<div v-if="ready && jobs.length" class="p-3 d-flex justify-content-between border-top">
<button @click="previous" class="btn btn-secondary btn-sm" :disabled="page==1">Previous</button>
<button @click="next" class="btn btn-secondary btn-sm" :disabled="page>=totalPages">Next</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,73 @@
<template>
<tr>
<td>
<router-link :title="job.name" :to="{ name: 'job-preview', params: { jobId: job.id, type: $route.params.type }}">
{{ jobBaseName(job.name) }}
</router-link>
<small class="ms-1 badge bg-secondary badge-sm"
:title="`Delayed for ${delayed}`"
v-if="delayed && (job.status == 'reserved' || job.status == 'pending')">
Delayed
</small>
<br>
<small class="text-muted">
Queue: {{job.queue}}
<span v-if="job.payload.tags && job.payload.tags.length" class="text-break">
| Tags: {{ job.payload.tags && job.payload.tags.length ? job.payload.tags.slice(0,3).join(', ') : '' }}<span class="text-secondary" v-if="job.payload.tags.length > 3"> +{{ job.payload.tags.length - 3 }} more</span>
</span>
</small>
</td>
<td class="table-fit text-muted">
{{ readableTimestamp(job.payload.pushedAt) }}
</td>
<td v-if="$route.params.type=='completed' || $route.params.type=='silenced'" class="table-fit text-muted">
{{ readableTimestamp(job.completed_at) }}
</td>
<td v-if="$route.params.type=='completed' || $route.params.type=='silenced'" class="table-fit text-end text-muted">
<span>{{ job.completed_at ? (job.completed_at - job.reserved_at).toFixed(2)+'s' : '-' }}</span>
</td>
</tr>
</template>
<script type="text/ecmascript-6">
import phpunserialize from 'phpunserialize'
import moment from 'moment-timezone';
export default {
props: {
job: {
type: Object,
required: true
}
},
computed: {
unserialized() {
try {
return phpunserialize(this.job.payload.data.command);
}catch(err){
//
}
},
delayed() {
if (this.unserialized && this.unserialized.delay && this.unserialized.delay.date) {
return moment.tz(this.unserialized.delay.date, this.unserialized.delay.timezone)
.fromNow(true);
} else if (this.unserialized && this.unserialized.delay) {
return this.formatDate(this.job.payload.pushedAt).add(this.unserialized.delay, 'seconds')
.fromNow(true);
}
return null;
},
},
}
</script>
@@ -0,0 +1,173 @@
<template>
<div>
<div class="card overflow-hidden">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0" v-if="!ready">Job Preview</h2>
<h2 class="h6 m-0" v-if="ready">{{job.name}}</h2>
<a data-bs-toggle="collapse" href="#collapseDetails" role="button">
Collapse
</a>
</div>
<div v-if="!ready" class="d-flex align-items-center justify-content-center card-bg-secondary p-5 bottom-radius">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon spin me-2 fill-text-color">
<path d="M12 10a2 2 0 0 1-3.41 1.41A2 2 0 0 1 10 8V0a9.97 9.97 0 0 1 10 10h-8zm7.9 1.41A10 10 0 1 1 8.59.1v2.03a8 8 0 1 0 9.29 9.29h2.02zm-4.07 0a6 6 0 1 1-7.25-7.25v2.1a3.99 3.99 0 0 0-1.4 6.57 4 4 0 0 0 6.56-1.42h2.1z"></path>
</svg>
<span>Loading...</span>
</div>
<div class="card-body card-bg-secondary collapse show" id="collapseDetails" v-if="ready">
<div class="row mb-2">
<div class="col-md-2 text-muted">ID</div>
<div class="col">{{job.id}}</div>
</div>
<div class="row mb-2">
<div class="col-md-2 text-muted">Connection</div>
<div class="col">{{job.connection}}</div>
</div>
<div class="row mb-2">
<div class="col-md-2 text-muted">Queue</div>
<div class="col">{{job.queue}}</div>
</div>
<div class="row mb-2">
<div class="col-md-2 text-muted">Pushed</div>
<div class="col">{{ readableTimestamp(job.payload.pushedAt) }}</div>
</div>
<div class="row mb-2" v-if="prettyPrintJob(job.payload.data).batchId">
<div class="col-md-2 text-muted">Batch</div>
<div class="col">
<router-link :to="{ name: 'batches-preview', params: { batchId: prettyPrintJob(job.payload.data).batchId }}">
{{ prettyPrintJob(job.payload.data).batchId }}
</router-link>
</div>
</div>
<div class="row mb-2" v-if="delayed">
<div class="col-md-2 text-muted">Delayed Until</div>
<div class="col">{{delayed}}</div>
</div>
<div class="row">
<div class="col-md-2 text-muted">Completed</div>
<div class="col" v-if="job.completed_at">{{readableTimestamp(job.completed_at)}}</div>
<div class="col" v-else>-</div>
</div>
</div>
</div>
<div class="card overflow-hidden mt-4" v-if="ready">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Data</h2>
<a data-bs-toggle="collapse" href="#collapseData" role="button">
Collapse
</a>
</div>
<div class="card-body code-bg text-white collapse show" id="collapseData">
<vue-json-pretty :data="prettyPrintJob(job.payload.data)"></vue-json-pretty>
</div>
</div>
<div class="card overflow-hidden mt-4" v-if="ready && job.payload.tags.length">
<div class="card-header d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">Tags</h2>
<a data-bs-toggle="collapse" href="#collapseTags" role="button">
Collapse
</a>
</div>
<div class="card-body code-bg text-white collapse show" id="collapseTags">
<vue-json-pretty :data="job.payload.tags"></vue-json-pretty>
</div>
</div>
</div>
</template>
<script type="text/ecmascript-6">
import phpunserialize from 'phpunserialize';
import moment from 'moment-timezone';
import StackTrace from './../../components/Stacktrace.vue';
export default {
components: {
'stack-trace': StackTrace,
},
data() {
return {
ready: false,
job: {}
};
},
computed: {
unserialized() {
return phpunserialize(this.job.payload.data.command);
},
delayed() {
let unserialized;
try {
unserialized = phpunserialize(this.job.payload.data.command);
}catch(err){
//
}
if (unserialized && unserialized.delay && unserialized.delay.date) {
return moment.tz(unserialized.delay.date, unserialized.delay.timezone)
.local()
.format('YYYY-MM-DD HH:mm:ss');
} else if (unserialized && unserialized.delay) {
return this.formatDate(this.job.payload.pushedAt).add(unserialized.delay, 'seconds')
.local()
.format('YYYY-MM-DD HH:mm:ss');
}
return null;
},
},
mounted() {
this.loadJob(this.$route.params.jobId);
document.title = "Horizon - Job Detail";
},
methods: {
/**
* Load a job by the given ID.
*/
loadJob(id) {
this.ready = false;
this.$http.get(Horizon.basePath + '/api/jobs/' + id)
.then(response => {
this.job = response.data;
this.ready = true;
});
},
/**
* Pretty print serialized job.
*/
prettyPrintJob(data) {
try {
return data.command && !data.command.includes('CallQueuedClosure')
? phpunserialize(data.command) : data;
} catch (err) {
return data;
}
}
}
}
</script>
@@ -0,0 +1,68 @@
$white: #ffffff;
$black: #000000;
$gray-50: #f9fafb;
$gray-100: #f3f4f6;
$gray-200: #e5e7eb;
$gray-300: #d1d5db;
$gray-400: #9ca3af;
$gray-500: #6b7280;
$gray-600: #4b5563;
$gray-700: #374151;
$gray-800: #1f2937;
$gray-900: #111827;
$red-50: #fef2f2;
$red-100: #fee2e2;
$red-200: #fecaca;
$red-300: #fca5a5;
$red-400: #f87171;
$red-500: #ef4444;
$red-600: #dc2626;
$red-700: #b91c1c;
$red-800: #991b1b;
$red-900: #7f1d1d;
$amber-50: #fffbeb;
$amber-100: #fef3c7;
$amber-200: #fde68a;
$amber-300: #fcd34d;
$amber-400: #fbbf24;
$amber-500: #f59e0b;
$amber-600: #d97706;
$amber-700: #b45309;
$amber-800: #92400e;
$amber-900: #78350f;
$emerald-50: #ecfdf5;
$emerald-100: #d1fae5;
$emerald-200: #a7f3d0;
$emerald-300: #6ee7b7;
$emerald-400: #34d399;
$emerald-500: #10b981;
$emerald-600: #059669;
$emerald-700: #047857;
$emerald-800: #065f46;
$emerald-900: #064e3b;
$blue-50: #eff6ff;
$blue-100: #dbeafe;
$blue-200: #bfdbfe;
$blue-300: #93c5fd;
$blue-400: #60a5fa;
$blue-500: #3b82f6;
$blue-600: #2563eb;
$blue-700: #1d4ed8;
$blue-800: #1e40af;
$blue-900: #1e3a8a;
$violet-50: #f5f3ff;
$violet-100: #ede9fe;
$violet-200: #ddd6fe;
$violet-300: #c4b5fd;
$violet-400: #a78bfa;
$violet-500: #8b5cf6;
$violet-600: #7c3aed;
$violet-700: #6d28d9;
$violet-800: #5b21b6;
$violet-900: #4c1d95;
@@ -0,0 +1,360 @@
@import 'syntaxhighlight';
@import 'bootstrap';
body {
padding-bottom: 20px;
}
.container {
max-width: 1440px;
}
html {
min-width: 1140px;
}
[v-cloak] {
display: none;
}
svg.icon {
width: 1rem;
height: 1rem;
}
.header {
border-bottom: solid 1px $header-border-color;
.logo {
text-decoration: none;
color: $logo-color;
svg {
width: 2rem;
height: 2rem;
}
}
}
.sidebar .nav-item {
a {
color: $sidebar-nav-color;
padding: 0.5rem 0.75rem;
margin-bottom: 4px;
border-radius: $border-radius-lg;
svg {
width: 1.25rem;
height: 1.25rem;
margin-right: 15px;
fill: $sidebar-nav-icon-color;
}
&:hover {
background-color: $sidebar-nav-hover-bg;
color: $sidebar-nav-hover-color;
}
&.active {
background-color: $sidebar-nav-active-bg;
color: $sidebar-nav-active-color;
svg {
fill: $sidebar-nav-active-icon-color;
}
}
}
}
.card {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
border: none;
.bottom-radius {
border-bottom-left-radius: $card-border-radius;
border-bottom-right-radius: $card-border-radius;
}
.card-header {
padding-top: 0.7rem;
padding-bottom: 0.7rem;
background-color: $card-cap-bg;
border-bottom: none;
min-height: 60px;
.btn-group {
.btn {
padding: 0.2rem 0.5rem;
}
}
.form-control-with-icon {
position: relative;
.icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 0;
left: 0.75rem;
bottom: 0;
.icon {
fill: $text-muted;
}
}
.form-control {
padding-left: 2.25rem;
font-size: 0.875rem;
border-radius: 9999px;
}
}
}
.table {
th,
td {
padding: 0.75rem 1.25rem;
}
&.table-sm {
th,
td {
padding: 1rem 1.25rem;
}
}
th {
background-color: $table-headers-color;
font-size: 0.875rem;
padding: 0.5rem 1.25rem;
border-bottom: 0;
}
&:not(.table-borderless) {
td {
border-top: 1px solid $table-border-color;
}
}
&.penultimate-column-right {
th:nth-last-child(2),
td:nth-last-child(2) {
text-align: right;
}
}
th.table-fit,
td.table-fit {
width: 1%;
white-space: nowrap;
}
}
}
.fill-text-color {
fill: $body-color;
}
.fill-danger {
fill: $danger;
}
.fill-warning {
fill: $warning;
}
.fill-info {
fill: $info;
}
.fill-success {
fill: $success;
}
.fill-primary {
fill: $primary;
}
button:hover {
.fill-primary {
fill: #fff;
}
}
.btn-outline-primary.active {
.fill-primary {
fill: $body-bg;
}
}
.btn-outline-primary:not(:disabled):not(.disabled).active:focus {
box-shadow: none !important;
}
.btn-muted {
color: $btn-muted-color;
background: $btn-muted-bg;
&:hover,
&:focus {
color: $btn-muted-hover-color;
background: $btn-muted-hover-bg;
}
&.active {
color: $btn-muted-active-color;
background: $btn-muted-active-bg;
}
}
.badge-secondary {
background: $badge-secondary-bg;
color: $badge-secondary-color;
}
.badge-success {
background: $badge-success-bg;
color: $badge-success-color;
}
.badge-info {
background: $badge-info-bg;
color: $badge-info-color;
}
.badge-warning {
background: $badge-warning-bg;
color: $badge-warning-color;
}
.badge-danger {
background: $badge-danger-bg;
color: $badge-danger-color;
}
.control-action {
svg {
fill: $control-action-icon-color;
width: 1.2rem;
height: 1.2rem;
&:hover {
fill: $control-action-icon-hover;
}
}
}
.info-icon {
fill: $control-action-icon-color;
}
@-webkit-keyframes spin {
from {
-ms-transform: rotate(0deg);
-moz-transform: rotate(0deg);
-webkit-transform: rotate(0deg);
-o-transform: rotate(0deg);
transform: rotate(0deg);
}
to {
-ms-transform: rotate(360deg);
-moz-transform: rotate(360deg);
-webkit-transform: rotate(360deg);
-o-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes spin {
from {
-ms-transform: rotate(0deg);
-moz-transform: rotate(0deg);
-webkit-transform: rotate(0deg);
-o-transform: rotate(0deg);
transform: rotate(0deg);
}
to {
-ms-transform: rotate(360deg);
-moz-transform: rotate(360deg);
-webkit-transform: rotate(360deg);
-o-transform: rotate(360deg);
transform: rotate(360deg);
}
}
.spin {
-webkit-animation: spin 2s linear infinite;
-moz-animation: spin 2s linear infinite;
-ms-animation: spin 2s linear infinite;
-o-animation: spin 2s linear infinite;
animation: spin 2s linear infinite;
}
.card {
.nav-pills {
background: $card-cap-bg;
.nav-link {
font-size: 0.9rem;
border-radius: 0;
padding: 0.75rem 1.25rem;
color: $pill-link;
&:hover,
&:focus {
color: $pill-link-hover;
}
&.active {
background: none;
color: $pill-link-active;
border-bottom: solid 2px $pill-link-active;
}
}
}
}
.list-enter-active:not(.dontanimate) {
transition: background 1s linear;
}
.list-enter:not(.dontanimate),
.list-leave-to:not(.dontanimate) {
background: $new-entries-bg;
}
.code-bg .list-enter:not(.dontanimate),
.code-bg .list-leave-to:not(.dontanimate) {
background: $new-code-entries-bg;
}
.card table {
td {
vertical-align: middle !important;
}
}
.card-bg-secondary {
background: $card-bg-secondary;
}
.code-bg {
background: $code-bg;
}
.disabled-watcher {
padding: 0.75rem;
color: #fff;
background: $danger;
}
.badge-sm {
font-size: 0.75rem;
}
.table > :not(:first-child) {
border-top: none;
}
@@ -0,0 +1,113 @@
@import 'colors';
$font-family-base: Figtree, sans-serif;
$font-weight-bold: 600;
$font-size-base: 1rem;
$badge-font-size: 0.875rem;
$link-decoration: none;
$link-hover-decoration: underline;
$primary: $violet-500;
$secondary: $gray-500;
$success: $emerald-500;
$info: $blue-500;
$warning: $amber-500;
$danger: $red-500;
$body-bg: $gray-900;
$body-color: $gray-100;
$text-muted: $gray-400;
$border-radius-lg: 6px;
$logo-color: $gray-200;
$link-color: $violet-400;
$link-hover-color: $violet-300;
$sidebar-nav-color: $gray-400;
$sidebar-nav-hover-color: $gray-300;
$sidebar-nav-hover-bg: $gray-800;
$sidebar-nav-icon-color: $gray-500;
$sidebar-nav-active-bg: $gray-800;
$sidebar-nav-active-color: $violet-400;
$sidebar-nav-active-icon-color: $violet-500;
$pill-link: $gray-400;
$pill-link-active: $violet-400;
$pill-link-hover: $gray-200;
$border-color: $gray-600;
$table-border-color: $gray-700;
$table-headers-color: $gray-800;
$table-hover-bg: $gray-700;
$header-border-color: $table-border-color;
$input-bg: $gray-800;
$input-color: $gray-200;
$input-border-color: $border-color;
$card-cap-bg: $gray-700;
$card-bg-secondary: $gray-800;
$card-bg: $gray-800;
$card-border-radius: $border-radius-lg;
$code-bg: #292d3e;
$modal-content-bg: $table-headers-color;
$modal-backdrop-bg: $gray-600;
$modal-footer-border-color: $input-border-color;
$modal-header-border-color: $input-border-color;
$new-entries-bg: $violet-900;
$new-code-entries-bg: $gray-600;
$control-action-icon-color: $gray-500;
$control-action-icon-hover: $violet-400;
$nav-pills-link-active-bg: $gray-800;
$dropdown-bg: $gray-700;
$dropdown-link-color: $white;
$btn-muted-color: $gray-400;
$btn-muted-bg: $gray-800;
$btn-muted-hover-color: $gray-300;
$btn-muted-hover-bg: $gray-700;
$btn-muted-active-color: $white;
$btn-muted-active-bg: $primary;
$badge-secondary-bg: $gray-300;
$badge-secondary-color: $gray-700;
$badge-success-bg: $emerald-500;
$badge-success-color: $white;
$badge-info-bg: $blue-500;
$badge-info-color: $white;
$badge-warning-bg: $amber-500;
$badge-warning-color: $white;
$badge-danger-bg: $red-500;
$badge-danger-color: $white;
$grid-breakpoints: (
xs: 0,
sm: 2px,
md: 8px,
lg: 9px,
xl: 10px,
) !default;
$container-max-widths: (
sm: 1137px,
md: 1138px,
lg: 1139px,
xl: 1140px,
) !default;
@import 'base';
.btn-primary {
color: rgb(255, 255, 255);
}
@@ -0,0 +1,103 @@
@import 'colors';
$font-family-base: Figtree, sans-serif;
$font-weight-bold: 600;
$font-size-base: 1rem;
$badge-font-size: 0.875rem;
$link-decoration: none;
$link-hover-decoration: underline;
$primary: #7746ec;
$secondary: $gray-500;
$success: $emerald-500;
$info: $blue-500;
$warning: $amber-500;
$danger: $red-500;
$body-bg: $gray-100;
$body-color: $gray-900;
$text-muted: $gray-500;
$border-radius-lg: 6px;
$btn-focus-width: 0;
$logo-color: $gray-700;
$sidebar-nav-color: $gray-600;
$sidebar-nav-hover-color: $primary;
$sidebar-nav-hover-bg: $gray-200;
$sidebar-nav-icon-color: $gray-400;
$sidebar-nav-active-bg: $gray-200;
$sidebar-nav-active-color: $primary;
$sidebar-nav-active-icon-color: $primary;
$pill-link: $gray-600;
$pill-link-active: $violet-600;
$pill-link-hover: $gray-800;
$border-color: $gray-300;
$table-headers-color: $gray-100;
$table-border-color: $gray-200;
$table-hover-bg: $gray-100;
$header-border-color: $table-border-color;
$input-bg: $white;
$input-color: $gray-800;
$input-border-color: $border-color;
$card-cap-bg: $white;
$card-bg-secondary: $gray-100;
$card-bg: $white;
$card-border-radius: $border-radius-lg;
$code-bg: #292d3e;
$new-entries-bg: $violet-50;
$new-code-entries-bg: $gray-600;
$control-action-icon-color: $gray-300;
$control-action-icon-hover: $violet-600;
$nav-pills-link-active-bg: $gray-200;
$dropdown-bg: $white;
$dropdown-link-color: $gray-700;
$btn-muted-color: $gray-600;
$btn-muted-bg: $gray-200;
$btn-muted-hover-color: $gray-900;
$btn-muted-hover-bg: $gray-300;
$btn-muted-active-color: $white;
$btn-muted-active-bg: $primary;
$badge-secondary-bg: $gray-200;
$badge-secondary-color: $gray-600;
$badge-success-bg: $emerald-100;
$badge-success-color: $emerald-600;
$badge-info-bg: $blue-100;
$badge-info-color: $blue-600;
$badge-warning-bg: $amber-100;
$badge-warning-color: $amber-600;
$badge-danger-bg: $red-100;
$badge-danger-color: $red-600;
$grid-breakpoints: (
xs: 0,
sm: 2px,
md: 8px,
lg: 9px,
xl: 10px
) !default;
$container-max-widths: (
sm: 1137px,
md: 1138px,
lg: 1139px,
xl: 1140px
) !default;
@import 'base';
@@ -0,0 +1,54 @@
.vjs-tree {
font-family: 'Monaco', 'Menlo', 'Consolas', 'Bitstream Vera Sans Mono', monospace !important;
&.is-root {
position: relative;
}
.vjs-tree-node {
display: flex;
position: relative;
&:hover {
background-color: unset;
}
.vjs-indent-unit {
&.has-line {
border-left: 1px dotted rgba(204, 204, 204, 0.28) !important;
}
}
&.has-carets {
padding-left: 15px;
}
.has-carets.has-selector,
.has-selector {
padding-left: 30px;
}
}
.vjs-indent {
display: flex;
position: relative;
}
.vjs-indent-unit {
width: 1em;
}
.vjs-tree-brackets {
cursor: pointer;
&:hover {
color: #20a0ff;
}
}
.vjs-key {
color: #c3cbd3 !important;
padding-right: 10px;
}
.vjs-value {
@extend .text-break;
}
.vjs-value-string {
color: #c3e88d !important;
}
.vjs-value-null,
.vjs-value-number,
.vjs-value-boolean,
.vjs-value-undefined {
color: #a291f5 !important;
}
}
@@ -0,0 +1,133 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Meta Information -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="csrf-token" content="{{ csrf_token() }}">
<link rel="shortcut icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAipJREFUeNrEV8txwjAQtQ2HHCmB3JKbSQOYCoA0gD0pgFBBwpEToQAGKmDglpwgFdg5kZtNB1BBsuusZ4RY2ZZjYGd2jGWh97Q/rUwjpziPT3V4dECboDZoXZoSka5Al5vFNMqzrpkD2IFHn8B1ZAM6BCKbQgQAuAaPWQFgjoinsoipAEcTr0FrRjmyJxLLTAI5wXFXAehBGMPYcDKIIIm5kkAGOJpwAjqHRfYpbkOXvTBBypIwpT+HCvA3Cqi9Rta8EhHOHS1YCy1oWMKHmQIcGQ90wGMfLaZIoEGAoiDGOHmxhFTr5PGZJgncZYszEGC6ogX6nNn/Ay6RGDCfYveYVOFCJuAaumbPiIk1kyUNS2H6SZngyZrMWM+i/JVlXjK4QUVI3pRTpYPlaG6yeyGvm0Jef1ItiArwQBKu8G5bTMEIhKLkU3q65D+HgieE7+MCBHbygMVMOlCK+CnVDOUZ5s00ghCt2T45C+DDD2MBW/O066YFLYGvuXU5C9i6GYaLUzqr+olQtS5aIMwwtW6QfQnv7awNVanolEWgo9nABBb1cNeSmMDyigRWZkqdPrdEkDm3SRYMr7D7odwRXdIK8e7lOuAxh8W5pHtSiOhw8S4A7iX9IErlyC5b/7t+/7Ar4TKiEuyyRuJA5cQ5Wz8gEhgPNyXvfCQPVtgI+SPxAT/vSqiSEbXh70Uvp27GRSMNeJjV2Jp5V6MGpUeuUR0wAemKuwdy8ivAAJcc0R2NFxWtAAAAAElFTkSuQmCC">
<title>Horizon{{ config('horizon.name') ? ' - ' . config('horizon.name') : '' }}</title>
<!-- Style sheets-->
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:300,400,500,600" rel="stylesheet" />
{{ Laravel\Horizon\Horizon::css() }}
{{ Laravel\Horizon\Horizon::js() }}
</head>
<body>
<div id="horizon" v-cloak>
<alert :message="alert.message"
:type="alert.type"
:auto-close="alert.autoClose"
:confirmation-proceed="alert.confirmationProceed"
:confirmation-cancel="alert.confirmationCancel"
v-if="alert.type"></alert>
<div class="container mb-5">
<div class="d-flex align-items-center py-4 header">
<router-link to="/" class="logo d-flex align-items-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30">
<path class="fill-primary" d="M5.26176342 26.4094389C2.04147988 23.6582233 0 19.5675182 0 15c0-4.1421356 1.67893219-7.89213562 4.39339828-10.60660172C7.10786438 1.67893219 10.8578644 0 15 0c8.2842712 0 15 6.71572875 15 15 0 8.2842712-6.7157288 15-15 15-3.716753 0-7.11777662-1.3517984-9.73823658-3.5905611zM4.03811305 15.9222506C5.70084247 14.4569342 6.87195416 12.5 10 12.5c5 0 5 5 10 5 3.1280454 0 4.2991572-1.9569336 5.961887-3.4222502C25.4934253 8.43417206 20.7645408 4 15 4 8.92486775 4 4 8.92486775 4 15c0 .3105915.01287248.6181765.03811305.9222506z"/>
</svg>
<h1 class="h4 mb-0 ms-2">
<strong>Laravel</strong> Horizon{{ config('horizon.name') ? ' - ' . config('horizon.name') : '' }}
</h1>
</router-link>
<div class="ms-auto">
<scheme-toggler></scheme-toggler>
<button class="btn btn-muted ms-2" :class="{active: autoLoadsNewEntries}" v-on:click.prevent="autoLoadNewEntries" title="Auto Load New Entries">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="icon" fill="currentColor">
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
<div class="row mt-4">
<div class="col-2 sidebar">
<ul class="nav flex-column">
<li class="nav-item">
<router-link active-class="active" to="/dashboard" class="nav-link d-flex align-items-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.25 2A2.25 2.25 0 002 4.25v2.5A2.25 2.25 0 004.25 9h2.5A2.25 2.25 0 009 6.75v-2.5A2.25 2.25 0 006.75 2h-2.5zm0 9A2.25 2.25 0 002 13.25v2.5A2.25 2.25 0 004.25 18h2.5A2.25 2.25 0 009 15.75v-2.5A2.25 2.25 0 006.75 11h-2.5zm9-9A2.25 2.25 0 0011 4.25v2.5A2.25 2.25 0 0013.25 9h2.5A2.25 2.25 0 0018 6.75v-2.5A2.25 2.25 0 0015.75 2h-2.5zm0 9A2.25 2.25 0 0011 13.25v2.5A2.25 2.25 0 0013.25 18h2.5A2.25 2.25 0 0018 15.75v-2.5A2.25 2.25 0 0015.75 11h-2.5z" clip-rule="evenodd" />
</svg>
<span>Dashboard</span>
</router-link>
</li>
<li class="nav-item">
<router-link active-class="active" to="/monitoring" class="nav-link d-flex align-items-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clip-rule="evenodd" />
</svg>
<span>Monitoring</span>
</router-link>
</li>
<li class="nav-item">
<router-link active-class="active" to="/metrics" class="nav-link d-flex align-items-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M15.5 2A1.5 1.5 0 0014 3.5v13a1.5 1.5 0 001.5 1.5h1a1.5 1.5 0 001.5-1.5v-13A1.5 1.5 0 0016.5 2h-1zM9.5 6A1.5 1.5 0 008 7.5v9A1.5 1.5 0 009.5 18h1a1.5 1.5 0 001.5-1.5v-9A1.5 1.5 0 0010.5 6h-1zM3.5 10A1.5 1.5 0 002 11.5v5A1.5 1.5 0 003.5 18h1A1.5 1.5 0 006 16.5v-5A1.5 1.5 0 004.5 10h-1z" />
</svg>
<span>Metrics</span>
</router-link>
</li>
<li class="nav-item">
<router-link active-class="active" to="/batches" class="nav-link d-flex align-items-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M2 3.75A.75.75 0 012.75 3h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 3.75zm0 4.167a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75a.75.75 0 01-.75-.75zm0 4.166a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75a.75.75 0 01-.75-.75zm0 4.167a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75a.75.75 0 01-.75-.75z" clip-rule="evenodd" />
</svg>
<span>Batches</span>
</router-link>
</li>
<li class="nav-item">
<router-link active-class="active" to="/jobs/pending" class="nav-link d-flex align-items-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M2 10a8 8 0 1116 0 8 8 0 01-16 0zm5-2.25A.75.75 0 017.75 7h.5a.75.75 0 01.75.75v4.5a.75.75 0 01-.75.75h-.5a.75.75 0 01-.75-.75v-4.5zm4 0a.75.75 0 01.75-.75h.5a.75.75 0 01.75.75v4.5a.75.75 0 01-.75.75h-.5a.75.75 0 01-.75-.75v-4.5z" clip-rule="evenodd" />
</svg>
<span>Pending Jobs</span>
</router-link>
</li>
<li class="nav-item">
<router-link active-class="active" to="/jobs/completed" class="nav-link d-flex align-items-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" />
</svg>
<span>Completed Jobs</span>
</router-link>
</li>
<li class="nav-item">
<router-link active-class="active" to="/jobs/silenced" class="nav-link d-flex align-items-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M4 8c0-.26.017-.517.049-.77l7.722 7.723a33.56 33.56 0 01-3.722-.01 2 2 0 003.862.15l1.134 1.134a3.5 3.5 0 01-6.53-1.409 32.91 32.91 0 01-3.257-.508.75.75 0 01-.515-1.076A11.448 11.448 0 004 8zM17.266 13.9a.756.756 0 01-.068.116L6.389 3.207A6 6 0 0116 8c.001 1.887.455 3.665 1.258 5.234a.75.75 0 01.01.666zM3.28 2.22a.75.75 0 00-1.06 1.06l14.5 14.5a.75.75 0 101.06-1.06L3.28 2.22z" />
</svg>
<span>Silenced Jobs</span>
</router-link>
</li>
<li class="nav-item">
<router-link active-class="active" to="/failed" class="nav-link d-flex align-items-center">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
</svg>
<span>Failed Jobs</span>
</router-link>
</li>
</ul>
</div>
<div class="col-10">
@if ($isDownForMaintenance)
<div class="alert alert-warning">
This application is in "maintenance mode". Queued jobs may not be processed unless your worker is using the "force" flag.
</div>
@endif
<router-view></router-view>
</div>
</div>
</div>
</div>
</body>
</html>