From b00551c394d03b648dbb69ac3df42cd1cb06a13b Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sat, 3 Oct 2026 21:04:50 +0800 Subject: [PATCH] feat(hosts): add instance-wide predefined tag suggestions (#1548) * feat(hosts): add shared predefined tag suggestions * style(hosts): format tag catalog routes --- .../database/routes/host-tag-routes.ts | 40 ++++++++++ src/backend/database/routes/host.ts | 7 ++ .../database/routes/host-tag-routes.test.ts | 71 ++++++++++++++++ src/ui/api/host-tags-api.ts | 9 +++ src/ui/locales/en.json | 6 +- src/ui/locales/translated/zh_CN.json | 6 +- src/ui/sidebar/AdminHostTags.tsx | 80 +++++++++++++++++++ src/ui/sidebar/AdminSettingsSections.tsx | 2 + src/ui/sidebar/HostEditorGeneralTab.tsx | 60 ++++++++++++-- 9 files changed, 274 insertions(+), 7 deletions(-) create mode 100644 src/backend/database/routes/host-tag-routes.ts create mode 100644 src/backend/tests/database/routes/host-tag-routes.test.ts create mode 100644 src/ui/api/host-tags-api.ts create mode 100644 src/ui/sidebar/AdminHostTags.tsx diff --git a/src/backend/database/routes/host-tag-routes.ts b/src/backend/database/routes/host-tag-routes.ts new file mode 100644 index 000000000..76346bd84 --- /dev/null +++ b/src/backend/database/routes/host-tag-routes.ts @@ -0,0 +1,40 @@ +import type { RequestHandler, Router } from "express"; +import { createCurrentSettingsRepository } from "../repositories/factory.js"; + +const CATALOG_KEY = "host_tag_catalog"; + +export function registerHostTagRoutes( + router: Router, + authenticate: RequestHandler, + requireAdmin: RequestHandler, +): void { + router.get("/tags", authenticate, async (_req, res) => { + const value = await createCurrentSettingsRepository().get(CATALOG_KEY); + res.json({ tags: value ? JSON.parse(value) : [] }); + }); + + router.put("/tags", authenticate, requireAdmin, async (req, res) => { + const tags: unknown = req.body?.tags; + if ( + !Array.isArray(tags) || + tags.length > 500 || + tags.some( + (tag) => + typeof tag !== "string" || !tag.trim() || tag.trim().length > 100, + ) + ) { + res.status(400).json({ + error: "Expected up to 500 non-empty tags of at most 100 characters", + }); + return; + } + const normalized = [ + ...new Set((tags as string[]).map((tag) => tag.trim())), + ]; + await createCurrentSettingsRepository().set( + CATALOG_KEY, + JSON.stringify(normalized), + ); + res.json({ tags: normalized }); + }); +} diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index ebd347dab..73a3942f4 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -53,6 +53,7 @@ import { registerHostFolderRoutes } from "./host-folder-routes.js"; import { registerHostNetworkRoutes } from "./host-network-routes.js"; import { registerHostBulkRoutes } from "./host-bulk-routes.js"; import { registerHostDefaultsRoutes } from "./host-defaults-routes.js"; +import { registerHostTagRoutes } from "./host-tag-routes.js"; import { registerHostStatusRoutes } from "./host-status-routes.js"; import { applyHostEnrollmentDefaults, @@ -136,6 +137,12 @@ const permissionManager = PermissionManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); const requireDataAccess = authManager.createDataAccessMiddleware(); +registerHostTagRoutes( + router, + authenticateJWT, + permissionManager.requirePermission("admin.settings.manage"), +); + registerHostStatusRoutes(router, { authenticateJWT, requireAdmin: authManager.createAdminMiddleware(), diff --git a/src/backend/tests/database/routes/host-tag-routes.test.ts b/src/backend/tests/database/routes/host-tag-routes.test.ts new file mode 100644 index 000000000..4723ecf82 --- /dev/null +++ b/src/backend/tests/database/routes/host-tag-routes.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import express from "express"; +import request from "supertest"; + +const settings = vi.hoisted(() => new Map()); +vi.mock("../../../database/repositories/factory.js", () => ({ + createCurrentSettingsRepository: () => ({ + get: async (key: string) => settings.get(key) ?? null, + set: async (key: string, value: string) => { + settings.set(key, value); + }, + }), +})); +import { registerHostTagRoutes } from "../../../database/routes/host-tag-routes.js"; + +function app() { + const server = express(); + server.use(express.json()); + registerHostTagRoutes( + server, + (req, res, next) => + req.headers.authorization ? next() : void res.sendStatus(401), + (req, res, next) => + req.headers.authorization === "admin" ? next() : void res.sendStatus(403), + ); + return server; +} + +beforeEach(() => settings.clear()); +describe("instance host tag catalog", () => { + it("allows authenticated users to read the same admin-managed catalog", async () => { + const server = app(); + await request(server) + .put("/tags") + .set("Authorization", "admin") + .send({ tags: [" Linux ", "Domain Controller", "Linux"] }) + .expect(200, { tags: ["Linux", "Domain Controller"] }); + await request(server) + .get("/tags") + .set("Authorization", "member") + .expect(200, { tags: ["Linux", "Domain Controller"] }); + }); + it("rejects anonymous reads and non-admin writes", async () => { + await request(app()).get("/tags").expect(401); + await request(app()) + .put("/tags") + .set("Authorization", "member") + .send({ tags: ["test"] }) + .expect(403); + expect(settings.size).toBe(0); + }); + it.each([null, [42], [" "], ["x".repeat(101)], Array(501).fill("tag")])( + "rejects invalid catalogs", + async (tags) => { + await request(app()) + .put("/tags") + .set("Authorization", "admin") + .send({ tags }) + .expect(400); + expect(settings.size).toBe(0); + }, + ); + it("allows clearing suggestions without modifying hosts", async () => { + settings.set("host_tag_catalog", '["Linux"]'); + await request(app()) + .put("/tags") + .set("Authorization", "admin") + .send({ tags: [] }) + .expect(200, { tags: [] }); + }); +}); diff --git a/src/ui/api/host-tags-api.ts b/src/ui/api/host-tags-api.ts new file mode 100644 index 000000000..1fc645fd1 --- /dev/null +++ b/src/ui/api/host-tags-api.ts @@ -0,0 +1,9 @@ +import { hostApi } from "@/main-axios"; + +export async function getHostTags(): Promise { + return (await hostApi.get<{ tags: string[] }>("/tags")).data.tags; +} + +export async function saveHostTags(tags: string[]): Promise { + return (await hostApi.put<{ tags: string[] }>("/tags", { tags })).data.tags; +} diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 70f962e7c..6e20db38c 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -1504,7 +1504,11 @@ "sslIssuer": "Issued by {{issuer}}", "sslNames": "Covers {{names}}", "sslManualTitle": "Upload a certificate", - "updateExternalAutoProvisionFailed": "Failed to update the auto-create setting" + "updateExternalAutoProvisionFailed": "Failed to update the auto-create setting", + "hostTags": "Predefined host tags", + "hostTagsDesc": "One tag per line, available to all users. Editing this list changes suggestions only; tags already assigned to hosts are kept.", + "hostTagsSaved": "Host tag suggestions saved.", + "hostTagsError": "Could not load or save host tag suggestions." }, "newUi": { "sidebar": { diff --git a/src/ui/locales/translated/zh_CN.json b/src/ui/locales/translated/zh_CN.json index f684ef5a2..74e6358b6 100644 --- a/src/ui/locales/translated/zh_CN.json +++ b/src/ui/locales/translated/zh_CN.json @@ -1502,7 +1502,11 @@ "sslIssuer": "Issued by {{issuer}}", "sslNames": "Covers {{names}}", "sslManualTitle": "Upload a certificate", - "updateExternalAutoProvisionFailed": "Failed to update the auto-create setting" + "updateExternalAutoProvisionFailed": "Failed to update the auto-create setting", + "hostTags": "预定义主机标签", + "hostTagsDesc": "每行一个标签,供所有用户使用。编辑此列表仅修改建议项,不会更改主机已有的标签。", + "hostTagsSaved": "主机标签建议已保存。", + "hostTagsError": "无法加载或保存主机标签建议。" }, "newUi": { "sidebar": { diff --git a/src/ui/sidebar/AdminHostTags.tsx b/src/ui/sidebar/AdminHostTags.tsx new file mode 100644 index 000000000..9bb344384 --- /dev/null +++ b/src/ui/sidebar/AdminHostTags.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/button"; +import { getHostTags, saveHostTags } from "@/api/host-tags-api"; + +export function AdminHostTags() { + const { t } = useTranslation(); + const [value, setValue] = useState(""); + const [loaded, setLoaded] = useState(false); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(""); + + useEffect(() => { + let cancelled = false; + getHostTags() + .then((tags) => { + if (cancelled) return; + setValue(tags.join("\n")); + setLoaded(true); + }) + .catch(() => { + if (!cancelled) setMessage(t("admin.hostTagsError")); + }); + return () => { + cancelled = true; + }; + }, [t]); + + async function save() { + setSaving(true); + setMessage(""); + try { + const tags = await saveHostTags( + value + .split("\n") + .map((tag) => tag.trim()) + .filter(Boolean), + ); + setValue(tags.join("\n")); + setMessage(t("admin.hostTagsSaved")); + window.dispatchEvent(new Event("termix:host-tags-changed")); + } catch { + setMessage(t("admin.hostTagsError")); + } finally { + setSaving(false); + } + } + + return ( +
+ +

+ {t("admin.hostTagsDesc")} +

+