jskelet 0.2.4 → 0.3.0
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/CHANGELOG.md +403 -375
- package/docs/02-mimari.md +9 -3
- package/docs/03-routing.md +6 -0
- package/docs/06-cache.md +34 -12
- package/docs/07-yapilandirma.md +44 -21
- package/docs/11-tasima.md +1 -0
- package/docs/en/02-architecture.md +9 -3
- package/docs/en/03-routing.md +6 -0
- package/docs/en/06-caching.md +71 -72
- package/docs/en/07-configuration.md +44 -21
- package/docs/en/11-migration.md +1 -0
- package/package.json +1 -1
- package/src/client/{cache-panel → admin}/i18n.js +94 -8
- package/src/client/{cache-panel → admin}/login.html +2 -2
- package/src/client/{cache-panel → admin}/panel.css +49 -1
- package/src/client/admin/panel.html +486 -0
- package/src/client/{cache-panel → admin}/panel.js +333 -6
- package/src/config/defaults.js +16 -7
- package/src/config/index.js +38 -22
- package/src/log.mjs +58 -0
- package/src/server/admin/actions.js +229 -0
- package/src/server/admin/auth.js +125 -0
- package/src/server/admin/event-log.js +151 -0
- package/src/server/admin/gate.js +209 -0
- package/src/server/admin/inventory.js +188 -0
- package/src/server/admin/mount.js +56 -0
- package/src/server/admin/router.js +216 -0
- package/src/server/admin/snapshot.js +126 -0
- package/src/server/cloudflare.js +21 -9
- package/src/server/create-app.js +10 -6
- package/src/server/middleware/trailing-slash.js +53 -0
- package/src/client/cache-panel/panel.html +0 -308
- package/src/server/cache-panel.js +0 -759
package/src/server/create-app.js
CHANGED
|
@@ -7,11 +7,13 @@
|
|
|
7
7
|
* 1. rewrites(beforeFiles) — statik dosyalardan da önce çalışmalı, yoksa
|
|
8
8
|
* `/assets/x.js` yolunu başka bir yere taşıyan kural işlemez.
|
|
9
9
|
* 2. compression — static'ten önce; sonra gelirse statik dosyalar sıkışmaz.
|
|
10
|
-
* 3. headers → devGate → redirects — gate'in 404'ü
|
|
10
|
+
* 3. headers → devGate → redirects → trailingSlash — gate'in 404'ü
|
|
11
|
+
* redirect'ten önce; trailingSlash config redirects'ten sonra, böylece
|
|
12
|
+
* açık kurallar istenen yolu önce görür.
|
|
11
13
|
* 4. staticPrecompressed → express.static — build'de üretilmiş `.br`/`.gz`
|
|
12
14
|
* kopyalar varsa onlar servis edilir (kalite 11), yoksa istek altındaki
|
|
13
15
|
* static'e düşer ve middleware anında sıkıştırır (kalite 5).
|
|
14
|
-
* 4b.
|
|
16
|
+
* 4b. admin paneli (açıksa) — statikten sonra, route'lardan önce: kendi
|
|
15
17
|
* gövde ayrıştırıcısını taşır ve uygulama yolunu gölgeleyemez.
|
|
16
18
|
* 5. body parser'lar — statikten sonra: görsel isteklerinde gövde ayrıştırma
|
|
17
19
|
* maliyeti ödenmesin.
|
|
@@ -29,6 +31,7 @@ import { csrf } from "./middleware/csrf.js";
|
|
|
29
31
|
import { staticPrecompressed } from "./middleware/static-precompressed.js";
|
|
30
32
|
import { devGate } from "./middleware/dev-gate.js";
|
|
31
33
|
import { redirects } from "./middleware/redirects.js";
|
|
34
|
+
import { trailingSlash } from "./middleware/trailing-slash.js";
|
|
32
35
|
import { configRewrites } from "./middleware/upstream-proxy.js";
|
|
33
36
|
import { getConfig, loadConfig } from "../config/index.js";
|
|
34
37
|
import { IMMUTABLE_CACHE } from "../config/defaults.js";
|
|
@@ -85,6 +88,7 @@ export async function createApp(options = {}) {
|
|
|
85
88
|
app.use(headersMiddleware());
|
|
86
89
|
app.use(devGate());
|
|
87
90
|
app.use(redirects());
|
|
91
|
+
app.use(trailingSlash());
|
|
88
92
|
|
|
89
93
|
app.use(staticPrecompressed(config.dirs.public));
|
|
90
94
|
app.use(
|
|
@@ -110,13 +114,13 @@ export async function createApp(options = {}) {
|
|
|
110
114
|
mountDevtools(app);
|
|
111
115
|
}
|
|
112
116
|
|
|
113
|
-
//
|
|
117
|
+
// Yönetim paneli ortama bakmaz, config'e bakar: açıkça etkinleştirilmedikçe
|
|
114
118
|
// modül hiç yüklenmez ve yol da yoktur. Kendi gövde ayrıştırıcısını
|
|
115
119
|
// taşıdığı için aşağıdaki parser'lardan önce durabiliyor; route'lardan
|
|
116
120
|
// önce olması gerekiyor ki uygulama aynı yolu gölgeleyemesin.
|
|
117
|
-
if (config.
|
|
118
|
-
const {
|
|
119
|
-
|
|
121
|
+
if (config.admin.enabled) {
|
|
122
|
+
const { mountAdmin } = await import("./admin/mount.js");
|
|
123
|
+
mountAdmin(app);
|
|
120
124
|
}
|
|
121
125
|
|
|
122
126
|
app.use(express.urlencoded({ extended: false, limit: "64kb" }));
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `jskelet.config.mjs` → `trailingSlash` uygulayıcısı.
|
|
3
|
+
*
|
|
4
|
+
* Açıkken kanonik URL `/` ile biter: `/hakkinda/` 200 döner, slash'sız
|
|
5
|
+
* istek 308 ile slash'lıya gider. 301 değil 308 — framework'ün diğer kalıcı
|
|
6
|
+
* yönlendirmeleri gibi metodu korur ve eski istemcilerin POST'u GET'e
|
|
7
|
+
* çevirmesini engeller.
|
|
8
|
+
*
|
|
9
|
+
* Kapalıyken (varsayılan) hiçbir şey yapmaz: mevcut sitelerin `/x` ve `/x/`
|
|
10
|
+
* biçimlerini kırmaz.
|
|
11
|
+
*
|
|
12
|
+
* İstisnalar Next ile aynı: uzantılı dosya yolları ve `/.well-known/`.
|
|
13
|
+
* Kök `/` zaten slash ile biter.
|
|
14
|
+
*/
|
|
15
|
+
import { getConfig } from "../../config/index.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Bu yola trailing slash dayatılmamalı mı.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} pathname
|
|
21
|
+
* @returns {boolean}
|
|
22
|
+
*/
|
|
23
|
+
export function skipTrailingSlash(pathname) {
|
|
24
|
+
if (!pathname || pathname === "/") return true;
|
|
25
|
+
if (pathname.startsWith("/.well-known")) return true;
|
|
26
|
+
|
|
27
|
+
// Son segmentte nokta varsa statik dosya sayılır (`/a.b/c` değil,
|
|
28
|
+
// `/foto.png` ve `/assets/app.js` gibi).
|
|
29
|
+
const lastSlash = pathname.lastIndexOf("/");
|
|
30
|
+
const lastSegment = pathname.slice(lastSlash + 1);
|
|
31
|
+
return lastSegment.includes(".");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @returns {import('express').RequestHandler}
|
|
36
|
+
*/
|
|
37
|
+
export function trailingSlash() {
|
|
38
|
+
if (!getConfig().trailingSlash) {
|
|
39
|
+
return (_req, _res, next) => next();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (req, res, next) => {
|
|
43
|
+
const pathname = req.path ?? "";
|
|
44
|
+
if (pathname.endsWith("/") || skipTrailingSlash(pathname)) {
|
|
45
|
+
next();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Query string `originalUrl`'den: `req.path` onu düşürür.
|
|
50
|
+
const query = req.originalUrl.slice(pathname.length);
|
|
51
|
+
res.redirect(308, `${pathname}/${query}`);
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -1,308 +0,0 @@
|
|
|
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" />
|
|
6
|
-
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet" />
|
|
7
|
-
<meta name="referrer" content="no-referrer" />
|
|
8
|
-
<title>Cache · JSkelet</title>
|
|
9
|
-
<link rel="icon" href="logo.png" />
|
|
10
|
-
<link rel="stylesheet" href="panel.css" />
|
|
11
|
-
</head>
|
|
12
|
-
<body>
|
|
13
|
-
<header>
|
|
14
|
-
<div class="brand">
|
|
15
|
-
<img src="logo.png" alt="" width="30" height="30" />
|
|
16
|
-
<span class="wordmark">JSkelet</span>
|
|
17
|
-
<span class="badge" id="version">—</span>
|
|
18
|
-
</div>
|
|
19
|
-
|
|
20
|
-
<nav class="crumbs" aria-label="Panel">
|
|
21
|
-
<span aria-current="page" data-i18n="nav.cache">Cache</span>
|
|
22
|
-
</nav>
|
|
23
|
-
|
|
24
|
-
<span class="spacer"></span>
|
|
25
|
-
|
|
26
|
-
<span class="chip" id="env">—</span>
|
|
27
|
-
<span class="chip" id="pid">—</span>
|
|
28
|
-
<span class="chip" id="uptime">—</span>
|
|
29
|
-
<span class="chip" id="memory">—</span>
|
|
30
|
-
|
|
31
|
-
<span id="language"></span>
|
|
32
|
-
|
|
33
|
-
<label class="check">
|
|
34
|
-
<input type="checkbox" id="auto" checked />
|
|
35
|
-
<span data-i18n="shell.live">live</span>
|
|
36
|
-
</label>
|
|
37
|
-
<button id="refresh" data-i18n="shell.refresh">Refresh</button>
|
|
38
|
-
<button id="logout" data-i18n="shell.signOut">Sign out</button>
|
|
39
|
-
</header>
|
|
40
|
-
|
|
41
|
-
<main>
|
|
42
|
-
<div class="cards">
|
|
43
|
-
<div class="card">
|
|
44
|
-
<div class="label" data-i18n="card.htmlEntries">HTML entries</div>
|
|
45
|
-
<div class="value" id="html-size">—</div>
|
|
46
|
-
<div class="sub" id="html-sub">—</div>
|
|
47
|
-
</div>
|
|
48
|
-
<div class="card">
|
|
49
|
-
<div class="label" data-i18n="card.htmlMemory">HTML in memory</div>
|
|
50
|
-
<div class="value" id="html-bytes">—</div>
|
|
51
|
-
<div class="sub" id="html-stale">—</div>
|
|
52
|
-
</div>
|
|
53
|
-
<div class="card">
|
|
54
|
-
<div class="label" data-i18n="card.dataEntries">Data entries</div>
|
|
55
|
-
<div class="value" id="data-size">—</div>
|
|
56
|
-
<div class="sub" id="data-sub">—</div>
|
|
57
|
-
</div>
|
|
58
|
-
<div class="card">
|
|
59
|
-
<div class="label" data-i18n="card.sharedTier">Shared tier</div>
|
|
60
|
-
<div class="value" id="redis-state">—</div>
|
|
61
|
-
<div class="sub" id="redis-sub">—</div>
|
|
62
|
-
</div>
|
|
63
|
-
<div class="card">
|
|
64
|
-
<div class="label" data-i18n="card.prewarm">Prewarm</div>
|
|
65
|
-
<div class="value" id="prewarm-state">—</div>
|
|
66
|
-
<div class="sub" id="prewarm-sub">—</div>
|
|
67
|
-
</div>
|
|
68
|
-
</div>
|
|
69
|
-
|
|
70
|
-
<section>
|
|
71
|
-
<h2>
|
|
72
|
-
<span data-i18n="redis.section">Shared tier</span>
|
|
73
|
-
<span class="count" id="redis-heading">—</span>
|
|
74
|
-
</h2>
|
|
75
|
-
<div class="body">
|
|
76
|
-
<dl class="kv" id="redis-kv"></dl>
|
|
77
|
-
|
|
78
|
-
<div class="row" id="redis-actions" hidden>
|
|
79
|
-
<button id="redis-inspect" data-i18n="redis.inspect">Count keys in Redis</button>
|
|
80
|
-
<button class="danger" id="redis-html" data-i18n="redis.dropHtml">
|
|
81
|
-
Drop shared HTML keys
|
|
82
|
-
</button>
|
|
83
|
-
<button class="danger" id="redis-data" data-i18n="redis.dropData">
|
|
84
|
-
Drop shared data keys
|
|
85
|
-
</button>
|
|
86
|
-
</div>
|
|
87
|
-
|
|
88
|
-
<div class="suggest" id="redis-suggest" hidden>
|
|
89
|
-
<p class="m0">
|
|
90
|
-
<strong data-i18n="redis.suggest.title">No shared tier.</strong>
|
|
91
|
-
<span data-i18n="redis.suggest.body"></span>
|
|
92
|
-
</p>
|
|
93
|
-
<pre><code>npm install ioredis</code></pre>
|
|
94
|
-
<pre><code>// jskelet.config.mjs
|
|
95
|
-
cache() {
|
|
96
|
-
return {
|
|
97
|
-
redis: { enabled: true, url: process.env.REDIS_URL },
|
|
98
|
-
};
|
|
99
|
-
}</code></pre>
|
|
100
|
-
<p class="hint m0" data-i18n="redis.suggest.hint"></p>
|
|
101
|
-
</div>
|
|
102
|
-
</div>
|
|
103
|
-
</section>
|
|
104
|
-
|
|
105
|
-
<section>
|
|
106
|
-
<h2>
|
|
107
|
-
<span data-i18n="cf.section">Cloudflare</span>
|
|
108
|
-
<span class="count" id="cf-heading">—</span>
|
|
109
|
-
<span class="tabs">
|
|
110
|
-
<button id="cf-refresh" data-i18n="cf.refresh">Refresh</button>
|
|
111
|
-
</span>
|
|
112
|
-
</h2>
|
|
113
|
-
<div class="body">
|
|
114
|
-
<dl class="kv" id="cf-kv"></dl>
|
|
115
|
-
|
|
116
|
-
<div id="cf-tools" hidden>
|
|
117
|
-
<div class="row">
|
|
118
|
-
<button class="danger" id="cf-purge-all" data-i18n="cf.purgeAll">
|
|
119
|
-
Purge everything
|
|
120
|
-
</button>
|
|
121
|
-
<button id="cf-purge-cached" data-i18n="cf.purgeCached">
|
|
122
|
-
Purge URLs cached here
|
|
123
|
-
</button>
|
|
124
|
-
<label class="check">
|
|
125
|
-
<input type="checkbox" id="cf-dev" />
|
|
126
|
-
<span data-i18n="cf.devMode"></span>
|
|
127
|
-
</label>
|
|
128
|
-
</div>
|
|
129
|
-
|
|
130
|
-
<div class="row">
|
|
131
|
-
<select id="cf-kind">
|
|
132
|
-
<option value="prefixes" data-i18n="cf.kind.prefixes">prefixes</option>
|
|
133
|
-
<option value="hosts" data-i18n="cf.kind.hosts">hosts</option>
|
|
134
|
-
<option value="tags" data-i18n="cf.kind.tags">cache tags</option>
|
|
135
|
-
</select>
|
|
136
|
-
<input
|
|
137
|
-
class="grow"
|
|
138
|
-
type="text"
|
|
139
|
-
id="cf-values"
|
|
140
|
-
spellcheck="false"
|
|
141
|
-
data-i18n-ph="cf.values.placeholder"
|
|
142
|
-
/>
|
|
143
|
-
<button class="primary" id="cf-purge-keys" data-i18n="cf.purge">Purge</button>
|
|
144
|
-
</div>
|
|
145
|
-
|
|
146
|
-
<div class="row">
|
|
147
|
-
<button id="cf-tiered" data-i18n="cf.toggleTiered">Toggle Tiered Cache</button>
|
|
148
|
-
<button id="cf-reserve" data-i18n="cf.toggleReserve">Toggle Cache Reserve</button>
|
|
149
|
-
<button class="danger" id="cf-reserve-clear" data-i18n="cf.clearReserve">
|
|
150
|
-
Clear Cache Reserve
|
|
151
|
-
</button>
|
|
152
|
-
</div>
|
|
153
|
-
|
|
154
|
-
<div class="row">
|
|
155
|
-
<input
|
|
156
|
-
class="grow"
|
|
157
|
-
type="text"
|
|
158
|
-
id="cf-path"
|
|
159
|
-
spellcheck="false"
|
|
160
|
-
data-i18n-ph="cf.path.placeholder"
|
|
161
|
-
/>
|
|
162
|
-
<button id="cf-analytics" data-i18n="cf.queryAnalytics">Query analytics</button>
|
|
163
|
-
</div>
|
|
164
|
-
|
|
165
|
-
<div id="cf-report" class="report" hidden></div>
|
|
166
|
-
|
|
167
|
-
<p class="hint" data-i18n="cf.edgeHint"></p>
|
|
168
|
-
</div>
|
|
169
|
-
|
|
170
|
-
<div class="suggest" id="cf-suggest" hidden>
|
|
171
|
-
<p class="m0">
|
|
172
|
-
<strong data-i18n="cf.suggest.title">Cloudflare is not connected.</strong>
|
|
173
|
-
<span data-i18n="cf.suggest.body"></span>
|
|
174
|
-
</p>
|
|
175
|
-
<pre><code>JSKELET_CLOUDFLARE_KEY=your_api_token
|
|
176
|
-
JSKELET_CLOUDFLARE_ZONE_ID=your_zone_id</code></pre>
|
|
177
|
-
<p class="hint m0" data-i18n-html="cf.suggest.hint"></p>
|
|
178
|
-
</div>
|
|
179
|
-
</div>
|
|
180
|
-
</section>
|
|
181
|
-
|
|
182
|
-
<section>
|
|
183
|
-
<h2>
|
|
184
|
-
<span data-i18n="host.section">Host</span>
|
|
185
|
-
<span class="count" id="host-heading">—</span>
|
|
186
|
-
</h2>
|
|
187
|
-
<div class="body">
|
|
188
|
-
<div class="meter">
|
|
189
|
-
<div class="meter-head">
|
|
190
|
-
<span data-i18n="host.memory">Memory</span>
|
|
191
|
-
<span class="mono" id="ram-text">—</span>
|
|
192
|
-
</div>
|
|
193
|
-
<div class="bar"><span id="ram-bar"></span></div>
|
|
194
|
-
<p class="hint m0" id="ram-note">—</p>
|
|
195
|
-
</div>
|
|
196
|
-
|
|
197
|
-
<div class="meter">
|
|
198
|
-
<div class="meter-head">
|
|
199
|
-
<span data-i18n="host.disk">Disk</span>
|
|
200
|
-
<span class="mono" id="disk-text">—</span>
|
|
201
|
-
</div>
|
|
202
|
-
<div class="bar"><span id="disk-bar"></span></div>
|
|
203
|
-
<p class="hint m0" id="disk-note">—</p>
|
|
204
|
-
</div>
|
|
205
|
-
</div>
|
|
206
|
-
</section>
|
|
207
|
-
|
|
208
|
-
<section>
|
|
209
|
-
<h2><span data-i18n="actions.section">Actions</span></h2>
|
|
210
|
-
<div class="body">
|
|
211
|
-
<div class="row">
|
|
212
|
-
<input
|
|
213
|
-
class="grow"
|
|
214
|
-
type="text"
|
|
215
|
-
id="target"
|
|
216
|
-
spellcheck="false"
|
|
217
|
-
data-i18n-ph="actions.target.placeholder"
|
|
218
|
-
/>
|
|
219
|
-
<label class="check">
|
|
220
|
-
<input type="checkbox" id="hard" />
|
|
221
|
-
<span data-i18n="actions.hard"></span>
|
|
222
|
-
</label>
|
|
223
|
-
<button class="primary" id="invalidate" data-i18n="actions.invalidate">
|
|
224
|
-
Invalidate
|
|
225
|
-
</button>
|
|
226
|
-
</div>
|
|
227
|
-
|
|
228
|
-
<div class="row">
|
|
229
|
-
<input
|
|
230
|
-
class="grow"
|
|
231
|
-
type="text"
|
|
232
|
-
id="prefix"
|
|
233
|
-
spellcheck="false"
|
|
234
|
-
data-i18n-ph="actions.prefix.placeholder"
|
|
235
|
-
/>
|
|
236
|
-
<button class="danger" id="clear-data" data-i18n="actions.clearData">
|
|
237
|
-
Clear data cache
|
|
238
|
-
</button>
|
|
239
|
-
<button class="danger" id="clear-html" data-i18n="actions.clearHtml">
|
|
240
|
-
Clear HTML cache
|
|
241
|
-
</button>
|
|
242
|
-
</div>
|
|
243
|
-
|
|
244
|
-
<div class="row">
|
|
245
|
-
<button id="prewarm" data-i18n="actions.prewarm">Prewarm all paths</button>
|
|
246
|
-
</div>
|
|
247
|
-
|
|
248
|
-
<p class="hint" data-i18n="actions.hint"></p>
|
|
249
|
-
</div>
|
|
250
|
-
</section>
|
|
251
|
-
|
|
252
|
-
<section>
|
|
253
|
-
<h2>
|
|
254
|
-
<span data-i18n="entries.section">Entries</span>
|
|
255
|
-
<span class="count" id="listed">—</span>
|
|
256
|
-
<span class="tabs" role="tablist">
|
|
257
|
-
<button id="tab-html" role="tab" aria-selected="true">HTML</button>
|
|
258
|
-
<button id="tab-data" role="tab" aria-selected="false" data-i18n="tab.data">
|
|
259
|
-
Data
|
|
260
|
-
</button>
|
|
261
|
-
</span>
|
|
262
|
-
</h2>
|
|
263
|
-
<div class="body">
|
|
264
|
-
<div class="row">
|
|
265
|
-
<input
|
|
266
|
-
class="grow"
|
|
267
|
-
type="search"
|
|
268
|
-
id="search"
|
|
269
|
-
spellcheck="false"
|
|
270
|
-
data-i18n-ph="entries.search"
|
|
271
|
-
/>
|
|
272
|
-
</div>
|
|
273
|
-
</div>
|
|
274
|
-
<div class="scroll">
|
|
275
|
-
<table>
|
|
276
|
-
<thead id="thead"></thead>
|
|
277
|
-
<tbody id="tbody"></tbody>
|
|
278
|
-
</table>
|
|
279
|
-
<div class="empty" id="empty" data-i18n="entries.empty" hidden>No entries.</div>
|
|
280
|
-
</div>
|
|
281
|
-
</section>
|
|
282
|
-
</main>
|
|
283
|
-
|
|
284
|
-
<footer>
|
|
285
|
-
<div class="foot-brand">
|
|
286
|
-
<img src="logo.png" alt="" width="24" height="24" />
|
|
287
|
-
<span class="wordmark">JSkelet</span>
|
|
288
|
-
<span class="mono" id="foot-release">—</span>
|
|
289
|
-
</div>
|
|
290
|
-
|
|
291
|
-
<div class="foot-links">
|
|
292
|
-
<a id="foot-repo" href="https://github.com/ayberkenis/jskelet" rel="noreferrer noopener">
|
|
293
|
-
GitHub
|
|
294
|
-
</a>
|
|
295
|
-
<span class="dot" aria-hidden="true">·</span>
|
|
296
|
-
<span data-i18n="footer.panel">Cache panel</span>
|
|
297
|
-
<span class="dot" aria-hidden="true">·</span>
|
|
298
|
-
<span class="live">
|
|
299
|
-
<span class="pulse"></span>
|
|
300
|
-
<span data-i18n="footer.rotates">Password rotates on restart</span>
|
|
301
|
-
</span>
|
|
302
|
-
</div>
|
|
303
|
-
</footer>
|
|
304
|
-
|
|
305
|
-
<div id="toast"></div>
|
|
306
|
-
<script type="module" src="panel.js"></script>
|
|
307
|
-
</body>
|
|
308
|
-
</html>
|