diff --git a/apps/control-panel-app/src/constants/success.ts b/apps/control-panel-app/src/constants/success.ts index 154a27b..585eb1b 100644 --- a/apps/control-panel-app/src/constants/success.ts +++ b/apps/control-panel-app/src/constants/success.ts @@ -45,4 +45,9 @@ export const SUCCESS_MESSAGES = { LIST: "MCP API keys fetched successfully", REVOKED: "MCP API key revoked successfully", }, + + TEMPLATE: { + LIST: "Templates fetched successfully", + CATEGORIES: "Template categories fetched successfully", + }, }; diff --git a/apps/control-panel-app/src/modules/service-template/constants/template-list.constants.ts b/apps/control-panel-app/src/modules/service-template/constants/template-list.constants.ts new file mode 100644 index 0000000..cab6353 --- /dev/null +++ b/apps/control-panel-app/src/modules/service-template/constants/template-list.constants.ts @@ -0,0 +1,3 @@ +export const DEFAULT_TEMPLATE_LIST_PAGE = 1; +export const DEFAULT_TEMPLATE_LIST_LIMIT = 12; +export const MAX_TEMPLATE_LIST_LIMIT = 100; diff --git a/apps/control-panel-app/src/modules/service-template/controllers/service-template.controller.ts b/apps/control-panel-app/src/modules/service-template/controllers/service-template.controller.ts index 95c81ad..adfd027 100644 --- a/apps/control-panel-app/src/modules/service-template/controllers/service-template.controller.ts +++ b/apps/control-panel-app/src/modules/service-template/controllers/service-template.controller.ts @@ -8,6 +8,11 @@ import { } from "@nestjs/common"; import type { Response } from "express"; +import { PaginatedResponse } from "@shared/common"; +import { ServiceResponse } from "@control-panel/common/interfaces/success-response.interface"; + +import { ListTemplatesQueryDto } from "../dto/list-templates-query.dto"; +import type { TemplateListItemDto } from "../dto/template-marketplace.dto"; import { ServiceTemplateService } from "../services/service-template.service"; @Controller("templates") @@ -16,11 +21,27 @@ export class ServiceTemplateController { private readonly serviceTemplateService: ServiceTemplateService, ) {} + /** + * Lists templates with pagination. + */ @Get() - listTemplates() { - return this.serviceTemplateService.listTemplates(); + listTemplates( + @Query() query: ListTemplatesQueryDto, + ): Promise>> { + return this.serviceTemplateService.listTemplatesPaginated(query); + } + + /** + * Lists unique template categories. + */ + @Get("categories") + listCategories(): Promise> { + return this.serviceTemplateService.listTemplateCategories(); } + /** + * Gets the template by slug and format. + */ @Get(":slug") async getTemplate( @Param("slug") slug: string, diff --git a/apps/control-panel-app/src/modules/service-template/dto/list-templates-query.dto.ts b/apps/control-panel-app/src/modules/service-template/dto/list-templates-query.dto.ts new file mode 100644 index 0000000..efebf6f --- /dev/null +++ b/apps/control-panel-app/src/modules/service-template/dto/list-templates-query.dto.ts @@ -0,0 +1,37 @@ +import { Transform, Type } from "class-transformer"; +import { IsInt, IsOptional, IsString, Max, Min } from "class-validator"; + +import { + DEFAULT_TEMPLATE_LIST_LIMIT, + DEFAULT_TEMPLATE_LIST_PAGE, + MAX_TEMPLATE_LIST_LIMIT, +} from "../constants/template-list.constants"; + +export class ListTemplatesQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page: number = DEFAULT_TEMPLATE_LIST_PAGE; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(MAX_TEMPLATE_LIST_LIMIT) + limit: number = DEFAULT_TEMPLATE_LIST_LIMIT; + + @IsOptional() + @IsString() + @Transform(({ value }: { value: unknown }) => + typeof value === "string" ? value.trim() : value, + ) + search?: string; + + @IsOptional() + @IsString() + @Transform(({ value }: { value: unknown }) => + typeof value === "string" ? value.trim() : value, + ) + category?: string; +} diff --git a/apps/control-panel-app/src/modules/service-template/services/service-template.service.ts b/apps/control-panel-app/src/modules/service-template/services/service-template.service.ts index 865d67a..292da11 100644 --- a/apps/control-panel-app/src/modules/service-template/services/service-template.service.ts +++ b/apps/control-panel-app/src/modules/service-template/services/service-template.service.ts @@ -5,7 +5,13 @@ import { NotFoundException, } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { + ArrayContains, + ArrayOverlap, + FindOptionsWhere, + ILike, + Repository, +} from "typeorm"; import { getTemplateDescriptionFromComments, getTemplateLongDescriptionFromComments, @@ -15,7 +21,16 @@ import { } from "@shared/common"; import * as yaml from "js-yaml"; +import { SUCCESS_MESSAGES } from "@control-panel/constants/success"; +import { ServiceResponse } from "@control-panel/common/interfaces/success-response.interface"; +import { PaginatedResponse } from "@shared/common"; + import { ServiceTemplateEntity } from "../entities/service-template.entity"; +import { + DEFAULT_TEMPLATE_LIST_LIMIT, + DEFAULT_TEMPLATE_LIST_PAGE, +} from "../constants/template-list.constants"; +import { ListTemplatesQueryDto } from "../dto/list-templates-query.dto"; import { PUBLIC_TEMPLATE_DETAIL_FIELDS, PUBLIC_TEMPLATE_LIST_FIELDS, @@ -54,6 +69,9 @@ export class ServiceTemplateService { private readonly templatePayloadService: TemplatePayloadService, ) {} + /** + * Gets the template by slug and format. + */ async getTemplate( slug: string, format: string = "yml", @@ -134,6 +152,101 @@ export class ServiceTemplateService { return this.listTemplates(PUBLIC_TEMPLATE_LIST_FIELDS, { category }); } + /** + * Lists templates with pagination. + */ + async listTemplatesPaginated( + query: ListTemplatesQueryDto, + ): Promise>> { + const page = query.page ?? DEFAULT_TEMPLATE_LIST_PAGE; + const limit = query.limit ?? DEFAULT_TEMPLATE_LIST_LIMIT; + const skip = (page - 1) * limit; + + if (query.category !== undefined && query.category.trim() === "") { + throw new BadRequestException("category query parameter cannot be empty"); + } + + const baseWhere: FindOptionsWhere = { + isActive: true, + }; + + if (query.category?.trim()) { + baseWhere.category = ArrayContains([query.category.trim().toLowerCase()]); + } + + let where: + | FindOptionsWhere + | FindOptionsWhere[]; + + if (query.search?.trim()) { + const searchTerm = query.search.trim(); + const search = ILike(`%${searchTerm}%`); + + where = [ + { ...baseWhere, name: search }, + { ...baseWhere, slug: search }, + { ...baseWhere, shortDescription: search }, + { ...baseWhere, tags: ArrayOverlap([searchTerm]) }, + ]; + } else { + where = baseWhere; + } + + try { + const [templates, total] = + await this.serviceTemplateRepository.findAndCount({ + where, + order: { name: "ASC" }, + skip, + take: limit, + }); + const totalPages = total === 0 ? 0 : Math.ceil(total / limit); + + return { + message: SUCCESS_MESSAGES.TEMPLATE.LIST, + data: { + data: templates.map((template) => this.toTemplateListItem(template)), + pagination: { + page, + limit, + total, + totalPages, + }, + }, + }; + } catch (error) { + if (error instanceof BadRequestException) { + throw error; + } + throw new InternalServerErrorException( + `Failed to list templates: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Lists unique template categories. + */ + async listTemplateCategories(): Promise> { + try { + const categories = await this.listUniqueCategories(); + return { + message: SUCCESS_MESSAGES.TEMPLATE.CATEGORIES, + data: categories, + }; + } catch (error) { + if (error instanceof BadRequestException) { + throw error; + } + throw new InternalServerErrorException( + `Failed to list template categories: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Lists unique template categories. + */ async listUniqueCategories(): Promise { const templates = await this.listTemplates(["category"]); const categories = new Set(); @@ -150,6 +263,9 @@ export class ServiceTemplateService { return Array.from(categories).sort((a, b) => a.localeCompare(b)); } + /** + * Gets the template details for the public template. + */ async getPublicTemplateDetails( slug: string, ): Promise { @@ -191,6 +307,9 @@ export class ServiceTemplateService { } } + /** + * Gets the template details. + */ async getTemplateDetails(slug: string): Promise { try { const template = await this.getTemplateEntity(slug); diff --git a/console-app/src/components/deployment-logs.css b/console-app/src/components/deployment-logs.css index 7e60cc6..12dc9ee 100644 --- a/console-app/src/components/deployment-logs.css +++ b/console-app/src/components/deployment-logs.css @@ -686,6 +686,7 @@ .deploy-logs-page .deploy-terminal-xterm-host .xterm-viewport { overflow-y: auto !important; width: 100% !important; + overscroll-behavior: contain; } .deploy-logs-page .deploy-terminal-loading { diff --git a/console-app/src/components/deployment-logs.tsx b/console-app/src/components/deployment-logs.tsx index f266c1f..5d3b7eb 100644 --- a/console-app/src/components/deployment-logs.tsx +++ b/console-app/src/components/deployment-logs.tsx @@ -1,12 +1,6 @@ import { BackLink } from "@/components/shared/back-link"; import { ServiceBrandIcon } from "@/components/shared/service-brand-icon"; -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { DeploymentTerminalViewer } from "@/components/deployment-terminal-viewer"; import "@/components/shared/kubeara-terminal-shell.css"; import { @@ -168,12 +162,17 @@ export function DeploymentLogs({ const [logView, setLogView] = useState("installation"); const deploymentQuery = useDeploymentQuery(deploymentId); - const { logs, status, deploymentStatus, hasReceivedStatus, isSocketConnected } = - useDeploymentLogStream({ - deploymentId, - serverId, - enabled: Boolean(serverId && deploymentId), - }); + const { + logs, + status, + deploymentStatus, + hasReceivedStatus, + isSocketConnected, + } = useDeploymentLogStream({ + deploymentId, + serverId, + enabled: Boolean(serverId && deploymentId), + }); const liveDeploymentStatus = hasReceivedStatus && deploymentStatus @@ -310,7 +309,11 @@ export function DeploymentLogs({ : null) ?? deploymentQuery.data?.statusMessage ?? resolvedStatus ?? - deploymentStateLabel(status, isStarting, liveDeploymentStatus)} + deploymentStateLabel( + status, + isStarting, + liveDeploymentStatus, + )} @@ -339,7 +342,10 @@ export function DeploymentLogs({
{isStreaming && filteredLineCount === 0 && ( -
+
(null); + const frameRef = useRef(null); const termRef = useRef(null); const fitRef = useRef(null); const writtenCountRef = useRef(0); @@ -146,6 +148,8 @@ export function DeploymentTerminalViewer({ const { visible: showScrollDown, handleClick: handleScrollDown } = useTerminalScrollDown(hostRef, scrollToBottom); + useTerminalWheelTrap(frameRef); + return (
{emptyMessage}
)} -
+
Clear diff --git a/console-app/src/components/shared/dropdown.css b/console-app/src/components/shared/dropdown.css index 63e3ba0..edf150f 100644 --- a/console-app/src/components/shared/dropdown.css +++ b/console-app/src/components/shared/dropdown.css @@ -24,7 +24,9 @@ font-size: 0.9375rem; text-align: left; cursor: pointer; - transition: border-color 0.15s, box-shadow 0.15s; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; } .dropdown-trigger:hover:not(:disabled) { @@ -37,7 +39,8 @@ box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent); } -.dropdown.is-disabled .dropdown-trigger { +.dropdown.is-disabled .dropdown-trigger, +.dropdown.is-disabled .dropdown-trigger--combobox { opacity: 0.6; cursor: not-allowed; } @@ -50,10 +53,84 @@ white-space: nowrap; } -.dropdown-chevron { +.dropdown-trigger--combobox { + padding: 0; + gap: 0; + cursor: default; + overflow: hidden; + background: var(--surface); +} + +.dropdown-trigger--combobox:hover:not(:has(input:read-only)) { + border-color: color-mix(in srgb, var(--primary) 50%, var(--border)); +} + +.dropdown-trigger--combobox:has(input:read-only):hover { + border-color: color-mix(in srgb, var(--primary) 35%, var(--border)); +} + +.dropdown-trigger--combobox.is-open, +.dropdown-trigger--combobox:focus-within { + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent); +} + +.dropdown-combobox-input { + flex: 1; + min-width: 0; + width: 100%; + padding: 0.625rem 0.5rem 0.625rem 0.75rem; + border: none; + background: transparent; + color: var(--foreground); + font-size: 0.875rem; + line-height: 1.4; +} + +.dropdown-combobox-input:focus { + outline: none; +} + +.dropdown-combobox-input:read-only { + cursor: pointer; + text-overflow: ellipsis; +} + +.dropdown-combobox-input::placeholder { + color: var(--muted); +} + +.dropdown-combobox-toggle { + display: inline-flex; + align-items: center; + justify-content: center; flex-shrink: 0; + align-self: stretch; + width: 2.375rem; + padding: 0; + border: none; + border-left: 1px solid var(--border); + background: color-mix(in srgb, var(--foreground) 3%, var(--surface)); color: var(--muted); - transition: transform 0.15s; + cursor: pointer; + transition: + background 0.15s ease, + color 0.15s ease; +} + +.dropdown-combobox-toggle:hover:not(:disabled) { + background: color-mix(in srgb, var(--primary) 8%, var(--surface)); + color: var(--primary); +} + +.dropdown-combobox-toggle:disabled { + cursor: not-allowed; +} + +.dropdown-chevron { + flex-shrink: 0; + color: inherit; + transition: transform 0.15s ease; } .dropdown-chevron.is-open { @@ -67,14 +144,48 @@ left: 0; right: 0; margin: 0; - padding: 0.25rem; + padding: 0.375rem; list-style: none; background: var(--surface); border: 1px solid var(--border); - border-radius: 8px; - box-shadow: var(--shadow); - max-height: 240px; + border-radius: 10px; + box-shadow: + var(--shadow), + 0 10px 28px color-mix(in srgb, var(--foreground) 8%, transparent); + max-height: 220px; overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--foreground) 18%, transparent) transparent; +} + +.dropdown-menu::-webkit-scrollbar { + width: 6px; +} + +.dropdown-menu::-webkit-scrollbar-track { + margin: 0.25rem 0; + background: transparent; +} + +.dropdown-menu::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--foreground) 16%, transparent); + border-radius: 999px; + border: 2px solid transparent; + background-clip: padding-box; +} + +.dropdown-menu::-webkit-scrollbar-thumb:hover { + background: color-mix(in srgb, var(--foreground) 28%, transparent); + background-clip: padding-box; +} + +.dropdown-empty { + padding: 0.75rem 0.625rem; + font-size: 0.8125rem; + color: var(--muted); + text-align: center; } .dropdown-option { @@ -82,12 +193,13 @@ width: 100%; padding: 0.5rem 0.625rem; border: none; - border-radius: 6px; + border-radius: 7px; background: transparent; color: var(--foreground); - font-size: 0.9375rem; + font-size: 0.875rem; text-align: left; cursor: pointer; + transition: background 0.12s ease, color 0.12s ease; } .dropdown-option:hover { @@ -99,3 +211,7 @@ color: var(--primary); font-weight: 600; } + +.dropdown-option.is-selected:hover { + background: color-mix(in srgb, var(--primary) 18%, transparent); +} diff --git a/console-app/src/components/shared/dropdown.tsx b/console-app/src/components/shared/dropdown.tsx index 068057c..b6d458b 100644 --- a/console-app/src/components/shared/dropdown.tsx +++ b/console-app/src/components/shared/dropdown.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useRef, useState } from "react"; +import { useEffect, useId, useMemo, useRef, useState } from "react"; import "./dropdown.css"; export type DropdownOption = { @@ -15,8 +15,34 @@ type DropdownProps = { disabled?: boolean; ariaLabel?: string; className?: string; + searchable?: boolean; + searchPlaceholder?: string; + noResultsLabel?: string; + /** When set, this option value always stays visible while filtering. */ + pinnedOptionValue?: T; }; +function ChevronIcon({ open }: { open: boolean }) { + return ( + + + + ); +} + export function Dropdown({ id: idProp, label, @@ -26,14 +52,49 @@ export function Dropdown({ disabled, ariaLabel, className, + searchable = false, + searchPlaceholder = "Search…", + noResultsLabel = "No results found", + pinnedOptionValue, }: DropdownProps) { const autoId = useId(); const id = idProp ?? autoId; const listboxId = `${id}-listbox`; const [open, setOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); const rootRef = useRef(null); + const comboboxInputRef = useRef(null); + + const selected = + options.find((option) => option.value === value) ?? options[0]; + + const visibleOptions = useMemo(() => { + if (!searchable) { + return options; + } - const selected = options.find((o) => o.value === value) ?? options[0]; + const query = searchQuery.trim().toLowerCase(); + if (!query) { + return options; + } + + return options.filter((option) => { + if ( + pinnedOptionValue !== undefined && + option.value === pinnedOptionValue + ) { + return true; + } + + return option.label.toLowerCase().includes(query); + }); + }, [options, pinnedOptionValue, searchQuery, searchable]); + + useEffect(() => { + if (!open) { + setSearchQuery(""); + } + }, [open]); useEffect(() => { if (!open) return; @@ -47,6 +108,7 @@ export function Dropdown({ function handleEscape(event: KeyboardEvent) { if (event.key === "Escape") { setOpen(false); + comboboxInputRef.current?.blur(); } } @@ -58,71 +120,158 @@ export function Dropdown({ }; }, [open]); + function openCombobox() { + if (disabled || open) return; + + setOpen(true); + setSearchQuery(""); + requestAnimationFrame(() => { + const input = comboboxInputRef.current; + input?.focus(); + input?.select(); + }); + } + + function toggleCombobox() { + if (disabled) return; + + if (open) { + setOpen(false); + comboboxInputRef.current?.blur(); + return; + } + + openCombobox(); + } + function selectOption(next: T) { onChange(next); setOpen(false); + setSearchQuery(""); + } + + function renderOptions(optionList: DropdownOption[]) { + if (optionList.length === 0) { + return ( +
  • + {noResultsLabel} +
  • + ); + } + + return optionList.map((option) => ( +
  • + +
  • + )); } return (
    {label && ( )} - - {open && ( -
      - {options.map((option) => ( -
    • - -
    • - ))} -
    + + {searchable ? ( + <> +
    + { + setSearchQuery(event.target.value); + if (!open) { + setOpen(true); + } + }} + onKeyDown={(event) => { + if (event.key === "ArrowDown" && !open) { + event.preventDefault(); + openCombobox(); + } + }} + /> + +
    + {open && ( +
      + {renderOptions(visibleOptions)} +
    + )} + + ) : ( + <> + + {open && ( +
      + {renderOptions(options)} +
    + )} + )}
    ); diff --git a/console-app/src/components/shared/kubeara-terminal-shell.css b/console-app/src/components/shared/kubeara-terminal-shell.css index 45c82df..5b54bad 100644 --- a/console-app/src/components/shared/kubeara-terminal-shell.css +++ b/console-app/src/components/shared/kubeara-terminal-shell.css @@ -73,7 +73,11 @@ } .server-terminal-intro .server-detail-section-title { - margin-bottom: 0; + margin: 0 0 0.25rem; +} + +.server-terminal-intro .server-detail-section-desc { + margin: 0; } .server-terminal-session-host { @@ -465,6 +469,7 @@ .server-terminal-xterm-host .xterm-viewport { scrollbar-color: rgb(255 153 0 / 45%) transparent; + overscroll-behavior: contain; } @media (max-width: 720px) { diff --git a/console-app/src/components/shared/terminal-scroll-down-button.css b/console-app/src/components/shared/terminal-scroll-down-button.css index 7491e69..d09fed6 100644 --- a/console-app/src/components/shared/terminal-scroll-down-button.css +++ b/console-app/src/components/shared/terminal-scroll-down-button.css @@ -5,11 +5,17 @@ min-height: inherit; display: flex; flex-direction: column; + overscroll-behavior: contain; } .terminal-viewer-frame .server-terminal-xterm-host { flex: 1; min-height: 0; + overscroll-behavior: contain; +} + +.terminal-viewer-frame .xterm-viewport { + overscroll-behavior: contain; } .terminal-scroll-down-anchor { diff --git a/console-app/src/components/shared/use-terminal-wheel-trap.ts b/console-app/src/components/shared/use-terminal-wheel-trap.ts new file mode 100644 index 0000000..98de3a3 --- /dev/null +++ b/console-app/src/components/shared/use-terminal-wheel-trap.ts @@ -0,0 +1,82 @@ +import { useEffect, type RefObject } from "react"; + +function getViewport(host: HTMLElement): HTMLElement | null { + return host.querySelector(".xterm-viewport"); +} + +function attachWheelTrap(container: HTMLElement): () => void { + const viewport = getViewport(container); + if (!viewport) { + return () => undefined; + } + + const stopPageScroll = (event: WheelEvent) => { + event.stopPropagation(); + }; + + const trapBoundaryScroll = (event: WheelEvent) => { + const { scrollTop, scrollHeight, clientHeight } = viewport; + const maxScrollTop = Math.max(0, scrollHeight - clientHeight); + + if (maxScrollTop <= 0) { + event.preventDefault(); + return; + } + + const atTop = scrollTop <= 0; + const atBottom = scrollTop >= maxScrollTop - 1; + + if ((event.deltaY < 0 && atTop) || (event.deltaY > 0 && atBottom)) { + event.preventDefault(); + } + }; + + container.addEventListener("wheel", stopPageScroll, { passive: true }); + viewport.addEventListener("wheel", trapBoundaryScroll, { passive: false }); + + return () => { + container.removeEventListener("wheel", stopPageScroll); + viewport.removeEventListener("wheel", trapBoundaryScroll); + }; +} + +/** + * Keeps mouse wheel scrolling inside an xterm terminal so the page does not scroll + * when the terminal buffer reaches the top or bottom. + */ +export function useTerminalWheelTrap( + containerRef: RefObject, +) { + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + let cleanup: (() => void) | undefined; + + const tryAttach = () => { + if (!getViewport(container)) { + return false; + } + + cleanup?.(); + cleanup = attachWheelTrap(container); + return true; + }; + + if (!tryAttach()) { + const observer = new MutationObserver(() => { + if (tryAttach()) { + observer.disconnect(); + } + }); + observer.observe(container, { childList: true, subtree: true }); + + return () => { + observer.disconnect(); + cleanup?.(); + }; + } + + return () => cleanup?.(); + }, [containerRef]); +} diff --git a/console-app/src/constants/query-keys.ts b/console-app/src/constants/query-keys.ts index 5587a52..e8b561e 100644 --- a/console-app/src/constants/query-keys.ts +++ b/console-app/src/constants/query-keys.ts @@ -59,10 +59,11 @@ export const QUERY_KEYS = { */ templates: { all: ["templates"] as const, - list: (serverId?: string) => + categories: () => ["templates", "categories"] as const, + list: (serverId?: string, params?: Record) => serverId - ? (["templates", "list", serverId] as const) - : (["templates", "list"] as const), + ? (["templates", "list", serverId, params] as const) + : (["templates", "list", params] as const), detail: (slug: string) => ["templates", slug] as const, }, diff --git a/console-app/src/features/deployments/hooks/use-deployment-log-stream.ts b/console-app/src/features/deployments/hooks/use-deployment-log-stream.ts index e071fdf..0c0ba7a 100644 --- a/console-app/src/features/deployments/hooks/use-deployment-log-stream.ts +++ b/console-app/src/features/deployments/hooks/use-deployment-log-stream.ts @@ -65,7 +65,8 @@ export function useDeploymentLogStream( const { deploymentId, enabled = true } = options; const [logs, setLogs] = useState([]); - const [deploymentStatus, setDeploymentStatus] = useState(null); + const [deploymentStatus, setDeploymentStatus] = + useState(null); const [hasReceivedStatus, setHasReceivedStatus] = useState(false); const [isSocketConnected, setIsSocketConnected] = useState(false); const [hasReceivedLog, setHasReceivedLog] = useState(false); @@ -127,7 +128,10 @@ export function useDeploymentLogStream( socket.on("connect", handleConnect); socket.on("disconnect", handleDisconnect); socket.on("connect_error", handleConnectError); - socket.on(DEPLOYMENT_SOCKET_EVENTS.DEPLOYMENT_STREAM, handleDeploymentStream); + socket.on( + DEPLOYMENT_SOCKET_EVENTS.DEPLOYMENT_STREAM, + handleDeploymentStream, + ); socket.on(DEPLOYMENT_SOCKET_EVENTS.DEPLOYMENT_STATUS, handleStatus); if (!socket.connected) { @@ -140,7 +144,10 @@ export function useDeploymentLogStream( socket.off("connect", handleConnect); socket.off("disconnect", handleDisconnect); socket.off("connect_error", handleConnectError); - socket.off(DEPLOYMENT_SOCKET_EVENTS.DEPLOYMENT_STREAM, handleDeploymentStream); + socket.off( + DEPLOYMENT_SOCKET_EVENTS.DEPLOYMENT_STREAM, + handleDeploymentStream, + ); socket.off(DEPLOYMENT_SOCKET_EVENTS.DEPLOYMENT_STATUS, handleStatus); }; }, [appendLine, deploymentId, enabled]); diff --git a/console-app/src/features/servers/components/server-detail/insights-tab.css b/console-app/src/features/servers/components/server-detail/insights-tab.css index 6d7e039..ebfa92b 100644 --- a/console-app/src/features/servers/components/server-detail/insights-tab.css +++ b/console-app/src/features/servers/components/server-detail/insights-tab.css @@ -1,15 +1,7 @@ -.insights-panel-header { - margin-bottom: 1.25rem; -} - -.insights-panel-header .server-detail-section-desc { - margin-bottom: 0; -} - .insights-grid { display: flex; flex-direction: column; - gap: 1rem; + gap: 0.875rem; width: 100%; } diff --git a/console-app/src/features/servers/components/server-detail/server-detail-section-header.tsx b/console-app/src/features/servers/components/server-detail/server-detail-section-header.tsx new file mode 100644 index 0000000..3d7ac9c --- /dev/null +++ b/console-app/src/features/servers/components/server-detail/server-detail-section-header.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from "react"; + +type ServerDetailSectionHeaderProps = { + title: string; + description?: ReactNode; + className?: string; +}; + +export function ServerDetailSectionHeader({ + title, + description, + className, +}: ServerDetailSectionHeaderProps) { + return ( +
    +

    {title}

    + {description ? ( +

    {description}

    + ) : null} +
    + ); +} diff --git a/console-app/src/features/servers/components/server-detail/tabs/activity-tab.tsx b/console-app/src/features/servers/components/server-detail/tabs/activity-tab.tsx index fe814ae..defe4b9 100644 --- a/console-app/src/features/servers/components/server-detail/tabs/activity-tab.tsx +++ b/console-app/src/features/servers/components/server-detail/tabs/activity-tab.tsx @@ -1,6 +1,7 @@ import { useMemo } from "react"; import { getServerActivity } from "@/lib/server-detail-data"; import { formatRelativeTime } from "@/lib/format-relative-time"; +import { ServerDetailSectionHeader } from "../server-detail-section-header"; import { activityIcon } from "../utils/activity-icon"; type ServerActivityTabProps = { @@ -19,10 +20,10 @@ export function ServerActivityTab({ return (
    -

    Recent activity

    -

    - Deployments, configuration changes, and alerts for this server. -

    +
    {activity.map((entry) => (
    diff --git a/console-app/src/features/servers/components/server-detail/tabs/insights-tab.tsx b/console-app/src/features/servers/components/server-detail/tabs/insights-tab.tsx index d5d54af..4c3c67a 100644 --- a/console-app/src/features/servers/components/server-detail/tabs/insights-tab.tsx +++ b/console-app/src/features/servers/components/server-detail/tabs/insights-tab.tsx @@ -1,6 +1,5 @@ import { getErrorMessage } from "@/api/api-error"; import { useServerResourcesQuery } from "@/features/servers/hooks"; -import { formatRelativeTime } from "@/lib/format-relative-time"; import { formatBytes, formatLoadAverage, @@ -8,6 +7,7 @@ import { formatUptime, } from "@/lib/format-metrics"; import { SkeletonInsightStack } from "@/components/shared/skeleton"; +import { ServerDetailSectionHeader } from "../server-detail-section-header"; import { InsightMetricCard } from "../insight-metric-card"; import "../insights-tab.css"; @@ -16,30 +16,24 @@ type ServerInsightsTabProps = { isActive: boolean; }; -function InsightsPanelHeader({ - timestamp, -}: { - timestamp?: string | null; -}) { +function InsightsPanelHeader() { return ( -
    -

    Resource usage

    -

    - On-demand snapshot for this server. CPU is sampled over one second; network - totals are cumulative since boot. - {timestamp ? ( - <> - {" "} - Collected{" "} - . - - ) : null} -

    -
    + + On-demand snapshot for this server. CPU is sampled over one second; + network totals are cumulative since boot. + + } + /> ); } -export function ServerInsightsTab({ serverId, isActive }: ServerInsightsTabProps) { +export function ServerInsightsTab({ + serverId, + isActive, +}: ServerInsightsTabProps) { const { data: resources, isLoading, @@ -96,7 +90,7 @@ export function ServerInsightsTab({ serverId, isActive }: ServerInsightsTabProps return (
    - +
    @@ -136,7 +136,10 @@ export function ServerInsightsTab({ serverId, isActive }: ServerInsightsTabProps { label: "Total", value: formatBytes(resources.disk.total) }, { label: "Used", value: formatBytes(resources.disk.used) }, { label: "Free", value: formatBytes(resources.disk.free) }, - { label: "Usage", value: formatPercent(resources.disk.usagePercent) }, + { + label: "Usage", + value: formatPercent(resources.disk.usagePercent), + }, ]} /> diff --git a/console-app/src/features/servers/components/server-detail/tabs/overview-tab.tsx b/console-app/src/features/servers/components/server-detail/tabs/overview-tab.tsx index f8f1f41..636c136 100644 --- a/console-app/src/features/servers/components/server-detail/tabs/overview-tab.tsx +++ b/console-app/src/features/servers/components/server-detail/tabs/overview-tab.tsx @@ -8,8 +8,12 @@ import type { ServerContainer, } from "@/features/deployments/types"; import { SkeletonMarketplaceGrid } from "@/components/shared/skeleton"; +import { ServerDetailSectionHeader } from "../server-detail-section-header"; import { ConnectedServiceCard } from "../connected-service-card"; -import { getContainerDisplayName, getContainerServiceName } from "../utils/container-display"; +import { + getContainerDisplayName, + getContainerServiceName, +} from "../utils/container-display"; type ServerOverviewTabProps = { serverId: string; @@ -24,13 +28,16 @@ export function ServerOverviewTab({ isLoading, isError, }: ServerOverviewTabProps) { - const { data: templates = [] } = useTemplatesQuery(serverId); + const { data: templatesResponse } = useTemplatesQuery(undefined, serverId); const templateLogos = useMemo( () => new Map( - templates.map((template) => [template.slug, template.logo ?? null]), + (templatesResponse?.data ?? []).map((template) => [ + template.slug, + template.logo ?? null, + ]), ), - [templates], + [templatesResponse?.data], ); const containerActionMutation = useContainerActionMutation(); @@ -46,8 +53,8 @@ export function ServerOverviewTab({ const isConfirmPending = Boolean( confirmAction && - pendingAction?.containerId === confirmAction.container.containerId && - pendingAction.action === confirmAction.action, + pendingAction?.containerId === confirmAction.container.containerId && + pendingAction.action === confirmAction.action, ); function handleContainerActionRequest( @@ -119,12 +126,10 @@ export function ServerOverviewTab({ /> ) : null} -

    Connected services

    - -

    - Containers discovered on this server, including Kubeara deployments and - self-managed workloads. -

    + {isLoading ? ( diff --git a/console-app/src/features/servers/components/server-detail/tabs/services-tab.tsx b/console-app/src/features/servers/components/server-detail/tabs/services-tab.tsx index 3db0bf5..afd7c0f 100644 --- a/console-app/src/features/servers/components/server-detail/tabs/services-tab.tsx +++ b/console-app/src/features/servers/components/server-detail/tabs/services-tab.tsx @@ -1,4 +1,5 @@ import { ServerTemplatesPanel } from "@/features/templates/components/server-templates-panel"; +import { ServerDetailSectionHeader } from "../server-detail-section-header"; type ServerServicesTabProps = { serverId: string; @@ -11,10 +12,10 @@ export function ServerServicesTab({ }: ServerServicesTabProps) { return (
    -

    Deploy a template

    -

    - Browse the marketplace and deploy services directly to this server. -

    + (null); + const frameRef = useRef(null); const termRef = useRef(null); const fitRef = useRef(null); const onDataRef = useRef(onData); @@ -158,8 +160,10 @@ export function ServerTerminalViewer({ const { visible: showScrollDown, handleClick: handleScrollDown } = useTerminalScrollDown(hostRef, scrollToBottom); + useTerminalWheelTrap(frameRef); + return ( -
    +
    { return response.data as Record; } -export async function fetchTemplates(): Promise { - const response = await apiClient.get("/templates"); - return unwrapServerApiData( +export async function fetchTemplates( + params: TemplatesListParams = {}, +): Promise { + const response = await apiClient.get("/templates", { params }); + return unwrapServerApiData( responseBody(response), "Failed to load templates", ); } +export async function fetchTemplateCategories(): Promise { + const response = await apiClient.get("/templates/categories"); + return unwrapServerApiData( + responseBody(response), + "Failed to load template categories", + ); +} + export async function fetchTemplateDetails(slug: string): Promise { const response = await apiClient.get(`/templates/${encodeURIComponent(slug)}`); return unwrapServerApiData( diff --git a/console-app/src/features/templates/components/marketplace-template-card.tsx b/console-app/src/features/templates/components/marketplace-template-card.tsx index c22c9fa..ebf6ccc 100644 --- a/console-app/src/features/templates/components/marketplace-template-card.tsx +++ b/console-app/src/features/templates/components/marketplace-template-card.tsx @@ -1,4 +1,8 @@ import { ServiceBrandIcon } from "@/components/shared/service-brand-icon"; +import { + getTemplateCategoryTagsDisplay, + normalizeTemplateCategories, +} from "../utils/format-template-category"; import type { ApiTemplate } from "../types"; type MarketplaceTemplateCardProps = { @@ -33,6 +37,8 @@ export function MarketplaceTemplateCard({ isDeployed = false, }: MarketplaceTemplateCardProps) { const configFieldCount = countConfigFields(template); + const categoryTags = getTemplateCategoryTagsDisplay(template.category); + const categoryValues = normalizeTemplateCategories(template.category); return (
    @@ -49,18 +55,34 @@ export function MarketplaceTemplateCard({ Deployed ) : null} -

    - {template.slug} - {template.version ? ( - v{template.version} - ) : null} -

    + {categoryTags ? ( +
      + {categoryTags.visible.map((label, index) => ( +
    • + {label} +
    • + ))} + {categoryTags.overflowCount > 0 ? ( +
    • + {categoryTags.overflowCount} more +
    • + ) : null} +
    + ) : null}
    {template.shortDescription ? ( -

    {template.shortDescription}

    +

    + {template.shortDescription} +

    ) : (

    No description provided. diff --git a/console-app/src/features/templates/components/server-templates-panel.tsx b/console-app/src/features/templates/components/server-templates-panel.tsx index 953484e..7b765e6 100644 --- a/console-app/src/features/templates/components/server-templates-panel.tsx +++ b/console-app/src/features/templates/components/server-templates-panel.tsx @@ -1,11 +1,20 @@ +import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { getErrorMessage } from "@/api/api-error"; -import { useTemplatesQuery } from "../hooks"; +import { Dropdown } from "@/components/shared/dropdown"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; +import { + useTemplateCategoriesQuery, + useTemplatesQuery, +} from "../hooks"; import { SkeletonMarketplaceGrid } from "@/components/shared/skeleton"; import { MarketplaceTemplateCard } from "./marketplace-template-card"; -import type { ApiTemplate } from "../types"; +import type { ApiTemplate, TemplatesListParams } from "../types"; import "../templates-ui.css"; +const PAGE_SIZE = 12; +const SEARCH_DEBOUNCE_MS = 300; + type ServerTemplatesPanelProps = { serverId: string; connectedTemplateSlugs?: Set; @@ -16,13 +25,64 @@ export function ServerTemplatesPanel({ connectedTemplateSlugs = new Set(), }: ServerTemplatesPanelProps) { const navigate = useNavigate(); - const templatesQuery = useTemplatesQuery(serverId); + const [searchInput, setSearchInput] = useState(""); + const debouncedSearch = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS); + const [category, setCategory] = useState(""); + const [page, setPage] = useState(1); + + const listParams = useMemo( + () => ({ + page, + limit: PAGE_SIZE, + search: debouncedSearch.trim() || undefined, + category: category || undefined, + }), + [page, debouncedSearch, category], + ); + + const templatesQuery = useTemplatesQuery(listParams, serverId); + const categoriesQuery = useTemplateCategoriesQuery(); function handleDeploy(template: ApiTemplate) { navigate(`/servers/${serverId}/deploy/${template.slug}`); } - if (templatesQuery.isPending) { + function handleSearchChange(value: string) { + setSearchInput(value); + setPage(1); + } + + function handleCategoryChange(value: string) { + setCategory(value); + setPage(1); + } + + function clearFilters() { + setSearchInput(""); + setCategory(""); + setPage(1); + } + + const templates = templatesQuery.data?.data ?? []; + const pagination = templatesQuery.data?.pagination; + const total = pagination?.total ?? 0; + const totalPages = Math.max(1, pagination?.totalPages ?? 1); + const currentPage = pagination?.page ?? page; + const rangeStart = total === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1; + const rangeEnd = Math.min(currentPage * PAGE_SIZE, total); + const hasFilters = searchInput.trim() !== "" || category !== ""; + const categories = categoriesQuery.data ?? []; + const categoryOptions = useMemo( + () => [ + { value: "", label: "All categories" }, + ...categories.map((entry) => ({ value: entry, label: entry })), + ], + [categories], + ); + const loading = templatesQuery.isPending; + const fetching = templatesQuery.isFetching; + + if (loading && !templatesQuery.data) { return ; } @@ -44,29 +104,97 @@ export function ServerTemplatesPanel({ ); } - const templates = templatesQuery.data ?? []; + const emptyMessage = hasFilters + ? "No templates match your search or filters." + : "There are no deployable templates for this server yet."; - if (templates.length === 0) { - return ( -

    -

    No templates available

    -

    - There are no deployable templates for this server yet. -

    + return ( +
    +
    +
    + handleSearchChange(e.target.value)} + aria-label="Search templates" + /> + + {hasFilters && ( + + )} +
    - ); - } - return ( -
    - {templates.map((template) => ( - - ))} + {templates.length === 0 ? ( +
    +

    No templates found

    +

    {emptyMessage}

    +
    + ) : ( +
    + {templates.map((template) => ( + + ))} +
    + )} + + {total > 0 && ( +
    +
    + Showing {rangeStart}–{rangeEnd} of {total} + {fetching && !loading ? " · Updating…" : ""} +
    +
    + + + Page {currentPage} of {totalPages} + + +
    +
    + )}
    ); } diff --git a/console-app/src/features/templates/hooks/index.ts b/console-app/src/features/templates/hooks/index.ts index 71ee0dd..b5a793d 100644 --- a/console-app/src/features/templates/hooks/index.ts +++ b/console-app/src/features/templates/hooks/index.ts @@ -1,11 +1,33 @@ import { useQuery } from "@tanstack/react-query"; import { QUERY_KEYS } from "@/constants/query-keys"; -import { fetchTemplateDetails, fetchTemplates } from "../api"; +import { + fetchTemplateCategories, + fetchTemplateDetails, + fetchTemplates, +} from "../api"; +import type { TemplatesListParams } from "../types"; -export function useTemplatesQuery(serverId?: string) { +const ALL_TEMPLATES_FETCH_PARAMS: TemplatesListParams = { + page: 1, + limit: 100, +}; + +export function useTemplatesQuery( + params: TemplatesListParams = ALL_TEMPLATES_FETCH_PARAMS, + serverId?: string, +) { + return useQuery({ + queryKey: QUERY_KEYS.templates.list(serverId, params), + queryFn: () => fetchTemplates(params), + staleTime: 5 * 60 * 1000, + placeholderData: (previousData) => previousData, + }); +} + +export function useTemplateCategoriesQuery() { return useQuery({ - queryKey: QUERY_KEYS.templates.list(serverId), - queryFn: fetchTemplates, + queryKey: QUERY_KEYS.templates.categories(), + queryFn: fetchTemplateCategories, staleTime: 5 * 60 * 1000, }); } diff --git a/console-app/src/features/templates/templates-ui.css b/console-app/src/features/templates/templates-ui.css index d436d06..f23848d 100644 --- a/console-app/src/features/templates/templates-ui.css +++ b/console-app/src/features/templates/templates-ui.css @@ -37,6 +37,117 @@ align-items: stretch; } +.server-templates-panel { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.server-templates-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.server-templates-filters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.625rem; + flex: 1; + min-width: min(100%, 280px); +} + +.server-templates-search { + flex: 1; + min-width: 200px; + padding: 0.625rem 0.75rem; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface); + color: var(--foreground); + font-size: 0.875rem; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; +} + +.server-templates-search:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent); +} + +.server-templates-category-dropdown { + flex-shrink: 0; + min-width: 200px; + width: auto; +} + +.server-templates-category-dropdown .dropdown-trigger--combobox { + min-width: 200px; + border-radius: 10px; + background: var(--surface); +} + +.server-templates-category-dropdown .dropdown-combobox-input { + font-size: 0.875rem; +} + +.server-templates-category-dropdown .dropdown-menu { + border-radius: 10px; + z-index: 100; +} + +.server-templates-pagination { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.25rem 0; + font-size: 0.875rem; + color: var(--muted); +} + +.server-templates-pagination-controls { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.server-templates-page-btn { + padding: 0.4rem 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + color: var(--foreground); + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + transition: + border-color 0.15s ease, + color 0.15s ease; +} + +.server-templates-page-btn:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--primary) 35%, var(--border)); + color: var(--primary); +} + +.server-templates-page-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.server-templates-page-indicator { + font-size: 0.8125rem; + font-weight: 600; + color: var(--foreground); +} + .templates-panel-state { display: flex; flex-direction: column; @@ -160,6 +271,10 @@ color: var(--success); } +.marketplace-card-headline-tags { + margin-top: 0.35rem; +} + .marketplace-card-slug { margin: 0.25rem 0 0; display: flex; @@ -1017,6 +1132,31 @@ grid-template-columns: 1fr; } + .server-templates-filters { + flex-direction: column; + align-items: stretch; + } + + .server-templates-search, + .server-templates-category-dropdown { + width: 100%; + min-width: 0; + } + + .server-templates-category-dropdown .dropdown-trigger--combobox { + width: 100%; + min-width: 0; + } + + .server-templates-pagination { + flex-direction: column; + align-items: stretch; + } + + .server-templates-pagination-controls { + justify-content: space-between; + } + .deploy-configure-page .deploy-service-card-main { flex-direction: column; padding: 1rem; diff --git a/console-app/src/features/templates/types/index.ts b/console-app/src/features/templates/types/index.ts index b534018..f050e12 100644 --- a/console-app/src/features/templates/types/index.ts +++ b/console-app/src/features/templates/types/index.ts @@ -36,3 +36,20 @@ export interface DeployFormField { description: string | null; section: "env" | "port"; } + +export type TemplatesListParams = { + page?: number; + limit?: number; + search?: string; + category?: string; +}; + +export type PaginatedTemplatesResponse = { + data: ApiTemplate[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + }; +}; diff --git a/console-app/src/features/templates/utils/format-template-category.ts b/console-app/src/features/templates/utils/format-template-category.ts index f4f736c..b970a6d 100644 --- a/console-app/src/features/templates/utils/format-template-category.ts +++ b/console-app/src/features/templates/utils/format-template-category.ts @@ -1,24 +1,62 @@ +export function normalizeTemplateCategories( + category: string[] | string | null | undefined, +): string[] { + if (category == null) { + return []; + } + + const values = Array.isArray(category) + ? category + : category + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + + return values.filter(Boolean); +} + +export function formatCategoryLabel(value: string): string { + return value + .trim() + .replace(/[-_]/g, " ") + .replace(/\b\w/g, (character) => character.toUpperCase()); +} + +export type TemplateCategoryTagsDisplay = { + visible: string[]; + overflowCount: number; +}; + +export function getTemplateCategoryTagsDisplay( + category: string[] | string | null | undefined, + maxVisible = 2, +): TemplateCategoryTagsDisplay | null { + const labels = normalizeTemplateCategories(category).map(formatCategoryLabel); + + if (labels.length === 0) { + return null; + } + + const limit = Math.max(1, maxVisible); + + return { + visible: labels.slice(0, limit), + overflowCount: Math.max(0, labels.length - limit), + }; +} + /** * Formats template category values for display. * API returns string[]; legacy/mock data may use a single string. */ export function formatTemplateCategory( - category: string[] | string | null | undefined, + category: string[] | string | null | undefined, ): string | null { - if (category == null) { - return null; - } - - const values = Array.isArray(category) - ? category - : category - .split(",") - .map((entry) => entry.trim()) - .filter(Boolean); - - if (values.length === 0) { - return null; - } - - return values.join(" · "); + const values = normalizeTemplateCategories(category); + + if (values.length === 0) { + return null; + } + + return values.map(formatCategoryLabel).join(" · "); } diff --git a/console-app/src/index.css b/console-app/src/index.css index 7ce521a..dded351 100644 --- a/console-app/src/index.css +++ b/console-app/src/index.css @@ -559,6 +559,26 @@ a:hover { padding: 2rem 1.5rem; } +.app-main:has(.server-detail) { + padding-top: 1.25rem; + padding-bottom: 1.5rem; +} + +.filter-clear-btn { + padding: 0.5rem 0.625rem; + border: none; + background: none; + color: var(--primary); + font-size: 0.8125rem; + font-weight: 600; + cursor: pointer; + white-space: nowrap; +} + +.filter-clear-btn:hover { + text-decoration: underline; +} + /* Dashboard */ .dashboard-header h1 { margin: 0 0 0.375rem; @@ -888,8 +908,8 @@ a:hover { display: flex; align-items: center; justify-content: space-between; - gap: 1rem 1.5rem; - margin-bottom: 0.5rem; + gap: 0.75rem 1.25rem; + margin-bottom: 0.25rem; } .server-detail-header .back-link { @@ -904,8 +924,8 @@ a:hover { } .server-detail-header-main h1 { - margin: 0 0 0.25rem; - font-size: 1.875rem; + margin: 0 0 0.125rem; + font-size: 1.625rem; font-weight: 700; line-height: 1.2; letter-spacing: -0.02em; diff --git a/console-app/src/pages/deploy-configure-page.tsx b/console-app/src/pages/deploy-configure-page.tsx index 90e8dda..6826082 100644 --- a/console-app/src/pages/deploy-configure-page.tsx +++ b/console-app/src/pages/deploy-configure-page.tsx @@ -1,7 +1,7 @@ import { Navigate, useParams } from "react-router-dom"; import { BackLink } from "@/components/shared/back-link"; import { DeployConfigurationForm } from "@/features/templates/components/deploy-configuration-form"; -import { useTemplateDetailsQuery, useTemplatesQuery } from "@/features/templates/hooks"; +import { useTemplateDetailsQuery } from "@/features/templates/hooks"; import { useServerQuery } from "@/features/servers/hooks"; import { DeployConfigurePageSkeleton } from "@/components/shared/skeleton"; import { buildServerDetailHref } from "@/features/servers/components/server-detail/utils/server-detail-tab-url"; @@ -20,7 +20,6 @@ export function DeployConfigurePage() { }>(); const serverQuery = useServerQuery(serverId); - const templatesQuery = useTemplatesQuery(serverId); const detailsQuery = useTemplateDetailsQuery(templateSlug); if (!serverId || !templateSlug) { @@ -29,11 +28,7 @@ export function DeployConfigurePage() { const backHref = buildServerDetailHref(serverId, "templates"); - if ( - serverQuery.isPending || - templatesQuery.isPending || - detailsQuery.isPending - ) { + if (serverQuery.isPending || detailsQuery.isPending) { return (
    @@ -46,8 +41,7 @@ export function DeployConfigurePage() { return ; } - const listTemplate = templatesQuery.data?.find((t) => t.slug === templateSlug); - const template = detailsQuery.data ?? listTemplate; + const template = detailsQuery.data; if (!template) { return ; diff --git a/console-app/src/pages/templates-page.tsx b/console-app/src/pages/templates-page.tsx index 808a793..4191877 100644 --- a/console-app/src/pages/templates-page.tsx +++ b/console-app/src/pages/templates-page.tsx @@ -12,7 +12,10 @@ import "@/features/templates/templates-ui.css"; */ export function TemplatesPage() { const { user } = useAuth(); - const { data: templates, isPending, isError, error } = useTemplatesQuery(); + const { data: templatesResponse, isPending, isError, error } = + useTemplatesQuery(); + + const templates = templatesResponse?.data ?? []; return (
    diff --git a/console-app/vite.config.ts b/console-app/vite.config.ts index c64e574..2082440 100644 --- a/console-app/vite.config.ts +++ b/console-app/vite.config.ts @@ -56,4 +56,7 @@ export default defineConfig({ preview: { port: 4000, }, + build: { + chunkSizeWarningLimit: 1500, + }, }); diff --git a/package.json b/package.json index 67be244..c36bce8 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "build:console-app": "npm run clean && cd console-app && npm run build", "build:agent-app": "nest build agent-app", "start": "nest start", - "start:console-app:dev": "npm run clean && cd console-app && npm run dev", + "start:console-app:dev": "cd console-app && npm run dev", "start:console-app": "npm run clean && cd console-app && npm run build && npm run start", "start:control-panel-app": "nest start control-panel-app", "start:control-panel-app:dev": "nest start control-panel-app --watch",