jskelet 0.2.1 → 0.2.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/CHANGELOG.md +54 -0
- package/docs/06-cache.md +195 -4
- package/docs/07-yapilandirma.md +70 -2
- package/docs/en/06-caching.md +202 -5
- package/docs/en/07-configuration.md +72 -2
- package/package.json +1 -1
- package/src/client/cache-panel/login.html +63 -0
- package/src/client/cache-panel/panel.css +448 -0
- package/src/client/cache-panel/panel.html +133 -0
- package/src/client/cache-panel/panel.js +339 -0
- package/src/client/devtools/overlay.js +85 -0
- package/src/client/devtools/report.js +13 -0
- package/src/config/defaults.js +67 -2
- package/src/config/index.js +98 -1
- package/src/index.js +5 -0
- package/src/server/cache-panel.js +505 -0
- package/src/server/create-app.js +16 -0
- package/src/server/data-cache.js +92 -2
- package/src/server/dev/report.js +8 -1
- package/src/server/html-cache.js +36 -0
- package/src/server/prewarm.js +94 -15
- package/src/server/upstream-limiter.js +376 -0
- package/src/server/upstream-tracking.js +25 -0
|
@@ -572,9 +572,9 @@ Details: [03-routing.md](./03-routing.md).
|
|
|
572
572
|
## `cache()`
|
|
573
573
|
|
|
574
574
|
**Type:**
|
|
575
|
-
`() => { html?: Record<string, number>, maxEntries?: number, data?: object, trackUpstream?: boolean, trackDependencies?: boolean, transientRetry?: object | false, redis?: object, prewarm?: object }` —
|
|
575
|
+
`() => { html?: Record<string, number>, maxEntries?: number, data?: object, trackUpstream?: boolean, trackDependencies?: boolean, transientRetry?: object | false, upstream?: object, redis?: object, prewarm?: object }` —
|
|
576
576
|
**Default:**
|
|
577
|
-
`{ html: {}, maxEntries: 500, data: { maxEntries: 10000, staleFactor: 10 }, trackUpstream: true, trackDependencies: true, transientRetry: { attempts: 1, delayMs: 300 }, redis: { enabled: false }, prewarm: { enabled: true, max: 400, intervalSeconds: 0 } }`
|
|
577
|
+
`{ html: {}, maxEntries: 500, data: { maxEntries: 10000, staleFactor: 10 }, trackUpstream: true, trackDependencies: true, transientRetry: { attempts: 1, delayMs: 300 }, upstream: { rate: 0 }, redis: { enabled: false }, prewarm: { enabled: true, max: 400, intervalSeconds: 0 } }`
|
|
578
578
|
|
|
579
579
|
### `cache().html`
|
|
580
580
|
|
|
@@ -643,6 +643,39 @@ transient upstream failure. The point is that an existing page never turns into
|
|
|
643
643
|
a 404; if the retries are exhausted the response is an uncached 503. `false` or
|
|
644
644
|
`attempts: 0` disables the retry. Details: [06-caching.md](./06-caching.md).
|
|
645
645
|
|
|
646
|
+
### `cache().upstream`
|
|
647
|
+
|
|
648
|
+
A per-host rate limit for the `fetch` calls that go to upstream APIs. Off by
|
|
649
|
+
default: unless `rate` is given, no request ever waits. `rate` is a ceiling; the
|
|
650
|
+
actual rate pulls itself down in response to 429s and climbs back step by step
|
|
651
|
+
during clean windows.
|
|
652
|
+
|
|
653
|
+
| Field | Type | Default | Meaning |
|
|
654
|
+
| --- | --- | --- | --- |
|
|
655
|
+
| `rate` | `number` | `0` | Maximum calls per second. `0` → brake disabled |
|
|
656
|
+
| `burst` | `number` | `0` | Bucket size; `0` → one second's budget of burst |
|
|
657
|
+
| `concurrency` | `number` | `8` | Calls allowed in flight at once |
|
|
658
|
+
| `minRate` | `number` | `0.5` | Floor of the decrease; the rate never goes below it |
|
|
659
|
+
| `increaseStep` | `number` | `1` | Step of the additive increase (calls/second) |
|
|
660
|
+
| `increaseIntervalMs` | `number` | `5000` | Increase period |
|
|
661
|
+
| `decreaseIntervalMs` | `number` | `1000` | Minimum time between two decreases |
|
|
662
|
+
| `breakerFailures` | `number` | `5` | Consecutive 429s after which the host is bypassed |
|
|
663
|
+
| `breakerCooldownMs` | `number` | `10000` | How long the bypass lasts |
|
|
664
|
+
| `hosts` | `Record<string, object>` | `{}` | Per-host overrides; same fields apply |
|
|
665
|
+
|
|
666
|
+
Only `429` and `503` penalise the rate: a `400`/`404`/`500` is not a quota
|
|
667
|
+
problem. Read the state with `getUpstreamLimiterStatus()` or from the dev
|
|
668
|
+
panel's **Server** tab. Details, and what to check before turning it on:
|
|
669
|
+
[06-caching.md](./06-caching.md).
|
|
670
|
+
|
|
671
|
+
```js
|
|
672
|
+
upstream: {
|
|
673
|
+
rate: 10,
|
|
674
|
+
concurrency: 4,
|
|
675
|
+
hosts: { "api.example.com": { rate: 3 } },
|
|
676
|
+
}
|
|
677
|
+
```
|
|
678
|
+
|
|
646
679
|
### `cache().redis`
|
|
647
680
|
|
|
648
681
|
An optional Redis second tier (L2). The in-process cache stays primary; Redis
|
|
@@ -677,6 +710,42 @@ redis: {
|
|
|
677
710
|
}
|
|
678
711
|
```
|
|
679
712
|
|
|
713
|
+
### `cache().panel`
|
|
714
|
+
|
|
715
|
+
The cache admin panel. It shows the state of the in-process tier and the Redis
|
|
716
|
+
tier, and it triggers targeted invalidation, single-entry drops and prewarming.
|
|
717
|
+
|
|
718
|
+
It does not look at the environment: without `enabled` **nothing is mounted**
|
|
719
|
+
and the path does not exist. When it is on it also works in production — that is
|
|
720
|
+
where the real questions ("why is this page stale", "did the webhook purge
|
|
721
|
+
land") get asked.
|
|
722
|
+
|
|
723
|
+
| Field | Type | Default | Meaning |
|
|
724
|
+
| --- | --- | --- | --- |
|
|
725
|
+
| `enabled` | `boolean` | `false` | Only turns on when explicitly `true` (`JSKELET_CACHE_PANEL` overrides it) |
|
|
726
|
+
| `basePath` | `string` | `"/_jskelet/cache"` | Root of the panel |
|
|
727
|
+
| `banAttempts` | `number` | `3` | How many failed attempts ban an IP |
|
|
728
|
+
| `banHours` | `number` | `24` | How long the ban lasts |
|
|
729
|
+
| `sessionHours` | `number` | `12` | Lifetime of the session cookie |
|
|
730
|
+
|
|
731
|
+
The password is generated **on every process start** and only appears in the
|
|
732
|
+
server log:
|
|
733
|
+
|
|
734
|
+
```
|
|
735
|
+
[cache-panel] http://localhost:3000/_jskelet/cache — password for this run: 3f9c…
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
There is no persistent secret (no config field, no environment variable):
|
|
739
|
+
leaking one means handing out the right to flush the cache, and a deploy should
|
|
740
|
+
revoke old access on its own. Banned and unauthorised requests all get a `404`.
|
|
741
|
+
Usage and screens: [06-caching.md](./06-caching.md).
|
|
742
|
+
|
|
743
|
+
```js
|
|
744
|
+
panel: {
|
|
745
|
+
enabled: process.env.CACHE_PANEL === "1",
|
|
746
|
+
}
|
|
747
|
+
```
|
|
748
|
+
|
|
680
749
|
### `cache().prewarm`
|
|
681
750
|
|
|
682
751
|
| Field | Type | Default | Meaning |
|
|
@@ -801,6 +870,7 @@ and no warning is printed.
|
|
|
801
870
|
| `HOST` | `startServer` | `::` | Interface to bind to. The default listens dual-stack (IPv6 + IPv4); it falls back to `0.0.0.0` where IPv6 is unavailable |
|
|
802
871
|
| `JSKELET_SECRET` | `jskelet/cookies` | — | The signed cookie secret. Read when `security.cookieSecret` is not set; if neither exists, the signed cookie API throws. [12](./12-dashboards-and-sessions.md) |
|
|
803
872
|
| `DEV_TOKEN` | `devGate`, `prewarm` | — | If set, every request without a token gets a 404. Prewarming carries the token as a cookie. [09](./09-dev-tools.md) |
|
|
873
|
+
| `JSKELET_CACHE_PANEL` | `createApp` | — | When set, turns the cache panel on; `0` turns off a panel enabled in the config. The env wins because the panel is usually opened once during an incident. [06](./06-caching.md) |
|
|
804
874
|
| `PREWARM` | `startPrewarm` | — | `0` turns prewarming off; `1` overrides `enabled: false` in the config and turns it on |
|
|
805
875
|
| `PREWARM_MAX` | `prewarm` | `400` | At most how many paths are prewarmed |
|
|
806
876
|
| `PREWARM_CONCURRENCY` | `prewarm` | prod 4, dev 1 | Number of parallel workers |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jskelet",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "A framework that feels like no framework: Express 5 + EJS server rendering, vanilla JS islands, Tailwind v4 and an in-process HTML TTL cache.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,63 @@
|
|
|
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</title>
|
|
9
|
+
<link rel="stylesheet" href="panel.css" />
|
|
10
|
+
</head>
|
|
11
|
+
<body class="login">
|
|
12
|
+
<form id="form" autocomplete="off">
|
|
13
|
+
<h1>Cache panel</h1>
|
|
14
|
+
<p>
|
|
15
|
+
This run's password is printed in the server log
|
|
16
|
+
(<code>[cache-panel]</code>).
|
|
17
|
+
</p>
|
|
18
|
+
<input
|
|
19
|
+
id="password"
|
|
20
|
+
type="password"
|
|
21
|
+
name="password"
|
|
22
|
+
maxlength="64"
|
|
23
|
+
spellcheck="false"
|
|
24
|
+
autocomplete="off"
|
|
25
|
+
placeholder="32-character password"
|
|
26
|
+
autofocus
|
|
27
|
+
/>
|
|
28
|
+
<button class="primary" type="submit">Unlock</button>
|
|
29
|
+
<p class="error" id="error"></p>
|
|
30
|
+
</form>
|
|
31
|
+
|
|
32
|
+
<script type="module">
|
|
33
|
+
const form = document.getElementById("form");
|
|
34
|
+
const input = document.getElementById("password");
|
|
35
|
+
const error = document.getElementById("error");
|
|
36
|
+
|
|
37
|
+
form.addEventListener("submit", async (event) => {
|
|
38
|
+
event.preventDefault();
|
|
39
|
+
error.textContent = "";
|
|
40
|
+
|
|
41
|
+
const response = await fetch("login", {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: { "Content-Type": "application/json" },
|
|
44
|
+
body: JSON.stringify({ password: input.value }),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (response.ok) {
|
|
48
|
+
location.reload();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Yasaklandıysa sunucu artık 404 dönüyor; kalan deneme sayısı
|
|
53
|
+
// bilinçli olarak bildirilmiyor.
|
|
54
|
+
error.textContent =
|
|
55
|
+
response.status === 404
|
|
56
|
+
? "Too many attempts."
|
|
57
|
+
: "Invalid password.";
|
|
58
|
+
input.value = "";
|
|
59
|
+
input.focus();
|
|
60
|
+
});
|
|
61
|
+
</script>
|
|
62
|
+
</body>
|
|
63
|
+
</html>
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Önbellek panelinin stili. Framework'ün build hattından geçmez: panel
|
|
3
|
+
* doğrudan diskten servis edilir ve uygulamanın Tailwind çıktısına bağlı
|
|
4
|
+
* olmamalı — panelin açık olduğu bir kurulumda `jskelet build` hiç
|
|
5
|
+
* koşmamış olabilir.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
*,
|
|
9
|
+
*::before,
|
|
10
|
+
*::after {
|
|
11
|
+
box-sizing: border-box;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
:root {
|
|
15
|
+
color-scheme: dark;
|
|
16
|
+
--bg: #0a0b0e;
|
|
17
|
+
--panel: #12141a;
|
|
18
|
+
--panel-2: #171a21;
|
|
19
|
+
--line: #23272f;
|
|
20
|
+
--text: #e7e9ee;
|
|
21
|
+
--muted: #8b93a1;
|
|
22
|
+
--accent: #6ea8fe;
|
|
23
|
+
--ok: #4ade80;
|
|
24
|
+
--warn: #fbbf24;
|
|
25
|
+
--bad: #f87171;
|
|
26
|
+
--radius: 10px;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
html,
|
|
30
|
+
body {
|
|
31
|
+
margin: 0;
|
|
32
|
+
padding: 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
body {
|
|
36
|
+
min-height: 100vh;
|
|
37
|
+
background:
|
|
38
|
+
radial-gradient(1200px 600px at 20% -10%, #16202f 0%, transparent 60%),
|
|
39
|
+
var(--bg);
|
|
40
|
+
color: var(--text);
|
|
41
|
+
font:
|
|
42
|
+
14px/1.5 ui-sans-serif,
|
|
43
|
+
system-ui,
|
|
44
|
+
-apple-system,
|
|
45
|
+
"Segoe UI",
|
|
46
|
+
sans-serif;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
code,
|
|
50
|
+
.mono,
|
|
51
|
+
input,
|
|
52
|
+
table {
|
|
53
|
+
font-family:
|
|
54
|
+
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono",
|
|
55
|
+
monospace;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
a {
|
|
59
|
+
color: var(--accent);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* ------------------------------------------------------------------ giriş */
|
|
63
|
+
|
|
64
|
+
.login {
|
|
65
|
+
display: grid;
|
|
66
|
+
place-items: center;
|
|
67
|
+
min-height: 100vh;
|
|
68
|
+
padding: 24px;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.login form {
|
|
72
|
+
width: 100%;
|
|
73
|
+
max-width: 420px;
|
|
74
|
+
padding: 28px;
|
|
75
|
+
border: 1px solid var(--line);
|
|
76
|
+
border-radius: 14px;
|
|
77
|
+
background: var(--panel);
|
|
78
|
+
box-shadow: 0 24px 60px rgb(0 0 0 / 45%);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.login h1 {
|
|
82
|
+
margin: 0 0 6px;
|
|
83
|
+
font-size: 18px;
|
|
84
|
+
letter-spacing: -0.01em;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.login p {
|
|
88
|
+
margin: 0 0 20px;
|
|
89
|
+
color: var(--muted);
|
|
90
|
+
font-size: 13px;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.login input {
|
|
94
|
+
width: 100%;
|
|
95
|
+
padding: 11px 12px;
|
|
96
|
+
border: 1px solid var(--line);
|
|
97
|
+
border-radius: var(--radius);
|
|
98
|
+
background: #0d0f14;
|
|
99
|
+
color: var(--text);
|
|
100
|
+
font-size: 13px;
|
|
101
|
+
letter-spacing: 0.06em;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.login input:focus {
|
|
105
|
+
border-color: var(--accent);
|
|
106
|
+
outline: none;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.login button {
|
|
110
|
+
width: 100%;
|
|
111
|
+
margin-top: 12px;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.error {
|
|
115
|
+
min-height: 18px;
|
|
116
|
+
margin-top: 10px;
|
|
117
|
+
color: var(--bad);
|
|
118
|
+
font-size: 12px;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* ------------------------------------------------------------------ düzen */
|
|
122
|
+
|
|
123
|
+
header {
|
|
124
|
+
display: flex;
|
|
125
|
+
flex-wrap: wrap;
|
|
126
|
+
gap: 12px;
|
|
127
|
+
align-items: center;
|
|
128
|
+
padding: 14px 20px;
|
|
129
|
+
border-bottom: 1px solid var(--line);
|
|
130
|
+
background: rgb(10 11 14 / 85%);
|
|
131
|
+
backdrop-filter: blur(8px);
|
|
132
|
+
position: sticky;
|
|
133
|
+
top: 0;
|
|
134
|
+
z-index: 5;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
header h1 {
|
|
138
|
+
margin: 0;
|
|
139
|
+
font-size: 15px;
|
|
140
|
+
font-weight: 600;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
header .spacer {
|
|
144
|
+
flex: 1;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.chip {
|
|
148
|
+
padding: 3px 9px;
|
|
149
|
+
border: 1px solid var(--line);
|
|
150
|
+
border-radius: 999px;
|
|
151
|
+
color: var(--muted);
|
|
152
|
+
font-size: 11.5px;
|
|
153
|
+
white-space: nowrap;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.chip.on {
|
|
157
|
+
border-color: color-mix(in oklab, var(--ok) 45%, var(--line));
|
|
158
|
+
color: var(--ok);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.chip.off {
|
|
162
|
+
border-color: color-mix(in oklab, var(--bad) 45%, var(--line));
|
|
163
|
+
color: var(--bad);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.chip.idle {
|
|
167
|
+
border-color: color-mix(in oklab, var(--warn) 45%, var(--line));
|
|
168
|
+
color: var(--warn);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
main {
|
|
172
|
+
max-width: 1180px;
|
|
173
|
+
margin: 0 auto;
|
|
174
|
+
padding: 20px;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.cards {
|
|
178
|
+
display: grid;
|
|
179
|
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
180
|
+
gap: 12px;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.card {
|
|
184
|
+
padding: 14px 16px;
|
|
185
|
+
border: 1px solid var(--line);
|
|
186
|
+
border-radius: var(--radius);
|
|
187
|
+
background: var(--panel);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.card .label {
|
|
191
|
+
color: var(--muted);
|
|
192
|
+
font-size: 11.5px;
|
|
193
|
+
text-transform: uppercase;
|
|
194
|
+
letter-spacing: 0.08em;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.card .value {
|
|
198
|
+
margin-top: 6px;
|
|
199
|
+
font-size: 22px;
|
|
200
|
+
font-weight: 600;
|
|
201
|
+
letter-spacing: -0.02em;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.card .sub {
|
|
205
|
+
margin-top: 2px;
|
|
206
|
+
color: var(--muted);
|
|
207
|
+
font-size: 12px;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
section {
|
|
211
|
+
margin-top: 20px;
|
|
212
|
+
border: 1px solid var(--line);
|
|
213
|
+
border-radius: var(--radius);
|
|
214
|
+
background: var(--panel);
|
|
215
|
+
overflow: hidden;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
section > h2 {
|
|
219
|
+
display: flex;
|
|
220
|
+
gap: 10px;
|
|
221
|
+
align-items: center;
|
|
222
|
+
margin: 0;
|
|
223
|
+
padding: 12px 16px;
|
|
224
|
+
border-bottom: 1px solid var(--line);
|
|
225
|
+
background: var(--panel-2);
|
|
226
|
+
font-size: 13px;
|
|
227
|
+
font-weight: 600;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
section > h2 .count {
|
|
231
|
+
color: var(--muted);
|
|
232
|
+
font-weight: 400;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.body {
|
|
236
|
+
padding: 14px 16px;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.row {
|
|
240
|
+
display: flex;
|
|
241
|
+
flex-wrap: wrap;
|
|
242
|
+
gap: 8px;
|
|
243
|
+
align-items: center;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
.row + .row {
|
|
247
|
+
margin-top: 10px;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/* --------------------------------------------------------------- kontroller */
|
|
251
|
+
|
|
252
|
+
button {
|
|
253
|
+
padding: 8px 13px;
|
|
254
|
+
border: 1px solid var(--line);
|
|
255
|
+
border-radius: 8px;
|
|
256
|
+
background: var(--panel-2);
|
|
257
|
+
color: var(--text);
|
|
258
|
+
font: inherit;
|
|
259
|
+
font-size: 12.5px;
|
|
260
|
+
cursor: pointer;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
button:hover:not(:disabled) {
|
|
264
|
+
border-color: color-mix(in oklab, var(--accent) 50%, var(--line));
|
|
265
|
+
color: #fff;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
button:disabled {
|
|
269
|
+
opacity: 0.5;
|
|
270
|
+
cursor: default;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
button.primary {
|
|
274
|
+
border-color: color-mix(in oklab, var(--accent) 55%, var(--line));
|
|
275
|
+
background: color-mix(in oklab, var(--accent) 18%, var(--panel-2));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
button.danger {
|
|
279
|
+
border-color: color-mix(in oklab, var(--bad) 45%, var(--line));
|
|
280
|
+
color: color-mix(in oklab, var(--bad) 80%, var(--text));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
button.tiny {
|
|
284
|
+
padding: 3px 8px;
|
|
285
|
+
font-size: 11px;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
input[type="text"],
|
|
289
|
+
input[type="password"],
|
|
290
|
+
input[type="search"] {
|
|
291
|
+
padding: 8px 11px;
|
|
292
|
+
border: 1px solid var(--line);
|
|
293
|
+
border-radius: 8px;
|
|
294
|
+
background: #0d0f14;
|
|
295
|
+
color: var(--text);
|
|
296
|
+
font-size: 12.5px;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
input[type="text"]:focus,
|
|
300
|
+
input[type="search"]:focus {
|
|
301
|
+
border-color: var(--accent);
|
|
302
|
+
outline: none;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
input.grow {
|
|
306
|
+
flex: 1;
|
|
307
|
+
min-width: 220px;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
label.check {
|
|
311
|
+
display: inline-flex;
|
|
312
|
+
gap: 6px;
|
|
313
|
+
align-items: center;
|
|
314
|
+
color: var(--muted);
|
|
315
|
+
font-size: 12.5px;
|
|
316
|
+
white-space: nowrap;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.hint {
|
|
320
|
+
margin: 8px 0 0;
|
|
321
|
+
color: var(--muted);
|
|
322
|
+
font-size: 12px;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/* -------------------------------------------------------------- sekme/tablo */
|
|
326
|
+
|
|
327
|
+
.tabs {
|
|
328
|
+
display: flex;
|
|
329
|
+
gap: 4px;
|
|
330
|
+
margin-left: auto;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
.tabs button {
|
|
334
|
+
border-color: transparent;
|
|
335
|
+
background: transparent;
|
|
336
|
+
color: var(--muted);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
.tabs button[aria-selected="true"] {
|
|
340
|
+
border-color: var(--line);
|
|
341
|
+
background: var(--panel);
|
|
342
|
+
color: var(--text);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
.scroll {
|
|
346
|
+
max-height: 460px;
|
|
347
|
+
overflow: auto;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
table {
|
|
351
|
+
width: 100%;
|
|
352
|
+
border-collapse: collapse;
|
|
353
|
+
font-size: 12.5px;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
th {
|
|
357
|
+
position: sticky;
|
|
358
|
+
top: 0;
|
|
359
|
+
padding: 9px 12px;
|
|
360
|
+
border-bottom: 1px solid var(--line);
|
|
361
|
+
background: var(--panel-2);
|
|
362
|
+
color: var(--muted);
|
|
363
|
+
font-weight: 500;
|
|
364
|
+
text-align: left;
|
|
365
|
+
white-space: nowrap;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
td {
|
|
369
|
+
padding: 8px 12px;
|
|
370
|
+
border-bottom: 1px solid color-mix(in oklab, var(--line) 60%, transparent);
|
|
371
|
+
vertical-align: middle;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
tr:last-child td {
|
|
375
|
+
border-bottom: none;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
td.key {
|
|
379
|
+
max-width: 520px;
|
|
380
|
+
overflow-wrap: anywhere;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
td.num {
|
|
384
|
+
text-align: right;
|
|
385
|
+
white-space: nowrap;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
td.actions {
|
|
389
|
+
text-align: right;
|
|
390
|
+
white-space: nowrap;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
.tag {
|
|
394
|
+
padding: 1px 7px;
|
|
395
|
+
border-radius: 999px;
|
|
396
|
+
font-size: 11px;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
.tag.fresh {
|
|
400
|
+
background: color-mix(in oklab, var(--ok) 18%, transparent);
|
|
401
|
+
color: var(--ok);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
.tag.stale {
|
|
405
|
+
background: color-mix(in oklab, var(--warn) 18%, transparent);
|
|
406
|
+
color: var(--warn);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
.empty {
|
|
410
|
+
padding: 28px 16px;
|
|
411
|
+
color: var(--muted);
|
|
412
|
+
text-align: center;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/* ------------------------------------------------------------------ toast */
|
|
416
|
+
|
|
417
|
+
#toast {
|
|
418
|
+
position: fixed;
|
|
419
|
+
right: 20px;
|
|
420
|
+
bottom: 20px;
|
|
421
|
+
z-index: 20;
|
|
422
|
+
display: grid;
|
|
423
|
+
gap: 8px;
|
|
424
|
+
justify-items: end;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
.toast {
|
|
428
|
+
max-width: 420px;
|
|
429
|
+
padding: 10px 14px;
|
|
430
|
+
border: 1px solid var(--line);
|
|
431
|
+
border-left: 3px solid var(--ok);
|
|
432
|
+
border-radius: 8px;
|
|
433
|
+
background: var(--panel);
|
|
434
|
+
box-shadow: 0 12px 30px rgb(0 0 0 / 45%);
|
|
435
|
+
font-size: 12.5px;
|
|
436
|
+
animation: rise 0.18s ease-out;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
.toast.bad {
|
|
440
|
+
border-left-color: var(--bad);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
@keyframes rise {
|
|
444
|
+
from {
|
|
445
|
+
opacity: 0;
|
|
446
|
+
transform: translateY(6px);
|
|
447
|
+
}
|
|
448
|
+
}
|