Added new event viewer to the WebUI. Migrated requests to use the new event system.
This commit is contained in:
@@ -63,6 +63,7 @@ try {
|
||||
]
|
||||
);
|
||||
fwrite(STDERR, $message . PHP_EOL);
|
||||
fwrite(STDERR, $e->getTraceAsString() . PHP_EOL);
|
||||
exit(503);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ use App\Commands\State\ExportCommand;
|
||||
use App\Commands\State\ImportCommand;
|
||||
use App\Commands\State\ProgressCommand;
|
||||
use App\Commands\State\PushCommand;
|
||||
use App\Commands\State\RequestsCommand;
|
||||
use App\Commands\System\IndexCommand;
|
||||
use App\Commands\System\PruneCommand;
|
||||
use App\Libs\Mappers\Import\MemoryMapper;
|
||||
@@ -305,14 +304,6 @@ return (function () {
|
||||
'timer' => $checkTaskTimer((string)env('WS_CRON_INDEXES_AT', '0 3 * * 3'), '0 3 * * 3'),
|
||||
'args' => env('WS_CRON_INDEXES_ARGS', '-v'),
|
||||
],
|
||||
RequestsCommand::TASK_NAME => [
|
||||
'command' => RequestsCommand::ROUTE,
|
||||
'name' => RequestsCommand::TASK_NAME,
|
||||
'info' => 'Process queued http requests.',
|
||||
'enabled' => (bool)env('WS_CRON_REQUESTS', true),
|
||||
'timer' => $checkTaskTimer((string)env('WS_CRON_REQUESTS_AT', '*/2 * * * *'), '*/2 * * * *'),
|
||||
'args' => env('WS_CRON_REQUESTS_ARGS', '-v --no-stats'),
|
||||
],
|
||||
DispatchCommand::TASK_NAME => [
|
||||
'command' => DispatchCommand::ROUTE,
|
||||
'name' => DispatchCommand::TASK_NAME,
|
||||
|
||||
@@ -186,7 +186,7 @@ return (function () {
|
||||
};
|
||||
|
||||
// -- Do not forget to update the tasks list if you add a new task.
|
||||
$tasks = ['import', 'export', 'push', 'progress', 'backup', 'prune', 'indexes', 'requests'];
|
||||
$tasks = ['import', 'export', 'push', 'progress', 'backup', 'prune', 'indexes'];
|
||||
$task_env = [
|
||||
[
|
||||
'key' => 'WS_CRON_{TASK}',
|
||||
|
||||
70
frontend/components/Pager.vue
Normal file
70
frontend/components/Pager.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<button rel="first" class="button" v-if="page !== 1" @click="changePage(1)" :disabled="isLoading"
|
||||
:class="{'is-loading':isLoading}">
|
||||
<span class="icon"><i class="fas fa-angle-double-left"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button rel="prev" class="button" v-if="page > 1 && (page-1) !== 1" @click="changePage(page-1)"
|
||||
:disabled="isLoading" :class="{'is-loading':isLoading}">
|
||||
<span class="icon"><i class="fas fa-angle-left"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select id="pager_list" v-model="currentPage" @change="changePage(currentPage)" :disabled="isLoading">
|
||||
<option v-for="(item, index) in makePagination(page, last_page)" :key="`pager-${index}`"
|
||||
:value="item.page" :disabled="0 === item.page">
|
||||
{{ item.text }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button rel="next" class="button" v-if="page !== last_page && ( page + 1 ) !== last_page"
|
||||
@click="changePage( page + 1 )" :disabled="isLoading" :class="{ 'is-loading': isLoading }">
|
||||
<span class="icon"><i class="fas fa-angle-right"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button rel="last" class="button" v-if="page !== last_page" @click="changePage(last_page)"
|
||||
:disabled="isLoading" :class="{ 'is-loading': isLoading }">
|
||||
<span class="icon"><i class="fas fa-angle-double-right"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {makePagination} from '~/utils/index'
|
||||
|
||||
const emitter = defineEmits(['navigate'])
|
||||
|
||||
const props = defineProps({
|
||||
page: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
last_page: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
})
|
||||
|
||||
const changePage = p => {
|
||||
if (p < 1 || p > props.last_page) {
|
||||
return
|
||||
}
|
||||
emitter('navigate', p)
|
||||
currentPage.value = p
|
||||
}
|
||||
|
||||
const currentPage = ref(props.page)
|
||||
</script>
|
||||
@@ -79,10 +79,15 @@
|
||||
<hr class="navbar-divider">
|
||||
|
||||
<NuxtLink class="navbar-item" to="/events" @click.native="(e) => changeRoute(e)">
|
||||
<span class="icon"><i class="fas fa-list"></i></span>
|
||||
<span class="icon"><i class="fas fa-calendar-alt"></i></span>
|
||||
<span>Events</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink class="navbar-item" to="/old_events" @click.native="(e) => changeRoute(e)">
|
||||
<span class="icon"><i class="fas fa-list"></i></span>
|
||||
<span>Old Events</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink class="navbar-item" to="/ignore" @click.native="(e) => changeRoute(e)">
|
||||
<span class="icon"><i class="fas fa-ban"></i></span>
|
||||
<span>Ignore List</span>
|
||||
|
||||
321
frontend/pages/events/index.vue
Normal file
321
frontend/pages/events/index.vue
Normal file
@@ -0,0 +1,321 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-12 is-clearfix is-unselectable">
|
||||
<span class="title is-4">
|
||||
<span class="icon"><i class="fas fa-calendar-alt"></i></span>
|
||||
Events
|
||||
</span>
|
||||
<div class="is-pulled-right">
|
||||
<div class="field is-grouped">
|
||||
<div class="control has-icons-left" v-if="toggleFilter">
|
||||
<input type="search" v-model.lazy="query" class="input" id="filter" placeholder="Filter">
|
||||
<span class="icon is-left"><i class="fas fa-filter"></i></span>
|
||||
</div>
|
||||
|
||||
<div class="control">
|
||||
<button class="button is-danger is-light" @click="toggleFilter = !toggleFilter">
|
||||
<span class="icon"><i class="fas fa-filter"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="control">
|
||||
<button class="button is-info" @click="loadContent(page, false)"
|
||||
:class="{'is-loading': isLoading}" :disabled="isLoading">
|
||||
<span class="icon"><i class="fas fa-sync"></i></span>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="is-hidden-mobile">
|
||||
<span class="subtitle">
|
||||
Show events that are queued to be dispatched, or have been dispatched.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-12" v-if="total && last_page > 1">
|
||||
<Pager @navigate="ePage => loadContent(ePage)" :last_page="last_page" :page="page" :is-loading="isLoading"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns is-multiline" v-if="items.length < 1">
|
||||
<div class="column is-12">
|
||||
<Message v-if="isLoading" message_class="has-background-info-90 has-text-dark" title="Loading"
|
||||
icon="fas fa-spinner fa-spin" message="Loading data. Please wait..."/>
|
||||
<Message v-else class="has-background-warning-80 has-text-dark" title="Warning"
|
||||
icon="fas fa-exclamation-triangle">
|
||||
<p>No items found.</p>
|
||||
</Message>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-6 is-12-mobile" v-for="item in filteredRows" :key="item.id">
|
||||
<div class="card">
|
||||
<header class="card-header">
|
||||
<div class="card-header-title">
|
||||
<NuxtLink :to="`/events/view/?id=${item.id}`" v-text="makeName(item.id)"/>
|
||||
</div>
|
||||
<div class="card-header-icon" @click="item._display = !item._display"
|
||||
v-if="Object.keys(item.event_data).length > 0">
|
||||
<span class="icon">
|
||||
<i class="fas" :class="{'fa-arrow-up': item?._display, 'fa-arrow-down': !item?._display }"></i>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="card-content p-0 m-0" v-if="item._display">
|
||||
<pre class="p-0 is-pre" style="position: relative; max-height:30vh; overflow-y:scroll;"><code
|
||||
class="language-json">{{
|
||||
JSON.stringify(item.event_data, null, 2)
|
||||
}}</code><button class="button is-small m-4"
|
||||
@click="() => copyText(JSON.stringify(item.event_data), false)"
|
||||
style="position: absolute; top:0; right:0;">
|
||||
<span class="icon"><i class="fas fa-copy"></i></span></button></pre>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="card-footer-item">
|
||||
<span class="tag" :class="getStatusClass(item.status)">{{ statuses[item.status].name }}</span>
|
||||
</div>
|
||||
<span class="card-footer-item">
|
||||
<span class="icon"><i class="fas fa-calendar"></i></span>
|
||||
<time class="has-tooltip" v-tooltip="`Created at: ${moment(item.created_at).format(tooltip_dateformat)}`">
|
||||
{{ moment(item.created_at).fromNow() }}
|
||||
</time>
|
||||
</span>
|
||||
<span class="card-footer-item">
|
||||
<span v-if="!item.updated_at" class="icon"><i class="fas fa-spinner fa-spin"></i></span>
|
||||
<template v-else>
|
||||
<span class="icon"><i class="fas fa-calendar-alt"></i></span>
|
||||
<time class="has-tooltip"
|
||||
v-tooltip="`Updated at: ${moment(item.updated_at).format(tooltip_dateformat)}`">
|
||||
{{ moment(item.updated_at).fromNow() }}
|
||||
</time>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<footer class="card-footer">
|
||||
<div class="card-footer-item" v-text="item.event"/>
|
||||
<div class="card-footer-item">
|
||||
<button class="button is-warning is-fullwidth"
|
||||
@click="resetEvent(item, 0 === item.status ? 4 : 0)"
|
||||
>
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fas fa-trash-arrow-up"></i></span>
|
||||
<span>{{ 0 === item.status ? 'Stop' : 'Reset' }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-footer-item">
|
||||
<button class="button is-danger is-fullwidth" @click="deleteItem(item)">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fas fa-trash"></i></span>
|
||||
<span>Delete</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-12">
|
||||
<Message message_class="has-background-info-90 has-text-dark" :toggle="show_page_tips"
|
||||
@toggle="show_page_tips = !show_page_tips" :use-toggle="true" title="Tips" icon="fas fa-info-circle">
|
||||
<ul>
|
||||
<li>Resetting event will return it to the queue to be dispatched again.</li>
|
||||
<li>Stopping event will prevent it from being dispatched.</li>
|
||||
<li>Events with status of <span class="tag is-warning">Running</span> Cannot be cancelled or stopped.</li>
|
||||
</ul>
|
||||
</Message>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {copyText, notification, parse_api_response} from '~/utils/index'
|
||||
import request from '~/utils/request'
|
||||
import moment from 'moment'
|
||||
import Pager from '~/components/Pager'
|
||||
import {getStatusClass, makeName} from '~/utils/events/helpers'
|
||||
import Message from '~/components/Message'
|
||||
import {useStorage} from '@vueuse/core'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const total = ref(0)
|
||||
const page = ref(parseInt(route.query.page ?? 1))
|
||||
const perpage = ref(parseInt(route.query.perpage ?? 26))
|
||||
const last_page = computed(() => Math.ceil(total.value / perpage.value))
|
||||
|
||||
const isLoading = ref(false)
|
||||
const toggleDispatcher = ref(false)
|
||||
const items = ref([])
|
||||
const statuses = ref([])
|
||||
const query = ref()
|
||||
const toggleFilter = ref(false)
|
||||
const show_page_tips = useStorage('show_page_tips', true)
|
||||
|
||||
watch(toggleFilter, () => {
|
||||
if (!toggleFilter.value) {
|
||||
query.value = ''
|
||||
}
|
||||
});
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
if (!query.value) {
|
||||
return items.value
|
||||
}
|
||||
|
||||
const toTower = query.value.toLowerCase();
|
||||
|
||||
return items.value.filter(i => {
|
||||
return Object.keys(i).some(k => {
|
||||
if (typeof i[k] === 'object') {
|
||||
return Object.values(i[k]).some(v => typeof v === 'string' ? v.toLowerCase().includes(toTower) : false)
|
||||
}
|
||||
return typeof i[k] === 'string' ? i[k].toLowerCase().includes(toTower) : false
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
const loadContent = async (pageNumber, updateHistory = true) => {
|
||||
try {
|
||||
pageNumber = parseInt(pageNumber)
|
||||
let p_perpage = parseInt(perpage.value)
|
||||
|
||||
if (isNaN(pageNumber) || pageNumber < 1) {
|
||||
pageNumber = 1
|
||||
}
|
||||
|
||||
if (isNaN(p_perpage) || p_perpage < 1) {
|
||||
p_perpage = 25
|
||||
}
|
||||
|
||||
let queryParams = new URLSearchParams()
|
||||
queryParams.append('page', pageNumber)
|
||||
queryParams.append('perpage', p_perpage)
|
||||
|
||||
isLoading.value = true
|
||||
toggleDispatcher.value = false
|
||||
items.value = []
|
||||
|
||||
const response = await request(`/system/events?${queryParams.toString()}`)
|
||||
const json = await parse_api_response(response)
|
||||
|
||||
if (200 !== response.status) {
|
||||
notification('error', 'Error', `Events request error. ${json.error.code}: ${json.error.message}`)
|
||||
return
|
||||
}
|
||||
|
||||
let title = `Events - Page #${pageNumber}`
|
||||
|
||||
useHead({title})
|
||||
|
||||
if (true === Boolean(updateHistory)) {
|
||||
await useRouter().push({
|
||||
path: '/events',
|
||||
query: {
|
||||
perpage: p_perpage,
|
||||
page: pageNumber,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if ('paging' in json) {
|
||||
page.value = json.paging.page
|
||||
perpage.value = json.paging.perpage
|
||||
total.value = json.paging.total
|
||||
}
|
||||
|
||||
items.value = json?.items ?? []
|
||||
statuses.value = json?.statuses ?? []
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
notification('crit', 'Error', `Events Request failure. ${e.message}`
|
||||
)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadContent(page.value)
|
||||
window.addEventListener('popstate', handlePopState)
|
||||
})
|
||||
|
||||
onUnmounted(() => window.removeEventListener('popstate', handlePopState))
|
||||
|
||||
const handlePopState = async () => {
|
||||
const route = useRoute()
|
||||
|
||||
if (route.query?.perpage) {
|
||||
perpage.value = route.query.perpage
|
||||
}
|
||||
|
||||
if (route.query?.page) {
|
||||
page.value = route.query.page
|
||||
}
|
||||
|
||||
await loadContent(page.value, false)
|
||||
}
|
||||
|
||||
const deleteItem = async item => {
|
||||
if (!confirm(`Delete '${item.id}'?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request(`/system/events/${item.id}`, {method: 'DELETE'})
|
||||
|
||||
if (200 !== response.status) {
|
||||
const json = await parse_api_response(response)
|
||||
notification('error', 'Error', `Events delete Request error. ${json.error.code}: ${json.error.message}`)
|
||||
return
|
||||
}
|
||||
|
||||
items.value = items.value.filter(i => i.id !== item.id)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
notification('crit', 'Error', `Events delete Request failure. ${e.message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const resetEvent = async (item, status = 0) => {
|
||||
if (!confirm(`Reset '${item.id}'?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request(`/system/events/${item.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
status: status,
|
||||
reset_logs: true,
|
||||
})
|
||||
})
|
||||
|
||||
const json = await parse_api_response(response)
|
||||
|
||||
if (200 !== response.status) {
|
||||
notification('error', 'Error', `Events view patch Request error. ${json.error.code}: ${json.error.message}`)
|
||||
return
|
||||
}
|
||||
|
||||
const index = items.value.findIndex(i => i.id === item.id)
|
||||
|
||||
if (index < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
items.value[index] = json
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
notification('crit', 'Error', `Events view patch Request failure. ${e.message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
193
frontend/pages/events/view.vue
Normal file
193
frontend/pages/events/view.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-12 is-clearfix is-unselectable">
|
||||
<span class="title is-4">
|
||||
<span class="icon"><i class="fas fa-calendar-alt"></i> </span>
|
||||
<NuxtLink to="/events" v-text="'Events'"/>
|
||||
: {{ makeName(id) }}
|
||||
</span>
|
||||
<div class="is-pulled-right">
|
||||
<div class="field is-grouped">
|
||||
<p class="control">
|
||||
<button class="button is-warning" @click="resetEvent" :disabled="item.status < 2">
|
||||
<span class="icon"><i class="fas fa-trash-arrow-up"></i></span>
|
||||
</button>
|
||||
</p>
|
||||
<p class="control">
|
||||
<button class="button is-danger" @click="deleteItem" :disabled="1 === item.status">
|
||||
<span class="icon"><i class="fas fa-trash"></i></span>
|
||||
</button>
|
||||
</p>
|
||||
<p class="control">
|
||||
<button class="button is-info" @click="loadContent()" :class="{'is-loading': isLoading}"
|
||||
:disabled="isLoading">
|
||||
<span class="icon"><i class="fas fa-sync"></i></span>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="is-hidden-mobile">
|
||||
<span class="subtitle"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-12" v-if="isLoading">
|
||||
<Message v-if="isLoading" message_class="has-background-info-90 has-text-dark" title="Loading"
|
||||
icon="fas fa-spinner fa-spin" message="Loading data. Please wait..."/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isLoading" class="columns is-multiline">
|
||||
<div class="column is-12">
|
||||
<div class="notification">
|
||||
<p class="title is-5">
|
||||
Event <span class="tag is-info">{{ item.event }}</span> was created at
|
||||
<span class="tag is-warning">
|
||||
<time class="has-tooltip" v-tooltip="moment(item.created_at).format(tooltip_dateformat)">
|
||||
{{ moment(item.created_at).fromNow() }}
|
||||
</time>
|
||||
</span>, and last updated at
|
||||
<span class="tag is-danger">
|
||||
<span v-if="!item.updated_at">not started</span>
|
||||
<time v-else class="has-tooltip" v-tooltip="moment(item.updated_at).format(tooltip_dateformat)">
|
||||
{{ moment(item.updated_at).fromNow() }}
|
||||
</time>
|
||||
</span>.
|
||||
with status of <span class="tag" :class="getStatusClass(item.status)">{{ item.status }}:
|
||||
{{ item.status_name }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-12" v-if="Object.keys(item.event_data).length > 0">
|
||||
<h2 class="title is-4 is-clickable is-unselectable" @click="toggleData = !toggleData">
|
||||
<span class="icon">
|
||||
<i class="fas" :class="{ 'fa-arrow-down': !toggleData, 'fa-arrow-up': toggleData }"></i>
|
||||
</span>
|
||||
<span>Show attached data</span>
|
||||
</h2>
|
||||
<pre class="p-0 is-pre-wrap" v-if="toggleData"><code
|
||||
style="word-break: break-word" class="language-json">{{
|
||||
JSON.stringify(item.event_data, null, 2)
|
||||
}}</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="column is-12" v-if="item.logs">
|
||||
<h2 class="title is-4 is-clickable is-unselectable" @click="toggleLogs = !toggleLogs">
|
||||
<span class="icon">
|
||||
<i class="fas" :class="{ 'fa-arrow-down': !toggleLogs, 'fa-arrow-up': toggleLogs }"></i>
|
||||
</span>
|
||||
<span>Show event logs</span>
|
||||
</h2>
|
||||
<pre class="p-0 is-pre-wrap" v-if="toggleLogs"><code
|
||||
style="word-break: break-word" class="language-json">{{
|
||||
JSON.stringify(item.logs, null, 2)
|
||||
}}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {notification, parse_api_response} from '~/utils/index'
|
||||
import request from '~/utils/request'
|
||||
import moment from 'moment'
|
||||
import {getStatusClass, makeName} from '~/utils/events/helpers'
|
||||
import {useStorage} from '@vueuse/core'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const id = ref(route.query.id)
|
||||
|
||||
const isLoading = ref(true)
|
||||
const item = ref({})
|
||||
|
||||
const toggleLogs = useStorage('events_toggle_logs', true)
|
||||
const toggleData = useStorage('events_toggle_data', true)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!id.value) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: 'Error ID not provided.'
|
||||
})
|
||||
}
|
||||
return await loadContent()
|
||||
})
|
||||
|
||||
const loadContent = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const response = await request(`/system/events/${id.value}`,)
|
||||
const json = await parse_api_response(response)
|
||||
|
||||
if (200 !== response.status) {
|
||||
notification('error', 'Error', `Errors viewItem request error. ${json.error.code}: ${json.error.message}`)
|
||||
return
|
||||
}
|
||||
|
||||
item.value = json
|
||||
|
||||
useHead({title: `Event: ${json.id}`})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
notification('crit', 'Error', `Errors viewItem Request failure. ${e.message}`
|
||||
)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteItem = async () => {
|
||||
if (!confirm(`Delete '${item.value.id}'?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request(`/system/events/${item.value.id}`, {method: 'DELETE'})
|
||||
|
||||
if (200 !== response.status) {
|
||||
const json = await parse_api_response(response)
|
||||
notification('error', 'Error', `Events view delete Request error. ${json.error.code}: ${json.error.message}`)
|
||||
return
|
||||
}
|
||||
|
||||
notification('success', 'Success', `Event '${makeName(item.value.id)}' deleted.`)
|
||||
await navigateTo('/events')
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
notification('crit', 'Error', `Events view delete Request failure. ${e.message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const resetEvent = async () => {
|
||||
if (!confirm(`Reset '${makeName(item.value.id)}'?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request(`/system/events/${item.value.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
status: 0,
|
||||
reset_logs: true,
|
||||
})
|
||||
})
|
||||
|
||||
const json = await parse_api_response(response)
|
||||
|
||||
if (200 !== response.status) {
|
||||
notification('error', 'Error', `Events view patch Request error. ${json.error.code}: ${json.error.message}`)
|
||||
return
|
||||
}
|
||||
|
||||
item.value = json
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
notification('crit', 'Error', `Events view patch Request failure. ${e.message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="column is-12 is-clearfix is-unselectable">
|
||||
<span class="title is-4">
|
||||
<span class="icon"><i class="fas fa-list-check"></i></span>
|
||||
Events
|
||||
Legacy Events
|
||||
</span>
|
||||
<div class="is-pulled-right">
|
||||
<div class="field is-grouped">
|
||||
@@ -18,11 +18,13 @@
|
||||
<div class="is-hidden-mobile">
|
||||
<span class="subtitle">
|
||||
This page will show events that are queued to be handled or sent to the backends.
|
||||
This endpoint is being deprecated and will be removed in the future, We are migrating to the new
|
||||
<code>events</code> endpoint.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-12" v-if="queue.length < 1 && progress.length < 1 && requests.length < 1">
|
||||
<div class="column is-12" v-if="queue.length < 1 && progress.length < 1">
|
||||
<Message v-if="isLoading" message_class="has-background-info-90 has-text-dark" title="Loading"
|
||||
icon="fas fa-spinner fa-spin" message="Loading data. Please wait..."/>
|
||||
<Message v-else message_class="is-background-success-90 has-text-dark" title="Information"
|
||||
@@ -187,88 +189,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns is-multiline" v-if="requests.length > 0">
|
||||
<div class="column is-12">
|
||||
<span class="title is-5 is-unselectable">
|
||||
<span class="icon"><i class="fas fa-envelope"></i></span>
|
||||
Request events
|
||||
</span>
|
||||
<div class="subtitle is-hidden-mobile">
|
||||
Events from backends. Consumed by <code>state:requests</code> task.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-4 is-6-tablet" v-for="i in requests" :key="`requests-${i.key}`">
|
||||
<div class="card" :class="{ 'is-success': i.item.watched }">
|
||||
<header class="card-header">
|
||||
<p class="card-header-title is-text-overflow pr-1">
|
||||
<span class="icon">
|
||||
<i class="fas" :class="{'fa-eye-slash': !i.item.watched, 'fa-eye': i.item.watched}"></i>
|
||||
</span>
|
||||
<NuxtLink :to="'/history/'+i.item.id" v-text="makeName(i.item)" v-if="i.item.id"/>
|
||||
<template v-else>{{ makeName(i.item) }}</template>
|
||||
</p>
|
||||
<span class="card-header-icon">
|
||||
<button class="button is-danger is-small" @click="deleteItem(i.item, 'requests', i.key)">
|
||||
<span class="icon"><i class="fas fa-trash"></i></span>
|
||||
</button>
|
||||
</span>
|
||||
</header>
|
||||
<div class="card-content">
|
||||
<div class="columns is-multiline is-mobile has-text-centered">
|
||||
<div class="column is-12 has-text-left" v-if="i.item?.content_title">
|
||||
<div class="is-text-overflow">
|
||||
<span class="icon"><i class="fas fa-heading"></i> </span>
|
||||
<NuxtLink :to="makeSearchLink('subtitle',i.item.content_title)" v-text="i.item.content_title"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-12 has-text-left" v-if="i.item?.content_path">
|
||||
<div class="is-text-overflow">
|
||||
<span class="icon"><i class="fas fa-file"></i> </span>
|
||||
<NuxtLink :to="makeSearchLink('path',i.item.content_path)" v-text="i.item.content_path"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6 has-text-left">
|
||||
<div class="is-text-overflow">
|
||||
<span class="icon"><i class="fas fa-info-circle"></i></span>
|
||||
is Tainted: {{ i.item?.isTainted ? 'Yes' : 'No' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6 has-text-right" v-if="i.item?.progress">
|
||||
<div class="is-text-overflow">
|
||||
<span class="icon"><i class="fas fa-bars-progress"></i></span>
|
||||
{{ formatDuration(i.item.progress) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer has-text-centered">
|
||||
<div class="card-footer-item">
|
||||
<div class="is-text-overflow">
|
||||
<span class="icon"><i class="fas fa-calendar"></i> </span>
|
||||
<span class="has-tooltip"
|
||||
v-tooltip="`${getMoment(ag(i.item.extra, `${i.item.via}.received_at`, i.item.updated)).format(TOOLTIP_DATE_FORMAT)}`">
|
||||
{{ getMoment(ag(i.item.extra, `${i.item.via}.received_at`, i.item.updated)).fromNow() }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer-item">
|
||||
<div class="is-text-overflow">
|
||||
<span class="icon"><i class="fas fa-server"></i> </span>
|
||||
<NuxtLink :to="'/backend/'+i.item.via" v-text="i.item.via"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer-item">
|
||||
<div class="is-text-overflow">
|
||||
<span class="icon"><i class="fas fa-envelope"></i> </span>
|
||||
<span>{{ i.item.event ?? '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-12">
|
||||
<Message message_class="has-background-info-90 has-text-dark" :toggle="show_page_tips"
|
||||
@@ -305,7 +225,6 @@ useHead({title: 'Queue'})
|
||||
|
||||
const queue = ref([])
|
||||
const progress = ref([])
|
||||
const requests = ref([])
|
||||
const isLoading = ref(false)
|
||||
const show_page_tips = useStorage('show_page_tips', true)
|
||||
|
||||
@@ -314,9 +233,8 @@ const loadContent = async () => {
|
||||
isLoading.value = true
|
||||
queue.value = []
|
||||
progress.value = []
|
||||
requests.value = []
|
||||
|
||||
const response = await request(`/system/events`)
|
||||
const response = await request(`/system/old_events`)
|
||||
let json
|
||||
|
||||
try {
|
||||
@@ -340,7 +258,6 @@ const loadContent = async () => {
|
||||
|
||||
queue.value = json?.queue
|
||||
progress.value = json?.progress
|
||||
requests.value = json?.requests
|
||||
} catch (e) {
|
||||
return notification('error', 'Error', e.message)
|
||||
} finally {
|
||||
@@ -354,7 +271,7 @@ const deleteItem = async (item, type, key) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request(`/system/events/0`, {
|
||||
const response = await request(`/system/old_events/0`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({type: type, id: key})
|
||||
})
|
||||
@@ -386,9 +303,6 @@ const deleteItem = async (item, type, key) => {
|
||||
case 'progress':
|
||||
progress.value = progress.value.filter(i => i.key !== key)
|
||||
break
|
||||
case 'requests':
|
||||
requests.value = requests.value.filter(i => i.key !== key)
|
||||
break
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
20
frontend/utils/events/helpers.js
Normal file
20
frontend/utils/events/helpers.js
Normal file
@@ -0,0 +1,20 @@
|
||||
const makeName = id => id.split('-').slice(0)[0]
|
||||
|
||||
const getStatusClass = status => {
|
||||
switch (status) {
|
||||
case 0:
|
||||
return 'is-light'
|
||||
case 1:
|
||||
return 'is-warning'
|
||||
case 2:
|
||||
return 'is-success'
|
||||
case 3:
|
||||
return 'is-danger'
|
||||
case 4:
|
||||
return 'is-danger is-light'
|
||||
default:
|
||||
return 'is-light'
|
||||
}
|
||||
}
|
||||
|
||||
export {makeName, getStatusClass}
|
||||
73
migrations/sqlite_1723988129_add-reference-to-events.sql
Normal file
73
migrations/sqlite_1723988129_add-reference-to-events.sql
Normal file
@@ -0,0 +1,73 @@
|
||||
-- # migrate_up
|
||||
|
||||
CREATE TABLE "_tmp_events"
|
||||
(
|
||||
"id" text NOT NULL,
|
||||
"status" integer NOT NULL DEFAULT '0',
|
||||
"reference" text NULL,
|
||||
"event" text NOT NULL,
|
||||
"event_data" text NOT NULL DEFAULT '{}',
|
||||
"options" text NOT NULL DEFAULT '{}',
|
||||
"attempts" integer NOT NULL DEFAULT '0',
|
||||
"logs" text NOT NULL DEFAULT '{}',
|
||||
"created_at" numeric NOT NULL,
|
||||
"updated_at" numeric NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
INSERT INTO "_tmp_events" ("id", "status", "event", "event_data", "options", "attempts", "logs", "created_at",
|
||||
"updated_at")
|
||||
SELECT "id",
|
||||
"status",
|
||||
"event",
|
||||
"event_data",
|
||||
"options",
|
||||
"attempts",
|
||||
"logs",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
FROM "events";
|
||||
|
||||
DROP TABLE "events";
|
||||
ALTER TABLE "_tmp_events"
|
||||
RENAME TO "events";
|
||||
CREATE INDEX "events_event" ON "events" ("event");
|
||||
CREATE INDEX "events_status" ON "events" ("status");
|
||||
CREATE INDEX "events_reference" ON "events" ("reference");
|
||||
|
||||
-- # migrate_down
|
||||
|
||||
CREATE TABLE "_tmp_events"
|
||||
(
|
||||
"id" text NOT NULL,
|
||||
"status" integer NOT NULL DEFAULT '0',
|
||||
"event" text NOT NULL,
|
||||
"event_data" text NOT NULL DEFAULT '{}',
|
||||
"options" text NOT NULL DEFAULT '{}',
|
||||
"attempts" integer NOT NULL DEFAULT '0',
|
||||
"logs" text NOT NULL DEFAULT '{}',
|
||||
"created_at" numeric NOT NULL,
|
||||
"updated_at" numeric NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
INSERT INTO "_tmp_events" ("id", "status", "event", "event_data", "options", "attempts", "logs", "created_at",
|
||||
"updated_at")
|
||||
SELECT "id",
|
||||
"status",
|
||||
"event",
|
||||
"event_data",
|
||||
"options",
|
||||
"attempts",
|
||||
"logs",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
FROM "events";
|
||||
|
||||
DROP TABLE "events";
|
||||
ALTER TABLE "_tmp_events"
|
||||
RENAME TO "events";
|
||||
CREATE INDEX "events_event" ON "events" ("event");
|
||||
CREATE INDEX "events_status" ON "events" ("status");
|
||||
|
||||
-- put your downgrade database commands here.
|
||||
@@ -14,6 +14,8 @@ use App\Libs\LogSuppressor;
|
||||
use App\Libs\Options;
|
||||
use App\Libs\Traits\APITraits;
|
||||
use App\Libs\Uri;
|
||||
use App\Listeners\ProcessRequestEvent;
|
||||
use App\Model\Events\EventsTable;
|
||||
use DateInterval;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Level;
|
||||
@@ -190,8 +192,6 @@ final class Webhooks
|
||||
return api_response(Status::NOT_MODIFIED);
|
||||
}
|
||||
|
||||
$items = $this->cache->get('requests', []);
|
||||
|
||||
$itemId = r('{type}://{id}:{tainted}@{backend}', [
|
||||
'type' => $entity->type,
|
||||
'backend' => $entity->via,
|
||||
@@ -199,14 +199,15 @@ final class Webhooks
|
||||
'id' => ag($entity->getMetadata($entity->via), iState::COLUMN_ID, '??'),
|
||||
]);
|
||||
|
||||
$items[$itemId] = [
|
||||
queueEvent(ProcessRequestEvent::NAME, [
|
||||
'options' => [
|
||||
Options::IMPORT_METADATA_ONLY => $metadataOnly,
|
||||
],
|
||||
'entity' => $entity,
|
||||
];
|
||||
|
||||
$this->cache->set('requests', $items, new DateInterval('P3D'));
|
||||
'entity' => $entity->getAll(),
|
||||
], [
|
||||
EventsTable::COLUMN_REFERENCE => $itemId,
|
||||
'unique' => true,
|
||||
]);
|
||||
|
||||
$pEnabled = (bool)env('WS_CRON_PROGRESS', false);
|
||||
if ($pEnabled && false === $metadataOnly && true === $entity->hasPlayProgress() && !$entity->isWatched()) {
|
||||
|
||||
@@ -6,114 +6,174 @@ namespace App\API\System;
|
||||
|
||||
use App\Libs\Attributes\Route\Delete;
|
||||
use App\Libs\Attributes\Route\Get;
|
||||
use App\Libs\Container;
|
||||
use App\Libs\Database\DatabaseInterface as iDB;
|
||||
use App\Libs\Attributes\Route\Patch;
|
||||
use App\Libs\Attributes\Route\Post;
|
||||
use App\Libs\DataUtil;
|
||||
use App\Libs\Entity\StateInterface as iState;
|
||||
use App\Libs\Enums\Http\Status;
|
||||
use App\Libs\Traits\APITraits;
|
||||
use DateInterval;
|
||||
use App\Model\Events\Event as EntityItem;
|
||||
use App\Model\Events\EventsRepository;
|
||||
use App\Model\Events\EventsTable as EntityTable;
|
||||
use App\Model\Events\EventStatus;
|
||||
use Psr\Http\Message\ResponseInterface as iResponse;
|
||||
use Psr\Http\Message\ServerRequestInterface as iRequest;
|
||||
use Psr\SimpleCache\CacheInterface as iCache;
|
||||
use Psr\SimpleCache\InvalidArgumentException;
|
||||
|
||||
final class Events
|
||||
final readonly class Events
|
||||
{
|
||||
use APITraits;
|
||||
|
||||
public const string URL = '%{api.prefix}/system/events';
|
||||
|
||||
private const array TYPES = ['queue', 'progress', 'requests'];
|
||||
public const int PERPAGE = 10;
|
||||
|
||||
public function __construct(private iCache $cache, private iDB $db)
|
||||
public function __construct(private EventsRepository $repo)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Get(self::URL . '[/]', name: 'system.events')]
|
||||
public function __invoke(iRequest $request): iResponse
|
||||
#[Get(pattern: self::URL . '[/]')]
|
||||
public function list(iRequest $request): iResponse
|
||||
{
|
||||
$response = [
|
||||
'queue' => [],
|
||||
'progress' => [],
|
||||
'requests' => [],
|
||||
[$page, $perpage, $start] = getPagination($request, 1, self::PERPAGE);
|
||||
|
||||
$arrParams = [];
|
||||
|
||||
$this->repo->setPerpage($perpage)->setStart($start)->setDescendingOrder();
|
||||
|
||||
$entities = $this->repo->findAll($arrParams, [
|
||||
EntityTable::COLUMN_ID,
|
||||
EntityTable::COLUMN_EVENT,
|
||||
EntityTable::COLUMN_STATUS,
|
||||
EntityTable::COLUMN_EVENT_DATA,
|
||||
EntityTable::COLUMN_OPTIONS,
|
||||
EntityTable::COLUMN_ATTEMPTS,
|
||||
EntityTable::COLUMN_CREATED_AT,
|
||||
EntityTable::COLUMN_UPDATED_AT,
|
||||
]);
|
||||
|
||||
$total = $this->repo->getTotal();
|
||||
|
||||
$arr = [
|
||||
'paging' => [
|
||||
'page' => $page,
|
||||
'total' => $total,
|
||||
'perpage' => $perpage,
|
||||
'next' => $page < @ceil($total / $perpage) ? $page + 1 : null,
|
||||
'previous' => !empty($entities) && $page > 1 ? $page - 1 : null
|
||||
],
|
||||
'items' => [],
|
||||
'statuses' => [],
|
||||
];
|
||||
|
||||
foreach ($this->cache->get('queue', []) as $key => $item) {
|
||||
if (null === ($entity = $this->db->get(Container::get(iState::class)::fromArray($item)))) {
|
||||
continue;
|
||||
}
|
||||
$response['queue'][] = ['key' => $key, 'item' => $this->formatEntity($entity)];
|
||||
foreach (EventStatus::cases() as $status) {
|
||||
$arr['statuses'][] = [
|
||||
'id' => $status->value,
|
||||
'name' => ucfirst(strtolower($status->name)),
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($this->cache->get('progress', []) as $key => $item) {
|
||||
if (null !== ($entity = $this->db->get($item))) {
|
||||
$item->id = $entity->id;
|
||||
}
|
||||
|
||||
$response['progress'][] = ['key' => $key, 'item' => $this->formatEntity($item)];
|
||||
foreach ($entities as $entity) {
|
||||
$arr['items'][] = $this->formatEntity($entity);
|
||||
}
|
||||
|
||||
foreach ($this->cache->get('requests', []) as $key => $request) {
|
||||
if (null === ($item = ag($request, 'entity')) || false === ($item instanceof iState)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (null !== ($entity = $this->db->get($item))) {
|
||||
$item->id = $entity->id;
|
||||
}
|
||||
|
||||
$response['requests'][] = ['key' => $key, 'item' => $this->formatEntity($item)];
|
||||
}
|
||||
|
||||
return api_response(Status::OK, $response);
|
||||
return api_response(Status::OK, $arr);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Delete(self::URL . '/{id}[/]', name: 'system.events.delete')]
|
||||
public function deleteEvent(iRequest $request, array $args = []): iResponse
|
||||
#[Post(pattern: self::URL . '[/]')]
|
||||
public function create(iRequest $request): iResponse
|
||||
{
|
||||
$params = DataUtil::fromRequest($request, true);
|
||||
$params = DataUtil::fromRequest($request);
|
||||
|
||||
if (null === ($id = $params->get('id', ag($args, 'id')))) {
|
||||
return api_error('Invalid id.', Status::BAD_REQUEST);
|
||||
if (null === ($event = $params->get(EntityTable::COLUMN_EVENT))) {
|
||||
return api_error('No event name was given.', Status::BAD_REQUEST, [
|
||||
...$params->getAll()
|
||||
]);
|
||||
}
|
||||
|
||||
$type = $params->get('type', 'queue');
|
||||
$data = (array)$params->get(EntityTable::COLUMN_EVENT_DATA, []);
|
||||
$item = queueEvent($event, $data, [EventsRepository::class => $this->repo]);
|
||||
|
||||
if (false === in_array($type, self::TYPES, true)) {
|
||||
return api_error(r("Invalid type '{type}'. Only '{types}' are supported.", [
|
||||
'type' => $type,
|
||||
'types' => implode(", ", self::TYPES),
|
||||
]), Status::BAD_REQUEST);
|
||||
return api_message(r("Event '{event}' was queued.", [
|
||||
'event' => $item->event,
|
||||
]), Status::ACCEPTED, $this->formatEntity($item));
|
||||
}
|
||||
|
||||
#[Get(pattern: self::URL . '/{id:uuid}[/]')]
|
||||
public function read(string $id): iResponse
|
||||
{
|
||||
if (null === ($entity = $this->repo->findById($id))) {
|
||||
return api_error('Item does not exists', Status::NOT_FOUND);
|
||||
}
|
||||
|
||||
$items = $this->cache->get($type, []);
|
||||
return api_response(Status::OK, $this->formatEntity($entity));
|
||||
}
|
||||
|
||||
if (empty($items)) {
|
||||
return api_error(r('{type} is empty.', ['type' => $type]), Status::NOT_FOUND);
|
||||
#[Delete(pattern: self::URL . '/{id:uuid}[/]')]
|
||||
public function delete(string $id): iResponse
|
||||
{
|
||||
if (null === ($entity = $this->repo->findById($id))) {
|
||||
return api_error('Item does not exists', Status::NOT_FOUND);
|
||||
}
|
||||
|
||||
if (false === array_key_exists($id, $items)) {
|
||||
return api_error(r("Record id '{id}' doesn't exists. for '{type}' list.", [
|
||||
'id' => $id,
|
||||
'type' => $type,
|
||||
]), Status::NOT_FOUND);
|
||||
if (EventStatus::RUNNING === $entity->status) {
|
||||
return api_error('Cannot delete event that is in running state.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
if ('queue' === $type) {
|
||||
$item = Container::get(iState::class)::fromArray(['id' => $id]);
|
||||
queuePush($item, remove: true);
|
||||
} else {
|
||||
unset($items[$id]);
|
||||
$this->cache->set($type, $items, new DateInterval('P3D'));
|
||||
$this->repo->remove($entity);
|
||||
|
||||
return api_response(Status::OK, $this->formatEntity($entity));
|
||||
}
|
||||
|
||||
#[Patch(pattern: self::URL . '/{id:uuid}[/]')]
|
||||
public function update(iRequest $request, string $id): iResponse
|
||||
{
|
||||
if (null === ($entity = $this->repo->findById($id))) {
|
||||
return api_error('Item does not exists', Status::NOT_FOUND);
|
||||
}
|
||||
|
||||
return api_response(Status::OK, ['id' => $id, 'type' => $type, 'status' => 'deleted']);
|
||||
if (EventStatus::RUNNING === $entity->status) {
|
||||
return api_error('Cannot update event in running state.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$params = DataUtil::fromRequest($request);
|
||||
|
||||
// -- Update State.
|
||||
if (null !== ($status = $params->get(EntityTable::COLUMN_STATUS))) {
|
||||
if (false === is_int($status) && false === ctype_digit($status)) {
|
||||
return api_error('status parameter must be a number.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (null == ($status = EventStatus::tryFrom((int)$status))) {
|
||||
return api_error('Invalid status parameter was given.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$entity->status = $status;
|
||||
}
|
||||
|
||||
if (null !== ($event = $params->get(EntityTable::COLUMN_EVENT))) {
|
||||
$entity->event = $event;
|
||||
}
|
||||
|
||||
if (null !== ($event_data = $params->get(EntityTable::COLUMN_EVENT_DATA))) {
|
||||
$entity->event_data = $event_data;
|
||||
}
|
||||
|
||||
if (true === (bool)$params->get('reset_logs', false)) {
|
||||
$entity->logs = [];
|
||||
}
|
||||
|
||||
$changed = !empty($entity->diff());
|
||||
|
||||
if ($changed) {
|
||||
$entity->updated_at = (string)makeDate();
|
||||
$entity->logs[] = 'Event was manually updated';
|
||||
$this->repo->save($entity);
|
||||
}
|
||||
|
||||
return api_message($changed ? 'Updated' : 'No Changes detected', Status::OK, $this->formatEntity($entity));
|
||||
}
|
||||
|
||||
private function formatEntity(EntityItem $entity): array
|
||||
{
|
||||
$data = $entity->getAll();
|
||||
$data['status_name'] = $entity->getStatusText();
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
119
src/API/System/OldEvents.php
Normal file
119
src/API/System/OldEvents.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\API\System;
|
||||
|
||||
use App\Libs\Attributes\Route\Delete;
|
||||
use App\Libs\Attributes\Route\Get;
|
||||
use App\Libs\Container;
|
||||
use App\Libs\Database\DatabaseInterface as iDB;
|
||||
use App\Libs\DataUtil;
|
||||
use App\Libs\Entity\StateInterface as iState;
|
||||
use App\Libs\Enums\Http\Status;
|
||||
use App\Libs\Traits\APITraits;
|
||||
use DateInterval;
|
||||
use Psr\Http\Message\ResponseInterface as iResponse;
|
||||
use Psr\Http\Message\ServerRequestInterface as iRequest;
|
||||
use Psr\SimpleCache\CacheInterface as iCache;
|
||||
use Psr\SimpleCache\InvalidArgumentException;
|
||||
|
||||
final class OldEvents
|
||||
{
|
||||
use APITraits;
|
||||
|
||||
public const string URL = '%{api.prefix}/system/old_events';
|
||||
|
||||
private const array TYPES = ['queue', 'progress', 'requests'];
|
||||
|
||||
public function __construct(private iCache $cache, private iDB $db)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Get(self::URL . '[/]', name: 'system.events')]
|
||||
public function __invoke(iRequest $request): iResponse
|
||||
{
|
||||
$response = [
|
||||
'queue' => [],
|
||||
'progress' => [],
|
||||
'requests' => [],
|
||||
];
|
||||
|
||||
foreach ($this->cache->get('queue', []) as $key => $item) {
|
||||
if (null === ($entity = $this->db->get(Container::get(iState::class)::fromArray($item)))) {
|
||||
continue;
|
||||
}
|
||||
$response['queue'][] = ['key' => $key, 'item' => $this->formatEntity($entity)];
|
||||
}
|
||||
|
||||
foreach ($this->cache->get('progress', []) as $key => $item) {
|
||||
if (null !== ($entity = $this->db->get($item))) {
|
||||
$item->id = $entity->id;
|
||||
}
|
||||
|
||||
$response['progress'][] = ['key' => $key, 'item' => $this->formatEntity($item)];
|
||||
}
|
||||
|
||||
foreach ($this->cache->get('requests', []) as $key => $request) {
|
||||
if (null === ($item = ag($request, 'entity')) || false === ($item instanceof iState)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (null !== ($entity = $this->db->get($item))) {
|
||||
$item->id = $entity->id;
|
||||
}
|
||||
|
||||
$response['requests'][] = ['key' => $key, 'item' => $this->formatEntity($item)];
|
||||
}
|
||||
|
||||
return api_response(Status::OK, $response);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Delete(self::URL . '/{id}[/]', name: 'system.events.delete')]
|
||||
public function deleteEvent(iRequest $request, array $args = []): iResponse
|
||||
{
|
||||
$params = DataUtil::fromRequest($request, true);
|
||||
|
||||
if (null === ($id = $params->get('id', ag($args, 'id')))) {
|
||||
return api_error('Invalid id.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$type = $params->get('type', 'queue');
|
||||
|
||||
if (false === in_array($type, self::TYPES, true)) {
|
||||
return api_error(r("Invalid type '{type}'. Only '{types}' are supported.", [
|
||||
'type' => $type,
|
||||
'types' => implode(", ", self::TYPES),
|
||||
]), Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$items = $this->cache->get($type, []);
|
||||
|
||||
if (empty($items)) {
|
||||
return api_error(r('{type} is empty.', ['type' => $type]), Status::NOT_FOUND);
|
||||
}
|
||||
|
||||
if (false === array_key_exists($id, $items)) {
|
||||
return api_error(r("Record id '{id}' doesn't exists. for '{type}' list.", [
|
||||
'id' => $id,
|
||||
'type' => $type,
|
||||
]), Status::NOT_FOUND);
|
||||
}
|
||||
|
||||
if ('queue' === $type) {
|
||||
$item = Container::get(iState::class)::fromArray(['id' => $id]);
|
||||
queuePush($item, remove: true);
|
||||
} else {
|
||||
unset($items[$id]);
|
||||
$this->cache->set($type, $items, new DateInterval('P3D'));
|
||||
}
|
||||
|
||||
return api_response(Status::OK, ['id' => $id, 'type' => $type, 'status' => 'deleted']);
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,24 @@ final class DispatchCommand extends Command
|
||||
assert($this->dispatcher instanceof EventDispatcher);
|
||||
|
||||
foreach ($events as $event) {
|
||||
if (null === ($newState = $this->repo->findOne($event->id))) {
|
||||
$this->logger->notice("The event '{id}' was deleted while the dispatcher was running", [
|
||||
'id' => $event->id
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($newState->status !== Status::PENDING) {
|
||||
$this->logger->notice(
|
||||
"The event '{id}' was changed to '{status}' while the dispatcher was running. Ignoring event.",
|
||||
[
|
||||
'id' => $event->id,
|
||||
'status' => $newState->status->name,
|
||||
]
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->runEvent($event);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Commands\State;
|
||||
|
||||
use App\Command;
|
||||
use App\Libs\Attributes\Route\Cli;
|
||||
use App\Libs\Config;
|
||||
use App\Libs\Entity\StateInterface as iState;
|
||||
use App\Libs\Mappers\Import\DirectMapper;
|
||||
use App\Libs\Options;
|
||||
use Psr\Log\LoggerInterface as iLogger;
|
||||
use Psr\SimpleCache\CacheInterface as iCache;
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
use Symfony\Component\Console\Helper\TableSeparator;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* Class RequestsCommand
|
||||
*
|
||||
* This class is responsible for processing queued HTTP requests.
|
||||
*
|
||||
* @package Your\Namespace
|
||||
*/
|
||||
#[Cli(command: self::ROUTE)]
|
||||
class RequestsCommand extends Command
|
||||
{
|
||||
public const ROUTE = 'state:requests';
|
||||
|
||||
public const TASK_NAME = 'requests';
|
||||
|
||||
/**
|
||||
* Class constructor.
|
||||
*
|
||||
* @param iLogger $logger The logger object.
|
||||
* @param iCache $cache The cache object.
|
||||
* @param DirectMapper $mapper The DirectMapper object.
|
||||
*/
|
||||
public function __construct(private iLogger $logger, private iCache $cache, private DirectMapper $mapper)
|
||||
{
|
||||
set_time_limit(0);
|
||||
ini_set('memory_limit', '-1');
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the command.
|
||||
*/
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName(self::ROUTE)
|
||||
->setDescription('Process queued webhook requests.')
|
||||
->addOption('keep', 'k', InputOption::VALUE_NONE, 'Do not expunge queue after run is complete.')
|
||||
->addOption('list', 'l', InputOption::VALUE_NONE, 'List queued requests.')
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Do not commit changes to backends.')
|
||||
->addOption('no-stats', null, InputOption::VALUE_NONE, 'Do not display end of run stats.')
|
||||
->setHelp('This command process <notice>queued</notice> webhook requests.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure the command is not running in parallel.
|
||||
*
|
||||
* @param InputInterface $input The input interface.
|
||||
* @param OutputInterface $output The output interface.
|
||||
*
|
||||
* @return int The exit code of the command.
|
||||
* @throws \Psr\Cache\InvalidArgumentException if the $key string is not a legal value
|
||||
*/
|
||||
protected function runCommand(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
return $this->single(fn(): int => $this->process($input, $output), $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the command.
|
||||
*
|
||||
* @param InputInterface $input The input interface.
|
||||
* @param OutputInterface $output The output interface.
|
||||
*
|
||||
* @return int The exit code of the command.
|
||||
* @throws \Psr\Cache\InvalidArgumentException if the $key string is not a legal value
|
||||
*/
|
||||
protected function process(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
if (!$this->cache->has('requests')) {
|
||||
$this->logger->info('No requests in the queue.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$queued = [];
|
||||
$requests = $this->cache->get('requests', []);
|
||||
|
||||
if (count($requests) < 1) {
|
||||
$this->logger->info('No requests in the queue.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($input->getOption('list')) {
|
||||
return $this->listItems($input, $output, $requests);
|
||||
}
|
||||
|
||||
if ($input->getOption('dry-run')) {
|
||||
$this->logger->info('Dry run mode. No changes will be committed.');
|
||||
}
|
||||
|
||||
$this->mapper->setOptions([
|
||||
Options::DRY_RUN => $input->getOption('dry-run'),
|
||||
Options::DEBUG_TRACE => $input->getOption('trace')
|
||||
]);
|
||||
|
||||
$fn = function (iState $state) use (&$queued) {
|
||||
$queued[$state->id] = $state;
|
||||
};
|
||||
|
||||
foreach ($requests as $request) {
|
||||
$entity = ag($request, 'entity');
|
||||
assert($entity instanceof iState);
|
||||
|
||||
$options = ag($request, 'options', []);
|
||||
|
||||
$lastSync = ag(Config::get("servers.{$entity->via}", []), 'import.lastSync');
|
||||
if (null !== $lastSync) {
|
||||
$lastSync = makeDate($lastSync);
|
||||
}
|
||||
|
||||
$this->logger->notice('SYSTEM: Processing [{backend}] [{title}] {tainted} request.', [
|
||||
'backend' => $entity->via,
|
||||
'title' => $entity->getName(),
|
||||
'event' => ag($entity->getExtra($entity->via), iState::COLUMN_EXTRA_EVENT, '??'),
|
||||
'tainted' => $entity->isTainted() ? 'tainted' : 'untainted',
|
||||
'lastSync' => $lastSync,
|
||||
]);
|
||||
|
||||
$this->mapper->add($entity, [
|
||||
Options::IMPORT_METADATA_ONLY => (bool)ag($options, Options::IMPORT_METADATA_ONLY),
|
||||
Options::STATE_UPDATE_EVENT => $fn,
|
||||
'after' => $lastSync,
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($queued as $item) {
|
||||
queuePush($item);
|
||||
}
|
||||
|
||||
$operations = $this->mapper->commit();
|
||||
|
||||
$a = [
|
||||
[
|
||||
'Type' => ucfirst(iState::TYPE_MOVIE),
|
||||
'Added' => $operations[iState::TYPE_MOVIE]['added'] ?? '-',
|
||||
'Updated' => $operations[iState::TYPE_MOVIE]['updated'] ?? '-',
|
||||
'Failed' => $operations[iState::TYPE_MOVIE]['failed'] ?? '-',
|
||||
],
|
||||
new TableSeparator(),
|
||||
[
|
||||
'Type' => ucfirst(iState::TYPE_EPISODE),
|
||||
'Added' => $operations[iState::TYPE_EPISODE]['added'] ?? '-',
|
||||
'Updated' => $operations[iState::TYPE_EPISODE]['updated'] ?? '-',
|
||||
'Failed' => $operations[iState::TYPE_EPISODE]['failed'] ?? '-',
|
||||
],
|
||||
];
|
||||
|
||||
if (false === $input->getOption('no-stats')) {
|
||||
(new Table($output))
|
||||
->setHeaders(array_keys($a[0]))
|
||||
->setStyle('box')
|
||||
->setRows(array_values($a))
|
||||
->render();
|
||||
}
|
||||
|
||||
if (false === $input->getOption('keep') && false === $input->getOption('dry-run')) {
|
||||
$this->cache->delete('requests');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists items based on the provided input and output.
|
||||
*
|
||||
* @param InputInterface $input The input interface object.
|
||||
* @param OutputInterface $output The output interface object.
|
||||
* @param array $requests The array of requests.
|
||||
*
|
||||
* @return int Returns the success status code.
|
||||
*/
|
||||
private function listItems(InputInterface $input, OutputInterface $output, array $requests): int
|
||||
{
|
||||
$list = [];
|
||||
|
||||
$mode = $input->getOption('output');
|
||||
|
||||
foreach ($requests as $request) {
|
||||
$opts = ag($request, 'options', []);
|
||||
$item = ag($request, 'entity');
|
||||
|
||||
assert($item instanceof iState);
|
||||
|
||||
if ('table' === $mode) {
|
||||
$builder = [
|
||||
'queued' => makeDate(ag($item->getExtra($item->via), iState::COLUMN_EXTRA_DATE))->format(
|
||||
'Y-m-d H:i:s T'
|
||||
),
|
||||
'via' => $item->via,
|
||||
'title' => $item->getName(),
|
||||
'played' => $item->isWatched() ? 'Yes' : 'No',
|
||||
'tainted' => $item->isTainted() ? 'Yes' : 'No',
|
||||
'event' => ag($item->getExtra($item->via), iState::COLUMN_EXTRA_EVENT, '??'),
|
||||
];
|
||||
} else {
|
||||
$builder = [
|
||||
...$item->getAll(),
|
||||
'tainted' => $item->isTainted(),
|
||||
'options' => $opts
|
||||
];
|
||||
}
|
||||
|
||||
$list[] = $builder;
|
||||
}
|
||||
|
||||
$this->displayContent($list, $output, $mode);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ final class DBLayer
|
||||
public const string IS_LEFT_OUTER_JOIN = 'LEFT OUTER JOIN';
|
||||
public const string IS_MATCH_AGAINST = 'MATCH() AGAINST()';
|
||||
public const string IS_JSON_CONTAINS = 'JSON_CONTAINS';
|
||||
public const string IS_JSON_EXTRACT = 'JSON_EXTRACT';
|
||||
public const string IS_JSON_SEARCH = 'JSON_SEARCH';
|
||||
|
||||
public function __construct(private PDO $pdo)
|
||||
@@ -596,6 +597,23 @@ final class DBLayer
|
||||
|
||||
$bind[$eBindName] = $opt[2];
|
||||
break;
|
||||
case self::IS_JSON_EXTRACT:
|
||||
if (!isset($opt[1], $opt[2], $opt[3])) {
|
||||
throw new RuntimeException('IS_JSON_CONTAINS: expects 3 parameters.');
|
||||
}
|
||||
|
||||
$eBindName = '__db_je_' . random_int(1, 1000);
|
||||
|
||||
$keys[] = sprintf(
|
||||
"JSON_EXTRACT(%s, %s) %s %s",
|
||||
$this->escapeIdentifier($column, true),
|
||||
$opt[1],
|
||||
$opt[2],
|
||||
':' . $eBindName,
|
||||
);
|
||||
|
||||
$bind[$eBindName] = $opt[3];
|
||||
break;
|
||||
case self::IS_INNER_JOIN:
|
||||
case self::IS_LEFT_JOIN:
|
||||
case self::IS_LEFT_OUTER_JOIN:
|
||||
|
||||
61
src/Libs/Extends/ProxyHandler.php
Normal file
61
src/Libs/Extends/ProxyHandler.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libs\Extends;
|
||||
|
||||
use App\Libs\Config;
|
||||
use Closure;
|
||||
use DateTimeInterface;
|
||||
use Monolog\Handler\AbstractProcessingHandler;
|
||||
use Monolog\Level;
|
||||
use Monolog\LogRecord;
|
||||
|
||||
/**
|
||||
* ProxyHandler, a handler that calls a closure to process the log record.
|
||||
*/
|
||||
final class ProxyHandler extends AbstractProcessingHandler
|
||||
{
|
||||
private bool $closed = false;
|
||||
|
||||
public function __construct(private readonly Closure $callback, $level = Level::Debug)
|
||||
{
|
||||
$this->bubble = true;
|
||||
parent::__construct($level, true);
|
||||
}
|
||||
|
||||
public static function create(Closure $callback, $level = Level::Debug): self
|
||||
{
|
||||
return new self($callback, $level);
|
||||
}
|
||||
|
||||
public function close(): void
|
||||
{
|
||||
$this->closed = true;
|
||||
}
|
||||
|
||||
protected function write(LogRecord $record): void
|
||||
{
|
||||
if (true === $this->closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
$date = $record['datetime'] ?? 'No date set';
|
||||
|
||||
if (true === ($date instanceof DateTimeInterface)) {
|
||||
$date = $date->format(DateTimeInterface::ATOM);
|
||||
}
|
||||
|
||||
$message = r('[{date}] {level}: {message}', [
|
||||
'date' => $date,
|
||||
'level' => $record['level_name'] ?? $record['level'] ?? '??',
|
||||
'message' => $record['message'],
|
||||
]);
|
||||
|
||||
if (false === empty($record['context']) && true === (bool)Config::get('logs.context')) {
|
||||
$message .= ' { ' . arrayToString($record['context']) . ' }';
|
||||
}
|
||||
|
||||
($this->callback)($message, $record);
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,6 @@ final class Initializer
|
||||
private Cli $cli;
|
||||
private ConsoleOutput $cliOutput;
|
||||
private iLogger|null $accessLog = null;
|
||||
private bool $booted = false;
|
||||
|
||||
/**
|
||||
* Initializes the object.
|
||||
|
||||
@@ -828,6 +828,14 @@ final class DirectMapper implements iImport
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function getLogger(): iLogger
|
||||
{
|
||||
return $this->logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
|
||||
@@ -655,6 +655,14 @@ final class MemoryMapper implements iImport
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function getLogger(): iLogger
|
||||
{
|
||||
return $this->logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
|
||||
@@ -195,6 +195,14 @@ final class RestoreMapper implements iImport
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function getLogger(): iLogger
|
||||
{
|
||||
return $this->logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
|
||||
@@ -108,6 +108,13 @@ interface ImportInterface extends Countable
|
||||
*/
|
||||
public function setLogger(LoggerInterface $logger): self;
|
||||
|
||||
/**
|
||||
* Get the logger instance.
|
||||
*
|
||||
* @return LoggerInterface The logger instance.
|
||||
*/
|
||||
public function getLogger(): LoggerInterface;
|
||||
|
||||
/**
|
||||
* Set the database object for this class.
|
||||
*
|
||||
|
||||
@@ -37,6 +37,7 @@ final class Options
|
||||
public const string NO_FALLBACK = 'NO_FALLBACK';
|
||||
public const string LIMIT_RESULTS = 'LIMIT_RESULTS';
|
||||
public const string NO_CHECK = 'NO_CHECK';
|
||||
public const string LOG_WRITER = 'LOG_WRITER';
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
|
||||
@@ -29,6 +29,7 @@ use App\Libs\Uri;
|
||||
use App\Model\Events\Event as EventInfo;
|
||||
use App\Model\Events\EventListener;
|
||||
use App\Model\Events\EventsRepository;
|
||||
use App\Model\Events\EventsTable;
|
||||
use App\Model\Events\EventStatus;
|
||||
use Monolog\Utils;
|
||||
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||
@@ -467,7 +468,7 @@ if (!function_exists('api_error')) {
|
||||
$response = api_response(
|
||||
status: $httpCode,
|
||||
body: array_replace_recursive($body, [
|
||||
'error' => [
|
||||
ag($opts, 'top_key', 'error') => [
|
||||
'code' => $httpCode->value,
|
||||
'message' => $message
|
||||
]
|
||||
@@ -488,6 +489,32 @@ if (!function_exists('api_error')) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('api_message')) {
|
||||
/**
|
||||
* Create API message response.
|
||||
*
|
||||
* @param string $message The error message.
|
||||
* @param Status|int $httpCode Optional. The HTTP status code. Default is {@see Status::OK}.
|
||||
* @param array $body Optional. Additional fields to include in the response body.
|
||||
* @param array $headers Optional. Additional headers to include in the response.
|
||||
* @param string|null $reason Optional. The reason phrase to include in the response. Default is null.
|
||||
* @param array $opts Optional. Additional options.
|
||||
*
|
||||
* @return iResponse A PSR-7 compatible response object.
|
||||
*/
|
||||
function api_message(
|
||||
string $message,
|
||||
Status|int $httpCode = Status::OK,
|
||||
array $body = [],
|
||||
array $headers = [],
|
||||
string|null $reason = null,
|
||||
array $opts = []
|
||||
): iResponse {
|
||||
$opts['top_key'] = 'info';
|
||||
return api_error($message, $httpCode, $body, $headers, $reason, $opts);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('httpClientChunks')) {
|
||||
/**
|
||||
* Handle response stream as chunks.
|
||||
@@ -735,7 +762,7 @@ if (!function_exists('getAppVersion')) {
|
||||
$proc = Process::fromShellCommandline(sprintf($cmd, escapeshellarg($gitDir)));
|
||||
$proc->run();
|
||||
if ($proc->isSuccessful()) {
|
||||
return explode(PHP_EOL, $proc->getOutput())[0];
|
||||
return trim(explode(PHP_EOL, $proc->getOutput())[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1959,7 +1986,24 @@ if (!function_exists('queueEvent')) {
|
||||
$repo = ag($opts, EventsRepository::class, fn() => Container::get(EventsRepository::class));
|
||||
assert($repo instanceof EventsRepository);
|
||||
|
||||
$item = $repo->getObject([]);
|
||||
$item = null;
|
||||
if (null !== ($reference = ag($opts, EventsTable::COLUMN_REFERENCE))) {
|
||||
$criteria = [];
|
||||
$isUnique = (bool)ag($opts, 'unique', false);
|
||||
|
||||
if (false === $isUnique) {
|
||||
$criteria[EventsTable::COLUMN_STATUS] = EventStatus::PENDING->value;
|
||||
}
|
||||
|
||||
if (null !== ($refItem = $repo->findByReference($reference, $criteria)) && true === $isUnique) {
|
||||
$repo->remove($refItem);
|
||||
} else {
|
||||
$item = $refItem;
|
||||
}
|
||||
unset($refItem);
|
||||
}
|
||||
|
||||
$item = $item ?? $repo->getObject([]);
|
||||
$item->event = $event;
|
||||
$item->status = EventStatus::PENDING;
|
||||
$item->event_data = $data;
|
||||
@@ -1968,9 +2012,33 @@ if (!function_exists('queueEvent')) {
|
||||
'class' => ag($opts, 'class', DataEvent::class),
|
||||
];
|
||||
|
||||
if ($reference) {
|
||||
$item->reference = $reference;
|
||||
}
|
||||
|
||||
$id = $repo->save($item);
|
||||
$item->id = $id;
|
||||
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('getPagination')) {
|
||||
function getPagination(iRequest $request, int $page = 1, int $perpage = 0, array $options = []): array
|
||||
{
|
||||
$page = (int)($request->getQueryParams()['page'] ?? $page);
|
||||
|
||||
if (0 === $perpage) {
|
||||
$perpage = 25;
|
||||
}
|
||||
|
||||
if (false === array_key_exists('force_perpage', $options)) {
|
||||
$perpage = (int)($request->getQueryParams()['perpage'] ?? $perpage);
|
||||
}
|
||||
|
||||
$start = (($page <= 2) ? ((1 === $page) ? 0 : $perpage) : $perpage * ($page - 1));
|
||||
$start = (!$page) ? 0 : $start;
|
||||
|
||||
return [$page, $perpage, $start];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\libs\Events\DataEvent;
|
||||
use App\Model\Events\EventListener;
|
||||
|
||||
#[EventListener(self::NAME)]
|
||||
final readonly class OnTestEvent
|
||||
{
|
||||
public const string NAME = 'test_event';
|
||||
|
||||
public function __invoke(DataEvent $e): DataEvent
|
||||
{
|
||||
$e->stopPropagation();
|
||||
return $e;
|
||||
}
|
||||
}
|
||||
80
src/Listeners/ProcessRequestEvent.php
Normal file
80
src/Listeners/ProcessRequestEvent.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Libs\Config;
|
||||
use App\Libs\Container;
|
||||
use App\Libs\Entity\StateInterface as iState;
|
||||
use App\libs\Events\DataEvent;
|
||||
use App\Libs\Extends\ProxyHandler;
|
||||
use App\Libs\Mappers\Import\DirectMapper;
|
||||
use App\Libs\Options;
|
||||
use App\Model\Events\EventListener;
|
||||
use Monolog\Logger;
|
||||
use Psr\Log\LoggerInterface as iLogger;
|
||||
|
||||
#[EventListener(self::NAME)]
|
||||
final readonly class ProcessRequestEvent
|
||||
{
|
||||
public const string NAME = 'process_request';
|
||||
|
||||
/**
|
||||
* Class constructor.
|
||||
*
|
||||
* @param iLogger $logger The logger object.
|
||||
*/
|
||||
public function __construct(private iLogger $logger, private DirectMapper $mapper)
|
||||
{
|
||||
set_time_limit(0);
|
||||
ini_set('memory_limit', '-1');
|
||||
}
|
||||
|
||||
public function __invoke(DataEvent $e): DataEvent
|
||||
{
|
||||
$e->stopPropagation();
|
||||
|
||||
$data = ag($e->getData(), 'entity');
|
||||
$entity = Container::get(iState::class)::fromArray($data);
|
||||
|
||||
$options = ag($data, 'options', []);
|
||||
|
||||
if (null !== ($lastSync = ag(Config::get("servers.{$entity->via}", []), 'import.lastSync'))) {
|
||||
$lastSync = makeDate($lastSync);
|
||||
}
|
||||
|
||||
$message = r('SYSTEM: Processing [{backend}] [{title}] {tainted} request.', [
|
||||
'backend' => $entity->via,
|
||||
'title' => $entity->getName(),
|
||||
'event' => ag($entity->getExtra($entity->via), iState::COLUMN_EXTRA_EVENT, '??'),
|
||||
'tainted' => $entity->isTainted() ? 'tainted' : 'untainted',
|
||||
'lastSync' => $lastSync,
|
||||
]);
|
||||
|
||||
$e->addLog($message);
|
||||
$this->logger->notice($message);
|
||||
|
||||
$logger = clone $this->logger;
|
||||
assert($logger instanceof Logger);
|
||||
|
||||
$handler = ProxyHandler::create($e->addLog(...));
|
||||
$logger->pushHandler($handler);
|
||||
|
||||
$oldLogger = $this->mapper->getLogger();
|
||||
$this->mapper->setLogger($logger);
|
||||
|
||||
$this->mapper->add($entity, [
|
||||
Options::IMPORT_METADATA_ONLY => (bool)ag($options, Options::IMPORT_METADATA_ONLY),
|
||||
Options::STATE_UPDATE_EVENT => fn(iState $state) => queuePush($state),
|
||||
'after' => $lastSync,
|
||||
]);
|
||||
|
||||
$this->mapper->commit();
|
||||
$this->mapper->setLogger($oldLogger);
|
||||
|
||||
$handler->close();
|
||||
|
||||
return $e;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,11 @@ final class Event extends EntityTable
|
||||
*/
|
||||
public EventStatus $status = EventStatus::PENDING;
|
||||
|
||||
/**
|
||||
* @uses EntityTable::COLUMN_REFERENCE
|
||||
*/
|
||||
public string|null $reference = null;
|
||||
|
||||
/**
|
||||
* @uses EntityTable::COLUMN_EVENT
|
||||
*/
|
||||
|
||||
@@ -30,6 +30,25 @@ final class EventsRepository
|
||||
return $this->_findOne([$this->primaryKey => $id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Will return the last event by reference.
|
||||
*
|
||||
* @param string|int $reference Reference to search by.
|
||||
* @param array $criteria Criteria to search by.
|
||||
*
|
||||
* @return EntityItem|null The event or null if not found.
|
||||
*/
|
||||
public function findByReference(string|int $reference, array $criteria = []): EntityItem|null
|
||||
{
|
||||
$criteria[EntityTable::COLUMN_REFERENCE] = $reference;
|
||||
|
||||
$items = (clone $this)->setPerpage(1)->setStart(0)->setDescendingOrder()
|
||||
->setSort(EntityTable::COLUMN_CREATED_AT)
|
||||
->findAll($criteria);
|
||||
|
||||
return $items[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $criteria Criteria to search by.
|
||||
* @param array $cols Columns to select.
|
||||
|
||||
@@ -13,6 +13,7 @@ abstract class EventsTable extends BasicModel
|
||||
|
||||
public const string COLUMN_ID = 'id';
|
||||
public const string COLUMN_STATUS = 'status';
|
||||
public const string COLUMN_REFERENCE = 'reference';
|
||||
public const string COLUMN_EVENT = 'event';
|
||||
public const string COLUMN_EVENT_DATA = 'event_data';
|
||||
public const string COLUMN_OPTIONS = 'options';
|
||||
|
||||
Reference in New Issue
Block a user