feat: update header and server list view

Kim committed Apr 13, 2026 at 12:16 UTC 132d2532d3769d3a7e35fc4e6f0fa1257f8c282a
5 files changed +125 -59
frontend/src/components/Header.tsx
+6 -6
@@ -59,26 +59,26 @@ export function Header({
59 </div>
60 </div>
61 {!isAdmin && (
62 - <nav className="hidden items-center gap-6 pl-2 text-base font-semibold text-text-muted xl:flex xl:pl-3">
62 + <nav className="hidden items-center gap-4 pl-2 text-sm font-semibold text-text-muted xl:flex xl:pl-3 2xl:gap-6 2xl:text-base">
63 {showQuickStartLink && (
64 <a
65 href="#quick-start"
66 - className="transition-colors hover:text-foreground"
66 + className="whitespace-nowrap transition-colors hover:text-foreground"
67 >
68 Quick Start
69 </a>
70 )}
71 <a
72 href="#live-servers"
73 - className="transition-colors hover:text-foreground"
73 + className="whitespace-nowrap transition-colors hover:text-foreground"
74 >
75 Live apps
76 </a>
77 <a
78 - href="#official-registry"
79 - className="transition-colors hover:text-foreground"
78 + href="#public-relays"
79 + className="whitespace-nowrap transition-colors hover:text-foreground"
80 >
81 - Official registry
81 + Public relays
82 </a>
83 </nav>
84 )}
frontend/src/components/ServerListView.tsx
+93 -51
@@ -13,6 +13,7 @@ import { BanStatusButtons } from "@/components/button/BanStatusButtons";
13 import { SortbySelect } from "@/components/select/SortbySelect";
14 import { ApprovalModeToggle } from "@/components/button/ApprovalModeToggle";
15 import { FloatingActionBar } from "@/components/FloatingActionBar";
16 +import { readCurrentOrigin } from "@/hooks/useTunnelCommand";
17 import { apiClient } from "@/lib/apiClient";
18 import { API_PATHS, ROUTE_PATHS } from "@/lib/apiPaths";
19 import {
@@ -229,6 +230,7 @@ export function ServerListView({
230 const serverItems = filteredServers as ListServer[];
231 const favoriteIds = useMemo(() => new Set(favorites), [favorites]);
232 const showLandingHero = !isAdmin && landingPageEnabled;
233 + const connectedRelayURL = useMemo(() => readCurrentOrigin(), []);
234
235 const handleToggleSelect = (identityKey: string) => {
236 setSelectedIdentityKeys((prev) => {
@@ -369,6 +371,9 @@ export function ServerListView({
371 }, [isAdmin, officialRegistryRelays]);
372
373 const officialRegistryList = officialRegistryRelays ?? [];
374 + const connectedOfficialRegistryRelay = officialRegistryList.find(
375 + (relay) => relay.url === connectedRelayURL
376 + );
377 const isAllSelected =
378 allIdentityKeys.length > 0 &&
379 allIdentityKeys.every((identityKey) => selectedIdentityKeys.has(identityKey));
@@ -857,73 +862,110 @@ export function ServerListView({
862 </section>
863
864 <section
860 - id="official-registry"
861 - aria-labelledby="official-registry-title"
865 + id="public-relays"
866 + aria-labelledby="public-relays-title"
867 className="scroll-mt-24 px-4 py-8 sm:px-6 md:px-8"
868 >
869 <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
870 <div className="space-y-2">
871 <p className="text-sm font-semibold uppercase tracking-[0.3em] text-primary">
867 - Official registry
872 + Relays
873 </p>
874 <h2
870 - id="official-registry-title"
875 + id="public-relays-title"
876 className="text-3xl font-semibold tracking-tight text-foreground"
877 >
878 Public relays
879 </h2>
880 </div>
876 - <a
877 - href={OFFICIAL_REGISTRY_SOURCE_URL}
878 - target="_blank"
879 - rel="noopener noreferrer"
880 - className="inline-flex h-10 items-center justify-center rounded-full bg-primary/12 px-4 text-sm font-semibold text-primary transition-colors hover:bg-primary/20"
881 - >
882 - Open registry.json
883 - </a>
881 </div>
882
886 - <div className="mt-6 rounded-xl border border-border/80 bg-secondary/35 p-5 sm:p-6">
887 - {officialRegistryAvailable ? (
888 - <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
889 - {officialRegistryList.map((relay) => {
890 - return (
891 - <div
892 - key={relay.url}
893 - className="flex flex-col gap-3 rounded-2xl border border-border/70 bg-background/90 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
894 - >
895 - <a
896 - href={relay.url}
897 - target="_blank"
898 - rel="noopener noreferrer"
899 - className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-mono text-[13px] text-foreground underline-offset-4 hover:underline sm:text-sm"
900 - >
901 - {relay.url}
902 - </a>
903 - <div className="flex shrink-0 flex-wrap items-center gap-2">
904 - {relay.status === "checking" ? (
905 - <span className="rounded-full bg-background px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-text-muted ring-1 ring-border">
906 - Checking
907 - </span>
908 - ) : relay.status === "disconnected" ? (
909 - <span className="rounded-full bg-background px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-text-muted ring-1 ring-border">
910 - Disconnected
911 - </span>
912 - ) : relay.releaseVersion ? (
913 - <span className="rounded-full bg-background px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-text-muted ring-1 ring-border">
914 - {relay.releaseVersion}
915 - </span>
916 - ) : null}
917 - </div>
918 - </div>
919 - );
920 - })}
883 + <div className="mt-6 flex flex-col gap-6">
884 + <div className="rounded-xl border border-border/80 bg-secondary/35 p-5 sm:p-6">
885 + <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
886 + <div className="space-y-1">
887 + <p className="text-sm font-semibold uppercase tracking-[0.18em] text-text-muted">
888 + Official registry
889 + </p>
890 + </div>
891 + <a
892 + href={OFFICIAL_REGISTRY_SOURCE_URL}
893 + target="_blank"
894 + rel="noopener noreferrer"
895 + className="inline-flex h-10 items-center justify-center rounded-full bg-primary/12 px-4 text-sm font-semibold text-primary transition-colors hover:bg-primary/20"
896 + >
897 + Open registry.json
898 + </a>
899 </div>
922 - ) : officialRegistryRelays !== null ? (
923 - <p className="text-sm text-text-muted">
924 - Registry entries are unavailable right now.
900 + <div className="mt-5">
901 + {officialRegistryAvailable ? (
902 + <div className="grid gap-3 md:grid-cols-2">
903 + {officialRegistryList.map((relay) => {
904 + return (
905 + <div
906 + key={relay.url}
907 + className="flex flex-col gap-3 rounded-2xl border border-border/70 bg-background/90 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
908 + >
909 + <a
910 + href={relay.url}
911 + target="_blank"
912 + rel="noopener noreferrer"
913 + className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-mono text-[13px] text-foreground underline-offset-4 hover:underline sm:text-sm"
914 + >
915 + {relay.url}
916 + </a>
917 + <div className="flex shrink-0 flex-wrap items-center gap-2">
918 + {relay.status === "checking" ? (
919 + <span className="rounded-full bg-background px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-text-muted ring-1 ring-border">
920 + Checking
921 + </span>
922 + ) : relay.status === "disconnected" ? (
923 + <span className="rounded-full bg-background px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-text-muted ring-1 ring-border">
924 + Disconnected
925 + </span>
926 + ) : relay.releaseVersion ? (
927 + <span className="rounded-full bg-background px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-text-muted ring-1 ring-border">
928 + {relay.releaseVersion}
929 + </span>
930 + ) : null}
931 + </div>
932 + </div>
933 + );
934 + })}
935 + </div>
936 + ) : officialRegistryRelays !== null ? (
937 + <p className="text-sm text-text-muted">
938 + Registry entries are unavailable right now.
939 + </p>
940 + ) : null}
941 + </div>
942 + </div>
943 +
944 + <div className="rounded-xl border border-border/80 bg-secondary/35 p-5 sm:p-6">
945 + <p className="text-sm font-semibold uppercase tracking-[0.18em] text-text-muted">
946 + Connected relay
947 </p>
926 - ) : null}
948 + <div className="mt-5 flex flex-col gap-3 rounded-2xl border border-border/70 bg-background/90 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
949 + <a
950 + href={connectedRelayURL}
951 + target="_blank"
952 + rel="noopener noreferrer"
953 + className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-mono text-[13px] text-foreground underline-offset-4 hover:underline sm:text-sm"
954 + >
955 + {connectedRelayURL}
956 + </a>
957 + <div className="flex shrink-0 flex-wrap items-center gap-2">
958 + <span className="rounded-full bg-background px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-text-muted ring-1 ring-border">
959 + Connected
960 + </span>
961 + {connectedOfficialRegistryRelay?.releaseVersion ? (
962 + <span className="rounded-full bg-background px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-text-muted ring-1 ring-border">
963 + {connectedOfficialRegistryRelay.releaseVersion}
964 + </span>
965 + ) : null}
966 + </div>
967 + </div>
968 + </div>
969 </div>
970 </section>
971 </main>
portal/discovery/relayset.go
+3
@@ -184,6 +184,7 @@ func (s *RelaySet) applyDiscoveredStateLocked(state RelayState, confirmed bool)
184
185 previousState, hadPrevious := s.relays[relayURL]
186 record := previousState
187 + bootstrap := record.Bootstrap
188
189 if hadPrevious && record.hasDescriptor() && record.Descriptor.Key() != relayKey {
190 return false, errors.New("descriptor identity does not match known relay url")
@@ -195,6 +196,7 @@ func (s *RelaySet) applyDiscoveredStateLocked(state RelayState, confirmed bool)
196 continue
197 }
198 previousURL = url
199 + bootstrap = bootstrap || existing.Bootstrap
200 if !record.hasDescriptor() &&
201 !record.Banned &&
202 record.consecutiveFailures == 0 {
@@ -216,6 +218,7 @@ func (s *RelaySet) applyDiscoveredStateLocked(state RelayState, confirmed bool)
218 record.Descriptor = state.Descriptor
219 record.LastSeenAt = state.LastSeenAt
220 }
221 + record.Bootstrap = bootstrap
222
223 if confirmed {
224 record = s.policy.OnConfirmed(record)
portal/discovery/relayset_test.go
+23
@@ -7,6 +7,29 @@ import (
7 "github.com/gosuda/portal-tunnel/v2/types"
8 )
9
10 +func TestApplyRelayDiscoveryResponsePreservesBootstrapFlag(t *testing.T) {
11 + set, err := NewRelaySet(types.Identity{}, "", []string{"https://relay-a.example"})
12 + if err != nil {
13 + t.Fatalf("NewRelaySet() error = %v", err)
14 + }
15 +
16 + desc := mustPolicyRelayDescriptor(t, "relay-a", "https://relay-a.example")
17 + if _, err := set.ApplyRelayDiscoveryResponse(desc.Identity, desc.APIHTTPSAddr, types.DiscoveryResponse{
18 + ProtocolVersion: types.ProtocolVersion,
19 + Self: desc,
20 + }, time.Now().UTC()); err != nil {
21 + t.Fatalf("ApplyRelayDiscoveryResponse() error = %v", err)
22 + }
23 +
24 + states := set.ActiveRelays()
25 + if len(states) != 1 {
26 + t.Fatalf("len(ActiveRelays()) = %d, want 1", len(states))
27 + }
28 + if !states[0].Bootstrap {
29 + t.Fatal("bootstrap relay lost bootstrap flag after discovery update")
30 + }
31 +}
32 +
33 func TestApplyRelayDiscoveryResponseAllowsURLChangeForSameIdentity(t *testing.T) {
34 set, err := NewRelaySet(types.Identity{}, "", nil)
35 if err != nil {
registry.json
-2
@@ -6,8 +6,6 @@
6 "https://portal.1ncursio.dev/",
7 "https://portal.dawnfullstack.com/",
8 "https://portal.damn.it.com/",
9 - "https://portal.korokorok.com/",
10 - "https://kakashit.org/",
9 "https://portal.tyutya.top/"
10 ]
11 }
\ No newline at end of file