525 lines
19 KiB
Vue
525 lines
19 KiB
Vue
<template>
|
|
<div>
|
|
<div class="mt-1 columns is-multiline">
|
|
<div class="column is-12 is-clearfix is-unselectable">
|
|
<span class="title is-4">
|
|
<span class="icon-text">
|
|
<template v-if="toggleForm">
|
|
<span class="icon"
|
|
><i class="fa-solid" :class="{ 'fa-edit': targetRef, 'fa-plus': !targetRef }"
|
|
/></span>
|
|
<span>{{ targetRef ? `Edit - ${target.name}` : 'Add new notification target' }}</span>
|
|
</template>
|
|
<template v-else>
|
|
<span class="icon"><i class="fa-solid fa-paper-plane" /></span>
|
|
<span>Notifications</span>
|
|
</template>
|
|
</span>
|
|
</span>
|
|
<div class="is-pulled-right" v-if="!toggleForm">
|
|
<div class="field is-grouped">
|
|
<p
|
|
class="control has-icons-left"
|
|
v-if="toggleFilter && notifications && notifications.length > 0"
|
|
>
|
|
<input
|
|
type="search"
|
|
v-model.lazy="query"
|
|
class="input"
|
|
id="filter"
|
|
placeholder="Filter displayed content"
|
|
/>
|
|
<span class="icon is-left"><i class="fas fa-filter" /></span>
|
|
</p>
|
|
|
|
<p class="control" v-if="notifications && notifications.length > 0">
|
|
<button class="button is-danger is-light" @click="toggleFilter = !toggleFilter">
|
|
<span class="icon"><i class="fas fa-filter" /></span>
|
|
<span v-if="!isMobile">Filter</span>
|
|
</button>
|
|
</p>
|
|
|
|
<p class="control">
|
|
<button
|
|
class="button is-primary"
|
|
@click="
|
|
resetForm(false);
|
|
toggleForm = true;
|
|
"
|
|
v-tooltip="'Add new notification target.'"
|
|
>
|
|
<span class="icon"><i class="fas fa-add" /></span>
|
|
<span v-if="!isMobile">New Notification</span>
|
|
</button>
|
|
</p>
|
|
<p class="control" v-if="notifications.length > 0">
|
|
<button
|
|
class="button is-warning"
|
|
@click="sendTest"
|
|
v-tooltip="'Send test notification.'"
|
|
:class="{ 'is-loading': sendingTest }"
|
|
:disabled="!notifications.length || sendingTest"
|
|
>
|
|
<span class="icon"><i class="fas fa-paper-plane" /></span>
|
|
<span v-if="!isMobile">Send Test</span>
|
|
</button>
|
|
</p>
|
|
<p class="control" v-if="notifications.length > 0">
|
|
<button
|
|
v-tooltip.bottom="'Change display style'"
|
|
class="button has-tooltip-bottom"
|
|
@click="() => (display_style = display_style === 'list' ? 'grid' : 'list')"
|
|
>
|
|
<span class="icon">
|
|
<i
|
|
class="fa-solid"
|
|
:class="{
|
|
'fa-table': display_style !== 'list',
|
|
'fa-table-list': display_style === 'list',
|
|
}"
|
|
/></span>
|
|
<span v-if="!isMobile">
|
|
{{ display_style === 'list' ? 'List' : 'Grid' }}
|
|
</span>
|
|
</button>
|
|
</p>
|
|
|
|
<p class="control" v-if="notifications.length > 0">
|
|
<button
|
|
class="button is-info"
|
|
@click="loadContent(page)"
|
|
:class="{ 'is-loading': isLoading }"
|
|
:disabled="isLoading || notifications.length < 1"
|
|
>
|
|
<span class="icon"><i class="fas fa-refresh" /></span>
|
|
<span v-if="!isMobile">Reload</span>
|
|
</button>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="is-hidden-mobile" v-if="!toggleForm">
|
|
<span class="subtitle">
|
|
Send notifications to your webhooks based on specified events or presets.
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="column is-12" v-if="!toggleForm && paging?.total_pages > 1">
|
|
<Pager
|
|
:page="paging.page"
|
|
:last_page="paging.total_pages"
|
|
:isLoading="isLoading"
|
|
@navigate="
|
|
async (newPage) => {
|
|
page = newPage;
|
|
await loadContent(newPage);
|
|
}
|
|
"
|
|
/>
|
|
</div>
|
|
|
|
<div class="column is-12" v-if="toggleForm">
|
|
<NotificationForm
|
|
:addInProgress="addInProgress"
|
|
:reference="targetRef"
|
|
:item="target"
|
|
@cancel="resetForm(true)"
|
|
@submit="updateItem"
|
|
:allowedEvents="allowedEvents"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="columns is-multiline"
|
|
v-if="!isLoading && !toggleForm && filteredTargets && filteredTargets.length > 0"
|
|
>
|
|
<template v-if="'list' === display_style">
|
|
<div class="column is-12">
|
|
<div class="table-container">
|
|
<table
|
|
class="table is-striped is-hoverable is-fullwidth is-bordered"
|
|
style="min-width: 850px; table-layout: fixed"
|
|
>
|
|
<thead>
|
|
<tr class="has-text-centered is-unselectable">
|
|
<th width="80%">
|
|
<span class="icon"><i class="fa-solid fa-paper-plane" /></span>
|
|
<span>Targets</span>
|
|
</th>
|
|
<th width="20%">
|
|
<span class="icon"><i class="fa-solid fa-gear" /></span>
|
|
<span>Actions</span>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="item in filteredTargets" :key="item.id">
|
|
<td class="is-text-overflow is-vcentered">
|
|
<div class="is-bold">
|
|
{{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }}) @
|
|
<NuxtLink target="_blank" :href="item.request.url">{{ item.name }}</NuxtLink>
|
|
</div>
|
|
<div class="is-unselectable">
|
|
<span class="icon-text">
|
|
<span class="icon"><i class="fa-solid fa-list-ul" /></span>
|
|
<span>On: {{ join_events(item.on) }}</span>
|
|
</span>
|
|
|
|
<span class="icon-text">
|
|
<span class="icon"><i class="fa-solid fa-sliders" /></span>
|
|
<span>Presets: {{ join_presets(item.presets) }}</span>
|
|
</span>
|
|
|
|
<span class="icon-text is-clickable" @click="toggleEnabled(item)">
|
|
<span
|
|
class="icon"
|
|
:class="item.enabled ? 'has-text-success' : 'has-text-danger'"
|
|
v-tooltip="
|
|
`Notification is ${item.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`
|
|
"
|
|
>
|
|
<i class="fa-solid fa-power-off" />
|
|
</span>
|
|
<span>{{ item.enabled ? 'Enabled' : 'Disabled' }}</span>
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td class="is-vcentered is-items-center">
|
|
<div class="field is-grouped is-grouped-centered">
|
|
<div class="control">
|
|
<button
|
|
class="button is-info is-small is-fullwidth"
|
|
@click="exportItem(item)"
|
|
>
|
|
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
|
<span v-if="!isMobile">Export</span>
|
|
</button>
|
|
</div>
|
|
<div class="control">
|
|
<button
|
|
class="button is-warning is-small is-fullwidth"
|
|
@click="editItem(item)"
|
|
>
|
|
<span class="icon"><i class="fa-solid fa-edit" /></span>
|
|
<span v-if="!isMobile">Edit</span>
|
|
</button>
|
|
</div>
|
|
<div class="control">
|
|
<button
|
|
class="button is-danger is-small is-fullwidth"
|
|
@click="deleteItem(item)"
|
|
>
|
|
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
|
<span v-if="!isMobile">Delete</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template v-else>
|
|
<div class="column is-6 is-12-mobile" v-for="item in filteredTargets" :key="item.id">
|
|
<div class="card is-flex is-full-height is-flex-direction-column">
|
|
<header class="card-header">
|
|
<div class="card-header-title is-text-overflow is-block">
|
|
{{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }}) @
|
|
<NuxtLink target="_blank" :href="item.request.url">{{ item.name }}</NuxtLink>
|
|
</div>
|
|
<div class="card-header-icon">
|
|
<div class="field is-grouped">
|
|
<div class="control" @click="toggleEnabled(item)">
|
|
<span
|
|
class="icon"
|
|
:class="item.enabled ? 'has-text-success' : 'has-text-danger'"
|
|
v-tooltip="
|
|
`Notification is ${item.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`
|
|
"
|
|
>
|
|
<i class="fa-solid fa-power-off" />
|
|
</span>
|
|
</div>
|
|
<div class="control">
|
|
<a
|
|
class="has-text-info"
|
|
v-tooltip="'Export target.'"
|
|
@click.prevent="exportItem(item)"
|
|
>
|
|
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
<div class="card-content is-flex-grow-1">
|
|
<div class="content">
|
|
<p>
|
|
<span class="icon"><i class="fa-solid fa-list-ul" /></span>
|
|
<span>On: {{ join_events(item.on) }}</span>
|
|
</p>
|
|
<p>
|
|
<span class="icon"><i class="fa-solid fa-sliders" /></span>
|
|
<span>Presets: {{ join_presets(item.presets) }}</span>
|
|
</p>
|
|
<p v-if="item.request?.headers && item.request.headers.length > 0">
|
|
<span class="icon"><i class="fa-solid fa-heading" /></span>
|
|
<span>Headers: {{ item.request.headers.map((h) => h.key).join(', ') }}</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="card-footer mt-auto">
|
|
<div class="card-footer-item">
|
|
<button class="button is-warning is-fullwidth" @click="editItem(item)">
|
|
<span class="icon"><i class="fa-solid fa-edit" /></span>
|
|
<span>Edit</span>
|
|
</button>
|
|
</div>
|
|
<div class="card-footer-item">
|
|
<button class="button is-danger is-fullwidth" @click="deleteItem(item)">
|
|
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
|
<span>Delete</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div
|
|
class="columns is-multiline"
|
|
v-if="!toggleForm && (isLoading || !filteredTargets || filteredTargets.length < 1)"
|
|
>
|
|
<div class="column is-12">
|
|
<Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
|
|
Loading data. Please wait...
|
|
</Message>
|
|
<Message
|
|
title="No Results"
|
|
class="is-warning"
|
|
icon="fas fa-search"
|
|
v-else-if="query"
|
|
:useClose="true"
|
|
@close="query = ''"
|
|
>
|
|
<p>
|
|
No results found for the query: <code>{{ query }}</code
|
|
>.
|
|
</p>
|
|
<p>Please try a different search term.</p>
|
|
</Message>
|
|
<Message v-else title="No targets" class="is-warning" icon="fas fa-exclamation-circle">
|
|
No notification targets found. Click on the
|
|
<span class="icon"><i class="fas fa-add" /></span>
|
|
<strong>New Notification</strong> button to add your first notification target.
|
|
</Message>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="columns is-multiline"
|
|
v-if="!toggleForm && filteredTargets && filteredTargets.length > 0"
|
|
>
|
|
<div class="column is-12">
|
|
<Message class="is-info" :body_class="'pl-0'">
|
|
<ul>
|
|
<li>
|
|
When you export notification target, We remove <code>Authorization</code> header key
|
|
by default, However this might not be enough to remove credentials from the exported
|
|
data. it's your responsibility to ensure that the exported data does not contain any
|
|
sensitive information for sharing.
|
|
</li>
|
|
<li>
|
|
When you set the request type as <code>Form</code>, the event data will be JSON
|
|
encoded and sent as <code>...&data_key=json_string</code>, only the
|
|
<code>data</code> field will be JSON encoded. The other keys <code>id</code>,
|
|
<code>event</code> and <code>created_at</code> will be sent as they are.
|
|
</li>
|
|
<li>
|
|
We also send two special headers <code>X-Event-ID</code> and <code>X-Event</code> with
|
|
the request.
|
|
</li>
|
|
<li>
|
|
If you have selected specific presets or events, this will take priority, For example,
|
|
if you limited the target to <code>default</code> preset and selected
|
|
<code>ALL</code> events, only events that reference the <code>default</code> preset
|
|
will be sent to that target. Like wise, if you have limited both events and presets,
|
|
then ONLY events that satisfy both conditions will be sent to that target. Only the
|
|
<code>test</code> events can bypass these conditions.
|
|
</li>
|
|
</ul>
|
|
</Message>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { useStorage } from '@vueuse/core';
|
|
import type { notification } from '~/types/notification';
|
|
import { useConfirm } from '~/composables/useConfirm';
|
|
import { useNotifications } from '~/composables/useNotifications';
|
|
import type { ImportedItem } from '~/types';
|
|
|
|
const toast = useNotification();
|
|
const box = useConfirm();
|
|
const display_style = useStorage<string>('notification_display_style', 'cards');
|
|
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
|
|
|
const notificationsStore = useNotifications();
|
|
const notifications = notificationsStore.notifications;
|
|
const paging = notificationsStore.pagination;
|
|
const allowedEvents = notificationsStore.events;
|
|
const isLoading = notificationsStore.isLoading;
|
|
const addInProgress = notificationsStore.addInProgress;
|
|
const lastError = notificationsStore.lastError;
|
|
|
|
const page = ref(1);
|
|
const targetRef = ref<number | undefined>(undefined);
|
|
const toggleForm = ref(false);
|
|
const sendingTest = ref(false);
|
|
|
|
const defaultState = (): notification => ({
|
|
name: '',
|
|
on: [],
|
|
presets: [],
|
|
enabled: true,
|
|
request: { method: 'POST', url: '', type: 'json', headers: [], data_key: 'data' },
|
|
});
|
|
|
|
const target = ref<notification>(defaultState());
|
|
const query = ref<string>('');
|
|
const toggleFilter = ref(false);
|
|
|
|
const filteredTargets = computed<notification[]>(() => {
|
|
const q = query.value?.toLowerCase();
|
|
const items = notifications.value.map((item) => ({ ...item })) as notification[];
|
|
if (!q) return items;
|
|
return items.filter((item: notification) => deepIncludes(item, q, new WeakSet()));
|
|
});
|
|
|
|
watch(toggleFilter, (val) => {
|
|
if (!val) {
|
|
query.value = '';
|
|
}
|
|
});
|
|
|
|
const loadContent = async (pageNumber = page.value) => {
|
|
await notificationsStore.loadNotifications(pageNumber);
|
|
};
|
|
|
|
const resetForm = (closeForm = false) => {
|
|
target.value = defaultState();
|
|
targetRef.value = undefined;
|
|
if (closeForm) {
|
|
toggleForm.value = false;
|
|
}
|
|
};
|
|
|
|
const deleteItem = async (item: notification) => {
|
|
if (true !== (await box.confirm(`Delete '${item.name}'?`))) {
|
|
return;
|
|
}
|
|
|
|
if (!item.id) {
|
|
toast.error('Notification target not found.');
|
|
return;
|
|
}
|
|
|
|
await notificationsStore.deleteNotification(item.id);
|
|
};
|
|
|
|
const toggleEnabled = async (item: notification) => {
|
|
if (!item.id) {
|
|
toast.error('Notification target not found.');
|
|
return;
|
|
}
|
|
|
|
await notificationsStore.patchNotification(item.id, { enabled: !item.enabled });
|
|
};
|
|
|
|
const updateItem = async ({
|
|
reference,
|
|
item,
|
|
}: {
|
|
reference: number | undefined;
|
|
item: notification;
|
|
}) => {
|
|
if (reference) {
|
|
await notificationsStore.updateNotification(reference, item);
|
|
} else {
|
|
await notificationsStore.createNotification(item);
|
|
}
|
|
|
|
if (!lastError.value) {
|
|
resetForm(true);
|
|
}
|
|
};
|
|
|
|
const editItem = (item: notification) => {
|
|
target.value = JSON.parse(JSON.stringify(item)) as notification;
|
|
targetRef.value = item.id ?? undefined;
|
|
toggleForm.value = true;
|
|
};
|
|
|
|
const join_events = (events: Array<string>) =>
|
|
!events || events.length < 1 ? 'ALL' : events.map((e) => ucFirst(e)).join(', ');
|
|
const join_presets = (presets: Array<string>) =>
|
|
!presets || presets.length < 1 ? 'ALL' : presets.map((e) => ucFirst(e)).join(', ');
|
|
|
|
const sendTest = async () => {
|
|
if (true !== (await box.confirm('Send test notification?'))) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
sendingTest.value = true;
|
|
const response = await request('/api/notifications/test', { method: 'POST' });
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
const message = await parse_api_error(data);
|
|
toast.error(`Failed to send test notification. ${message}`);
|
|
return;
|
|
}
|
|
|
|
toast.success('Test notification sent.');
|
|
} catch (error: any) {
|
|
console.error(error);
|
|
const message = error?.message || 'Unknown error';
|
|
toast.error(`Failed to send test notification. ${message}`);
|
|
} finally {
|
|
sendingTest.value = false;
|
|
}
|
|
};
|
|
|
|
onMounted(async () => await notificationsStore.loadNotifications(page.value));
|
|
|
|
const exportItem = async (item: notification) => {
|
|
const data: notification & ImportedItem = {
|
|
...JSON.parse(JSON.stringify(item)),
|
|
_type: 'notification',
|
|
_version: '1.0',
|
|
};
|
|
|
|
const keys = ['id', 'raw'];
|
|
keys.forEach((k) => {
|
|
if (Object.prototype.hasOwnProperty.call(data, k)) {
|
|
const { [k]: _, ...rest } = data as any;
|
|
Object.assign(data, rest);
|
|
}
|
|
});
|
|
|
|
if (data.request?.headers?.length) {
|
|
data.request.headers = data.request.headers.filter(
|
|
(h) => 'authorization' !== h.key.toLowerCase(),
|
|
);
|
|
}
|
|
|
|
copyText(encode(data));
|
|
};
|
|
</script>
|