update 2026-11-11 12:16:13

This commit is contained in:
action committed 2026-11-11 12:16:13 +08:00
1 parent 30afd5f00d
commit dc0eb451d5
19 files changed
+1042 -13

No files matched your search

+31 -1
View File
@@ -8,9 +8,39 @@ include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI support for ddnsto LUCI_TITLE:=LuCI support for ddnsto
LUCI_DEPENDS:=+ddnsto +block-mount LUCI_DEPENDS:=+ddnsto +block-mount
LUCI_PKGARCH:=all LUCI_PKGARCH:=all
PKG_VERSION:=4.2.2-r1 PKG_VERSION:=4.2.2-r3
PKG_RELEASE:= PKG_RELEASE:=
define Package/luci-app-ddnsto/postinst
#!/bin/sh
# Maintain the old desktop entry without a second manifest or file owner.
root="$${IPKG_INSTROOT:-}"
sh "$${root}/usr/share/ddnsto/register-desktop.sh" || exit $$?
icon="$${root}/usr/share/linkeasefull/app-meta/ddnsto/logo.png"
if [ "$$(readlink "$${icon}" 2>/dev/null)" = "/www/luci-static/resources/app-icons/ddnsto.png" ]; then
rm -f "$${icon}"
fi
rmdir "$${root}/usr/share/linkeasefull/app-meta/ddnsto" "$${root}/usr/share/linkeasefull/app-meta" 2>/dev/null || true
if [ -z "$${IPKG_INSTROOT}" ]; then
rm -f /tmp/luci-indexcache /tmp/luci-indexcache.*
rm -rf /tmp/luci-modulecache/
fi
exit 0
endef
define Package/luci-app-ddnsto/prerm
#!/bin/sh
root="$${IPKG_INSTROOT:-}"
for directory in /usr/share/linkease/apps.d /usr/share/linkeasefull/desktop-apps.d; do
registration="$${root}$${directory}/16-ddnsto.json"
if [ "$$(readlink "$${registration}" 2>/dev/null)" = "/usr/share/ddnsto/ddnsto-plugin.json" ]; then
rm -f "$${registration}"
fi
done
rmdir "$${root}/usr/share/linkeasefull/desktop-apps.d" 2>/dev/null || true
exit 0
endef
include $(TOPDIR)/feeds/luci/luci.mk include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature # call BuildPackage - OpenWrt buildroot signature
@@ -0,0 +1,46 @@
{
"schemaVersion": 1,
"id": "ddnsto",
"name": "DDNSTO",
"icon": "logo.png",
"staticRoot": "/usr/share/ddnsto/www",
"desktop": {
"mode": "builtin",
"component": "LuciContainer",
"props": {
"initialPath": "/cgi-bin/luci/admin/services/ddnsto",
"contentOnly": true,
"isolation": "scoped-dom-css",
"mountIds": [
"root",
"app"
],
"hideChrome": true,
"preloadScripts": [
"/luci-static/linkeasefull-embed/embed-prelude.js"
],
"preloadStyles": [
"/luci-static/linkeasefull-embed/embed.css"
]
}
},
"standalone": {
"url": "/cgi-bin/luci/admin/services/ddnsto"
},
"window": {
"width": 980,
"height": 700,
"singleton": true
},
"capabilities": [
"ddnsto",
"openwrt",
"luci",
"network"
],
"categories": [
"network",
"service",
"openwrt"
]
}
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# Called by the owning plugin and its metadata migration hook.
set -e
root="${IPKG_INSTROOT:-}"
manifest="/usr/share/ddnsto/ddnsto-plugin.json"
legacy="${root}/usr/share/linkeasefull/desktop-apps.d/16-ddnsto.json"
[ -f "${root}${manifest}" ] || exit 0
# Replace only the exact historical metadata payload; preserve custom entries.
if [ -f "${legacy}" ] && [ ! -L "${legacy}" ] && [ "$(sha256sum "${legacy}" | cut -d ' ' -f 1)" = "a72a8b3e529c91b2a3bc32ef695e5f7dc2935e332fcbd3201f985e05c2c8763d" ]; then
rm -f "${legacy}"
fi
if [ ! -e "${legacy}" ] && [ ! -L "${legacy}" ]; then
mkdir -p "${legacy%/*}"
ln -s "${manifest}" "${legacy}"
fi
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

