This commit is contained in:
root
2026-05-17 14:06:06 +00:00
parent a3e8a4a96c
commit a91188ca68
29 changed files with 442 additions and 58 deletions

View File

@@ -34,6 +34,7 @@ import { LiveTiktokHdmiView } from "@/components/live-product/LiveTiktokHdmiView
import type { PortalNavTarget } from "@/components/live-product/LivePipelineStrip";
import { LiveYoutubeUnmannedView } from "@/components/live-product/LiveYoutubeUnmannedView";
import { fetchJsonResponse } from "@/lib/fetchJson";
import { extractNekoBrowserLinks, formatNekoPorts, type NekoBrowserLink } from "@/lib/nekoLinks";
import { normalizeBrowserReachableHttpUrl } from "@/lib/panelUrls";
type Lang = "zh" | "en";
@@ -72,6 +73,13 @@ type HubDashboard = {
platform?: string;
ips?: string[];
quick_links?: Record<string, string>;
neko_instances?: Array<{
id?: number;
label?: string;
port?: number;
url?: string;
quick_link_key?: string;
}>;
neko_login?: {
user_login?: string;
user_password?: string;
@@ -141,10 +149,44 @@ type HubDashboard = {
port?: number;
tcp?: { ok?: boolean; reachable?: boolean; latency_ms?: number; error?: string };
http?: { ok?: boolean; http_code?: number; reachable?: boolean; latency_ms?: number; error?: string };
instances?: Array<{
id?: number;
port?: number;
tcp?: { ok?: boolean; reachable?: boolean; latency_ms?: number; error?: string };
http?: { ok?: boolean; http_code?: number; reachable?: boolean; latency_ms?: number; error?: string };
}>;
running_count?: number;
required_count?: number;
};
};
};
type DeploymentReport = {
ready?: boolean;
pass_count?: number;
total_count?: number;
required_total?: number;
mdns_host?: string;
primary_lan_ip?: string;
port?: string;
live_edge_enabled?: boolean;
mdns_enabled?: boolean;
checks?: Array<{
id: string;
label: string;
ok: boolean;
kind?: string;
detail?: string;
url?: string;
target?: string;
http_code?: number;
reachable?: boolean;
}>;
failing_checks?: Array<{ id: string; label: string; ok: boolean; detail?: string }>;
hints?: string[];
urls?: Record<string, string>;
};
type SrsProbe = NonNullable<HubDashboard["probes"]>["srs"];
type ProcessEntry = { pm2: string; script: string; label: string };
@@ -223,6 +265,7 @@ export default function LiveControlApp() {
const [apkPath, setApkPath] = useState("/sdcard/app.apk");
const [androidSerial, setAndroidSerial] = useState("");
const [hubCfgSnapshot, setHubCfgSnapshot] = useState<Record<string, unknown> | null>(null);
const [deployCheck, setDeployCheck] = useState<DeploymentReport | null>(null);
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true);
const [showStreamProbes, setShowStreamProbes] = useState(true);
const dashboardInFlightRef = useRef<Promise<void> | null>(null);
@@ -255,10 +298,22 @@ export default function LiveControlApp() {
if (dashboardInFlightRef.current) return dashboardInFlightRef.current;
const task = (async () => {
try {
const d = await fetchJson<HubDashboard>("/hub/dashboard");
const [dashResult, deployResult] = await Promise.allSettled([
fetchJson<HubDashboard>("/hub/dashboard"),
fetchJson<DeploymentReport>("/deploy/check"),
] as const);
if (dashResult.status !== "fulfilled") {
throw dashResult.reason;
}
const d = dashResult.value;
setDash(d);
setDashErr(null);
setLastSyncedAt(Date.now());
if (deployResult.status === "fulfilled") {
setDeployCheck(deployResult.value);
} else {
setDeployCheck(null);
}
const devs = d.android?.devices || [];
if (devs.length && !androidSerial) {
const ready = devs.find((x) => (x.state || "").toLowerCase() === "device");
@@ -266,6 +321,7 @@ export default function LiveControlApp() {
}
} catch (e) {
setDashErr((e as Error).message);
setDeployCheck(null);
} finally {
setLoading(false);
dashboardInFlightRef.current = null;
@@ -433,6 +489,8 @@ export default function LiveControlApp() {
}
return o;
}, [dash?.stack?.quick_links]);
const nekoLinks = useMemo<NekoBrowserLink[]>(() => extractNekoBrowserLinks(ql), [ql]);
const nekoPortsLabel = useMemo(() => formatNekoPorts(nekoLinks), [nekoLinks]);
const runService = async (
service: string,
@@ -973,12 +1031,106 @@ export default function LiveControlApp() {
</div>
) : null}
{deployCheck ? (
<div
className={`rounded-2xl border p-5 ${
deployCheck.ready
? "border-emerald-500/25 bg-gradient-to-br from-emerald-500/[0.08] via-zinc-950/50 to-cyan-500/[0.05]"
: "border-amber-500/30 bg-gradient-to-br from-amber-500/[0.1] via-zinc-950/55 to-rose-500/[0.05]"
}`}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-white">{t("安装验收", "Deployment health")}</h3>
<p className="mt-1 text-[11px] text-zinc-500">
{t(
"一键脚本完成后,本机 API、局域网入口、反向代理与 mDNS 的可用性检查。",
"Post-install checks for loopback API, LAN access, reverse proxy, and mDNS.",
)}
</p>
</div>
<span
className={`rounded-full px-3 py-1 text-[10px] font-semibold ${
deployCheck.ready
? "bg-emerald-500/20 text-emerald-100 ring-1 ring-emerald-500/30"
: "bg-amber-500/20 text-amber-100 ring-1 ring-amber-500/30"
}`}
>
{deployCheck.ready ? t("可交付", "Ready") : t("需处理", "Needs attention")}
</span>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<div className="rounded-xl border border-white/[0.06] bg-black/30 p-4">
<p className="text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
{t("验收摘要", "Acceptance summary")}
</p>
<p className="mt-2 font-mono text-sm text-white">
{deployCheck.pass_count ?? 0}/{deployCheck.total_count ?? 0}
<span className="ml-2 text-[11px] text-zinc-500">
{t("已通过", "checks passed")}
</span>
</p>
<p className="mt-1 text-[11px] text-zinc-500">
{t("必需项", "Required")}: {deployCheck.required_total ?? 0} · mDNS{" "}
{deployCheck.mdns_enabled ? t("已启用", "enabled") : t("已关闭", "disabled")} · edge{" "}
{deployCheck.live_edge_enabled ? t("已启用", "enabled") : t("已关闭", "disabled")}
</p>
<div className="mt-3 flex flex-wrap gap-2">
{Object.entries(deployCheck.urls || {})
.filter(([, url]) => !!url)
.slice(0, 4)
.map(([key, url]) => (
<a
key={key}
href={normalizeBrowserReachableHttpUrl(url)}
target="_blank"
rel="noreferrer"
className="rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-[11px] text-zinc-200 ring-1 ring-white/10"
>
{key}
</a>
))}
</div>
</div>
<div className="rounded-xl border border-white/[0.06] bg-black/30 p-4">
<p className="text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
{deployCheck.failing_checks?.length ? t("失败项", "Failing checks") : t("当前状态", "Current state")}
</p>
{deployCheck.failing_checks?.length ? (
<ul className="mt-2 space-y-2 text-[11px] text-zinc-300">
{deployCheck.failing_checks.slice(0, 4).map((item) => (
<li key={item.id} className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-3 py-2">
<span className="font-semibold text-amber-100">{item.label}</span>
<span className="ml-2 text-zinc-400">{item.detail || "—"}</span>
</li>
))}
</ul>
) : (
<p className="mt-2 text-[11px] text-emerald-200/90">
{t("必需检查已全部通过,可直接用浏览器面板交付。", "All required checks pass; browser control plane is ready.")}
</p>
)}
{(deployCheck.hints || []).length ? (
<ul className="mt-3 list-inside list-disc space-y-1 text-[10px] text-zinc-400">
{(deployCheck.hints || []).slice(0, 3).map((hint) => (
<li key={hint}>{hint}</li>
))}
</ul>
) : null}
</div>
</div>
</div>
) : null}
{dash?.stack?.neko_login ? (
<div className="rounded-2xl border border-fuchsia-500/25 bg-gradient-to-r from-fuchsia-500/[0.08] to-violet-500/[0.06] p-5">
<h3 className="text-sm font-semibold text-white">Neko {t("登录", "sign-in")}</h3>
<p className="mt-1 text-[11px] text-zinc-500">
{dash.stack.neko_login.note_zh ||
t("Neko Chromium :9200 使用下列账号策略。", "Neko Chromium :9200 uses the policy below.")}
t(
nekoPortsLabel ? `Neko Chromium ${nekoPortsLabel} 使用下列账号策略。` : "Neko Chromium 多实例使用下列账号策略。",
nekoPortsLabel ? `Neko Chromium ${nekoPortsLabel} uses the policy below.` : "Neko Chromium instances use the policy below.",
)}
</p>
<p className="mt-2 text-[11px] text-amber-200/85">
{t(
@@ -986,6 +1138,22 @@ export default function LiveControlApp() {
"Use two fields: display name is free-form; password must match NEKO_* (default 12345678). Wrong password looks like infinite loading.",
)}
</p>
{nekoLinks.length ? (
<div className="mt-4 flex flex-wrap gap-2">
{nekoLinks.map((link) => (
<a
key={link.key}
href={link.url}
target="_blank"
rel="noreferrer"
className="rounded-lg border border-fuchsia-400/20 bg-black/25 px-3 py-2 text-[11px] text-fuchsia-100 ring-1 ring-fuchsia-400/20"
>
{link.label}
{link.port ? ` :${link.port}` : ""}
</a>
))}
</div>
) : null}
<div className="mt-4 grid gap-3 sm:grid-cols-2 font-mono text-sm">
<div className="rounded-xl border border-white/10 bg-black/40 px-4 py-3">
<p className="text-[10px] uppercase text-zinc-500">{t("昵称示例", "Display Name Example")}</p>
@@ -1178,9 +1346,23 @@ export default function LiveControlApp() {
const p = dash.probes?.neko;
const tcpOk = p?.tcp?.reachable;
const http = p?.http;
const instances = p?.instances || [];
const runningCount = p?.running_count ?? instances.filter((item) => item.tcp?.reachable).length;
const requiredCount = p?.required_count ?? instances.length;
return (
<div className="rounded-2xl border border-white/[0.07] bg-zinc-950/40 p-4 backdrop-blur-sm">
<p className="text-xs font-semibold text-white">Neko Chromium</p>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold text-white">Neko Chromium</p>
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
runningCount > 0
? "bg-emerald-500/20 text-emerald-200 ring-1 ring-emerald-500/30"
: "bg-rose-500/15 text-rose-200 ring-1 ring-rose-500/25"
}`}
>
{runningCount}/{requiredCount || "—"} {t("在线", "online")}
</span>
</div>
<p className="mt-1 text-[11px] text-zinc-500">
{t(
"Docker 内 Chromium(非 Chrome 品牌);arm64/x86 同一镜像。",
@@ -1198,6 +1380,35 @@ export default function LiveControlApp() {
? `${p.tcp.latency_ms} ms`
: "—"}
</p>
{instances.length ? (
<div className="mt-3 space-y-2">
{instances.map((item) => (
<div
key={`${item.id}-${item.port}`}
className="flex items-center justify-between gap-3 rounded-lg border border-white/[0.05] bg-black/25 px-3 py-2"
>
<div className="min-w-0">
<p className="text-[11px] font-semibold text-zinc-200">
Neko {item.id ?? "?"}
</p>
<p className="font-mono text-[10px] text-zinc-500">
:{item.port ?? "—"} · TCP {item.tcp?.reachable ? "OK" : "—"} · HTTP{" "}
{item.http?.http_code ?? "—"}
</p>
</div>
<span
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold ${
item.tcp?.reachable
? "bg-emerald-500/15 text-emerald-200 ring-1 ring-emerald-500/25"
: "bg-zinc-500/15 text-zinc-400 ring-1 ring-zinc-500/25"
}`}
>
{item.tcp?.reachable ? t("运行中", "Running") : t("未连通", "Offline")}
</span>
</div>
))}
</div>
) : null}
{!http?.reachable && http?.error ? (
<p className="mt-1 line-clamp-2 text-[10px] text-amber-200/80">{http.error}</p>
) : null}