Push one modified file to Forgejo repo zgaetano/dragonflight on branch main, then deploy to TrueNAS.

## File to push:
- Read /tmp/wd/wild-dragon/services/web-ui/public/shell.jsx (full content)
- Push to Forgejo with owner=zgaetano, repo=dragonflight, branch=main
- Commit message: "fix(shell): dynamic failed-jobs badge in sidebar nav, replaces hardcoded 3"

## Deploy after push:
Use mcp__claude_ai_mcp-server__ssh_ssh_exec to run:
1. cd /mnt/NVME/MAM/wild-dragon && git pull 2>&1 | tail -5
2. cd /mnt/NVME/MAM/wild-dragon && docker compose up -d --no-build web-ui 2>&1 | tail -5

Report commit SHA and deploy output.


I'll read the file, push it to Forgejo, then deploy. Let me start by loading the required tool schemas and reading the file in parallel.


ToolSearch

Question: select:mcp__claude_ai_mcp-server__forgejo_forgejo_get_file,mcp__claude_ai_mcp-server__forgejo_forgejo_update_file,mcp__claude_ai_mcp-server__ssh_ssh_exec


1	// shell.jsx - app shell: sidebar nav, topbar, route container
2
3	const NAV_TREE = [
4	  { id: "home", label: "Home", icon: "home" },
5	  { id: "library", label: "Library", icon: "library" },
6	  { id: "projects", label: "Projects", icon: "folder" },
7	  {
8	    id: "ingest", label: "Ingest", icon: "upload", group: true,
9	    children: [
10	      { id: "upload", label: "Upload", icon: "upload" },
11	      { id: "recorders", label: "Recorders", icon: "record" },
12	      { id: "schedule", label: "Schedule", icon: "jobs" },
13	      { id: "capture", label: "Capture", icon: "capture" },
14	      { id: "monitors", label: "Monitors", icon: "monitor" },
15	    ],
16	  },
17	  { id: "jobs", label: "Jobs", icon: "jobs" },
18	  { id: "editor", label: "Editor", icon: "editor", badge: { kind: "dev", text: "DEV" } },
19	];
20
21	const ADMIN_TREE = [
22	  { id: "users", label: "Users", icon: "users" },
23	  { id: "tokens", label: "Tokens", icon: "token" },
24	  { id: "containers", label: "Containers", icon: "container" },
25	  { id: "cluster", label: "Cluster", icon: "cluster" },
26	  { id: "settings", label: "Settings", icon: "settings" },
27	];
28
29	const NAV_FLAT = (() => {
30	  const out = [];
31	  const visit = (arr) => arr.forEach(n => {
32	    if (n.group && n.children) { visit(n.children); return; }
33	    out.push({ id: n.id, label: n.label, icon: n.icon });
34	  });
35	  visit(NAV_TREE); visit(ADMIN_TREE);
36	  return out;
37	})();
38
39	function NavItem({ item, active, onSelect, depth = 0, openGroups, toggleGroup }) {
40	  const isGroup = item.group && item.children;
41	  const isOpen = isGroup && openGroups.has(item.id);
42	  const isActive = active === item.id || (isGroup && item.children.some(c => c.id === active));
43
44	  return (
45	    <>
46	      <div
47	        className={`nav-item ${isActive && !isGroup ? "active" : ""} ${isGroup ? "has-children" : ""} ${isOpen ? "open" : ""}`}
48	        onClick={() => {
49	          if (isGroup) toggleGroup(item.id);
50	          else onSelect(item.id);
51	        }}
52	      >
53	        <Icon name={item.icon} size={15} className="nav-icon" />
54	        {item.label}
55	        {item.badge && (
56	          <span className={`nav-badge ${item.badge.kind}`}>{item.badge.text}
57	        )}
58	        {isGroup && <Icon name="chevron" size={12} className="nav-caret" />}
59

60	      {isGroup && isOpen && (
61

62	          {item.children.map(c => (
63	            <div
64	              key={c.id}
65	              className={`nav-item ${active === c.id ? "active" : ""}`}
66	              onClick={() => onSelect(c.id)}
67	            >
68	              <Icon name={c.icon} size={14} className="nav-icon" />
69	              {c.label}
70	              {c.badge && <span className={`nav-badge ${c.badge.kind}`}>{c.badge.text}}
71

72	          ))}
73

74	      )}
75	    </>
76	  );
77	}
78
79	function Sidebar({ active, onNavigate }) {
80	  const [openGroups, setOpenGroups] = React.useState(new Set(["ingest"]));
81	  const [failedJobs, setFailedJobs] = React.useState(
82	    (window.ZAMPP_DATA?.JOBS || []).filter(j => j.status === 'failed').length
83	  );
84	  React.useEffect(() => {
85	    let cancelled = false;
86	    const poll = () => {
87	      window.ZAMPP_API.fetch('/jobs')
88	        .then(list => {
89	          if (cancelled) return;
90	          const failed = (list || []).filter(j => j.status === 'failed').length;
91	          setFailedJobs(failed);
92	        })
93	        .catch(() => {});
94	    };
95	    const t = setInterval(poll, 30_000);
96	    return () => { cancelled = true; clearInterval(t); };
97	  }, []);
98	  const toggleGroup = (id) => {
99	    setOpenGroups(prev => {
100	      const next = new Set(prev);
101	      if (next.has(id)) next.delete(id);
102	      else next.add(id);
103	      return next;
104	    });
105	  };
106
107	  React.useEffect(() => {
108	    const ingestChildren = ["upload", "recorders", "schedule", "capture", "monitors"];
109	    if (ingestChildren.includes(active)) {
110	      setOpenGroups(prev => prev.has("ingest") ? prev : new Set([...prev, "ingest"]));
111	    }
112	  }, [active]);
113
114	  return (
115
116

117
D

118

119
Dragonflight

120

121
v1.2.0

122

123

124	        {NAV_TREE.map(item => {
125	          const dynItem = (item.id === 'jobs' && failedJobs > 0)
126	            ? { ...item, badge: { kind: 'danger', text: String(failedJobs) } }
127	            : item;
128	          return (
129	            <NavItem
130	              key={item.id}
131	              item={dynItem}
132	              active={active}
133	              onSelect={onNavigate}
134	              openGroups={openGroups}
135	              toggleGroup={toggleGroup}
136	            />
137	          );
138	        })}
139
Admin

140	        {ADMIN_TREE.map(item => (
141	          <NavItem
142	            key={item.id}
143	            item={item}
144	            active={active}
145	            onSelect={onNavigate}
146	            openGroups={openGroups}
147	            toggleGroup={toggleGroup}
148	          />
149	        ))}
150

151

152
{(window.ZAMPP_DATA.ME?.initials) || 'ZG'}

153

154
{window.ZAMPP_DATA.ME?.name || 'Admin'}

155
{window.ZAMPP_DATA.ME?.role || 'admin'}

156

157	        <button className="icon-btn" data-tip="Sign out" title="Sign out"
158	          onClick={() => {
159	            // Best-effort logout — API call may 401 with auth disabled, that's fine.
160	            window.ZAMPP_API.fetch('/auth/logout', { method: 'POST' })
161	              .catch(() => {})
162	              .finally(() => { window.location.reload(); });
163	          }}>
164	          <Icon name="power" />
165	        </button>
166

167
168	  );
169	}
170
171	function assetSearchIcon(a) {
172	  const t = (a.type || a.media_type || '').toLowerCase();
173	  if (t.includes('audio')) return 'audio';
174	  if (t.includes('image')) return 'image';
175	  return 'film';
176	}
177
178	function GlobalSearch({ onNavigate, onOpenAsset, onOpenProject }) {
179	  const [q, setQ] = React.useState('');
180	  const [open, setOpen] = React.useState(false);
181	  const [sel, setSel] = React.useState(0);
182	  const inputRef = React.useRef(null);
183	  const listRef = React.useRef(null);
184	  const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
185
186	  React.useEffect(() => {
187	    const handler = (e) => {
188	      const key = e.key && e.key.toLowerCase();
189	      if ((e.metaKey || e.ctrlKey) && key === 'k') {
190	        e.preventDefault();
191	        if (inputRef.current) {
192	          inputRef.current.focus();
193	          inputRef.current.select();
194	          setOpen(true);
195	        }
196	      }
197	    };
198	    window.addEventListener('keydown', handler);
199	    return () => window.removeEventListener('keydown', handler);
200	  }, []);
201
202	  const results = React.useMemo(() => {
203	    const term = q.trim().toLowerCase();
204	    if (!term) return [];
205	    const D = window.ZAMPP_DATA || {};
206	    const out = [];
207
208	    (D.ASSETS || []).forEach(a => {
209	      const hay = ((a.name || '') + ' ' + (a.project || '') + ' ' + (a.filename || '')).toLowerCase();
210	      if (hay.includes(term)) {
211	        const sub = [a.project, a.res, a.duration].filter(x => x && x !== '—').join(' · ');
212	        out.push({ kind: 'asset', icon: assetSearchIcon(a), label: a.name, sub, item: a });
213	      }
214	    });
215	    (D.PROJECTS || []).forEach(p => {
216	      if ((p.name || '').toLowerCase().includes(term)) {
217	        out.push({ kind: 'project', icon: 'folder', label: p.name, sub: (p.assets || 0) + ' assets', item: p, color: p.color });
218	      }
219	    });
220	    (D.RECORDERS || []).forEach(r => {
221	      const hay = ((r.name || '') + ' ' + (r.url || '') + ' ' + (r.source || '')).toLowerCase();
222	      if (hay.includes(term)) {
223	        out.push({ kind: 'recorder', icon: 'record', label: r.name || r.url || 'Recorder', sub: [r.status, r.source].filter(Boolean).join(' · '), item: r });
224	      }
225	    });
226	    (D.JOBS || []).forEach(j => {
227	      const hay = ((j.asset || '') + ' ' + (j.kind || '') + ' ' + (j.status || '')).toLowerCase();
228	      if (hay.includes(term)) {
229	        out.push({ kind: 'job', icon: 'jobs', label: j.asset || j.kind || 'Job', sub: [j.kind, j.status].filter(Boolean).join(' · '), item: j });
230	      }
231	    });
232	    (D.USERS || []).forEach(u => {
233	      const hay = ((u.name || '') + ' ' + (u.email || '') + ' ' + (u.username || '')).toLowerCase();
234	      if (hay.includes(term)) {
235	        out.push({ kind: 'user', icon: 'users', label: u.name, sub: u.email || u.role, item: u });
236	      }
237	    });
238	    NAV_FLAT.forEach(n => {
239	      if (n.label.toLowerCase().includes(term)) {
240	        out.push({ kind: 'nav', icon: n.icon, label: n.label, sub: 'Go to ' + n.label, target: n.id });
241	      }
242	    });
243
244	    return out.slice(0, 40);
245	  }, [q, open]);
246
247	  React.useEffect(() => { setSel(0); }, [q]);
248
249	  React.useEffect(() => {
250	    if (!listRef.current) return;
251	    const el = listRef.current.querySelector('.search-result.active');
252	    if (el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' });
253	  }, [sel]);
254
255	  const handleSelect = (r) => {
256	    if (!r) return;
257	    setOpen(false);
258	    setQ('');
259	    if (inputRef.current) inputRef.current.blur();
260	    if (r.kind === 'asset' && onOpenAsset) onOpenAsset(r.item);
261	    else if (r.kind === 'project' && onOpenProject) onOpenProject(r.item);
262	    else if (r.kind === 'recorder') onNavigate && onNavigate('recorders');
263	    else if (r.kind === 'job') onNavigate && onNavigate('jobs');
264	    else if (r.kind === 'user') onNavigate && onNavigate('users');
265	    else if (r.kind === 'nav') onNavigate && onNavigate(r.target);
266	  };
267
268	  const onKeyDown = (e) => {
269	    if (e.key === 'Escape') {
270	      setOpen(false); setQ('');
271	      if (inputRef.current) inputRef.current.blur();
272	      return;
273	    }
274	    if (!open || results.length === 0) return;
275	    if (e.key === 'ArrowDown') { e.preventDefault(); setSel(s => Math.min(s + 1, results.length - 1)); }
276	    else if (e.key === 'ArrowUp') { e.preventDefault(); setSel(s => Math.max(0, s - 1)); }
277	    else if (e.key === 'Enter') { e.preventDefault(); handleSelect(results[sel]); }
278	  };
279
280	  const showDropdown = open && q.trim().length > 0;
281
282	  return (
283

284	      <div className={`search ${showDropdown ? 'is-open' : ''}`}>
285	        <Icon name="search" className="search-icon" />
286	        <input
287	          ref={inputRef}
288	          value={q}
289	          onChange={e => { setQ(e.target.value); setOpen(true); }}
290	          onFocus={() => setOpen(true)}
291	          onBlur={() => setTimeout(() => setOpen(false), 120)}
292	          onKeyDown={onKeyDown}
293	          placeholder="Search assets, projects, recorders…"
294	        />
295	        {isMac ? '⌘K' : 'Ctrl K'}
296

297	      {showDropdown && (
298
 e.preventDefault()}
302	        >
303	          {results.length === 0 ? (
304
No results for “{q}”

305	          ) : results.map((r, i) => (
306	            <div
307	              key={r.kind + '-' + i + '-' + (r.item ? (r.item.id || r.item.name) : (r.target || r.label))}
308	              className={`search-result ${i === sel ? 'active' : ''}`}
309	              onClick={() => handleSelect(r)}
310	              onMouseEnter={() => setSel(i)}
311	            >
312
316	                <Icon name={r.icon} size={14} />
317
318

319
{r.label}

320	                {r.sub &&
{r.sub}
}
321

322	              <span className={`search-result-kind kind-${r.kind}`}>{r.kind}
323

324	          ))}
325

326	      )}
327

328	  );
329	}
330
331	function Topbar({ crumbs, onNavigate, right, onOpenAsset, onOpenProject }) {
332	  // Light cluster ping — the badge in the topbar should reflect reality,
333	  // not just look reassuring. /metrics/home returns cluster online/total.
334	  const [clusterHealthy, setClusterHealthy] = React.useState(true);
335	  React.useEffect(() => {
336	    let cancelled = false;
337	    const ping = () => {
338	      window.ZAMPP_API.fetch('/metrics/home?hours=1')
339	        .then(d => {
340	          if (cancelled) return;
341	          const c = d?.cards?.cluster;
342	          setClusterHealthy(!c || c.online >= c.total);
343	        })
344	        .catch(() => {});
345	    };
346	    ping();
347	    const t = setInterval(ping, 30_000);
348	    return () => { cancelled = true; clearInterval(t); };
349	  }, []);
350
351	  return (
352
353

354	        {crumbs.map((c, i) => (
355	          <React.Fragment key={i}>
356	            {i > 0 && <Icon name="chevron" size={12} className="sep" />}
357	            <span
358	              className={i === crumbs.length - 1 ? "current" : ""}
359	              style={{ cursor: c.to && i < crumbs.length - 1 ? "pointer" : "default" }}
360	              onClick={() => c.to && onNavigate && onNavigate(c.to)}
361	            >
362	              {c.label}
363
364	          </React.Fragment>
365	        ))}
366

367

368	      <GlobalSearch onNavigate={onNavigate} onOpenAsset={onOpenAsset} onOpenProject={onOpenProject} />
369

370
371	        {clusterHealthy ? 'cluster healthy' : 'cluster degraded'}
372

373	      {right}
374
375	  );
376	}
377
378	// General-purpose read-only form field used by recorder modal and other forms.
379	// Renders as a labeled select (when select=true) or text input.
380	function Field({ label, value, select, children }) {
381	  return (
382

383	      {label}
384	      {children || (select
385	        ? (
386	          <select className="field-input" defaultValue={value} style={{ appearance: 'auto' }}>
387	            <option value={value}>{value}</option>
388	          </select>
389	        ) : (
390	          <input className="field-input" defaultValue={value} readOnly />
391	        )
392	      )}
393

394	  );
395	}
396
397	window.Sidebar = Sidebar;
398	window.Topbar = Topbar;
399	window.NAV_TREE = NAV_TREE;
400	window.NAV_FLAT = NAV_FLAT;
401	window.Field = Field;
402	window.GlobalSearch = GlobalSearch;
403


