From 92decfbdbd17166ef14d3303bd75dfdbb880f0a1 Mon Sep 17 00:00:00 2001 From: parsa222 <70150489+parsa222@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:12:54 -0700 Subject: [PATCH] feat(hosts): reusable color-coded host tags Create reusable, color-coded tags and assign multiple to each host; the host card is tinted with the first tag's color (a soft, glassy fill) and shows one chip per tag. Backend: HostTag model + hosts_tags_association M2M (migration a821c186b426), tag CRUD/operation/router at /api/host/tags (RBAC via the hosts resource), and tag_ids/tags on host create/modify/response. Tags order_by id for a deterministic tint color; bulk host delete clears tag links explicitly (SQLite has no FK cascade). Frontend: theme-aware palette, react-query hooks, a tag picker (assign + inline create/edit/delete) in the host form, and card/row coloring across grid, list, and table views. The orval client was hand-extended because codegen is broken on Node 22. Tests: unit (schema validation) + API/e2e (CRUD, delete-tag cascade, dedup, invalid id, clone, reorder, bulk-delete). Verified on SQLite, MySQL/MariaDB, and PostgreSQL/TimescaleDB. --- app/db/crud/host.py | 72 +++- .../versions/a821c186b426_add_host_tags.py | 45 +++ app/db/models.py | 23 ++ app/models/host.py | 53 +++ app/operation/host.py | 67 ++++ app/routers/host.py | 51 ++- dashboard/src/constants/hostTagColors.ts | 25 ++ .../hosts/components/host-tag-picker.tsx | 239 ++++++++++++ .../features/hosts/components/hosts-list.tsx | 9 + .../hosts/components/sortable-host.tsx | 16 +- .../components/use-hosts-list-columns.tsx | 15 + .../src/features/hosts/dialogs/host-modal.tsx | 15 + .../src/features/hosts/forms/host-form.ts | 3 + dashboard/src/service/api/index.ts | 42 +++ dashboard/src/service/hostTags.ts | 63 ++++ tests/api/test_host_tag.py | 356 ++++++++++++++++++ tests/test_host_tags_unit.py | 110 ++++++ 17 files changed, 1191 insertions(+), 13 deletions(-) create mode 100644 app/db/migrations/versions/a821c186b426_add_host_tags.py create mode 100644 dashboard/src/constants/hostTagColors.ts create mode 100644 dashboard/src/features/hosts/components/host-tag-picker.tsx create mode 100644 dashboard/src/service/hostTags.ts create mode 100644 tests/api/test_host_tag.py create mode 100644 tests/test_host_tags_unit.py diff --git a/app/db/crud/host.py b/app/db/crud/host.py index 2327756fd..ff81a4673 100644 --- a/app/db/crud/host.py +++ b/app/db/crud/host.py @@ -6,9 +6,20 @@ from sqlalchemy.dialects.mysql import insert as mysql_insert from sqlalchemy import insert from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload -from app.db.models import ProxyHost, ProxyInbound -from app.models.host import CreateHost, HostListQuery +from app.db.models import HostTag, ProxyHost, ProxyInbound, hosts_tags_association +from app.models.host import CreateHost, HostListQuery, HostTagCreate, HostTagModify + + +async def resolve_host_tags(db: AsyncSession, tag_ids: list[int]) -> list[HostTag]: + """Resolve a list of tag ids to HostTag rows, preserving the requested order.""" + if not tag_ids: + return [] + unique_ids = list(dict.fromkeys(tag_ids)) + result = await db.execute(select(HostTag).where(HostTag.id.in_(unique_ids))) + by_id = {tag.id: tag for tag in result.scalars().all()} + return [by_id[tag_id] for tag_id in unique_ids if tag_id in by_id] async def upsert_inbounds(db: AsyncSession, inbound_tags: list[str]) -> dict[str, ProxyInbound]: @@ -121,7 +132,7 @@ async def get_hosts(db: AsyncSession, query: HostListQuery | None = None) -> lis List[ProxyHost]: List of hosts sorted by the specified option. """ query = query or HostListQuery() - stmt = select(ProxyHost).order_by(ProxyHost.priority.asc()) + stmt = select(ProxyHost).options(selectinload(ProxyHost.tags)).order_by(ProxyHost.priority.asc()) if query.ids: stmt = stmt.where(ProxyHost.id.in_(query.ids)) @@ -145,7 +156,7 @@ async def get_host_by_id(db: AsyncSession, id: int) -> ProxyHost: Returns: ProxyHost: The host if found. """ - stmt = select(ProxyHost).where(ProxyHost.id == id) + stmt = select(ProxyHost).options(selectinload(ProxyHost.tags)).where(ProxyHost.id == id) result = await db.execute(stmt) return result.scalar_one_or_none() @@ -161,17 +172,17 @@ async def create_host(db: AsyncSession, new_host: CreateHost) -> ProxyHost: Returns: ProxyHost: The retrieved or newly created proxy host. """ - db_host = ProxyHost(**new_host.model_dump(exclude={"inbound_tag", "id"})) + db_host = ProxyHost(**new_host.model_dump(exclude={"inbound_tag", "id", "tags", "tag_ids"})) db_host.inbound = await get_or_create_inbound(db, new_host.inbound_tag) + db_host.tags = await resolve_host_tags(db, new_host.tag_ids) db.add(db_host) await db.commit() - await db.refresh(db_host) - return db_host + return await get_host_by_id(db, db_host.id) async def modify_host(db: AsyncSession, db_host: ProxyHost, modified_host: CreateHost) -> ProxyHost: - host_data = modified_host.model_dump(exclude={"id", "inbound_tag"}) + host_data = modified_host.model_dump(exclude={"id", "inbound_tag", "tags", "tag_ids"}) for key, value in host_data.items(): setattr(db_host, key, value) @@ -181,9 +192,49 @@ async def modify_host(db: AsyncSession, db_host: ProxyHost, modified_host: Creat else: db_host.inbound = await get_or_create_inbound(db, modified_host.inbound_tag) + db_host.tags = await resolve_host_tags(db, modified_host.tag_ids) + + await db.commit() + return await get_host_by_id(db, db_host.id) + + +async def get_host_tags(db: AsyncSession) -> list[HostTag]: + """Return all host tags ordered by name.""" + result = await db.execute(select(HostTag).order_by(HostTag.name.asc())) + return list(result.scalars().all()) + + +async def get_host_tag_by_id(db: AsyncSession, id: int) -> HostTag | None: + result = await db.execute(select(HostTag).where(HostTag.id == id)) + return result.scalar_one_or_none() + + +async def get_host_tag_by_name(db: AsyncSession, name: str) -> HostTag | None: + result = await db.execute(select(HostTag).where(HostTag.name == name)) + return result.scalar_one_or_none() + + +async def create_host_tag(db: AsyncSession, new_tag: HostTagCreate) -> HostTag: + db_tag = HostTag(name=new_tag.name, color=new_tag.color.value) + db.add(db_tag) + await db.commit() + await db.refresh(db_tag) + return db_tag + + +async def modify_host_tag(db: AsyncSession, db_tag: HostTag, modified_tag: HostTagModify) -> HostTag: + if modified_tag.name is not None: + db_tag.name = modified_tag.name + if modified_tag.color is not None: + db_tag.color = modified_tag.color.value + await db.commit() + await db.refresh(db_tag) + return db_tag + + +async def remove_host_tag(db: AsyncSession, db_tag: HostTag) -> None: + await db.delete(db_tag) await db.commit() - await db.refresh(db_host) - return db_host async def remove_host(db: AsyncSession, db_host: ProxyHost) -> ProxyHost: @@ -213,5 +264,6 @@ async def remove_hosts(db: AsyncSession, host_ids: list[int]) -> None: if not host_ids: return + await db.execute(delete(hosts_tags_association).where(hosts_tags_association.c.host_id.in_(host_ids))) await db.execute(delete(ProxyHost).where(ProxyHost.id.in_(host_ids))) await db.commit() diff --git a/app/db/migrations/versions/a821c186b426_add_host_tags.py b/app/db/migrations/versions/a821c186b426_add_host_tags.py new file mode 100644 index 000000000..e18f2bb05 --- /dev/null +++ b/app/db/migrations/versions/a821c186b426_add_host_tags.py @@ -0,0 +1,45 @@ +"""add host tags + +Revision ID: a821c186b426 +Revises: a3b4c5d6e7f8 +Create Date: 2026-07-01 19:12:23.382022 + +""" + +from alembic import op +import sqlalchemy as sa +import app.db.compiles_types + + +revision = "a821c186b426" +down_revision = "b6c9d0e1f2a3" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "host_tags", + sa.Column("name", sa.String(length=64), nullable=False), + sa.Column("color", sa.String(length=32), nullable=False), + sa.Column("id", app.db.compiles_types.SqliteCompatibleBigInteger(), autoincrement=True, nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_host_tags")), + sa.UniqueConstraint("name", name=op.f("uq_host_tags_name")), + ) + op.create_table( + "hosts_tags_association", + sa.Column("host_id", app.db.compiles_types.SqliteCompatibleBigInteger(), nullable=False), + sa.Column("tag_id", app.db.compiles_types.SqliteCompatibleBigInteger(), nullable=False), + sa.ForeignKeyConstraint( + ["host_id"], ["hosts.id"], name=op.f("fk_hosts_tags_association_host_id_hosts"), ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["tag_id"], ["host_tags.id"], name=op.f("fk_hosts_tags_association_tag_id_host_tags"), ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("host_id", "tag_id", name=op.f("pk_hosts_tags_association")), + ) + + +def downgrade() -> None: + op.drop_table("hosts_tags_association") + op.drop_table("host_tags") diff --git a/app/db/models.py b/app/db/models.py index 3be7f7a75..5b798d00e 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -58,6 +58,13 @@ def fk_id_table_column(name: str, target: str, **column_kwargs: Any): fk_id_table_column("groups_id", "groups.id", primary_key=True), ) +hosts_tags_association = Table( + "hosts_tags_association", + Base.metadata, + fk_id_table_column("host_id", "hosts.id", primary_key=True, ondelete="CASCADE"), + fk_id_table_column("tag_id", "host_tags.id", primary_key=True, ondelete="CASCADE"), +) + class AdminStatus(str, Enum): active = "active" @@ -574,6 +581,22 @@ class ProxyHost(Base, IdMixin): ) wireguard_overrides: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON(none_as_null=True), default=None) subscription_templates: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON(none_as_null=True), default=None) + tags: Mapped[List["HostTag"]] = relationship( + secondary=hosts_tags_association, back_populates="hosts", init=False, order_by="HostTag.id" + ) + + @property + def tag_ids(self) -> list[int]: + return [tag.id for tag in self.tags] + + +class HostTag(Base, IdMixin): + """User-defined, reusable, color-coded label that can be attached to hosts.""" + + __tablename__ = "host_tags" + name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + color: Mapped[str] = mapped_column(String(32), nullable=False) + hosts: Mapped[List["ProxyHost"]] = relationship(secondary=hosts_tags_association, back_populates="tags", init=False) class System(Base, IdMixin): diff --git a/app/models/host.py b/app/models/host.py index fd091a9b8..69d96a2b0 100644 --- a/app/models/host.py +++ b/app/models/host.py @@ -287,6 +287,57 @@ def empty_str_to_none(cls, v): return v +class HostTagColor(str, Enum): + """Curated, theme-aware palette keys for host tags (rendered on the dashboard).""" + + slate = "slate" + red = "red" + orange = "orange" + amber = "amber" + green = "green" + teal = "teal" + sky = "sky" + blue = "blue" + violet = "violet" + pink = "pink" + + +class HostTag(BaseModel): + id: int | None = Field(default=None) + name: str = Field(min_length=1, max_length=64) + color: HostTagColor + + model_config = ConfigDict(from_attributes=True) + + +class HostTagCreate(BaseModel): + name: str = Field(min_length=1, max_length=64) + color: HostTagColor + + @field_validator("name", mode="after") + @classmethod + def strip_name(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("Tag name cannot be empty") + return v + + +class HostTagModify(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=64) + color: HostTagColor | None = Field(default=None) + + @field_validator("name", mode="after") + @classmethod + def strip_name(cls, v: str | None) -> str | None: + if v is None: + return v + v = v.strip() + if not v: + raise ValueError("Tag name cannot be empty") + return v + + class BaseHost(BaseModel): id: int | None = Field(default=None) remark: str @@ -317,6 +368,8 @@ class BaseHost(BaseModel): verify_peer_cert_by_name: set[str] | None = Field(default_factory=set) wireguard_overrides: WireGuardHostOverrides | None = None subscription_templates: SubscriptionTemplates | None = None + tags: list[HostTag] = Field(default_factory=list) + tag_ids: list[int] = Field(default_factory=list) model_config = ConfigDict(from_attributes=True) diff --git a/app/operation/host.py b/app/operation/host.py index ca7e59b7c..1fa1bf078 100644 --- a/app/operation/host.py +++ b/app/operation/host.py @@ -1,25 +1,38 @@ import asyncio +from sqlalchemy.exc import IntegrityError + from app.db import AsyncSession from app.db.models import ProxyHost from app.models.admin import AdminDetails from app.models.client_template import ClientTemplateType +from app.db.models import HostTag as DBHostTag from app.models.host import ( BaseHost, BulkHostSelection, BulkHostsActionResponse, CreateHost, HostListQuery, + HostTag, + HostTagCreate, + HostTagModify, RemoveHostsResponse, ) from app.operation import BaseOperation from app.db.crud.host import ( create_host, + create_host_tag, get_host_by_id, + get_host_tag_by_id, + get_host_tag_by_name, + get_host_tags, get_hosts, modify_host, + modify_host_tag, remove_host, + remove_host_tag, remove_hosts, + resolve_host_tags, ) from app.core.hosts import host_manager from app.utils.logger import get_logger @@ -60,11 +73,23 @@ async def validate_ds_host(self, db: AsyncSession, host: CreateHost, host_id: in ): return await self.raise_error("download host cannot have a download host", 400, db=db) + async def validate_host_tags(self, db: AsyncSession, tag_ids: list[int]) -> None: + """Ensure every requested host-tag id actually exists.""" + unique_ids = list(dict.fromkeys(tag_ids or [])) + if not unique_ids: + return + resolved = await resolve_host_tags(db, unique_ids) + if len(resolved) != len(unique_ids): + found = {tag.id for tag in resolved} + missing = [tag_id for tag_id in unique_ids if tag_id not in found] + await self.raise_error(f"Host tag(s) not found: {missing}", 404, db=db) + async def create_host(self, db: AsyncSession, new_host: CreateHost, admin: AdminDetails) -> BaseHost: await self.validate_subscription_templates(db, new_host) await self.validate_ds_host(db, new_host) await self.check_host_inbound_tags([new_host.inbound_tag]) + await self.validate_host_tags(db, new_host.tag_ids) db_host = await create_host(db, new_host) @@ -85,6 +110,7 @@ async def modify_host( if modified_host.inbound_tag: await self.check_host_inbound_tags([modified_host.inbound_tag]) + await self.validate_host_tags(db, modified_host.tag_ids) db_host = await self.get_validated_host(db, host_id) @@ -110,6 +136,47 @@ async def remove_host(self, db: AsyncSession, host_id: int, admin: AdminDetails) await host_manager.remove_host(host.id) + async def get_validated_host_tag(self, db: AsyncSession, tag_id: int) -> DBHostTag: + db_tag = await get_host_tag_by_id(db, tag_id) + if not db_tag: + await self.raise_error("Host tag not found", 404, db=db) + return db_tag + + async def get_host_tags(self, db: AsyncSession) -> list[HostTag]: + db_tags = await get_host_tags(db) + return [HostTag.model_validate(tag) for tag in db_tags] + + async def create_host_tag(self, db: AsyncSession, new_tag: HostTagCreate, admin: AdminDetails) -> HostTag: + if await get_host_tag_by_name(db, new_tag.name): + await self.raise_error("A host tag with this name already exists", 409, db=db) + try: + db_tag = await create_host_tag(db, new_tag) + except IntegrityError: + await self.raise_error("A host tag with this name already exists", 409, db=db) + logger.info(f'Host tag "{db_tag.name}" created by admin "{admin.username}"') + return HostTag.model_validate(db_tag) + + async def modify_host_tag( + self, db: AsyncSession, tag_id: int, modified_tag: HostTagModify, admin: AdminDetails + ) -> HostTag: + db_tag = await self.get_validated_host_tag(db, tag_id) + if modified_tag.name and modified_tag.name != db_tag.name: + existing = await get_host_tag_by_name(db, modified_tag.name) + if existing and existing.id != db_tag.id: + await self.raise_error("A host tag with this name already exists", 409, db=db) + try: + db_tag = await modify_host_tag(db, db_tag, modified_tag) + except IntegrityError: + await self.raise_error("A host tag with this name already exists", 409, db=db) + logger.info(f'Host tag "{db_tag.name}" ({db_tag.id}) modified by admin "{admin.username}"') + return HostTag.model_validate(db_tag) + + async def remove_host_tag(self, db: AsyncSession, tag_id: int, admin: AdminDetails) -> None: + db_tag = await self.get_validated_host_tag(db, tag_id) + name = db_tag.name + await remove_host_tag(db, db_tag) + logger.info(f'Host tag "{name}" ({tag_id}) deleted by admin "{admin.username}"') + async def modify_hosts( self, db: AsyncSession, modified_hosts: list[CreateHost], admin: AdminDetails ) -> list[BaseHost]: diff --git a/app/routers/host.py b/app/routers/host.py index 98e7d994d..b040e5502 100644 --- a/app/routers/host.py +++ b/app/routers/host.py @@ -2,7 +2,16 @@ from app.db import AsyncSession, get_db from app.models.admin import AdminDetails -from app.models.host import BaseHost, BulkHostSelection, BulkHostsActionResponse, CreateHost, RemoveHostsResponse +from app.models.host import ( + BaseHost, + BulkHostSelection, + BulkHostsActionResponse, + CreateHost, + HostTag, + HostTagCreate, + HostTagModify, + RemoveHostsResponse, +) from app.operation import OperatorType from app.operation.host import HostOperation from app.utils import responses @@ -14,6 +23,46 @@ router = APIRouter(tags=["Host"], prefix="/api/host", responses={401: responses._401, 403: responses._403}) +@router.get("/tags", response_model=list[HostTag]) +async def get_host_tags( + db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(require_permission("hosts", "read")) +): + """List all host tags.""" + return await host_operator.get_host_tags(db=db) + + +@router.post("/tags", response_model=HostTag, status_code=status.HTTP_201_CREATED, responses={409: responses._409}) +async def create_host_tag( + new_tag: HostTagCreate, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("hosts", "create")), +): + """Create a reusable, color-coded host tag.""" + return await host_operator.create_host_tag(db, new_tag=new_tag, admin=admin) + + +@router.put("/tags/{tag_id}", response_model=HostTag, responses={404: responses._404, 409: responses._409}) +async def modify_host_tag( + tag_id: int, + modified_tag: HostTagModify, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("hosts", "update")), +): + """Modify a host tag by **id**.""" + return await host_operator.modify_host_tag(db, tag_id=tag_id, modified_tag=modified_tag, admin=admin) + + +@router.delete("/tags/{tag_id}", status_code=status.HTTP_204_NO_CONTENT, responses={404: responses._404}) +async def remove_host_tag( + tag_id: int, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("hosts", "update")), +): + """Delete a host tag by **id** (also detaches it from any hosts).""" + await host_operator.remove_host_tag(db, tag_id=tag_id, admin=admin) + return {} + + @router.get("/{host_id}", response_model=BaseHost) async def get_host( host_id: int, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(require_permission("hosts", "read")) diff --git a/dashboard/src/constants/hostTagColors.ts b/dashboard/src/constants/hostTagColors.ts new file mode 100644 index 000000000..dff045307 --- /dev/null +++ b/dashboard/src/constants/hostTagColors.ts @@ -0,0 +1,25 @@ +import { HostTagColor } from '@/service/api' + +export interface TagColorStyle { + chip: string + bar: string + tint: string +} + +export const HOST_TAG_COLORS: Record = { + slate: { chip: 'bg-slate-500/15 text-slate-700 dark:text-slate-300 border-slate-500/30', bar: 'bg-slate-500', tint: 'bg-slate-500/[0.06] dark:bg-slate-500/10 backdrop-blur-sm backdrop-saturate-150' }, + red: { chip: 'bg-red-500/15 text-red-700 dark:text-red-300 border-red-500/30', bar: 'bg-red-500', tint: 'bg-red-500/[0.06] dark:bg-red-500/10 backdrop-blur-sm backdrop-saturate-150' }, + orange: { chip: 'bg-orange-500/15 text-orange-700 dark:text-orange-300 border-orange-500/30', bar: 'bg-orange-500', tint: 'bg-orange-500/[0.06] dark:bg-orange-500/10 backdrop-blur-sm backdrop-saturate-150' }, + amber: { chip: 'bg-amber-500/15 text-amber-700 dark:text-amber-300 border-amber-500/30', bar: 'bg-amber-500', tint: 'bg-amber-500/[0.06] dark:bg-amber-500/10 backdrop-blur-sm backdrop-saturate-150' }, + green: { chip: 'bg-green-500/15 text-green-700 dark:text-green-300 border-green-500/30', bar: 'bg-green-500', tint: 'bg-green-500/[0.06] dark:bg-green-500/10 backdrop-blur-sm backdrop-saturate-150' }, + teal: { chip: 'bg-teal-500/15 text-teal-700 dark:text-teal-300 border-teal-500/30', bar: 'bg-teal-500', tint: 'bg-teal-500/[0.06] dark:bg-teal-500/10 backdrop-blur-sm backdrop-saturate-150' }, + sky: { chip: 'bg-sky-500/15 text-sky-700 dark:text-sky-300 border-sky-500/30', bar: 'bg-sky-500', tint: 'bg-sky-500/[0.06] dark:bg-sky-500/10 backdrop-blur-sm backdrop-saturate-150' }, + blue: { chip: 'bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/30', bar: 'bg-blue-500', tint: 'bg-blue-500/[0.06] dark:bg-blue-500/10 backdrop-blur-sm backdrop-saturate-150' }, + violet: { chip: 'bg-violet-500/15 text-violet-700 dark:text-violet-300 border-violet-500/30', bar: 'bg-violet-500', tint: 'bg-violet-500/[0.06] dark:bg-violet-500/10 backdrop-blur-sm backdrop-saturate-150' }, + pink: { chip: 'bg-pink-500/15 text-pink-700 dark:text-pink-300 border-pink-500/30', bar: 'bg-pink-500', tint: 'bg-pink-500/[0.06] dark:bg-pink-500/10 backdrop-blur-sm backdrop-saturate-150' }, +} + +export const HOST_TAG_COLOR_KEYS = Object.keys(HOST_TAG_COLORS) as HostTagColor[] + +export const getTagColorStyle = (color?: string | null): TagColorStyle => + (color && HOST_TAG_COLORS[color as HostTagColor]) || HOST_TAG_COLORS.slate diff --git a/dashboard/src/features/hosts/components/host-tag-picker.tsx b/dashboard/src/features/hosts/components/host-tag-picker.tsx new file mode 100644 index 000000000..8b7a094c3 --- /dev/null +++ b/dashboard/src/features/hosts/components/host-tag-picker.tsx @@ -0,0 +1,239 @@ +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Check, Loader2, Pencil, Plus, Tag as TagIcon, Trash2, X } from 'lucide-react' +import { toast } from 'sonner' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { cn } from '@/lib/utils' +import { HOST_TAG_COLORS, HOST_TAG_COLOR_KEYS, getTagColorStyle } from '@/constants/hostTagColors' +import type { HostTag, HostTagColor } from '@/service/api' +import { useCreateHostTag, useHostTags, useModifyHostTag, useRemoveHostTag } from '@/service/hostTags' + +export function TagChip({ + tag, + onRemove, + className, +}: { + tag: Pick + onRemove?: () => void + className?: string +}) { + const style = getTagColorStyle(tag.color) + return ( + + + {tag.name} + {onRemove && ( + + )} + + ) +} + +function ColorSwatchRow({ value, onChange }: { value: HostTagColor; onChange: (c: HostTagColor) => void }) { + return ( +
+ {HOST_TAG_COLOR_KEYS.map(key => ( + + ))} +
+ ) +} + +interface HostTagPickerProps { + value: number[] + onChange: (ids: number[]) => void +} + +export default function HostTagPicker({ value, onChange }: HostTagPickerProps) { + const { t } = useTranslation() + const { data: tags = [], isLoading } = useHostTags() + const createTag = useCreateHostTag() + const modifyTag = useModifyHostTag() + const removeTag = useRemoveHostTag() + + const [open, setOpen] = useState(false) + const [newName, setNewName] = useState('') + const [newColor, setNewColor] = useState('slate') + const [editingId, setEditingId] = useState(null) + const [editName, setEditName] = useState('') + const [editColor, setEditColor] = useState('slate') + + const selected = useMemo(() => tags.filter(tag => tag.id != null && value.includes(tag.id)), [tags, value]) + + const toggle = (id: number) => onChange(value.includes(id) ? value.filter(x => x !== id) : [...value, id]) + + const handleCreate = () => { + const name = newName.trim() + if (!name) return + createTag.mutate( + { name, color: newColor }, + { + onSuccess: tag => { + if (tag.id != null) onChange([...value, tag.id]) + setNewName('') + setNewColor('slate') + }, + onError: () => toast.error(t('hostTags.createFailed', { defaultValue: 'Failed to create tag (name may already exist)' })), + }, + ) + } + + const startEdit = (tag: HostTag) => { + setEditingId(tag.id ?? null) + setEditName(tag.name) + setEditColor(tag.color) + } + + const saveEdit = () => { + if (editingId == null) return + const name = editName.trim() + if (!name) return + modifyTag.mutate( + { tagId: editingId, data: { name, color: editColor } }, + { + onSuccess: () => setEditingId(null), + onError: () => toast.error(t('hostTags.updateFailed', { defaultValue: 'Failed to update tag' })), + }, + ) + } + + const handleRemove = (id: number) => { + removeTag.mutate(id, { + onSuccess: () => onChange(value.filter(x => x !== id)), + onError: () => toast.error(t('hostTags.deleteFailed', { defaultValue: 'Failed to delete tag' })), + }) + } + + return ( +
+ {selected.map(tag => ( + tag.id != null && toggle(tag.id)} /> + ))} + + + + + + +
+ + {t('hostTags.title', { defaultValue: 'Host tags' })} +
+ +
+ {isLoading && ( +
+ +
+ )} + {!isLoading && tags.length === 0 && ( +
+ {t('hostTags.empty', { defaultValue: 'No tags yet. Create one below.' })} +
+ )} + {tags.map(tag => { + const id = tag.id + if (id == null) return null + + if (editingId === id) { + return ( +
+ setEditName(e.target.value)} className="mb-2 h-7" /> + +
+ + +
+
+ ) + } + + const isSel = value.includes(id) + const style = getTagColorStyle(tag.color) + return ( +
+ + + +
+ ) + })} +
+ +
+
{t('hostTags.create', { defaultValue: 'Create tag' })}
+
+ setNewName(e.target.value)} + placeholder={t('hostTags.namePlaceholder', { defaultValue: 'Tag name' })} + className="h-7" + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault() + handleCreate() + } + }} + /> + +
+
+ +
+
+
+
+
+ ) +} diff --git a/dashboard/src/features/hosts/components/hosts-list.tsx b/dashboard/src/features/hosts/components/hosts-list.tsx index aca4c571d..0559b99b8 100644 --- a/dashboard/src/features/hosts/components/hosts-list.tsx +++ b/dashboard/src/features/hosts/components/hosts-list.tsx @@ -19,6 +19,7 @@ import { toast } from 'sonner' import { Power, PowerOff, Trash2 } from 'lucide-react' import HostModal from '../dialogs/host-modal' import SortableHost from './sortable-host' +import { getTagColorStyle } from '@/constants/hostTagColors' import { BulkActionItem, BulkActionsBar } from '@/features/users/components/bulk-actions-bar' import { BulkActionAlertDialog } from '@/features/users/components/bulk-action-alert-dialog' import { Skeleton } from '@/components/ui/skeleton' @@ -180,6 +181,7 @@ export default function HostsList({ const formData: HostFormValues = { remark: host.remark || '', + tag_ids: host.tags?.map(tag => tag.id).filter((id): id is number => typeof id === 'number') ?? [], address: Array.isArray(host.address) ? host.address : host.address ? [host.address] : [], port: host.port ? Number(host.port) : undefined, inbound_tag: host.inbound_tag || '', @@ -367,6 +369,7 @@ export default function HostsList({ const newHost: CreateHost = { remark: `${host.remark || ''} (copy)`, + tag_ids: host.tags?.map(tag => tag.id).filter((id): id is number => typeof id === 'number') ?? [], address: host.address || [], port: host.port, inbound_tag: host.inbound_tag || '', @@ -570,6 +573,7 @@ export default function HostsList({ const hostsToUpdate: CreateHost[] = updatedHosts.map((host, index) => ({ id: host.id, remark: host.remark || '', + tag_ids: host.tags?.map(tag => tag.id).filter((id): id is number => typeof id === 'number') ?? [], address: host.address || [], port: host.port, inbound_tag: host.inbound_tag || '', @@ -922,6 +926,7 @@ export default function HostsList({ isLoading={isCurrentlyLoading} loadingRows={6} className="max-w-screen-[2000px] min-h-screen gap-4 overflow-hidden" + gridClassName="auto-rows-fr" enableSelection={canUpdate} injectSelectionProps={canUpdate} selectedRowIds={selectedHostIds} @@ -976,6 +981,10 @@ export default function HostsList({ isLoading={isCurrentlyLoading} loadingRows={6} className="max-w-screen-[2000px] min-h-screen gap-3 overflow-hidden" + rowClassName={host => { + const isSelected = typeof host.id === 'number' && selectedHostIds.includes(host.id) + return !isSelected && host.tags && host.tags.length > 0 ? getTagColorStyle(host.tags[0].color).tint : '' + }} enableSelection={canUpdate} selectedRowIds={selectedHostIds} onSelectionChange={ids => setSelectedHostIds(ids.map(id => Number(id)))} diff --git a/dashboard/src/features/hosts/components/sortable-host.tsx b/dashboard/src/features/hosts/components/sortable-host.tsx index a137e5e66..7c07da66e 100644 --- a/dashboard/src/features/hosts/components/sortable-host.tsx +++ b/dashboard/src/features/hosts/components/sortable-host.tsx @@ -7,7 +7,9 @@ import { ChevronsLeftRightEllipsis, CloudCog, GripVertical, Settings } from 'luc import { useTranslation } from 'react-i18next' import useDirDetection from '@/hooks/use-dir-detection' import { cn } from '@/lib/utils' +import { getTagColorStyle } from '@/constants/hostTagColors' import HostActionsMenu from './host-actions-menu' +import { TagChip } from './host-tag-picker' import type { ReactNode } from 'react' interface SortableHostProps { @@ -43,10 +45,13 @@ export default function SortableHost({ host, onEdit, onDuplicate, onDataChanged, } const cursor = isDragging ? 'grabbing' : 'grab' + const tags = host.tags ?? [] + const tint = tags.length > 0 ? getTagColorStyle(tags[0].color).tint : '' + return ( -
+
{ if (canUpdate) onEdit(host) }} @@ -79,6 +84,13 @@ export default function SortableHost({ host, onEdit, onDuplicate, onDataChanged, {t('inbound')}: {host.inbound_tag ?? ''}
+ {tags.length > 0 && ( +
+ {tags.map(tag => ( + + ))} +
+ )}
diff --git a/dashboard/src/features/hosts/components/use-hosts-list-columns.tsx b/dashboard/src/features/hosts/components/use-hosts-list-columns.tsx index 208e8ad93..6231a2b10 100644 --- a/dashboard/src/features/hosts/components/use-hosts-list-columns.tsx +++ b/dashboard/src/features/hosts/components/use-hosts-list-columns.tsx @@ -4,6 +4,7 @@ import { ListColumn } from '@/components/common/list-generator' import { BaseHost } from '@/service/api' import { cn } from '@/lib/utils' import HostActionsMenu from '@/features/hosts/components/host-actions-menu' +import { TagChip } from '@/features/hosts/components/host-tag-picker' import { Settings } from 'lucide-react' interface UseHostsListColumnsProps { @@ -48,6 +49,20 @@ export const useHostsListColumns = ({ onEdit, onDuplicate, onDataChanged, canUpd cell: host => {host.inbound_tag ?? ''}, hideOnMobile: true, }, + { + id: 'tags', + header: t('hostTags.label', { defaultValue: 'Tags' }), + width: '1.5fr', + cell: host => + host.tags && host.tags.length > 0 ? ( +
+ {host.tags.map(tag => ( + + ))} +
+ ) : null, + hideOnMobile: true, + }, ...(canUpdate || canCreate ? [ { diff --git a/dashboard/src/features/hosts/dialogs/host-modal.tsx b/dashboard/src/features/hosts/dialogs/host-modal.tsx index 833c94684..9fbdcff45 100644 --- a/dashboard/src/features/hosts/dialogs/host-modal.tsx +++ b/dashboard/src/features/hosts/dialogs/host-modal.tsx @@ -22,6 +22,7 @@ import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { UseFormReturn } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { hostFormDefaultValues, type HostFormValues } from '@/features/hosts/forms/host-form' +import HostTagPicker from '@/features/hosts/components/host-tag-picker' import { LoaderButton } from '@/components/ui/loader-button' interface HostModalProps { @@ -994,6 +995,20 @@ const HostModal: React.FC = ({ isDialogOpen, onOpenChange, onSub )} /> + ( + + {t('hostTags.label', { defaultValue: 'Tags' })} + + + + + + )} + /> +
) => + queryClient.invalidateQueries({ + predicate: query => + query.queryKey.some(part => typeof part === 'string' && part.toLowerCase().includes('host')), + }) + +export const getHostTags = () => fetcher('/api/host/tags', { method: 'GET' }) + +export const createHostTag = (data: HostTagCreate) => fetcher('/api/host/tags', { method: 'POST', body: data }) + +export const modifyHostTag = (tagId: number, data: HostTagModify) => + fetcher(`/api/host/tags/${tagId}`, { method: 'PUT', body: data }) + +export const removeHostTag = (tagId: number) => fetcher(`/api/host/tags/${tagId}`, { method: 'DELETE' }) + +export const useHostTags = (options?: Partial>) => + useQuery({ queryKey: hostTagsQueryKey, queryFn: getHostTags, ...options }) + +export const useCreateHostTag = (options?: UseMutationOptions) => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: createHostTag, + ...options, + onSuccess: (...args) => { + void invalidateHostAndTagQueries(queryClient) + options?.onSuccess?.(...args) + }, + }) +} + +export const useModifyHostTag = ( + options?: UseMutationOptions, +) => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ tagId, data }: { tagId: number; data: HostTagModify }) => modifyHostTag(tagId, data), + ...options, + onSuccess: (...args) => { + void invalidateHostAndTagQueries(queryClient) + options?.onSuccess?.(...args) + }, + }) +} + +export const useRemoveHostTag = (options?: UseMutationOptions) => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: removeHostTag, + ...options, + onSuccess: (...args) => { + void invalidateHostAndTagQueries(queryClient) + options?.onSuccess?.(...args) + }, + }) +} diff --git a/tests/api/test_host_tag.py b/tests/api/test_host_tag.py new file mode 100644 index 000000000..1d6a1379a --- /dev/null +++ b/tests/api/test_host_tag.py @@ -0,0 +1,356 @@ +"""API / e2e tests for host tags: CRUD, host assignment, and edge cases. + +Runs against the configured test DB (SQLite by default; can target Postgres/MySQL +via TEST_FROM/DATABASE_URL), so it also exercises the migration + M2M cascade. +""" + +from fastapi import status + +from tests.api import client +from tests.api.helpers import auth_headers, create_core, delete_core, get_inbounds, unique_name + +UNPROCESSABLE = 422 + + +def _create_tag(token, *, name=None, color="red"): + resp = client.post( + "/api/host/tags", + headers=auth_headers(token), + json={"name": name or unique_name("tag"), "color": color}, + ) + assert resp.status_code == status.HTTP_201_CREATED, resp.text + return resp.json() + + +def _delete_tag(token, tag_id): + client.delete(f"/api/host/tags/{tag_id}", headers=auth_headers(token)) + + +def _list_tags(token): + resp = client.get("/api/host/tags", headers=auth_headers(token)) + assert resp.status_code == status.HTTP_200_OK + return resp.json() + + +def _create_host(token, inbound, *, remark=None, tag_ids=None, priority=1): + payload = { + "remark": remark or unique_name("host"), + "address": ["127.0.0.1"], + "port": 443, + "inbound_tag": inbound, + "priority": priority, + } + if tag_ids is not None: + payload["tag_ids"] = tag_ids + return client.post("/api/host", headers=auth_headers(token), json=payload) + + +def _get_host(token, host_id): + return client.get(f"/api/host/{host_id}", headers=auth_headers(token)) + + +def _delete_host(token, host_id): + client.delete(f"/api/host/{host_id}", headers=auth_headers(token)) + + +def test_host_tag_crud(access_token): + created = _create_tag(access_token, color="violet") + tag_id = created["id"] + try: + assert isinstance(tag_id, int) + assert created["color"] == "violet" + assert any(t["id"] == tag_id for t in _list_tags(access_token)) + + resp = client.put( + f"/api/host/tags/{tag_id}", + headers=auth_headers(access_token), + json={"name": created["name"] + "-x", "color": "green"}, + ) + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["color"] == "green" + assert resp.json()["name"].endswith("-x") + finally: + resp = client.delete(f"/api/host/tags/{tag_id}", headers=auth_headers(access_token)) + assert resp.status_code == status.HTTP_204_NO_CONTENT + assert not any(t["id"] == tag_id for t in _list_tags(access_token)) + + +def test_host_tag_duplicate_name_conflicts(access_token): + tag = _create_tag(access_token) + try: + resp = client.post( + "/api/host/tags", + headers=auth_headers(access_token), + json={"name": tag["name"], "color": "blue"}, + ) + assert resp.status_code == status.HTTP_409_CONFLICT + finally: + _delete_tag(access_token, tag["id"]) + + +def test_host_tag_invalid_color_rejected(access_token): + resp = client.post( + "/api/host/tags", headers=auth_headers(access_token), json={"name": unique_name("t"), "color": "neon"} + ) + assert resp.status_code == UNPROCESSABLE + + +def test_host_tag_blank_name_rejected(access_token): + resp = client.post("/api/host/tags", headers=auth_headers(access_token), json={"name": " ", "color": "red"}) + assert resp.status_code == UNPROCESSABLE + + +def test_host_tag_modify_missing_is_404(access_token): + resp = client.put("/api/host/tags/99999999", headers=auth_headers(access_token), json={"color": "sky"}) + assert resp.status_code == status.HTTP_404_NOT_FOUND + + +def test_host_tag_delete_missing_is_404(access_token): + resp = client.delete("/api/host/tags/99999999", headers=auth_headers(access_token)) + assert resp.status_code == status.HTTP_404_NOT_FOUND + + +def test_host_tag_rename_to_existing_conflicts(access_token): + a = _create_tag(access_token) + b = _create_tag(access_token) + try: + resp = client.put(f"/api/host/tags/{b['id']}", headers=auth_headers(access_token), json={"name": a["name"]}) + assert resp.status_code == status.HTTP_409_CONFLICT + finally: + _delete_tag(access_token, a["id"]) + _delete_tag(access_token, b["id"]) + + +def test_host_create_with_tags(access_token): + core = create_core(access_token) + inbound = get_inbounds(access_token)[0] + t1 = _create_tag(access_token, color="red") + t2 = _create_tag(access_token, color="amber") + host_id = None + try: + resp = _create_host(access_token, inbound, tag_ids=[t1["id"], t2["id"]]) + assert resp.status_code == status.HTTP_201_CREATED, resp.text + body = resp.json() + host_id = body["id"] + assert body["tag_ids"] == [t1["id"], t2["id"]] + assert [t["id"] for t in body["tags"]] == [t1["id"], t2["id"]] + assert [t["color"] for t in body["tags"]] == ["red", "amber"] + + got = _get_host(access_token, host_id).json() + assert {t["id"] for t in got["tags"]} == {t1["id"], t2["id"]} + finally: + if host_id: + _delete_host(access_token, host_id) + _delete_tag(access_token, t1["id"]) + _delete_tag(access_token, t2["id"]) + delete_core(access_token, core["id"]) + + +def test_host_create_with_invalid_tag_id_is_404(access_token): + core = create_core(access_token) + inbound = get_inbounds(access_token)[0] + try: + resp = _create_host(access_token, inbound, tag_ids=[99999999]) + assert resp.status_code == status.HTTP_404_NOT_FOUND + finally: + delete_core(access_token, core["id"]) + + +def test_host_create_dedups_repeated_tag_ids(access_token): + core = create_core(access_token) + inbound = get_inbounds(access_token)[0] + tag = _create_tag(access_token) + host_id = None + try: + resp = _create_host(access_token, inbound, tag_ids=[tag["id"], tag["id"], tag["id"]]) + assert resp.status_code == status.HTTP_201_CREATED, resp.text + host_id = resp.json()["id"] + assert resp.json()["tag_ids"] == [tag["id"]] + assert len(resp.json()["tags"]) == 1 + finally: + if host_id: + _delete_host(access_token, host_id) + _delete_tag(access_token, tag["id"]) + delete_core(access_token, core["id"]) + + +def test_host_modify_tags(access_token): + core = create_core(access_token) + inbound = get_inbounds(access_token)[0] + t1 = _create_tag(access_token) + t2 = _create_tag(access_token) + host_id = None + try: + host_id = _create_host(access_token, inbound, tag_ids=[t1["id"]]).json()["id"] + + resp = client.put( + f"/api/host/{host_id}", + headers=auth_headers(access_token), + json={ + "remark": unique_name("h"), + "address": ["127.0.0.1"], + "port": 443, + "inbound_tag": inbound, + "priority": 1, + "tag_ids": [t2["id"]], + }, + ) + assert resp.status_code == status.HTTP_200_OK, resp.text + assert resp.json()["tag_ids"] == [t2["id"]] + + resp = client.put( + f"/api/host/{host_id}", + headers=auth_headers(access_token), + json={ + "remark": unique_name("h"), + "address": ["127.0.0.1"], + "port": 443, + "inbound_tag": inbound, + "priority": 1, + "tag_ids": [], + }, + ) + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["tags"] == [] + finally: + if host_id: + _delete_host(access_token, host_id) + _delete_tag(access_token, t1["id"]) + _delete_tag(access_token, t2["id"]) + delete_core(access_token, core["id"]) + + +def test_delete_tag_detaches_from_host(access_token): + """Deleting a tag must remove it from hosts (M2M cascade), not error or orphan.""" + core = create_core(access_token) + inbound = get_inbounds(access_token)[0] + tag = _create_tag(access_token) + host_id = _create_host(access_token, inbound, tag_ids=[tag["id"]]).json()["id"] + try: + resp = client.delete(f"/api/host/tags/{tag['id']}", headers=auth_headers(access_token)) + assert resp.status_code == status.HTTP_204_NO_CONTENT + + got = _get_host(access_token, host_id) + assert got.status_code == status.HTTP_200_OK + assert got.json()["tags"] == [] + assert got.json()["tag_ids"] == [] + finally: + _delete_host(access_token, host_id) + delete_core(access_token, core["id"]) + + +def test_delete_host_keeps_tags(access_token): + """Deleting a host must not delete the (reusable) tags.""" + core = create_core(access_token) + inbound = get_inbounds(access_token)[0] + tag = _create_tag(access_token) + host_id = _create_host(access_token, inbound, tag_ids=[tag["id"]]).json()["id"] + try: + _delete_host(access_token, host_id) + assert any(t["id"] == tag["id"] for t in _list_tags(access_token)) + finally: + _delete_tag(access_token, tag["id"]) + delete_core(access_token, core["id"]) + + +def test_clone_host_carries_tags(access_token): + """Mirrors the dashboard 'duplicate host' flow: a new host with the same tag_ids, + sharing the same (reusable) tag entities.""" + core = create_core(access_token) + inbound = get_inbounds(access_token)[0] + t1 = _create_tag(access_token) + t2 = _create_tag(access_token) + ids = [] + try: + original = _create_host(access_token, inbound, tag_ids=[t1["id"], t2["id"]]).json() + ids.append(original["id"]) + + clone = _create_host( + access_token, inbound, remark=original["remark"] + " (copy)", tag_ids=original["tag_ids"] + ).json() + ids.append(clone["id"]) + + assert {t["id"] for t in clone["tags"]} == {t1["id"], t2["id"]} + assert {t["id"] for t in original["tags"]} == {t["id"] for t in clone["tags"]} + finally: + for host_id in ids: + _delete_host(access_token, host_id) + _delete_tag(access_token, t1["id"]) + _delete_tag(access_token, t2["id"]) + delete_core(access_token, core["id"]) + + +def test_bulk_modify_hosts_preserves_tags(access_token): + """PUT /api/hosts (the drag-reorder path) must keep each host's tags.""" + core = create_core(access_token) + inbound = get_inbounds(access_token)[0] + tag = _create_tag(access_token) + host_id = None + try: + host = _create_host(access_token, inbound, tag_ids=[tag["id"]]).json() + host_id = host["id"] + + resp = client.put( + "/api/hosts", + headers=auth_headers(access_token), + json=[ + { + "id": host_id, + "remark": host["remark"], + "address": ["127.0.0.1"], + "port": 443, + "inbound_tag": inbound, + "priority": 7, + "tag_ids": host["tag_ids"], + } + ], + ) + assert resp.status_code == status.HTTP_200_OK, resp.text + + updated = _get_host(access_token, host_id).json() + assert updated["tag_ids"] == [tag["id"]] + finally: + if host_id: + _delete_host(access_token, host_id) + _delete_tag(access_token, tag["id"]) + delete_core(access_token, core["id"]) + + +def test_bulk_delete_tagged_host_keeps_tag(access_token): + """Bulk delete (a core DELETE) must remove the host + its tag links but keep the tag.""" + core = create_core(access_token) + inbound = get_inbounds(access_token)[0] + tag = _create_tag(access_token) + try: + host_id = _create_host(access_token, inbound, tag_ids=[tag["id"]]).json()["id"] + + resp = client.post("/api/hosts/bulk/delete", headers=auth_headers(access_token), json={"ids": [host_id]}) + assert resp.status_code == status.HTTP_200_OK, resp.text + + assert _get_host(access_token, host_id).status_code == status.HTTP_404_NOT_FOUND + assert any(t["id"] == tag["id"] for t in _list_tags(access_token)) + finally: + _delete_tag(access_token, tag["id"]) + delete_core(access_token, core["id"]) + + +def test_bulk_disable_enable_tagged_host(access_token): + """Bulk disable/enable serializes each host (refresh + model_validate reads .tags) — must not + error on a tagged host, and must not drop the tag.""" + core = create_core(access_token) + inbound = get_inbounds(access_token)[0] + tag = _create_tag(access_token) + host_id = None + try: + host_id = _create_host(access_token, inbound, tag_ids=[tag["id"]]).json()["id"] + + for path in ("/api/hosts/bulk/disable", "/api/hosts/bulk/enable"): + resp = client.post(path, headers=auth_headers(access_token), json={"ids": [host_id]}) + assert resp.status_code == status.HTTP_200_OK, f"{path}: {resp.text}" + + assert _get_host(access_token, host_id).json()["tag_ids"] == [tag["id"]] + finally: + if host_id: + _delete_host(access_token, host_id) + _delete_tag(access_token, tag["id"]) + delete_core(access_token, core["id"]) diff --git a/tests/test_host_tags_unit.py b/tests/test_host_tags_unit.py new file mode 100644 index 000000000..dd322b9e1 --- /dev/null +++ b/tests/test_host_tags_unit.py @@ -0,0 +1,110 @@ +"""Unit tests for host-tag Pydantic schemas (no DB required).""" + +import pytest +from pydantic import ValidationError + +from app.models.host import BaseHost, HostTag, HostTagColor, HostTagCreate, HostTagModify + + +def test_host_tag_color_enum_values(): + assert HostTagColor("red") is HostTagColor.red + assert {c.value for c in HostTagColor} == { + "slate", + "red", + "orange", + "amber", + "green", + "teal", + "sky", + "blue", + "violet", + "pink", + } + + +def test_host_tag_color_rejects_unknown(): + with pytest.raises(ValueError): + HostTagColor("neon") + + +def test_host_tag_create_strips_name(): + tag = HostTagCreate(name=" Germany ", color="red") + assert tag.name == "Germany" + assert tag.color is HostTagColor.red + + +def test_host_tag_create_rejects_blank_name(): + with pytest.raises(ValidationError): + HostTagCreate(name=" ", color="red") + + +def test_host_tag_create_rejects_empty_name(): + with pytest.raises(ValidationError): + HostTagCreate(name="", color="red") + + +def test_host_tag_create_rejects_too_long_name(): + with pytest.raises(ValidationError): + HostTagCreate(name="x" * 65, color="red") + + +def test_host_tag_create_rejects_bad_color(): + with pytest.raises(ValidationError): + HostTagCreate(name="ok", color="rainbow") + + +def test_host_tag_modify_all_optional(): + empty = HostTagModify() + assert empty.name is None and empty.color is None + + color_only = HostTagModify(color="blue") + assert color_only.name is None + assert color_only.color is HostTagColor.blue + + +def test_host_tag_modify_strips_name(): + modified = HostTagModify(name=" Prod ") + assert modified.name == "Prod" + + +def test_host_tag_modify_rejects_blank_name(): + with pytest.raises(ValidationError): + HostTagModify(name=" ") + + +def test_base_host_carries_tags_and_tag_ids(): + host = BaseHost( + remark="h1", + priority=0, + tags=[HostTag(id=1, name="Germany", color="red"), HostTag(id=2, name="Premium", color="amber")], + tag_ids=[1, 2], + ) + assert [t.name for t in host.tags] == ["Germany", "Premium"] + assert host.tag_ids == [1, 2] + + +def test_base_host_defaults_tags_empty(): + host = BaseHost(remark="h1", priority=0) + assert host.tags == [] + assert host.tag_ids == [] + + +def test_base_host_reads_tags_from_orm_like_object(): + """from_attributes should pull tags + the tag_ids property off an ORM-like object.""" + + class FakeTag: + def __init__(self, id, name, color): + self.id, self.name, self.color = id, name, color + + class FakeHost: + remark = "orm-host" + priority = 3 + tags = [FakeTag(1, "Germany", "red")] + + @property + def tag_ids(self): + return [t.id for t in self.tags] + + host = BaseHost.model_validate(FakeHost()) + assert host.tags[0].color is HostTagColor.red + assert host.tag_ids == [1]