@intranefr/superbackend 1.4.4 → 1.5.1
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/.env.example +5 -0
- package/README.md +11 -0
- package/index.js +39 -1
- package/package.json +11 -3
- package/public/sdk/ui-components.iife.js +191 -0
- package/sdk/ui-components/browser/src/index.js +228 -0
- package/src/admin/endpointRegistry.js +120 -0
- package/src/controllers/admin.controller.js +111 -5
- package/src/controllers/adminBlockDefinitions.controller.js +127 -0
- package/src/controllers/adminBlockDefinitionsAi.controller.js +54 -0
- package/src/controllers/adminCache.controller.js +342 -0
- package/src/controllers/adminContextBlockDefinitions.controller.js +141 -0
- package/src/controllers/adminCrons.controller.js +388 -0
- package/src/controllers/adminDbBrowser.controller.js +124 -0
- package/src/controllers/adminEjsVirtual.controller.js +13 -3
- package/src/controllers/adminHeadless.controller.js +91 -2
- package/src/controllers/adminHealthChecks.controller.js +570 -0
- package/src/controllers/adminI18n.controller.js +51 -29
- package/src/controllers/adminLlm.controller.js +126 -2
- package/src/controllers/adminPages.controller.js +720 -0
- package/src/controllers/adminPagesContextBlocksAi.controller.js +54 -0
- package/src/controllers/adminProxy.controller.js +113 -0
- package/src/controllers/adminRateLimits.controller.js +138 -0
- package/src/controllers/adminRbac.controller.js +803 -0
- package/src/controllers/adminScripts.controller.js +320 -0
- package/src/controllers/adminSeoConfig.controller.js +71 -48
- package/src/controllers/adminTerminals.controller.js +39 -0
- package/src/controllers/adminUiComponents.controller.js +315 -0
- package/src/controllers/adminUiComponentsAi.controller.js +34 -0
- package/src/controllers/blogAdmin.controller.js +279 -0
- package/src/controllers/blogAiAdmin.controller.js +224 -0
- package/src/controllers/blogAutomationAdmin.controller.js +141 -0
- package/src/controllers/blogInternal.controller.js +26 -0
- package/src/controllers/blogPublic.controller.js +89 -0
- package/src/controllers/fileManager.controller.js +190 -0
- package/src/controllers/fileManagerStoragePolicy.controller.js +23 -0
- package/src/controllers/healthChecksPublic.controller.js +196 -0
- package/src/controllers/metrics.controller.js +64 -4
- package/src/controllers/orgAdmin.controller.js +366 -0
- package/src/controllers/uiComponentsPublic.controller.js +118 -0
- package/src/middleware/auth.js +7 -0
- package/src/middleware/internalCronAuth.js +29 -0
- package/src/middleware/rbac.js +62 -0
- package/src/middleware.js +879 -56
- package/src/models/BlockDefinition.js +27 -0
- package/src/models/BlogAutomationLock.js +14 -0
- package/src/models/BlogAutomationRun.js +39 -0
- package/src/models/BlogPost.js +42 -0
- package/src/models/CacheEntry.js +26 -0
- package/src/models/ConsoleEntry.js +32 -0
- package/src/models/ConsoleLog.js +23 -0
- package/src/models/ContextBlockDefinition.js +33 -0
- package/src/models/CronExecution.js +47 -0
- package/src/models/CronJob.js +70 -0
- package/src/models/ExternalDbConnection.js +49 -0
- package/src/models/FileEntry.js +22 -0
- package/src/models/HeadlessModelDefinition.js +10 -0
- package/src/models/HealthAutoHealAttempt.js +57 -0
- package/src/models/HealthCheck.js +132 -0
- package/src/models/HealthCheckRun.js +51 -0
- package/src/models/HealthIncident.js +49 -0
- package/src/models/Page.js +95 -0
- package/src/models/PageCollection.js +42 -0
- package/src/models/ProxyEntry.js +66 -0
- package/src/models/RateLimitCounter.js +19 -0
- package/src/models/RateLimitMetricBucket.js +20 -0
- package/src/models/RbacGrant.js +25 -0
- package/src/models/RbacGroup.js +16 -0
- package/src/models/RbacGroupMember.js +13 -0
- package/src/models/RbacGroupRole.js +13 -0
- package/src/models/RbacRole.js +25 -0
- package/src/models/RbacUserRole.js +13 -0
- package/src/models/ScriptDefinition.js +42 -0
- package/src/models/ScriptRun.js +22 -0
- package/src/models/UiComponent.js +29 -0
- package/src/models/UiComponentProject.js +26 -0
- package/src/models/UiComponentProjectComponent.js +18 -0
- package/src/routes/admin.routes.js +1 -0
- package/src/routes/adminBlog.routes.js +21 -0
- package/src/routes/adminBlogAi.routes.js +16 -0
- package/src/routes/adminBlogAutomation.routes.js +27 -0
- package/src/routes/adminCache.routes.js +20 -0
- package/src/routes/adminConsoleManager.routes.js +302 -0
- package/src/routes/adminCrons.routes.js +25 -0
- package/src/routes/adminDbBrowser.routes.js +65 -0
- package/src/routes/adminEjsVirtual.routes.js +2 -1
- package/src/routes/adminHeadless.routes.js +8 -1
- package/src/routes/adminHealthChecks.routes.js +28 -0
- package/src/routes/adminI18n.routes.js +4 -3
- package/src/routes/adminLlm.routes.js +4 -2
- package/src/routes/adminPages.routes.js +55 -0
- package/src/routes/adminProxy.routes.js +15 -0
- package/src/routes/adminRateLimits.routes.js +17 -0
- package/src/routes/adminRbac.routes.js +38 -0
- package/src/routes/adminScripts.routes.js +21 -0
- package/src/routes/adminSeoConfig.routes.js +5 -4
- package/src/routes/adminTerminals.routes.js +13 -0
- package/src/routes/adminUiComponents.routes.js +30 -0
- package/src/routes/blogInternal.routes.js +14 -0
- package/src/routes/blogPublic.routes.js +9 -0
- package/src/routes/fileManager.routes.js +62 -0
- package/src/routes/fileManagerStoragePolicy.routes.js +9 -0
- package/src/routes/healthChecksPublic.routes.js +9 -0
- package/src/routes/log.routes.js +43 -60
- package/src/routes/metrics.routes.js +4 -2
- package/src/routes/orgAdmin.routes.js +6 -0
- package/src/routes/pages.routes.js +123 -0
- package/src/routes/proxy.routes.js +46 -0
- package/src/routes/rbac.routes.js +47 -0
- package/src/routes/uiComponentsPublic.routes.js +9 -0
- package/src/routes/webhook.routes.js +2 -1
- package/src/routes/workflows.routes.js +4 -0
- package/src/services/blockDefinitionsAi.service.js +247 -0
- package/src/services/blog.service.js +99 -0
- package/src/services/blogAutomation.service.js +978 -0
- package/src/services/blogCronsBootstrap.service.js +184 -0
- package/src/services/blogPublishing.service.js +58 -0
- package/src/services/cacheLayer.service.js +696 -0
- package/src/services/consoleManager.service.js +700 -0
- package/src/services/consoleOverride.service.js +6 -1
- package/src/services/cronScheduler.service.js +350 -0
- package/src/services/dbBrowser.service.js +536 -0
- package/src/services/ejsVirtual.service.js +102 -32
- package/src/services/fileManager.service.js +475 -0
- package/src/services/fileManagerStoragePolicy.service.js +285 -0
- package/src/services/headlessExternalModels.service.js +292 -0
- package/src/services/headlessModels.service.js +26 -6
- package/src/services/healthChecks.service.js +650 -0
- package/src/services/healthChecksBootstrap.service.js +109 -0
- package/src/services/healthChecksScheduler.service.js +106 -0
- package/src/services/llmDefaults.service.js +190 -0
- package/src/services/migrationAssets/s3.js +2 -2
- package/src/services/pages.service.js +602 -0
- package/src/services/pagesContext.service.js +331 -0
- package/src/services/pagesContextBlocksAi.service.js +349 -0
- package/src/services/proxy.service.js +535 -0
- package/src/services/rateLimiter.service.js +623 -0
- package/src/services/rbac.service.js +212 -0
- package/src/services/scriptsRunner.service.js +259 -0
- package/src/services/terminals.service.js +152 -0
- package/src/services/terminalsWs.service.js +100 -0
- package/src/services/uiComponentsAi.service.js +299 -0
- package/src/services/uiComponentsCrypto.service.js +39 -0
- package/src/services/workflow.service.js +23 -8
- package/src/utils/orgRoles.js +14 -0
- package/src/utils/rbac/engine.js +60 -0
- package/src/utils/rbac/rightsRegistry.js +29 -0
- package/views/admin-blog-automation.ejs +877 -0
- package/views/admin-blog-edit.ejs +542 -0
- package/views/admin-blog.ejs +399 -0
- package/views/admin-cache.ejs +681 -0
- package/views/admin-console-manager.ejs +680 -0
- package/views/admin-crons.ejs +645 -0
- package/views/admin-db-browser.ejs +445 -0
- package/views/admin-ejs-virtual.ejs +16 -10
- package/views/admin-file-manager.ejs +942 -0
- package/views/admin-headless.ejs +294 -24
- package/views/admin-health-checks.ejs +725 -0
- package/views/admin-i18n.ejs +59 -5
- package/views/admin-llm.ejs +99 -1
- package/views/admin-organizations.ejs +528 -10
- package/views/admin-pages.ejs +2424 -0
- package/views/admin-proxy.ejs +491 -0
- package/views/admin-rate-limiter.ejs +625 -0
- package/views/admin-rbac.ejs +1331 -0
- package/views/admin-scripts.ejs +497 -0
- package/views/admin-seo-config.ejs +61 -7
- package/views/admin-terminals.ejs +328 -0
- package/views/admin-ui-components.ejs +741 -0
- package/views/admin-users.ejs +261 -4
- package/views/admin-workflows.ejs +7 -7
- package/views/file-manager.ejs +866 -0
- package/views/pages/blocks/contact.ejs +27 -0
- package/views/pages/blocks/cta.ejs +18 -0
- package/views/pages/blocks/faq.ejs +20 -0
- package/views/pages/blocks/features.ejs +19 -0
- package/views/pages/blocks/hero.ejs +13 -0
- package/views/pages/blocks/html.ejs +5 -0
- package/views/pages/blocks/image.ejs +14 -0
- package/views/pages/blocks/testimonials.ejs +26 -0
- package/views/pages/blocks/text.ejs +10 -0
- package/views/pages/layouts/default.ejs +51 -0
- package/views/pages/layouts/minimal.ejs +42 -0
- package/views/pages/layouts/sidebar.ejs +54 -0
- package/views/pages/partials/footer.ejs +13 -0
- package/views/pages/partials/header.ejs +12 -0
- package/views/pages/partials/sidebar.ejs +8 -0
- package/views/pages/runtime/page.ejs +10 -0
- package/views/pages/templates/article.ejs +20 -0
- package/views/pages/templates/default.ejs +12 -0
- package/views/pages/templates/landing.ejs +14 -0
- package/views/pages/templates/listing.ejs +15 -0
- package/views/partials/admin-image-upload-modal.ejs +221 -0
- package/views/partials/dashboard/nav-items.ejs +14 -0
- package/views/partials/llm-provider-model-picker.ejs +183 -0
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Admin Crons</title>
|
|
7
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
8
|
+
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
|
9
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/dist/tabler-icons.min.css">
|
|
10
|
+
</head>
|
|
11
|
+
<body class="bg-gray-50">
|
|
12
|
+
<div id="app" class="max-w-7xl mx-auto px-6 py-6">
|
|
13
|
+
<div class="flex items-center justify-between mb-6">
|
|
14
|
+
<div>
|
|
15
|
+
<h1 class="text-2xl font-semibold text-gray-900">Crons</h1>
|
|
16
|
+
<div class="text-sm text-gray-500">Schedule scripts and HTTP calls to run automatically</div>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="flex items-center gap-2">
|
|
19
|
+
<button @click="showCreateModal = true" class="px-3 py-2 rounded bg-blue-600 text-white text-sm hover:bg-blue-700">
|
|
20
|
+
<i class="ti ti-plus mr-1"></i> New Cron
|
|
21
|
+
</button>
|
|
22
|
+
<button @click="loadCrons" class="px-3 py-2 rounded bg-gray-600 text-white text-sm hover:bg-gray-700">
|
|
23
|
+
<i class="ti ti-refresh mr-1"></i> Refresh
|
|
24
|
+
</button>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<!-- Crons List -->
|
|
29
|
+
<div class="bg-white border border-gray-200 rounded-lg">
|
|
30
|
+
<div class="overflow-x-auto">
|
|
31
|
+
<table class="w-full">
|
|
32
|
+
<thead class="bg-gray-50 border-b border-gray-200">
|
|
33
|
+
<tr>
|
|
34
|
+
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
|
35
|
+
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Schedule</th>
|
|
36
|
+
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
|
|
37
|
+
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Next Run</th>
|
|
38
|
+
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
|
39
|
+
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
|
40
|
+
</tr>
|
|
41
|
+
</thead>
|
|
42
|
+
<tbody class="divide-y divide-gray-200">
|
|
43
|
+
<tr v-for="cron in crons" :key="cron._id" class="hover:bg-gray-50">
|
|
44
|
+
<td class="px-4 py-3">
|
|
45
|
+
<div>
|
|
46
|
+
<div class="text-sm font-medium text-gray-900">{{ cron.name }}</div>
|
|
47
|
+
<div class="text-xs text-gray-500">{{ cron.description || 'No description' }}</div>
|
|
48
|
+
</div>
|
|
49
|
+
</td>
|
|
50
|
+
<td class="px-4 py-3">
|
|
51
|
+
<div class="text-sm text-gray-900 font-mono">{{ cron.cronExpression }}</div>
|
|
52
|
+
<div class="text-xs text-gray-500">{{ cron.timezone }}</div>
|
|
53
|
+
</td>
|
|
54
|
+
<td class="px-4 py-3">
|
|
55
|
+
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
|
56
|
+
:class="cron.taskType === 'script' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800'">
|
|
57
|
+
<i :class="cron.taskType === 'script' ? 'ti ti-terminal-2' : 'ti ti-world'" class="mr-1"></i>
|
|
58
|
+
{{ cron.taskType }}
|
|
59
|
+
</span>
|
|
60
|
+
</td>
|
|
61
|
+
<td class="px-4 py-3 text-sm text-gray-900">
|
|
62
|
+
{{ formatNextRun(cron.nextRunAt) }}
|
|
63
|
+
</td>
|
|
64
|
+
<td class="px-4 py-3">
|
|
65
|
+
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
|
66
|
+
:class="cron.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'">
|
|
67
|
+
{{ cron.enabled ? 'Enabled' : 'Disabled' }}
|
|
68
|
+
</span>
|
|
69
|
+
</td>
|
|
70
|
+
<td class="px-4 py-3">
|
|
71
|
+
<div class="flex items-center gap-1">
|
|
72
|
+
<button @click="triggerCron(cron._id)"
|
|
73
|
+
class="p-1 text-emerald-600 hover:bg-emerald-50 rounded"
|
|
74
|
+
title="Run now">
|
|
75
|
+
<i class="ti ti-player-play"></i>
|
|
76
|
+
</button>
|
|
77
|
+
<button @click="toggleCron(cron)"
|
|
78
|
+
:class="cron.enabled ? 'text-orange-600 hover:bg-orange-50' : 'text-green-600 hover:bg-green-50'"
|
|
79
|
+
class="p-1 rounded"
|
|
80
|
+
:title="cron.enabled ? 'Disable' : 'Enable'">
|
|
81
|
+
<i :class="cron.enabled ? 'ti ti-player-pause' : 'ti ti-player-track-next'"></i>
|
|
82
|
+
</button>
|
|
83
|
+
<button @click="viewHistory(cron)"
|
|
84
|
+
class="p-1 text-blue-600 hover:bg-blue-50 rounded"
|
|
85
|
+
title="View history">
|
|
86
|
+
<i class="ti ti-history"></i>
|
|
87
|
+
</button>
|
|
88
|
+
<button @click="editCron(cron)"
|
|
89
|
+
class="p-1 text-gray-600 hover:bg-gray-50 rounded"
|
|
90
|
+
title="Edit">
|
|
91
|
+
<i class="ti ti-edit"></i>
|
|
92
|
+
</button>
|
|
93
|
+
<button @click="deleteCron(cron)"
|
|
94
|
+
class="p-1 text-red-600 hover:bg-red-50 rounded"
|
|
95
|
+
title="Delete">
|
|
96
|
+
<i class="ti ti-trash"></i>
|
|
97
|
+
</button>
|
|
98
|
+
</div>
|
|
99
|
+
</td>
|
|
100
|
+
</tr>
|
|
101
|
+
</tbody>
|
|
102
|
+
</table>
|
|
103
|
+
<div v-if="crons.length === 0" class="text-center py-8 text-gray-500">
|
|
104
|
+
No cron jobs configured. Click "New Cron" to create one.
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<!-- Create/Edit Modal -->
|
|
110
|
+
<div v-if="showCreateModal || editingCron" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
111
|
+
<div class="bg-white rounded-lg w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
112
|
+
<div class="p-6">
|
|
113
|
+
<h2 class="text-lg font-semibold mb-4">{{ editingCron ? 'Edit Cron Job' : 'Create Cron Job' }}</h2>
|
|
114
|
+
|
|
115
|
+
<div class="space-y-4">
|
|
116
|
+
<div class="grid grid-cols-2 gap-4">
|
|
117
|
+
<div>
|
|
118
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
|
119
|
+
<input v-model="form.name" type="text" class="w-full border rounded px-3 py-2" placeholder="My scheduled task" />
|
|
120
|
+
</div>
|
|
121
|
+
<div>
|
|
122
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
|
123
|
+
<input v-model="form.description" type="text" class="w-full border rounded px-3 py-2" placeholder="(optional)" />
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<div class="grid grid-cols-2 gap-4">
|
|
128
|
+
<div>
|
|
129
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Cron Expression</label>
|
|
130
|
+
<div class="flex gap-2">
|
|
131
|
+
<input v-model="form.cronExpression" type="text" class="flex-1 border rounded px-3 py-2 font-mono text-sm"
|
|
132
|
+
placeholder="0 9 * * *" />
|
|
133
|
+
<button @click="showPresets = !showPresets" class="px-3 py-2 border rounded text-sm hover:bg-gray-50">
|
|
134
|
+
Presets
|
|
135
|
+
</button>
|
|
136
|
+
</div>
|
|
137
|
+
<div v-if="showPresets" class="mt-2 p-2 bg-gray-50 rounded text-sm">
|
|
138
|
+
<div v-for="preset in presets" :key="preset.expression"
|
|
139
|
+
@click="form.cronExpression = preset.expression; showPresets = false"
|
|
140
|
+
class="cursor-pointer hover:bg-white px-2 py-1 rounded">
|
|
141
|
+
{{ preset.label }}: <code class="text-xs">{{ preset.expression }}</code>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
<div>
|
|
146
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Timezone</label>
|
|
147
|
+
<input v-model="form.timezone" type="text" class="w-full border rounded px-3 py-2" placeholder="UTC" />
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<div>
|
|
152
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Task Type</label>
|
|
153
|
+
<select v-model="form.taskType" @change="onTaskTypeChange" class="w-full border rounded px-3 py-2">
|
|
154
|
+
<option value="">Select task type</option>
|
|
155
|
+
<option value="script">Script</option>
|
|
156
|
+
<option value="http">HTTP Call</option>
|
|
157
|
+
</select>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<!-- Script Configuration -->
|
|
161
|
+
<div v-if="form.taskType === 'script'" class="space-y-4 p-4 bg-gray-50 rounded">
|
|
162
|
+
<div>
|
|
163
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Script</label>
|
|
164
|
+
<select v-model="form.scriptId" class="w-full border rounded px-3 py-2">
|
|
165
|
+
<option value="">Select a script</option>
|
|
166
|
+
<option v-for="script in scripts" :key="script._id" :value="script._id">
|
|
167
|
+
{{ script.name }} ({{ script.type }})
|
|
168
|
+
</option>
|
|
169
|
+
</select>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<div>
|
|
173
|
+
<div class="flex items-center justify-between mb-2">
|
|
174
|
+
<label class="text-sm font-medium text-gray-700">Override Environment Variables</label>
|
|
175
|
+
<button @click="addScriptEnv" type="button" class="text-sm text-blue-600 hover:underline">Add</button>
|
|
176
|
+
</div>
|
|
177
|
+
<div v-for="(env, index) in form.scriptEnv" :key="index" class="flex gap-2 mb-2">
|
|
178
|
+
<input v-model="env.key" type="text" placeholder="Key" class="flex-1 border rounded px-3 py-2 text-sm" />
|
|
179
|
+
<input v-model="env.value" type="text" placeholder="Value" class="flex-1 border rounded px-3 py-2 text-sm" />
|
|
180
|
+
<button @click="form.scriptEnv.splice(index, 1)" class="text-red-600 hover:text-red-800">
|
|
181
|
+
<i class="ti ti-trash"></i>
|
|
182
|
+
</button>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<!-- HTTP Configuration -->
|
|
188
|
+
<div v-if="form.taskType === 'http'" class="space-y-4 p-4 bg-gray-50 rounded">
|
|
189
|
+
<div class="grid grid-cols-2 gap-4">
|
|
190
|
+
<div>
|
|
191
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Method</label>
|
|
192
|
+
<select v-model="form.httpMethod" class="w-full border rounded px-3 py-2">
|
|
193
|
+
<option value="GET">GET</option>
|
|
194
|
+
<option value="POST">POST</option>
|
|
195
|
+
<option value="PUT">PUT</option>
|
|
196
|
+
<option value="DELETE">DELETE</option>
|
|
197
|
+
<option value="PATCH">PATCH</option>
|
|
198
|
+
</select>
|
|
199
|
+
</div>
|
|
200
|
+
<div>
|
|
201
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">URL</label>
|
|
202
|
+
<input v-model="form.httpUrl" type="url" class="w-full border rounded px-3 py-2" placeholder="https://api.example.com/webhook" />
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
<div v-if="['POST', 'PUT', 'PATCH'].includes(form.httpMethod)">
|
|
207
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Body Type</label>
|
|
208
|
+
<select v-model="form.httpBodyType" class="w-full border rounded px-3 py-2">
|
|
209
|
+
<option value="raw">Raw Text</option>
|
|
210
|
+
<option value="json">JSON</option>
|
|
211
|
+
<option value="form">Form Data</option>
|
|
212
|
+
</select>
|
|
213
|
+
<textarea v-model="form.httpBody" rows="4" class="mt-2 w-full border rounded px-3 py-2 font-mono text-sm"
|
|
214
|
+
placeholder="Request body content"></textarea>
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
<div>
|
|
218
|
+
<div class="flex items-center justify-between mb-2">
|
|
219
|
+
<label class="text-sm font-medium text-gray-700">Headers</label>
|
|
220
|
+
<button @click="addHttpHeader" type="button" class="text-sm text-blue-600 hover:underline">Add</button>
|
|
221
|
+
</div>
|
|
222
|
+
<div v-for="(header, index) in form.httpHeaders" :key="index" class="flex gap-2 mb-2">
|
|
223
|
+
<input v-model="header.key" type="text" placeholder="Key" class="flex-1 border rounded px-3 py-2 text-sm" />
|
|
224
|
+
<input v-model="header.value" type="text" placeholder="Value" class="flex-1 border rounded px-3 py-2 text-sm" />
|
|
225
|
+
<button @click="form.httpHeaders.splice(index, 1)" class="text-red-600 hover:text-red-800">
|
|
226
|
+
<i class="ti ti-trash"></i>
|
|
227
|
+
</button>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
<div>
|
|
232
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Authentication</label>
|
|
233
|
+
<select v-model="form.httpAuth.type" class="w-full border rounded px-3 py-2">
|
|
234
|
+
<option value="none">None</option>
|
|
235
|
+
<option value="bearer">Bearer Token</option>
|
|
236
|
+
<option value="basic">Basic Auth</option>
|
|
237
|
+
</select>
|
|
238
|
+
<div v-if="form.httpAuth.type === 'bearer'" class="mt-2">
|
|
239
|
+
<input v-model="form.httpAuth.token" type="text" placeholder="Bearer token"
|
|
240
|
+
class="w-full border rounded px-3 py-2 font-mono text-sm" />
|
|
241
|
+
</div>
|
|
242
|
+
<div v-if="form.httpAuth.type === 'basic'" class="mt-2 grid grid-cols-2 gap-2">
|
|
243
|
+
<input v-model="form.httpAuth.username" type="text" placeholder="Username"
|
|
244
|
+
class="border rounded px-3 py-2" />
|
|
245
|
+
<input v-model="form.httpAuth.password" type="password" placeholder="Password"
|
|
246
|
+
class="border rounded px-3 py-2" />
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
<div class="grid grid-cols-2 gap-4">
|
|
252
|
+
<div>
|
|
253
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Timeout (ms)</label>
|
|
254
|
+
<input v-model.number="form.timeoutMs" type="number" class="w-full border rounded px-3 py-2" placeholder="300000" />
|
|
255
|
+
</div>
|
|
256
|
+
<div class="flex items-center pt-6">
|
|
257
|
+
<input v-model="form.enabled" type="checkbox" class="h-4 w-4" />
|
|
258
|
+
<label class="ml-2 text-sm text-gray-700">Enable immediately</label>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
|
|
263
|
+
<div class="mt-6 flex justify-end gap-2">
|
|
264
|
+
<button @click="closeModal" class="px-4 py-2 border rounded text-gray-700 hover:bg-gray-50">Cancel</button>
|
|
265
|
+
<button @click="saveCron" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
|
266
|
+
{{ editingCron ? 'Update' : 'Create' }}
|
|
267
|
+
</button>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
<!-- Execution History Modal -->
|
|
274
|
+
<div v-if="showHistoryModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
275
|
+
<div class="bg-white rounded-lg w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
|
276
|
+
<div class="p-6">
|
|
277
|
+
<div class="flex items-center justify-between mb-4">
|
|
278
|
+
<h2 class="text-lg font-semibold">Execution History</h2>
|
|
279
|
+
<button @click="showHistoryModal = false" class="text-gray-400 hover:text-gray-600">
|
|
280
|
+
<i class="ti ti-x text-xl"></i>
|
|
281
|
+
</button>
|
|
282
|
+
</div>
|
|
283
|
+
|
|
284
|
+
<div class="mb-4">
|
|
285
|
+
<p class="text-sm text-gray-600">Showing history for: <strong>{{ selectedCron?.name }}</strong></p>
|
|
286
|
+
</div>
|
|
287
|
+
|
|
288
|
+
<div class="overflow-x-auto">
|
|
289
|
+
<table class="w-full text-sm">
|
|
290
|
+
<thead class="bg-gray-50">
|
|
291
|
+
<tr>
|
|
292
|
+
<th class="px-4 py-2 text-left">Started</th>
|
|
293
|
+
<th class="px-4 py-2 text-left">Duration</th>
|
|
294
|
+
<th class="px-4 py-2 text-left">Status</th>
|
|
295
|
+
<th class="px-4 py-2 text-left">Actions</th>
|
|
296
|
+
</tr>
|
|
297
|
+
</thead>
|
|
298
|
+
<tbody class="divide-y">
|
|
299
|
+
<tr v-for="exec in executions" :key="exec._id">
|
|
300
|
+
<td class="px-4 py-2">{{ formatDate(exec.startedAt) }}</td>
|
|
301
|
+
<td class="px-4 py-2">{{ exec.durationMs ? `${exec.durationMs}ms` : '-' }}</td>
|
|
302
|
+
<td class="px-4 py-2">
|
|
303
|
+
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
|
304
|
+
:class="getStatusClass(exec.status)">
|
|
305
|
+
{{ exec.status }}
|
|
306
|
+
</span>
|
|
307
|
+
</td>
|
|
308
|
+
<td class="px-4 py-2">
|
|
309
|
+
<button @click="viewExecution(exec)" class="text-blue-600 hover:underline text-sm">
|
|
310
|
+
View Details
|
|
311
|
+
</button>
|
|
312
|
+
</td>
|
|
313
|
+
</tr>
|
|
314
|
+
</tbody>
|
|
315
|
+
</table>
|
|
316
|
+
<div v-if="executions.length === 0" class="text-center py-4 text-gray-500">
|
|
317
|
+
No executions found
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
|
|
324
|
+
<!-- Execution Details Modal -->
|
|
325
|
+
<div v-if="showExecutionModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
326
|
+
<div class="bg-white rounded-lg w-full max-w-3xl max-h-[90vh] overflow-y-auto">
|
|
327
|
+
<div class="p-6">
|
|
328
|
+
<div class="flex items-center justify-between mb-4">
|
|
329
|
+
<h2 class="text-lg font-semibold">Execution Details</h2>
|
|
330
|
+
<button @click="showExecutionModal = false" class="text-gray-400 hover:text-gray-600">
|
|
331
|
+
<i class="ti ti-x text-xl"></i>
|
|
332
|
+
</button>
|
|
333
|
+
</div>
|
|
334
|
+
|
|
335
|
+
<div v-if="selectedExecution" class="space-y-4">
|
|
336
|
+
<div class="grid grid-cols-2 gap-4 text-sm">
|
|
337
|
+
<div>
|
|
338
|
+
<span class="font-medium">Status:</span>
|
|
339
|
+
<span class="ml-2" :class="getStatusClass(selectedExecution.status)">
|
|
340
|
+
{{ selectedExecution.status }}
|
|
341
|
+
</span>
|
|
342
|
+
</div>
|
|
343
|
+
<div>
|
|
344
|
+
<span class="font-medium">Duration:</span>
|
|
345
|
+
<span class="ml-2">{{ selectedExecution.durationMs ? `${selectedExecution.durationMs}ms` : '-' }}</span>
|
|
346
|
+
</div>
|
|
347
|
+
<div>
|
|
348
|
+
<span class="font-medium">Started:</span>
|
|
349
|
+
<span class="ml-2">{{ formatDate(selectedExecution.startedAt) }}</span>
|
|
350
|
+
</div>
|
|
351
|
+
<div>
|
|
352
|
+
<span class="font-medium">Finished:</span>
|
|
353
|
+
<span class="ml-2">{{ formatDate(selectedExecution.finishedAt) }}</span>
|
|
354
|
+
</div>
|
|
355
|
+
<div v-if="selectedExecution.httpStatusCode">
|
|
356
|
+
<span class="font-medium">HTTP Status:</span>
|
|
357
|
+
<span class="ml-2">{{ selectedExecution.httpStatusCode }}</span>
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
|
|
361
|
+
<div v-if="selectedExecution.error" class="p-3 bg-red-50 border border-red-200 rounded">
|
|
362
|
+
<h3 class="text-sm font-medium text-red-800 mb-1">Error</h3>
|
|
363
|
+
<pre class="text-sm text-red-700 whitespace-pre-wrap">{{ selectedExecution.error }}</pre>
|
|
364
|
+
</div>
|
|
365
|
+
|
|
366
|
+
<div v-if="selectedExecution.output" class="p-3 bg-gray-50 border rounded">
|
|
367
|
+
<h3 class="text-sm font-medium text-gray-800 mb-2">Output</h3>
|
|
368
|
+
<pre class="text-sm text-gray-700 whitespace-pre-wrap font-mono">{{ selectedExecution.output }}</pre>
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
|
|
376
|
+
<script>
|
|
377
|
+
const { createApp } = Vue;
|
|
378
|
+
|
|
379
|
+
createApp({
|
|
380
|
+
data() {
|
|
381
|
+
return {
|
|
382
|
+
crons: [],
|
|
383
|
+
scripts: [],
|
|
384
|
+
presets: [],
|
|
385
|
+
executions: [],
|
|
386
|
+
|
|
387
|
+
showCreateModal: false,
|
|
388
|
+
showHistoryModal: false,
|
|
389
|
+
showExecutionModal: false,
|
|
390
|
+
showPresets: false,
|
|
391
|
+
|
|
392
|
+
editingCron: null,
|
|
393
|
+
selectedCron: null,
|
|
394
|
+
selectedExecution: null,
|
|
395
|
+
|
|
396
|
+
form: {
|
|
397
|
+
name: '',
|
|
398
|
+
description: '',
|
|
399
|
+
cronExpression: '',
|
|
400
|
+
timezone: 'UTC',
|
|
401
|
+
enabled: true,
|
|
402
|
+
taskType: '',
|
|
403
|
+
scriptId: '',
|
|
404
|
+
scriptEnv: [],
|
|
405
|
+
httpMethod: 'GET',
|
|
406
|
+
httpUrl: '',
|
|
407
|
+
httpHeaders: [],
|
|
408
|
+
httpBody: '',
|
|
409
|
+
httpBodyType: 'raw',
|
|
410
|
+
httpAuth: { type: 'none', token: '', username: '', password: '' },
|
|
411
|
+
timeoutMs: 300000
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
},
|
|
415
|
+
|
|
416
|
+
async mounted() {
|
|
417
|
+
await this.loadCrons();
|
|
418
|
+
await this.loadScripts();
|
|
419
|
+
await this.loadPresets();
|
|
420
|
+
},
|
|
421
|
+
|
|
422
|
+
methods: {
|
|
423
|
+
async loadCrons() {
|
|
424
|
+
try {
|
|
425
|
+
const response = await fetch('/api/admin/crons');
|
|
426
|
+
const data = await response.json();
|
|
427
|
+
this.crons = data.items || [];
|
|
428
|
+
} catch (error) {
|
|
429
|
+
console.error('Failed to load crons:', error);
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
|
|
433
|
+
async loadScripts() {
|
|
434
|
+
try {
|
|
435
|
+
const response = await fetch('/api/admin/scripts');
|
|
436
|
+
const data = await response.json();
|
|
437
|
+
this.scripts = data.items || [];
|
|
438
|
+
} catch (error) {
|
|
439
|
+
console.error('Failed to load scripts:', error);
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
|
|
443
|
+
async loadPresets() {
|
|
444
|
+
try {
|
|
445
|
+
const response = await fetch('/api/admin/crons/presets');
|
|
446
|
+
const data = await response.json();
|
|
447
|
+
this.presets = data.presets || [];
|
|
448
|
+
} catch (error) {
|
|
449
|
+
console.error('Failed to load presets:', error);
|
|
450
|
+
}
|
|
451
|
+
},
|
|
452
|
+
|
|
453
|
+
async saveCron() {
|
|
454
|
+
try {
|
|
455
|
+
// Don't send scriptId for HTTP tasks
|
|
456
|
+
if (this.form.taskType === 'http') {
|
|
457
|
+
this.form.scriptId = undefined;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const url = this.editingCron ? `/api/admin/crons/${this.editingCron._id}` : '/api/admin/crons';
|
|
461
|
+
const method = this.editingCron ? 'PUT' : 'POST';
|
|
462
|
+
|
|
463
|
+
const response = await fetch(url, {
|
|
464
|
+
method,
|
|
465
|
+
headers: { 'Content-Type': 'application/json' },
|
|
466
|
+
body: JSON.stringify(this.form)
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
if (response.ok) {
|
|
470
|
+
this.closeModal();
|
|
471
|
+
await this.loadCrons();
|
|
472
|
+
} else {
|
|
473
|
+
const error = await response.json();
|
|
474
|
+
alert(error.error || 'Failed to save cron job');
|
|
475
|
+
}
|
|
476
|
+
} catch (error) {
|
|
477
|
+
console.error('Failed to save cron:', error);
|
|
478
|
+
alert('Failed to save cron job');
|
|
479
|
+
}
|
|
480
|
+
},
|
|
481
|
+
|
|
482
|
+
async toggleCron(cron) {
|
|
483
|
+
try {
|
|
484
|
+
const endpoint = cron.enabled ? 'disable' : 'enable';
|
|
485
|
+
const response = await fetch(`/api/admin/crons/${cron._id}/${endpoint}`, { method: 'POST' });
|
|
486
|
+
|
|
487
|
+
if (response.ok) {
|
|
488
|
+
await this.loadCrons();
|
|
489
|
+
} else {
|
|
490
|
+
alert('Failed to toggle cron job');
|
|
491
|
+
}
|
|
492
|
+
} catch (error) {
|
|
493
|
+
console.error('Failed to toggle cron:', error);
|
|
494
|
+
alert('Failed to toggle cron job');
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
|
|
498
|
+
async triggerCron(cronId) {
|
|
499
|
+
try {
|
|
500
|
+
const response = await fetch(`/api/admin/crons/${cronId}/trigger`, { method: 'POST' });
|
|
501
|
+
|
|
502
|
+
if (response.ok) {
|
|
503
|
+
const data = await response.json();
|
|
504
|
+
alert(`Cron job triggered successfully!\nExecution ID: ${data.executionId}`);
|
|
505
|
+
// Optionally refresh the executions if history modal is open
|
|
506
|
+
if (this.showHistoryModal && this.selectedCron?._id === cronId) {
|
|
507
|
+
await this.viewHistory(this.selectedCron);
|
|
508
|
+
}
|
|
509
|
+
} else {
|
|
510
|
+
const error = await response.json();
|
|
511
|
+
alert(`Failed to trigger cron job: ${error.error || 'Unknown error'}`);
|
|
512
|
+
}
|
|
513
|
+
} catch (error) {
|
|
514
|
+
console.error('Failed to trigger cron:', error);
|
|
515
|
+
alert('Failed to trigger cron job');
|
|
516
|
+
}
|
|
517
|
+
},
|
|
518
|
+
|
|
519
|
+
async deleteCron(cron) {
|
|
520
|
+
if (!confirm(`Are you sure you want to delete "${cron.name}"?`)) return;
|
|
521
|
+
|
|
522
|
+
try {
|
|
523
|
+
const response = await fetch(`/api/admin/crons/${cron._id}`, { method: 'DELETE' });
|
|
524
|
+
|
|
525
|
+
if (response.ok) {
|
|
526
|
+
await this.loadCrons();
|
|
527
|
+
} else {
|
|
528
|
+
alert('Failed to delete cron job');
|
|
529
|
+
}
|
|
530
|
+
} catch (error) {
|
|
531
|
+
console.error('Failed to delete cron:', error);
|
|
532
|
+
alert('Failed to delete cron job');
|
|
533
|
+
}
|
|
534
|
+
},
|
|
535
|
+
|
|
536
|
+
async viewHistory(cron) {
|
|
537
|
+
this.selectedCron = cron;
|
|
538
|
+
this.showHistoryModal = true;
|
|
539
|
+
|
|
540
|
+
try {
|
|
541
|
+
const response = await fetch(`/api/admin/crons/${cron._id}/executions`);
|
|
542
|
+
const data = await response.json();
|
|
543
|
+
this.executions = data.items || [];
|
|
544
|
+
} catch (error) {
|
|
545
|
+
console.error('Failed to load executions:', error);
|
|
546
|
+
}
|
|
547
|
+
},
|
|
548
|
+
|
|
549
|
+
async viewExecution(execution) {
|
|
550
|
+
try {
|
|
551
|
+
const response = await fetch(`/api/admin/crons/${this.selectedCron._id}/executions/${execution._id}`);
|
|
552
|
+
const data = await response.json();
|
|
553
|
+
this.selectedExecution = data.item;
|
|
554
|
+
this.showExecutionModal = true;
|
|
555
|
+
} catch (error) {
|
|
556
|
+
console.error('Failed to load execution details:', error);
|
|
557
|
+
}
|
|
558
|
+
},
|
|
559
|
+
|
|
560
|
+
editCron(cron) {
|
|
561
|
+
this.editingCron = cron;
|
|
562
|
+
this.form = {
|
|
563
|
+
name: cron.name,
|
|
564
|
+
description: cron.description,
|
|
565
|
+
cronExpression: cron.cronExpression,
|
|
566
|
+
timezone: cron.timezone,
|
|
567
|
+
enabled: cron.enabled,
|
|
568
|
+
taskType: cron.taskType,
|
|
569
|
+
scriptId: cron.scriptId || undefined,
|
|
570
|
+
scriptEnv: cron.scriptEnv ? [...cron.scriptEnv] : [],
|
|
571
|
+
httpMethod: cron.httpMethod || 'GET',
|
|
572
|
+
httpUrl: cron.httpUrl || '',
|
|
573
|
+
httpHeaders: cron.httpHeaders ? [...cron.httpHeaders] : [],
|
|
574
|
+
httpBody: cron.httpBody || '',
|
|
575
|
+
httpBodyType: cron.httpBodyType || 'raw',
|
|
576
|
+
httpAuth: cron.httpAuth || { type: 'none', token: '', username: '', password: '' },
|
|
577
|
+
timeoutMs: cron.timeoutMs || 300000
|
|
578
|
+
};
|
|
579
|
+
},
|
|
580
|
+
|
|
581
|
+
onTaskTypeChange() {
|
|
582
|
+
// Reset task-specific fields when type changes
|
|
583
|
+
this.form.scriptId = undefined;
|
|
584
|
+
this.form.scriptEnv = [];
|
|
585
|
+
this.form.httpMethod = 'GET';
|
|
586
|
+
this.form.httpUrl = '';
|
|
587
|
+
this.form.httpHeaders = [];
|
|
588
|
+
this.form.httpBody = '';
|
|
589
|
+
this.form.httpAuth = { type: 'none', token: '', username: '', password: '' };
|
|
590
|
+
},
|
|
591
|
+
|
|
592
|
+
addScriptEnv() {
|
|
593
|
+
this.form.scriptEnv.push({ key: '', value: '' });
|
|
594
|
+
},
|
|
595
|
+
|
|
596
|
+
addHttpHeader() {
|
|
597
|
+
this.form.httpHeaders.push({ key: '', value: '' });
|
|
598
|
+
},
|
|
599
|
+
|
|
600
|
+
closeModal() {
|
|
601
|
+
this.showCreateModal = false;
|
|
602
|
+
this.editingCron = null;
|
|
603
|
+
this.form = {
|
|
604
|
+
name: '',
|
|
605
|
+
description: '',
|
|
606
|
+
cronExpression: '',
|
|
607
|
+
timezone: 'UTC',
|
|
608
|
+
enabled: true,
|
|
609
|
+
taskType: '',
|
|
610
|
+
scriptId: undefined,
|
|
611
|
+
scriptEnv: [],
|
|
612
|
+
httpMethod: 'GET',
|
|
613
|
+
httpUrl: '',
|
|
614
|
+
httpHeaders: [],
|
|
615
|
+
httpBody: '',
|
|
616
|
+
httpBodyType: 'raw',
|
|
617
|
+
httpAuth: { type: 'none', token: '', username: '', password: '' },
|
|
618
|
+
timeoutMs: 300000
|
|
619
|
+
};
|
|
620
|
+
},
|
|
621
|
+
|
|
622
|
+
formatNextRun(date) {
|
|
623
|
+
if (!date) return 'Not scheduled';
|
|
624
|
+
return new Date(date).toLocaleString();
|
|
625
|
+
},
|
|
626
|
+
|
|
627
|
+
formatDate(date) {
|
|
628
|
+
if (!date) return '-';
|
|
629
|
+
return new Date(date).toLocaleString();
|
|
630
|
+
},
|
|
631
|
+
|
|
632
|
+
getStatusClass(status) {
|
|
633
|
+
switch (status) {
|
|
634
|
+
case 'succeeded': return 'bg-green-100 text-green-800';
|
|
635
|
+
case 'failed': return 'bg-red-100 text-red-800';
|
|
636
|
+
case 'running': return 'bg-blue-100 text-blue-800';
|
|
637
|
+
case 'timed_out': return 'bg-yellow-100 text-yellow-800';
|
|
638
|
+
default: return 'bg-gray-100 text-gray-800';
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}).mount('#app');
|
|
643
|
+
</script>
|
|
644
|
+
</body>
|
|
645
|
+
</html>
|