relay-server: update view

Kim committed Oct 29, 2025 at 18:43 UTC c4f401db9b289cf89be141f9fc65898a6363f106
1 file changed +72 -39
cmd/relay-server/view.go
+72 -39
@@ -332,65 +332,98 @@ var serverTmpl = template.Must(template.New("admin-index").Parse(`<!doctype html
332 }
333 body { margin:0; background:var(--bg); color:var(--ink); font-family:sans-serif; font-size:16px; line-height:1.6 }
334 .wrap { max-width: 980px; margin: 0 auto; padding: 32px 20px }
335 - header { display:flex; align-items:center; justify-content:space-between; padding: 20px 24px; background:var(--panel); border:1px solid var(--line); border-radius: 14px }
336 - .brand { font-weight:800; font-size:22px; letter-spacing:.2px }
335 + header { display:flex; align-items:center; gap:16px; justify-content:space-between; padding: 20px 24px; background:var(--panel); border:1px solid var(--line); border-radius: 14px }
336 + .brand { display:flex; align-items:center; height:36px; font-weight:800; font-size:18px; letter-spacing:.2px }
337 .status { color:var(--ok); font-weight:700 }
338 + .app-left { display:flex; align-items:center; gap:12px }
339 main { margin-top: 22px }
340 .section { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:18px; margin-bottom:14px }
341 + .grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap:16px; margin-top:14px }
342 + .card { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:16px; display:flex; flex-direction:column; box-shadow: 0 1px 2px rgba(15,23,42,0.06); transition: box-shadow .2s ease, transform .2s ease; text-decoration:none; color:inherit; cursor:pointer; min-height: 180px }
343 + .card:hover { box-shadow: 0 6px 20px rgba(15,23,42,0.12); transform: translateY(-2px) }
344 + .card-head { display:flex; align-items:center; gap:10px; margin-bottom:8px }
345 + .status-dot { width:10px; height:10px; border-radius:999px; background:var(--muted) }
346 + .status-dot.ok { background: var(--ok) }
347 + .status-dot.bad { background: var(--bad) }
348 .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; color:#374151; word-break: break-all }
341 - .title { font-weight:800; margin:0 0 10px 0; font-size:18px }
342 - .muted { color:var(--muted); font-size:14px }
349 + .title { font-weight:800; margin:0; font-size:16px }
350 + .muted { color:var(--muted); font-size:13px }
351 .pill { display:inline-flex; align-items:center; gap:8px; padding:6px 10px; border-radius:999px; font-weight:800; font-size:13px }
352 .pill.ok { background:var(--ok-bg); color:var(--ok) }
353 .pill.bad { background:var(--bad-bg); color:var(--bad) }
354 .pill .dot { width:8px; height:8px; border-radius:999px; background:var(--ok); display:inline-block }
355 .pill.bad .dot { background:var(--bad) }
356 .head { display:flex; align-items:center; justify-content:space-between; gap:12px }
349 - .btn { display:inline-block; background:var(--primary); color:#fff; text-decoration:none; border-radius:10px; padding:10px 14px; font-weight:800; margin-top:8px }
357 + .btn { display:inline-block; background:var(--primary); color:#fff; text-decoration:none; border-radius:8px; padding:6px 10px; font-weight:700; font-size:13px; line-height:1; margin-top:6px }
358 + .btn.outline { background:transparent; color:var(--primary); border:1px solid var(--primary) }
359 + .actions { margin-top:auto; display:flex; justify-content:flex-end }
360 + /* Google-like search bar */
361 + .searchbar { width: 320px; max-width: 50vw; margin: 0; display:flex; align-items:center; gap:10px; height:36px; padding:0 12px; border:1px solid var(--line); border-radius:999px; background:#fff }
362 + .searchbar:focus-within { box-shadow: 0 1px 6px rgba(32,33,36,.28); border-color:#dfe1e5 }
363 + .searchbar input { width:100%; height:100%; border:none; outline:none; font-size:14px; background:transparent; color:#111 }
364 + .searchbar .icon { width:18px; height:18px; color:#9aa0a6 }
365 + .topbar { display:flex; align-items:center; gap:16px; justify-content:space-between }
366 + .rightbar { display:flex; align-items:center; gap:12px; margin-left:auto }
367 + .gh-btn { width:36px; height:36px; border-radius:999px; display:inline-flex; align-items:center; justify-content:center; color:#000; background:#fff; border:1px solid var(--line) }
368 + .counter { display:inline-flex; align-items:center; gap:6px; height:36px; padding:0 10px; border-radius:999px; background:var(--ok-bg); color:var(--ok); font-weight:800 }
369 + .counter .icon { width:18px; height:18px }
370 </style>
371 </head>
372 <body>
373 <div class="wrap">
374 <header>
375 <div class="brand">Portal</div>
356 - <div class="status">Admin</div>
376 + <div class="rightbar">
377 + <div class="searchbar" role="search">
378 + <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
379 + <path d="M15.5 14h-.79l-.28-.27a6.471 6.471 0 0 0 1.57-4.23C15.99 6.01 13.48 3.5 10.49 3.5S5 6.01 5 9s2.51 5.5 5.5 5.5c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l4.25 4.25c.41.41 1.08.41 1.49 0 .41-.41.41-1.08 0-1.49L15.5 14Zm-5 0C8.01 14 6 11.99 6 9.5S8.01 5 10.5 5 15 7.01 15 9.5 12.99 14 10.5 14Z"/>
380 + </svg>
381 + <input id="search" type="text" placeholder="Search by name" aria-label="Search by name" oninput="filterCards(this.value)">
382 + </div>
383 + <a class="gh-btn" href="https://github.com/gosuda/portal" target="_blank" rel="noopener" aria-label="GitHub repository" title="gosuda/portal">
384 + <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
385 + <path d="M12 .5C5.73.5.5 5.73.5 12c0 5.08 3.29 9.37 7.86 10.88.58.1.79-.25.79-.56 0-.27-.01-1.16-.02-2.11-3.2.69-3.88-1.39-3.88-1.39-.53-1.35-1.29-1.71-1.29-1.71-1.05-.72.08-.7.08-.7 1.16.08 1.78 1.19 1.78 1.19 1.03 1.77 2.7 1.26 3.36.96.1-.75.4-1.26.73-1.55-2.56-.29-5.26-1.28-5.26-5.72 0-1.26.45-2.3 1.19-3.11-.12-.29-.52-1.45.11-3.02 0 0 .98-.31 3.2 1.19.93-.26 1.94-.39 2.94-.39s2.01.13 2.95.39c2.22-1.5 3.2-1.19 3.2-1.19.63 1.57.23 2.73.12 3.02.74.81 1.19 1.85 1.19 3.11 0 4.45-2.7 5.43-5.28 5.72.41.36.77 1.07.77 2.16 0 1.56-.01 2.81-.01 3.19 0 .31.21.67.8.56C20.22 21.36 23.5 17.08 23.5 12 23.5 5.73 18.27.5 12 .5Z"/>
386 + </svg>
387 + </a>
388 + <div class="counter" aria-label="Active clients" title="Active clients">
389 + <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
390 + <path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5s-3 1.34-3 3 1.34 3 3 3Zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5 5 6.34 5 8s1.34 3 3 3Zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5C18 14.17 13.33 13 11 13Zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h4v-2.5c0-2.33-4.67-3.5-7-3.5Z"/>
391 + </svg>
392 + <span>{{len .Rows}}</span>
393 + </div>
394 + </div>
395 </header>
396 <main>
359 - <section class="section">
360 - <div class="title">Server</div>
361 - <div class="mono">Server ID: {{.NodeID}}</div>
362 - {{if .Bootstraps}}
363 - <div class="muted" style="margin-top:6px">Bootstrap URLs</div>
364 - <div class="mono">{{range .Bootstraps}}<a href="{{.}}" target="_blank" rel="noreferrer noopener">{{.}}</a><br/>{{end}}</div>
365 - {{end}}
366 - <div class="muted" style="margin-top:6px">Active clients: {{len .Rows}}</div>
367 - </section>
368 - {{range .Rows}}
369 - <section class="section" id="peer-{{.Peer}}" data-peer="{{.Peer}}" data-name="{{.Name}}">
370 - <div class="head">
371 - <div class="title">{{if .Name}}{{.Name}}{{else}}(unnamed){{end}}</div>
372 - <div>
373 - <span class="muted" style="margin-right:8px">{{.Kind}}</span>
374 - {{if .Connected}}
375 - <span class="pill ok"><span class="dot"></span>Connected</span>
376 - {{else}}
377 - <span class="pill bad"><span class="dot"></span>Disconnected</span>
378 - {{end}}
397 +
398 +
399 + <div class="grid">
400 + {{range .Rows}}
401 + <a class="card {{if .Connected}}connected{{else}}disconnected{{end}}" id="peer-{{.Peer}}" data-peer="{{.Peer}}" data-name="{{.Name}}" href="{{.Link}}">
402 + <div class="card-head">
403 + <span class="status-dot {{if .Connected}}ok{{else}}bad{{end}}"></span>
404 + <div class="title">{{if .Name}}{{.Name}}{{else}}(unnamed){{end}}</div>
405 </div>
380 - </div>
381 - {{if .DNS}}<div class="muted">DNS Label: <span class="mono">{{.DNS}}</span></div>{{end}}
382 - <div class="muted">Lease Identity</div>
383 - <div class="mono">{{.Peer}}</div>
384 - <div class="muted" style="margin-top:6px">Last seen: {{.LastSeen}}{{if .TTL}} - TTL: {{.TTL}}{{end}}</div>
385 - <a class="btn" href="{{.Link}}">Open</a>
386 - </section>
387 - {{else}}
388 - <section class="section">
389 - <div class="title">No clients discovered</div>
390 - <div class="muted">Start a client and ensure bootstrap URLs point at this server's /relay WebSocket endpoint.</div>
391 - </section>
392 - {{end}}
406 + <div class="muted">Last seen: {{.LastSeen}}</div>
407 + </a>
408 + {{else}}
409 + <article class="card">
410 + <div class="title">No clients discovered</div>
411 + <div class="muted">Start a client and ensure bootstrap URLs point at this server's /relay WebSocket endpoint.</div>
412 + </article>
413 + {{end}}
414 + </div>
415 </main>
416 </div>
417 + <script>
418 + function filterCards(q) {
419 + q = (q || '').toLowerCase().trim();
420 + const cards = document.querySelectorAll('.grid .card');
421 + cards.forEach(card => {
422 + const name = (card.getAttribute('data-name') || '').toLowerCase();
423 + const show = !q || name.includes(q);
424 + card.style.display = show ? '' : 'none';
425 + });
426 + }
427 + </script>
428 </body>
429 </html>`))