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> = { const STEP_LABELS: Record<string, string> = {
find_zone: "Lookup DNS Zone", find_zone: "查找 DNS Zone",
create_tunnel: "Create Tunnel", create_tunnel: "创建隆道",
configure_tunnel: "Configure Ingress", configure_tunnel: "配置 Ingress",
create_dns: "Create DNS Record", create_dns: "创建 DNS 记录",
create_access_app: "Create Access App", create_access_app: "创建访问应用",
setup_ca: "Setup Short-lived Cert", setup_ca: "配置短期证书",
get_token: "Get Tunnel Token", get_token: "获取隆道 Token",
browser_rendering: "Browser Rendering", browser_rendering: "浏览器渲染提醒",
}; };
export default function CreatePage() { export default function CreatePage() {
@ -143,24 +143,24 @@ export default function CreatePage() {
<Rocket className="w-5 h-5 text-emerald-500" /> <Rocket className="w-5 h-5 text-emerald-500" />
</div> </div>
<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)]"> <p className="text-sm text-[var(--text-muted)]">
One form Tunnel + DNS + Access App + Install Command + DNS + 访 +
</p> </p>
</div> </div>
</div> </div>
{/* Form */} {/* Form */}
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-5">
<Field icon={<Server className="w-4 h-4" />} label="Server Name" <Field icon={<Server className="w-4 h-4" />} label="服务器名称"
hint="A friendly label for this server, e.g. prod-web-1"> hint="服务器昵称,如 prod-web-1">
<input type="text" value={form.serverName} onChange={set("serverName")} <input type="text" value={form.serverName} onChange={set("serverName")}
placeholder="prod-web-1" className="form-input" required /> placeholder="prod-web-1" className="form-input" required />
</Field> </Field>
{/* SSH Hostname — zone dropdown + subdomain */} {/* SSH Hostname — zone dropdown + subdomain */}
<Field icon={<Globe className="w-4 h-4" />} label="SSH Hostname" <Field icon={<Globe className="w-4 h-4" />} label="SSH 访问域名"
hint={hostname ? `Will create: ${hostname}` : "Select a zone, then enter a subdomain"}> hint={hostname ? `将创建:${hostname}` : "选择 Zone 再填写子域名前缀"}>
<div className="flex gap-2"> <div className="flex gap-2">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<input <input
@ -177,11 +177,11 @@ export default function CreatePage() {
{zonesLoading ? ( {zonesLoading ? (
<div className="form-input flex items-center gap-2 text-[var(--text-faint)]"> <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" /> <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> </div>
) : zonesError ? ( ) : zonesError ? (
<div className="form-input flex items-center gap-2"> <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)]"> <button type="button" onClick={fetchZones} className="shrink-0 text-[var(--text-muted)] hover:text-[var(--text)]">
<RefreshCw className="w-3.5 h-3.5" /> <RefreshCw className="w-3.5 h-3.5" />
</button> </button>
@ -194,7 +194,7 @@ export default function CreatePage() {
className="form-input appearance-none pr-8" className="form-input appearance-none pr-8"
required required
> >
<option value="">Select zone</option> <option value=""> Zone</option>
{zones.map((z) => ( {zones.map((z) => (
<option key={z.id} value={z.id}>{z.name}</option> <option key={z.id} value={z.id}>{z.name}</option>
))} ))}
@ -206,20 +206,20 @@ export default function CreatePage() {
</div> </div>
</Field> </Field>
<Field icon={<Hash className="w-4 h-4" />} label="SSH Port" <Field icon={<Hash className="w-4 h-4" />} label="SSH 端口"
hint="Server-side SSH port (usually 22)"> hint="服务器 SSH 端口(通常为 22">
<input type="number" value={form.sshPort} onChange={set("sshPort")} <input type="number" value={form.sshPort} onChange={set("sshPort")}
placeholder="22" className="form-input w-28" /> placeholder="22" className="form-input w-28" />
</Field> </Field>
<Field icon={<Mail className="w-4 h-4" />} label="SSO Email" <Field icon={<Mail className="w-4 h-4" />} label="SSO 邮筱"
hint="Your Cloudflare SSO email — its prefix becomes the Linux user"> hint="Cloudflare SSO 邮筱,前缀将作为 Linux 用户名">
<input type="email" value={form.ssoEmail} onChange={set("ssoEmail")} <input type="email" value={form.ssoEmail} onChange={set("ssoEmail")}
placeholder="admin@example.com" className="form-input" required /> placeholder="admin@example.com" className="form-input" required />
</Field> </Field>
<Field icon={<Mail className="w-4 h-4" />} label="Allowed Emails (optional)" <Field icon={<Mail className="w-4 h-4" />} label="允许访问的邮筱(可选)"
hint="Comma-separated emails for the Access Policy. Leave empty = any authenticated user"> hint="多个邮筱用逗号分隔,留空表示所有已认证用户均可访问">
<textarea value={form.allowedEmails} onChange={set("allowedEmails")} <textarea value={form.allowedEmails} onChange={set("allowedEmails")}
placeholder="alice@example.com, bob@example.com" placeholder="alice@example.com, bob@example.com"
rows={2} className="form-input resize-none" /> 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" 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 ? ( {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> </button>
</form> </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"> <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" /> <XCircle className="w-5 h-5 text-red-400 shrink-0 mt-0.5" />
<div> <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> <p className="text-sm text-red-400 mt-1 break-words">{error}</p>
</div> </div>
</div> </div>
@ -255,7 +255,7 @@ export default function CreatePage() {
{/* Steps */} {/* Steps */}
<div className="rounded-xl border border-[var(--border)] bg-[var(--bg-card)] overflow-hidden"> <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)]"> <div className="px-5 py-3 border-b border-[var(--border)] text-sm font-medium text-[var(--text-muted)]">
Pipeline Steps
</div> </div>
<div className="divide-y divide-[var(--border-subtle)]"> <div className="divide-y divide-[var(--border-subtle)]">
{result.steps.map((s, i) => ( {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="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)]"> <div className="flex items-center gap-2 text-sm font-medium text-[var(--text-muted)]">
<Terminal className="w-4 h-4" /> <Terminal className="w-4 h-4" />
Install Command
</div> </div>
<button <button
onClick={copyCommand} 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)]" 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 ? ( {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> </button>
</div> </div>
@ -297,7 +297,7 @@ export default function CreatePage() {
{result.installCommand} {result.installCommand}
</pre> </pre>
<div className="px-5 py-3 border-t border-[var(--border)] text-xs text-[var(--text-faint)]"> <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>
</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" 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" /> <ExternalLink className="w-4 h-4" />
Open {result.launchUrl} {result.launchUrl}
</a> </a>
)} )}
</div> </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"> <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" /> <Monitor className="w-6 h-6 text-emerald-500" />
</div> </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"> <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> </p>
<button <button
onClick={() => signIn("github", { callbackUrl: "/" })} 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" 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" /> <Github className="w-4 h-4" />
Continue with GitHub 使 GitHub
</button> </button>
<p className="text-xs text-[var(--text-faint)] mt-6"> <p className="text-xs text-[var(--text-faint)] mt-6">
Your Cloudflare credentials are stored encrypted in your browser session. Cloudflare
</p> </p>
</div> </div>
</div> </div>

View File

@ -59,9 +59,9 @@ export default function SettingsPage() {
<Settings className="w-5 h-5 text-[var(--text-muted)]" /> <Settings className="w-5 h-5 text-[var(--text-muted)]" />
</div> </div>
<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)]"> <p className="text-sm text-[var(--text-muted)]">
Cloudflare API credentials Cloudflare API
</p> </p>
</div> </div>
</div> </div>
@ -81,18 +81,17 @@ export default function SettingsPage() {
<div className="text-sm min-w-0"> <div className="text-sm min-w-0">
{state.configured ? ( {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"> <p className="text-[var(--text-faint)] mt-0.5 font-mono text-xs break-all">
Account: {state.accountId}<br /> ID{state.accountId}<br />
Token: {state.apiTokenMasked} {state.apiTokenMasked}
</p> </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"> <p className="text-[var(--text-faint)] mt-0.5 text-xs">
Enter your Cloudflare API credentials below. They&apos;ll be stored Cloudflare API Redis 90
encrypted in your session cookie (90 days).
</p> </p>
</> </>
)} )}
@ -105,32 +104,32 @@ export default function SettingsPage() {
<div> <div>
<label className="flex items-center gap-1.5 text-sm font-medium text-[var(--text)] mb-1.5"> <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)]" /> <Key className="w-3.5 h-3.5 text-[var(--text-muted)]" />
Account ID ID
</label> </label>
<input <input
type="text" type="text"
value={accountId} value={accountId}
onChange={(e) => setAccountId(e.target.value)} onChange={(e) => setAccountId(e.target.value)}
placeholder="Your Cloudflare Account ID" placeholder="您的 Cloudflare Account ID"
className="settings-input" className="settings-input"
required required
/> />
<p className="text-xs text-[var(--text-faint)] mt-1"> <p className="text-xs text-[var(--text-faint)] mt-1">
Found in Cloudflare Dashboard right sidebar Cloudflare
</p> </p>
</div> </div>
<div> <div>
<label className="flex items-center gap-1.5 text-sm font-medium text-[var(--text)] mb-1.5"> <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)]" /> <Key className="w-3.5 h-3.5 text-[var(--text-muted)]" />
API Token API Token
</label> </label>
<div className="relative"> <div className="relative">
<input <input
type={showToken ? "text" : "password"} type={showToken ? "text" : "password"}
value={apiToken} value={apiToken}
onChange={(e) => setApiToken(e.target.value)} 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" className="settings-input pr-10"
required={!state?.configured} required={!state?.configured}
/> />
@ -143,7 +142,7 @@ export default function SettingsPage() {
</button> </button>
</div> </div>
<p className="text-xs text-[var(--text-faint)] mt-1"> <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> </p>
</div> </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" 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 ? ( {saving ? (
<><RefreshCw className="w-4 h-4 animate-spin" /> Saving...</> <><RefreshCw className="w-4 h-4 animate-spin" /> ...</>
) : saved ? ( ) : 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> </button>
</form> </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="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"> <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" /> <AlertTriangle className="w-10 h-10 text-red-400 mx-auto mb-4" />
<h2 className="text-lg font-semibold text-red-300 mb-2"> <h2 className="text-lg font-semibold text-red-400 mb-2">
Failed to load tunnels
</h2> </h2>
<p className="text-sm text-red-400/80 break-words">{error}</p> <p className="text-sm text-red-400/80 break-words">{error}</p>
</div> </div>

