diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 89dd1a2..0ccb4af 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -14,7 +14,6 @@ import { type User } from './types'; import type { EntryPoints } from './types/entrypoints'; -import { PROFILE_SK, SOURCE_TAB_SK, TOKEN_SK } from './store'; import { flattenMiddlewareData, type Middleware, @@ -31,6 +30,12 @@ import { get, writable, type Writable } from 'svelte/store'; import { toast } from 'svelte-sonner'; import { goto } from '$app/navigation'; import type { Overview } from './types/overview'; +import { profile } from './stores/profile'; +import { user } from './stores/user'; +import { source } from './stores/source'; +import { token } from './stores/common'; + +export type RouterWithService = { router: Router; service: Service }; // Global state variables export const BACKEND_PORT = import.meta.env.PORT || 3000; @@ -44,6 +49,7 @@ export const overview: Writable = writable({} as Overview); export const version: Writable = writable(''); export const routers: Writable = writable([]); export const services: Writable = writable([]); +export const routerServiceMerge: Writable = writable([]); export const middlewares: Writable = writable([]); export const users: Writable = writable([]); export const rdps: Writable = writable([]); @@ -55,9 +61,6 @@ export const backups: Writable = writable([]); export const stats: Writable = writable({} as Stats); // App state -export const profile: Writable = writable({} as Profile); -export const user: Writable = writable({} as User); -export const source: Writable = writable({} as TraefikSource); export const mwNames: Writable = writable([]); // Loading and error states @@ -72,13 +75,11 @@ interface APIOptions { } async function send(endpoint: string, options: APIOptions = {}, fetch?: typeof window.fetch) { - const token = localStorage.getItem(TOKEN_SK); - // Custom fetch function that adds the Authorization header const customFetch: typeof window.fetch = async (url, options) => { const headers = new Headers(options?.headers); // Get existing headers - if (token) { - headers.set('Authorization', 'Bearer ' + token); // Add the Authorization header + if (token.value) { + headers.set('Authorization', 'Bearer ' + token.value); // Add the Authorization header } // Don't set Content-Type for FormData const isFormData = options?.body instanceof FormData; @@ -126,7 +127,7 @@ export const api = { body: { username, password } }); if (data.token) { - localStorage.setItem(TOKEN_SK, data.token); + token.value = data.token; goto('/'); } @@ -134,17 +135,19 @@ export const api = { }, async verify(fetch: typeof window.fetch = window.fetch) { - const token = localStorage.getItem(TOKEN_SK); try { const data = await send( '/verify', { method: 'POST', - body: token + body: token.value }, fetch ); - return data; + + if (data) { + user.value = data; + } } catch (err: unknown) { const error = err instanceof Error ? err.message : String(err); toast.error('Session expired', { description: error }); @@ -157,45 +160,37 @@ export const api = { await send(`/reset/${username}`, { method: 'POST' }); }, - async verifyOTP(username: string, token: string) { + async verifyOTP(username: string, otp: string) { const data = await send('/verify/otp', { method: 'POST', - body: { username, token } + body: { username, token: otp } }); if (data.token) { - localStorage.setItem(TOKEN_SK, data.token); + token.value = data.token; goto('/'); } await api.load(); }, async load() { + if (!user.isLoggedIn()) return; + // Load profiles await api.listProfiles(); - const savedProfileID = parseInt(localStorage.getItem(PROFILE_SK) ?? ''); - if (get(profiles)) { - const switchProfile = - get(profiles).find((item) => item.id === savedProfileID) ?? get(profiles)[0]; - profile.set(switchProfile); - localStorage.setItem(PROFILE_SK, switchProfile.id.toString()); + if (get(profiles) && !profile.value) { + profile.value = get(profiles)[0]; } - // Load source - const savedSource = localStorage.getItem(SOURCE_TAB_SK) as TraefikSource; - if (Object.values(TraefikSource).includes(savedSource)) { - source.set(savedSource); - } else { - source.set(TraefikSource.LOCAL); - } - localStorage.setItem(SOURCE_TAB_SK, get(source)); - // Load Traefik Config - await api.getTraefikConfig(get(profile).id, get(source)); + if (profile.value && source.value) { + await api.getTraefikConfig(source.value); + } }, logout() { - localStorage.removeItem(TOKEN_SK); + token.value = null; + user.clear(); goto('/login'); }, @@ -242,24 +237,25 @@ export const api = { async deleteProfile(id: number) { await send(`/profile/${id}`, { method: 'DELETE' }); - if (id === get(profile).id) { - localStorage.removeItem(PROFILE_SK); - profile.set({} as Profile); + if (id === profile.value?.id) { + profile.value = {} as Profile; } await api.listProfiles(); // Refresh the list }, // Traefik ------------------------------------------------------------------- - async getTraefikConfig(id: number, source: TraefikSource) { - if (!id || !Object.values(TraefikSource).includes(source)) return; - await fetchTraefikMetadata(id); - await fetchTraefikConfig(id, source); + async getTraefikConfig(source: TraefikSource) { + await fetchTraefikMetadata(); + await fetchTraefikConfig(source); }, - async getTraefikConfigLocal(id: number) { - if (!id) return; + async getTraefikConfigLocal() { + if (!profile.isValid()) { + toast.error('No valid profile selected'); + return; + } // Get the local config without mutating the stores - const res = await send(`/traefik/${id}/${TraefikSource.LOCAL}`); + const res = await send(`/traefik/${profile.id}/${TraefikSource.LOCAL}`); if (!res) { return; } @@ -270,40 +266,62 @@ export const api = { return { traefik, routers, services, middlewares }; }, - async getDynamicConfig(profileName: string) { - return await send(`/${profileName}`); + async getDynamicConfig() { + if (!profile.hasValidName()) { + toast.error('Profile name is required'); + return; + } + return await send(`/${profile.name}`); }, // Routers ------------------------------------------------------------------- - async upsertRouter(id: number, data: UpsertRouterParams) { - await send(`/router/${id}`, { + async upsertRouter(data: UpsertRouterParams) { + if (!profile.hasValidId()) { + toast.error('Invalid profile ID'); + return; + } + + await send(`/router/${profile.id}`, { method: 'POST', body: data }); - await api.getTraefikConfig(id, TraefikSource.LOCAL); + await api.getTraefikConfig(TraefikSource.LOCAL); }, - async deleteRouter(id: number, data: Router) { - await send(`/router/${id}/${data.name}/${data.protocol}`, { + async deleteRouter(data: Router) { + if (!profile.hasValidId()) { + toast.error('Invalid profile ID'); + return; + } + + await send(`/router/${profile.id}/${data.name}/${data.protocol}`, { method: 'DELETE' }); - await api.getTraefikConfig(id, TraefikSource.LOCAL); + await api.getTraefikConfig(TraefikSource.LOCAL); }, // Middlewares --------------------------------------------------------------- - async upsertMiddleware(id: number, data: UpsertMiddlewareParams) { - await send(`/middleware/${id}`, { + async upsertMiddleware(data: UpsertMiddlewareParams) { + if (!profile.hasValidId()) { + toast.error('Invalid profile ID'); + return; + } + await send(`/middleware/${profile.id}`, { method: 'POST', body: data }); - await api.getTraefikConfig(id, TraefikSource.LOCAL); + await api.getTraefikConfig(TraefikSource.LOCAL); }, - async deleteMiddleware(id: number, data: Middleware) { - await send(`/middleware/${id}/${data.name}/${data.protocol}`, { + async deleteMiddleware(data: Middleware) { + if (!profile.hasValidId()) { + toast.error('Invalid profile ID'); + return; + } + await send(`/middleware/${profile.id}/${data.name}/${data.protocol}`, { method: 'DELETE' }); - await api.getTraefikConfig(id, TraefikSource.LOCAL); + await api.getTraefikConfig(TraefikSource.LOCAL); }, // DNS Providers ------------------------------------------------------------- @@ -413,8 +431,11 @@ export const api = { }, async listAgentsByProfile(): Promise { - if (!get(profile).id) return []; - const data = await send(`/agent/list/${get(profile).id}`); + if (!profile.hasValidId()) { + toast.error('Invalid profile ID'); + return []; + } + const data = await send(`/agent/list/${profile.id}`); agents.set(data); return data; }, @@ -424,8 +445,13 @@ export const api = { }, async createAgent() { - if (!get(profile).id) return; - await send(`/agent/${get(profile).id}`, { method: 'POST' }); + if (!profile.hasValidId()) { + toast.error('Invalid profile ID'); + return; + } + await send(`/agent/${profile.id}`, { + method: 'POST' + }); await api.listAgentsByProfile(); }, @@ -542,8 +568,12 @@ export const api = { }; // Helper -async function fetchTraefikMetadata(id: number) { - const res = await send(`/traefik/${id}/${TraefikSource.API}`); +async function fetchTraefikMetadata() { + if (!profile.isValid()) { + toast.error('No valid profile selected'); + return; + } + const res = await send(`/traefik/${profile.id}/${TraefikSource.API}`); if (!res) { // Reset metadata stores overview.set({} as Overview); @@ -564,79 +594,69 @@ async function fetchTraefikMetadata(id: number) { return true; } -async function fetchTraefikConfig(profileID: number, source: TraefikSource) { - // Reset stores - traefik.set([]); - routers.set([]); - services.set([]); - middlewares.set([]); +async function fetchTraefikConfig(src: TraefikSource) { + if (!profile.isValid() || !source.isValid(src)) { + toast.error('No valid profile selected'); + return; + } + source.value = src; - const res = await send(`/traefik/${profileID}/${source}`); + const res = await send(`/traefik/${profile.id}/${source.value}`); if (!res) { + // Reset stores + traefik.set([]); + routers.set([]); + services.set([]); + middlewares.set([]); + routerServiceMerge.set([]); return; } - // Set stores - traefik.set(res); - routers.set(flattenRouterData(res)); - services.set(flattenServiceData(res)); - middlewares.set(flattenMiddlewareData(res)); + // Update stores with proper diffing + traefik.update((current) => { + return JSON.stringify(current) === JSON.stringify(res) ? current : res; + }); + + const newRouters = flattenRouterData(res); + const newServices = flattenServiceData(res); + const newMiddlewares = flattenMiddlewareData(res); + const newMerge = newRouters.map((router) => { + const routerProvider = router.name.split('@')[1]; + let serviceName = router.service; // api@internal + + // Most of time the service name doesn't include the provider + if (!router.service?.includes('@')) { + serviceName = router.service + '@' + routerProvider; + } + const service = newServices.find((service) => service.name === serviceName); + + return { router, service: service || ({} as Service) }; + }); + + routers.update((current) => { + if (!current || current.length === 0) return newRouters; + if (JSON.stringify(current) === JSON.stringify(newRouters)) return current; + return newRouters; + }); + + services.update((current) => { + if (!current || current.length === 0) return newServices; + if (JSON.stringify(current) === JSON.stringify(newServices)) return current; + return newServices; + }); + + middlewares.update((current) => { + if (!current || current.length === 0) return newMiddlewares; + if (JSON.stringify(current) === JSON.stringify(newMiddlewares)) return current; + return newMiddlewares; + }); + + routerServiceMerge.update((current) => { + if (!current || current.length === 0) return newMerge; + if (JSON.stringify(current) === JSON.stringify(newMerge)) return current; + return newMerge; + }); // Fetch the router dns relations await api.listRouterDNSProviders(res.id); } - -// Login ---------------------------------------------------------------------- -// export async function login(username: string, password: string, remember: boolean) { -// const loginURL = remember ? `${BASE_URL}/login?remember=true` : `${BASE_URL}/login`; -// const response = await fetch(loginURL, { -// method: 'POST', -// body: JSON.stringify({ username, password }) -// }); -// if (response.ok) { -// const { token } = await response.json(); -// localStorage.setItem(TOKEN_SK, token); -// loggedIn.set(true); -// goto('/'); -// toast.success('Login successful'); -// await getProfiles(); -// await getProviders(); -// } else { -// toast.error('Login failed', { -// description: await response.text(), -// duration: 3000 -// }); -// return; -// } -// } - -// export async function sendResetEmail(username: string) { -// const response = await fetch(`${BASE_URL}/reset/${username}`, { -// method: 'POST' -// }); -// if (response.ok) { -// toast.success('Password reset email sent!'); -// } else { -// toast.error('Request failed', { -// description: await response.text(), -// duration: 3000 -// }); -// } -// } - -// export async function resetPassword(token: string, password: string) { -// const response = await fetch(`${BASE_URL}/reset`, { -// method: 'POST', -// body: JSON.stringify({ password }), -// headers: { Authorization: `Bearer ${token}` } -// }); -// if (response.ok) { -// toast.success('Password reset successful!'); -// goto('/login'); -// } else { -// toast.error('Request failed', { -// description: await response.text(), -// duration: 3000 -// }); -// } -// } diff --git a/web/src/lib/components/forms/MWForm.svelte b/web/src/lib/components/forms/MWForm.svelte deleted file mode 100644 index 36741bf..0000000 --- a/web/src/lib/components/forms/MWForm.svelte +++ /dev/null @@ -1,235 +0,0 @@ - - -
- {#if currentSchema instanceof z.ZodObject} - {#each generateFormFields(currentSchema) as field} - {#if field.type === 'object'} -
- - ) || {}} - onChange={(newValues) => handleValueChange(field.path, newValues)} - /> -
- {:else if field.type === 'string'} -
- - handleValueChange(field.path, e.currentTarget.value)} - /> -
- {:else if field.type === 'number'} -
- - handleValueChange(field.path, Number(e.currentTarget.value))} - /> -
- {:else if field.type === 'boolean'} -
- handleValueChange(field.path, checked)} - /> - -
- {:else if field.type === 'array'} -
- - {#each (values[field.key] as unknown[]) || [] as item, i} -
- { - const newArray = [...((values[field.key] as unknown[]) || [])]; - newArray[i] = e.currentTarget.value; - handleValueChange(field.path, newArray); - }} - /> - -
- {/each} - -
- {/if} - {/each} - {/if} -
diff --git a/web/src/lib/components/forms/service.svelte b/web/src/lib/components/forms/service.svelte index b6d27c6..b73994a 100644 --- a/web/src/lib/components/forms/service.svelte +++ b/web/src/lib/components/forms/service.svelte @@ -13,19 +13,31 @@ mode: 'create' | 'edit'; } - let { service = $bindable(), router, mode }: Props = $props(); + let { service = $bindable(), router = $bindable(), mode }: Props = $props(); let routerProvider = $derived(router.name ? router.name?.split('@')[1] : 'http'); let disabled = $derived(routerProvider !== 'http' && mode === 'edit'); - let passHostHeader = $state(true); - let servers = $state(['']); + let servers = $state(getServers()); + let passHostHeader = $state(service.loadBalancer?.passHostHeader ?? true); + + function getServers() { + if (!service.loadBalancer) { + service.loadBalancer = { servers: [] }; + } + let servers = service.loadBalancer.servers?.map((s) => + router.protocol === 'http' ? s.url : s.address + ); + if (servers?.length === 0) { + return ['']; + } + return servers ?? ['']; + } function update() { if (!service.loadBalancer) { service.loadBalancer = { servers: [] }; } - service.loadBalancer.servers = servers.map((s) => router.protocol === 'http' ? { url: s } : { address: s } ); @@ -40,15 +52,9 @@ servers = servers.filter((_, i) => i !== index); update(); } - $effect(() => { - if (service.loadBalancer?.servers) { - servers = service.loadBalancer.servers - .map((s) => (router.protocol === 'http' ? s.url : s.address)) - .filter((s): s is string => s !== undefined); - } - if (service.loadBalancer?.passHostHeader !== undefined) { - passHostHeader = service.loadBalancer.passHostHeader; + if (router.protocol) { + service.protocol = router.protocol; } }); @@ -81,24 +87,35 @@
- {#each servers || [] as _, i} + {#each servers as _, i}
- + {#if !disabled} + + {/if}
{/each}
- + {#if !disabled} + + {/if} diff --git a/web/src/lib/components/modals/info.svelte b/web/src/lib/components/modals/info.svelte index 6bec4e0..466d5e7 100644 --- a/web/src/lib/components/modals/info.svelte +++ b/web/src/lib/components/modals/info.svelte @@ -5,11 +5,12 @@ import * as Tooltip from '$lib/components/ui/tooltip/index.js'; import { Button } from '$lib/components/ui/button/index.js'; import { Badge } from '$lib/components/ui/badge/index.js'; - import { api, entrypoints, overview, profile, version } from '$lib/api'; + import { api, entrypoints, overview, version } from '$lib/api'; import Highlight, { LineNumbers } from 'svelte-highlight'; import { Copy, CopyCheck } from 'lucide-svelte'; import { json, yaml } from 'svelte-highlight/languages'; import YAML from 'yaml'; + import { onMount } from 'svelte'; let code = $state(''); let displayCode = $state(''); @@ -40,12 +41,10 @@ } }; - profile.subscribe(async (value) => { - if (value.name) { - const config = await api.getDynamicConfig(value.name); - code = JSON.stringify(config, null, 2); - displayCode = code; - } + onMount(async () => { + const config = await api.getDynamicConfig(); + code = JSON.stringify(config, null, 2); + displayCode = code; }); diff --git a/web/src/lib/components/modals/middleware.svelte b/web/src/lib/components/modals/middleware.svelte index a73b1ce..84e1088 100644 --- a/web/src/lib/components/modals/middleware.svelte +++ b/web/src/lib/components/modals/middleware.svelte @@ -16,7 +16,7 @@ import { Input } from '$lib/components/ui/input/index.js'; import { Label } from '$lib/components/ui/label/index.js'; import { Switch } from '$lib/components/ui/switch/index.js'; - import { api, profile } from '$lib/api'; + import { api } from '$lib/api'; import { toast } from 'svelte-sonner'; import Separator from '../ui/separator/separator.svelte'; import DynamicForm from '../forms/DynamicForm.svelte'; @@ -94,7 +94,7 @@ type: data.type, ...(data.protocol === 'http' ? { middleware: data } : { tcpMiddleware: data }) }; - await api.upsertMiddleware($profile.id, params); + await api.upsertMiddleware(params); toast.success(`Middleware updated successfully`); resetForm(); } catch (err: unknown) { diff --git a/web/src/lib/components/modals/router.svelte b/web/src/lib/components/modals/router.svelte index 6e2d9e8..c40220b 100644 --- a/web/src/lib/components/modals/router.svelte +++ b/web/src/lib/components/modals/router.svelte @@ -1,5 +1,5 @@ @@ -202,7 +211,7 @@ {#if showSourceTabs} - handleTabChange(value as TraefikSource)}> + Local API @@ -320,10 +329,8 @@ ( - table.setPageSize(Number(value)), (pagination.pageSize = Number(value)) - )} + bind:value={limit.value} + onValueChange={handleLimitChange} > {pagination.pageSize} diff --git a/web/src/lib/components/ui/array-input/array-input.svelte b/web/src/lib/components/ui/array-input/array-input.svelte deleted file mode 100644 index 2ca936e..0000000 --- a/web/src/lib/components/ui/array-input/array-input.svelte +++ /dev/null @@ -1,103 +0,0 @@ - - -
- -
    - {#each items || [] as item, index} -
  • - {#if !disabled} -
    - {#if index === 0} - - {/if} - {#if (items?.length ?? 0) > 1 && index >= 1} - - {/if} -
    - {/if} - {#if items} - update(index, e.target)} - {disabled} - /> - {/if} -
  • - {/each} -
-
diff --git a/web/src/lib/components/ui/object-input/object-input.svelte b/web/src/lib/components/ui/object-input/object-input.svelte deleted file mode 100644 index 20c9cb8..0000000 --- a/web/src/lib/components/ui/object-input/object-input.svelte +++ /dev/null @@ -1,141 +0,0 @@ - - -
- -
    - {#each internalItems as { id, key, value }, index (id)} -
  • - {#if !disabled} -
    - {#if index === 0} - - {/if} - {#if internalItems.length > 1 && index >= 1} - - {/if} -
    - {/if} - updateKey(id, e)} - {disabled} - /> - updateValue(id, e)} - {disabled} - /> -
  • - {/each} -
-
diff --git a/web/src/lib/components/utils/HoverInfo.svelte b/web/src/lib/components/utils/HoverInfo.svelte deleted file mode 100644 index 446e11e..0000000 --- a/web/src/lib/components/utils/HoverInfo.svelte +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - {text} - - diff --git a/web/src/lib/components/utils/middlewareModules.ts b/web/src/lib/components/utils/middlewareModules.ts deleted file mode 100644 index 6fb503e..0000000 --- a/web/src/lib/components/utils/middlewareModules.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { Middleware } from '$lib/types/middlewares'; -import type { SvelteComponent } from 'svelte'; - -export const MiddlewareForms = { - addPrefix: import('$lib/components/forms/addPrefix.svelte'), - stripPrefix: import('$lib/components/forms/stripPrefix.svelte'), - stripPrefixRegex: import('$lib/components/forms/stripPrefixRegex.svelte'), - replacePath: import('$lib/components/forms/replacePath.svelte'), - replacePathRegex: import('$lib/components/forms/replacePathRegex.svelte'), - chain: import('$lib/components/forms/chain.svelte'), - ipAllowList: import('$lib/components/forms/ipAllowList.svelte'), - headers: import('$lib/components/forms/headers.svelte'), - errors: import('$lib/components/forms/errorPage.svelte'), - rateLimit: import('$lib/components/forms/rateLimit.svelte'), - redirectRegex: import('$lib/components/forms/redirectRegex.svelte'), - redirectScheme: import('$lib/components/forms/redirectScheme.svelte'), - basicAuth: import('$lib/components/forms/basicAuth.svelte'), - digestAuth: import('$lib/components/forms/digestAuth.svelte'), - forwardAuth: import('$lib/components/forms/forwardAuth.svelte'), - inFlightReq: import('$lib/components/forms/inFlightReq.svelte'), - buffering: import('$lib/components/forms/buffering.svelte'), - circuitBreaker: import('$lib/components/forms/circuitBreaker.svelte'), - compress: import('$lib/components/forms/compress.svelte'), - passTLSClientCert: import('$lib/components/forms/passTLSClientCert.svelte'), - retry: import('$lib/components/forms/retry.svelte'), - plugin: import('$lib/components/forms/plugin.svelte'), - - // TCP-specific - inFlightConn: import('$lib/components/forms/inFlightConn.svelte') -}; - -export const LoadMiddlewareForm = async ( - mw: Middleware -): Promise => { - const moduleKey = Object.keys(MiddlewareForms).find( - (key) => key.toLowerCase() === mw.type?.toLowerCase() - ) as keyof typeof MiddlewareForms | undefined; - - return moduleKey ? (await MiddlewareForms[moduleKey]).default : null; -}; diff --git a/web/src/lib/components/utils/ruleEditor.svelte b/web/src/lib/components/utils/ruleEditor.svelte index fe73e53..f8c6caa 100644 --- a/web/src/lib/components/utils/ruleEditor.svelte +++ b/web/src/lib/components/utils/ruleEditor.svelte @@ -1,14 +1,12 @@ value && localStorage.setItem(RULE_EDITOR_TAB_SK, value)} + bind:value={ruleTab.value} + onValueChange={(value) => (ruleTab.value = value)} class="flex flex-col gap-2" >
diff --git a/web/src/lib/components/utils/validation.ts b/web/src/lib/components/utils/validation.ts deleted file mode 100644 index 3c50213..0000000 --- a/web/src/lib/components/utils/validation.ts +++ /dev/null @@ -1,103 +0,0 @@ -// Some extra zod validations -import { z } from 'zod'; - -const ipv4Regex = - /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/; -const ipv4CidrRegex = /^(3[0-2]|[12]?[0-9])$/; - -const ipv6Regex = - /^(([a-f0-9]{1,4}:){7}|::([a-f0-9]{1,4}:){0,6}|([a-f0-9]{1,4}:){1}:([a-f0-9]{1,4}:){0,5}|([a-f0-9]{1,4}:){2}:([a-f0-9]{1,4}:){0,4}|([a-f0-9]{1,4}:){3}:([a-f0-9]{1,4}:){0,3}|([a-f0-9]{1,4}:){4}:([a-f0-9]{1,4}:){0,2}|([a-f0-9]{1,4}:){5}:([a-f0-9]{1,4}:){0,1})([a-f0-9]{1,4}|(((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2}))\.){3}((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2})))$/; -const ipv6CidrRegex = /^(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/; - -const timeUnitRegex = /^(0|[1-9]\d*)(ns|us|µs|ms|s|m|h)$/; - -// Custom Zod schema to validate either IPv4 or IPv6 with or without CIDR -export const CustomIPSchema = z - .string() - .trim() - .refine( - (value) => { - const [ipAddress, mask] = value.split('/'); - if (!mask) { - return ipv4Regex.test(ipAddress) || ipv6Regex.test(ipAddress); - } else { - return ( - (ipv4Regex.test(ipAddress) && ipv4CidrRegex.test(mask)) || - (ipv6Regex.test(ipAddress) && ipv6CidrRegex.test(mask)) - ); - } - }, - { - message: 'Invalid IP address or CIDR notation' - } - ); - -export const CustomIPSchemaOptional = z - .string() - .trim() - .refine( - (value) => { - const [ipAddress, mask] = value.split('/'); - if (!ipAddress) return true; - if (!mask) { - return ipv4Regex.test(ipAddress) || ipv6Regex.test(ipAddress); - } else { - return ( - (ipv4Regex.test(ipAddress) && ipv4CidrRegex.test(mask)) || - (ipv6Regex.test(ipAddress) && ipv6CidrRegex.test(mask)) - ); - } - }, - { - message: 'Invalid IP address or CIDR notation' - } - ); - -export const CustomTimeUnitSchema = z - .string() - .trim() - .refine((value) => timeUnitRegex.test(value), { - message: 'Invalid time unit' - }); - -export const CustomTimeUnitSchemaOptional = z - .string() - .trim() - .optional() - .refine( - (value) => { - if (!value) return true; - return timeUnitRegex.test(value); - }, - { - message: 'Invalid time unit' - } - ); - -const isEmpty = (obj: any): boolean => { - if (obj === null || obj === undefined) return true; // Handle null and undefined - if (typeof obj === 'string') return obj.trim() === ''; // Handle empty strings - if (Array.isArray(obj)) return obj.length === 0; // Handle empty arrays - - // Check if all values in an object are empty - if (typeof obj === 'object') { - return Object.keys(obj).length === 0 || Object.values(obj).every(isEmpty); - } - return false; -}; -export const cleanEmptyObjects = (obj: any) => { - for (const key in obj) { - if (Array.isArray(obj[key])) { - // If it's an array, check if it's empty or contains only empty strings - if (obj[key].length === 0 || (obj[key].length === 1 && obj[key][0] === '')) { - delete obj[key]; // Delete the empty array - } - } else if (typeof obj[key] === 'object' && obj[key] !== null) { - // If it's an object, recursively check its properties - cleanEmptyObjects(obj[key]); - if (isEmpty(obj[key])) { - delete obj[key]; // Delete if the nested object is empty - } - } - } -}; diff --git a/web/src/lib/storage.svelte.ts b/web/src/lib/storage.svelte.ts new file mode 100644 index 0000000..30ffcd6 --- /dev/null +++ b/web/src/lib/storage.svelte.ts @@ -0,0 +1,35 @@ +export class LocalStorage { + #key: string; + #value = $state(undefined); + + constructor(key: string, initial?: T) { + this.#key = key; + + if (typeof localStorage !== 'undefined') { + const stored = localStorage.getItem(key); + this.#value = stored ? JSON.parse(stored) : initial; + + if (stored === null && initial !== undefined) { + localStorage.setItem(key, JSON.stringify(initial)); + } + } else { + this.#value = initial; + } + } + + get value(): T | undefined { + return this.#value; + } + + set value(newValue: T | undefined) { + this.#value = newValue; + if (typeof localStorage !== 'undefined') { + localStorage.setItem(this.#key, JSON.stringify(newValue)); + } + } +} + +// Helper function to create a new localStorage store +export function createLocalStorage(key: string, defaultValue: T) { + return new LocalStorage(key, defaultValue); +} diff --git a/web/src/lib/stores/common.ts b/web/src/lib/stores/common.ts new file mode 100644 index 0000000..0b53101 --- /dev/null +++ b/web/src/lib/stores/common.ts @@ -0,0 +1,16 @@ +import { createLocalStorage } from '$lib/storage.svelte'; + +export const token = createLocalStorage('auth-token', null); +export const limit = createLocalStorage('limit', '10'); +export const routerColumns = createLocalStorage('router-columns', []); +export const middlewareColumns = createLocalStorage('middleware-columns', []); +export const ruleTab = createLocalStorage('rule-tab', 'simple'); + +export const DateFormat = new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric' +}); diff --git a/web/src/lib/stores/profile.ts b/web/src/lib/stores/profile.ts new file mode 100644 index 0000000..9ca22d3 --- /dev/null +++ b/web/src/lib/stores/profile.ts @@ -0,0 +1,38 @@ +import type { Profile } from '$lib/types'; +import { createLocalStorage } from '$lib/storage.svelte'; + +class ProfileStore { + private store = createLocalStorage('selected_profile', null); + + get value(): Profile | null { + return this.store.value ?? null; + } + + set value(profile: Profile | null) { + this.store.value = profile; + } + + // Helper methods for safe access + get id(): number | undefined { + return this.value?.id; + } + + get name(): string | undefined { + return this.value?.name; + } + + // Validation methods + hasValidId(): boolean { + return typeof this.id === 'number' && !isNaN(this.id); + } + + hasValidName(): boolean { + return typeof this.name === 'string' && this.name.length > 0; + } + + isValid(): boolean { + return this.value !== null && this.hasValidId() && this.hasValidName(); + } +} + +export const profile = new ProfileStore(); diff --git a/web/src/lib/stores/source.ts b/web/src/lib/stores/source.ts new file mode 100644 index 0000000..41de239 --- /dev/null +++ b/web/src/lib/stores/source.ts @@ -0,0 +1,39 @@ +import { TraefikSource } from '$lib/types'; +import { createLocalStorage } from '$lib/storage.svelte'; + +class TraefikSourceStore { + private store = createLocalStorage('traefik_source', TraefikSource.API); + + get value(): TraefikSource { + return this.store.value ?? TraefikSource.API; + } + + set value(source: TraefikSource) { + if (this.isValid(source)) { + this.store.value = source; + } + } + + isValid(source: string): source is TraefikSource { + return Object.values(TraefikSource).includes(source as TraefikSource); + } + + isApi(): boolean { + return this.value === TraefikSource.API; + } + + isLocal(): boolean { + return this.value === TraefikSource.LOCAL; + } + + isAgent(): boolean { + return this.value === TraefikSource.AGENT; + } + + // Reset to default + reset(): void { + this.store.value = TraefikSource.API; + } +} + +export const source = new TraefikSourceStore(); diff --git a/web/src/lib/store.ts b/web/src/lib/stores/theme.ts similarity index 56% rename from web/src/lib/store.ts rename to web/src/lib/stores/theme.ts index 3f07621..9d843c0 100644 --- a/web/src/lib/store.ts +++ b/web/src/lib/stores/theme.ts @@ -1,25 +1,5 @@ import { writable } from 'svelte/store'; -// Some global constants -export const DateFormat = new Intl.DateTimeFormat('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - hour: 'numeric', - minute: 'numeric', - second: 'numeric' -}); - -// Localstorage keys -export const PROFILE_SK = 'profile'; -export const TOKEN_SK = 'token'; -export const LIMIT_SK = 'limit'; -export const LOCAL_PROVIDER_SK = 'local-provider'; -export const ROUTER_COLUMN_SK = 'router-columns'; -export const MIDDLEWARE_COLUMN_SK = 'middleware-columns'; -export const RULE_EDITOR_TAB_SK = 'rule-editor-tab'; -export const SOURCE_TAB_SK = 'traefik-source-tab'; - // Light/Dark Mode const getInitialTheme = () => { if (typeof window !== 'undefined') { diff --git a/web/src/lib/stores/user.ts b/web/src/lib/stores/user.ts new file mode 100644 index 0000000..13eb336 --- /dev/null +++ b/web/src/lib/stores/user.ts @@ -0,0 +1,73 @@ +import type { User } from '$lib/types'; +import { createLocalStorage } from '$lib/storage.svelte'; + +class UserStore { + private store = createLocalStorage('current_user', null); + + get value(): User | null { + return this.store.value ?? null; + } + + set value(user: User | null) { + this.store.value = user; + } + + // Safe getters for required fields + get id(): number | undefined { + return this.value?.id; + } + + get username(): string | undefined { + return this.value?.username; + } + + get isAdmin(): boolean { + return this.value?.isAdmin ?? false; + } + + // Optional field getters + get email(): string | undefined { + return this.value?.email; + } + + get lastLogin(): string | undefined { + return this.value?.lastLogin; + } + + get createdAt(): string | undefined { + return this.value?.createdAt; + } + + get updatedAt(): string | undefined { + return this.value?.updatedAt; + } + + // Validation methods + hasValidId(): boolean { + return typeof this.id === 'number' && !isNaN(this.id); + } + + hasValidUsername(): boolean { + return typeof this.username === 'string' && this.username.length > 0; + } + + isLoggedIn(): boolean { + return this.value !== null && this.hasValidId() && this.hasValidUsername(); + } + + hasEmail(): boolean { + return typeof this.email === 'string' && this.email.length > 0; + } + + // Authorization methods + canAccessAdmin(): boolean { + return this.isLoggedIn() && this.isAdmin; + } + + // Clear user data (logout) + clear(): void { + this.store.value = null; + } +} + +export const user = new UserStore(); diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 772f872..d35a2fa 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -6,9 +6,10 @@ import AppFooter from '$lib/components/nav/AppFooter.svelte'; import { Toaster } from '$lib/components/ui/sonner'; import { onMount } from 'svelte'; - import { api, BASE_URL, user, profile } from '$lib/api'; + import { api, BASE_URL } from '$lib/api'; import autoAnimate from '@formkit/auto-animate'; - import { TraefikSource } from '$lib/types'; + import { source } from '$lib/stores/source'; + import { user } from '$lib/stores/user'; // import CommandCenter from '$lib/components/utils/commandCenter.svelte'; interface Props { @@ -18,16 +19,22 @@ let { children }: Props = $props(); // Realtime updates + interface Event { + type: string; + message: string; + } + const eventSource = new EventSource(`${BASE_URL}/events`); - eventSource.onmessage = (event) => { - if (!$user) return; - let data = JSON.parse(event.data); + eventSource.onmessage = async (event) => { + if (!user.isLoggedIn()) return; + let data: Event = JSON.parse(event.data); switch (data.message) { case 'profile': api.listProfiles(); break; case 'traefik': - api.getTraefikConfig($profile.id, TraefikSource.LOCAL); + if (!source.isValid(source.value)) return; + await api.getTraefikConfig(source.value); break; case 'user': api.listUsers(); @@ -44,7 +51,6 @@ }; onMount(async () => { - if (!$user) return; await api.load(); }); @@ -53,7 +59,7 @@ - {#if $user} + {#if user.isLoggedIn()}
diff --git a/web/src/routes/+layout.ts b/web/src/routes/+layout.ts index d625076..4436508 100644 --- a/web/src/routes/+layout.ts +++ b/web/src/routes/+layout.ts @@ -1,8 +1,9 @@ import type { LayoutLoad } from './$types'; -import { TOKEN_SK } from '$lib/store'; -import { api, user } from '$lib/api'; +import { api } from '$lib/api'; import { goto } from '$app/navigation'; import { toast } from 'svelte-sonner'; +import { user } from '$lib/stores/user'; +import { token } from '$lib/stores/common'; export const ssr = false; export const prerender = true; @@ -11,24 +12,17 @@ export const trailingSlash = 'always'; const isPublicRoute = (path: string) => path.startsWith('/login/'); export const load: LayoutLoad = async ({ url, fetch }) => { - const token = localStorage.getItem(TOKEN_SK); - // Case 1: No token and accessing protected route - if (!token && !isPublicRoute(url.pathname)) { + if (!token.value && !isPublicRoute(url.pathname)) { await goto('/login/'); - user.set(null); + user.clear(); return; } // Case 2: Has token, verify it - if (token) { + if (token.value) { try { - const verified = await api.verify(fetch); - - // Token is valid - if (verified) { - user.set(verified); - } + await api.verify(fetch); // Trying to access public route if (isPublicRoute(url.pathname)) { @@ -42,13 +36,13 @@ export const load: LayoutLoad = async ({ url, fetch }) => { if (!isPublicRoute(url.pathname)) { await goto('/login'); } - user.set(null); + user.clear(); toast.error('Session expired', { description: error.message }); return; } } // Case 3: No token and accessing public route - user.set(null); + user.clear(); return; }; diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 5c1e62c..1ba1446 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -3,7 +3,7 @@ import { Badge } from '$lib/components/ui/badge'; import { Globe, Shield, Bot, LayoutDashboard, Origami, Users } from 'lucide-svelte'; import { onMount } from 'svelte'; - import { api, profiles, profile, stats } from '$lib/api'; + import { api, profiles, stats } from '$lib/api'; import { TraefikSource, type Agent } from '$lib/types'; import { type Router, type Service } from '$lib/types/router'; import type { Middleware } from '$lib/types/middlewares'; @@ -19,10 +19,10 @@ await api.loadStats(); if (!$profiles) return; - await api.getTraefikConfig($profile.id, TraefikSource.LOCAL); + await api.getTraefikConfig(TraefikSource.LOCAL); // Get profile stats - const t = await api.getTraefikConfigLocal($profile.id); + const t = await api.getTraefikConfigLocal(); const a = await api.listAgentsByProfile(); profileStats.routers = t?.routers || []; profileStats.services = t?.services || []; diff --git a/web/src/routes/agents/+page.svelte b/web/src/routes/agents/+page.svelte index 474149d..5c35722 100644 --- a/web/src/routes/agents/+page.svelte +++ b/web/src/routes/agents/+page.svelte @@ -5,10 +5,11 @@ import { Bot, KeyRound, Pencil, Trash } from 'lucide-svelte'; import { type Agent } from '$lib/types'; import AgentModal from '$lib/components/modals/agent.svelte'; - import { api, agents, profile } from '$lib/api'; + import { api, agents } from '$lib/api'; import { renderComponent } from '$lib/components/ui/data-table'; import { toast } from 'svelte-sonner'; - import { DateFormat } from '$lib/store'; + import { DateFormat } from '$lib/stores/common'; + import { onMount } from 'svelte'; interface ModalState { isOpen: boolean; @@ -118,10 +119,8 @@ return agentOffline(agent) ? 'bg-green-500/10' : 'bg-red-500/10'; } - profile.subscribe((value) => { - if (value.id) { - api.listAgentsByProfile(); - } + onMount(async () => { + await api.listAgentsByProfile(); }); diff --git a/web/src/routes/dns/+page.svelte b/web/src/routes/dns/+page.svelte index fccba17..182e15e 100644 --- a/web/src/routes/dns/+page.svelte +++ b/web/src/routes/dns/+page.svelte @@ -10,7 +10,7 @@ import { renderComponent } from '$lib/components/ui/data-table'; import { toast } from 'svelte-sonner'; import { onMount } from 'svelte'; - import { DateFormat } from '$lib/store'; + import { DateFormat } from '$lib/stores/common'; interface ModalState { isOpen: boolean; diff --git a/web/src/routes/middlewares/+page.svelte b/web/src/routes/middlewares/+page.svelte index 1baa7b1..2c6c90d 100644 --- a/web/src/routes/middlewares/+page.svelte +++ b/web/src/routes/middlewares/+page.svelte @@ -8,9 +8,11 @@ import type { Middleware, SupportedMiddleware } from '$lib/types/middlewares'; import { Eye, Layers, Pencil, Trash } from 'lucide-svelte'; import { TraefikSource } from '$lib/types'; - import { api, profile, middlewares, source } from '$lib/api'; + import { api, middlewares } from '$lib/api'; import { renderComponent } from '$lib/components/ui/data-table'; import { toast } from 'svelte-sonner'; + import { source } from '$lib/stores/source'; + import { onMount } from 'svelte'; interface ModalState { isOpen: boolean; @@ -45,7 +47,7 @@ return; } - await api.deleteMiddleware($profile.id, middleware); + await api.deleteMiddleware(middleware); toast.success('Middleware deleted'); } catch (err: unknown) { const e = err as Error; @@ -102,7 +104,7 @@ id: 'actions', enableHiding: false, cell: ({ row }) => { - if ($source === TraefikSource.LOCAL) { + if (source.value === TraefikSource.LOCAL) { return renderComponent(TableActions, { actions: [ { @@ -147,10 +149,8 @@ } ]; - profile.subscribe((value) => { - if (value.id) { - api.getTraefikConfig(value.id, $source); - } + onMount(() => { + api.getTraefikConfig(source.value); }); @@ -158,7 +158,7 @@ Middlewares - +
diff --git a/web/src/routes/plugins/+page.svelte b/web/src/routes/plugins/+page.svelte index 25c164e..f7006d1 100644 --- a/web/src/routes/plugins/+page.svelte +++ b/web/src/routes/plugins/+page.svelte @@ -8,12 +8,12 @@ import { Input } from '$lib/components/ui/input'; import { Delete } from 'lucide-svelte'; import type { Plugin } from '$lib/types'; - import { profile, api, plugins } from '$lib/api'; + import { api, plugins } from '$lib/api'; import { onMount } from 'svelte'; import { Textarea } from '$lib/components/ui/textarea'; import { toast } from 'svelte-sonner'; import YAML from 'yaml'; - import type { HTTPMiddleware, UpsertMiddlewareParams } from '$lib/types/middlewares'; + import type { UpsertMiddlewareParams } from '$lib/types/middlewares'; // State let open = $state(false); @@ -44,8 +44,6 @@ }); async function installPlugin(plugin: Plugin) { - if (!$profile.id) return; - const data = YAML.parse(plugin.snippet.yaml); const pluginContent = extractPluginContent(data); const name = Object.keys(pluginContent)[0]; @@ -62,7 +60,7 @@ } } }; - await api.upsertMiddleware($profile.id, middleware); + await api.upsertMiddleware(middleware); selectedPlugin = plugin; yamlSnippet = generateYamlSnippet(plugin); diff --git a/web/src/routes/router/+page.svelte b/web/src/routes/router/+page.svelte index aa1bc4c..274ae41 100644 --- a/web/src/routes/router/+page.svelte +++ b/web/src/routes/router/+page.svelte @@ -8,10 +8,11 @@ import type { Router, Service, TLS } from '$lib/types/router'; import { Pencil, Route, Trash } from 'lucide-svelte'; import { TraefikSource } from '$lib/types'; - import { api, profile, routers, services, source } from '$lib/api'; + import { api, routerServiceMerge, type RouterWithService } from '$lib/api'; import { renderComponent } from '$lib/components/ui/data-table'; import { toast } from 'svelte-sonner'; - import { SOURCE_TAB_SK } from '$lib/store'; + import { source } from '$lib/stores/source'; + import { onMount } from 'svelte'; interface ModalState { isOpen: boolean; @@ -30,8 +31,8 @@ service: '' }; const defaultService: Service = { - name: '', - protocol: 'http', + name: defaultRouter.name, + protocol: defaultRouter.protocol, loadBalancer: { servers: [], passHostHeader: true @@ -71,7 +72,7 @@ return; } - await api.deleteRouter($profile.id, router); + await api.deleteRouter(router); toast.success('Router deleted'); } catch (err: unknown) { const e = err as Error; @@ -79,24 +80,6 @@ } }; - type RouterWithService = { router: Router; service: Service }; - let mergedData: RouterWithService[] = $derived( - $routers.map((router) => { - const routerProvider = router.name.split('@')[1]; - let serviceName = router.service; // api@internal - - // Most of time the service name doesn't include the provider - if (!router.service?.includes('@')) { - serviceName = router.service + '@' + routerProvider; - } - const service = $services.find((service) => service.name === serviceName); - return { - router, - service: service || ({} as Service) - }; - }) - ); - const columns: ColumnDef[] = [ { header: 'Name', @@ -126,7 +109,7 @@ cell: ({ row }) => { const name = row.getValue('provider') as string; const provider = name.split('@')[1]; - if (!provider && $source === TraefikSource.AGENT) { + if (!provider && source.value === TraefikSource.AGENT) { return renderComponent(ColumnBadge, { label: 'agent', variant: 'secondary' @@ -214,7 +197,7 @@ id: 'actions', enableHiding: false, cell: ({ row }) => { - if ($source === TraefikSource.LOCAL) { + if (source.value === TraefikSource.LOCAL) { return renderComponent(TableActions, { actions: [ { @@ -251,14 +234,8 @@ } ]; - profile.subscribe((value) => { - if (value.id) { - let savedSource = localStorage.getItem(SOURCE_TAB_SK) as TraefikSource; - if (savedSource) { - source.set(savedSource); - api.getTraefikConfig(value.id, savedSource); - } - } + onMount(() => { + api.getTraefikConfig(source.value); }); @@ -266,7 +243,7 @@ Routers - +
@@ -275,7 +252,7 @@

Router Management

- +
@@ -299,7 +276,7 @@

Router Management

- +
diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte index b83e072..4f44434 100644 --- a/web/src/routes/settings/+page.svelte +++ b/web/src/routes/settings/+page.svelte @@ -11,7 +11,7 @@ import { onMount } from 'svelte'; import type { Setting } from '$lib/types'; import { toast } from 'svelte-sonner'; - import { DateFormat } from '$lib/store'; + import { DateFormat } from '$lib/stores/common'; let hasChanges = $state(false); let changedValues = $state>({}); diff --git a/web/src/routes/users/+page.svelte b/web/src/routes/users/+page.svelte index 25602dc..d0393d7 100644 --- a/web/src/routes/users/+page.svelte +++ b/web/src/routes/users/+page.svelte @@ -10,7 +10,7 @@ import { renderComponent } from '$lib/components/ui/data-table'; import { toast } from 'svelte-sonner'; import { onMount } from 'svelte'; - import { DateFormat } from '$lib/store'; + import { DateFormat } from '$lib/stores/common'; interface ModalState { isOpen: boolean;