feat: add cloudflare auto-tunnel onboarding flow and bump version to v0.9.81
This commit is contained in:
+1
-1
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "获取公网域名"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,12 +31,14 @@ const whiteList = [
|
||||
"preCheckFileFromUrl",
|
||||
"passkeyLoginStart",
|
||||
"passkeyLoginFinish",
|
||||
"getAutoTunnelInfo",
|
||||
];
|
||||
const whiteList401 = [
|
||||
"getAgoraVoicingList",
|
||||
"getAgoraChannels",
|
||||
"getGoogleAuthConfig",
|
||||
"getGithubAuthConfig",
|
||||
"getAutoTunnelInfo",
|
||||
];
|
||||
const errorWhiteList = [
|
||||
"preCheckFileFromUrl",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -150,3 +150,8 @@ export interface CloudflaredStatus {
|
||||
url: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface AutoTunnelInfo {
|
||||
auto_cftunnel: boolean;
|
||||
tunnel_status: CloudflaredStatus;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user