@@ -0,0 +1 @@
/usr/share/ddnsto/ddnsto-plugin.json
+21 -1
View File
@@ -7,12 +7,32 @@ include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI support for fastnet LUCI_TITLE:=LuCI support for fastnet
LUCI_DEPENDS:=+fastnet +luci-lib-linkeaseauth +linkease-app-entry LUCI_DEPENDS:=+fastnet +luci-lib-linkeaseauth +linkease-app-entry
# APK uses -rN; OPKG uses -N for runtime package releases.
LUCI_EXTRA_DEPENDS:=fastnet (>=0.8.9-$(if $(CONFIG_USE_APK),r)1)
LUCI_PKGARCH:=all LUCI_PKGARCH:=all
PKG_VERSION:=0.8.1-r5 PKG_VERSION:=0.8.9-r1
PKG_RELEASE:= PKG_RELEASE:=
# Preserve LuCI translation version ordering when upgrading older git-* packages.
PKG_PO_VERSION:=git-26.313.00000-082
LUCI_MINIFY_CSS:=0 LUCI_MINIFY_CSS:=0
LUCI_MINIFY_JS:=0 LUCI_MINIFY_JS:=0
define Package/luci-app-fastnet/postinst
#!/bin/sh
set -e
"$${IPKG_INSTROOT}/usr/libexec/fastnet-card-register" install
if [ -z "$${IPKG_INSTROOT}" ]; then
rm -f /tmp/luci-indexcache /tmp/luci-indexcache.*
rm -rf /tmp/luci-modulecache/
killall -HUP rpcd 2>/dev/null || true
fi
endef
define Package/luci-app-fastnet/prerm
#!/bin/sh
"$${IPKG_INSTROOT}/usr/libexec/fastnet-card-register" remove
endef
include $(TOPDIR)/feeds/luci/luci.mk include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature # call BuildPackage - OpenWrt buildroot signature
+65 -1
View File
@@ -1,10 +1,15 @@
module("luci.controller.fastnet", package.seeall) module("luci.controller.fastnet", package.seeall)
function index() function index()
for _,action in ipairs({"summary","run","task","cancel"}) do
entry({"admin","services","fastnet","card",action},call("action_card_"..action)).leaf=true
end
if not nixio.fs.access("/etc/config/fastnet") then return end if not nixio.fs.access("/etc/config/fastnet") then return end
entry({"admin", "services", "fastnet"}, cbi("fastnet"), _("FastNet"), 50).dependent = true entry({"admin", "services", "fastnet"}, cbi("fastnet"), _("FastNet"), 50).dependent = true
entry({"admin", "services", "fastnet", "status"}, call("action_status")).leaf = true entry({"admin", "services", "fastnet", "status"}, call("action_status")).leaf = true
entry({"admin", "services", "fastnet", "browser-origin"}, call("action_browser_origin")).leaf = true
entry({"admin", "services", "fastnet", "embed"}, call("action_embed")).leaf = true
local open = entry({"admin", "services", "fastnet", "open"}, call("action_open")) local open = entry({"admin", "services", "fastnet", "open"}, call("action_open"))
open.leaf = true open.leaf = true
open.dependent = false open.dependent = false
@@ -30,5 +35,64 @@ function action_status()
end end
function action_open() function action_open()
compat():open("fastnet") local dispatcher = require "luci.dispatcher"
require("luci.http").redirect(dispatcher.build_url("admin", "services", "fastnet", "embed") .. "?standalone=1")
end
-- Only the launch metadata passes through LuCI. The iframe and all test data
-- connect to the configured FastNet service port in the browser.
function action_embed()
local http = require "luci.http"
local ctx = require("luci.dispatcher").context
if http.getenv("REQUEST_METHOD") ~= "GET" then http.status(405); return end
if not ctx.authsession or ctx.authuser ~= "root" then http.status(403); return end
local authority = http.getenv("HTTP_HOST") or ""
local host = authority:match("^(%[[%x:%.]+%]):?%d*$") or authority:match("^([%w%.%-]+):?%d*$")
local uci = require("luci.model.uci").cursor()
local port = tonumber(uci:get_first("fastnet", "fastnet", "port") or "3200")
if uci:get_first("fastnet", "fastnet", "enabled") == "0" or not host or not port or port % 1 ~= 0 or port < 1 or port > 65535 then
http.status(503); return
end
local url = "http://" .. host .. ":" .. tostring(port) .. "/webui/"
local query = {}
if http.formvalue("standalone") ~= "1" then
query[#query+1] = "embed=quickstart"
query[#query+1] = "theme=" .. (http.formvalue("theme") == "dark" and "dark" or "light")
end
if http.formvalue("view") == "history" then query[#query+1] = "view=history" end
local token = uci:get_first("fastnet", "fastnet", "token") or ""
if token ~= "" then query[#query+1] = "token=" .. http.urlencode(token) end
if #query > 0 then url = url .. "?" .. table.concat(query, "&") end
http.header("Cache-Control", "no-store")
http.header("Referrer-Policy", "no-referrer")
if http.formvalue("format") == "json" then
http.prepare_content("application/json"); http.write_json({url=url})
else
http.redirect(url)
end
end
function action_card_summary() require("luci.model.fastnet.card").new():handle("summary") end
function action_card_run() require("luci.model.fastnet.card").new():handle("run") end
function action_card_task() require("luci.model.fastnet.card").new():handle("task") end
function action_card_cancel() require("luci.model.fastnet.card").new():handle("cancel") end
-- uHTTPd owns REMOTE_ADDR. The app proxy's loopback peer and browser-provided
-- forwarded headers cannot stand in for the real browser in LAN capability UI.
function action_browser_origin()
local http = require "luci.http"
local ctx = require("luci.dispatcher").context
if not ctx.authsession or ctx.authuser ~= "root" then http.status(403); return end
if http.getenv("REQUEST_METHOD") ~= "GET" then http.status(405); return end
local address = http.getenv("REMOTE_ADDR")
if type(address) ~= "string" or address == "" or #address > 64 or not address:match("^[%x:%.]+$") then http.status(503); return end
local loopback = address == "::1" or address:match("^127%.") ~= nil or address:match("^::ffff:127%.") ~= nil
local local_address = loopback
for _, entry in ipairs(require("nixio").getifaddrs() or {}) do
if entry.addr == address then local_address = true; break end
end
http.prepare_content("application/json")
http.header("Cache-Control", "no-store")
http.header("X-Content-Type-Options", "nosniff")
http.write_json({isLoopback=loopback,isLocal=local_address,isLocalInterface=local_address})
end end
@@ -0,0 +1,150 @@
-- The public authenticated adapter. No shell commands, core tokens or probes.
local M = {}
local SOCKET = "/var/run/fastnet-card.sock"
local STATE = "/etc/fastnet/card-state.json"
local MAX_BODY, MAX_RESPONSE = 1024, 8192
local function socket_request(method, path, body)
local nixio = require "nixio"
local socket = nixio.socket("unix", "stream")
if not socket then return nil, nil, "service_unavailable" end
local deadline = os.time() + 6
local function timeout()
local remaining = deadline - os.time()
if remaining <= 0 then return false end
socket:setopt("socket", "sndtimeo", remaining)
socket:setopt("socket", "rcvtimeo", remaining)
return true
end
local function fail(reason) socket:close(); return nil, nil, reason end
timeout()
if not socket:connect(SOCKET) then return fail("service_unavailable") end
body = body or ""
local request = method .. " /api/card/" .. path .. " HTTP/1.0\r\nHost: localhost\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: " .. #body .. "\r\n\r\n" .. body
local offset = 1
while offset <= #request do
if not timeout() then return fail("upstream_timeout") end
local sent = socket:send(request:sub(offset))
if not sent or sent <= 0 then return fail("upstream_timeout") end
offset = offset + sent
end
local chunks, length = {}, 0
while true do
if not timeout() then return fail("upstream_timeout") end
local chunk = socket:recv(2048)
if not chunk then return fail("upstream_timeout") end
if chunk == "" then break end
length = length + #chunk
if length > MAX_RESPONSE + 4096 then return fail("upstream_too_large") end
chunks[#chunks + 1] = chunk
end
socket:close()
local raw = table.concat(chunks)
local headers, response = raw:match("^(.-)\r\n\r\n(.*)$")
local status = headers and tonumber(headers:match("^HTTP/1%.[01]%s+(%d%d%d)"))
if not status or not headers:lower():match("content%-type:%s*application/json") or #response > MAX_RESPONSE then return nil, nil, "invalid_upstream" end
return status, response
end
local function defaults()
local nixio, fs = require "nixio", require "nixio.fs"
local function read(path, maximum)
local file = nixio.open(path, "r")
if not file then return nil end
local parts, length = {}, 0
while true do
local part=file:read(4096)
if not part then file:close();return nil end
if part=="" then break end
length=length+#part
if length>maximum then file:close();return nil end
parts[#parts+1]=part
end
file:close();return table.concat(parts)
end
return {http=require "luci.http",ctx=require("luci.dispatcher").context,json=require "luci.jsonc",fs=fs,
enabled=function() return require("luci.model.uci").cursor():get_first("fastnet", "fastnet", "enabled") ~= "0" end,
state=function() return read(STATE, 128*1024) end,request=socket_request}
end
function M.new(deps)
local d = deps or defaults()
local http, ctx, json = d.http, d.ctx, d.json
local function reply(status, value)
http.status(status)
http.prepare_content("application/json")
http.header("Cache-Control", "no-store")
http.header("X-Content-Type-Options", "nosniff")
http.write(type(value)=="string" and value or json.stringify(value))
end
local function error_reply(status, code) return reply(status, {error=code}) end
-- jsonc drops null object members when decoding. Forward live core JSON as
-- bytes; reconstruct nullable fields explicitly only for a stopped service.
local function atom(value) return value==nil and "null" or json.stringify(value) end
local function measurement(value)
if type(value)~="table" then return '{"status":"not_tested","value":null,"measuredAt":null,"error":null}' end
return '{"status":'..atom(value.status)..',"value":'..atom(value.value)..',"measuredAt":'..atom(value.measuredAt)..',"error":'..atom(value.error)..',"runId":'..atom(value.runId)..'}'
end
local function stopped_summary(reason)
local raw=d.state();local disk=raw and json.parse(raw) or nil
local latest=type(disk)=="table" and (disk.schemaVersion==1 or disk.schemaVersion==2) and disk.latest or nil
local result="null"
if type(latest)=="table" then
result='{"runId":'..atom(latest.runId)..',"measuredAt":'..atom(latest.measuredAt)..',"download":'..measurement(latest.download)..',"upload":'..measurement(latest.upload)..',"nat":'..measurement(latest.nat)..',"ipv6":'..measurement(latest.ipv6)
if type(latest.bandwidth)=="table" then result=result..',"bandwidth":'..json.stringify(latest.bandwidth) end
result=result..'}'
end
local state = d.enabled() and "unavailable" or "disabled"
return reply(200,'{"schemaVersion":1,"instanceId":"","service":{"state":'..atom(state)..',"reason":'..atom(reason)..'},"latest":'..result..',"latestResults":'..json.stringify(type(disk)=="table" and disk.latestResults or {})..',"task":null}')
end
return {handle=function(_, action)
if type(ctx.authsession)~="string" or ctx.authsession=="" then return error_reply(401,"unauthenticated") end
if ctx.authuser~="root" then return error_reply(403,"forbidden") end
local writing=action=="run" or action=="cancel"
local method=http.getenv("REQUEST_METHOD")
if method~=(writing and "POST" or "GET") then return error_reply(405,"method") end
if not d.fs.access("/etc/config/fastnet") then return error_reply(503,"installation_incomplete") end
if not d.fs.access("/usr/sbin/FastNet") then return error_reply(503,"core_missing") end
local body, path = nil, action
if writing then
local site=http.getenv("HTTP_SEC_FETCH_SITE")
if site and site~="same-origin" and site~="none" then return error_reply(403,"cross_origin") end
local origin=http.getenv("HTTP_ORIGIN")
local secure=http.getenv("HTTPS");local scheme=(secure=="on" or secure=="1") and "https" or "http"
if origin and origin~=scheme.."://"..(http.getenv("HTTP_HOST") or "") then return error_reply(403,"cross_origin") end
-- Some uhttpd builds omit custom CGI headers. The standard LuCI query
-- token is read without consuming the JSON request body.
local csrf=http.getenv("HTTP_X_CSRF_TOKEN") or http.getenv("HTTP_X-CSRF-TOKEN") or http.formvalue("token",true)
if type(ctx.authtoken)~="string" or ctx.authtoken=="" or csrf~=ctx.authtoken then return error_reply(403,"csrf") end
local kind=(http.getenv("CONTENT_TYPE") or ""):match("^%s*([^;]+)")
if kind~="application/json" then return error_reply(422,"content_type") end
local size=tonumber(http.getenv("CONTENT_LENGTH"))
if size and size>MAX_BODY then return error_reply(413,"body_too_large") end
body=http.content()
if type(body)~="string" or #body>MAX_BODY then return error_reply(413,"body_too_large") end
local key=action=="run" and "idempotencyKey" or "id"
local value=body:match('^%s*{%s*"'..key..'"%s*:%s*"([A-Za-z0-9_-]+)"%s*}%s*$')
if not value or #value>(action=="run" and 64 or 96) or #value<(action=="run" and 16 or 1) then return error_reply(422,"invalid_request") end
elseif action=="task" then
local id=http.formvalue("id")
if type(id)~="string" or #id<1 or #id>96 or not id:match("^[A-Za-z0-9_-]+$") then return error_reply(422,"invalid_id") end
path="task?id="..id
elseif action~="summary" then return error_reply(404,"not_found") end
local status, raw, reason=d.request(method,path,body)
if not status then
if action=="summary" and reason=="service_unavailable" then return stopped_summary(reason) end
return error_reply(reason=="service_unavailable" and 503 or 502,reason or "invalid_upstream")
end
local value=type(raw)=="string" and #raw<=MAX_RESPONSE and json.parse(raw) or nil
if type(value)~="table" then return error_reply(502,"invalid_upstream") end
if status>=400 then
local allowed={busy=true,not_found=true,invalid_id=true,invalid_request=true,persistence_failed=true,local_only=true,method=true}
return error_reply(status,allowed[value.error] and value.error or "core_error")
end
if action=="summary" then
if value.schemaVersion~=1 or type(value.service)~="table" then return error_reply(502,"invalid_upstream") end
elseif type(value.id)~="string" or type(value.state)~="string" then return error_reply(502,"invalid_upstream") end
return reply(status,raw)
end}
end
return M
@@ -1,5 +1,6 @@
#!/bin/sh #!/bin/sh
/usr/libexec/fastnet-card-register install || exit 1
rm -f /tmp/luci-indexcache rm -f /tmp/luci-indexcache
exit 0 exit 0
+23
View File
@@ -0,0 +1,23 @@
#!/bin/sh
set -eu
root=${IPKG_INSTROOT:-}
source="$root/usr/share/fastnet/quickstart/fastnet.json"
target="$root/usr/share/quickstart/cards.d/fastnet.json"
case "${1:-install}" in
install)
[ -f "$source" ] || exit 1
entry=$(sed -n 's/^[[:space:]]*"entry": "\(\/luci-static\/fastnet\/quickstart\/card\.[a-f0-9][a-f0-9]*\.js\)".*/\1/p' "$source")
[ -n "$entry" ] && [ -f "$root/www$entry" ] || exit 1
umask 022
mkdir -p "${target%/*}"
temporary="$target.new.$$"
trap 'rm -f "$temporary"' EXIT HUP INT TERM
cp "$source" "$temporary"
mv -f "$temporary" "$target"
;;
remove)
# Never delete another package's registration, including a replaced file.
if [ -f "$source" ] && cmp -s "$source" "$target"; then rm -f "$target"; fi
;;
*) exit 2 ;;
esac
@@ -0,0 +1,23 @@
{
"schemaVersion": 1,
"id": "plugin.fastnet",
"title": "外网测速",
"glyph": "activity",
"abi": "quickstart-card-v1",
"version": "0.8.9-1",
"apiBase": "/cgi-bin/luci/admin/services/fastnet/card",
"forms": {
"standard": {
"minWidth": 280,
"minHeight": 260
},
"summary": {
"minWidth": 240,
"minHeight": 148
}
},
"stacked": [
"summary"
],
"entry": "/luci-static/fastnet/quickstart/card.dfb0ce55.js"
}
@@ -0,0 +1,398 @@
var D = (n, b, v) => new Promise((e, d) => {
var l = (r) => {
try {
f(v.next(r));
} catch (q) {
d(q);
}
}, a = (r) => {
try {
f(v.throw(r));
} catch (q) {
d(q);
}
}, f = (r) => r.done ? e(r.value) : Promise.resolve(r.value).then(l, a);
f((v = v.apply(n, b)).next());
});
import { defineAsyncComponent as ia, h as W, computed as z, ref as J, onMounted as ua, onUnmounted as pa, openBlock as C, createElementBlock as B, unref as c, normalizeClass as G, createElementVNode as s, createTextVNode as w, toDisplayString as o, createBlock as la, resolveDynamicComponent as oa, withCtx as fa, createCommentVNode as ca, reactive as ma } from "/luci-static/quickstart/card-runtime-v1.js";
const ga = '[data-fastnet-card]{--fn-down:var(--qs-card-accent);position:relative;isolation:isolate;container-type:inline-size;gap:12px;border-radius:inherit}[data-fastnet-card] .qs-card-header h2>svg{color:var(--fn-down)}[data-fastnet-card] .qs-card-primary{background:var(--qs-card-accent);color:var(--qs-card-panel);border-color:transparent;padding:6px 13px;border-radius:9px;font-weight:600}[data-fastnet-card] .qs-card-menu>summary{border-color:transparent;color:var(--qs-card-muted)}[data-fastnet-card] .fn-speed-stage{flex:1;min-height:0;display:flex;flex-direction:column;justify-content:center;gap:14px;padding:18px 20px;border-radius:14px;border:1px solid var(--qs-card-line);background:var(--qs-card-soft)}[data-fastnet-card] .fn-speed-caption{display:flex;align-items:center;justify-content:space-between;gap:6px;font-size:10px;letter-spacing:.7px;color:var(--qs-card-muted)}[data-fastnet-card] .fn-live{display:flex;align-items:center;gap:5px;letter-spacing:0;white-space:nowrap}[data-fastnet-card] .fn-live:before{content:"";width:5px;height:5px;border-radius:50%;background:var(--qs-card-muted)}[data-fastnet-card] .fn-live.is-active{color:var(--qs-card-accent)}[data-fastnet-card] .fn-live.is-active:before{background:currentColor;box-shadow:0 0 0 3px var(--qs-card-soft)}[data-fastnet-card] .qs-card-metrics{gap:18px;align-items:center;grid-template-columns:1.25fr 1fr}[data-fastnet-card] .qs-card-metrics strong{font-size:clamp(30px,12cqi,52px);line-height:1.15;letter-spacing:-1.7px;font-weight:700;margin-top:4px;color:var(--fn-down)}[data-fastnet-card] .qs-card-metrics>div+div{border-left:1px solid var(--qs-card-line);padding-left:18px}[data-fastnet-card] .qs-card-metrics>div+div strong{font-size:clamp(25px,8cqi,36px);color:var(--qs-card-text);letter-spacing:-.9px}[data-fastnet-card] .qs-card-metrics small{letter-spacing:0}[data-fastnet-card] .qs-card-time{gap:5px;font-size:10px}[data-fastnet-card] .qs-card-statuses{padding:0;border:0;gap:10px}[data-fastnet-card] .qs-card-statuses>div{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:3px 8px;padding:8px 10px;border:1px solid var(--qs-card-line);border-radius:8px;background:var(--qs-card-panel)}[data-fastnet-card] .qs-card-statuses span{display:flex;align-items:center;gap:6px;letter-spacing:.3px;margin:0;flex:none}[data-fastnet-card] .qs-card-statuses i{width:6px;height:6px;border-radius:50%;background:var(--qs-card-muted)}[data-fastnet-card] .qs-card-statuses [data-tone=good] i{background:var(--qs-card-good)}[data-fastnet-card] .qs-card-statuses [data-tone=warning] i{background:var(--qs-card-warning)}[data-fastnet-card].qs-card-summary{gap:3px;background:none}[data-fastnet-card].qs-card-summary .fn-speed-stage{display:block;flex:none;padding:0;border:0;background:none}[data-fastnet-card].qs-card-summary .fn-speed-caption{display:none}[data-fastnet-card].qs-card-summary .qs-card-metrics{grid-template-columns:1fr 1fr;gap:12px}[data-fastnet-card].qs-card-summary .qs-card-metrics strong{font-size:23px;letter-spacing:-.5px;margin:0}[data-fastnet-card].qs-card-summary .qs-card-metrics>div+div{padding:0;border:0}[data-fastnet-card].qs-card-summary .qs-card-statuses>div{padding:0;border:0;background:none;max-width:50%;flex-wrap:nowrap;gap:5px}[data-fastnet-card].qs-card-summary .qs-card-statuses span{margin:0;gap:4px}[data-fastnet-card].qs-card-summary .qs-card-statuses i{width:4px;height:4px}[data-fastnet-card].qs-card-summary .qs-card-primary{padding:2px 7px;border-radius:6px}@media(max-width:600px){[data-fastnet-card]:not(.qs-card-summary) .fn-speed-stage{padding:14px;gap:12px}[data-fastnet-card]:not(.qs-card-summary) .qs-card-metrics strong{font-size:36px}[data-fastnet-card]:not(.qs-card-summary) .qs-card-metrics>div+div strong{font-size:28px}}[data-fastnet-card].fn-dense:not(.qs-card-summary){gap:5px;padding:10px 14px}[data-fastnet-card].fn-dense:not(.qs-card-summary) .fn-speed-stage{padding:6px 12px;gap:3px;min-height:81px;flex-shrink:0}[data-fastnet-card].fn-dense:not(.qs-card-summary) .fn-speed-caption{display:none}[data-fastnet-card].fn-dense:not(.qs-card-summary) .qs-card-metrics strong{font-size:28px;line-height:1.15;margin:0}[data-fastnet-card].fn-dense:not(.qs-card-summary) .qs-card-metrics>div+div strong{font-size:23px}[data-fastnet-card].fn-dense:not(.qs-card-summary) .qs-card-statuses>div{padding:6px 9px}[data-fastnet-card] .fn-lan-summary{flex:none;min-width:0;padding:0 20px;color:var(--qs-card-text)}[data-fastnet-card] .fn-lan-caption{display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:2px 8px;font-size:10px;color:var(--qs-card-muted)}[data-fastnet-card] .fn-lan-caption time{font-variant-numeric:tabular-nums;white-space:nowrap}[data-fastnet-card] .fn-lan-metrics{display:grid;grid-template-columns:1.25fr 1fr;gap:18px;margin-top:6px}[data-fastnet-card] .fn-lan-metrics>div{min-width:0}[data-fastnet-card] .fn-lan-metrics>div+div{padding-left:18px;border-left:1px solid var(--qs-card-line)}[data-Line truncated
function ya(n) {
var b, v, e, d;
if ((n == null ? void 0 : n.schemaVersion) !== 1 || !["ready", "disabled", "unavailable"].includes((b = n.service) == null ? void 0 : b.state) || !("latest" in n) || !("task" in n)) throw Error("invalid_summary");
if ((v = n.latest) != null && v.bandwidth) {
const l = n.latest.bandwidth;
if (![l.downloadBps, l.uploadBps].every((a) => typeof a == "number" && Number.isFinite(a) && a >= 0) || !l.measuredAt || !Number.isFinite(Date.parse(l.measuredAt))) throw Error("invalid_bandwidth");
}
if (n.task && !K.includes(n.task.state)) throw Error("invalid_task");
for (const l of ["download", "upload", "nat", "ipv6"]) if (n.latest) {
const a = n.latest[l];
if (!a || !["ok", "unavailable", "error", "not_tested"].includes(a.status) || a.value !== null && (["download", "upload"].includes(l) ? typeof a.value != "number" || !Number.isFinite(a.value) || a.value < 0 : typeof a.value != "string")) throw Error("invalid_measurement");
}
for (const l of ["download", "upload", "lanDownload", "lanUpload", "lanPing"]) {
const a = (d = (e = n.latestResults) == null ? void 0 : e[l]) == null ? void 0 : d.value;
if (a != null && (!Number.isFinite(a) || a < 0)) throw Error("invalid_measurement");
}
return n;
}
function va(n) {
var e, d;
const b = n == null ? void 0 : n.latestResults;
if (b != null && b.download || b != null && b.upload) {
const l = b.download, a = b.upload;
return { downloadBps: (e = l == null ? void 0 : l.value) != null ? e : null, uploadBps: (d = a == null ? void 0 : a.value) != null ? d : null, measuredAt: [l == null ? void 0 : l.measuredAt, a == null ? void 0 : a.measuredAt].filter(Boolean).sort().pop(), mixed: !!(l && a && l.runId !== a.runId) };
}
const v = n == null ? void 0 : n.latest;
return v ? v.bandwidth || { downloadBps: v.download.status === "ok" ? v.download.value : null, uploadBps: v.upload.status === "ok" ? v.upload.value : null, measuredAt: v.measuredAt } : null;
}
function E(n) {
return n == null ? "—" : n > 0 && n < 1e5 ? "<0.1" : (n / 1e6).toLocaleString("en-US", { maximumFractionDigits: n >= 1e8 ? 0 : 1 });
}
function H(n, b) {
return !n || !Number.isFinite(Date.parse(n)) ? "—" : new Date(n).toLocaleString(b === "en" ? "en-GB" : "zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: !1 });
}
function xa(n, b = (e) => e, v = globalThis) {
const e = b({ loading: !1, summary: null, task: null, submitting: !1, cancelling: !1, error: "", errorAction: "read", paused: !1, failures: 0 });
let d = !0, l = !0, a, f, r, q = null, y = 0;
const h = () => {
v.clearTimeout(a), a = void 0, f == null || f.abort(), f = void 0, y++;
}, N = (u) => [401, 403].includes(u == null ? void 0 : u.status) ? "permission" : (u == null ? void 0 : u.status) === 404 ? "upgrade" : "unavailable", S = () => {
d && l && !e.paused && (a = v.setTimeout(k(e.task) ? I : _, k(e.task) ? 2e3 : 15e3));
};
function I() {
return D(this, null, function* () {
if (!d || !l || !k(e.task)) return;
const u = e.task.id, m = ++y;
f = new AbortController();
try {
const g = yield n.request("/task?id=" + encodeURIComponent(u), { signal: f.signal });
if (!d || m !== y) return;
if (g.id !== u || !K.includes(g.state)) throw Error("invalid_task");
if (e.task = g, e.failures = 0, e.error = "", !k(g)) {
q = null, yield _();
return;
}
} catch (g) {
if (!d || m !== y) return;
e.failures++, e.error = N(g), e.errorAction = "read", (e.failures >= 3 || ["permission", "upgrade"].includes(e.error)) && (e.paused = !0);
} finally {
d && m === y && (f = void 0, S());
}
});
}
function _() {
return D(this, null, function* () {
if (h(), !d || !l) return;
const u = y;
e.loading = !0, f = new AbortController();
try {
const m = ya(yield n.request("/summary", { signal: f.signal }));
if (!d || u !== y) return;
e.summary = m, e.task = m.task || m.lastTask || null, e.error = "", e.failures = 0, e.paused = !1, k(e.task) && (q = null);
} catch (m) {
d && u === y && (e.error = N(m), e.errorAction = "read", e.failures++, e.paused = e.failures >= 3 || ["permission", "upgrade"].includes(e.error));
} finally {
d && u === y && (e.loading = !1, f = void 0, S());
}
});
}
function V() {
return D(this, null, function* () {
var m;
if (!d || e.error === "permission" || e.submitting || k(e.task) || e.loading || ((m = e.summary) == null ? void 0 : m.service.state) !== "ready") return;
h();
const u = y;
e.submitting = !0, e.error = "", r = new AbortController(), q || (q = Array.from(crypto.getRandomValues(new Uint8Array(16)), (g) => g.toString(16).padStart(2, "0")).join(""));
try {
const g = yield n.request("/run", { method: "POST", body: JSON.stringify({ idempotencyKey: q }), signal: r.signal });
if (!d) return;
if (!K.includes(g.state)) throw Error("invalid_task");
if (q = null, u !== y) return;
e.task = g, e.paused = !1, e.failures = 0, k(g) ? S() : yield _();
} catch (g) {
if (!d || u !== y) return;
(g == null ? void 0 : g.status) === 409 ? (q = null, yield _()) : (e.error = N(g), e.errorAction = ["permission", "upgrade"].includes(e.error) ? "read" : "run", g != null && g.status && g.status < 500 && (q = null));
} finally {
d && (e.submitting = !1, r = void 0);
}
});
}
function M() {
return D(this, null, function* () {
if (!d || !k(e.task) || e.cancelling) return;
h();
const u = y;
e.cancelling = !0, r = new AbortController();
try {
const m = yield n.request("/cancel", { method: "POST", body: JSON.stringify({ id: e.task.id }), signal: r.signal });
if (!d || u !== y) return;
e.task = m, k(m) ? S() : yield _();
} catch (m) {
d && u === y && (e.error = N(m), e.errorAction = "read", S());
} finally {
d && (e.cancelling = !1, r = void 0);
}
});
}
return { state: e, start: V, stop: M, reload: _, visibility(u) {
l = u, h(), u && _();
}, dispose() {
d = !1, h(), r == null || r.abort();
} };
}
const ba = ["data-task-id"], qa = { class: "qs-card-header" }, ha = {
key: 0,
class: "widget-action-group"
}, wa = {
key: 1,
class: "qs-card-actions"
}, ka = { class: "qs-card-menu" }, _a = ["aria-label"], Aa = { class: "fn-speed-stage" }, Ca = { class: "fn-speed-caption" }, Na = { class: "qs-card-metrics" }, Sa = { class: "qs-card-time" }, Ta = ["datetime"], za = ["aria-label"], Ba = { class: "fn-lan-caption" }, Ra = { class: "fn-lan-route" }, Fa = ["datetime"], Pa = { class: "fn-lan-metrics" }, Da = { class: "fn-lan-label" }, Ea = { class: "fn-lan-label" }, Ia = { class: "qs-card-statuses" }, La = ["data-tone"], Ua = ["title"], Va = ["data-tone"], Ma = ["title"], Oa = {
key: 0,
class: "qs-card-progress",
role: "status"
}, ja = ["disabled"], $a = {
key: 1,
class: "qs-card-alert",
role: "alert"
}, Wa = {
key: 2,
class: "qs-card-alert",
role: "status"
}, Ha = {
__name: "Card",
props: ["context"],
setup(n) {
const b = ia(() => import("./chunk.31ac0177.js")), v = ia({ loader: () => import("./chunk.1932f6b1.js"), timeout: 8e3, errorComponent: { render: () => W("p", { role: "alert", style: "padding:24px" }, [W("span", "FastNet 暂时无法打开 / Unable to open FastNet. "), W("a", { href: "/cgi-bin/luci/admin/services/fastnet/open", target: "_blank", rel: "noopener" }, "新窗口打开 / Open in new window ↗")]) } }), e = n, d = e.context, l = d.locale === "en" ? "en" : "zh-cn", a = (i, t) => l === "en" ? t : i, f = xa(d, ma), r = f.state, q = "/cgi-bin/luci/admin/services/fastnet", y = z(() => va(r.summary)), h = z(() => k(r.task)), N = z(() => {
var i, t, p, x, A, P;
return [(p = (t = (i = r.summary) == null ? void 0 : i.latestResults) == null ? void 0 : t.lanDownload) == null ? void 0 : p.measuredAt, (P = (A = (x = r.summary) == null ? void 0 : x.latestResults) == null ? void 0 : A.lanUpload) == null ? void 0 : P.measuredAt].filter(Boolean).sort().pop();
}), S = { preparing: a("准备测速", "Preparing"), ping: a("检测延迟", "Testing latency"), download: a("测试下载", "Testing download"), upload: a("测试上传", "Testing upload"), nat: a("检测 NAT", "Checking NAT"), ipv6: a("检测 IPv6", "Checking IPv6") }, I = z(() => {
var i, t;
return ((i = r.task) == null ? void 0 : i.state) === "cancel_requested" ? a("正在停止", "Stopping") : S[(t = r.task) == null ? void 0 : t.stage] || a("测速中", "Testing");
}), _ = z(() => r.error === "permission" ? a("登录已失效或权限不足,请重新登录", "Session expired or permission denied; sign in again") : r.error === "upgrade" ? a("插件接口不可用,请检查或升级 FastNet", "Plugin API unavailable; check or update FastNet") : r.paused ? a("连接连续失败,已暂停查询;后台任务继续运行", "Queries paused after repeated failures; the background task continues") : a("暂时无法确认结果,保留已有数据", "Result unavailable; existing data retained")), V = z(() => [{ id: "details", label: a("查看上次结果", "Last result details") }, { id: "history", label: a("测速历史", "Test history") }, { id: "reload", label: a("重新读取", "Reload") }, { id: "settings", label: a("插件设置", "Plugin settings") }, { id: "full", label: a("完整 FastNet", "Full FastNet") }]);
function M(i) {
i === "details" ? X() : i === "history" ? T("history") : i === "reload" ? f.reload() : i === "settings" ? window.open(q, "_blank", "noopener") : i === "full" && T();
}
const u = J(!1), m = J(null), g = J(!1);
let R, F, O = !0;
function Q(i) {
var p, x;
const t = (x = (p = r.summary) == null ? void 0 : p.latest) == null ? void 0 : x[i];
return j(i) || (t == null ? void 0 : t.status) === "error" ? "warning" : (t == null ? void 0 : t.status) === "ok" ? "good" : "neutral";
}
function T(i) {
var p, x;
const t = (p = i == null ? void 0 : i.currentTarget) == null ? void 0 : p.closest("details");
t == null || t.removeAttribute("open"), (x = t == null ? void 0 : t.querySelector("summary")) == null || x.focus({ preventScroll: !0 }), u.value = !0, f.visibility(!1), d.openDetails(v, { context: d, locale: l, view: i === "history" ? "history" : "quick" }, { wide: !0, headerAction: { href: q + "/open", label: a("新窗口打开", "Open in new window") }, onClose() {
u.value = !1, f.visibility(!document.hidden && !u.value && O);
} });
}
const j = (i) => {
var t, p, x;
return !h.value && !!((x = (p = (t = r.summary) == null ? void 0 : t.lastTask) == null ? void 0 : p.issues) != null && x[i]);
};
function L(i) {
var x, A;
const t = (A = (x = r.summary) == null ? void 0 : x.latest) == null ? void 0 : A[i];
let p;
return !t || t.status === "not_tested" ? p = a("未检测", "Not tested") : t.status === "error" ? p = a("检测失败", "Probe failed") : t.status === "unavailable" ? p = a("不可用", "Unavailable") : i === "ipv6" ? p = /部分|准备度 [0-9]\/10/.test(t.value) ? a("部分可用", "Partly available") : a("可用", "Available") : p = /port.?restricted/i.test(t.value) ? a("端口受限锥形", "Port Restricted Cone") : /restricted.*cone/i.test(t.value) ? a("受限锥形", "Restricted Cone") : /full.?cone/i.test(t.value) ? a("全锥形", "Full Cone") : /symmetric/i.test(t.value) ? a("对称型", "Symmetric") : t.value, j(i) ? p + " · " + a("本次失败", "latest failed") : p;
}
function X(i) {
var p, x;
const t = (p = i == null ? void 0 : i.currentTarget) == null ? void 0 : p.closest("details");
t == null || t.removeAttribute("open"), (x = t == null ? void 0 : t.querySelector("summary")) == null || x.focus({ preventScroll: !0 }), d.openDetails(b, { state: r, locale: l, stamp: H, mbps: E });
}
function U() {
f.visibility(!document.hidden && !u.value && O);
}
return ua(() => {
R = new ResizeObserver(() => {
var i;
g.value = ((i = m.value) == null ? void 0 : i.clientHeight) < 380;
}), m.value && R.observe(m.value), F = new IntersectionObserver((i) => {
var t;
O = ((t = i[0]) == null ? void 0 : t.isIntersecting) !== !1, U();
}), m.value && F.observe(m.value), document.addEventListener("visibilitychange", U), U();
}), pa(() => {
R == null || R.disconnect(), F == null || F.disconnect(), document.removeEventListener("visibilitychange", U), f.dispose();
}), (i, t) => {
var p, x, A, P, Y, Z, aa, ta, ea, sa, na, ra, da;
return C(), B("article", {
ref_key: "root",
ref: m,
class: G(["qs-card-content", { "qs-card-summary": c(d).form === "summary", "fn-dense": g.value }]),
"data-fastnet-card": "",
"data-task-id": h.value ? c(r).task.id : void 0
}, [
s("header", qa, [
s("h2", null, [
t[4] || (t[4] = s("svg", {
viewBox: "0 0 24 24",
"aria-hidden": "true"
}, [
s("path", { d: "M3 12h4l3-8 4 16 3-8h4" })
], -1)),
w(o(a("外网测速", "Internet speed")), 1)
]),
(p = c(d).components) != null && p.PrimaryAction && ((x = c(d).components) != null && x.Menu) ? (C(), B("div", ha, [
(C(), la(oa(c(d).components.PrimaryAction), {
icon: "activity",
"data-fastnet-test": "",
onClick: T
}, {
default: fa(() => [
w(o(h.value ? a("查看进度", "View progress") : a("测速", "Test")), 1)
]),
_: 1
})),
(C(), la(oa(c(d).components.Menu), {
label: a("外网测速更多操作", "Internet speed actions"),
items: V.value,
onSelect: M
}, null, 40, ["label", "items"]))
])) : (C(), B("div", wa, [
s("button", {
class: "qs-card-primary",
"data-fastnet-test": "",
onClick: T
}, o(h.value ? a("查看进度", "View progress") : a("测速", "Test")), 1),
s("details", ka, [
s("summary", {
"aria-label": a("外网测速更多操作", "Internet speed actions")
}, "⋮", 8, _a),
s("div", null, [
s("button", {
"data-fastnet-details": "",
onClick: X
}, o(a("查看上次结果", "Last result details")), 1),
s("button", {
onClick: t[0] || (t[0] = ($) => T("history"))
}, o(a("测速历史", "Test history")), 1),
s("button", {
onClick: t[1] || (t[1] = ($) => c(f).reload())
}, o(a("重新读取", "Reload")), 1),
s("a", {
href: q,
target: "_blank",
rel: "noopener"
}, o(a("插件设置", "Plugin settings")), 1),
s("button", { onClick: T }, o(a("完整 FastNet", "Full FastNet")), 1)
])
])
]))
]),
s("div", Aa, [
s("div", Ca, [
s("span", null, "WAN · " + o(a("最近有效结果", "LATEST VALID RESULTS")), 1),
s("span", {
class: G(["fn-live", { "is-active": h.value }])
}, o(h.value ? I.value : (A = y.value) != null && A.measuredAt ? a("已保存", "Saved") : a("等待测速", "Ready to test")), 3)
]),
s("div", Na, [
s("div", null, [
s("span", null, "↓ " + o(a("上次下载", "Last download")), 1),
s("strong", null, [
w(o(c(E)((P = y.value) == null ? void 0 : P.downloadBps)), 1),
t[5] || (t[5] = s("small", null, "Mbps", -1))
])
]),
s("div", null, [
s("span", null, "↑ " + o(a("上次上传", "Last upload")), 1),
s("strong", null, [
w(o(c(E)((Y = y.value) == null ? void 0 : Y.uploadBps)), 1),
t[6] || (t[6] = s("small", null, "Mbps", -1))
])
])
]),
s("p", Sa, [
w(o(a("上次测速", "Last tested")) + " ", 1),
s("time", {
datetime: ((Z = y.value) == null ? void 0 : Z.measuredAt) || void 0
}, o((aa = y.value) != null && aa.measuredAt ? c(H)(y.value.measuredAt, c(l)) : a("尚未测速", "No test yet")), 9, Ta)
])
]),
s("section", {
class: "fn-lan-summary",
"aria-label": a("内网测速结果", "LAN speed results")
}, [
s("div", Ba, [
s("span", null, [
t[7] || (t[7] = w("LAN ", -1)),
s("span", Ra, "· " + o(a("浏览器 → 路由器", "Browser → Router")), 1)
]),
s("time", {
datetime: N.value || void 0
}, o(c(H)(N.value, c(l))), 9, Fa)
]),
s("div", Pa, [
s("div", null, [
s("span", Da, o(a("下载", "Download")), 1),
s("strong", null, [
w("↓ " + o(c(E)((sa = (ea = (ta = c(r).summary) == null ? void 0 : ta.latestResults) == null ? void 0 : ea.lanDownload) == null ? void 0 : sa.value)), 1),
t[8] || (t[8] = s("small", null, "Mbps", -1))
])
]),
s("div", null, [
s("span", Ea, o(a("上传", "Upload")), 1),
s("strong", null, [
w("↑ " + o(c(E)((da = (ra = (na = c(r).summary) == null ? void 0 : na.latestResults) == null ? void 0 : ra.lanUpload) == null ? void 0 : da.value)), 1),
t[9] || (t[9] = s("small", null, "Mbps", -1))
])
])
])
], 8, za),
s("div", Ia, [
s("div", {
"data-tone": Q("nat")
}, [
t[10] || (t[10] = s("span", null, [
s("i", { "aria-hidden": "true" }),
w("NAT")
], -1)),
s("strong", {
title: L("nat")
}, o(L("nat")), 9, Ua)
], 8, La),
s("div", {
"data-tone": Q("ipv6")
}, [
t[11] || (t[11] = s("span", null, [
s("i", { "aria-hidden": "true" }),
w("IPv6")
], -1)),
s("strong", {
class: G({ "qs-card-warning": j("ipv6") }),
title: L("ipv6")
}, o(L("ipv6")), 11, Ma)
], 8, Va)
]),
h.value ? (C(), B("div", Oa, [
s("span", null, [
w(o(I.value), 1),
s("small", null, o(a("上次有效结果保持显示", "Previous valid results remain visible")), 1)
]),
s("button", {
disabled: c(r).cancelling || c(r).task.state === "cancel_requested",
onClick: t[2] || (t[2] = ($) => c(f).stop())
}, o(c(r).cancelling || c(r).task.state === "cancel_requested" ? a("正在停止", "Stopping") : a("停止", "Stop")), 9, ja)
])) : ca("", !0),
c(r).error ? (C(), B("p", $a, [
w(o(_.value) + " ", 1),
s("button", {
onClick: t[3] || (t[3] = ($) => c(r).errorAction === "run" ? T() : c(f).reload())
}, o(c(r).paused ? a("恢复查询", "Resume queries") : a("重试", "Retry")), 1)
])) : c(r).summary && c(r).summary.service.state !== "ready" ? (C(), B("p", Wa, [
w(o(c(r).summary.service.state === "disabled" ? a("服务未启用", "Service disabled") : a("服务暂不可用", "Service unavailable")) + " ", 1),
s("a", { href: q }, o(a("打开设置", "Open settings")), 1)
])) : ca("", !0)
], 10, ba);
};
}
};
if (!document.getElementById("fastnet-card-v1-style")) {
const n = document.createElement("style");
n.id = "fastnet-card-v1-style", n.textContent = ga, document.head.appendChild(n);
}
const Ka = 1;
export {
Ka as apiVersion,
Ha as default
};
@@ -0,0 +1,120 @@
var S = (p, d, t) => new Promise((_, o) => {
var u = (i) => {
try {
n(t.next(i));
} catch (a) {
o(a);
}
}, f = (i) => {
try {
n(t.throw(i));
} catch (a) {
o(a);
}
}, n = (i) => i.done ? _(i.value) : Promise.resolve(i.value).then(u, f);
n((t = t.apply(p, d)).next());
});
import { ref as v, computed as B, watch as H, onMounted as L, onUnmounted as O, openBlock as w, createElementBlock as g, createElementVNode as x, createCommentVNode as y, toDisplayString as b } from "/luci-static/quickstart/card-runtime-v1.js";
function R(p, d = location) {
const t = new URL(p);
if (t.protocol !== "http:" || t.hostname !== d.hostname || t.pathname !== "/webui/" || t.username || t.password || t.hash) throw Error("invalid_direct_target");
return { url: t.href, origin: t.origin, mixedContent: d.protocol === "https:" };
}
const j = ".fn-web-dialog{width:100%;min-width:0;min-height:0;display:flex;flex-direction:column;background:var(--qs-card-panel);color:var(--qs-card-text)}.fn-web-frame{position:relative;flex:1;min-height:0}.fn-web-frame iframe{display:block;border:0;width:100%;height:100%;background:var(--qs-card-panel)}.fn-web-loading{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:24px;text-align:center;background:var(--qs-card-panel);color:var(--qs-card-muted)}.fn-web-loading strong{color:var(--qs-card-text);font-size:16px}.fn-web-loading p{font-size:12px;line-height:1.7;max-width:360px;margin:0}.fn-web-loading button{font:inherit;color:var(--qs-card-accent);border:1px solid var(--qs-card-line);background:var(--qs-card-soft);padding:10px 20px;border-radius:8px;cursor:pointer}.fn-web-loading a{color:var(--qs-card-accent);font-size:14px}.fn-web-footnote{font-size:10px;color:var(--qs-card-muted);line-height:1.6;margin:0!important;padding:9px 20px;border-top:1px solid var(--qs-card-line)}@media(max-width:600px){.fn-web-footnote{padding:8px 14px}}", U = {
class: "fn-web-dialog",
"data-fastnet-web-dialog": ""
}, W = { class: "fn-web-frame" }, A = ["src", "title"], D = ["role"], M = ["href"], V = { class: "fn-web-footnote" }, J = {
__name: "WebDialog",
props: ["context", "locale", "view"],
emits: ["close"],
setup(p, { emit: d }) {
if (!document.getElementById("fastnet-web-v1-style")) {
const e = document.createElement("style");
e.id = "fastnet-web-v1-style", e.textContent = j, document.head.appendChild(e);
}
const t = p, _ = d, o = (e, r) => t.locale === "en" ? r : e, u = v(null), f = v(!1), n = v(!1), i = v(0), a = v(null), P = B(() => {
if (!a.value) return "";
const e = new URL(a.value.url);
return e.searchParams.delete("embed"), e.searchParams.delete("theme"), e.href;
});
let c, s, k = !1;
function z() {
clearTimeout(c), c = setTimeout(() => {
f.value || (n.value = !0);
}, 15e3);
}
function T() {
return S(this, null, function* () {
s == null || s.abort(), s = new AbortController();
const e = s;
clearTimeout(c), a.value = null, f.value = !1, n.value = !1, i.value++, z();
try {
const r = yield fetch("/cgi-bin/luci/admin/services/fastnet/embed?format=json&theme=" + t.context.theme.effective + (t.view === "history" ? "&view=history" : ""), { credentials: "same-origin", cache: "no-store", redirect: "error", signal: e.signal });
if (!r.ok) throw Error("launch_unavailable");
const l = R((yield r.json()).url);
if (k || e.signal.aborted) return;
a.value = l, l.mixedContent && clearTimeout(c);
} catch (r) {
!k && !e.signal.aborted && (clearTimeout(c), n.value = !0);
}
});
}
function q() {
var e, r;
a.value && ((r = (e = u.value) == null ? void 0 : e.contentWindow) == null || r.postMessage({ type: "quickstart:theme", theme: t.context.theme.effective }, a.value.origin));
}
function C(e) {
var r, l, h, N, F, E;
if (!(!a.value || e.origin !== a.value.origin || e.source !== ((r = u.value) == null ? void 0 : r.contentWindow))) {
if (((l = e.data) == null ? void 0 : l.type) === "fastnet:ready")
clearTimeout(c), f.value = !0, n.value = !1, q();
else if (((h = e.data) == null ? void 0 : h.type) === "fastnet:close") _("close");
else if (((N = e.data) == null ? void 0 : N.type) === "fastnet:focus") {
const m = (F = u.value) == null ? void 0 : F.closest("section.network-dialog");
(E = (m == null ? void 0 : m.querySelector(e.data.backward ? "[data-network-dialog-close]" : "[data-network-dialog-open]")) || (m == null ? void 0 : m.querySelector("[data-network-dialog-close]"))) == null || E.focus();
}
}
}
return H(() => t.context.theme.effective, q), L(() => {
window.addEventListener("message", C), T();
}), O(() => {
k = !0, s == null || s.abort(), clearTimeout(c), window.removeEventListener("message", C);
}), (e, r) => {
var l, h;
return w(), g("div", U, [
x("div", W, [
a.value && !n.value && !a.value.mixedContent ? (w(), g("iframe", {
key: i.value,
ref_key: "frame",
ref: u,
src: a.value.url,
title: o("FastNet 完整测速界面", "FastNet speed test interface"),
referrerpolicy: "origin"
}, null, 8, A)) : y("", !0),
f.value ? y("", !0) : (w(), g("div", {
key: 1,
class: "fn-web-loading",
role: n.value ? "alert" : "status"
}, [
x("strong", null, b((l = a.value) != null && l.mixedContent ? o("在新窗口中直接测速", "Test directly in a new window") : n.value ? o("FastNet 页面暂时无法加载", "FastNet could not be loaded") : o("正在打开 FastNet…", "Opening FastNet…")), 1),
x("p", null, b((h = a.value) != null && h.mixedContent ? o("当前 HTTPS 页面无法嵌入 HTTP 测速服务,请在新窗口打开 FastNet。", "This HTTPS page cannot embed the HTTP service. Open FastNet in a new window.") : n.value ? o("可以重试,或在新窗口中打开。请确认插件服务已启用。", "Retry or open in a new window. Check that the plugin service is enabled.") : o("打开页面不会自动测速;请在子窗口中点击开始。", "Opening the page does not start a test. Select Start inside the window.")), 1),
a.value && (n.value || a.value.mixedContent) ? (w(), g("a", {
key: 0,
href: P.value,
target: "_blank",
rel: "noopener noreferrer"
}, b(o("打开 FastNet ↗", "Open FastNet ↗")), 9, M)) : y("", !0),
n.value ? (w(), g("button", {
key: 1,
onClick: T
}, b(o("重新加载", "Reload")), 1)) : y("", !0)
], 8, D))
]),
x("p", V, b(o("核心已接受的测速继续运行;关闭窗口将中断浏览器测量,已保存结果保留。", "Accepted core tests continue. Browser tests stop when closed; saved results remain.")), 1)
]);
};
}
};
export {
J as default
};
@@ -0,0 +1,41 @@
import { openBlock as d, createElementBlock as i, createElementVNode as l, toDisplayString as s, Fragment as N, renderList as g, createTextVNode as h, unref as y, createCommentVNode as P } from "/luci-static/quickstart/card-runtime-v1.js";
const k = { class: "qs-card-details" }, D = { key: 0 }, T = {
href: "/cgi-bin/luci/admin/services/fastnet/open",
target: "_blank",
rel: "noopener"
}, x = {
__name: "Details",
props: ["state", "locale", "stamp", "mbps"],
setup(c) {
const o = c, b = o.stamp, w = o.mbps, _ = ["download", "upload", "nat", "ipv6", "lanPing", "lanDownload", "lanUpload"], e = (a, t) => o.locale === "en" ? t : a, f = { download: e("下载", "Download"), upload: e("上传", "Upload"), nat: "NAT", ipv6: "IPv6", lanPing: e("内网延迟", "LAN latency"), lanDownload: e("内网下载", "LAN download"), lanUpload: e("内网上传", "LAN upload") };
function p(a) {
var t, n, r, u;
return ((n = (t = o.state.summary) == null ? void 0 : t.latestResults) == null ? void 0 : n[a]) || ((u = (r = o.state.summary) == null ? void 0 : r.latest) == null ? void 0 : u[a]);
}
function A(a) {
const t = p(a);
return !t || t.status === "not_tested" ? e("未检测", "Not tested") : t.status === "error" ? e("检测失败", "Probe failed") : t.status === "unavailable" ? e("不可用", "Unavailable") : a === "lanPing" ? t.value.toFixed(1) + " ms" : /download|upload/i.test(a) ? w(t.value) + " Mbps" : t.value;
}
return (a, t) => (d(), i("div", k, [
l("p", null, s(e("默认外网出口 · 路由器 → 互联网", "Default WAN route · Router → Internet")), 1),
l("dl", null, [
(d(), i(N, null, g(_, (n) => {
var r, u, m, v;
return d(), i(N, { key: n }, [
l("dt", null, s(f[n]), 1),
l("dd", null, [
h(s(A(n)), 1),
l("small", null, s(y(b)((r = p(n)) == null ? void 0 : r.measuredAt, c.locale)), 1),
(v = (m = (u = c.state.summary) == null ? void 0 : u.lastTask) == null ? void 0 : m.issues) != null && v[n] ? (d(), i("span", D, s(e("本次探测失败,保留上次有效结果", "Latest probe failed; previous valid result retained")), 1)) : P("", !0)
])
], 64);
}), 64))
]),
l("p", null, s(e("测速会使用外网上传和下载带宽。NAT 与 IPv6 分别反映各自检测时间的结果。", "Testing uses WAN upload and download bandwidth. NAT and IPv6 describe their own detection times.")), 1),
l("a", T, s(e("打开完整 FastNet", "Open full FastNet")), 1)
]));
}
};
export {
x as default
};
@@ -0,0 +1,30 @@
{
"schemaVersion": 1,
"abi": "quickstart-card-v1",
"vue": "3.5.44",
"compiler": "3.5.44",
"version": "0.8.9-1",
"sourceCommit": "082bf1e42911b8fdfed7ac421518ee990952b304",
"sourceFiles": {
"quickstart-card/manifest.json": "0f8b3a0dc15803c27243288e97ebea098ba2894c26b836293ed656f9396104d1",
"quickstart-card/package-lock.json": "daaa5d8d53587115f2fb7127adb51cbfaa9c5d55906e598ab643e8b4b0fc3a6d",
"quickstart-card/package.json": "7683ee1fb095bbf9897941bc4bf4796c488545cc9f712a943870ded1cdf7fccb",
"quickstart-card/src/Card.vue": "792c0528eb22ad3e84db8f023b29b430ea5faf8b1638766176bf24ce05aa90f7",
"quickstart-card/src/Details.vue": "a94eb71ec6ac389f4c1d6e5ea82c546ed1be5e31da410a63563c78e8a233407c",
"quickstart-card/src/WebDialog.vue": "9248328cf1e8d2afd671a44ba6340e7d84ef7f32f101461471f9f1d2920517f4",
"quickstart-card/src/directFrame.js": "648cd8ed2f9f2188aee71475df1ee4f936df73fdc7ce3fa7de03f0ae2c8b2f1a",
"quickstart-card/src/index.js": "48c908c983f14c159bf50c1d2ab3c54824f04bc2ecdacc714691164dea974c75",
"quickstart-card/src/model.js": "0497552a2caadd0f3d1333f359cb6704a9e370e68ce71ed80c2d9090857124e3",
"quickstart-card/src/style.css": "818e49ac458ee245b2c27fd7a7d62bd7e2117e44a85bbb388dddd175b05fce66",
"quickstart-card/src/webStyle.css": "7bc8849ff35cce46a1aec88239905f307eb7ebe655d8d19c9c88d854018526b7",
"quickstart-card/vite.config.js": "7dda71bfe6923df40b4142608d38fa239da9e79e0b30ff3cd6d1747c49f657eb",
"scripts/build-quickstart-card.sh": "345645c1513398221c612f6f1c0da77b3e4c958a86a18ef9382236307a55e3e8",
"scripts/card-provenance.py": "2bbed5b4ad40fe94310bc6b9ba6f4d20fc7ed8ec675800b4ebcb5ed68d01a026"
},
"sourceSha256": "ca7ed2ac012210d4be444c080306e27e77c02eab0a998dda7b5f911f712f4385",
"assets": {
"card.dfb0ce55.js": "ed9555d2ead731e603cce340c950c681d8dcb64f301c52ed5380bf8e7cd62664",
"chunk.1932f6b1.js": "af27ba0c26dff540b8fd4cf07eaa50cca447539f7cc3d7d29f3fb3c44bf0ffef",
"chunk.31ac0177.js": "c9d9ebd0ea447c7cee8743a912f63c744ff426a50eb1c5dc8c8321b86103d4f1"
}
}
+22 -7
View File
@@ -7,7 +7,7 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-linkeasefull-embed PKG_NAME:=luci-app-linkeasefull-embed
PKG_VERSION:=1.2.0-r1 PKG_VERSION:=1.2.0-r3
PKG_RELEASE:= PKG_RELEASE:=
PKG_BUILD_PARALLEL:=1 PKG_BUILD_PARALLEL:=1
@@ -28,14 +28,29 @@ define Package/$(PKG_NAME)/description
Register OpenWrt LuCI as a LinkEaseFull desktop module and provide embedded-mode assets. Register OpenWrt LuCI as a LinkEaseFull desktop module and provide embedded-mode assets.
endef endef
define Package/$(PKG_NAME)/postinst
#!/bin/sh
# Compatibility entry for desktops which only scan the old directory.
root="$${IPKG_INSTROOT:-}"
legacy="$${root}/usr/share/linkeasefull/desktop-apps.d/15-openwrt-luci.json"
if [ ! -e "$${legacy}" ] && [ ! -L "$${legacy}" ]; then
mkdir -p "$${legacy%/*}"
ln -s /usr/share/linkeasefull/openwrt-luci/openwrt-luci.json "$${legacy}" || exit $$?
fi
exit 0
endef
define Package/$(PKG_NAME)/prerm define Package/$(PKG_NAME)/prerm
#!/bin/sh #!/bin/sh
set -e set -e
root="$${IPKG_INSTROOT:-}" root="$${IPKG_INSTROOT:-}"
registration="$${root}/usr/share/linkeasefull/desktop-apps.d/15-openwrt-luci.json" for directory in /usr/share/linkease/apps.d /usr/share/linkeasefull/desktop-apps.d; do
if [ "$$(readlink "$${registration}" 2>/dev/null)" = "/usr/share/linkeasefull/openwrt-luci/openwrt-luci.json" ]; then registration="$${root}$${directory}/15-openwrt-luci.json"
rm -f "$${registration}" if [ "$$(readlink "$${registration}" 2>/dev/null)" = "/usr/share/linkeasefull/openwrt-luci/openwrt-luci.json" ]; then
fi rm -f "$${registration}"
fi
done
rmdir "$${root}/usr/share/linkeasefull/desktop-apps.d" 2>/dev/null || true
exit 0 exit 0
endef endef
@@ -46,13 +61,13 @@ define Build/Compile
endef endef
define Package/$(PKG_NAME)/install define Package/$(PKG_NAME)/install
$(INSTALL_DIR) $(1)/usr/share/linkeasefull/openwrt-luci $(1)/usr/share/linkeasefull/desktop-apps.d $(INSTALL_DIR) $(1)/usr/share/linkeasefull/openwrt-luci $(1)/usr/share/linkease/apps.d
$(INSTALL_DIR) $(1)/www/luci-static/linkeasefull-embed $(INSTALL_DIR) $(1)/www/luci-static/linkeasefull-embed
$(INSTALL_DATA) ./files/openwrt-luci/openwrt-luci.json $(1)/usr/share/linkeasefull/openwrt-luci/openwrt-luci.json $(INSTALL_DATA) ./files/openwrt-luci/openwrt-luci.json $(1)/usr/share/linkeasefull/openwrt-luci/openwrt-luci.json
$(INSTALL_DATA) ./files/openwrt-luci/openwrt.svg $(1)/usr/share/linkeasefull/openwrt-luci/openwrt.svg $(INSTALL_DATA) ./files/openwrt-luci/openwrt.svg $(1)/usr/share/linkeasefull/openwrt-luci/openwrt.svg
$(INSTALL_DATA) ./files/www/embed.css $(1)/www/luci-static/linkeasefull-embed/embed.css $(INSTALL_DATA) ./files/www/embed.css $(1)/www/luci-static/linkeasefull-embed/embed.css
$(INSTALL_DATA) ./files/www/embed-prelude.js $(1)/www/luci-static/linkeasefull-embed/embed-prelude.js $(INSTALL_DATA) ./files/www/embed-prelude.js $(1)/www/luci-static/linkeasefull-embed/embed-prelude.js
ln -sf /usr/share/linkeasefull/openwrt-luci/openwrt-luci.json $(1)/usr/share/linkeasefull/desktop-apps.d/15-openwrt-luci.json ln -sf /usr/share/linkeasefull/openwrt-luci/openwrt-luci.json $(1)/usr/share/linkease/apps.d/15-openwrt-luci.json
endef endef
$(eval $(call BuildPackage,$(PKG_NAME))) $(eval $(call BuildPackage,$(PKG_NAME)))
+1 -1
View File
@@ -7,7 +7,7 @@ separate from the independently installable `linkeasefull` runtime package.
This package installs: This package installs:
- `/usr/share/linkeasefull/openwrt-luci/openwrt-luci.json` - `/usr/share/linkeasefull/openwrt-luci/openwrt-luci.json`
- `/usr/share/linkeasefull/desktop-apps.d/15-openwrt-luci.json` - `/usr/share/linkease/apps.d/15-openwrt-luci.json`
- `/www/luci-static/linkeasefull-embed/embed-prelude.js` - `/www/luci-static/linkeasefull-embed/embed-prelude.js`
- `/www/luci-static/linkeasefull-embed/embed.css` - `/www/luci-static/linkeasefull-embed/embed.css`
+1 -1
View File
@@ -5,7 +5,7 @@ include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI shared integration for LinkEase apps LUCI_TITLE:=LuCI shared integration for LinkEase apps
LUCI_PKGARCH:=all LUCI_PKGARCH:=all
PKG_VERSION:=1.2.1-r1 PKG_VERSION:=1.2.1-r3
PKG_RELEASE:= PKG_RELEASE:=
include $(TOPDIR)/feeds/luci/luci.mk include $(TOPDIR)/feeds/luci/luci.mk
@@ -25,7 +25,16 @@ local manifests = {
basePath = "/apps/kspeeder/", basePath = "/apps/kspeeder/",
externalOpen = { enabled = true, path = "/" } externalOpen = { enabled = true, path = "/" }
}, },
desktop = { mode = "module", entry = "desktop-entry.js" }, desktop = {
mode = "iframe",
target = {
scheme = "http", hostMode = "request-host", path = "/",
port = {
default = 5003,
keys = { uci = "istoreenhance.@istoreenhance[0].adminport" }
}
}
},
backend = { backend = {
type = "http", portFromUci = "istoreenhance.@istoreenhance[0].adminport", type = "http", portFromUci = "istoreenhance.@istoreenhance[0].adminport",
defaultPort = 5003 defaultPort = 5003
@@ -155,4 +164,47 @@ equal(builtin.registered, true)
equal(builtin.available, false) equal(builtin.available, false)
equal(builtin.reason, "entry_unsupported_and_external_disabled") equal(builtin.reason, "entry_unsupported_and_external_disabled")
local scanned = {}
local default_deps = dependencies("fastnet.json", false)
default_deps.registration_dirs = nil
default_deps.fs.dir = function(directory)
table.insert(scanned, directory)
return function() return nil end
end
apps_openwrt.new(default_deps):resolve("missing-app")
equal(#scanned, 2, "default discovery must retain the old desktop fallback")
equal(scanned[1], "/usr/share/linkease/apps.d")
equal(scanned[2], "/usr/share/linkeasefull/desktop-apps.d")
local function compatibility_lookup(current, legacy, explicit)
local deps = dependencies("fastnet.json", true)
deps.registration_dirs = explicit
deps.fs.dir = function(directory)
local value = directory == "/usr/share/linkease/apps.d" and current
or directory == "/usr/share/linkeasefull/desktop-apps.d" and legacy
local emitted = false
return function()
if emitted or not value then return nil end
emitted = true
return "17-fastnet.json"
end
end
local readfile = deps.fs.readfile
deps.fs.readfile = function(path)
if path == "/usr/share/linkease/apps.d/17-fastnet.json" then return current end
if path == "/usr/share/linkeasefull/desktop-apps.d/17-fastnet.json" then return legacy end
return readfile(path)
end
deps.json.parse = function(raw)
if raw == "new" then return { id = "fastnet", desktop = { mode = "module" }, standalone = { basePath = "/apps/fastnet/new/" } } end
if raw == "old" then return { id = "fastnet", desktop = { mode = "module" }, standalone = { basePath = "/apps/fastnet/old/" } } end
if raw == "state" then return { active = "gateway" } end
end
return apps_openwrt.new(deps):resolve("fastnet")
end
equal(compatibility_lookup("new", "old").url, "/apps/fastnet/new/", "canonical ID wins")
equal(compatibility_lookup(nil, "old").url, "/apps/fastnet/old/", "legacy-only app stays visible")
equal(compatibility_lookup("invalid", "old").url, "/apps/fastnet/old/", "invalid JSON must not hide legacy")
equal(compatibility_lookup(nil, "old", { "/explicit" }).registered, false, "explicit directories stay isolated")
print("linkease apps OpenWrt adapter tests passed") print("linkease apps OpenWrt adapter tests passed")