Now I have everything I need. Let me get the current file SHA from Forgejo, then push the update.


File: services/web-ui/public/shell.jsx

// shell.jsx - app shell: sidebar nav, topbar, route container

const NAV_TREE = [
  { id: "home", label: "Home", icon: "home" },
  { id: "library", label: "Library", icon: "library" },
  { id: "projects", label: "Projects", icon: "folder" },
  {
    id: "ingest", label: "Ingest", icon: "upload", group: true,
    children: [
      { id: "upload", label: "Upload", icon: "upload" },
      { id: "recorders", label: "Recorders", icon: "record" },
      { id: "schedule", label: "Schedule", icon: "jobs" },
      { id: "capture", label: "Capture", icon: "capture" },
      { id: "monitors", label: "Monitors", icon: "monitor" },
    ],
  },
  { id: "jobs", label: "Jobs", icon: "jobs", badge: { kind: "neutral", text: "3" } },
  { id: "editor", label: "Editor", icon: "editor", badge: { kind: "dev", text: "DEV" } },
];

const ADMIN_TREE = [
  { id: "users", label: "Users", icon: "users" },
  { id: "tokens", label: "Tokens", icon: "token" },
  { id: "containers", label: "Containers", icon: "container" },
  { id: "cluster", label: "Cluster", icon: "cluster" },
  { id: "settings", label: "Settings", icon: "settings" },
];

