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