lvwerra HF Staff commited on
Commit
545eab2
·
verified ·
1 Parent(s): a69a644

Install page v2 (plain title, ready-note, visible code panel) + public-bucket lock gate; OpenClaw onboard-then-chat; Settings agents as rows + manual update path

Browse files
server/src/index.js CHANGED
@@ -159,6 +159,8 @@ app.get('/api/info', (_req, res) => res.json({
159
  spaceHost: process.env.SPACE_HOST || null,
160
  tmux: USE_TMUX,
161
  locked: isPublic(),
 
 
162
  canRelaunch: !!(process.env.SPACE_ID && hfToken()),
163
  // While public, /api/info stays reachable (the Locked page needs it) — don't
164
  // advertise which credentials exist to the whole internet.
 
159
  spaceHost: process.env.SPACE_HOST || null,
160
  tmux: USE_TMUX,
161
  locked: isPublic(),
162
+ lockReason: visibility().reason,
163
+ lockBucket: visibility().bucket,
164
  canRelaunch: !!(process.env.SPACE_ID && hfToken()),
165
  // While public, /api/info stays reachable (the Locked page needs it) — don't
166
  // advertise which credentials exist to the whole internet.
server/src/runner.js CHANGED
@@ -208,6 +208,14 @@ function commandFor(session) {
208
  return `if ${hasTranscript}; then exec claude --resume ${session.sessionUuid}; else ${fresh}; fi`;
209
  }
210
 
 
 
 
 
 
 
 
 
211
  // Codex: resume this agent's pinned conversation (captured from its rollout
212
  // file after launch — see scheduleCodexCapture). Existence-checked like
213
  // Claude, so a purged rollout starts fresh honestly and a crash ends the
 
208
  return `if ${hasTranscript}; then exec claude --resume ${session.sessionUuid}; else ${fresh}; fi`;
209
  }
210
 
211
+ // OpenClaw: first run needs its onboarding wizard (keys, workspace); once the
212
+ // config exists, go straight to the TUI. Decided by config-file existence —
213
+ // same honest pattern as the Claude transcript check.
214
+ if (cli.id === 'openclaw') {
215
+ const cfg = '"${OPENCLAW_CONFIG_PATH:-$HOME/.openclaw/openclaw.json}"';
216
+ return `if [ -s ${cfg} ]; then exec openclaw chat; else openclaw onboard && exec openclaw chat; fi`;
217
+ }
218
+
219
  // Codex: resume this agent's pinned conversation (captured from its rollout
220
  // file after launch — see scheduleCodexCapture). Existence-checked like
221
  // Claude, so a purged rollout starts fresh honestly and a crash ends the
server/src/visibility.js CHANGED
@@ -1,36 +1,78 @@
1
  // Self-visibility check. This app has no authentication, so it must only run a
2
- // usable terminal backend when the Space is PRIVATE. We detect visibility from
3
- // the public HF API: an unauthenticated GET of a *public* space returns 200 with
4
- // `private:false`; a *private* space returns 401/404 (we can't see it without a
5
- // token). No token required.
6
  //
7
- // When public, the server locks down (see index.js) and the UI shows a setup
8
- // widget instead. Re-checked periodically so flipping the Space to Private
9
- // unlocks it within a minute no rebuild needed.
 
 
 
 
 
 
 
10
 
11
  const SPACE_ID = process.env.SPACE_ID || null;
12
- let state = { spaceId: SPACE_ID, public: false, known: false, checkedAt: 0 };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
  async function check() {
15
  // No SPACE_ID → local dev or non-Space host: never lock.
16
  if (!SPACE_ID) {
17
- state = { spaceId: null, public: false, known: true, checkedAt: Date.now() };
18
  return state;
19
  }
20
  try {
21
- const r = await fetch(`https://huggingface.co/api/spaces/${SPACE_ID}`, {
22
- headers: { 'user-agent': 'agent-manager' },
23
- });
24
  if (r.ok) {
25
  const j = await r.json();
26
- state = { spaceId: SPACE_ID, public: j.private === false, known: true, checkedAt: Date.now() };
27
- } else {
28
- // Not publicly visible (401/404) → it's private → safe to run.
29
- state = { spaceId: SPACE_ID, public: false, known: true, checkedAt: Date.now() };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  }
 
31
  } catch {
32
- // Network blip: keep the last known verdict rather than flapping. Initial
33
- // state is not-public, so a private Space is never wrongly locked.
34
  state = { ...state, checkedAt: Date.now() };
35
  }
36
  return state;
 
1
  // Self-visibility check. This app has no authentication, so it must only run a
2
+ // usable terminal backend when BOTH the Space and its mounted bucket(s) are
3
+ // PRIVATE a public bucket exposes everything agents saved (including login
4
+ // tokens), which is exactly as bad as a public Space.
 
5
  //
6
+ // Space check: unauthenticated GET of a *public* space returns 200 with
7
+ // `private:false`; a *private* space returns 401/404. No token required.
8
+ // Bucket check: same pattern via /api/buckets/{id}. The bucket ids are
9
+ // discovered once from the Space's own metadata (runtime.volumes), which for a
10
+ // private Space requires the HF_TOKEN the relaunch feature already uses —
11
+ // without a token we can't discover volumes and skip the bucket gate.
12
+ //
13
+ // When locked, the server blocks every working API (see index.js) and the UI
14
+ // shows the setup/warning page. Re-checked periodically so fixing visibility
15
+ // unlocks within a minute — no rebuild needed.
16
 
17
  const SPACE_ID = process.env.SPACE_ID || null;
18
+ const hfToken = () => process.env.HF_TOKEN || process.env.HUGGING_FACE_HUB_TOKEN || process.env.HF_API_TOKEN || null;
19
+
20
+ let state = { spaceId: SPACE_ID, public: false, known: false, checkedAt: 0, reason: null, bucket: null, buckets: [] };
21
+ let volumes = null; // bucket ids mounted on this Space; discovered once (fixed until restart)
22
+
23
+ const HEADERS = { 'user-agent': 'agent-manager' };
24
+
25
+ async function discoverBuckets() {
26
+ if (volumes !== null) return volumes;
27
+ const token = hfToken();
28
+ if (!token) { volumes = []; return volumes; } // can't inspect a private Space without a token
29
+ try {
30
+ const r = await fetch(`https://huggingface.co/api/spaces/${SPACE_ID}`, {
31
+ headers: { ...HEADERS, authorization: `Bearer ${token}` },
32
+ });
33
+ if (!r.ok) return null; // transient — retry next cycle
34
+ const j = await r.json();
35
+ volumes = ((j.runtime && j.runtime.volumes) || [])
36
+ .filter((v) => v && v.type === 'bucket' && v.source)
37
+ .map((v) => v.source);
38
+ } catch { return null; }
39
+ return volumes;
40
+ }
41
 
42
  async function check() {
43
  // No SPACE_ID → local dev or non-Space host: never lock.
44
  if (!SPACE_ID) {
45
+ state = { spaceId: null, public: false, known: true, checkedAt: Date.now(), reason: null, bucket: null, buckets: [] };
46
  return state;
47
  }
48
  try {
49
+ const r = await fetch(`https://huggingface.co/api/spaces/${SPACE_ID}`, { headers: HEADERS });
 
 
50
  if (r.ok) {
51
  const j = await r.json();
52
+ if (j.private === false) {
53
+ state = { spaceId: SPACE_ID, public: true, known: true, checkedAt: Date.now(), reason: 'public-space', bucket: null, buckets: state.buckets };
54
+ return state;
55
+ }
56
+ }
57
+ // Not publicly visible (401/404) → the Space is private. Now the bucket(s).
58
+ const buckets = await discoverBuckets();
59
+ if (buckets === null) { state = { ...state, checkedAt: Date.now() }; return state; } // keep last verdict
60
+ for (const id of buckets) {
61
+ try {
62
+ const b = await fetch(`https://huggingface.co/api/buckets/${id}`, { headers: HEADERS });
63
+ if (b.ok) {
64
+ const bj = await b.json();
65
+ if (bj.private === false) {
66
+ state = { spaceId: SPACE_ID, public: true, known: true, checkedAt: Date.now(), reason: 'public-bucket', bucket: id, buckets };
67
+ return state;
68
+ }
69
+ }
70
+ // 401/404 → bucket is private → safe.
71
+ } catch { state = { ...state, checkedAt: Date.now() }; return state; } // blip: keep last verdict
72
  }
73
+ state = { spaceId: SPACE_ID, public: false, known: true, checkedAt: Date.now(), reason: null, bucket: null, buckets };
74
  } catch {
75
+ // Network blip: keep the last known verdict rather than flapping.
 
76
  state = { ...state, checkedAt: Date.now() };
77
  }
78
  return state;
web/src/App.tsx CHANGED
@@ -324,7 +324,7 @@ export default function App() {
324
  );
325
  };
326
 
327
- if (info?.locked) return <Locked spaceId={info.spaceId} />;
328
 
329
  if (settingsOpen) {
330
  return (
 
324
  );
325
  };
326
 
327
+ if (info?.locked) return <Locked spaceId={info.spaceId} reason={info.lockReason} bucket={info.lockBucket} />;
328
 
329
  if (settingsOpen) {
330
  return (
web/src/components/Locked.tsx CHANGED
@@ -1,5 +1,5 @@
1
  import { useState } from 'react';
2
- import { LockGlyph, SlidersGlyph, SunGlyph, PulseGlyph } from './icons';
3
  import Logo from './Logo';
4
 
5
  // A frozen, slightly dimmed replica of the real sidebar so the install page
@@ -73,9 +73,14 @@ function MockSidebar() {
73
  );
74
  }
75
 
76
- // Shown when the server reports the Space is public: the terminal backend is
77
- // disabled server-side and this page explains how to run a private copy.
78
- export default function Locked({ spaceId }: { spaceId?: string | null }) {
 
 
 
 
 
79
  const id = spaceId || 'owner/space-name';
80
  const cmd = `from huggingface_hub import HfApi, Volume, create_bucket
81
 
@@ -102,20 +107,44 @@ api.duplicate_repo(
102
  navigator.clipboard?.writeText(cmd).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); }).catch(() => {});
103
  };
104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  return (
106
  <div className="app locked-app">
107
  <MockSidebar />
108
  <div className="main locked-main">
109
  <div className="install">
110
- <div className="install-head">
111
- <span className="install-lock"><LockGlyph /></span>
112
- <div>
113
- <h1>Your own Agent Manager, in two steps</h1>
114
- <p className="locked-lead">
115
- A private cloud workspace for Claude Code, Codex, Gemini CLI and friends —
116
- dispatch agents from anywhere, they keep working when you close the tab.
117
- </p>
118
- </div>
119
  </div>
120
  <p className="locked-sub">
121
  This copy is <b>public</b>, so its terminals are disabled: the app has no login of its
@@ -140,10 +169,15 @@ api.duplicate_repo(
140
  agents' work, logins and history survive rebuilds and sleep.
141
  </p>
142
  <img src="/install/mount-bucket.png" alt="The Mount a bucket dialog: private bucket, mount path /data, read & write" />
 
143
  </div>
144
 
145
- <details className="install-code">
146
- <summary>Prefer code? Duplicate + bucket in one script</summary>
 
 
 
 
147
  <div className="locked-cmd">
148
  <pre><code>{cmd}</code></pre>
149
  <button className="locked-copy" onClick={copy} aria-label="Copy setup code" title={copied ? 'Copied' : 'Copy'}>
 
1
  import { useState } from 'react';
2
+ import { SlidersGlyph, SunGlyph, PulseGlyph } from './icons';
3
  import Logo from './Logo';
4
 
5
  // A frozen, slightly dimmed replica of the real sidebar so the install page
 
73
  );
74
  }
75
 
76
+ // Shown when the server locks itself: either the Space is public (visitors get
77
+ // the install guide) or the owner's bucket is public (they get a warning — a
78
+ // public bucket exposes everything the agents saved, credentials included).
79
+ export default function Locked({ spaceId, reason, bucket }: {
80
+ spaceId?: string | null;
81
+ reason?: string | null;
82
+ bucket?: string | null;
83
+ }) {
84
  const id = spaceId || 'owner/space-name';
85
  const cmd = `from huggingface_hub import HfApi, Volume, create_bucket
86
 
 
107
  navigator.clipboard?.writeText(cmd).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); }).catch(() => {});
108
  };
109
 
110
+ if (reason === 'public-bucket') {
111
+ return (
112
+ <div className="app locked-app">
113
+ <MockSidebar />
114
+ <div className="main locked-main">
115
+ <div className="install">
116
+ <h1>Your storage bucket is public</h1>
117
+ <p className="locked-lead">
118
+ The bucket mounted at <span className="mono">/data</span>
119
+ {bucket ? <> (<span className="mono">{bucket}</span>)</> : null} is <b>public</b> —
120
+ everything your agents saved is readable by anyone, including credentials and
121
+ shell history. The terminals stay disabled until it's private.
122
+ </p>
123
+ <div className="step">
124
+ <div className="step-head"><span className="step-n mono">!</span><h3>Make the bucket private</h3></div>
125
+ <p className="locked-sub">
126
+ Open <b>{bucket ? <a href={`https://huggingface.co/buckets/${bucket}/settings`} target="_blank" rel="noreferrer">the bucket's settings</a> : 'the bucket’s settings on Hugging Face'}</b> and
127
+ switch its visibility to <b>Private</b>. This page unlocks automatically within a minute —
128
+ and consider rotating any credentials that were stored while it was public.
129
+ </p>
130
+ </div>
131
+ </div>
132
+ </div>
133
+ </div>
134
+ );
135
+ }
136
+
137
  return (
138
  <div className="app locked-app">
139
  <MockSidebar />
140
  <div className="main locked-main">
141
  <div className="install">
142
+ <div>
143
+ <h1>Set up the Agent Manager in two steps</h1>
144
+ <p className="locked-lead">
145
+ A private cloud workspace for Claude Code, Codex, Gemini CLI and friends —
146
+ dispatch agents from anywhere, they keep working when you close the tab.
147
+ </p>
 
 
 
148
  </div>
149
  <p className="locked-sub">
150
  This copy is <b>public</b>, so its terminals are disabled: the app has no login of its
 
169
  agents' work, logins and history survive rebuilds and sleep.
170
  </p>
171
  <img src="/install/mount-bucket.png" alt="The Mount a bucket dialog: private bucket, mount path /data, read & write" />
172
+ <p className="install-done">That's it — the Space restarts, unlocks itself, and you're ready to go. Open it and spawn your first agent.</p>
173
  </div>
174
 
175
+ <details className="install-code step">
176
+ <summary className="step-head">
177
+ <span className="step-n mono">&gt;_</span>
178
+ <h3>Prefer code? Both steps in one script</h3>
179
+ <span className="install-code-caret">▸</span>
180
+ </summary>
181
  <div className="locked-cmd">
182
  <pre><code>{cmd}</code></pre>
183
  <button className="locked-copy" onClick={copy} aria-label="Copy setup code" title={copied ? 'Copied' : 'Copy'}>
web/src/components/SettingsView.tsx CHANGED
@@ -4,6 +4,7 @@ import * as api from '../api';
4
  import SkillsEditor from './SkillsEditor';
5
  import UsagePanel from './UsagePanel';
6
  import { SunGlyph, MoonGlyph, RefreshGlyph } from './icons';
 
7
 
8
  type Page = 'general' | 'usage' | 'skills';
9
  const PAGES: { id: Page; label: string }[] = [
@@ -74,20 +75,22 @@ export default function SettingsView({
74
 
75
  <h3>Agents</h3>
76
  <div className="s-help">A coloured dot means the agent is configured and ready. Log in once inside a session (or set a key as a Space secret) — credentials persist on the bucket across restarts and all sessions.</div>
77
- <div className="agent-grid">
78
  {clis.filter((c) => c.id !== 'shell' && c.id !== 'files').map((c) => {
79
  const ready = c.available && c.ready;
80
  return (
81
- <div key={c.id} className="agent-chip">
82
  <span
83
  className="status"
84
  style={ready
85
  ? { background: c.color }
86
  : { background: 'var(--muted)', opacity: 0.4 }}
87
  />
88
- <span>{c.label}</span>
89
- {c.available && c.version && <span className="s-muted mono chip-ver">v{c.version}</span>}
90
- <span className="s-muted chip-state">{!c.available ? 'unavailable' : ready ? 'ready' : 'needs setup'}</span>
 
 
91
  </div>
92
  );
93
  })}
@@ -98,8 +101,14 @@ export default function SettingsView({
98
  <div className="s-label">Update CLIs</div>
99
  <div className="s-help">
100
  Factory-reboots the Space to reinstall every CLI at its latest version and relaunch. Sessions survive on the bucket.
101
- {!info?.canRelaunch && ' Needs a write-scoped HF_TOKEN set as a Space secret.'}
102
  </div>
 
 
 
 
 
 
 
103
  </div>
104
  {!info?.canRelaunch ? (
105
  <button className="btn-ghost" disabled title="Add a write-scoped HF_TOKEN secret to the Space to enable"><RefreshGlyph /> Relaunch &amp; update</button>
 
4
  import SkillsEditor from './SkillsEditor';
5
  import UsagePanel from './UsagePanel';
6
  import { SunGlyph, MoonGlyph, RefreshGlyph } from './icons';
7
+ import Logo from './Logo';
8
 
9
  type Page = 'general' | 'usage' | 'skills';
10
  const PAGES: { id: Page; label: string }[] = [
 
75
 
76
  <h3>Agents</h3>
77
  <div className="s-help">A coloured dot means the agent is configured and ready. Log in once inside a session (or set a key as a Space secret) — credentials persist on the bucket across restarts and all sessions.</div>
78
+ <div className="agent-rows">
79
  {clis.filter((c) => c.id !== 'shell' && c.id !== 'files').map((c) => {
80
  const ready = c.available && c.ready;
81
  return (
82
+ <div key={c.id} className="agent-row">
83
  <span
84
  className="status"
85
  style={ready
86
  ? { background: c.color }
87
  : { background: 'var(--muted)', opacity: 0.4 }}
88
  />
89
+ <Logo cli={c.id} size={14} tint={c.color} />
90
+ <span className="ar-name">{c.label}</span>
91
+ <span className="spacer" />
92
+ {c.available && c.version && <span className="ar-ver mono">v{c.version}</span>}
93
+ <span className={`ar-state${ready ? ' ok' : ''}`}>{!c.available ? 'unavailable' : ready ? 'ready' : 'needs setup'}</span>
94
  </div>
95
  );
96
  })}
 
101
  <div className="s-label">Update CLIs</div>
102
  <div className="s-help">
103
  Factory-reboots the Space to reinstall every CLI at its latest version and relaunch. Sessions survive on the bucket.
 
104
  </div>
105
+ {!info?.canRelaunch && (
106
+ <div className="s-help" style={{ marginTop: 6 }}>
107
+ This button needs a write-scoped <span className="mono">HF_TOKEN</span> Space secret. Without one,
108
+ update manually: open your Space's <b>Settings</b> tab on Hugging Face and press{' '}
109
+ <b>Factory reboot</b> — same effect.
110
+ </div>
111
+ )}
112
  </div>
113
  {!info?.canRelaunch ? (
114
  <button className="btn-ghost" disabled title="Add a write-scoped HF_TOKEN secret to the Space to enable"><RefreshGlyph /> Relaunch &amp; update</button>
web/src/styles.css CHANGED
@@ -342,9 +342,14 @@ body {
342
  .step-head h3 { margin: 0; font-size: 14px; }
343
  .step-n { flex: none; width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--border)); color: var(--accent); border-radius: 50%; font-size: 11.5px; font-weight: 600; }
344
  .step img { width: 100%; max-width: 480px; border: 1px solid var(--border); border-radius: var(--r-lg); display: block; }
345
- .install-code summary { cursor: pointer; color: var(--muted); font-size: 13px; padding: 2px 0; }
346
- .install-code summary:hover { color: var(--text); }
347
- .install-code[open] summary { margin-bottom: 8px; }
 
 
 
 
 
348
  .install-relock { border-top: 1px solid var(--border); padding-top: 14px; }
349
  .locked-cmd { position: relative; margin-top: 8px; }
350
  .locked-cmd pre { margin: 0; overflow-x: hidden; white-space: pre-wrap; overflow-wrap: anywhere; background: var(--panel-2); border: 1px solid var(--border); border-radius: var(--r-md); padding: 42px 14px 14px; font-size: 12px; line-height: 1.5; }
@@ -452,11 +457,15 @@ body {
452
  .s-label { font-weight: 600; font-size: 14px; }
453
  .s-help { color: var(--muted); font-size: 12.5px; line-height: 1.5; margin: 2px 0 0; }
454
  .s-muted { color: var(--muted); }
455
- .agent-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 8px; margin-top: 8px; }
456
- .agent-chip { display: flex; align-items: center; gap: 8px; padding: 10px 12px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-lg); font-size: 13px; }
457
- .agent-chip .s-muted { font-size: 11px; }
458
- .agent-chip .chip-ver { margin-left: 6px; opacity: 0.8; }
459
- .agent-chip .chip-state { margin-left: auto; }
 
 
 
 
460
  .kv { display: flex; flex-direction: column; background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-xl); overflow: hidden; margin-top: 8px; }
461
  .kv > div { display: flex; justify-content: space-between; gap: 16px; padding: 11px 16px; border-bottom: 1px solid var(--border); font-size: 13px; }
462
  .kv > div:last-child { border-bottom: none; }
 
342
  .step-head h3 { margin: 0; font-size: 14px; }
343
  .step-n { flex: none; width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--border)); color: var(--accent); border-radius: 50%; font-size: 11.5px; font-weight: 600; }
344
  .step img { width: 100%; max-width: 480px; border: 1px solid var(--border); border-radius: var(--r-lg); display: block; }
345
+ .install-done { margin: 2px 0 0; font-size: 13px; line-height: 1.5; color: var(--go); font-weight: 500; }
346
+ /* the code path: same panel as the steps, collapsed to its title row */
347
+ .install-code summary { cursor: pointer; list-style: none; }
348
+ .install-code summary::-webkit-details-marker { display: none; }
349
+ .install-code summary .step-n { border-radius: var(--r-sm); font-size: 9px; letter-spacing: 0.02em; }
350
+ .install-code .install-code-caret { margin-left: auto; color: var(--muted); font-size: 11px; transition: transform 0.15s ease-out; display: inline-block; }
351
+ .install-code[open] .install-code-caret { transform: rotate(90deg); }
352
+ .install-code[open] summary { margin-bottom: 4px; }
353
  .install-relock { border-top: 1px solid var(--border); padding-top: 14px; }
354
  .locked-cmd { position: relative; margin-top: 8px; }
355
  .locked-cmd pre { margin: 0; overflow-x: hidden; white-space: pre-wrap; overflow-wrap: anywhere; background: var(--panel-2); border: 1px solid var(--border); border-radius: var(--r-md); padding: 42px 14px 14px; font-size: 12px; line-height: 1.5; }
 
457
  .s-label { font-weight: 600; font-size: 14px; }
458
  .s-help { color: var(--muted); font-size: 12.5px; line-height: 1.5; margin: 2px 0 0; }
459
  .s-muted { color: var(--muted); }
460
+ /* agents: one hairline row per CLI dot · logo · name · version · state */
461
+ .agent-rows { display: flex; flex-direction: column; background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-lg); margin-top: 8px; overflow: hidden; }
462
+ .agent-row { display: flex; align-items: center; gap: 10px; padding: 9px 14px; font-size: 13px; }
463
+ .agent-row + .agent-row { border-top: 1px solid var(--border); }
464
+ .agent-row .spacer { flex: 1; }
465
+ .ar-name { white-space: nowrap; font-weight: 500; }
466
+ .ar-ver { font-size: 11.5px; color: var(--muted); font-variant-numeric: tabular-nums; }
467
+ .ar-state { flex: none; width: 88px; text-align: right; font-size: 11.5px; color: var(--muted); white-space: nowrap; }
468
+ .ar-state.ok { color: var(--go); }
469
  .kv { display: flex; flex-direction: column; background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-xl); overflow: hidden; margin-top: 8px; }
470
  .kv > div { display: flex; justify-content: space-between; gap: 16px; padding: 11px 16px; border-bottom: 1px solid var(--border); font-size: 13px; }
471
  .kv > div:last-child { border-bottom: none; }