const NAV_FLAT = (() => {
  const out = [];
  const visit = (arr) => arr.forEach(n => {
    if (n.group && n.children) { visit(n.children); return; }
    out.push({ id: n.id, label: n.label, icon: n.icon });
  });
  visit(NAV_TREE); visit(ADMIN_TREE);
  return out;
})();

function NavItem({ item, active, onSelect, depth = 0, openGroups, toggleGroup }) {
  const isGroup = item.group && item.children;
  const isOpen = isGroup && openGroups.has(item.id);
  const isActive = active === item.id || (isGroup && item.children.some(c => c.id === active));

  return (
    <>
      <div
        className={`nav-item ${isActive && !isGroup ? "active" : ""} ${isGroup ? "has-children" : ""} ${isOpen ? "open" : ""}`}
        onClick={() => {
          if (isGroup) toggleGroup(item.id);
          else onSelect(item.id);
        }}
      >
        <Icon name={item.icon} size={15} className="nav-icon" />
        {item.label}
        {item.badge && (
          <span className={`nav-badge ${item.badge.kind}`}>{item.badge.text}
        )}
        {isGroup && <Icon name="chevron" size={12} className="nav-caret" />}

      {isGroup && isOpen && (

          {item.children.map(c => (
            <div
              key={c.id}
              className={`nav-item ${active === c.id ? "active" : ""}`}
              onClick={() => onSelect(c.id)}
            >
              <Icon name={c.icon} size={14} className="nav-icon" />
              {c.label}
              {c.badge && <span className={`nav-badge ${c.badge.kind}`}>{c.badge.text}}

          ))}

      )}
    </>
  );
}

