@escalated-dev/escalated 0.9.0 → 0.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -5
- package/package.json +86 -83
- package/src/components/EscalatedLayout.vue +9 -0
- package/src/index.js +4 -0
- package/src/pages/Admin/Automations/Form.vue +280 -0
- package/src/pages/Admin/Automations/Index.vue +230 -0
- package/src/pages/Admin/Plugins/Index.vue +10 -2
- package/src/pages/Admin/Settings/DatabaseConnection.vue +228 -0
- package/src/pages/Admin/Settings/PublicTickets.vue +163 -0
- package/src/pages/Admin/Webhooks/DeliveryLog.vue +1 -2
- package/src/pages/Admin/Workflows/Form.vue +24 -0
- package/src/pages/Plugin/Page.vue +61 -0
- package/src/resolvePage.js +160 -0
|
@@ -108,8 +108,7 @@ function togglePayload(id) {
|
|
|
108
108
|
<td colspan="5" class="bg-[var(--esc-panel-hover)] px-4 py-3">
|
|
109
109
|
<pre
|
|
110
110
|
class="max-h-40 overflow-auto rounded-lg bg-[var(--esc-panel-surface-alt)] p-3 text-xs text-[var(--esc-panel-text-tertiary)]"
|
|
111
|
-
>{{ JSON.stringify(delivery.payload, null, 2) }}</pre
|
|
112
|
-
>
|
|
111
|
+
>{{ JSON.stringify(delivery.payload, null, 2) }}</pre>
|
|
113
112
|
</td>
|
|
114
113
|
</tr>
|
|
115
114
|
</template>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import Builder from './Builder.vue';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The workflow editor, under the name the backends render.
|
|
6
|
+
*
|
|
7
|
+
* Builder.vue is the editor itself and takes exactly these props. Several
|
|
8
|
+
* backends render `Escalated/Admin/Workflows/Form` for both create and edit,
|
|
9
|
+
* and a page name with no component behind it is not an error in Inertia --
|
|
10
|
+
* the panel simply comes up blank. This is that name.
|
|
11
|
+
*/
|
|
12
|
+
defineProps({
|
|
13
|
+
workflow: { type: Object, default: null },
|
|
14
|
+
|
|
15
|
+
// Passed by the backends alongside the workflow. Builder derives its own
|
|
16
|
+
// trigger list, so this is accepted rather than forwarded; declaring it
|
|
17
|
+
// keeps it out of the fallthrough attributes.
|
|
18
|
+
triggerEvents: { type: [Array, Object], default: null },
|
|
19
|
+
});
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<template>
|
|
23
|
+
<Builder :workflow="workflow" />
|
|
24
|
+
</template>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import EscalatedLayout from '../../components/EscalatedLayout.vue';
|
|
3
|
+
import { computed, resolveComponent } from 'vue';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A full page registered by a plugin.
|
|
7
|
+
*
|
|
8
|
+
* A plugin declares a route on the backend and a component on the frontend;
|
|
9
|
+
* the backend renders this page with the component's name, and this is where
|
|
10
|
+
* the two meet. `defineEscalatedPlugin` registers those components globally on
|
|
11
|
+
* the app, so the name is resolved from the global registry rather than
|
|
12
|
+
* imported.
|
|
13
|
+
*
|
|
14
|
+
* Two backends render `Escalated/Plugin/Page`, and there was no component
|
|
15
|
+
* behind the name — so every plugin page was blank, whether or not the plugin
|
|
16
|
+
* had registered its component correctly. A plugin that has not been installed
|
|
17
|
+
* on the frontend now says so instead.
|
|
18
|
+
*/
|
|
19
|
+
const props = defineProps({
|
|
20
|
+
plugin: { type: String, default: '' },
|
|
21
|
+
component: { type: String, default: '' },
|
|
22
|
+
layout: { type: String, default: 'admin' },
|
|
23
|
+
props: { type: Object, default: () => ({}) },
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const resolved = computed(() => {
|
|
27
|
+
if (!props.component) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const found = resolveComponent(props.component);
|
|
32
|
+
|
|
33
|
+
// resolveComponent hands back the name as a string when nothing is
|
|
34
|
+
// registered under it, which would render as a literal <ComponentName>
|
|
35
|
+
// element rather than as anything useful.
|
|
36
|
+
return typeof found === 'string' ? null : found;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const title = computed(() => props.component || props.plugin || 'Plugin');
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<template>
|
|
43
|
+
<EscalatedLayout :title="title">
|
|
44
|
+
<component :is="resolved" v-if="resolved" v-bind="props.props" />
|
|
45
|
+
|
|
46
|
+
<div
|
|
47
|
+
v-else
|
|
48
|
+
class="rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] p-6 text-center"
|
|
49
|
+
>
|
|
50
|
+
<p class="text-sm text-[var(--esc-panel-text-secondary)]">
|
|
51
|
+
The <strong>{{ plugin || 'plugin' }}</strong> plugin registered a page here, but no component named
|
|
52
|
+
<code class="rounded bg-[var(--esc-panel-hover)] px-1">{{ component || '(unnamed)' }}</code> is
|
|
53
|
+
available.
|
|
54
|
+
</p>
|
|
55
|
+
<p class="mt-2 text-xs text-[var(--esc-panel-text-muted)]">
|
|
56
|
+
Register it on the frontend with <code>defineEscalatedPlugin</code> — the backend route exists, so only
|
|
57
|
+
the component is missing.
|
|
58
|
+
</p>
|
|
59
|
+
</div>
|
|
60
|
+
</EscalatedLayout>
|
|
61
|
+
</template>
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import manifest from '../pages.json';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve an Escalated page name to its component, loudly.
|
|
5
|
+
*
|
|
6
|
+
* Inertia resolves a page name to a component, and a name with nothing behind
|
|
7
|
+
* it is not an error: `resolve()` returns undefined, Vue renders nothing, and
|
|
8
|
+
* the panel comes up blank. It looks like a permissions problem or an empty
|
|
9
|
+
* dataset, and the request was a 200 the whole time — which is how four screens
|
|
10
|
+
* shipped blank across this package and its backends without anyone noticing.
|
|
11
|
+
*
|
|
12
|
+
* This wraps the lookup so an unresolvable name says so. In development it
|
|
13
|
+
* throws, naming what was asked for and the nearest thing that exists; in
|
|
14
|
+
* production it logs and hands back a component that renders the same message,
|
|
15
|
+
* because a panel that explains itself beats a panel that is empty.
|
|
16
|
+
*
|
|
17
|
+
* Wire it into the host's Inertia setup:
|
|
18
|
+
*
|
|
19
|
+
* import { resolveEscalatedPage } from '@escalated-dev/escalated';
|
|
20
|
+
*
|
|
21
|
+
* createInertiaApp({
|
|
22
|
+
* resolve: (name) =>
|
|
23
|
+
* resolveEscalatedPage(name, () =>
|
|
24
|
+
* resolvePageComponent(`./Pages/${name}.vue`, import.meta.glob('./Pages/**')),
|
|
25
|
+
* ),
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* @param {string} name The page name the backend rendered.
|
|
29
|
+
* @param {Function} [fallback] Resolver for names this package does not own.
|
|
30
|
+
* @param {Object} [options]
|
|
31
|
+
* @param {boolean} [options.throwOnMissing] Defaults to true outside production.
|
|
32
|
+
* @returns {Promise<Object>|Object}
|
|
33
|
+
*/
|
|
34
|
+
export function resolveEscalatedPage(name, fallback, options = {}) {
|
|
35
|
+
const pages = import.meta.glob('./pages/**/*.vue');
|
|
36
|
+
const path = `./pages/${String(name ?? '').replace(/^Escalated\//, '')}.vue`;
|
|
37
|
+
|
|
38
|
+
if (pages[path]) {
|
|
39
|
+
return pages[path]();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Not ours: a host page, or a plugin's. Only names this package claims are
|
|
43
|
+
// its responsibility to complain about.
|
|
44
|
+
if (!isEscalatedPage(name)) {
|
|
45
|
+
if (fallback) {
|
|
46
|
+
return fallback(name);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return missing(name, options);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return missing(name, options);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Whether a name is one this package is supposed to own. */
|
|
56
|
+
export function isEscalatedPage(name) {
|
|
57
|
+
return String(name ?? '').startsWith('Escalated/');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Every page name this package can resolve. */
|
|
61
|
+
export function escalatedPages() {
|
|
62
|
+
return [...manifest.pages];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Names close enough to be the one that was meant.
|
|
67
|
+
*
|
|
68
|
+
* Nearly every real instance of this has been a naming difference rather than
|
|
69
|
+
* an absent screen — `CohortAnalysis` for `Cohorts`, `New` for `Form` — so the
|
|
70
|
+
* message is far more useful when it can point at the likely intent.
|
|
71
|
+
*/
|
|
72
|
+
export function suggestPages(name, limit = 3) {
|
|
73
|
+
const wanted = segments(name);
|
|
74
|
+
|
|
75
|
+
if (!wanted.length) {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return manifest.pages
|
|
80
|
+
.map((page) => ({ page, score: similarity(wanted, segments(page)) }))
|
|
81
|
+
.filter((entry) => entry.score > 0)
|
|
82
|
+
.sort((a, b) => b.score - a.score || a.page.localeCompare(b.page))
|
|
83
|
+
.slice(0, limit)
|
|
84
|
+
.map((entry) => entry.page);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function segments(name) {
|
|
88
|
+
return String(name ?? '')
|
|
89
|
+
.toLowerCase()
|
|
90
|
+
.split('/')
|
|
91
|
+
.filter(Boolean);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* How much two page paths have in common.
|
|
96
|
+
*
|
|
97
|
+
* Segment overlap rather than leaf equality: nearly every page ends in `Index`
|
|
98
|
+
* or `Form`, so matching on the last segment alone ranks half the package
|
|
99
|
+
* equally. What actually distinguishes the intended page is the path around it
|
|
100
|
+
* -- `Admin/KB/Articles/Index` and `Admin/KnowledgeBase/Articles/Index` share
|
|
101
|
+
* three segments and differ in one.
|
|
102
|
+
*/
|
|
103
|
+
function similarity(wanted, candidate) {
|
|
104
|
+
const shared = wanted.filter((part) => candidate.includes(part)).length;
|
|
105
|
+
|
|
106
|
+
if (!shared) {
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The folder counts for more than the leaf, so a differently named screen
|
|
111
|
+
// in the right place outranks the same leaf somewhere unrelated.
|
|
112
|
+
const sameFolder = wanted.slice(0, -1).join('/') === candidate.slice(0, -1).join('/') ? 2 : 0;
|
|
113
|
+
const lengthPenalty = Math.abs(wanted.length - candidate.length) * 0.5;
|
|
114
|
+
|
|
115
|
+
// Every page in a folder shares its whole path, so the folder alone ranks
|
|
116
|
+
// them all equally -- `CohortAnalysis` would tie with `AgentMetrics` and
|
|
117
|
+
// lose on alphabet. How much of the name itself matches is what separates
|
|
118
|
+
// the one that was meant.
|
|
119
|
+
const leaf = commonPrefix(wanted[wanted.length - 1], candidate[candidate.length - 1]);
|
|
120
|
+
|
|
121
|
+
return shared + sameFolder + leaf - lengthPenalty;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** How many leading characters two names share, as a fraction of the shorter. */
|
|
125
|
+
function commonPrefix(a = '', b = '') {
|
|
126
|
+
let i = 0;
|
|
127
|
+
|
|
128
|
+
while (i < a.length && i < b.length && a[i] === b[i]) {
|
|
129
|
+
i += 1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return i / Math.max(1, Math.min(a.length, b.length));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function missing(name, options) {
|
|
136
|
+
const suggestions = suggestPages(name);
|
|
137
|
+
const message =
|
|
138
|
+
`Escalated: no page component for "${name}". ` +
|
|
139
|
+
(suggestions.length
|
|
140
|
+
? `Did you mean ${suggestions.map((s) => `"${s}"`).join(', ')}?`
|
|
141
|
+
: 'It is not in this package; a host or plugin page needs its own resolver.');
|
|
142
|
+
|
|
143
|
+
const shouldThrow = options.throwOnMissing ?? !isProduction();
|
|
144
|
+
|
|
145
|
+
if (shouldThrow) {
|
|
146
|
+
throw new Error(message);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
console.error(message);
|
|
150
|
+
|
|
151
|
+
return { render: () => null, name: 'EscalatedMissingPage', __escalatedMissing: message };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isProduction() {
|
|
155
|
+
try {
|
|
156
|
+
return import.meta.env?.PROD === true;
|
|
157
|
+
} catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|