diff --git a/components/servers/ServerPlayerManagement.vue b/components/servers/ServerPlayerManagement.vue index ed12b15fb..a776dd2e9 100644 --- a/components/servers/ServerPlayerManagement.vue +++ b/components/servers/ServerPlayerManagement.vue @@ -13,6 +13,11 @@ import { import { Button } from "~/components/ui/button"; import { Badge } from "~/components/ui/badge"; import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "~/components/ui/popover"; import { Tooltip, TooltipContent, @@ -25,6 +30,7 @@ import SanctionPlayer from "~/components/SanctionPlayer.vue"; import PlayerSanctions from "~/components/PlayerSanctions.vue"; import KickPlayer from "~/components/KickPlayer.vue"; import ClipBoard from "~/components/ClipBoard.vue"; +import TimeAgo from "~/components/TimeAgo.vue"; import getGraphqlClient from "~/graphql/getGraphqlClient"; import { useAuthStore } from "~/stores/AuthStore"; import { useApplicationSettingsStore } from "~/stores/ApplicationSettings"; @@ -42,6 +48,7 @@ const props = defineProps<{ gameServerNodeId?: string | null; apiPassword?: string | null; pluginRuntime?: string | null; + online?: boolean; }>(); const { t } = useI18n(); @@ -80,7 +87,6 @@ const pluginState = ref<{ player_management_seen_at: string | null; } | null>(null); const now = ref(Date.now()); -const installOpen = ref(false); const showConfig = ref(false); const selectedRuntime = ref(null); let pluginSub: { unsubscribe: () => void } | null = null; @@ -112,6 +118,26 @@ const pluginVersionLabel = computed(() => { return version && /^\d/.test(version) ? `v${version}` : version; }); +const runtimeLabel = computed(() => { + const runtime = pluginState.value?.player_management_runtime; + + return runtime ? RUNTIME_LABELS[runtime] : null; +}); + +// An offline server can't check in either way, and the status pill already +// says it is offline, so a missing plugin is only worth flagging once RCON is up. +const showPluginStatus = computed( + () => isCommunityServer.value && (pluginActive.value || props.online), +); + +const pluginStatusLabel = computed(() => + t( + pluginActive.value + ? "pages.dedicated_servers.detail.player_management_plugin.active" + : "pages.dedicated_servers.detail.player_management_plugin.not_detected_title", + ), +); + const canInstall = computed( () => props.gameServerNodeId === null && authStore.isAdmin, ); @@ -343,190 +369,217 @@ onBeforeUnmount(() => { {{ players.length }} - - - +
+ + - - {{ $t("common.refresh") }} - - -
- - +
diff --git a/tests/components/servers/ServerPlayerManagement.spec.ts b/tests/components/servers/ServerPlayerManagement.spec.ts index cbad5295e..26e62b1b2 100644 --- a/tests/components/servers/ServerPlayerManagement.spec.ts +++ b/tests/components/servers/ServerPlayerManagement.spec.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { flushPromises } from "@vue/test-utils"; import { mountSuspended } from "@nuxt/test-utils/runtime"; import ServerPlayerManagement from "~/components/servers/ServerPlayerManagement.vue"; @@ -42,15 +42,49 @@ const community = (seenAt: string | null) => ({ player_management_seen_at: seenAt, }); +let unmount: (() => void) | null = null; + async function mountCard(props: Record = {}) { const wrapper = await mountSuspended(ServerPlayerManagement, { props: { serverId: "server-1", ...props }, + attachTo: document.body, }); + unmount = () => wrapper.unmount(); await flushPromises(); return wrapper; } +function statusButton(wrapper: Awaited>) { + return wrapper.find('button[aria-label^="Player Management plugin"]'); +} + +// The popover portals out of the card, so its content is read off the body. +async function openStatus(wrapper: Awaited>) { + await statusButton(wrapper).trigger("click"); + await flushPromises(); + return document.body.querySelector('[role="dialog"]')!; +} + +function buttonIn(root: HTMLElement, text: string) { + return Array.from(root.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === text, + ); +} + +// A browser can't focus reka's popper wrapper (a plain div), but happy-dom +// can, and the focusin it fires lands outside the popover and dismisses one +// with nothing focusable inside the moment it opens. +const focus = HTMLElement.prototype.focus; + beforeEach(() => { + vi.spyOn(HTMLElement.prototype, "focus").mockImplementation(function ( + this: HTMLElement, + options?: FocusOptions, + ) { + if (!this.hasAttribute("data-reka-popper-content-wrapper")) { + focus.call(this, options); + } + }); state.server = null; vi.spyOn( useApplicationSettingsStore(), @@ -64,16 +98,28 @@ beforeEach(() => { } as any; }); +afterEach(() => { + unmount?.(); + unmount = null; +}); + describe("ServerPlayerManagement plugin status", () => { it("shows a community server whose plugin checked in as active", async () => { state.server = community(new Date().toISOString()); const wrapper = await mountCard({ gameServerNodeId: "node-1" }); - expect(wrapper.text()).toContain("Player Management plugin active"); - expect(wrapper.text()).toContain("v0.0.412"); - expect(wrapper.text()).toContain("SwiftlyS2"); - expect(wrapper.text()).not.toContain("has not checked in"); + expect(statusButton(wrapper).attributes("aria-label")).toBe( + "Player Management plugin active", + ); + expect(statusButton(wrapper).text()).toContain("v0.0.412"); + + const popover = await openStatus(wrapper); + + expect(popover.textContent).toContain("v0.0.412"); + expect(popover.textContent).toContain("SwiftlyS2"); + expect(popover.textContent).toContain("Last check-in"); + expect(popover.textContent).not.toContain("has not checked in"); }); it("treats a heartbeat older than a few minutes as the plugin being gone", async () => { @@ -81,10 +127,28 @@ describe("ServerPlayerManagement plugin status", () => { new Date(Date.now() - 10 * 60 * 1000).toISOString(), ); + const wrapper = await mountCard({ + gameServerNodeId: "node-1", + online: true, + }); + + expect(statusButton(wrapper).attributes("aria-label")).toBe( + "Player Management plugin not detected", + ); + + const popover = await openStatus(wrapper); + + expect(popover.textContent).toContain("switch the server off and back on"); + expect(popover.textContent).not.toContain("Install Steps"); + }); + + it("stays quiet about a missing plugin while the server is offline", async () => { + state.server = community(null); + const wrapper = await mountCard({ gameServerNodeId: "node-1" }); - expect(wrapper.text()).toContain("switch the server off and back on"); - expect(wrapper.text()).not.toContain("Install Plugin"); + expect(statusButton(wrapper).exists()).toBe(false); + expect(wrapper.text()).not.toContain("has not checked in"); }); it("offers an administrator the download and config for a server outside a node", async () => { @@ -93,36 +157,44 @@ describe("ServerPlayerManagement plugin status", () => { const wrapper = await mountCard({ gameServerNodeId: null, apiPassword: "secret-password", + online: true, }); - expect(wrapper.text()).toContain( + let popover = await openStatus(wrapper); + + expect(popover.textContent).toContain( "only enforced live once the 5Stack Player Management plugin is installed", ); - const install = wrapper - .findAll("button") - .find((button) => button.text() === "Install Plugin"); - await install!.trigger("click"); - - const download = wrapper.find('a[href*="PlayerManagement"]'); - expect(download.attributes("href")).toBe( + const download = popover.querySelector('a[href*="PlayerManagement"]'); + expect(download?.getAttribute("href")).toBe( "https://github.com/5stackgg/game-server/releases/download/sw-v0.0.412/PlayerManagement-sw-v0.0.412.zip", ); - expect(wrapper.text()).toContain( + expect(popover.textContent).toContain( "addons/swiftlys2/configs/plugins/PlayerManagement/config.jsonc", ); // The config carries the api password, so it stays hidden like the // page's own plugin config until asked for. - expect(wrapper.html()).not.toContain("secret-password"); + expect(document.body.innerHTML).not.toContain("secret-password"); - const show = wrapper - .findAll("button") - .find((button) => button.text() === "Show Config"); - await show!.trigger("click"); + buttonIn(popover, "Show Config")!.click(); + await flushPromises(); - expect(wrapper.find("pre").text()).toContain('"PlayerManagement"'); - expect(wrapper.find("pre").text()).toContain("secret-password"); + expect(popover.querySelector("pre")?.textContent).toContain( + '"PlayerManagement"', + ); + expect(popover.querySelector("pre")?.textContent).toContain( + "secret-password", + ); + + // Closing the popover hides it again rather than leaving it on screen. + await statusButton(wrapper).trigger("click"); + await flushPromises(); + popover = await openStatus(wrapper); + + expect(popover.querySelector("pre")).toBeNull(); + expect(document.body.innerHTML).not.toContain("secret-password"); }); it("never shows install steps to someone who is not an administrator", async () => { @@ -132,18 +204,22 @@ describe("ServerPlayerManagement plugin status", () => { } as any; state.server = community(null); - const wrapper = await mountCard({ gameServerNodeId: null }); + const wrapper = await mountCard({ gameServerNodeId: null, online: true }); + + const popover = await openStatus(wrapper); - expect(wrapper.text()).toContain("installed on this server"); - expect(wrapper.text()).not.toContain("Install Plugin"); + expect(popover.textContent).toContain("installed on this server"); + expect(popover.textContent).not.toContain("Install Steps"); }); it("says nothing about the plugin on a Ranked server", async () => { state.server = { ...community(null), type: "Ranked" }; - const wrapper = await mountCard({ gameServerNodeId: "node-1" }); + const wrapper = await mountCard({ + gameServerNodeId: "node-1", + online: true, + }); - expect(wrapper.text()).not.toContain("Player Management plugin"); - expect(wrapper.text()).not.toContain("has not checked in"); + expect(statusButton(wrapper).exists()).toBe(false); }); });