diff --git a/internal/api/middlewares/audit.go b/internal/api/middlewares/audit.go index 7dac63e..d40f9eb 100644 --- a/internal/api/middlewares/audit.go +++ b/internal/api/middlewares/audit.go @@ -29,6 +29,10 @@ func NewAuditInterceptor(app *config.App) connect.UnaryInterceptorFunc { // Only audit on successful operations if err == nil { if auditEvent := extractAuditEvent(req, resp); auditEvent != nil { + if auditEvent.Details == "" || auditEvent.Event == "" { + slog.Warn("audit event is missing details or event", "event", auditEvent) + return resp, err + } // Log audit event asynchronously to avoid blocking the response go func(auditCtx context.Context) { if auditErr := createAuditLog(auditCtx, app.Conn.GetQuery(), *auditEvent); auditErr != nil { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 544acc3..78628a8 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -39,9 +39,15 @@ export function useClient( } // Basic health check function -export async function checkHealth() { +export async function checkHealth( + customFetch?: typeof fetch, +): Promise { try { - const res = await fetch(`${baseURL.value}/healthz`, { method: "GET" }); + if (!baseURL.value) throw new Error("Base URL not set"); + if (!customFetch) customFetch = fetch; + const res = await customFetch(`${baseURL.value}/healthz`, { + method: "GET", + }); return res.ok; } catch { return false; diff --git a/web/src/lib/components/forms/HTTPRouterForm.svelte b/web/src/lib/components/forms/HTTPRouterForm.svelte index 0a449d0..3c72020 100644 --- a/web/src/lib/components/forms/HTTPRouterForm.svelte +++ b/web/src/lib/components/forms/HTTPRouterForm.svelte @@ -10,13 +10,14 @@ import { entryPointClient, middlewareClient, routerClient } from '$lib/api'; import { MiddlewareType } from '$lib/gen/mantrae/v1/middleware_pb'; import { unmarshalConfig, marshalConfig } from '$lib/types'; - import { onMount } from 'svelte'; import { profile } from '$lib/stores/profile'; + import { formatArrayDisplay } from '$lib/utils'; + import { onMount } from 'svelte'; let { router = $bindable() }: { router: Router } = $props(); let certResolvers: string[] = $state([]); - let config = $state(unmarshalConfig(router.config) as HTTPRouter); + let config = $state(unmarshalConfig(router.config) as HTTPRouter); $effect(() => { if (config) router.config = marshalConfig(config); @@ -41,6 +42,7 @@ let tmp = unmarshalConfig(r.config) as HTTPRouter; return tmp.tls?.certResolver ?? ''; }) + .filter(Boolean) ); certResolvers = Array.from(resolverSet); }); @@ -52,14 +54,16 @@ - {config.entryPoints?.join(', ') || 'Select entrypoints'} + + {formatArrayDisplay(config.entryPoints) || 'Select entrypoints'} + {#await entryPointClient.listEntryPoints( { profileId: profile.id, limit: -1n, offset: 0n } ) then value} {#each value.entryPoints as e (e.id)}
- {e.name} + {e.name} {#if e.isDefault} {/if} @@ -76,13 +80,15 @@ - {config.middlewares?.join(', ') || 'Select middlewares'} + + {formatArrayDisplay(config.middlewares) || 'Select middlewares'} + {#await middlewareClient.listMiddlewares( { profileId: profile.id, type: MiddlewareType.HTTP, limit: -1n, offset: 0n } ) then value} {#each value.middlewares as middleware (middleware.name)} - {middleware.name} + {middleware.name} {/each} {/await} @@ -90,12 +96,14 @@
+
- +
{ const input = e.target as HTMLInputElement; if (!input.value) { @@ -107,7 +115,7 @@ config.tls.certResolver = input.value; }} /> -
+
{#each certResolvers as resolver (resolver)} {#if resolver !== config.tls?.certResolver} {resolver} diff --git a/web/src/lib/components/modals/RouterModal.svelte b/web/src/lib/components/modals/RouterModal.svelte index 633edef..1843e0a 100644 --- a/web/src/lib/components/modals/RouterModal.svelte +++ b/web/src/lib/components/modals/RouterModal.svelte @@ -6,7 +6,6 @@ import * as Dialog from '$lib/components/ui/dialog/index.js'; import { Input } from '$lib/components/ui/input'; import { Label } from '$lib/components/ui/label'; - import Separator from '$lib/components/ui/separator/separator.svelte'; import * as Select from '$lib/components/ui/select/index.js'; import * as Tabs from '$lib/components/ui/tabs/index.js'; import * as Tooltip from '$lib/components/ui/tooltip/index.js'; @@ -168,17 +167,21 @@ - + Router Service - + - -
- {item.id ? 'Update' : 'Create'} Router - + +
+ {item.id ? 'Update' : 'Create'} Router + {item.id ? 'Update existing router' : 'Create a new router'}
@@ -220,7 +223,7 @@ {#each value.dnsProviders as dns (dns.id)} - {dns.name} + {dns.name} {#if dns.isActive} {/if} @@ -231,14 +234,20 @@ {/if} {/await}
- -
-
+ +
+
- +
-
+
(item.type = parseInt(value, 10))} > - {routerTypes.find((t) => t.value === item.type)?.label ?? 'Select type'} + + {routerTypes.find((t) => t.value === item.type)?.label ?? 'Select type'} + @@ -275,15 +286,16 @@ - + - -
- {item.id ? 'Update' : 'Create'} Service - - {item.id ? 'Update existing service' : 'Create a new service'} - -
+ + + {item.id ? 'Update' : 'Create'} + Service + + + {item.id ? 'Update existing service' : 'Create a new service'} + {#if item.type === RouterType.HTTP} @@ -300,15 +312,13 @@
- - -
+
{#if item.id} {/if} -
diff --git a/web/src/lib/components/tables/ColumnBadge.svelte b/web/src/lib/components/tables/ColumnBadge.svelte index bd19fa0..d25abc4 100644 --- a/web/src/lib/components/tables/ColumnBadge.svelte +++ b/web/src/lib/components/tables/ColumnBadge.svelte @@ -1,5 +1,6 @@ -
- {#each visible as item (item)} - column?.setFilterValue?.(item)} - class="flex items-center gap-1 hover:cursor-pointer" - {...restProps} - > - {#if icon} - {@const Icon = icon} - - {/if} - {item} - +
+ {#each visible as item, index (item)} + {@const truncated = truncateText(item, truncateAt)} + {@const shouldShowTooltip = item.length > truncateAt} + + {#if shouldShowTooltip} + + + + column?.setFilterValue?.(item)} + class="flex items-center gap-1.5 transition-colors duration-200 hover:cursor-pointer + {responsive ? 'text-xs sm:text-sm' : ''} + {index > 0 && responsive ? 'hidden sm:flex' : ''}" + {...restProps} + > + {#if icon && (!responsive || index === 0)} + {@const Icon = icon} + + {/if} + {truncated} + + + + {item} + + + + {:else} + column?.setFilterValue?.(item)} + class="flex items-center gap-1.5 transition-colors duration-200 hover:cursor-pointer + {responsive ? 'text-xs sm:text-sm' : ''} + {index > 0 && responsive ? 'hidden sm:flex' : ''}" + {...restProps} + > + {#if icon && (!responsive || index === 0)} + {@const Icon = icon} + + {/if} + {item} + + {/if} {/each} - {#if hidden.length} - + {#if hidden.length || (responsive && visible.length > 1)} + - - +{hidden.length} more + + {#if responsive && visible.length > 1} + +{visible.length - 1 + hidden.length} + + {:else} + +{hidden.length} + {/if} - -
+ +
+ {#if responsive} + {#each visible.slice(1) as item (item)} + column?.setFilterValue?.(item)} + class="flex items-center gap-1 text-xs hover:cursor-pointer" + {...restProps} + > + {#if icon} + {@const Icon = icon} + + {/if} + {item} + + {/each} + {/if} {#each hidden as item (item)} column?.setFilterValue?.(item)} - class="flex items-center gap-1 hover:cursor-pointer" + class="flex items-center gap-1 text-xs hover:cursor-pointer" {...restProps} > {#if icon} {@const Icon = icon} - + {/if} {item} @@ -75,3 +140,81 @@ {/if}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/lib/components/tables/ColumnRule.svelte b/web/src/lib/components/tables/ColumnRule.svelte index 5d2b6b4..70a89e1 100644 --- a/web/src/lib/components/tables/ColumnRule.svelte +++ b/web/src/lib/components/tables/ColumnRule.svelte @@ -1,44 +1,56 @@ {#if parsedRules.length === 0} None -{:else if parsedRules.length === 1} - {#if parsedRules[0].isClickable} - - - {parsedRules[0].value} - +{:else if !shouldShowMultiple && primaryRule} + + {@const Icon = showIcons ? getRuleIcon(primaryRule.type) : null} + {@const variant = getRuleVariant(primaryRule.type)} + {@const truncated = truncateValue(primaryRule.value, maxDisplayLength)} + {@const shouldShowTooltip = primaryRule.value.length > maxDisplayLength} + + {#if primaryRule.isClickable} + {#if shouldShowTooltip} + + + + + {truncated} + + + +
+
{primaryRule.type}
+
{primaryRule.value}
+
+
+
+
+ {:else} + + {truncated} + + {/if} + {:else if shouldShowTooltip} + + + + + {truncated} + + + +
+
{primaryRule.type}
+
{primaryRule.value}
+
+
+
+
+ {:else} + + {#if Icon} + + {/if} + {truncated} + {/if} {:else} - + + - +
+ + + + + + + {parsedRules.length} rules + +
- -
- {#each parsedRules as { value, isClickable } (value)} -
-
- {#if isClickable} -
- - {value} - - - {:else} -
- {value} + + +
+
+ + Traefik Rules ({parsedRules.length}) +
+ +
+ {#each parsedRules as rule, index (rule.value + index)} +
+ {#if showIcons} + {@const Icon = getRuleIcon(rule.type)} + +
+ + + {rule.type}: + + + {#if rule.isClickable} + + {rule.value} + + {:else} + {rule.value} + {/if} +
+ + {#if rule.operator} + + {rule.operator} + + {/if} {/if}
-
- {/each} + {/each} +
diff --git a/web/src/lib/components/tables/ColumnTLS.svelte b/web/src/lib/components/tables/ColumnTLS.svelte index ebd856c..25a78af 100644 --- a/web/src/lib/components/tables/ColumnTLS.svelte +++ b/web/src/lib/components/tables/ColumnTLS.svelte @@ -1,65 +1,243 @@ {#if tls} - + - column?.setFilterValue?.(tls?.certResolver ?? '')}> - {tls.certResolver ? tls.certResolver : 'Enabled'} + {@const StatusIcon = getStatusIcon()} + {@const variant = getBadgeVariant()} + {@const status = tlsStatus()} + + column?.setFilterValue?.(tls?.certResolver ?? 'enabled')} + class="cursor-pointer transition-colors duration-200 hover:shadow-sm + {compact ? 'px-2 text-xs' : 'text-sm'}" + > + + + {getStatusText()} + + {#if !compact && status.configCount !== undefined && status.configCount > 1} + +{status.configCount - 1} + {/if} - - {#if tls.certResolver} -
- - Resolver: {tls.certResolver} + + +
+ +
+ + TLS Configuration
- {/if} - {#if tls?.passthrough} -
- - Passthrough: Enabled + + +
+ {#if tls.certResolver} +
+ +
+
+ Certificate Resolver +
+
+ {truncateText(tls.certResolver, 20)} +
+
+ Automatic SSL certificate management +
+
+
+ {/if} + + {#if isTCPTLSConfig(tls) && tls.passthrough} +
+ +
+
+ TLS Passthrough +
+
+ TLS termination handled by backend +
+
+
+ {/if} + + {#if tls.options} +
+ +
+
+ TLS Options +
+
+ {tls.options} +
+
+ Custom TLS settings applied +
+
+
+ {/if} + + {#if tls.domains?.length} +
+ +
+
+ Certificate Domains ({tls.domains.length}) +
+
+ {#each tls.domains.slice(0, 3) as domain (domain.main)} +
+ + {domain.main} + + {#if domain.sans?.length} + + (+{domain.sans.length} SAN{domain.sans.length > 1 ? 's' : ''}) + + {/if} +
+ {/each} + {#if tls.domains.length > 3} +
+ +{tls.domains.length - 3} more domains... +
+ {/if} +
+
+
+ {/if} + + + {#if !tls.certResolver && !(isTCPTLSConfig(tls) && tls.passthrough) && !tls.options && !tls.domains?.length} +
+ +
+
+ Basic TLS Enabled +
+
+ TLS is enabled but has minimal configuration. Consider setting up a certificate + resolver or defining specific domains. +
+
+
+ {/if}
- {/if} - {#if tls.options} -
- - Options: {tls.options} -
- {/if} - {#if tls.domains?.length} -
- -
- Domains: -
    - {#each tls.domains as d (d)} -
  • {d.main}{d.sans ? ` (${d.sans.join(', ')})` : ''}
  • - {/each} -
-
-
- {/if} - {#if !tls.certResolver && !tls.passthrough && !tls.options && !tls.domains?.length} -
- - TLS is enabled but has no configuration -
- {/if} + + + + + + + + + + + + + + + + + + + + + + +
{:else} - Disabled + + + + + column?.setFilterValue?.('disabled')} + > + + Disabled + + + +
+
TLS Disabled
+
This router does not use TLS encryption
+
+
+
+
{/if} diff --git a/web/src/lib/components/tables/ColumnText.svelte b/web/src/lib/components/tables/ColumnText.svelte index 6891f8d..69f0642 100644 --- a/web/src/lib/components/tables/ColumnText.svelte +++ b/web/src/lib/components/tables/ColumnText.svelte @@ -1,4 +1,5 @@ -
- +
+ {#if shouldTruncate && showTooltip} + + + + + + + {label} + + + + {:else} + + {/if} + {#if icon} {@const Icon = icon} - + {/if}
diff --git a/web/src/lib/components/tables/DataTable.svelte b/web/src/lib/components/tables/DataTable.svelte index 41f411d..247e25d 100644 --- a/web/src/lib/components/tables/DataTable.svelte +++ b/web/src/lib/components/tables/DataTable.svelte @@ -28,10 +28,13 @@ import { ArrowDown, ArrowUp, + Check, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, + CircleCheck, + CircleX, Delete, Plus, Search, @@ -237,10 +240,6 @@ !cardConfig.excludeColumns?.includes(col.id) ); } - - function getActionsColumn() { - return table.getAllColumns().find((col) => col.id === 'actions'); - }
@@ -356,9 +355,9 @@ {:else} - No results. + + No results. + {/each} @@ -375,91 +374,129 @@
{:else} -
+
{#each table.getRowModel().rows as row (row.id)} - c.column.id === cardConfig.titleKey) + : null} + {@const subtitleCell = cardConfig.subtitleKey + ? row.getVisibleCells().find((c) => c.column.id === cardConfig.subtitleKey) + : null} + {@const actionsCell = row.getVisibleCells().find((c) => c.column.id === 'actions')} + {@const visibleColumns = getVisibleColumns()} + + + +
row.toggleSelected()} + ontouchstart={(e) => { + let touchTimer; + touchTimer = setTimeout(() => { + // Long press action - could show context menu + if (actionsCell) { + e.preventDefault(); + // You could dispatch a custom event here for showing actions menu + } + }, 500); + + // Clear timer on touch end + const clearTimer = () => { + clearTimeout(touchTimer); + document.removeEventListener('touchend', clearTimer); + document.removeEventListener('touchmove', clearTimer); + }; + + document.addEventListener('touchend', clearTimer); + document.addEventListener('touchmove', clearTimer); + }} > - - - - - - - - + {#if isSelected} + + {/if} - - {#if cardConfig.titleKey} - {@const titleColumn = table.getColumn(cardConfig.titleKey)} - {#if titleColumn} - +
+ +
+ {#if titleCell && cardConfig.titleKey} + {@const titleColumn = table.getColumn(cardConfig.titleKey)} +

c.column.id === cardConfig.titleKey) - }} + content={titleColumn?.columnDef.cell} + context={titleCell.getContext()} /> - +

{/if} - {/if} - {#if cardConfig.subtitleKey} - {@const subtitleColumn = table.getColumn(cardConfig.subtitleKey)} - {#if subtitleColumn} - + {#if subtitleCell && cardConfig.subtitleKey} + {@const subtitleColumn = table.getColumn(cardConfig.subtitleKey)} +
c.column.id === cardConfig.subtitleKey) - }} + content={subtitleColumn?.columnDef.cell} + context={subtitleCell.getContext()} /> - - {/if} - {/if} - - - - {#each getVisibleColumns() as column (column.id)} - {@const cell = row.getVisibleCells().find((c) => c.column.id === column.id)} - {#if cell && column.id !== cardConfig.titleKey && column.id !== cardConfig.subtitleKey} -
- - {column.columnDef.header}: - -
- -
{/if} - {/each} -
+
- {#if getActionsColumn()} - {@const actionsCell = row.getVisibleCells().find((c) => c.column.id === 'actions')} - {#if actionsCell} - - - + + {#if visibleColumns.length > 0} +
+ {#each visibleColumns as column (column.id)} + {@const cell = row.getVisibleCells().find((c) => c.column.id === column.id)} + {#if cell && column.id !== cardConfig.titleKey && column.id !== cardConfig.subtitleKey} +
+ + {column.columnDef.header} + +
+ +
+
+ {/if} + {/each} +
{/if} - {/if} - + + + {#if actionsCell} +
+
+ +
+
+ {/if} +
+ + +
+
{:else} -
- No results. +
+
+
📋
+

No results found

+

Try adjusting your search or filters

+
{/each}
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 366c5c3..f815b63 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -1,8 +1,12 @@ -import { DnsProviderType } from './gen/mantrae/v1/dns_provider_pb'; -import { MiddlewareType } from './gen/mantrae/v1/middleware_pb'; -import { RouterType } from './gen/mantrae/v1/router_pb'; -import { ServiceType } from './gen/mantrae/v1/service_pb'; -import type { JsonObject } from '@bufbuild/protobuf'; +import { DnsProviderType } from "./gen/mantrae/v1/dns_provider_pb"; +import { MiddlewareType } from "./gen/mantrae/v1/middleware_pb"; +import { RouterType } from "./gen/mantrae/v1/router_pb"; +import { ServiceType } from "./gen/mantrae/v1/service_pb"; +import type { JsonObject } from "@bufbuild/protobuf"; +import type { Component } from "svelte"; +import type { IconProps } from "@lucide/svelte"; + +export type IconComponent = Component, "">; // Parse protobuf config export function unmarshalConfig(json: JsonObject | undefined): T { @@ -17,62 +21,32 @@ export function marshalConfig(config: T): JsonObject { // Convert enum to select options export const routerTypes = Object.keys(RouterType) - .filter((key) => isNaN(Number(key)) && key !== 'UNSPECIFIED') + .filter((key) => isNaN(Number(key)) && key !== "UNSPECIFIED") .map((key) => ({ label: key.toUpperCase(), - value: RouterType[key as keyof typeof RouterType] + value: RouterType[key as keyof typeof RouterType], })); export const serviceTypes = Object.keys(ServiceType) - .filter((key) => isNaN(Number(key)) && key !== 'UNSPECIFIED') + .filter((key) => isNaN(Number(key)) && key !== "UNSPECIFIED") .map((key) => ({ label: key.toUpperCase(), - value: ServiceType[key as keyof typeof ServiceType] + value: ServiceType[key as keyof typeof ServiceType], })); export const middlewareTypes = Object.keys(MiddlewareType) - .filter((key) => isNaN(Number(key)) && key !== 'UNSPECIFIED') + .filter((key) => isNaN(Number(key)) && key !== "UNSPECIFIED") .map((key) => ({ label: key.toUpperCase(), - value: MiddlewareType[key as keyof typeof MiddlewareType] + value: MiddlewareType[key as keyof typeof MiddlewareType], })); export const dnsProviderTypes = Object.keys(DnsProviderType) - .filter((key) => isNaN(Number(key)) && key !== 'UNSPECIFIED') + .filter((key) => isNaN(Number(key)) && key !== "UNSPECIFIED") .map((key) => ({ label: key - .replace('DNS_PROVIDER_TYPE_', '') + .replace("DNS_PROVIDER_TYPE_", "") .toLowerCase() .replace(/^\w/, (c) => c.toUpperCase()), - value: DnsProviderType[key as keyof typeof DnsProviderType] + value: DnsProviderType[key as keyof typeof DnsProviderType], })); - -export const HTTPMiddlewareKeys = [ - { value: 'addPrefix', label: 'Add Prefix' }, - { value: 'basicAuth', label: 'Basic Auth' }, - { value: 'digestAuth', label: 'Digest Auth' }, - { value: 'buffering', label: 'Buffering' }, - { value: 'chain', label: 'Chain' }, - { value: 'circuitBreaker', label: 'Circuit Breaker' }, - { value: 'compress', label: 'Compress' }, - { value: 'errorPage', label: 'Error Page' }, - { value: 'forwardAuth', label: 'Forward Auth' }, - { value: 'headers', label: 'Headers' }, - { value: 'ipAllowList', label: 'IP Allow List' }, - { value: 'inFlightReq', label: 'In-Flight Request' }, - { value: 'passTLSClientCert', label: 'Pass TLS Client Cert' }, - { value: 'rateLimit', label: 'Rate Limit' }, - { value: 'redirectRegex', label: 'Redirect Regex' }, - { value: 'redirectScheme', label: 'Redirect Scheme' }, - { value: 'replacePath', label: 'Replace Path' }, - { value: 'replacePathRegex', label: 'Replace Path Regex' }, - { value: 'retry', label: 'Retry' }, - { value: 'stripPrefix', label: 'Strip Prefix' }, - { value: 'stripPrefixRegex', label: 'Strip Prefix Regex' }, - { value: 'plugin', label: 'Plugin' } -]; - -export const TCPMiddlewareKeys = [ - { value: 'ipAllowList', label: 'IP Allow List' }, - { value: 'inFlightConn', label: 'In-Flight Connection' } -]; diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index fa11738..e14a64f 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -1,83 +1,32 @@ -import { type ClassValue, clsx } from 'clsx'; -import { twMerge } from 'tailwind-merge'; +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } // eslint-disable-next-line @typescript-eslint/no-explicit-any -export type WithoutChild = T extends { child?: any } ? Omit : T; +export type WithoutChild = T extends { child?: any } ? Omit : T; // eslint-disable-next-line @typescript-eslint/no-explicit-any -export type WithoutChildren = T extends { children?: any } ? Omit : T; +export type WithoutChildren = T extends { children?: any } + ? Omit + : T; export type WithoutChildrenOrChild = WithoutChildren>; export type WithElementRef = T & { ref?: U | null; }; -export function safeClone(obj: T): T { - try { - return JSON.parse(JSON.stringify(obj)); - } catch (e) { - console.warn('Failed to clone object:', e); - return obj; - } +// Helper function to truncate text with ellipsis +export function truncateText(text: string, maxLength: number = 30): string { + return text.length > maxLength ? text.substring(0, maxLength) + "..." : text; } -export async function tryLoad(fn: () => Promise, fallback: T): Promise { - try { - return await fn(); - } catch (err: unknown) { - const error = err instanceof Error ? err.message : String(err); - console.warn('Failed to load:', error); - return fallback; - } -} - -export function cleanupFormData(data: unknown): unknown { - // Base cases for non-objects - if (data === null || data === undefined || data === '') { - return null; - } - - if (typeof data !== 'object') { - // Keep primitive values like numbers and booleans - return data; - } - - // Handle arrays - if (Array.isArray(data)) { - // Filter out empty values and clean each remaining item - const filtered = data - .filter((item) => item !== null && item !== undefined && item !== '') - .map((item) => cleanupFormData(item)) - .filter((item) => item !== null); - - return filtered.length > 0 ? filtered : null; - } - - // Handle objects - const result: Record = {}; - let hasValidProperty = false; - - for (const [key, value] of Object.entries(data)) { - // Skip default values for specific properties - if ( - (key === 'depth' && value === 0) || - (key === 'requestHost' && value === false) || - (key === 'excludedIPs' && Array.isArray(value) && value.length === 0) - ) { - continue; - } - - const cleanValue = cleanupFormData(value); - - // Only include meaningful values - if (cleanValue !== null) { - result[key] = cleanValue; - hasValidProperty = true; - } - } - - // Return null for empty objects to remove them entirely - return hasValidProperty ? result : null; +// Helper function to format array display with ellipsis +export function formatArrayDisplay( + arr: string[] | undefined, + maxItems: number = 2, +): string { + if (!arr || arr.length === 0) return ""; + if (arr.length <= maxItems) return arr.join(", "); + return `${arr.slice(0, maxItems).join(", ")} (+${arr.length - maxItems})`; } diff --git a/web/src/routes/+layout.ts b/web/src/routes/+layout.ts index 225de1b..4d6a7a1 100644 --- a/web/src/routes/+layout.ts +++ b/web/src/routes/+layout.ts @@ -15,7 +15,7 @@ export const load: LayoutLoad = async ({ url, fetch }) => { const isPublic = currentPath.startsWith("/login") || currentPath.startsWith("/welcome"); - const healthy = await checkHealth(); + const healthy = await checkHealth(fetch); if (!healthy) { // No backend, force redirect to welcome screen to enter backend URL if (currentPath !== "/welcome") { diff --git a/web/src/routes/router/+page.svelte b/web/src/routes/router/+page.svelte index 461a0e4..5d7a421 100644 --- a/web/src/routes/router/+page.svelte +++ b/web/src/routes/router/+page.svelte @@ -28,11 +28,13 @@ Route, Table, Trash, + TriangleAlert, Waves } from '@lucide/svelte'; import type { ColumnDef, PaginationState } from '@tanstack/table-core'; import { onMount } from 'svelte'; import { toast } from 'svelte-sonner'; + import { type IconComponent } from '$lib/types'; let item = $state({} as Router); let open = $state(false); @@ -52,12 +54,13 @@ label: row.getValue('name') as string, icon: row.original.agentId ? Bot : undefined, iconProps: { class: 'text-green-500', size: 20 }, - class: 'text-sm' + truncate: true, + maxLength: 20 }); } }, { - header: 'Protocol', + header: 'Type', accessorKey: 'type', enableSorting: true, enableGlobalFilter: false, @@ -77,25 +80,19 @@ return protocol === filterValue; }, cell: ({ row, column }) => { - let protocol = row.getValue('type') as RouterType.HTTP | RouterType.TCP | RouterType.UDP; - - let label = 'Unspecified'; - let icon = undefined; - if (protocol === RouterType.HTTP) { - label = 'HTTP'; - icon = Globe; - } else if (protocol === RouterType.TCP) { - label = 'TCP'; - icon = Network; - } else if (protocol === RouterType.UDP) { - label = 'UDP'; - icon = Waves; - } + const protocol = row.getValue('type') as RouterType; + const label = getProtocolLabel(protocol); + const iconMap: Record = { + [RouterType.HTTP]: Globe, + [RouterType.TCP]: Network, + [RouterType.UDP]: Waves, + [RouterType.UNSPECIFIED]: TriangleAlert + }; return renderComponent(ColumnBadge, { label, - icon, + icon: iconMap[protocol], variant: 'outline', - class: 'hover:cursor-pointer', + responsive: true, column: column }); } @@ -141,15 +138,10 @@ { header: 'Rules', accessorKey: 'config.rule', - id: 'rule', enableSorting: true, cell: ({ row }) => { - let rule = ''; - if (row.original.config?.rule !== undefined) { - rule = row.getValue('rule') as string; - } return renderComponent(ColumnRule, { - rule: rule, + rule: (row.original.config?.rule as string) ?? '', routerType: row.original.type as RouterType.HTTP | RouterType.TCP }); } @@ -157,7 +149,6 @@ { header: 'TLS', accessorKey: 'config.tls', - id: 'tls', enableSorting: true, enableGlobalFilter: false, filterFn: (row, columnId, filterValue) => { @@ -165,34 +156,10 @@ return tls?.certResolver === filterValue; }, cell: ({ row, column }) => { - let tls: RouterTLSConfig | RouterTCPTLSConfig = {}; - if (row.original.config?.tls !== undefined) { - tls = row.getValue('tls') as RouterTLSConfig | RouterTCPTLSConfig; - } + const tls = row.original.config?.tls as RouterTLSConfig | RouterTCPTLSConfig; return renderComponent(ColumnTls, { tls, column }); } }, - { - header: 'Enabled', - accessorKey: 'enabled', - enableSorting: true, - enableGlobalFilter: false, - cell: ({ row }) => { - return renderComponent(TableActions, { - actions: [ - { - type: 'button', - label: row.original.enabled ? 'Disable' : 'Enable', - icon: row.original.enabled ? Power : PowerOff, - iconProps: { - class: row.original.enabled ? 'text-green-500 size-5' : 'text-red-500 size-5' - }, - onClick: () => toggleItem(row.original, !row.original.enabled) - } - ] - }); - } - }, { id: 'actions', enableHiding: false, @@ -200,6 +167,15 @@ cell: ({ row }) => { return renderComponent(TableActions, { actions: [ + { + type: 'button', + label: row.original.enabled ? 'Disable' : 'Enable', + icon: row.original.enabled ? Power : PowerOff, + iconProps: { + class: row.original.enabled ? 'text-green-500' : 'text-red-500' + }, + onClick: () => toggleItem(row.original, !row.original.enabled) + }, { type: 'button', label: 'Edit Router', @@ -341,8 +317,14 @@ rowCount = Number(response.totalCount); } - onMount(async () => { - await refreshData(pageSize.value ?? 10, pageIndex.value ?? 0); + const checkMobile = () => { + let isMobile = window.matchMedia('(max-width: 768px)').matches; + if (isMobile) viewMode = 'grid'; + }; + onMount(() => { + refreshData(pageSize.value ?? 10, pageIndex.value ?? 0); + window.addEventListener('resize', checkMobile); + return () => window.removeEventListener('resize', checkMobile); }); @@ -350,35 +332,37 @@ Routers -
-
-
-

-
- +
+
+
+

+
+
- Routers + Routers

-

Manage your routers and services

+

Manage your routers and services

- -
+ +