diff --git a/src/components/BackendSwitcher.vue b/src/components/BackendSwitcher.vue index 96ce86df..fd703d61 100644 --- a/src/components/BackendSwitcher.vue +++ b/src/components/BackendSwitcher.vue @@ -75,9 +75,14 @@ const isLoading = ref(false); const protocolList = location.protocol === "https:" ? ["wss"] : ["wss", "ws"]; const newUrl = computed(() => { - const host = parseHost(newHost.value); - if (!host) return ""; - return `${newProtocol.value}//${host}${newPathname.value}`; + if (!newHost.value) return ""; + try { + const host = parseHost(newHost.value); + if (!host) return ""; + return `${newProtocol.value}//${host}${newPathname.value}`; + } catch { + return ""; + } }); /** diff --git a/src/components/agents/ShowAgentCommandDialog.vue b/src/components/agents/ShowAgentCommandDialog.vue new file mode 100644 index 00000000..0fd962f2 --- /dev/null +++ b/src/components/agents/ShowAgentCommandDialog.vue @@ -0,0 +1,209 @@ + + + + + + + {{ t("dashboard.agents.addTitle") }} + + 已关闭此agent的历史授权token,并生成新的连接命令和token,已连接的agent(如果存在)会被强制断开连接,直至使用新的连接命令重新连接,适用于重装系统后恢复连接 + + + + + + + + {{ t("dashboard.agents.installTitle") }} + + + {{ t("dashboard.agents.installSubtitle") }} + + + + + + + + + + + 定时器每3秒检查一次agent是否在线,运行后请耐心等待agent上线 + + + + + + + {{ t("dashboard.agents.completed") }} + + + + + + + 关闭 + + + + + diff --git a/src/components/agents/generateToken.ts b/src/components/agents/generateToken.ts index 4e5168c1..d7663a8b 100644 --- a/src/components/agents/generateToken.ts +++ b/src/components/agents/generateToken.ts @@ -42,3 +42,25 @@ export async function preGenerateToken( console.error("Token pre-generation failed:", e); } } + +export async function reGenerateToken( + nodeUuid: string, + backend = currentBackend, +) { + if (!backend.value) return; + try { + try { + await getWsConnection(backend.value.url).call<{ + key?: string; + secret?: string; + }>("token_delete", { + token: backend.value.token, + target_token: `[agent]:${nodeUuid}`, + }); + } catch {} + + return preGenerateToken(nodeUuid, backend); + } catch (e) { + console.error("Token re-generation failed:", e); + } +} diff --git a/src/components/extensions/ExtensionDetail.vue b/src/components/extensions/ExtensionDetail.vue index 7bece3cc..67367272 100644 --- a/src/components/extensions/ExtensionDetail.vue +++ b/src/components/extensions/ExtensionDetail.vue @@ -173,6 +173,8 @@ const saveFile = async () => { props.extension.id, selectedFile.value, encoded.buffer as ArrayBuffer, + undefined, + props.extension.storage, ); fileContent.value = editedContent.value; toast.success("已保存"); @@ -195,6 +197,7 @@ const handleFileUpload = async (e: Event) => { selectedFile.value, content, file.type || undefined, + props.extension.storage, ); fileContent.value = await new Response(content).text(); toast.success("已上传"); @@ -353,7 +356,7 @@ const loadFileContent = async (path: string) => { fileLoading.value = true; try { - const url = getStaticUrl(props.extension.id, path); + const url = getStaticUrl(props.extension.id, path, props.extension.storage); const resp = await fetch(url); if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`); fileContent.value = await resp.text(); diff --git a/src/components/node-manage/NodeManageTabAgents.vue b/src/components/node-manage/NodeManageTabAgents.vue index a031cf9f..e2c3df83 100644 --- a/src/components/node-manage/NodeManageTabAgents.vue +++ b/src/components/node-manage/NodeManageTabAgents.vue @@ -16,6 +16,8 @@ import { CloudDownload, Info, Router, + LifeBuoy, + LifeBuoyIcon, } from "lucide-vue-next"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -50,8 +52,10 @@ import codeCopy from "@/components/node-manage/codeCopy.vue"; import { useBackendStore } from "@/composables/useBackendStore"; import { getWsConnection } from "@/composables/useWsConnection"; import AddAgentDialog from "@/components/agents/AddAgentDialog.vue"; +import ShowAgentCommandDialog from "@/components/agents/ShowAgentCommandDialog.vue"; import { useAgentInfo } from "@/composables/useAgentInfo"; import VersionDialog from "@/components/node-manage/VersionDialog.vue"; +import { PopConfirm } from "@/components/ui/pop-confirm"; import { compareVersions } from "compare-versions"; import { useTask } from "@/composables/useTask"; @@ -72,6 +76,8 @@ const { agents, loading, fetchAgents, fetchAgentVersion } = currentAgentInfo; const searchQuery = ref(""); const selectedUuids = ref>(new Set()); const addAgentOpen = ref(false); +const showAgentCommandOpen = ref(false); +const showCommandAgentUuid = ref(""); const sortable = ref(false); const changeVersionOpen = ref(false); @@ -558,6 +564,25 @@ refresh(); > + + + + + + @@ -392,7 +392,7 @@ fetchVersion(); size="icon" variant="ghost" class="h-8 w-8" - title="manage" + title="分享" @click=" shareBackend = backend; shareOpen = true; diff --git a/src/components/node/setting/NodeSettingTabBasic.vue b/src/components/node/setting/NodeSettingTabBasic.vue index a7c2add2..c0151b44 100644 --- a/src/components/node/setting/NodeSettingTabBasic.vue +++ b/src/components/node/setting/NodeSettingTabBasic.vue @@ -8,6 +8,7 @@ import type { NodeMetadata } from "@/types/agent"; import { useKv } from "@/composables/useKv"; import { useNodeMetadata } from "@/composables/useNodeMetadata"; import { useI18n } from "vue-i18n"; +import { shorterUUID } from "@/utils/format"; const props = defineProps<{ uuid: string }>(); @@ -52,7 +53,7 @@ onMounted(async () => { ]); } - form.value = parseMetadataFields(results, props.uuid.slice(-6)); + form.value = parseMetadataFields(results, shorterUUID(props.uuid)); } catch (e: unknown) { toast.error(e instanceof Error ? e.message : t("dashboard.saveFailed")); } finally { diff --git a/src/components/node/setting/NodeSettingTabDelete.vue b/src/components/node/setting/NodeSettingTabDelete.vue index 2ee94287..eb3d3a6b 100644 --- a/src/components/node/setting/NodeSettingTabDelete.vue +++ b/src/components/node/setting/NodeSettingTabDelete.vue @@ -159,8 +159,8 @@ async function handleDelete() { // will be cleared in js worker. // await afterAgentDelete(props.uuid, "data"); - } catch { - // ignore + } catch (e) { + toast.error(e instanceof Error ? e.message : String(e)); } setStep(2, "done"); diff --git a/src/components/theme-management/ThemeTokenPresetDialog.vue b/src/components/theme-management/ThemeTokenPresetDialog.vue index 6eaad68c..b131f64e 100644 --- a/src/components/theme-management/ThemeTokenPresetDialog.vue +++ b/src/components/theme-management/ThemeTokenPresetDialog.vue @@ -65,7 +65,16 @@ const handleSave = async () => { } saving.value = true; try { - await savePresets(presets.value); + const normalized = presets.value.map((p) => { + if (!p.backend_url) return p; + try { + const parsed = new URL(p.backend_url); + return { ...p, backend_url: `${parsed.protocol}//${parsed.host}` }; + } catch { + return p; + } + }); + await savePresets(normalized); toast.success("Token 预设已保存"); emit("update:open", false); } catch (e: unknown) { diff --git a/src/composables/useAgentInfo.ts b/src/composables/useAgentInfo.ts index c63aa281..78bb8eed 100644 --- a/src/composables/useAgentInfo.ts +++ b/src/composables/useAgentInfo.ts @@ -26,6 +26,7 @@ import { useInFlightDedupe } from "@/composables/useInFlightDedupe"; import { metaKey2Attr, type NodeMetadata } from "@/types/agent"; import { groupBy } from "@/utils/groupBy"; +import { shorterUUID } from "@/utils/format"; // ============ Types ============ @@ -119,7 +120,7 @@ export function useAgentInfo( Object.keys(grouped).forEach((uuid) => { const arr = grouped[uuid]?.map(({ key, value }) => ({ key, value })) ?? []; - const metadata = parseMetadataFields(arr, "节点" + uuid.slice(-6)); + const metadata = parseMetadataFields(arr, "节点" + shorterUUID(uuid)); metaMap.set(uuid, metadata); }); } diff --git a/src/composables/useBackendStore.ts b/src/composables/useBackendStore.ts index b6436b31..45c485ab 100644 --- a/src/composables/useBackendStore.ts +++ b/src/composables/useBackendStore.ts @@ -12,6 +12,15 @@ const LS_KEY_CURRENT = "nodeget_current_backend"; const backends = ref([]); const currentBackend = ref(null); +export const normalizeUrl = (url: string): string => { + try { + const parsed = new URL(url); + return `${parsed.protocol}//${parsed.host}`; + } catch { + return url; + } +}; + const init = () => { // Load backends from localStorage const storedBackends = localStorage.getItem(LS_KEY_BACKENDS); @@ -19,7 +28,10 @@ const init = () => { try { const parsed = JSON.parse(storedBackends); if (Array.isArray(parsed)) { - backends.value = parsed as Backend[]; + backends.value = (parsed as Backend[]).map((b) => ({ + ...b, + url: normalizeUrl(b.url), + })); } } catch (e) { console.error("Failed to parse backends from localStorage", e); @@ -32,7 +44,10 @@ const init = () => { try { const parsed = JSON.parse(storedCurrent); if (parsed && typeof parsed === "object") { - currentBackend.value = parsed as Backend; + currentBackend.value = { + ...(parsed as Backend), + url: normalizeUrl((parsed as Backend).url), + }; } } catch (e) { console.error("Failed to parse current backend from localStorage", e); @@ -103,9 +118,13 @@ watch( ); const addBackend = (backend: Backend) => { - backends.value.push(backend); + const normalized: Backend = { + ...backend, + url: normalizeUrl(backend.url), + }; + backends.value.push(normalized); if (!currentBackend.value) { - currentBackend.value = backend; + currentBackend.value = normalized; } }; diff --git a/src/composables/useExtensions.ts b/src/composables/useExtensions.ts index 5f2a8f76..e72d5410 100644 --- a/src/composables/useExtensions.ts +++ b/src/composables/useExtensions.ts @@ -4,6 +4,8 @@ import { useThemeStore } from "@/stores/theme"; import { getWsConnection } from "@/composables/useWsConnection"; import { useJsRuntime } from "@/composables/useJsRuntime"; import { useCron } from "@/composables/useCron"; +import { useStaticBucket } from "@/composables/useStaticBucket"; +import { bufToBase64 } from "@/utils/base64"; import { unzip } from "fflate"; // 将 zip 文件解压,模拟成 webkitRelativePath 格式的 File 数组 @@ -43,6 +45,19 @@ export const extractZipToFiles = (zipFile: File): Promise => export const EXTENSION_NAMESPACE = "extension-information"; +const STATIC_STORAGE_MIN_VERSION = "0.2.11"; + +const semverGt = (a: string | undefined, b: string): boolean => { + if (!a) return false; + if (!/^\d+\.\d+\.\d+$/.test(a)) return false; + const parse = (v: string) => v.split(".").map((n) => parseInt(n, 10)); + const [aMa, aMi, aPa] = parse(a); + const [bMa, bMi, bPa] = parse(b); + if (aMa !== bMa) return (aMa ?? 0) > (bMa ?? 0); + if (aMi !== bMi) return (aMi ?? 0) > (bMi ?? 0); + return (aPa ?? 0) > (bPa ?? 0); +}; + export type AppJsonRoute = { type: "node" | "global"; name: string; @@ -74,6 +89,8 @@ export type ExtensionFile = { size: number; }; +export type ExtensionStorage = "static" | "worker"; + export type ExtensionKvData = { app: AppJson; disabled: boolean; @@ -83,6 +100,7 @@ export type ExtensionKvData = { readme: string; worker_name: string | null; files?: ExtensionFile[]; + storage?: ExtensionStorage; }; export type Extension = ExtensionKvData & { id: string }; @@ -98,30 +116,11 @@ export function useExtensions() { const jsRuntime = useJsRuntime(); const cronApi = useCron(); + const staticBucketApi = useStaticBucket(currentBackend); const rpc = (method: string, params: unknown): Promise => getWsConnection(backendUrl.value).call(method, params); - // 按扩展名推断 MIME type(file.type 为空时的 fallback) - const guessMimeType = (filename: string): string | undefined => { - const ext = filename.split(".").pop()?.toLowerCase(); - const map: Record = { - svg: "image/svg+xml", - png: "image/png", - jpg: "image/jpeg", - jpeg: "image/jpeg", - gif: "image/gif", - webp: "image/webp", - ico: "image/x-icon", - js: "application/javascript", - css: "text/css", - html: "text/html", - json: "application/json", - wasm: "application/wasm", - }; - return ext ? map[ext] : undefined; - }; - // backend.url 是 WebSocket 地址(wss/ws),静态文件接口需要 HTTP(S) const httpBaseUrl = computed(() => backendUrl.value @@ -129,6 +128,53 @@ export function useExtensions() { .replace(/^ws:\/\//, "http://"), ); + const bucketBaseUrl = computed(() => { + try { + const url = new URL(httpBaseUrl.value); + return `${url.protocol}//${url.host}`; + } catch { + return httpBaseUrl.value; + } + }); + + const getBackendCargoVersion = async (): Promise => { + try { + const info = await rpc<{ cargo_version: string }>( + "nodeget-server_version", + [], + ); + return info?.cargo_version; + } catch { + return undefined; + } + }; + + const getBucketName = (extensionId: string) => `ext-${extensionId}`; + + const ensureBucket = async (extensionId: string): Promise => { + const name = getBucketName(extensionId); + try { + const existing = await staticBucketApi.readBucket(name); + if (existing !== null) return; + } catch { + // not found — fall through to create + } + try { + await staticBucketApi.createBucket({ + name, + path: name, + is_http_root: false, + cors: true, + }); + } catch (e) { + try { + await staticBucketApi.readBucket(name); + } catch { + throw e; + } + } + }; + const ensureNamespace = async () => { const namespaces = await rpc("kv_list_all_namespace", { token: backendToken.value, @@ -209,6 +255,16 @@ export function useExtensions() { /* ignore */ } } + if (ext?.storage === "static") { + try { + await staticBucketApi.deleteBucket(getBucketName(id)); + } catch (e) { + console.warn( + `[deleteExtension] 清理 bucket 失败,请手动删除 ${getBucketName(id)}:`, + e, + ); + } + } await rpc("kv_delete_key", { token: backendToken.value, namespace: EXTENSION_NAMESPACE, @@ -222,21 +278,30 @@ export function useExtensions() { path: string, content: ArrayBuffer, contentType?: string, + storage?: ExtensionStorage, ) => { - const url = `${httpBaseUrl.value}/worker-route/static-worker-route/${extensionId}/${path}`; - const headers: Record = { - Authorization: `Bearer ${backendToken.value}`, - }; - if (contentType) headers["Content-Type"] = contentType; - const resp = await fetch(url, { - method: "POST", - body: content, - headers, - }); - if (!resp.ok) { - throw new Error( - `上传文件 ${path} 失败: ${resp.status} ${resp.statusText}`, - ); + const resolvedStorage = + storage ?? extensions.value.find((e) => e.id === extensionId)?.storage; + + if (resolvedStorage === "static") { + await rpc("static-bucket-file_upload", { + token: backendToken.value, + name: getBucketName(extensionId), + path, + base64: bufToBase64(content), + }); + } else { + const url = `${httpBaseUrl.value}/worker-route/static-worker-route/${extensionId}/${path}`; + const headers: Record = { + Authorization: `Bearer ${backendToken.value}`, + }; + if (contentType) headers["Content-Type"] = contentType; + const resp = await fetch(url, { method: "POST", body: content, headers }); + if (!resp.ok) { + throw new Error( + `上传文件 ${path} 失败: ${resp.status} ${resp.statusText}`, + ); + } } }; @@ -309,10 +374,23 @@ export function useExtensions() { // 生成扩展 UUID const extensionId = crypto.randomUUID(); + const backendVersion = await getBackendCargoVersion(); + const storage: ExtensionStorage = semverGt( + backendVersion, + STATIC_STORAGE_MIN_VERSION, + ) + ? "static" + : "worker"; + onProgress?.("正在创建 Token..."); await ensureNamespace(); const token = await createExtensionToken(appJson.limits ?? []); + if (storage === "static") { + onProgress?.("正在准备 static-bucket..."); + await ensureBucket(extensionId); + } + onProgress?.("正在上传静态文件..."); // 上传 resources/ 目录下的文件 @@ -331,7 +409,8 @@ export function useExtensions() { extensionId, relativePath, content, - file.type || guessMimeType(file.name), + file.type || undefined, + storage, ); uploadedFiles.push({ path: relativePath, size: file.size }); onProgress?.(`已上传: ${relativePath}`); @@ -390,6 +469,7 @@ export function useExtensions() { readme, worker_name, files: uploadedFiles, + storage, }; await saveExtension(extensionId, kvData); @@ -449,6 +529,18 @@ export function useExtensions() { onProgress?.("Token 权限未变化,复用原 Token"); } + const backendVersion = await getBackendCargoVersion(); + const storage: ExtensionStorage = + existing.storage === "static" || + semverGt(backendVersion, STATIC_STORAGE_MIN_VERSION) + ? "static" + : "worker"; + + if (storage === "static") { + onProgress?.("正在准备 static-bucket..."); + await ensureBucket(existing.id); + } + onProgress?.("正在上传静态文件..."); const rootFolder = files[0]?.webkitRelativePath.split("/")[0] ?? ""; const resourcePrefix = `${rootFolder}/resources/`; @@ -466,6 +558,7 @@ export function useExtensions() { relativePath, content, file.type || undefined, + storage, ); uploadedFiles.push({ path: relativePath, size: file.size }); onProgress?.(`已上传: ${relativePath}`); @@ -566,12 +659,20 @@ export function useExtensions() { readme, worker_name, files: uploadedFiles, + storage, }; await saveExtension(existing.id, kvData); }; - const getStaticUrl = (extensionId: string, path: string): string => { + const getStaticUrl = ( + extensionId: string, + path: string, + storage?: ExtensionStorage, + ): string => { + if (storage === "static") { + return `${bucketBaseUrl.value}/nodeget/static/${getBucketName(extensionId)}/${path}`; + } return `${httpBaseUrl.value}/worker-route/static-worker-route/${extensionId}/${path}`; }; @@ -581,6 +682,7 @@ export function useExtensions() { token: string, nodeUuid?: string, workerName?: string | null, + storage?: ExtensionStorage, ): Promise => { let base: string; if (entry.startsWith("@")) { @@ -591,7 +693,7 @@ export function useExtensions() { : extensionId; base = `${httpBaseUrl.value}/worker-route/${route}/${workerEntry}`; } else { - base = getStaticUrl(extensionId, entry); + base = getStaticUrl(extensionId, entry, storage); } const theme = useThemeStore().isDark ? "dark" : "light"; const params: string[] = [ @@ -619,7 +721,13 @@ export function useExtensions() { await ensureNamespace(); onProgress?.("正在上传图标..."); - await uploadFile(extensionId, "assets/icon.svg", svgBytes, "image/svg+xml"); + await uploadFile( + extensionId, + "assets/icon.svg", + svgBytes, + "image/svg+xml", + "worker", + ); const appJson: AppJson = { name, @@ -643,6 +751,7 @@ export function useExtensions() { readme: "", worker_name: workerName, files: [{ path: "assets/icon.svg", size: svgBytes.byteLength }], + storage: undefined, }; onProgress?.("正在保存扩展信息..."); diff --git a/src/layout/components/Sidebar.vue b/src/layout/components/Sidebar.vue index 4ec25b75..06489fb7 100644 --- a/src/layout/components/Sidebar.vue +++ b/src/layout/components/Sidebar.vue @@ -205,7 +205,7 @@ const extensionGlobalRoutes = computed(() => meta: { title: r.name, icon: ext.app.icon - ? getStaticUrl(ext.id, ext.app.icon) + ? getStaticUrl(ext.id, ext.app.icon, ext.storage) : LayoutGrid, order: 100, }, @@ -226,7 +226,7 @@ const extensionNodeRoutes = computed(() => { meta: { title: r.name, icon: ext.app.icon - ? getStaticUrl(ext.id, ext.app.icon) + ? getStaticUrl(ext.id, ext.app.icon, ext.storage) : LayoutGrid, }, })), diff --git a/src/pages/dashboard/app/[extensionRoute].vue b/src/pages/dashboard/app/[extensionRoute].vue index 16cf15b1..6b672c12 100644 --- a/src/pages/dashboard/app/[extensionRoute].vue +++ b/src/pages/dashboard/app/[extensionRoute].vue @@ -46,6 +46,7 @@ watch( m.ext.token, undefined, m.ext.worker_name, + m.ext.storage, ); }, { immediate: true }, diff --git a/src/pages/dashboard/node/[uuid]/[extensionRoute].vue b/src/pages/dashboard/node/[uuid]/[extensionRoute].vue index 98ed45a3..b336141f 100644 --- a/src/pages/dashboard/node/[uuid]/[extensionRoute].vue +++ b/src/pages/dashboard/node/[uuid]/[extensionRoute].vue @@ -45,6 +45,7 @@ watch( m.ext.token, nodeUuid.value, m.ext.worker_name, + m.ext.storage, ); }, { immediate: true }, diff --git a/src/pages/dashboard/servers-detail/[backendId].vue b/src/pages/dashboard/servers-detail/[backendId].vue index 0f2fd677..94304a7f 100644 --- a/src/pages/dashboard/servers-detail/[backendId].vue +++ b/src/pages/dashboard/servers-detail/[backendId].vue @@ -16,7 +16,7 @@ import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; // import { RadioGroup, RadioGroupItem, } from '@/components/ui/radio-group' -import { useBackendStore } from "@/composables/useBackendStore"; +import { useBackendStore, normalizeUrl } from "@/composables/useBackendStore"; import { useBackendExtra } from "@/composables/useBackendExtra"; import { getWsConnection } from "@/composables/useWsConnection"; import { useThemeStore } from "@/stores/theme"; @@ -233,7 +233,7 @@ function saveEdit(field: string) { if (field === "name") { backends.value[idx]!.name = editValue.value; } else if (field === "url") { - backends.value[idx]!.url = editValue.value; + backends.value[idx]!.url = normalizeUrl(editValue.value); } else if (field === "token") { backends.value[idx]!.token = editValue.value; } diff --git a/src/pages/dashboard/servers.vue b/src/pages/dashboard/servers.vue index 40391aac..84919900 100644 --- a/src/pages/dashboard/servers.vue +++ b/src/pages/dashboard/servers.vue @@ -93,7 +93,7 @@ onMounted(() => { if (!exists) { addBackend(decoded); if (backends.value.length === 1) { - selectBackend(decoded); + selectBackend(backends.value[0]!); } } } catch { diff --git a/src/utils/format.ts b/src/utils/format.ts index b909344e..cf3bd2ca 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -34,3 +34,6 @@ export const formatUptime = (uptime: number) => { export const formatTimestamp = (ts: number) => { return new Date(ts).toLocaleTimeString(); }; + +export const shorterUUID = (uuid: string, length: number = 8) => + uuid.replace(/-/g, "").slice(0, length);
+ {{ t("dashboard.agents.installSubtitle") }} +
+ 定时器每3秒检查一次agent是否在线,运行后请耐心等待agent上线 +