dondo-donuts 0.1.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/LICENSE +21 -0
- package/README.md +162 -0
- package/icon.png +0 -0
- package/icon.svg +69 -0
- package/package.json +58 -0
- package/src/antigravity/google.test.ts +70 -0
- package/src/antigravity/google.ts +199 -0
- package/src/antigravity/keychain.test.ts +8 -0
- package/src/antigravity/keychain.ts +84 -0
- package/src/antigravity/service.ts +111 -0
- package/src/codex/service.ts +136 -0
- package/src/codex/usage.test.ts +96 -0
- package/src/codex/usage.ts +219 -0
- package/src/config.ts +55 -0
- package/src/errors.test.ts +20 -0
- package/src/errors.ts +42 -0
- package/src/package-ui-smoke.test.ts +140 -0
- package/src/server.test.ts +63 -0
- package/src/server.ts +289 -0
- package/src/shell.test.ts +8 -0
- package/src/shell.ts +49 -0
- package/src/storage/crypto.ts +24 -0
- package/src/storage/file.ts +23 -0
- package/src/storage/secret.ts +50 -0
- package/src/storage/vault.test.ts +72 -0
- package/src/storage/vault.ts +130 -0
- package/src/types.ts +54 -0
- package/src/ui/client.tsx +392 -0
- package/src/ui/html.ts +15 -0
- package/src/ui/styles.css +213 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { render } from 'preact';
|
|
2
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
3
|
+
import packageJson from '../../package.json';
|
|
4
|
+
import type { LimitResult, ModelLimit } from '../types.ts';
|
|
5
|
+
|
|
6
|
+
type AccountEntry = {
|
|
7
|
+
active: boolean;
|
|
8
|
+
key: string;
|
|
9
|
+
updatedAt: string;
|
|
10
|
+
limitUpdatedAt: string;
|
|
11
|
+
quota: LimitResult | null;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type AntigravityState = {
|
|
15
|
+
account: string;
|
|
16
|
+
entries: AccountEntry[];
|
|
17
|
+
service: string;
|
|
18
|
+
vaultPath: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type CodexState = {
|
|
22
|
+
authPath: string;
|
|
23
|
+
entries: AccountEntry[];
|
|
24
|
+
vaultPath: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type Tab = 'antigravity' | 'codex';
|
|
28
|
+
|
|
29
|
+
const api = async <T,>(path: string, body?: unknown): Promise<T> => {
|
|
30
|
+
const response = await fetch(path, {
|
|
31
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
32
|
+
headers: { 'Content-Type': 'application/json' },
|
|
33
|
+
method: body ? 'POST' : 'GET',
|
|
34
|
+
});
|
|
35
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
36
|
+
const json = contentType.includes('application/json') ? await response.json() : { error: await response.text() };
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
throw new Error(json.error ?? response.statusText);
|
|
39
|
+
}
|
|
40
|
+
return json as T;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const formatDate = (value: string) => (value ? new Date(value).toLocaleString() : '');
|
|
44
|
+
|
|
45
|
+
const ModelCard = ({ model }: { model: [string, ModelLimit] }) => {
|
|
46
|
+
const [name, data] = model;
|
|
47
|
+
const width = Math.max(0, Math.min(100, data.percentage));
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div class="model">
|
|
51
|
+
<div>
|
|
52
|
+
<b>{data.displayName || name}</b>
|
|
53
|
+
</div>
|
|
54
|
+
<div class="muted small">{name}</div>
|
|
55
|
+
<div class="bar">
|
|
56
|
+
<div class="fill" style={{ width: `${width}%` }} />
|
|
57
|
+
</div>
|
|
58
|
+
<div class="small">
|
|
59
|
+
{data.percentage}% left{data.resetTime ? ` · resets ${formatDate(data.resetTime)}` : ''}
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const AccountRow = ({
|
|
66
|
+
entry,
|
|
67
|
+
pending,
|
|
68
|
+
onLoad,
|
|
69
|
+
onRefresh,
|
|
70
|
+
}: {
|
|
71
|
+
entry: AccountEntry;
|
|
72
|
+
pending: boolean;
|
|
73
|
+
onLoad: (key: string) => void;
|
|
74
|
+
onRefresh: (key: string) => void;
|
|
75
|
+
}) => (
|
|
76
|
+
<article class="row">
|
|
77
|
+
<div class="row-head">
|
|
78
|
+
<div>
|
|
79
|
+
<div class="keyline">
|
|
80
|
+
<div class="key">{entry.key}</div>
|
|
81
|
+
{entry.active ? <span class="badge">Active</span> : null}
|
|
82
|
+
</div>
|
|
83
|
+
<div class="muted small">
|
|
84
|
+
Updated {formatDate(entry.updatedAt)}
|
|
85
|
+
{entry.limitUpdatedAt ? ` · limits ${formatDate(entry.limitUpdatedAt)}` : ''}
|
|
86
|
+
{entry.quota?.ok ? ` · ${entry.quota.tier}` : ''}
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
<div class="actions">
|
|
90
|
+
<button type="button" disabled={pending} onClick={() => onRefresh(entry.key)}>
|
|
91
|
+
Refresh
|
|
92
|
+
</button>
|
|
93
|
+
<button type="button" disabled={pending} onClick={() => onLoad(entry.key)}>
|
|
94
|
+
Load
|
|
95
|
+
</button>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
{entry.quota?.ok ? (
|
|
99
|
+
<div class="quota">
|
|
100
|
+
{Object.entries(entry.quota.models).map((model) => (
|
|
101
|
+
<ModelCard key={model[0]} model={model} />
|
|
102
|
+
))}
|
|
103
|
+
</div>
|
|
104
|
+
) : (
|
|
105
|
+
<div class="err small">{entry.quota?.error ?? 'No cached limit data'}</div>
|
|
106
|
+
)}
|
|
107
|
+
</article>
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const AntigravityPanel = ({ active }: { active: boolean }) => {
|
|
111
|
+
const [state, setState] = useState<AntigravityState | null>(null);
|
|
112
|
+
const [status, setStatus] = useState('');
|
|
113
|
+
const [key, setKey] = useState('');
|
|
114
|
+
const [loaded, setLoaded] = useState(false);
|
|
115
|
+
const [pendingKey, setPendingKey] = useState('');
|
|
116
|
+
|
|
117
|
+
const refresh = async (forceLimits = false) => {
|
|
118
|
+
setStatus(forceLimits ? 'Refreshing limits...' : 'Loading accounts...');
|
|
119
|
+
setState(
|
|
120
|
+
await api<AntigravityState>(
|
|
121
|
+
forceLimits ? '/api/antigravity/limits/refresh' : '/api/antigravity/state',
|
|
122
|
+
forceLimits ? {} : undefined,
|
|
123
|
+
),
|
|
124
|
+
);
|
|
125
|
+
setLoaded(true);
|
|
126
|
+
setStatus('');
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const save = async (event: Event) => {
|
|
130
|
+
event.preventDefault();
|
|
131
|
+
const trimmed = key.trim();
|
|
132
|
+
if (!trimmed) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
setStatus('Saving...');
|
|
136
|
+
try {
|
|
137
|
+
await api('/api/antigravity/save', { key: trimmed });
|
|
138
|
+
setKey('');
|
|
139
|
+
await refresh(false);
|
|
140
|
+
setStatus(`Saved ${trimmed}`);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
setStatus(error instanceof Error ? error.message : String(error));
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const load = async (entryKey: string) => {
|
|
147
|
+
setStatus(`Loading ${entryKey}...`);
|
|
148
|
+
setPendingKey(entryKey);
|
|
149
|
+
try {
|
|
150
|
+
await api('/api/antigravity/load', { key: entryKey });
|
|
151
|
+
await refresh(false);
|
|
152
|
+
setStatus(`Loaded ${entryKey}`);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
setStatus(error instanceof Error ? error.message : String(error));
|
|
155
|
+
} finally {
|
|
156
|
+
setPendingKey('');
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const refreshOne = async (entryKey: string) => {
|
|
161
|
+
setStatus(`Refreshing ${entryKey}...`);
|
|
162
|
+
setPendingKey(entryKey);
|
|
163
|
+
try {
|
|
164
|
+
setState(await api<AntigravityState>('/api/antigravity/limits/refresh', { key: entryKey }));
|
|
165
|
+
setStatus(`Refreshed ${entryKey}`);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
setStatus(error instanceof Error ? error.message : String(error));
|
|
168
|
+
} finally {
|
|
169
|
+
setPendingKey('');
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const clear = async () => {
|
|
174
|
+
if (!confirm('Clear the live Antigravity keychain item and local auth state?')) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
setStatus('Clearing...');
|
|
178
|
+
try {
|
|
179
|
+
await api('/api/antigravity/clear', {});
|
|
180
|
+
await refresh(false);
|
|
181
|
+
setStatus('Cleared live Antigravity auth state');
|
|
182
|
+
} catch (error) {
|
|
183
|
+
setStatus(error instanceof Error ? error.message : String(error));
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
if (!active || loaded) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
refresh(false).catch((error) => {
|
|
192
|
+
setStatus(error.message);
|
|
193
|
+
});
|
|
194
|
+
}, [active, loaded]);
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<div hidden={!active}>
|
|
198
|
+
<div class="toolbar">
|
|
199
|
+
<div class="muted small">{state ? `${state.service}/${state.account} · ${state.vaultPath}` : ''}</div>
|
|
200
|
+
<button type="button" onClick={() => refresh(true).catch((error) => setStatus(error.message))}>
|
|
201
|
+
Refresh limits
|
|
202
|
+
</button>
|
|
203
|
+
</div>
|
|
204
|
+
<section class="panel">
|
|
205
|
+
<form onSubmit={save}>
|
|
206
|
+
<input
|
|
207
|
+
value={key}
|
|
208
|
+
placeholder="Account label"
|
|
209
|
+
autocomplete="off"
|
|
210
|
+
onInput={(event) => setKey(event.currentTarget.value)}
|
|
211
|
+
/>
|
|
212
|
+
<button class="primary" type="submit">
|
|
213
|
+
Save current
|
|
214
|
+
</button>
|
|
215
|
+
<button type="button" onClick={() => clear().catch((error) => setStatus(error.message))}>
|
|
216
|
+
Clear live
|
|
217
|
+
</button>
|
|
218
|
+
</form>
|
|
219
|
+
<div id="status" class="status muted">
|
|
220
|
+
{status}
|
|
221
|
+
</div>
|
|
222
|
+
</section>
|
|
223
|
+
<section class="list">
|
|
224
|
+
{state?.entries.length ? (
|
|
225
|
+
state.entries.map((entry) => (
|
|
226
|
+
<AccountRow
|
|
227
|
+
key={entry.key}
|
|
228
|
+
entry={entry}
|
|
229
|
+
pending={pendingKey === entry.key}
|
|
230
|
+
onLoad={load}
|
|
231
|
+
onRefresh={refreshOne}
|
|
232
|
+
/>
|
|
233
|
+
))
|
|
234
|
+
) : (
|
|
235
|
+
<div class="muted">No saved accounts yet.</div>
|
|
236
|
+
)}
|
|
237
|
+
</section>
|
|
238
|
+
</div>
|
|
239
|
+
);
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const CodexPanel = ({ active }: { active: boolean }) => {
|
|
243
|
+
const [state, setState] = useState<CodexState | null>(null);
|
|
244
|
+
const [status, setStatus] = useState('');
|
|
245
|
+
const [key, setKey] = useState('');
|
|
246
|
+
const [loaded, setLoaded] = useState(false);
|
|
247
|
+
const [pendingKey, setPendingKey] = useState('');
|
|
248
|
+
|
|
249
|
+
const refresh = async (forceLimits = false) => {
|
|
250
|
+
setStatus(forceLimits ? 'Refreshing limits...' : 'Loading accounts...');
|
|
251
|
+
setState(
|
|
252
|
+
await api<CodexState>(
|
|
253
|
+
forceLimits ? '/api/codex/limits/refresh' : '/api/codex/state',
|
|
254
|
+
forceLimits ? {} : undefined,
|
|
255
|
+
),
|
|
256
|
+
);
|
|
257
|
+
setLoaded(true);
|
|
258
|
+
setStatus('');
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const save = async (event: Event) => {
|
|
262
|
+
event.preventDefault();
|
|
263
|
+
const trimmed = key.trim();
|
|
264
|
+
if (!trimmed) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
setStatus('Saving...');
|
|
268
|
+
try {
|
|
269
|
+
await api('/api/codex/save', { key: trimmed });
|
|
270
|
+
setKey('');
|
|
271
|
+
await refresh(false);
|
|
272
|
+
setStatus(`Saved ${trimmed}`);
|
|
273
|
+
} catch (error) {
|
|
274
|
+
setStatus(error instanceof Error ? error.message : String(error));
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const load = async (entryKey: string) => {
|
|
279
|
+
setStatus(`Loading ${entryKey}...`);
|
|
280
|
+
setPendingKey(entryKey);
|
|
281
|
+
try {
|
|
282
|
+
await api('/api/codex/load', { key: entryKey });
|
|
283
|
+
await refresh(false);
|
|
284
|
+
setStatus(`Loaded ${entryKey}`);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
setStatus(error instanceof Error ? error.message : String(error));
|
|
287
|
+
} finally {
|
|
288
|
+
setPendingKey('');
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const refreshOne = async (entryKey: string) => {
|
|
293
|
+
setStatus(`Refreshing ${entryKey}...`);
|
|
294
|
+
setPendingKey(entryKey);
|
|
295
|
+
try {
|
|
296
|
+
setState(await api<CodexState>('/api/codex/limits/refresh', { key: entryKey }));
|
|
297
|
+
setStatus(`Refreshed ${entryKey}`);
|
|
298
|
+
} catch (error) {
|
|
299
|
+
setStatus(error instanceof Error ? error.message : String(error));
|
|
300
|
+
} finally {
|
|
301
|
+
setPendingKey('');
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
useEffect(() => {
|
|
306
|
+
if (!active || loaded) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
refresh(false).catch((error) => {
|
|
310
|
+
setStatus(error.message);
|
|
311
|
+
});
|
|
312
|
+
}, [active, loaded]);
|
|
313
|
+
|
|
314
|
+
return (
|
|
315
|
+
<div hidden={!active}>
|
|
316
|
+
<div class="toolbar">
|
|
317
|
+
<div class="muted small">{state ? `${state.authPath} · ${state.vaultPath}` : ''}</div>
|
|
318
|
+
<button type="button" onClick={() => refresh(true).catch((error) => setStatus(error.message))}>
|
|
319
|
+
Refresh limits
|
|
320
|
+
</button>
|
|
321
|
+
</div>
|
|
322
|
+
<section class="panel">
|
|
323
|
+
<form onSubmit={save}>
|
|
324
|
+
<input
|
|
325
|
+
value={key}
|
|
326
|
+
placeholder="Account label"
|
|
327
|
+
autocomplete="off"
|
|
328
|
+
onInput={(event) => setKey(event.currentTarget.value)}
|
|
329
|
+
/>
|
|
330
|
+
<button class="primary" type="submit">
|
|
331
|
+
Save current
|
|
332
|
+
</button>
|
|
333
|
+
</form>
|
|
334
|
+
<div class="status muted">{status}</div>
|
|
335
|
+
</section>
|
|
336
|
+
<section class="list">
|
|
337
|
+
{state?.entries.length ? (
|
|
338
|
+
state.entries.map((entry) => (
|
|
339
|
+
<AccountRow
|
|
340
|
+
key={entry.key}
|
|
341
|
+
entry={entry}
|
|
342
|
+
pending={pendingKey === entry.key}
|
|
343
|
+
onLoad={load}
|
|
344
|
+
onRefresh={refreshOne}
|
|
345
|
+
/>
|
|
346
|
+
))
|
|
347
|
+
) : (
|
|
348
|
+
<div class="muted">No saved accounts yet.</div>
|
|
349
|
+
)}
|
|
350
|
+
</section>
|
|
351
|
+
</div>
|
|
352
|
+
);
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const App = () => {
|
|
356
|
+
const [tab, setTab] = useState<Tab>('antigravity');
|
|
357
|
+
|
|
358
|
+
return (
|
|
359
|
+
<main>
|
|
360
|
+
<div class="top">
|
|
361
|
+
<div class="brand">
|
|
362
|
+
<img src="/icon.svg" alt="" />
|
|
363
|
+
<h1>Dondo</h1>
|
|
364
|
+
</div>
|
|
365
|
+
</div>
|
|
366
|
+
<nav class="tabs" aria-label="Platforms">
|
|
367
|
+
<button
|
|
368
|
+
type="button"
|
|
369
|
+
class={tab === 'antigravity' ? 'tab active' : 'tab'}
|
|
370
|
+
onClick={() => setTab('antigravity')}
|
|
371
|
+
>
|
|
372
|
+
Antigravity
|
|
373
|
+
</button>
|
|
374
|
+
<button type="button" class={tab === 'codex' ? 'tab active' : 'tab'} onClick={() => setTab('codex')}>
|
|
375
|
+
Codex
|
|
376
|
+
</button>
|
|
377
|
+
</nav>
|
|
378
|
+
<AntigravityPanel active={tab === 'antigravity'} />
|
|
379
|
+
<CodexPanel active={tab === 'codex'} />
|
|
380
|
+
<footer class="footer">
|
|
381
|
+
<a href={packageJson.homepage} target="_blank" rel="noreferrer">
|
|
382
|
+
GitHub
|
|
383
|
+
</a>
|
|
384
|
+
</footer>
|
|
385
|
+
</main>
|
|
386
|
+
);
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const root = document.getElementById('app');
|
|
390
|
+
if (root) {
|
|
391
|
+
render(<App />, root);
|
|
392
|
+
}
|
package/src/ui/html.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const renderHtml = () => `<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Dondo</title>
|
|
7
|
+
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
|
8
|
+
<link rel="apple-touch-icon" href="/icon.png" />
|
|
9
|
+
<link rel="stylesheet" href="/assets/styles.css" />
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div id="app"></div>
|
|
13
|
+
<script type="module" src="/assets/app.js"></script>
|
|
14
|
+
</body>
|
|
15
|
+
</html>`;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
* {
|
|
2
|
+
box-sizing: border-box;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
body {
|
|
6
|
+
margin: 0;
|
|
7
|
+
background: #f7f8fa;
|
|
8
|
+
color: #1f2937;
|
|
9
|
+
font:
|
|
10
|
+
14px / 1.45 ui-sans-serif,
|
|
11
|
+
system-ui,
|
|
12
|
+
-apple-system,
|
|
13
|
+
BlinkMacSystemFont,
|
|
14
|
+
"Segoe UI",
|
|
15
|
+
sans-serif;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
main {
|
|
19
|
+
max-width: 960px;
|
|
20
|
+
margin: 38px auto;
|
|
21
|
+
padding: 0 20px;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
h1 {
|
|
25
|
+
margin: 0;
|
|
26
|
+
font-size: 24px;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
button,
|
|
30
|
+
input {
|
|
31
|
+
font: inherit;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
button {
|
|
35
|
+
cursor: pointer;
|
|
36
|
+
border: 1px solid #d1d5db;
|
|
37
|
+
border-radius: 6px;
|
|
38
|
+
background: #fff;
|
|
39
|
+
padding: 9px 12px;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
button:hover {
|
|
43
|
+
filter: brightness(0.98);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
button:disabled {
|
|
47
|
+
cursor: default;
|
|
48
|
+
opacity: 0.55;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
input {
|
|
52
|
+
flex: 1;
|
|
53
|
+
border: 1px solid #d1d5db;
|
|
54
|
+
border-radius: 6px;
|
|
55
|
+
padding: 10px 12px;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
form,
|
|
59
|
+
.actions,
|
|
60
|
+
.tabs {
|
|
61
|
+
display: flex;
|
|
62
|
+
gap: 10px;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.top {
|
|
66
|
+
display: flex;
|
|
67
|
+
align-items: center;
|
|
68
|
+
justify-content: space-between;
|
|
69
|
+
gap: 16px;
|
|
70
|
+
margin-bottom: 18px;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.brand {
|
|
74
|
+
display: flex;
|
|
75
|
+
align-items: center;
|
|
76
|
+
gap: 10px;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.brand img {
|
|
80
|
+
width: 32px;
|
|
81
|
+
height: 32px;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.tabs {
|
|
85
|
+
margin-bottom: 16px;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.tab {
|
|
89
|
+
color: #374151;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.tab.active,
|
|
93
|
+
button.primary {
|
|
94
|
+
border-color: #111827;
|
|
95
|
+
background: #111827;
|
|
96
|
+
color: #fff;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.toolbar {
|
|
100
|
+
display: flex;
|
|
101
|
+
align-items: center;
|
|
102
|
+
justify-content: space-between;
|
|
103
|
+
gap: 12px;
|
|
104
|
+
margin-bottom: 12px;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.muted {
|
|
108
|
+
color: #6b7280;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.panel,
|
|
112
|
+
.row {
|
|
113
|
+
border: 1px solid #e5e7eb;
|
|
114
|
+
border-radius: 8px;
|
|
115
|
+
background: #fff;
|
|
116
|
+
box-shadow: 0 1px 2px #00000008;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.panel {
|
|
120
|
+
margin-bottom: 16px;
|
|
121
|
+
padding: 16px;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.list {
|
|
125
|
+
display: grid;
|
|
126
|
+
gap: 10px;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.row {
|
|
130
|
+
padding: 14px;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.row-head {
|
|
134
|
+
display: flex;
|
|
135
|
+
align-items: center;
|
|
136
|
+
justify-content: space-between;
|
|
137
|
+
gap: 12px;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.key {
|
|
141
|
+
font-weight: 650;
|
|
142
|
+
font-size: 16px;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.keyline {
|
|
146
|
+
display: flex;
|
|
147
|
+
align-items: center;
|
|
148
|
+
gap: 8px;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.badge {
|
|
152
|
+
border-radius: 999px;
|
|
153
|
+
background: #e8f5ee;
|
|
154
|
+
color: #166534;
|
|
155
|
+
padding: 2px 7px;
|
|
156
|
+
font-size: 11px;
|
|
157
|
+
font-weight: 650;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.quota {
|
|
161
|
+
display: grid;
|
|
162
|
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
163
|
+
gap: 8px;
|
|
164
|
+
margin-top: 12px;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.model {
|
|
168
|
+
border: 1px solid #eef0f3;
|
|
169
|
+
border-radius: 6px;
|
|
170
|
+
padding: 9px;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.bar {
|
|
174
|
+
overflow: hidden;
|
|
175
|
+
height: 6px;
|
|
176
|
+
margin-top: 7px;
|
|
177
|
+
border-radius: 999px;
|
|
178
|
+
background: #eef0f3;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.fill {
|
|
182
|
+
height: 100%;
|
|
183
|
+
background: #2563eb;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.err {
|
|
187
|
+
color: #b91c1c;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.status {
|
|
191
|
+
min-height: 20px;
|
|
192
|
+
margin-top: 10px;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.footer {
|
|
196
|
+
margin-top: 22px;
|
|
197
|
+
padding-top: 16px;
|
|
198
|
+
border-top: 1px solid #e5e7eb;
|
|
199
|
+
text-align: right;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.footer a {
|
|
203
|
+
color: #374151;
|
|
204
|
+
text-decoration: none;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.footer a:hover {
|
|
208
|
+
text-decoration: underline;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.small {
|
|
212
|
+
font-size: 12px;
|
|
213
|
+
}
|