Added new event viewer to the WebUI. Migrated requests to use the new event system.

This commit is contained in:
Abdulmhsen B. A. A.
2024-08-18 18:35:19 +03:00
parent 91e5b0cbc9
commit fcdcee7db4
29 changed files with 1257 additions and 437 deletions

View File

@@ -63,6 +63,7 @@ try {
]
);
fwrite(STDERR, $message . PHP_EOL);
fwrite(STDERR, $e->getTraceAsString() . PHP_EOL);
exit(503);
}

View File

@@ -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,

View File

@@ -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}',

View 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>

View File

@@ -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>

View 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>

View 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>&nbsp;</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>&nbsp;
<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>&nbsp;
<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>

View File

@@ -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>&nbsp;
</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>&nbsp;</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>&nbsp;</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>&nbsp;</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>&nbsp;</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>&nbsp;</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) {

View 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}

View 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.

View File

@@ -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()) {

View File

@@ -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;
}
}

View 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']);
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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:

View 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);
}
}

View File

@@ -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.

View File

@@ -828,6 +828,14 @@ final class DirectMapper implements iImport
return $this;
}
/**
* @inheritdoc
*/
public function getLogger(): iLogger
{
return $this->logger;
}
/**
* @inheritdoc
*/

View File

@@ -655,6 +655,14 @@ final class MemoryMapper implements iImport
return $this;
}
/**
* @inheritdoc
*/
public function getLogger(): iLogger
{
return $this->logger;
}
/**
* @inheritdoc
*/

View File

@@ -195,6 +195,14 @@ final class RestoreMapper implements iImport
return $this;
}
/**
* @inheritdoc
*/
public function getLogger(): iLogger
{
return $this->logger;
}
/**
* @inheritdoc
*/

View File

@@ -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.
*

View File

@@ -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()
{

View File

@@ -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];
}
}

View File

@@ -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;
}
}

View 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;
}
}

View File

@@ -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
*/

View File

@@ -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.

View File

@@ -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';