fix frontend
Kim committed
Mar 19, 2026 at 21:51 UTC
100855ed1e25778d6db61df1a2e457495b151f33
12 files changed
+577
-139
cmd/relay-server/frontend.go
+25
@@ -16,6 +16,7 @@ import (
16
17
"github.com/gosuda/portal/v2/portal"
18
"github.com/gosuda/portal/v2/types"
19
+ "github.com/gosuda/portal/v2/utils"
20
)
21
22
type readDirFileFS interface {
@@ -83,6 +84,7 @@ func (f *Frontend) Handler() *http.ServeMux {
84
85
mux.HandleFunc(types.PathAdmin, f.serveAdmin)
86
mux.HandleFunc(types.PathAdminPrefix, f.serveAdmin)
87
+ mux.HandleFunc(types.PathTunnelStatus, f.serveTunnelStatus)
88
mux.HandleFunc(types.PathInstallShell, func(w http.ResponseWriter, r *http.Request) {
89
serveInstallScript(w, r, f.portalURL, false)
90
})
@@ -197,6 +199,29 @@ func (f *Frontend) injectServerData(htmlContent string) string {
199
return strings.Replace(htmlContent, "</head>", ssrScript+"\n</head>", 1)
200
}
201
202
+func (f *Frontend) serveTunnelStatus(w http.ResponseWriter, r *http.Request) {
203
+ if r.Method != http.MethodGet {
204
+ utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
205
+ return
206
+ }
207
+
208
+ hostname := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("hostname")))
209
+ if hostname == "" {
210
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "hostname is required")
211
+ return
212
+ }
213
+
214
+ resp := types.TunnelStatusResponse{
215
+ Hostname: hostname,
216
+ }
217
+ if snapshot, ok := f.server.LeaseSnapshotByHostname(hostname); ok {
218
+ resp.Hostname = snapshot.Hostname
219
+ resp.Registered = true
220
+ resp.ServiceAlive = snapshot.Ready > 0
221
+ }
222
+ utils.WriteAPIData(w, http.StatusOK, resp)
223
+}
224
+
225
func (f *Frontend) injectOGMetadata(htmlContent, title, description, imageURL string) string {
226
if title == "" {
227
title = "Portal Proxy Gateway"
frontend/src/components/Header.tsx
+51
-34
@@ -1,3 +1,4 @@
1
+import { useId } from "react";
2
import { LogOut } from "lucide-react";
3
import { Button } from "@/components/ui/button";
4
import {
@@ -14,33 +15,42 @@ interface HeaderProps {
15
title?: string;
16
isAdmin?: boolean;
17
onLogout?: () => void;
17
- ctaLabel?: string;
18
}
19
20
const repoURL = "https://github.com/gosuda/portal";
21
-const architectureURL = `${repoURL}/blob/main/docs/architecture.md`;
22
-
21
export function Header({
22
title = "PORTAL",
23
isAdmin,
24
onLogout,
27
- ctaLabel = "Add Your Server",
25
}: HeaderProps) {
26
const releaseVersion = getReleaseVersion();
27
+ const logoGradientId = useId();
28
29
return (
30
<header className="flex flex-wrap items-center justify-between gap-4 px-1 py-2 sm:px-2">
31
<div className="flex items-center gap-4 text-foreground">
34
- <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-background text-primary">
32
+ <div className="flex h-12 w-12 items-center justify-center">
33
<svg
34
xmlns="http://www.w3.org/2000/svg"
35
width="26"
36
height="26"
37
viewBox="0 0 906.26 1457.543"
40
- className="text-primary"
38
>
39
+ <defs>
40
+ <linearGradient
41
+ id={logoGradientId}
42
+ x1="0%"
43
+ y1="0%"
44
+ x2="100%"
45
+ y2="100%"
46
+ >
47
+ <stop offset="0%" stopColor="#22c55e" />
48
+ <stop offset="55%" stopColor="#14b8a6" />
49
+ <stop offset="100%" stopColor="#0ea5e9" />
50
+ </linearGradient>
51
+ </defs>
52
<path
43
- fill="currentColor"
53
+ fill={`url(#${logoGradientId})`}
54
d="M254.854 137.158c-34.46 84.407-88.363 149.39-110.934 245.675 90.926-187.569 308.397-483.654 554.729-348.685 135.487 74.216 194.878 270.78 206.058 467.566 21.924 385.996-190.977 853.604-467.585 943.057-174.879 56.543-307.375-86.447-364.527-198.115-176.498-344.82 2.041-910.077 182.259-1109.498zm198.13 7.918C202.61 280.257 4.622 968.542 207.322 1270.414c51.713 77.029 194.535 160.648 285.294 71.318-209.061 31.529-288.389-176.143-301.145-340.765 31.411 147.743 139.396 326.12 309.075 253.588 251.957-107.723 376.778-648.46 269.433-966.817 22.394 134.616 15.572 317.711-47.551 412.087 86.655-230.615 7.903-704.478-269.444-554.749z"
55
/>
56
</svg>
@@ -57,9 +67,6 @@ export function Header({
67
</span>
68
)}
69
</div>
60
- <p className="text-sm text-text-muted">
61
- Instant public URLs for local and private apps.
62
- </p>
70
</div>
71
</div>
72
@@ -67,39 +74,49 @@ export function Header({
74
{!isAdmin && (
75
<nav className="hidden items-center gap-5 text-sm font-medium text-text-muted md:flex">
76
<a href="#live-servers" className="transition-colors hover:text-foreground">
70
- Discover
77
+ Live apps
78
</a>
79
<a
73
- href={repoURL}
74
- target="_blank"
75
- rel="noopener noreferrer"
80
+ href="#official-registry"
81
className="transition-colors hover:text-foreground"
82
>
78
- Docs
79
- </a>
80
- <a
81
- href={architectureURL}
82
- target="_blank"
83
- rel="noopener noreferrer"
84
- className="transition-colors hover:text-foreground"
85
- >
86
- Architecture
83
+ Official registry
84
</a>
85
</nav>
86
)}
87
91
- <TunnelCommandModal
92
- trigger={
93
- <Button
94
- className={clsx(
95
- "h-11 cursor-pointer rounded-full px-5 text-base font-semibold shadow-none",
96
- isAdmin && "hidden sm:inline-flex"
97
- )}
88
+ {isAdmin ? (
89
+ <TunnelCommandModal
90
+ trigger={
91
+ <Button
92
+ className={clsx(
93
+ "h-11 cursor-pointer rounded-full px-5 text-base font-semibold shadow-none",
94
+ "hidden sm:inline-flex"
95
+ )}
96
+ >
97
+ <span className="truncate">Add Your Server</span>
98
+ </Button>
99
+ }
100
+ />
101
+ ) : (
102
+ <a
103
+ href={repoURL}
104
+ target="_blank"
105
+ rel="noopener noreferrer"
106
+ className="text-foreground transition-colors hover:text-primary"
107
+ aria-label="View source on GitHub"
108
+ >
109
+ <svg
110
+ height="32"
111
+ width="32"
112
+ viewBox="0 0 24 24"
113
+ fill="currentColor"
114
+ className="opacity-80 hover:opacity-100"
115
>
99
- <span className="truncate">{ctaLabel}</span>
100
- </Button>
101
- }
102
- />
116
+ <path d="M12 1C5.923 1 1 5.923 1 12c0 4.867 3.149 8.979 7.521 10.436.55.096.756-.233.756-.522 0-.262-.013-1.128-.013-2.049-2.764.509-3.479-.674-3.699-1.292-.124-.317-.66-1.293-1.127-1.554-.385-.207-.936-.715-.014-.729.866-.014 1.485.797 1.691 1.128.99 1.663 2.571 1.196 3.204.907.096-.715.385-1.196.701-1.471-2.448-.275-5.005-1.224-5.005-5.432 0-1.196.426-2.186 1.128-2.956-.111-.275-.496-1.402.11-2.915 0 0 .921-.288 3.024 1.128a10.193 10.193 0 0 1 2.75-.371c.936 0 1.871.123 2.75.371 2.104-1.43 3.025-1.128 3.025-1.128.605 1.513.221 2.64.111 2.915.701.77 1.127 1.747 1.127 2.956 0 4.222-2.571 5.157-5.019 5.432.399.344.743 1.004.743 2.035 0 1.471-.014 2.654-.014 3.025 0 .289.206.632.756.522C19.851 20.979 23 16.854 23 12c0-6.077-4.922-11-11-11Z" />
117
+ </svg>
118
+ </a>
119
+ )}
120
121
{isAdmin && onLogout && (
122
<TooltipProvider>
frontend/src/components/ServerListView.tsx
+135
-32
@@ -22,6 +22,13 @@ import {
22
export type BanFilter = "all" | "banned" | "active";
23
type ListServer = ClientServer | AdminServer;
24
25
+interface OfficialRegistryDocument {
26
+ relays?: string[];
27
+}
28
+
29
+const OFFICIAL_REGISTRY_URL =
30
+ "https://raw.githubusercontent.com/gosuda/portal/main/registry.json";
31
+
32
interface ServerListViewProps {
33
title?: string;
34
searchQuery: string;
@@ -99,6 +106,8 @@ export function ServerListView({
106
onLogout,
107
}: ServerListViewProps) {
108
const [showFilterModal, setShowFilterModal] = useState(false);
109
+ const [officialRegistryRelays, setOfficialRegistryRelays] = useState<string[] | null>(null);
110
+ const [officialRegistryFailed, setOfficialRegistryFailed] = useState(false);
111
const [selectedLeaseIds, setSelectedLeaseIds] = useState<Set<string>>(
112
new Set()
113
);
@@ -129,8 +138,6 @@ export function ServerListView({
138
})),
139
[serverItems]
140
);
132
- const featuredPublicRows = serverRows.slice(0, 3);
133
- const remainingPublicRows = serverRows.slice(3);
141
142
const allLeaseIds = useMemo(
143
() => [
@@ -175,9 +182,57 @@ export function ServerListView({
182
setSelectedLeaseIds((prev) => (prev.size === 0 ? prev : new Set()));
183
}, [isAdmin]);
184
185
+ useEffect(() => {
186
+ if (isAdmin) {
187
+ return;
188
+ }
189
+
190
+ let cancelled = false;
191
+
192
+ const loadOfficialRegistry = async () => {
193
+ try {
194
+ const response = await fetch(OFFICIAL_REGISTRY_URL, {
195
+ headers: { Accept: "application/json" },
196
+ });
197
+ if (!response.ok) {
198
+ throw new Error(`registry request failed with status ${response.status}`);
199
+ }
200
+ const document = (await response.json()) as OfficialRegistryDocument;
201
+ if (cancelled) {
202
+ return;
203
+ }
204
+
205
+ setOfficialRegistryFailed(false);
206
+ setOfficialRegistryRelays(
207
+ Array.isArray(document.relays)
208
+ ? document.relays.filter(
209
+ (relay): relay is string =>
210
+ typeof relay === "string" && relay.trim().length > 0
211
+ )
212
+ : []
213
+ );
214
+ } catch (error) {
215
+ if (!cancelled) {
216
+ console.error("Failed to load official registry", error);
217
+ setOfficialRegistryFailed(true);
218
+ setOfficialRegistryRelays([]);
219
+ }
220
+ }
221
+ };
222
+
223
+ void loadOfficialRegistry();
224
+
225
+ return () => {
226
+ cancelled = true;
227
+ };
228
+ }, [isAdmin]);
229
+
230
const isAllSelected =
231
allLeaseIds.length > 0 &&
232
allLeaseIds.every((id) => selectedLeaseIds.has(id));
233
+ const officialRegistryURL = OFFICIAL_REGISTRY_URL;
234
+ const officialRegistryAvailable =
235
+ officialRegistryRelays !== null && officialRegistryRelays.length > 0;
236
237
const handleSelectAll = () => {
238
if (isAllSelected) {
@@ -358,12 +413,7 @@ export function ServerListView({
413
) : (
414
<>
415
<div className="sticky top-0 z-20 bg-background/95 pt-5">
361
- <Header
362
- title={title}
363
- isAdmin={isAdmin}
364
- onLogout={onLogout}
365
- ctaLabel="Get Started"
366
- />
416
+ <Header title={title} isAdmin={isAdmin} onLogout={onLogout} />
417
</div>
418
<main className="z-0 flex-1 px-4 pb-14 pt-6 sm:px-6">
419
<LandingHero />
@@ -371,11 +421,11 @@ export function ServerListView({
421
<section
422
id="live-servers"
423
aria-labelledby="live-servers-title"
374
- className="mt-8 border-t border-border pt-8"
424
+ className="mt-8 scroll-mt-24 border-t border-border pt-8"
425
>
426
<div className="space-y-2">
427
<p className="text-sm font-semibold uppercase tracking-[0.3em] text-primary">
378
- Discovery
428
+ Live apps
429
</p>
430
<h2
431
id="live-servers-title"
@@ -383,32 +433,17 @@ export function ServerListView({
433
>
434
Browse live apps
435
</h2>
386
- <p className="max-w-2xl text-sm leading-6 text-text-muted">
387
- Explore public services exposed through Portal.
388
- </p>
436
</div>
437
438
{serverRows.length > 0 ? (
392
- <>
393
- <div className="mt-6">
394
- <div className={gridClasses}>
395
- {featuredPublicRows.map(renderServerCard)}
396
- </div>
397
- </div>
398
-
399
- <div className="mt-4 border-t border-border pt-6">
400
- {searchBar}
401
- <div className="px-1 pt-3 text-sm text-text-muted">
402
- {filteredServers.length.toLocaleString()} services
403
- visible
404
- </div>
405
- {remainingPublicRows.length > 0 && (
406
- <div className={gridClasses}>
407
- {remainingPublicRows.map(renderServerCard)}
408
- </div>
409
- )}
439
+ <div className="mt-6 border-t border-border pt-6">
440
+ {searchBar}
441
+ <div className="px-1 pt-3 text-sm text-text-muted">
442
+ {filteredServers.length.toLocaleString()} services
443
+ visible
444
</div>
411
- </>
445
+ {serverGrid}
446
+ </div>
447
) : (
448
<div className="mt-6">
449
{searchBar}
@@ -423,6 +458,74 @@ export function ServerListView({
458
</div>
459
)}
460
</section>
461
+
462
+ <section
463
+ id="official-registry"
464
+ aria-labelledby="official-registry-title"
465
+ className="mt-8 scroll-mt-24 border-t border-border pt-8"
466
+ >
467
+ <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
468
+ <div className="space-y-2">
469
+ <h2
470
+ id="official-registry-title"
471
+ className="text-2xl font-semibold tracking-tight text-foreground"
472
+ >
473
+ Official registry
474
+ </h2>
475
+ <p className="max-w-2xl text-sm leading-6 text-text-muted">
476
+ Portal reads default public relays from this registry.
477
+ </p>
478
+ </div>
479
+ <a
480
+ href={officialRegistryURL}
481
+ target="_blank"
482
+ rel="noopener noreferrer"
483
+ className="inline-flex h-11 items-center justify-center rounded-full border border-border px-4 text-sm font-semibold text-foreground transition-colors hover:border-foreground"
484
+ >
485
+ Open registry.json
486
+ </a>
487
+ </div>
488
+
489
+ <div className="mt-5">
490
+ <div className="rounded-2xl border border-border bg-background px-4 py-4">
491
+ <div className="flex items-center justify-between gap-3">
492
+ <p className="text-xs font-semibold uppercase tracking-[0.2em] text-text-muted">
493
+ Relays
494
+ </p>
495
+ {officialRegistryAvailable && (
496
+ <span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-semibold text-text-muted">
497
+ {officialRegistryRelays.length}
498
+ </span>
499
+ )}
500
+ </div>
501
+
502
+ {officialRegistryRelays === null && !officialRegistryFailed ? (
503
+ <p className="mt-3 text-sm text-text-muted">
504
+ Loading official registry...
505
+ </p>
506
+ ) : officialRegistryAvailable &&
507
+ officialRegistryRelays.length > 0 ? (
508
+ <div className="mt-3 space-y-1.5">
509
+ {officialRegistryRelays.map((relay) => (
510
+ <a
511
+ key={relay}
512
+ href={relay}
513
+ target="_blank"
514
+ rel="noopener noreferrer"
515
+ className="block overflow-x-auto whitespace-nowrap font-mono text-sm text-foreground underline-offset-4 hover:underline"
516
+ >
517
+ {relay}
518
+ </a>
519
+ ))}
520
+ </div>
521
+ ) : (
522
+ <p className="mt-3 text-sm text-text-muted">
523
+ Registry entries are unavailable right now.
524
+ </p>
525
+ )}
526
+ </div>
527
+ </div>
528
+ </section>
529
</main>
530
</>
531
)}
frontend/src/components/TunnelCommandForm.tsx
+174
-55
@@ -1,11 +1,22 @@
1
-import { useEffect, useId, useMemo, useState, type ChangeEvent } from "react";
1
+import {
2
+ useEffect,
3
+ useId,
4
+ useMemo,
5
+ useState,
6
+ type ChangeEvent,
7
+ type KeyboardEvent,
8
+} from "react";
9
import { Check, Copy, X } from "lucide-react";
10
import { Input } from "@/components/ui/input";
11
+import { apiClient } from "@/lib/apiClient";
12
+import { API_PATHS } from "@/lib/apiPaths";
13
import { cn } from "@/lib/utils";
14
import {
15
buildDefaultTunnelName,
16
buildTunnelCommand,
17
+ buildTunnelDisplayCommand,
18
buildTunnelPreviewURL,
19
+ buildTunnelStatusHostname,
20
normalizeAbsoluteHTTPURL,
21
type TunnelCommandOS,
22
} from "@/lib/tunnelCommand";
@@ -16,6 +27,14 @@ interface TunnelCommandFormProps {
27
mode?: "full" | "hero";
28
}
29
30
+type TunnelStatus = "waiting" | "registered" | "alive";
31
+
32
+interface TunnelStatusResponse {
33
+ hostname: string;
34
+ registered: boolean;
35
+ service_alive: boolean;
36
+}
37
+
38
export function TunnelCommandForm({
39
className,
40
theme = "light",
@@ -66,6 +85,7 @@ export function TunnelCommandForm({
85
const [copied, setCopied] = useState(false);
86
const [os, setOs] = useState<TunnelCommandOS>("unix");
87
const [thumbnailURL, setThumbnailURL] = useState("");
88
+ const [tunnelStatus, setTunnelStatus] = useState<TunnelStatus>("waiting");
89
const resolvedNameSeed = useMemo(
90
() => `${nameSeed}:${nameShuffleKey}`,
91
[nameSeed, nameShuffleKey]
@@ -89,14 +109,38 @@ export function TunnelCommandForm({
109
return "Thumbnail must be an absolute http:// or https:// URL.";
110
}, [thumbnailURL, normalizedThumbnailURL]);
111
92
- const command = useMemo(
112
+ const copyCommand = useMemo(
113
() =>
114
buildTunnelCommand({
115
currentOrigin,
116
target,
117
name: effectiveName,
118
nameSeed,
99
- relayUrls: isHero ? [] : relayUrls,
119
+ relayUrls: isHero ? [currentOrigin] : relayUrls,
120
+ defaultRelays: isHero ? true : defaultRelays,
121
+ thumbnailURL: normalizedThumbnailURL,
122
+ os,
123
+ }),
124
+ [
125
+ currentOrigin,
126
+ defaultRelays,
127
+ effectiveName,
128
+ isHero,
129
+ nameSeed,
130
+ normalizedThumbnailURL,
131
+ os,
132
+ relayUrls,
133
+ target,
134
+ ]
135
+ );
136
+ const displayCommand = useMemo(
137
+ () =>
138
+ buildTunnelDisplayCommand({
139
+ currentOrigin,
140
+ target,
141
+ name: effectiveName,
142
+ nameSeed,
143
+ relayUrls: isHero ? [currentOrigin] : relayUrls,
144
defaultRelays: isHero ? true : defaultRelays,
145
thumbnailURL: normalizedThumbnailURL,
146
os,
@@ -117,6 +161,11 @@ export function TunnelCommandForm({
161
() => buildTunnelPreviewURL(currentOrigin, effectiveName, target, nameSeed),
162
[currentOrigin, effectiveName, nameSeed, target]
163
);
164
+ const statusHostname = useMemo(
165
+ () =>
166
+ buildTunnelStatusHostname(currentOrigin, effectiveName, target, nameSeed),
167
+ [currentOrigin, effectiveName, nameSeed, target]
168
+ );
169
170
useEffect(() => {
171
if (!copied) {
@@ -132,6 +181,47 @@ export function TunnelCommandForm({
181
};
182
}, [copied]);
183
184
+ useEffect(() => {
185
+ if (!isHero || statusHostname === "") {
186
+ return;
187
+ }
188
+
189
+ let cancelled = false;
190
+
191
+ const poll = async () => {
192
+ try {
193
+ const params = new URLSearchParams({ hostname: statusHostname });
194
+ const statusResponse = await apiClient.get<TunnelStatusResponse>(
195
+ `${API_PATHS.tunnel.status}?${params.toString()}`
196
+ );
197
+ if (cancelled) {
198
+ return;
199
+ }
200
+
201
+ if (!statusResponse.registered) {
202
+ setTunnelStatus("waiting");
203
+ return;
204
+ }
205
+ setTunnelStatus(statusResponse.service_alive ? "alive" : "registered");
206
+ } catch {
207
+ if (!cancelled) {
208
+ setTunnelStatus("waiting");
209
+ }
210
+ }
211
+ };
212
+
213
+ setTunnelStatus("waiting");
214
+ void poll();
215
+ const interval = window.setInterval(() => {
216
+ void poll();
217
+ }, 1500);
218
+
219
+ return () => {
220
+ cancelled = true;
221
+ window.clearInterval(interval);
222
+ };
223
+ }, [isHero, statusHostname]);
224
+
225
const addRelayURL = (url: string) => {
226
const trimmed = url.trim();
227
if (!trimmed || relayUrls.includes(trimmed)) {
@@ -151,7 +241,7 @@ export function TunnelCommandForm({
241
setRelayUrls((prev) => prev.filter((candidate) => candidate !== url));
242
};
243
154
- const handleURLKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
244
+ const handleURLKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
245
if (event.key === "Enter") {
246
event.preventDefault();
247
addRelayURL(urlInput);
@@ -165,7 +255,7 @@ export function TunnelCommandForm({
255
256
const handleCopy = async () => {
257
try {
168
- await navigator.clipboard.writeText(command);
258
+ await navigator.clipboard.writeText(copyCommand);
259
setCopied(true);
260
} catch (error) {
261
console.error("Failed to copy tunnel command", error);
@@ -195,44 +285,93 @@ export function TunnelCommandForm({
285
setIsAutoName(true);
286
setNameShuffleKey(next);
287
};
288
+ const tunnelStatusTone =
289
+ tunnelStatus === "alive"
290
+ ? isTerminal
291
+ ? "bg-green-400"
292
+ : "bg-green-600"
293
+ : tunnelStatus === "registered"
294
+ ? isTerminal
295
+ ? "bg-sky-400"
296
+ : "bg-sky-600"
297
+ : isTerminal
298
+ ? "bg-slate-500"
299
+ : "bg-slate-400";
300
+ const tunnelStatusHeadline =
301
+ tunnelStatus === "alive"
302
+ ? "This URL is live now"
303
+ : tunnelStatus === "registered"
304
+ ? "URL reserved"
305
+ : "Waiting";
306
+ const tunnelStatusLabel =
307
+ tunnelStatus === "alive"
308
+ ? ""
309
+ : tunnelStatus === "registered"
310
+ ? "Tunnel registered on this relay"
311
+ : "";
312
313
const commandSection = (
200
- <div className="space-y-2">
314
+ <div className={cn("space-y-2", isHero && "space-y-3")}>
315
<label
316
className={cn(
317
"text-sm font-medium",
318
isTerminal ? "text-slate-200" : "text-foreground"
319
)}
320
>
207
- {isHero ? "Command" : "Generated Command"}
321
+ {isHero ? "Copy this command" : "Generated Command"}
322
</label>
323
+ {isHero && (
324
+ <p className="text-xs leading-5 text-slate-400 sm:text-sm">
325
+ Copy and paste it into your terminal to publish a public URL.
326
+ </p>
327
+ )}
328
<div className="relative">
329
<pre
330
className={cn(
212
- "overflow-x-auto whitespace-pre-wrap break-all rounded-xl p-4 pr-12 font-mono text-sm leading-7",
331
+ "overflow-x-auto whitespace-pre-wrap break-all font-mono",
332
isTerminal
214
- ? "border border-white/10 bg-black/30 text-white"
215
- : "bg-border text-foreground"
333
+ ? isHero
334
+ ? "min-h-[104px] rounded-xl border border-green-status/20 bg-black/45 p-4 text-sm leading-7 text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]"
335
+ : "rounded-xl border border-white/10 bg-black/30 p-4 pr-12 text-sm leading-7 text-white"
336
+ : "rounded-xl bg-border p-4 pr-12 text-sm leading-7 text-foreground"
337
)}
338
>
218
- {command}
339
+ {displayCommand}
340
</pre>
341
+ {!isHero && (
342
+ <button
343
+ type="button"
344
+ onClick={handleCopy}
345
+ className={cn(
346
+ "absolute right-2 top-2 rounded-md p-2 transition-colors",
347
+ isTerminal ? "hover:bg-white/10" : "hover:bg-background/70"
348
+ )}
349
+ aria-label="Copy command"
350
+ >
351
+ {copied ? (
352
+ <Check className="h-4 w-4 text-green-600" />
353
+ ) : (
354
+ <Copy className="h-4 w-4 text-text-muted" />
355
+ )}
356
+ </button>
357
+ )}
358
+ </div>
359
+ {isHero && (
360
<button
361
type="button"
362
onClick={handleCopy}
363
className={cn(
224
- "absolute right-2 top-2 rounded-md p-2 transition-colors",
225
- isTerminal ? "hover:bg-white/10" : "hover:bg-background/70"
364
+ "inline-flex w-full items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold transition-colors",
365
+ copied
366
+ ? "bg-white text-slate-950"
367
+ : "bg-green-status text-slate-950 hover:bg-green-300"
368
)}
369
aria-label="Copy command"
370
>
229
- {copied ? (
230
- <Check className="h-4 w-4 text-green-600" />
231
- ) : (
232
- <Copy className="h-4 w-4 text-text-muted" />
233
- )}
371
+ {copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
372
+ <span>{copied ? "Copied" : "Copy command"}</span>
373
</button>
235
- </div>
374
+ )}
375
376
{isHero && (
377
<div
@@ -241,14 +380,15 @@ export function TunnelCommandForm({
380
isTerminal ? "border-white/10 bg-white/5" : "border-border bg-white"
381
)}
382
>
244
- <p
383
+ <div
384
className={cn(
246
- "text-xs font-semibold uppercase tracking-[0.24em]",
247
- isTerminal ? "text-slate-400" : "text-text-muted"
385
+ "flex items-center gap-2 text-sm font-semibold",
386
+ isTerminal ? "text-slate-200" : "text-foreground"
387
)}
388
>
250
- Public URL
251
- </p>
389
+ <span className={cn("h-2 w-2 rounded-full", tunnelStatusTone)} aria-hidden="true" />
390
+ <span>{tunnelStatusHeadline}</span>
391
+ </div>
392
<a
393
href={previewURL}
394
target="_blank"
@@ -260,25 +400,21 @@ export function TunnelCommandForm({
400
>
401
{previewURL}
402
</a>
403
+ {tunnelStatusLabel !== "" && (
404
+ <div
405
+ className={cn(
406
+ "flex items-center gap-2 text-xs font-medium",
407
+ isTerminal ? "text-slate-300" : "text-text-muted"
408
+ )}
409
+ >
410
+ <span>{tunnelStatusLabel}</span>
411
+ </div>
412
+ )}
413
</div>
414
)}
415
</div>
416
);
417
268
- const customizationSectionLabel =
269
- isHero ? (
270
- <div className="pt-1">
271
- <p
272
- className={cn(
273
- "text-xs font-semibold uppercase tracking-[0.24em]",
274
- isTerminal ? "text-slate-400" : "text-text-muted"
275
- )}
276
- >
277
- Customize
278
- </p>
279
- </div>
280
- ) : null;
281
-
418
const heroFieldLabelClass = cn(
419
"text-[11px] font-semibold uppercase tracking-[0.2em]",
420
isTerminal ? "text-slate-400" : "text-text-muted"
@@ -302,7 +438,6 @@ export function TunnelCommandForm({
438
return (
439
<div className={cn("space-y-5", className)}>
440
{isHero && commandSection}
305
- {customizationSectionLabel}
441
442
{isHero ? (
443
<div className="grid gap-3 sm:grid-cols-2">
@@ -515,22 +650,6 @@ export function TunnelCommandForm({
650
)}
651
652
<div className={cn("space-y-2", isHero && "pt-1")}>
518
- <label
519
- className={cn(
520
- isHero
521
- ? "text-[11px] font-semibold uppercase tracking-[0.2em]"
522
- : "text-sm font-medium",
523
- isTerminal
524
- ? isHero
525
- ? "text-slate-400"
526
- : "text-slate-200"
527
- : isHero
528
- ? "text-text-muted"
529
- : "text-foreground"
530
- )}
531
- >
532
- Operating System
533
- </label>
653
<div
654
className={cn(
655
"flex p-1",
frontend/src/components/TunnelCommandModal.tsx
+1
-1
@@ -20,7 +20,7 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
20
<DialogTrigger asChild>
21
{trigger || (
22
<Button className="cursor-pointer">
23
- <span className="truncate">Get Started</span>
23
+ <span className="truncate">Add Your Server</span>
24
</Button>
25
)}
26
</DialogTrigger>
frontend/src/index.css
+6
@@ -68,6 +68,12 @@
68
background-color: var(--background);
69
}
70
71
+ @media (prefers-reduced-motion: no-preference) {
72
+ html {
73
+ scroll-behavior: smooth;
74
+ }
75
+ }
76
+
77
body {
78
min-height: 100vh;
79
background:
frontend/src/lib/apiPaths.ts
+3
@@ -17,6 +17,9 @@ export const API_PATHS = {
17
domain: "/sdk/domain",
18
connect: "/sdk/connect",
19
},
20
+ tunnel: {
21
+ status: "/tunnel/status",
22
+ },
23
healthz: "/healthz",
24
install: {
25
shell: "/install.sh",
frontend/src/lib/tunnelCommand.test.ts
new
+68
@@ -0,0 +1,68 @@
1
+import { describe, expect, it } from "vitest";
2
+
3
+import {
4
+ buildTunnelCommand,
5
+ buildTunnelDisplayCommand,
6
+ buildTunnelPreviewURL,
7
+} from "@/lib/tunnelCommand";
8
+
9
+describe("tunnelCommand", () => {
10
+ it("keeps copied unix commands flat and directly pasteable", () => {
11
+ const options = {
12
+ currentOrigin: "https://localhost:4017",
13
+ target: "3000",
14
+ name: "My App",
15
+ nameSeed: "web_portal",
16
+ relayUrls: ["https://localhost:4017"],
17
+ defaultRelays: true,
18
+ thumbnailURL: "",
19
+ os: "unix" as const,
20
+ };
21
+
22
+ const command = buildTunnelCommand(options);
23
+
24
+ expect(command).toBe(
25
+ [
26
+ "curl -ksSL https://localhost:4017/install.sh | bash",
27
+ "portal expose --name my-app --relays https://localhost:4017 3000",
28
+ ].join("\n")
29
+ );
30
+ expect(command).not.toContain(" \\\n");
31
+ expect(command).not.toContain("\n --name");
32
+ });
33
+
34
+ it("uses the same flat layout for display and copy", () => {
35
+ const options = {
36
+ currentOrigin: "https://relay.example.com",
37
+ target: "localhost:3000",
38
+ name: "",
39
+ nameSeed: "web_portal",
40
+ relayUrls: ["https://relay.example.com"],
41
+ defaultRelays: false,
42
+ thumbnailURL: "https://example.com/thumb.png",
43
+ os: "windows" as const,
44
+ };
45
+
46
+ expect(buildTunnelDisplayCommand(options)).toBe(buildTunnelCommand(options));
47
+ });
48
+
49
+ it("uses the relay root host for preview URLs instead of a placeholder host", () => {
50
+ expect(
51
+ buildTunnelPreviewURL(
52
+ "https://localhost:4017",
53
+ "my-app",
54
+ "3000",
55
+ "web_portal"
56
+ )
57
+ ).toBe("https://my-app.localhost");
58
+
59
+ expect(
60
+ buildTunnelPreviewURL(
61
+ "https://portal.example.com",
62
+ "my-app",
63
+ "3000",
64
+ "web_portal"
65
+ )
66
+ ).toBe("https://my-app.portal.example.com");
67
+ });
68
+});
frontend/src/lib/tunnelCommand.ts
+94
-17
@@ -17,8 +17,6 @@ export interface TunnelCommandOptions {
17
os: TunnelCommandOS;
18
}
19
20
-const DEFAULT_PREVIEW_HOST = "portal.run";
21
-
20
export function buildDefaultTunnelName(
21
target: string,
22
nameSeed: string
@@ -36,6 +34,58 @@ export function buildTunnelCommand({
34
target,
35
thumbnailURL,
36
}: TunnelCommandOptions): string {
37
+ const { installLine, exposeHead, exposeOptions } = buildTunnelCommandParts({
38
+ currentOrigin,
39
+ defaultRelays,
40
+ name,
41
+ nameSeed,
42
+ os,
43
+ relayUrls,
44
+ target,
45
+ thumbnailURL,
46
+ });
47
+
48
+ return joinTunnelCommand(installLine, exposeHead, exposeOptions);
49
+}
50
+
51
+export function buildTunnelDisplayCommand({
52
+ currentOrigin,
53
+ defaultRelays,
54
+ name,
55
+ nameSeed,
56
+ os,
57
+ relayUrls,
58
+ target,
59
+ thumbnailURL,
60
+}: TunnelCommandOptions): string {
61
+ const { installLine, exposeHead, exposeOptions } = buildTunnelCommandParts({
62
+ currentOrigin,
63
+ defaultRelays,
64
+ name,
65
+ nameSeed,
66
+ os,
67
+ relayUrls,
68
+ target,
69
+ thumbnailURL,
70
+ });
71
+
72
+ return joinTunnelCommand(installLine, exposeHead, exposeOptions);
73
+}
74
+
75
+function buildTunnelCommandParts({
76
+ currentOrigin,
77
+ defaultRelays,
78
+ name,
79
+ nameSeed,
80
+ os,
81
+ relayUrls,
82
+ target,
83
+ thumbnailURL,
84
+}: TunnelCommandOptions): {
85
+ installLine: string;
86
+ exposeHead: string;
87
+ exposeOptions: string[];
88
+} {
89
const targetValue = target.trim() === "" ? "3000" : target.trim();
90
const nameValue = resolveExposeName(name, targetValue, nameSeed);
91
const relayURLValue =
@@ -65,18 +115,22 @@ export function buildTunnelCommand({
115
}
116
117
if (os === "windows") {
68
- return [
69
- `$ProgressPreference = 'SilentlyContinue'`,
70
- `irm ${formatToken(installPowerShellURL, os)} | iex`,
71
- `portal expose ${[...exposeArgs, formatToken(targetValue, os)].join(" ")}`,
72
- ].join("\n");
118
+ return {
119
+ installLine: [
120
+ `$ProgressPreference = 'SilentlyContinue'`,
121
+ `irm ${formatToken(installPowerShellURL, os)} | iex`,
122
+ ].join("\n"),
123
+ exposeHead: "portal expose",
124
+ exposeOptions: [...exposeArgs, formatToken(targetValue, os)],
125
+ };
126
}
127
128
const curlFlags = isLocalRelayOrigin(currentOrigin) ? "-ksSL" : "-sSL";
76
- return [
77
- `curl ${curlFlags} ${formatToken(installScriptURL, os)} | bash`,
78
- `portal expose ${[...exposeArgs, formatToken(targetValue, os)].join(" ")}`,
79
- ].join("\n");
129
+ return {
130
+ installLine: `curl ${curlFlags} ${formatToken(installScriptURL, os)} | bash`,
131
+ exposeHead: "portal expose",
132
+ exposeOptions: [...exposeArgs, formatToken(targetValue, os)],
133
+ };
134
}
135
136
export function buildTunnelPreviewURL(
@@ -90,6 +144,20 @@ export function buildTunnelPreviewURL(
144
return `https://${subdomain}.${baseHost}`;
145
}
146
147
+export function buildTunnelStatusHostname(
148
+ origin: string,
149
+ name: string,
150
+ target: string,
151
+ nameSeed: string
152
+): string {
153
+ const relayHost = getRelayOriginHost(origin);
154
+ if (relayHost === "") {
155
+ return "";
156
+ }
157
+ const subdomain = resolveExposeName(name, target, nameSeed);
158
+ return `${subdomain}.${relayHost}`;
159
+}
160
+
161
export function normalizeAbsoluteHTTPURL(raw: string): string {
162
const trimmed = raw.trim();
163
if (trimmed === "") {
@@ -108,15 +176,16 @@ export function normalizeAbsoluteHTTPURL(raw: string): string {
176
}
177
178
function getTunnelBaseHost(origin: string): string {
179
+ const relayHost = getRelayOriginHost(origin);
180
+ return relayHost;
181
+}
182
+
183
+function getRelayOriginHost(origin: string): string {
184
try {
185
const parsed = new URL(origin);
113
- const hostname = parsed.hostname.trim().toLowerCase();
114
- if (isLocalRelayHostname(hostname)) {
115
- return DEFAULT_PREVIEW_HOST;
116
- }
117
- return hostname;
186
+ return parsed.hostname.trim().toLowerCase();
187
} catch {
119
- return DEFAULT_PREVIEW_HOST;
188
+ return "";
189
}
190
}
191
@@ -152,3 +221,11 @@ function formatToken(value: string, os: TunnelCommandOS): string {
221
}
222
return os === "windows" ? quotePowerShellValue(value) : quoteShellValue(value);
223
}
224
+
225
+function joinTunnelCommand(
226
+ installLine: string,
227
+ exposeHead: string,
228
+ exposeOptions: string[]
229
+): string {
230
+ return [installLine, [exposeHead, ...exposeOptions].join(" ")].join("\n");
231
+}
portal/server.go
+12
@@ -207,6 +207,18 @@ func (s *Server) LeaseSnapshots() []types.Lease {
207
return snapshots
208
}
209
210
+func (s *Server) LeaseSnapshotByHostname(hostname string) (types.Lease, bool) {
211
+ if s == nil || s.registry == nil {
212
+ return types.Lease{}, false
213
+ }
214
+
215
+ record, ok := s.registry.Lookup(hostname)
216
+ if !ok || record == nil || time.Now().After(record.ExpiresAt) {
217
+ return types.Lease{}, false
218
+ }
219
+ return s.registry.Snapshot(record), true
220
+}
221
+
222
func (s *Server) prepareAPITLS(ctx context.Context) (keyless.TLSMaterialConfig, *acme.Manager, error) {
223
acmeCfg := s.cfg.ACME
224
if baseDomain := utils.NormalizeHostname(acmeCfg.BaseDomain); baseDomain != "" && baseDomain != s.rootHost {
types/api.go
+6
@@ -88,6 +88,12 @@ type DomainResponse struct {
88
Version string `json:"version"`
89
}
90
91
+type TunnelStatusResponse struct {
92
+ Hostname string `json:"hostname"`
93
+ Registered bool `json:"registered"`
94
+ ServiceAlive bool `json:"service_alive"`
95
+}
96
+
97
type AdminLoginRequest struct {
98
Key string `json:"key"`
99
}
types/paths.go
+2
@@ -21,6 +21,8 @@ const (
21
PathInstallPowerShell = "/install.ps1"
22
PathInstallBinPrefix = "/install/bin/"
23
24
+ PathTunnelStatus = "/tunnel/status"
25
+
26
PathSDKPrefix = "/sdk/"
27
PathSDKDomain = "/sdk/domain"
28
PathSDKRegister = "/sdk/register"