Skip to content
Merged
11 changes: 8 additions & 3 deletions src/components/BackendSwitcher.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 "";
}
});

/**
Expand Down
209 changes: 209 additions & 0 deletions src/components/agents/ShowAgentCommandDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import {
Loader2,
CircleCheckBig,
RefreshCw,
Copy,
Check,
} from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Codemirror } from "vue-codemirror";
import { shell } from "@codemirror/legacy-modes/mode/shell";
import { StreamLanguage } from "@codemirror/language";
import { oneDark } from "@codemirror/theme-one-dark";
import { useThemeStore } from "@/stores/theme";
import { getWsConnection } from "@/composables/useWsConnection";
import {
useTask,
type CreateTaskBlockingResponse,
} from "@/composables/useTask";
import { useBackendExtra } from "@/composables/useBackendExtra";
import { reGenerateToken } from "@/components/agents/generateToken";
import { toast } from "vue-sonner";

const props = withDefaults(
defineProps<{
uuid: string;
}>(),
{
uuid: "",
},
);

const open = defineModel<boolean>("open", { required: true });
const emit = defineEmits<{
added: [];
}>();

const { t } = useI18n();
const themeStore = useThemeStore();
const { currentBackendInfo } = useBackendExtra();
const { createVersionTask } = useTask();

// 预生成的 token
const generatedToken = ref("");

reGenerateToken(props.uuid)
.then((newToken) => {
if (!newToken) {
toast.error("预生成 token 失败,可能会导致安装失败,请重试");
return;
}
generatedToken.value = newToken;
})
.catch((e) => {
console.error("Failed to pre-generate token:", e);
toast.error("预生成 token 失败,可能会导致安装失败,请重试");
});

const isOnline = ref(false);
const isCopied = ref(false);
let pollTimer: ReturnType<typeof setInterval> | null = null;

const checkOnline = async () => {
if (!currentBackendInfo.value) return;
try {
const conn = getWsConnection(currentBackendInfo.value.url);
const result = await conn.call<{ uuids: string[] }>(
"nodeget-server_list_all_agent_uuid",
{ token: currentBackendInfo.value.token },
);
const version = await createVersionTask(props.uuid, true, 1500);
isOnline.value = (version as CreateTaskBlockingResponse).success;
} catch {
// ignore
}
};

const startPolling = () => {
stopPolling();
isOnline.value = false;
pollTimer = setInterval(checkOnline, 3000);
checkOnline();
};

const stopPolling = () => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
};

const copyInstallScript = async () => {
try {
await navigator.clipboard.writeText(installScript.value);
isCopied.value = true;
setTimeout(() => {
isCopied.value = false;
}, 2000);
} catch (e) {
console.error("Failed to copy:", e);
}
};

// 安装脚本内容 (4 参数)
const installScript = computed(() => {
const uuid = props.uuid || "{AGENT_UUID}";
const token = generatedToken.value || "{TOKEN}";
const serverWs =
currentBackendInfo.value?.agentConfigWsUrl ||
currentBackendInfo.value?.url ||
"{Server_WS}";
const serverName = currentBackendInfo.value?.name || "{Server_NAME}";
return `bash <(curl -sL ${import.meta.env.VITE_INSTALL_URL}) install-agent \\
--agent-id "${uuid}" \\
--token "${token}" \\
--server-ws "${serverWs}" \\
--server-id "${currentBackendInfo.value?.uuid}" \\
--server-name "${serverName}"`;
});

const editorExtensions = computed(() => [
StreamLanguage.define(shell),
...(themeStore.isDark ? [oneDark] : []),
]);

onMounted(startPolling);
onUnmounted(stopPolling);
</script>

<template>
<Dialog v-model:open="open">
<DialogContent
class="sm:max-w-xl max-h-[80vh] grid-rows-[auto_1fr_auto] p-0"
>
<DialogHeader class="px-6 pt-6 pb-2">
<DialogTitle>{{ t("dashboard.agents.addTitle") }}</DialogTitle>
<DialogDescription>
已关闭此agent的历史授权token,并生成新的连接命令和token,已连接的agent(如果存在)会被强制断开连接,直至使用新的连接命令重新连接,适用于重装系统后恢复连接
</DialogDescription>
</DialogHeader>

<div class="overflow-y-auto px-6 min-h-0">
<div class="space-y-4 py-2">
<div>
<h3 class="text-base font-medium">
{{ t("dashboard.agents.installTitle") }}
</h3>
<p class="text-sm text-muted-foreground">
{{ t("dashboard.agents.installSubtitle") }}
</p>
</div>
<div class="rounded-md border overflow-hidden relative">
<button
type="button"
@click="copyInstallScript"
class="absolute top-2 right-2 z-10 p-1.5 rounded-md bg-background/80 hover:bg-background border border-border/50 hover:border-border transition-colors"
:title="isCopied ? 'Copied!' : 'Copy to clipboard'"
>
<Check v-if="isCopied" class="h-4 w-4 text-green-500" />
<Copy v-else class="h-4 w-4 text-muted-foreground" />
</button>
<Codemirror
:model-value="installScript"
:extensions="editorExtensions"
:disabled="true"
:style="{ minHeight: '120px' }"
/>
</div>
<p class="text-xs text-muted-foreground">
定时器每3秒检查一次agent是否在线,运行后请耐心等待agent上线
</p>
</div>

<div
class="flex flex-col items-center justify-center py-8 gap-4"
v-if="isOnline"
>
<CircleCheckBig class="h-16 w-16 text-green-500" />
<h3 class="text-xl font-semibold">
{{ t("dashboard.agents.completed") }}
</h3>
</div>
</div>

<DialogFooter class="px-6 pb-6 pt-2">
<Button
variant="outline"
@click="
$emit('added');
$emit('update:open', false);
"
>
关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
22 changes: 22 additions & 0 deletions src/components/agents/generateToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
5 changes: 4 additions & 1 deletion src/components/extensions/ExtensionDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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("已保存");
Expand All @@ -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("已上传");
Expand Down Expand Up @@ -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();
Expand Down
31 changes: 31 additions & 0 deletions src/components/node-manage/NodeManageTabAgents.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand All @@ -72,6 +76,8 @@ const { agents, loading, fetchAgents, fetchAgentVersion } = currentAgentInfo;
const searchQuery = ref("");
const selectedUuids = ref<Set<string>>(new Set());
const addAgentOpen = ref(false);
const showAgentCommandOpen = ref(false);
const showCommandAgentUuid = ref("");
const sortable = ref(false);

const changeVersionOpen = ref(false);
Expand Down Expand Up @@ -558,6 +564,25 @@ refresh();
>
<CloudDownload class="h-4 w-4" />
</Button>
<PopConfirm
title="重新连接agent?(危险操作)"
description="会关闭已授权此agent的token,并生成新的连接命令和token,已连接的agent(如果存在)会被强制断开连接,直至使用新的连接命令重新连接,适用于重装系统后恢复连接"
confirm-text="确定"
:cancel-text="t('dashboard.servers.deleteCancel')"
@confirm="
showCommandAgentUuid = agent.uuid;
showAgentCommandOpen = true;
"
>
<Button
size="icon"
variant="ghost"
class="h-8 w-8"
title="重新显示连接命令"
>
<LifeBuoyIcon class="h-4 w-4" />
</Button>
</PopConfirm>
<Button
size="icon"
variant="ghost"
Expand All @@ -577,6 +602,12 @@ refresh();
v-model:open="addAgentOpen"
@added="refresh()"
/>
<ShowAgentCommandDialog
v-if="showAgentCommandOpen"
v-model:open="showAgentCommandOpen"
:uuid="showCommandAgentUuid"
@added="refresh()"
/>
<VersionDialog
v-if="changeVersionOpen"
:availableVersions="availableVersions"
Expand Down
4 changes: 2 additions & 2 deletions src/components/node-manage/NodeManageTabServers.vue
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ fetchVersion();
size="icon"
variant="ghost"
class="h-8 w-8"
title="Upgrade"
title="升级"
@click="openChooseVersion(backend.url)"
>
<CloudDownload class="h-4 w-4" />
Expand All @@ -392,7 +392,7 @@ fetchVersion();
size="icon"
variant="ghost"
class="h-8 w-8"
title="manage"
title="分享"
@click="
shareBackend = backend;
shareOpen = true;
Expand Down
3 changes: 2 additions & 1 deletion src/components/node/setting/NodeSettingTabBasic.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>();

Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions src/components/node/setting/NodeSettingTabDelete.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
Loading
Loading