feat: 全界面中文汉化 + 安装命令改为 curl 脚本

This commit is contained in:
chunzhimoe 2026-04-12 16:50:13 +08:00
parent c6a57b5551
commit 28a6f4bc45
9 changed files with 122 additions and 120 deletions

View File

@ -39,14 +39,14 @@ interface Zone {
}
const STEP_LABELS: Record<string, string> = {
find_zone: "Lookup DNS Zone",
create_tunnel: "Create Tunnel",
configure_tunnel: "Configure Ingress",
create_dns: "Create DNS Record",
create_access_app: "Create Access App",
setup_ca: "Setup Short-lived Cert",
get_token: "Get Tunnel Token",
browser_rendering: "Browser Rendering",
find_zone: "查找 DNS Zone",
create_tunnel: "创建隆道",
configure_tunnel: "配置 Ingress",
create_dns: "创建 DNS 记录",
create_access_app: "创建访问应用",
setup_ca: "配置短期证书",
get_token: "获取隆道 Token",
browser_rendering: "浏览器渲染提醒",
};
export default function CreatePage() {
@ -143,24 +143,24 @@ export default function CreatePage() {
<Rocket className="w-5 h-5 text-emerald-500" />
</div>
<div>
<h1 className="text-2xl font-bold text-[var(--text)]">Deploy SSH Host</h1>
<h1 className="text-2xl font-bold text-[var(--text)]"> SSH </h1>
<p className="text-sm text-[var(--text-muted)]">
One form Tunnel + DNS + Access App + Install Command
+ DNS + 访 +
</p>
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-5">
<Field icon={<Server className="w-4 h-4" />} label="Server Name"
hint="A friendly label for this server, e.g. prod-web-1">
<Field icon={<Server className="w-4 h-4" />} label="服务器名称"
hint="服务器昵称,如 prod-web-1">
<input type="text" value={form.serverName} onChange={set("serverName")}
placeholder="prod-web-1" className="form-input" required />
</Field>
{/* SSH Hostname — zone dropdown + subdomain */}
<Field icon={<Globe className="w-4 h-4" />} label="SSH Hostname"
hint={hostname ? `Will create: ${hostname}` : "Select a zone, then enter a subdomain"}>
<Field icon={<Globe className="w-4 h-4" />} label="SSH 访问域名"
hint={hostname ? `将创建:${hostname}` : "选择 Zone 再填写子域名前缀"}>
<div className="flex gap-2">
<div className="flex-1 min-w-0">
<input
@ -177,11 +177,11 @@ export default function CreatePage() {
{zonesLoading ? (
<div className="form-input flex items-center gap-2 text-[var(--text-faint)]">
<Loader2 className="w-3.5 h-3.5 animate-spin shrink-0" />
<span className="text-sm">Loading zones</span>
<span className="text-sm"> Zone </span>
</div>
) : zonesError ? (
<div className="form-input flex items-center gap-2">
<span className="text-sm text-red-500 truncate flex-1">{zonesError}</span>
<span className="text-sm text-red-500 truncate flex-1">: {zonesError}</span>
<button type="button" onClick={fetchZones} className="shrink-0 text-[var(--text-muted)] hover:text-[var(--text)]">
<RefreshCw className="w-3.5 h-3.5" />
</button>
@ -194,7 +194,7 @@ export default function CreatePage() {
className="form-input appearance-none pr-8"
required
>
<option value="">Select zone</option>
<option value=""> Zone</option>
{zones.map((z) => (
<option key={z.id} value={z.id}>{z.name}</option>
))}
@ -206,20 +206,20 @@ export default function CreatePage() {
</div>
</Field>
<Field icon={<Hash className="w-4 h-4" />} label="SSH Port"
hint="Server-side SSH port (usually 22)">
<Field icon={<Hash className="w-4 h-4" />} label="SSH 端口"
hint="服务器 SSH 端口(通常为 22">
<input type="number" value={form.sshPort} onChange={set("sshPort")}
placeholder="22" className="form-input w-28" />
</Field>
<Field icon={<Mail className="w-4 h-4" />} label="SSO Email"
hint="Your Cloudflare SSO email — its prefix becomes the Linux user">
<Field icon={<Mail className="w-4 h-4" />} label="SSO 邮筱"
hint="Cloudflare SSO 邮筱,前缀将作为 Linux 用户名">
<input type="email" value={form.ssoEmail} onChange={set("ssoEmail")}
placeholder="admin@example.com" className="form-input" required />
</Field>
<Field icon={<Mail className="w-4 h-4" />} label="Allowed Emails (optional)"
hint="Comma-separated emails for the Access Policy. Leave empty = any authenticated user">
<Field icon={<Mail className="w-4 h-4" />} label="允许访问的邮筱(可选)"
hint="多个邮筱用逗号分隔,留空表示所有已认证用户均可访问">
<textarea value={form.allowedEmails} onChange={set("allowedEmails")}
placeholder="alice@example.com, bob@example.com"
rows={2} className="form-input resize-none" />
@ -231,9 +231,9 @@ export default function CreatePage() {
className="w-full flex items-center justify-center gap-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 disabled:cursor-not-allowed text-white font-semibold py-3 px-4 transition"
>
{loading ? (
<><Loader2 className="w-4 h-4 animate-spin" />Deploying</>
<><Loader2 className="w-4 h-4 animate-spin" /></>
) : (
<><Rocket className="w-4 h-4" />Deploy Now</>
<><Rocket className="w-4 h-4" /></>
)}
</button>
</form>
@ -243,7 +243,7 @@ export default function CreatePage() {
<div className="mt-6 rounded-xl border border-red-500/20 bg-red-500/5 p-4 flex items-start gap-3">
<XCircle className="w-5 h-5 text-red-400 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-semibold text-red-500">Deploy failed</p>
<p className="text-sm font-semibold text-red-500"></p>
<p className="text-sm text-red-400 mt-1 break-words">{error}</p>
</div>
</div>
@ -255,7 +255,7 @@ export default function CreatePage() {
{/* Steps */}
<div className="rounded-xl border border-[var(--border)] bg-[var(--bg-card)] overflow-hidden">
<div className="px-5 py-3 border-b border-[var(--border)] text-sm font-medium text-[var(--text-muted)]">
Pipeline Steps
</div>
<div className="divide-y divide-[var(--border-subtle)]">
{result.steps.map((s, i) => (
@ -280,16 +280,16 @@ export default function CreatePage() {
<div className="px-5 py-3 border-b border-[var(--border)] flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium text-[var(--text-muted)]">
<Terminal className="w-4 h-4" />
Install Command
</div>
<button
onClick={copyCommand}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium transition bg-[var(--bg-subtle)] hover:bg-[var(--bg-card)] border border-[var(--border)] text-[var(--text-muted)]"
>
{copied ? (
<><Check className="w-3 h-3 text-emerald-500" />Copied</>
<><Check className="w-3 h-3 text-emerald-500" /></>
) : (
<><Copy className="w-3 h-3" />Copy</>
<><Copy className="w-3 h-3" /></>
)}
</button>
</div>
@ -297,7 +297,7 @@ export default function CreatePage() {
{result.installCommand}
</pre>
<div className="px-5 py-3 border-t border-[var(--border)] text-xs text-[var(--text-faint)]">
Paste on your server (requires root) installs cloudflared, registers the tunnel, and configures SSH CA trust.
root cloudflared SSH CA
</div>
</div>
)}
@ -311,7 +311,7 @@ export default function CreatePage() {
className="flex items-center justify-center gap-2 rounded-xl border border-[var(--accent-border)] bg-[var(--accent-dim)] p-4 text-emerald-600 dark:text-emerald-300 hover:bg-[var(--accent-dim)] transition font-medium"
>
<ExternalLink className="w-4 h-4" />
Open {result.launchUrl}
{result.launchUrl}
</a>
)}
</div>

View File

@ -11,19 +11,19 @@ export default function LoginPage() {
<div className="w-12 h-12 rounded-2xl bg-[var(--accent-dim)] border border-[var(--accent-border)] flex items-center justify-center mx-auto mb-5">
<Monitor className="w-6 h-6 text-emerald-500" />
</div>
<h1 className="text-xl font-bold text-[var(--text)] mb-1">SSH Launcher</h1>
<h1 className="text-xl font-bold text-[var(--text)] mb-1">SSH </h1>
<p className="text-sm text-[var(--text-muted)] mb-8">
Sign in to manage your Cloudflare Zero Trust SSH hosts
Cloudflare Zero Trust SSH
</p>
<button
onClick={() => signIn("github", { callbackUrl: "/" })}
className="w-full flex items-center justify-center gap-3 rounded-xl bg-[var(--text)] text-[var(--bg)] font-semibold py-3 px-4 hover:opacity-90 transition"
>
<Github className="w-4 h-4" />
Continue with GitHub
使 GitHub
</button>
<p className="text-xs text-[var(--text-faint)] mt-6">
Your Cloudflare credentials are stored encrypted in your browser session.
Cloudflare
</p>
</div>
</div>

View File

@ -59,9 +59,9 @@ export default function SettingsPage() {
<Settings className="w-5 h-5 text-[var(--text-muted)]" />
</div>
<div>
<h1 className="text-xl font-bold text-[var(--text)]">Settings</h1>
<h1 className="text-xl font-bold text-[var(--text)]"></h1>
<p className="text-sm text-[var(--text-muted)]">
Cloudflare API credentials
Cloudflare API
</p>
</div>
</div>
@ -81,18 +81,17 @@ export default function SettingsPage() {
<div className="text-sm min-w-0">
{state.configured ? (
<>
<p className="font-medium text-emerald-600 dark:text-emerald-400">Credentials configured</p>
<p className="font-medium text-emerald-600 dark:text-emerald-400"></p>
<p className="text-[var(--text-faint)] mt-0.5 font-mono text-xs break-all">
Account: {state.accountId}<br />
Token: {state.apiTokenMasked}
ID{state.accountId}<br />
{state.apiTokenMasked}
</p>
</>
) : (
<>
<p className="font-medium text-amber-600 dark:text-amber-400">Not configured</p>
<p className="font-medium text-amber-600 dark:text-amber-400"></p>
<p className="text-[var(--text-faint)] mt-0.5 text-xs">
Enter your Cloudflare API credentials below. They&apos;ll be stored
encrypted in your session cookie (90 days).
Cloudflare API Redis 90
</p>
</>
)}
@ -105,32 +104,32 @@ export default function SettingsPage() {
<div>
<label className="flex items-center gap-1.5 text-sm font-medium text-[var(--text)] mb-1.5">
<Key className="w-3.5 h-3.5 text-[var(--text-muted)]" />
Account ID
ID
</label>
<input
type="text"
value={accountId}
onChange={(e) => setAccountId(e.target.value)}
placeholder="Your Cloudflare Account ID"
placeholder="您的 Cloudflare Account ID"
className="settings-input"
required
/>
<p className="text-xs text-[var(--text-faint)] mt-1">
Found in Cloudflare Dashboard right sidebar
Cloudflare
</p>
</div>
<div>
<label className="flex items-center gap-1.5 text-sm font-medium text-[var(--text)] mb-1.5">
<Key className="w-3.5 h-3.5 text-[var(--text-muted)]" />
API Token
API Token
</label>
<div className="relative">
<input
type={showToken ? "text" : "password"}
value={apiToken}
onChange={(e) => setApiToken(e.target.value)}
placeholder={state?.configured ? "Enter new token to update" : "Your Cloudflare API Token"}
placeholder={state?.configured ? "输入新 Token 以更新" : "您的 Cloudflare API Token"}
className="settings-input pr-10"
required={!state?.configured}
/>
@ -143,7 +142,7 @@ export default function SettingsPage() {
</button>
</div>
<p className="text-xs text-[var(--text-faint)] mt-1">
Needs: Access Write · Tunnel Edit · DNS Edit · Zone Read
Access Write · Tunnel Edit · DNS Edit · Zone Read
</p>
</div>
@ -160,11 +159,11 @@ export default function SettingsPage() {
className="w-full flex items-center justify-center gap-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 text-white font-semibold py-2.5 transition"
>
{saving ? (
<><RefreshCw className="w-4 h-4 animate-spin" /> Saving...</>
<><RefreshCw className="w-4 h-4 animate-spin" /> ...</>
) : saved ? (
<><Check className="w-4 h-4" /> Saved!</>
<><Check className="w-4 h-4" /> </>
) : (
<><Save className="w-4 h-4" /> Save Credentials</>
<><Save className="w-4 h-4" /> </>
)}
</button>
</form>

View File

@ -20,8 +20,8 @@ export default async function TunnelsPage() {
<div className="min-h-[calc(100vh-3.5rem)] flex items-center justify-center p-4">
<div className="max-w-md w-full rounded-2xl border border-red-500/20 bg-red-500/5 p-8 text-center">
<AlertTriangle className="w-10 h-10 text-red-400 mx-auto mb-4" />
<h2 className="text-lg font-semibold text-red-300 mb-2">
Failed to load tunnels
<h2 className="text-lg font-semibold text-red-400 mb-2">
</h2>
<p className="text-sm text-red-400/80 break-words">{error}</p>
</div>

View File

@ -83,10 +83,10 @@ export default function Dashboard({ apps }: { apps: SshAppData[] }) {
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight text-[var(--text)]">
SSH Launcher
SSH
</h1>
<p className="text-sm text-[var(--text-muted)]">
Cloudflare Zero Trust · Browser Terminal
Cloudflare Zero Trust ·
</p>
</div>
</div>
@ -96,23 +96,23 @@ export default function Dashboard({ apps }: { apps: SshAppData[] }) {
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8">
<StatCard
icon={<Server className="w-4 h-4" />}
label="Total Hosts"
label="主机总数"
value={apps.length}
/>
<StatCard
icon={<Activity className="w-4 h-4" />}
label="Visible"
label="当前显示"
value={filtered.length}
/>
<StatCard
icon={<Tag className="w-4 h-4" />}
label="Tags"
label="标签"
value={allTags.length}
/>
<StatCard
icon={<Shield className="w-4 h-4" />}
label="Zero Trust"
value="ON"
label="零信任"
value="开启"
accent
/>
</div>
@ -124,7 +124,7 @@ export default function Dashboard({ apps }: { apps: SshAppData[] }) {
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-faint)]" />
<input
type="text"
placeholder="Search hosts by name, domain, or tag..."
placeholder="按名称、域名或标签搜索主机..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-xl border border-[var(--border)] bg-[var(--bg-subtle)] pl-10 pr-4 py-2.5 text-sm text-[var(--text)] placeholder:text-[var(--text-faint)] outline-none transition focus:border-[var(--accent-border)] focus:ring-1 focus:ring-[var(--accent-dim)]"
@ -142,7 +142,7 @@ export default function Dashboard({ apps }: { apps: SshAppData[] }) {
: "bg-zinc-800/50 text-zinc-500 border border-zinc-800 hover:border-zinc-700"
}`}
>
All
</button>
{allTags.map((tag) => (
<button
@ -170,12 +170,12 @@ export default function Dashboard({ apps }: { apps: SshAppData[] }) {
<Terminal className="w-8 h-8 opacity-30" />
</div>
<p className="text-lg font-medium text-[var(--text-muted)]">
No hosts found
</p>
<p className="text-sm mt-1">
{search || selectedTag
? "Try adjusting your search or filter."
: "Ensure your API token has Access: Apps and Policies Read permission."}
? "请调整搜索条件或过滤器"
: "请确认 API Token 具备 Access: Apps and Policies Read 权限"}
</p>
</div>
)}
@ -191,11 +191,11 @@ export default function Dashboard({ apps }: { apps: SshAppData[] }) {
<footer className="mt-14 pt-6 border-t border-[var(--border-subtle)] flex flex-col sm:flex-row items-center justify-between gap-2 text-xs text-[var(--text-faint)]">
<div className="flex items-center gap-1.5">
<Shield className="w-3 h-3" />
<span>Protected by Cloudflare Zero Trust</span>
<span> Cloudflare Zero Trust </span>
</div>
<div className="flex items-center gap-1.5">
<Clock className="w-3 h-3" />
<span>Auto-refreshes every 60s</span>
<span> 60 </span>
</div>
</footer>
</div>

View File

@ -60,7 +60,7 @@ export default function MetricsWidget({ metricsUrl }: { metricsUrl: string }) {
return (
<div className="mt-3 pt-3 border-t border-[var(--border)] flex items-center gap-2 text-xs text-[var(--text-faint)]">
<Activity className="w-3 h-3" />
<span>Metrics agent unreachable</span>
<span></span>
</div>
);
}
@ -90,7 +90,7 @@ export default function MetricsWidget({ metricsUrl }: { metricsUrl: string }) {
</div>
<Bar value={data.cpu} />
<p className="text-[10px] text-[var(--text-faint)]">
load {data.load.map((l) => l.toFixed(2)).join(" ")}
{data.load.map((l) => l.toFixed(2)).join(" ")}
</p>
</div>
<div className="space-y-1">
@ -159,11 +159,11 @@ export default function MetricsWidget({ metricsUrl }: { metricsUrl: string }) {
{/* Uptime */}
<div className="flex items-center justify-between text-[10px] text-[var(--text-faint)]">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" /> up {fmtUptime(data.uptime)}
<Clock className="w-3 h-3" /> {fmtUptime(data.uptime)}
</span>
{lastUpdate && (
<span>
{Math.round((Date.now() - lastUpdate) / 1000)}s ago
{Math.round((Date.now() - lastUpdate) / 1000)}
</span>
)}
</div>

View File

@ -8,10 +8,10 @@ import { useSession, signIn, signOut } from "next-auth/react";
import { Monitor, PlusCircle, Server, Sun, Moon, Settings, LogOut, LogIn } from "lucide-react";
const links = [
{ href: "/", label: "Hosts", icon: Monitor },
{ href: "/create", label: "Deploy", icon: PlusCircle },
{ href: "/tunnels", label: "Tunnels", icon: Server },
{ href: "/settings", label: "Settings", icon: Settings },
{ href: "/", label: "主机", icon: Monitor },
{ href: "/create", label: "部署", icon: PlusCircle },
{ href: "/tunnels", label: "隧道", icon: Server },
{ href: "/settings", label: "设置", icon: Settings },
];
function ThemeToggle() {
@ -41,7 +41,7 @@ function UserWidget() {
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-medium transition"
>
<LogIn className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Sign in</span>
<span className="hidden sm:inline"></span>
</button>
);
}
@ -61,7 +61,7 @@ function UserWidget() {
<button
onClick={() => signOut({ callbackUrl: "/login" })}
className="w-7 h-7 flex items-center justify-center rounded-lg text-[var(--text-faint)] hover:text-red-500 hover:bg-[var(--bg-card)] transition"
title="Sign out"
title="退出登录"
>
<LogOut className="w-3.5 h-3.5" />
</button>
@ -82,7 +82,7 @@ export default function Navigation() {
<div className="w-7 h-7 rounded-lg bg-[var(--accent-dim)] border border-[var(--accent-border)] flex items-center justify-center">
<Monitor className="w-3.5 h-3.5 text-emerald-500" />
</div>
<span className="hidden sm:inline text-sm">SSH Launcher</span>
<span className="hidden sm:inline text-sm">SSH </span>
</Link>
<div className="flex items-center gap-1 flex-1">

View File

@ -30,13 +30,13 @@ interface Tunnel {
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "Just now";
if (mins < 60) return `${mins}m ago`;
if (mins < 1) return "刚刚";
if (mins < 60) return `${mins} 分钟前`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
if (hrs < 24) return `${hrs} 小时前`;
const days = Math.floor(hrs / 24);
if (days < 30) return `${days}d ago`;
return `${Math.floor(days / 30)}mo ago`;
if (days < 30) return `${days} 天前`;
return `${Math.floor(days / 30)} 个月前`;
}
export default function TunnelList({ tunnels }: { tunnels: Tunnel[] }) {
@ -57,43 +57,42 @@ export default function TunnelList({ tunnels }: { tunnels: Tunnel[] }) {
return (
<div className="min-h-[calc(100vh-3.5rem)]">
<div className="fixed inset-0 -z-10">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,rgba(16,185,129,0.08),transparent)]" />
<div className="absolute inset-0 bg-zinc-950" style={{ zIndex: -1 }} />
<div className="fixed inset-0 -z-10 pointer-events-none">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,var(--accent-dim),transparent)]" />
</div>
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
{/* Header */}
<div className="flex items-center gap-3 mb-8">
<div className="w-10 h-10 rounded-xl bg-blue-500/10 border border-blue-500/20 flex items-center justify-center">
<Server className="w-5 h-5 text-blue-400" />
<div className="w-10 h-10 rounded-xl bg-[var(--accent-dim)] border border-[var(--accent-border)] flex items-center justify-center">
<Server className="w-5 h-5 text-emerald-500" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Tunnels</h1>
<p className="text-sm text-zinc-500">
{tunnels.length} total · {activeCount} active
<h1 className="text-2xl font-bold text-[var(--text)]"></h1>
<p className="text-sm text-[var(--text-muted)]">
{tunnels.length} · {activeCount} 线
</p>
</div>
</div>
{/* Search */}
<div className="relative mb-6">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-faint)]" />
<input
type="text"
placeholder="Search by name or ID..."
placeholder="按名称或 ID 搜索..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-xl border border-zinc-800 bg-zinc-900/80 backdrop-blur-sm pl-10 pr-4 py-2.5 text-sm text-zinc-200 placeholder-zinc-600 outline-none transition focus:border-blue-500/50 focus:ring-1 focus:ring-blue-500/20"
className="w-full rounded-xl border border-[var(--border)] bg-[var(--bg-subtle)] pl-10 pr-4 py-2.5 text-sm text-[var(--text)] placeholder:text-[var(--text-faint)] outline-none transition focus:border-[var(--accent-border)] focus:ring-1 focus:ring-[var(--accent-dim)]"
/>
</div>
{/* Empty */}
{filtered.length === 0 && (
<div className="flex flex-col items-center justify-center py-24 text-zinc-500">
<div className="flex flex-col items-center justify-center py-24 text-[var(--text-faint)]">
<Server className="w-12 h-12 opacity-30 mb-4" />
<p className="text-lg font-medium text-zinc-400">
No tunnels found
<p className="text-lg font-medium text-[var(--text-muted)]">
</p>
</div>
)}
@ -106,7 +105,7 @@ export default function TunnelList({ tunnels }: { tunnels: Tunnel[] }) {
return (
<div
key={tunnel.id}
className="rounded-xl border border-zinc-800/80 bg-zinc-900/60 backdrop-blur-sm p-5 transition hover:border-zinc-700"
className="rounded-xl border border-[var(--border)] bg-[var(--bg-card)] p-5 transition hover:border-[var(--accent-border)]"
>
<div className="flex items-start justify-between gap-4">
{/* Left */}
@ -117,45 +116,45 @@ export default function TunnelList({ tunnels }: { tunnels: Tunnel[] }) {
) : (
<WifiOff className="w-4 h-4 text-zinc-600 shrink-0" />
)}
<span className="font-semibold text-white truncate">
<span className="font-semibold text-[var(--text)] truncate">
{tunnel.name}
</span>
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
isActive
? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
: "bg-zinc-800 text-zinc-500 border border-zinc-700"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20"
: "bg-[var(--bg-subtle)] text-[var(--text-faint)] border border-[var(--border)]"
}`}
>
{isActive ? "Active" : "Inactive"}
{isActive ? "在线" : "离线"}
</span>
</div>
<p className="text-xs text-zinc-600 font-mono truncate">
<p className="text-xs text-[var(--text-faint)] font-mono truncate">
{tunnel.id}
</p>
</div>
{/* Right meta */}
<div className="text-right text-xs text-zinc-600 shrink-0">
<div className="text-right text-xs text-[var(--text-faint)] shrink-0">
<div className="flex items-center gap-1 justify-end">
<Clock className="w-3 h-3" />
Created {timeAgo(tunnel.created_at)}
{timeAgo(tunnel.created_at)}
</div>
</div>
</div>
{/* Connections */}
{isActive && tunnel.connections.length > 0 && (
<div className="mt-3 pt-3 border-t border-zinc-800/50 flex flex-wrap gap-2">
<div className="mt-3 pt-3 border-t border-[var(--border)] flex flex-wrap gap-2">
{tunnel.connections.map((conn) => (
<div
key={conn.id}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-zinc-800/50 text-xs text-zinc-400"
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-[var(--bg-subtle)] text-xs text-[var(--text-muted)]"
>
<MapPin className="w-3 h-3 text-zinc-600" />
<MapPin className="w-3 h-3 text-[var(--text-faint)]" />
{conn.origin_ip}
<span className="text-zinc-600">·</span>
<span className="text-zinc-600">
<span className="text-[var(--text-faint)]">·</span>
<span className="text-[var(--text-faint)]">
{timeAgo(conn.opened_at)}
</span>
</div>
@ -168,9 +167,9 @@ export default function TunnelList({ tunnels }: { tunnels: Tunnel[] }) {
</div>
{/* Footer */}
<div className="mt-14 pt-6 border-t border-zinc-900 flex items-center gap-2 text-xs text-zinc-600">
<div className="mt-14 pt-6 border-t border-[var(--border-subtle)] flex items-center gap-2 text-xs text-[var(--text-faint)]">
<Shield className="w-3 h-3" />
<span>Cloudflare Tunnel · auto-refreshes every 30s</span>
<span>Cloudflare · 30 </span>
</div>
</div>
</div>

View File

@ -532,28 +532,32 @@ export async function runFullSetup(input: SetupInput): Promise<SetupResult> {
// Build install command
const loginUser = input.ssoEmail.split("@")[0];
const SCRIPT_URL =
"https://raw.githubusercontent.com/chunzhimoe/cf-status/main/setup-cf-browser-ssh.sh";
let installCommand: string | null = null;
if (tunnelToken) {
const parts = [
`sudo bash setup-cf-browser-ssh.sh`,
` --tunnel-token "${tunnelToken}"`,
` --sso-email "${input.ssoEmail}"`,
`curl -fsSL ${SCRIPT_URL} \\`,
` | sudo bash -s -- \\`,
` --tunnel-token "${tunnelToken}" \\`,
` --sso-email "${input.ssoEmail}" \\`,
` --login-user "${loginUser}"`,
];
if (caPubkey) {
parts[parts.length - 1] += ` \\`;
parts.push(` --ca-pubkey "${caPubkey}"`);
}
installCommand = parts.join(" \\\n");
installCommand = parts.join("\n");
}
const launchUrl = `https://${input.hostname}`;
// Browser rendering reminder
// 浏览器渲染提醒
steps.push({
step: "browser_rendering",
status: "skipped",
message:
"Remember: enable Browser rendering → SSH in the Cloudflare dashboard for this Access app",
"请在 Cloudflare 控制台 → Access App → 编辑 → 启用「Browser rendering → SSH」",
});
return { steps, tunnelToken, caPubkey, installCommand, launchUrl };