function Sidebar({ active, onNavigate }) {
  const [openGroups, setOpenGroups] = React.useState(new Set(["ingest"]));
  const toggleGroup = (id) => {
    setOpenGroups(prev => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  };

  React.useEffect(() => {
    const ingestChildren = ["upload", "recorders", "schedule", "capture", "monitors"];
    if (ingestChildren.includes(active)) {
      setOpenGroups(prev => prev.has("ingest") ? prev : new Set([...prev, "ingest"]));
    }
  }, [active]);

  return (

D

Dragonflight

v1.2.0

        {NAV_TREE.map(item => (
          <NavItem
            key={item.id}
            item={item}
            active={active}
            onSelect={onNavigate}
            openGroups={openGroups}
            toggleGroup={toggleGroup}
          />
        ))}

Admin

        {ADMIN_TREE.map(item => (
          <NavItem
            key={item.id}
            item={item}
            active={active}
            onSelect={onNavigate}
            openGroups={openGroups}
            toggleGroup={toggleGroup}
          />
        ))}

{(window.ZAMPP_DATA.ME?.initials) || 'ZG'}

{window.ZAMPP_DATA.ME?.name || 'Admin'}

{window.ZAMPP_DATA.ME?.role || 'admin'}

        <button className="icon-btn" data-tip="Sign out" title="Sign out"
          onClick={() => {
            // Best-effort logout — API call may 401 with auth disabled, that's fine.
            window.ZAMPP_API.fetch('/auth/logout', { method: 'POST' })
              .catch(() => {})
              .finally(() => { window.location.reload(); });
          }}>
          <Icon name="power" />
        </button>

  );
}

function assetSearchIcon(a) {
  const t = (a.type || a.media_type || '').toLowerCase();
  if (t.includes('audio')) return 'audio';
  if (t.includes('image')) return 'image';
  return 'film';
}

function GlobalSearch({ onNavigate, onOpenAsset, onOpenProject }) {
  const [q, setQ] = React.useState('');
  const [open, setOpen] = React.useState(false);
  const [sel, setSel] = React.useState(0);
  const inputRef = React.useRef(null);
  const listRef = React.useRef(null);
  const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform);

  React.useEffect(() => {
    const handler = (e) => {
      const key = e.key && e.key.toLowerCase();
      if ((e.metaKey || e.ctrlKey) && key === 'k') {
        e.preventDefault();
        if (inputRef.current) {
          inputRef.current.focus();
          inputRef.current.select();
          setOpen(true);
        }
      }
    };
    window.addEventListener('keydown', handler);
    return () => window.removeEventListener('keydown', handler);
  }, []);

  const results = React.useMemo(() => {
    const term = q.trim().toLowerCase();
    if (!term) return [];
    const D = window.ZAMPP_DATA || {};
    const out = [];

    (D.ASSETS || []).forEach(a => {
      const hay = ((a.name || '') + ' ' + (a.project || '') + ' ' + (a.filename || '')).toLowerCase();
      if (hay.includes(term)) {
        const sub = [a.project, a.res, a.duration].filter(x => x && x !== '—').join(' · ');
        out.push({ kind: 'asset', icon: assetSearchIcon(a), label: a.name, sub, item: a });
      }
    });
    (D.PROJECTS || []).forEach(p => {
      if ((p.name || '').toLowerCase().includes(term)) {
        out.push({ kind: 'project', icon: 'folder', label: p.name, sub: (p.assets || 0) + ' assets', item: p, color: p.color });
      }
    });
    (D.RECORDERS || []).forEach(r => {
      const hay = ((r.name || '') + ' ' + (r.url || '') + ' ' + (r.source || '')).toLowerCase();
      if (hay.includes(term)) {
        out.push({ kind: 'recorder', icon: 'record', label: r.name || r.url || 'Recorder', sub: [r.status, r.source].filter(Boolean).join(' · '), item: r });
      }
    });
    (D.JOBS || []).forEach(j => {
      const hay = ((j.asset || '') + ' ' + (j.kind || '') + ' ' + (j.status || '')).toLowerCase();
      if (hay.includes(term)) {
        out.push({ kind: 'job', icon: 'jobs', label: j.asset || j.kind || 'Job', sub: [j.kind, j.status].filter(Boolean).join(' · '), item: j });
      }
    });
    (D.USERS || []).forEach(u => {
      const hay = ((u.name || '') + ' ' + (u.email || '') + ' ' + (u.username || '')).toLowerCase();
      if (hay.includes(term)) {
        out.push({ kind: 'user', icon: 'users', label: u.name, sub: u.email || u.role, item: u });
      }
    });
    NAV_FLAT.forEach(n => {
      if (n.label.toLowerCase().includes(term)) {
        out.push({ kind: 'nav', icon: n.icon, label: n.label, sub: 'Go to ' + n.label, target: n.id });
      }
    });

    return out.slice(0, 40);
  }, [q, open]);

  React.useEffect(() => { setSel(0); }, [q]);

  React.useEffect(() => {
    if (!listRef.current) return;
    const el = listRef.current.querySelector('.search-result.active');
    if (el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' });
  }, [sel]);

  const handleSelect = (r) => {
    if (!r) return;
    setOpen(false);
    setQ('');
    if (inputRef.current) inputRef.current.blur();
    if (r.kind === 'asset' && onOpenAsset) onOpenAsset(r.item);
    else if (r.kind === 'project' && onOpenProject) onOpenProject(r.item);
    else if (r.kind === 'recorder') onNavigate && onNavigate('recorders');
    else if (r.kind === 'job') onNavigate && onNavigate('jobs');
    else if (r.kind === 'user') onNavigate && onNavigate('users');
    else if (r.kind === 'nav') onNavigate && onNavigate(r.target);
  };

  const onKeyDown = (e) => {
    if (e.key === 'Escape') {
      setOpen(false); setQ('');
      if (inputRef.current) inputRef.current.blur();
      return;
    }
    if (!open || results.length === 0) return;
    if (e.key === 'ArrowDown') { e.preventDefault(); setSel(s => Math.min(s + 1, results.length - 1)); }
    else if (e.key === 'ArrowUp') { e.preventDefault(); setSel(s => Math.max(0, s - 1)); }
    else if (e.key === 'Enter') { e.preventDefault(); handleSelect(results[sel]); }
  };

  const showDropdown = open && q.trim().length > 0;

  return (

      <div className={`search ${showDropdown ? 'is-open' : ''}`}>
        <Icon name="search" className="search-icon" />
        <input
          ref={inputRef}
          value={q}
          onChange={e => { setQ(e.target.value); setOpen(true); }}
          onFocus={() => setOpen(true)}
          onBlur={() => setTimeout(() => setOpen(false), 120)}
          onKeyDown={onKeyDown}
          placeholder="Search assets, projects, recorders…"
        />
        {isMac ? '⌘K' : 'Ctrl K'}

      {showDropdown && (

 e.preventDefault()}
        >
          {results.length === 0 ? (

No results for "{q}"

          ) : results.map((r, i) => (
            <div
              key={r.kind + '-' + i + '-' + (r.item ? (r.item.id || r.item.name) : (r.target || r.label))}
              className={`search-result ${i === sel ? 'active' : ''}`}
              onClick={() => handleSelect(r)}
              onMouseEnter={() => setSel(i)}
            >

                <Icon name={r.icon} size={14} />

{r.label}

                {r.sub &&
{r.sub}
}

              <span className={`search-result-kind kind-${r.kind}`}>{r.kind}

          ))}

      )}

  );
}

