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"