View File

@ -83,10 +83,10 @@ export default function Dashboard({ apps }: { apps: SshAppData[] }) {
</div> </div>
<div> <div>
<h1 className="text-2xl font-bold tracking-tight text-[var(--text)]"> <h1 className="text-2xl font-bold tracking-tight text-[var(--text)]">
SSH Launcher SSH
</h1> </h1>
<p className="text-sm text-[var(--text-muted)]"> <p className="text-sm text-[var(--text-muted)]">
Cloudflare Zero Trust · Browser Terminal Cloudflare Zero Trust ·
</p> </p>
</div> </div>
</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"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8">
<StatCard <StatCard
icon={<Server className="w-4 h-4" />} icon={<Server className="w-4 h-4" />}
label="Total Hosts" label="主机总数"
value={apps.length} value={apps.length}
/> />
<StatCard <StatCard
icon={<Activity className="w-4 h-4" />} icon={<Activity className="w-4 h-4" />}
label="Visible" label="当前显示"
value={filtered.length} value={filtered.length}
/> />
<StatCard <StatCard
icon={<Tag className="w-4 h-4" />} icon={<Tag className="w-4 h-4" />}
label="Tags" label="标签"
value={allTags.length} value={allTags.length}
/> />
<StatCard <StatCard
icon={<Shield className="w-4 h-4" />} icon={<Shield className="w-4 h-4" />}
label="Zero Trust" label="零信任"
value="ON" value="开启"
accent accent
/> />
</div> </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)]" /> <Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-faint)]" />
<input <input
type="text" type="text"
placeholder="Search hosts by name, domain, or tag..." placeholder="按名称、域名或标签搜索主机..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} 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)]" 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" : "bg-zinc-800/50 text-zinc-500 border border-zinc-800 hover:border-zinc-700"
}`} }`}
> >
All
</button> </button>
{allTags.map((tag) => ( {allTags.map((tag) => (
<button <button
@ -170,12 +170,12 @@ export default function Dashboard({ apps }: { apps: SshAppData[] }) {
<Terminal className="w-8 h-8 opacity-30" /> <Terminal className="w-8 h-8 opacity-30" />
</div> </div>
<p className="text-lg font-medium text-[var(--text-muted)]"> <p className="text-lg font-medium text-[var(--text-muted)]">
No hosts found
</p> </p>
<p className="text-sm mt-1"> <p className="text-sm mt-1">
{search || selectedTag {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> </p>
</div> </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)]"> <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"> <div className="flex items-center gap-1.5">
<Shield className="w-3 h-3" /> <Shield className="w-3 h-3" />
<span>Protected by Cloudflare Zero Trust</span> <span> Cloudflare Zero Trust </span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Clock className="w-3 h-3" /> <Clock className="w-3 h-3" />
<span>Auto-refreshes every 60s</span> <span> 60 </span>
</div> </div>
</footer> </footer>
</div> </div>

View File

@ -60,7 +60,7 @@ export default function MetricsWidget({ metricsUrl }: { metricsUrl: string }) {
return ( return (
<div className="mt-3 pt-3 border-t border-[var(--border)] flex items-center gap-2 text-xs text-[var(--text-faint)]"> <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" /> <Activity className="w-3 h-3" />
<span>Metrics agent unreachable</span> <span></span>
</div> </div>
); );
} }
@ -90,7 +90,7 @@ export default function MetricsWidget({ metricsUrl }: { metricsUrl: string }) {
</div> </div>
<Bar value={data.cpu} /> <Bar value={data.cpu} />
<p className="text-[10px] text-[var(--text-faint)]"> <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> </p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
@ -159,11 +159,11 @@ export default function MetricsWidget({ metricsUrl }: { metricsUrl: string }) {
{/* Uptime */} {/* Uptime */}
<div className="flex items-center justify-between text-[10px] text-[var(--text-faint)]"> <div className="flex items-center justify-between text-[10px] text-[var(--text-faint)]">
<span className="flex items-center gap-1"> <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> </span>
{lastUpdate && ( {lastUpdate && (
<span> <span>
{Math.round((Date.now() - lastUpdate) / 1000)}s ago {Math.round((Date.now() - lastUpdate) / 1000)}
</span> </span>
)} )}
</div> </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"; import { Monitor, PlusCircle, Server, Sun, Moon, Settings, LogOut, LogIn } from "lucide-react";
const links = [ const links = [
{ href: "/", label: "Hosts", icon: Monitor }, { href: "/", label: "主机", icon: Monitor },
{ href: "/create", label: "Deploy", icon: PlusCircle }, { href: "/create", label: "部署", icon: PlusCircle },
{ href: "/tunnels", label: "Tunnels", icon: Server }, { href: "/tunnels", label: "隧道", icon: Server },
{ href: "/settings", label: "Settings", icon: Settings }, { href: "/settings", label: "设置", icon: Settings },
]; ];
function ThemeToggle() { 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" 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" /> <LogIn className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Sign in</span> <span className="hidden sm:inline"></span>
</button> </button>
); );
} }
@ -61,7 +61,7 @@ function UserWidget() {
<button <button
onClick={() => signOut({ callbackUrl: "/login" })} 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" 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" /> <LogOut className="w-3.5 h-3.5" />
</button> </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"> <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" /> <Monitor className="w-3.5 h-3.5 text-emerald-500" />
</div> </div>
<span className="hidden sm:inline text-sm">SSH Launcher</span> <span className="hidden sm:inline text-sm">SSH </span>
</Link> </Link>
<div className="flex items-center gap-1 flex-1"> <div className="flex items-center gap-1 flex-1">

View File

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

View File

@ -532,28 +532,32 @@ export async function runFullSetup(input: SetupInput): Promise<SetupResult> {
// Build install command // Build install command
const loginUser = input.ssoEmail.split("@")[0]; 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; let installCommand: string | null = null;
if (tunnelToken) { if (tunnelToken) {
const parts = [ const parts = [
`sudo bash setup-cf-browser-ssh.sh`, `curl -fsSL ${SCRIPT_URL} \\`,
` --tunnel-token "${tunnelToken}"`, ` | sudo bash -s -- \\`,
` --sso-email "${input.ssoEmail}"`, ` --tunnel-token "${tunnelToken}" \\`,
` --sso-email "${input.ssoEmail}" \\`,
` --login-user "${loginUser}"`, ` --login-user "${loginUser}"`,
]; ];
if (caPubkey) { if (caPubkey) {
parts[parts.length - 1] += ` \\`;
parts.push(` --ca-pubkey "${caPubkey}"`); parts.push(` --ca-pubkey "${caPubkey}"`);
} }
installCommand = parts.join(" \\\n"); installCommand = parts.join("\n");
} }
const launchUrl = `https://${input.hostname}`; const launchUrl = `https://${input.hostname}`;
// Browser rendering reminder // 浏览器渲染提醒
steps.push({ steps.push({
step: "browser_rendering", step: "browser_rendering",
status: "skipped", status: "skipped",
message: 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 }; return { steps, tunnelToken, caPubkey, installCommand, launchUrl };