function Topbar({ crumbs, onNavigate, right, onOpenAsset, onOpenProject }) {
  // Light cluster ping — the badge in the topbar should reflect reality,
  // not just look reassuring. /metrics/home returns cluster online/total.
  const [clusterHealthy, setClusterHealthy] = React.useState(true);
  React.useEffect(() => {
    let cancelled = false;
    const ping = () => {
      window.ZAMPP_API.fetch('/metrics/home?hours=1')
        .then(d => {
          if (cancelled) return;
          const c = d?.cards?.cluster;
          setClusterHealthy(!c || c.online >= c.total);
        })
        .catch(() => {});
    };
    ping();
    const t = setInterval(ping, 30_000);
    return () => { cancelled = true; clearInterval(t); };
  }, []);

  return (

        {crumbs.map((c, i) => (
          <React.Fragment key={i}>
            {i > 0 && <Icon name="chevron" size={12} className="sep" />}
            <span
              className={i === crumbs.length - 1 ? "current" : ""}
              style={{ cursor: c.to && i < crumbs.length - 1 ? "pointer" : "default" }}
              onClick={() => c.to && onNavigate && onNavigate(c.to)}
            >
              {c.label}

          </React.Fragment>
        ))}

      <GlobalSearch onNavigate={onNavigate} onOpenAsset={onOpenAsset} onOpenProject={onOpenProject} />

        {clusterHealthy ? 'cluster healthy' : 'cluster degraded'}

      {right}

  );
}

// General-purpose read-only form field used by recorder modal and other forms.
// Renders as a labeled select (when select=true) or text input.
function Field({ label, value, select, children }) {
  return (

      {label}
      {children || (select
        ? (
          <select className="field-input" defaultValue={value} style={{ appearance: 'auto' }}>
            <option value={value}>{value}</option>
          </select>
        ) : (
          <input className="field-input" defaultValue={value} readOnly />
        )
      )}

  );
}

window.Sidebar = Sidebar;
window.Topbar = Topbar;
window.NAV_TREE = NAV_TREE;
window.NAV_FLAT = NAV_FLAT;
window.Field = Field;
window.GlobalSearch = GlobalSearch;


You've hit your limit · resets May 25, 9pm (UTC)