feat: add cloudflare auto-tunnel onboarding flow and bump version to v0.9.81

This commit is contained in:
haorwen
2026-06-21 13:11:22 +08:00
parent 497e0f309e
commit 55611facc4
17 changed files with 532 additions and 55 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "vocechat-web",
"version": "0.9.80",
"version": "0.9.81",
"homepage": "https://voce.chat",
"dependencies": {
"@metamask/onboarding": "^1.0.1",
+11 -1
View File
@@ -36,6 +36,16 @@
"admin_account": "Admin-Konto",
"who_sign_up": "Registrierungsberechtigung",
"invites": "Einladungen",
"update_domain_tip": "Falsche Domain? Hier aktualisieren:"
"update_domain_tip": "Falsche Domain? Hier aktualisieren:",
"tunnel_initializing": "Initializing Tunnel service...",
"tunnel_dns_warning": "Waiting for DNS propagation. If access fails, try clearing your DNS cache and retrying.",
"tunnel_redirect_countdown": "Redirecting to public address in {{seconds}}s...",
"tunnel_domain_assigned": "A temporary public domain has been assigned to you",
"tunnel_opt_in_title": "Get an automatically assigned public domain?",
"tunnel_opt_in_yes": "Yes",
"tunnel_opt_in_skip": "Skip",
"tunnel_progress": "Starting tunnel...",
"tunnel_error": "Tunnel error",
"tunnel_get_domain": "Get Public Domain"
}
}
+11 -1
View File
@@ -36,6 +36,16 @@
"admin_account": "Admin Account",
"who_sign_up": "Who Can Sign Up",
"invites": "Invites",
"update_domain_tip": "Incorrect link domain?Please update it here:"
"update_domain_tip": "Incorrect link domain?Please update it here:",
"tunnel_initializing": "Initializing Tunnel service...",
"tunnel_dns_warning": "Waiting for DNS propagation. If access fails, try clearing your DNS cache and retrying.",
"tunnel_redirect_countdown": "Redirecting to public address in {{seconds}}s...",
"tunnel_domain_assigned": "A temporary public domain has been assigned to you",
"tunnel_opt_in_title": "Get an automatically assigned public domain?",
"tunnel_opt_in_yes": "Yes",
"tunnel_opt_in_skip": "Skip",
"tunnel_progress": "Starting tunnel...",
"tunnel_error": "Tunnel error",
"tunnel_get_domain": "Get Public Domain"
}
}
+11 -1
View File
@@ -36,6 +36,16 @@
"admin_account": "Cuenta de Administrador",
"who_sign_up": "Quién Puede Registrarse",
"invites": "Invitaciones",
"update_domain_tip": "¿Enlace de dominio incorrecto? Actualízalo aquí:"
"update_domain_tip": "¿Enlace de dominio incorrecto? Actualízalo aquí:",
"tunnel_initializing": "Initializing Tunnel service...",
"tunnel_dns_warning": "Waiting for DNS propagation. If access fails, try clearing your DNS cache and retrying.",
"tunnel_redirect_countdown": "Redirecting to public address in {{seconds}}s...",
"tunnel_domain_assigned": "A temporary public domain has been assigned to you",
"tunnel_opt_in_title": "Get an automatically assigned public domain?",
"tunnel_opt_in_yes": "Yes",
"tunnel_opt_in_skip": "Skip",
"tunnel_progress": "Starting tunnel...",
"tunnel_error": "Tunnel error",
"tunnel_get_domain": "Get Public Domain"
}
}
+11 -1
View File
@@ -36,6 +36,16 @@
"admin_account": "Compte Administrateur",
"who_sign_up": "Qui Peut S'inscrire",
"invites": "Invitations",
"update_domain_tip": "Lien de domaine incorrect ? Veuillez le mettre à jour ici :"
"update_domain_tip": "Lien de domaine incorrect ? Veuillez le mettre à jour ici :",
"tunnel_initializing": "Initializing Tunnel service...",
"tunnel_dns_warning": "Waiting for DNS propagation. If access fails, try clearing your DNS cache and retrying.",
"tunnel_redirect_countdown": "Redirecting to public address in {{seconds}}s...",
"tunnel_domain_assigned": "A temporary public domain has been assigned to you",
"tunnel_opt_in_title": "Get an automatically assigned public domain?",
"tunnel_opt_in_yes": "Yes",
"tunnel_opt_in_skip": "Skip",
"tunnel_progress": "Starting tunnel...",
"tunnel_error": "Tunnel error",
"tunnel_get_domain": "Get Public Domain"
}
}
+11 -1
View File
@@ -36,6 +36,16 @@
"admin_account": "Conta de administrador",
"who_sign_up": "Quem pode se inscrever",
"invites": "Convites",
"update_domain_tip": "Domínio de link incorreto? Atualize-o aqui:"
"update_domain_tip": "Domínio de link incorreto? Atualize-o aqui:",
"tunnel_initializing": "Initializing Tunnel service...",
"tunnel_dns_warning": "Waiting for DNS propagation. If access fails, try clearing your DNS cache and retrying.",
"tunnel_redirect_countdown": "Redirecting to public address in {{seconds}}s...",
"tunnel_domain_assigned": "A temporary public domain has been assigned to you",
"tunnel_opt_in_title": "Get an automatically assigned public domain?",
"tunnel_opt_in_yes": "Yes",
"tunnel_opt_in_skip": "Skip",
"tunnel_progress": "Starting tunnel...",
"tunnel_error": "Tunnel error",
"tunnel_get_domain": "Get Public Domain"
}
}
+11 -1
View File
@@ -36,6 +36,16 @@
"admin_account": "Учетная запись администратора",
"who_sign_up": "Кто может зарегистрироваться",
"invites": "Приглашения",
"update_domain_tip": "Неправильный домен ссылки? Пожалуйста, обновите его здесь:"
"update_domain_tip": "Неправильный домен ссылки? Пожалуйста, обновите его здесь:",
"tunnel_initializing": "Initializing Tunnel service...",
"tunnel_dns_warning": "Waiting for DNS propagation. If access fails, try clearing your DNS cache and retrying.",
"tunnel_redirect_countdown": "Redirecting to public address in {{seconds}}s...",
"tunnel_domain_assigned": "A temporary public domain has been assigned to you",
"tunnel_opt_in_title": "Get an automatically assigned public domain?",
"tunnel_opt_in_yes": "Yes",
"tunnel_opt_in_skip": "Skip",
"tunnel_progress": "Starting tunnel...",
"tunnel_error": "Tunnel error",
"tunnel_get_domain": "Get Public Domain"
}
}
+11 -1
View File
@@ -36,6 +36,16 @@
"admin_account": "Yönetici Hesabı",
"who_sign_up": "Kimler kaydolabilir",
"invites": "Davetler",
"update_domain_tip": "Alan Adı bağlantısı yanlış mı? Lütfen buradan güncelleyin:"
"update_domain_tip": "Alan Adı bağlantısı yanlış mı? Lütfen buradan güncelleyin:",
"tunnel_initializing": "Initializing Tunnel service...",
"tunnel_dns_warning": "Waiting for DNS propagation. If access fails, try clearing your DNS cache and retrying.",
"tunnel_redirect_countdown": "Redirecting to public address in {{seconds}}s...",
"tunnel_domain_assigned": "A temporary public domain has been assigned to you",
"tunnel_opt_in_title": "Get an automatically assigned public domain?",
"tunnel_opt_in_yes": "Yes",
"tunnel_opt_in_skip": "Skip",
"tunnel_progress": "Starting tunnel...",
"tunnel_error": "Tunnel error",
"tunnel_get_domain": "Get Public Domain"
}
}
+11 -1
View File
@@ -36,6 +36,16 @@
"admin_account": "管理员账号",
"who_sign_up": "注册权限",
"invites": "邀请",
"update_domain_tip": "链接域名有问题?可在此更新设置:"
"update_domain_tip": "链接域名有问题?可在此更新设置:",
"tunnel_initializing": "正在初始化 Tunnel 服务...",
"tunnel_dns_warning": "正在等待 DNS 传播,如果访问失败请尝试清除 DNS 缓存后再试",
"tunnel_redirect_countdown": "{{seconds}} 秒后重定向到公网地址...",
"tunnel_domain_assigned": "已为您分配了一个临时的公网域名",
"tunnel_opt_in_title": "获取自动分配的公网域名?",
"tunnel_opt_in_yes": "是",
"tunnel_opt_in_skip": "跳过",
"tunnel_progress": "正在启动 Tunnel...",
"tunnel_error": "Tunnel 出错",
"tunnel_get_domain": "获取公网域名"
}
}
+2
View File
@@ -31,12 +31,14 @@ const whiteList = [
"preCheckFileFromUrl",
"passkeyLoginStart",
"passkeyLoginFinish",
"getAutoTunnelInfo",
];
const whiteList401 = [
"getAgoraVoicingList",
"getAgoraChannels",
"getGoogleAuthConfig",
"getGithubAuthConfig",
"getAutoTunnelInfo",
];
const errorWhiteList = [
"preCheckFileFromUrl",
+6
View File
@@ -7,6 +7,7 @@ import {
AgoraConfig,
AgoraTokenResponse,
AgoraVoicingListResponse,
AutoTunnelInfo,
CloudflaredStatus,
CreateAdminDTO,
FirebaseConfig,
@@ -493,6 +494,9 @@ export const serverApi = createApi({
responseHandler: "text",
}),
}),
getAutoTunnelInfo: builder.query<AutoTunnelInfo, void>({
query: () => ({ url: `/admin/cloudflared/auto_info` }),
}),
}),
});
@@ -556,4 +560,6 @@ export const {
useDeleteGroupAnnouncementMutation,
useLazyGetCloudflaredStatusQuery,
useStopCloudflaredMutation,
useGetAutoTunnelInfoQuery,
useLazyGetAutoTunnelInfoQuery,
} = serverApi;
+10 -3
View File
@@ -3,17 +3,20 @@ import { useTranslation } from "react-i18next";
import { useWizard, Wizard } from "react-use-wizard";
import clsx from "clsx";
import steps from "./steps";
import { buildSteps } from "./steps";
import AdminAccount from "./steps/admin-account";
import DonePage from "./steps/done-page";
import GetPublicDomain from "./steps/get-public-domain";
import InviteLink from "./steps/invite-link";
import ServerName from "./steps/server-name";
import WelcomePage from "./steps/welcome-page";
import WhoCanSignUp from "./steps/who-can-sign-up";
import SelectLanguage from "../../components/Language";
import { useGetAutoTunnelInfoQuery } from "@/app/services/server";
const Navigator = () => {
const Navigator = ({ showTunnelStep }: { showTunnelStep: boolean }) => {
const { activeStep, goToStep } = useWizard();
const steps = buildSteps(showTunnelStep);
const canJumpTo = steps[activeStep]?.canJumpTo || [];
console.log("active step", activeStep);
@@ -51,17 +54,21 @@ const Navigator = () => {
export default function OnboardingPage() {
const { t } = useTranslation("welcome");
const [serverName, setServerName] = useState("");
const { data: autoInfo } = useGetAutoTunnelInfoQuery();
const showTunnelStep = autoInfo ? !autoInfo.auto_cftunnel : false;
return (
<>
<title>{t("onboarding.title") || ""}</title>
<div className="h-screen bg-neutral-100 dark:bg-neutral-900 overflow-y-auto">
<Wizard header={<Navigator />}>
<Wizard header={<Navigator showTunnelStep={showTunnelStep} />}>
<WelcomePage />
<ServerName serverName={serverName} setServerName={setServerName} />
<AdminAccount serverName={serverName} />
<WhoCanSignUp />
{/* lazy call invite link API */}
<InviteLink />
{showTunnelStep && <GetPublicDomain />}
<DonePage serverName={serverName} />
</Wizard>
</div>
+42 -28
View File
@@ -1,4 +1,3 @@
// import { useState } from "react";
import { t } from "i18next";
// `name` for in-code usage, `label` for display
@@ -8,33 +7,48 @@ export interface Step {
canJumpTo?: string[];
}
const steps: Step[] = [
{
name: "welcomePage",
label: t("welcome:onboarding.welcome_page")
},
{
name: "serverName",
label: t("welcome:onboarding.set_name")
},
{
name: "adminAccount",
label: t("welcome:onboarding.admin_account")
},
{
name: "whoCanSignUp",
label: t("welcome:onboarding.who_sign_up")
},
{
name: "inviteLink",
label: t("welcome:onboarding.invites"),
canJumpTo: ["whoCanSignUp"]
},
{
export function buildSteps(showTunnelStep: boolean): Step[] {
const base: Step[] = [
{
name: "welcomePage",
label: t("welcome:onboarding.welcome_page")
},
{
name: "serverName",
label: t("welcome:onboarding.set_name")
},
{
name: "adminAccount",
label: t("welcome:onboarding.admin_account")
},
{
name: "whoCanSignUp",
label: t("welcome:onboarding.who_sign_up")
},
{
name: "inviteLink",
label: t("welcome:onboarding.invites"),
canJumpTo: ["whoCanSignUp"]
},
];
if (showTunnelStep) {
base.push({
name: "getPublicDomain",
label: t("welcome:onboarding.tunnel_get_domain"),
canJumpTo: ["whoCanSignUp", "inviteLink"]
});
}
base.push({
name: "donePage",
label: t("welcome:onboarding.done"),
canJumpTo: ["whoCanSignUp", "inviteLink"]
}
];
canJumpTo: showTunnelStep
? ["whoCanSignUp", "inviteLink", "getPublicDomain"]
: ["whoCanSignUp", "inviteLink"]
});
export default steps;
return base;
}
export default buildSteps(false);
+22 -2
View File
@@ -3,20 +3,40 @@ import { useNavigate } from "react-router-dom";
import StyledButton from "@/components/styled/Button";
import PlayIcon from "@/assets/icons/play.svg?url";
import { useGetAutoTunnelInfoQuery } from "@/app/services/server";
export default function DonePage({ serverName }: { serverName: string }) {
const { t } = useTranslation("welcome", { keyPrefix: "onboarding" });
const navigate = useNavigate();
const { data: autoInfo } = useGetAutoTunnelInfoQuery();
const tunnelUrl =
autoInfo?.tunnel_status.status === "running" ? autoInfo.tunnel_status.url : null;
return (
<div className="h-full px-2 flex-center flex-col text-center md:w-[588px] m-auto dark:text-gray-100">
<span className="text-2xl font-bold mb-2">{t("done_welcome", { serverName })}</span>
<span className="text-sm mb-12">{t("done_title")}</span>
<span className="text-xl mb-12">
<span className="text-sm mb-6">{t("done_title")}</span>
<span className="text-xl mb-6">
<Trans i18nKey={"onboarding.done_desc"} ns={"welcome"}>
<span className="font-bold" />
</Trans>
</span>
{tunnelUrl && (
<div className="w-full mb-8 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 rounded-lg text-left">
<p className="text-sm font-semibold text-green-700 dark:text-green-300 mb-1">
{t("tunnel_domain_assigned")}
</p>
<a
href={tunnelUrl}
target="_blank"
rel="noreferrer"
className="font-mono text-sm text-green-600 dark:text-green-400 break-all hover:underline"
>
{tunnelUrl}
</a>
</div>
)}
<StyledButton
className="!w-32 h-auto flex flex-col items-center py-3"
onClick={() => navigate("/")}
@@ -0,0 +1,184 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useWizard } from "react-use-wizard";
import StyledButton from "@/components/styled/Button";
import { BASE_ORIGIN, tokenHeader } from "@/app/config";
import { getLocalAuthData } from "@/utils";
type Phase = "prompt" | "starting" | "done" | "error";
const COUNTDOWN_SECONDS = 10;
interface SseEvent {
event: string;
message: string;
url: string | null;
progress: number | null;
}
export default function GetPublicDomain() {
const { t } = useTranslation("welcome", { keyPrefix: "onboarding" });
const { nextStep } = useWizard();
const [phase, setPhase] = useState<Phase>("prompt");
const [logs, setLogs] = useState<string[]>([]);
const [tunnelUrl, setTunnelUrl] = useState<string | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [countdown, setCountdown] = useState(COUNTDOWN_SECONDS);
const abortRef = useRef<AbortController | null>(null);
const countRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
return () => {
abortRef.current?.abort();
if (countRef.current) clearInterval(countRef.current);
};
}, []);
const addLog = (msg: string) =>
setLogs((prev) => [...prev.slice(-29), msg]);
function startCountdown(url: string) {
setTunnelUrl(url);
setPhase("done");
let remaining = COUNTDOWN_SECONDS;
setCountdown(remaining);
countRef.current = setInterval(() => {
remaining -= 1;
setCountdown(remaining);
if (remaining <= 0) {
clearInterval(countRef.current!);
window.location.href = url;
}
}, 1000);
}
const handleYes = async () => {
setPhase("starting");
setLogs([]);
setErrorMsg(null);
const ctrl = new AbortController();
abortRef.current = ctrl;
try {
const { token } = getLocalAuthData();
const resp = await fetch(`${BASE_ORIGIN}/api/admin/cloudflared/start`, {
method: "POST",
headers: { [tokenHeader]: token },
signal: ctrl.signal,
});
if (!resp.ok || !resp.body) {
setPhase("error");
setErrorMsg(`HTTP ${resp.status}`);
return;
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split("\n\n");
buf = parts.pop() ?? "";
for (const part of parts) {
const line = part.trim();
if (!line.startsWith("data:")) continue;
try {
const evt: SseEvent = JSON.parse(line.slice("data:".length).trim());
addLog(evt.message);
if (evt.event === "url" || evt.event === "already_running") {
reader.cancel();
startCountdown(evt.url!);
return;
}
if (evt.event === "error" || evt.event === "stopped") {
reader.cancel();
setPhase("error");
setErrorMsg(evt.message);
return;
}
} catch {
// ignore malformed lines
}
}
}
} catch (err: any) {
if (err?.name !== "AbortError") {
setPhase("error");
setErrorMsg(String(err));
}
}
};
if (phase === "prompt") {
return (
<div className="flex-center flex-col h-full text-center gap-8 dark:text-gray-100 px-4">
<span className="text-2xl font-bold">{t("tunnel_opt_in_title")}</span>
<div className="flex gap-4">
<StyledButton className="w-24 h-11" onClick={handleYes}>
{t("tunnel_opt_in_yes")}
</StyledButton>
<StyledButton className="w-24 h-11 ghost" onClick={nextStep}>
{t("tunnel_opt_in_skip")}
</StyledButton>
</div>
</div>
);
}
if (phase === "starting") {
return (
<div className="flex-center flex-col h-full text-center gap-6 dark:text-gray-100 px-4">
<div className="w-10 h-10 border-4 border-primary-400 border-t-transparent rounded-full animate-spin" />
<span className="text-lg font-semibold">{t("tunnel_progress")}</span>
{logs.length > 0 && (
<div className="w-full md:w-[480px] p-3 bg-gray-50 dark:bg-gray-800 rounded-lg font-mono text-xs text-gray-500 dark:text-gray-400 max-h-40 overflow-y-auto text-left flex flex-col gap-0.5">
{logs.map((log, i) => (
<div key={i}>{log}</div>
))}
</div>
)}
</div>
);
}
if (phase === "done" && tunnelUrl) {
return (
<div className="flex-center flex-col h-full text-center gap-6 dark:text-gray-100 px-4 md:max-w-md m-auto">
<div className="w-10 h-10 border-4 border-green-400 border-t-transparent rounded-full animate-spin" />
<span className="font-mono text-sm break-all text-green-600 dark:text-green-400">
{tunnelUrl}
</span>
<span className="text-lg font-semibold">
{t("tunnel_redirect_countdown", { seconds: countdown })}
</span>
</div>
);
}
if (phase === "error") {
return (
<div className="flex-center flex-col h-full text-center gap-6 dark:text-gray-100 px-4">
<span className="text-lg font-semibold text-red-500">{t("tunnel_error")}</span>
{errorMsg && (
<p className="text-sm text-gray-500 dark:text-gray-400 break-all max-w-sm">
{errorMsg}
</p>
)}
<StyledButton className="w-24 h-11" onClick={nextStep}>
{t("tunnel_opt_in_skip")}
</StyledButton>
</div>
);
}
return null;
}
+172 -13
View File
@@ -1,23 +1,182 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useWizard } from "react-use-wizard";
import StyledButton from "@/components/styled/Button";
import PlayIcon from "@/assets/icons/play.svg?url";
import { BASE_ORIGIN } from "@/app/config";
import { useGetAutoTunnelInfoQuery } from "@/app/services/server";
type Phase = "loading" | "normal" | "initializing" | "dns_wait" | "redirecting";
const COUNTDOWN_SECONDS = 10;
interface SseEvent {
event: string;
message: string;
url: string | null;
progress: number | null;
}
export default function WelcomePage() {
const { t } = useTranslation("welcome", { keyPrefix: "onboarding" });
const { nextStep } = useWizard();
return (
<div className="flex-center flex-col h-full text-center dark:text-gray-100">
<span className="text-2xl mb-2 font-bold">{t("welcome")}</span>
<span className="text-sm mb-6">{t("welcome_desc")}</span>
<StyledButton
className="!w-32 h-auto flex flex-col gap-2 items-center py-3 text-sm"
onClick={nextStep}
>
<img src={PlayIcon} alt="play icon" />
<span>{t("start")}</span>
</StyledButton>
</div>
);
const { data: autoInfo, isLoading, isError } = useGetAutoTunnelInfoQuery();
const [phase, setPhase] = useState<Phase>("loading");
const [logs, setLogs] = useState<string[]>([]);
const [countdown, setCountdown] = useState(COUNTDOWN_SECONDS);
const [tunnelUrl, setTunnelUrl] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const countRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Determine phase once auto_info loads
useEffect(() => {
if (isLoading) return;
if (isError || !autoInfo?.auto_cftunnel) {
setPhase("normal");
return;
}
// AUTO_CFTUNNEL=true: check if already running
if (autoInfo.tunnel_status.status === "running" && autoInfo.tunnel_status.url) {
setTunnelUrl(autoInfo.tunnel_status.url);
startDnsCountdown(autoInfo.tunnel_status.url);
} else {
setPhase("initializing");
}
}, [isLoading, autoInfo]);
// Subscribe to SSE progress stream while initializing
useEffect(() => {
if (phase !== "initializing") return;
const ctrl = new AbortController();
abortRef.current = ctrl;
(async () => {
try {
const resp = await fetch(`${BASE_ORIGIN}/api/admin/cloudflared/auto_stream`, { signal: ctrl.signal });
if (!resp.ok || !resp.body) return;
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split("\n\n");
buf = parts.pop() ?? "";
for (const part of parts) {
const line = part.trim();
if (!line.startsWith("data:")) continue;
try {
const evt: SseEvent = JSON.parse(line.slice("data:".length).trim());
if (evt.message) {
setLogs((prev) => [...prev.slice(-29), evt.message]);
}
if (evt.event === "url" || evt.event === "already_running") {
reader.cancel();
startDnsCountdown(evt.url!);
return;
}
} catch {
// ignore malformed lines
}
}
}
} catch {
// AbortError on cleanup, or transient network error — stay in initializing
}
})();
return () => {
abortRef.current?.abort();
};
}, [phase]);
function startDnsCountdown(url: string) {
setPhase("dns_wait");
setCountdown(COUNTDOWN_SECONDS);
let remaining = COUNTDOWN_SECONDS;
countRef.current = setInterval(() => {
remaining -= 1;
setCountdown(remaining);
if (remaining <= 0) {
clearInterval(countRef.current!);
setPhase("redirecting");
window.location.href = url;
}
}, 1000);
}
useEffect(() => {
return () => {
if (countRef.current) clearInterval(countRef.current);
};
}, []);
if (phase === "loading" || isLoading) {
return (
<div className="flex-center flex-col h-full text-center dark:text-gray-100">
<div className="w-8 h-8 border-4 border-primary-400 border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (phase === "normal") {
return (
<div className="flex-center flex-col h-full text-center dark:text-gray-100">
<span className="text-2xl mb-2 font-bold">{t("welcome")}</span>
<span className="text-sm mb-6">{t("welcome_desc")}</span>
<StyledButton
className="!w-32 h-auto flex flex-col gap-2 items-center py-3 text-sm"
onClick={nextStep}
>
<img src={PlayIcon} alt="play icon" />
<span>{t("start")}</span>
</StyledButton>
</div>
);
}
if (phase === "initializing") {
return (
<div className="flex-center flex-col h-full text-center gap-6 dark:text-gray-100 px-4">
<div className="w-10 h-10 border-4 border-primary-400 border-t-transparent rounded-full animate-spin" />
<span className="text-lg font-semibold">{t("tunnel_initializing")}</span>
{logs.length > 0 && (
<div className="w-full md:w-[480px] p-3 bg-gray-50 dark:bg-gray-800 rounded-lg font-mono text-xs text-gray-500 dark:text-gray-400 max-h-40 overflow-y-auto text-left flex flex-col gap-0.5">
{logs.map((log, i) => (
<div key={i}>{log}</div>
))}
</div>
)}
</div>
);
}
if (phase === "dns_wait" || phase === "redirecting") {
return (
<div className="flex-center flex-col h-full text-center gap-6 dark:text-gray-100 px-4 md:max-w-md m-auto">
<div className="w-10 h-10 border-4 border-green-400 border-t-transparent rounded-full animate-spin" />
{tunnelUrl && (
<span className="font-mono text-sm break-all text-green-600 dark:text-green-400">
{tunnelUrl}
</span>
)}
<p className="text-sm text-gray-500 dark:text-gray-400">
{t("tunnel_dns_warning")}
</p>
<span className="text-lg font-semibold">
{t("tunnel_redirect_countdown", { seconds: countdown })}
</span>
</div>
);
}
return null;
}
+5
View File
@@ -150,3 +150,8 @@ export interface CloudflaredStatus {
url: string | null;
error: string | null;
}
export interface AutoTunnelInfo {
auto_cftunnel: boolean;
tunnel_status: CloudflaredStatus;
}