From 42c60797558b486ba41801ee5bb10d78fcae7722 Mon Sep 17 00:00:00 2001 From: Nico Date: Wed, 1 Apr 2026 17:09:22 +0200 Subject: [PATCH] Multi-tenant build system with K8s namespace separation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build-time tenant config via VITE_TENANT env var (--mode loop42/dev). Content pages moved to tenants/{name}/pages/ with dynamic imports and loop42 fallback. Feature-gated routing (viewer/devTools per tenant). Dockerfile parameterized with TENANT build arg. Deployed to separate K8s namespaces: loop42.de → ns/loop42, loop42.dev → ns/dev. Co-Authored-By: Claude Opus 4.6 --- .env.dev | 1 + .env.loop42 | 1 + .env.production | 3 +- Dockerfile | 3 +- src/App.vue | 10 +++- src/composables/useTheme.ts | 4 +- src/router.ts | 32 +++++++---- src/tenant.ts | 34 ++++++++++++ src/tenantPages.ts | 21 +++++++ tenants/dev/config.ts | 16 ++++++ tenants/dev/pages/HomePage.vue | 55 +++++++++++++++++++ tenants/loop42/config.ts | 16 ++++++ .../loop42/pages/DatenschutzPage.vue | 0 .../loop42/pages/HomePage.vue | 0 .../loop42/pages/ImpressumPage.vue | 0 15 files changed, 178 insertions(+), 18 deletions(-) create mode 100644 .env.dev create mode 100644 .env.loop42 create mode 100644 src/tenant.ts create mode 100644 src/tenantPages.ts create mode 100644 tenants/dev/config.ts create mode 100644 tenants/dev/pages/HomePage.vue create mode 100644 tenants/loop42/config.ts rename src/views/DatenschutzView.vue => tenants/loop42/pages/DatenschutzPage.vue (100%) rename src/views/CompanyView.vue => tenants/loop42/pages/HomePage.vue (100%) rename src/views/ImpressumView.vue => tenants/loop42/pages/ImpressumPage.vue (100%) diff --git a/.env.dev b/.env.dev new file mode 100644 index 0000000..b2ee630 --- /dev/null +++ b/.env.dev @@ -0,0 +1 @@ +VITE_TENANT=dev diff --git a/.env.loop42 b/.env.loop42 new file mode 100644 index 0000000..ba459df --- /dev/null +++ b/.env.loop42 @@ -0,0 +1 @@ +VITE_TENANT=loop42 diff --git a/.env.production b/.env.production index 23fe3a5..95048b2 100644 --- a/.env.production +++ b/.env.production @@ -1 +1,2 @@ -VITE_WS_URL=wss://assay.loop42.de/ws +# Default production tenant (overridden by --mode loop42 or --mode dev) +VITE_TENANT=loop42 diff --git a/Dockerfile b/Dockerfile index 4515507..376ad74 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,10 @@ FROM node:22-alpine AS build +ARG TENANT=loop42 WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . -RUN npm run build +RUN npx vite build --mode $TENANT FROM nginx:alpine COPY --from=build /app/dist /usr/share/nginx/html diff --git a/src/App.vue b/src/App.vue index bf37e40..8ceb125 100644 --- a/src/App.vue +++ b/src/App.vue @@ -14,7 +14,7 @@ - +
@@ -33,6 +33,7 @@ import { useRoute } from 'vue-router'; import { ws, auth, agents } from './store'; import { THEME_LOGOS, useTheme } from './composables/useTheme'; import { useChatStore } from './store/chat'; +import tenant from './tenant'; import { defineAsyncComponent } from 'vue'; @@ -44,11 +45,14 @@ import router from './router'; import TtsPlayerBar from './components/TtsPlayerBar.vue'; const AgentsView = defineAsyncComponent(() => import('./views/AgentsView.vue')); -const ViewerView = defineAsyncComponent(() => import('./views/ViewerView.vue')); +const ViewerView = tenant.features.viewer + ? defineAsyncComponent(() => import('./views/ViewerView.vue')) + : null; const chatStore = useChatStore(); const visited = reactive({ nyx: false, viewer: false }); -const isSocketRoute = computed(() => ['nyx', 'viewer'].includes(route.name as string)); +const socketRoutes = ['nyx', ...(tenant.features.viewer ? ['viewer'] : [])]; +const isSocketRoute = computed(() => socketRoutes.includes(route.name as string)); const routerReady = ref(false); router.isReady().then(() => { routerReady.value = true; }); diff --git a/src/composables/useTheme.ts b/src/composables/useTheme.ts index c25ee0d..6cfcd4d 100644 --- a/src/composables/useTheme.ts +++ b/src/composables/useTheme.ts @@ -1,5 +1,6 @@ import { ref, watch, type Component } from 'vue'; import { CommandLineIcon, SunIcon, CubeTransparentIcon } from '@heroicons/vue/24/outline'; +import tenant from '../tenant'; export type Theme = 'titan' | 'eras' | 'loop42'; @@ -37,9 +38,10 @@ export function agentLogo(agentId: string): string | null { } const stored = localStorage.getItem(STORAGE_KEY); +const defaultTheme = (tenant.defaultTheme || 'loop42') as Theme; const theme = ref( stored === 'workhorse' ? 'loop42' : // migrate legacy name - (stored as Theme) || 'loop42' + (stored as Theme) || defaultTheme ); const THEME_FAVICONS: Record = { diff --git a/src/router.ts b/src/router.ts index fde05dc..03980f1 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,20 +1,28 @@ -import { createRouter, createWebHistory } from 'vue-router'; +import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'; import LoginView from './views/LoginView.vue'; import { THEME_NAMES, useTheme } from './composables/useTheme'; +import tenant from './tenant'; +import { HomePage, ImpressumPage, DatenschutzPage } from './tenantPages'; + +const routes: RouteRecordRaw[] = [ + { path: '/', name: 'home', component: HomePage, meta: { suffix: '' } }, + { path: '/impressum', name: 'impressum', component: ImpressumPage, meta: { suffix: 'Impressum' } }, + { path: '/datenschutz', name: 'datenschutz', component: DatenschutzPage, meta: { suffix: 'Datenschutz' } }, + { path: '/login', name: 'login', component: LoginView, meta: { suffix: 'Login' } }, + { path: '/nyx', name: 'nyx', component: () => import('./views/AgentsView.vue'), meta: { suffix: 'nyx', requiresAuth: true } }, + { path: '/agents', redirect: '/nyx' }, + { path: '/chat', redirect: '/nyx' }, +]; + +if (tenant.features.viewer) { + routes.push({ path: '/viewer', name: 'viewer', component: () => import('./views/ViewerView.vue'), meta: { suffix: 'Viewer', requiresAuth: true } }); +} + +routes.push({ path: '/:pathMatch(.*)*', redirect: '/' }); const router = createRouter({ history: createWebHistory(), - routes: [ - { path: '/', name: 'home', component: () => import('./views/CompanyView.vue'), meta: { suffix: '' } }, - { path: '/impressum', name: 'impressum', component: () => import('./views/ImpressumView.vue'), meta: { suffix: 'Impressum' } }, - { path: '/datenschutz', name: 'datenschutz', component: () => import('./views/DatenschutzView.vue'), meta: { suffix: 'Datenschutz' } }, - { path: '/login', name: 'login', component: LoginView, meta: { suffix: 'Login' } }, - { path: '/nyx', name: 'nyx', component: () => import('./views/AgentsView.vue'), meta: { suffix: 'nyx', requiresAuth: true } }, - { path: '/agents', redirect: '/nyx' }, - { path: '/chat', redirect: '/nyx' }, - { path: '/viewer', name: 'viewer', component: () => import('./views/ViewerView.vue'), meta: { suffix: 'Viewer', requiresAuth: true } }, - { path: '/:pathMatch(.*)*', redirect: '/' }, - ], + routes, }); // Auth guard — redirect to /login for protected routes diff --git a/src/tenant.ts b/src/tenant.ts new file mode 100644 index 0000000..24ca157 --- /dev/null +++ b/src/tenant.ts @@ -0,0 +1,34 @@ +/** + * tenant.ts — Build-time tenant configuration + * + * VITE_TENANT selects which tenant config to load. + * Vite's static analysis + tree-shaking ensures only the + * active tenant's code ends up in the bundle. + */ + +export interface TenantFeatures { + graph: boolean; + trace: boolean; + viewer: boolean; + devTools: boolean; +} + +export interface TenantConfig { + id: string; + name: string; + domain: string; + defaultTheme: string; + features: TenantFeatures; +} + +const modules = import.meta.glob('../tenants/*/config.ts', { eager: true, import: 'default' }); + +const tenantId = import.meta.env.VITE_TENANT || 'loop42'; +const key = `../tenants/${tenantId}/config.ts`; +const config = modules[key] as TenantConfig; + +if (!config) { + throw new Error(`Unknown tenant: ${tenantId}. Available: ${Object.keys(modules).join(', ')}`); +} + +export default config; diff --git a/src/tenantPages.ts b/src/tenantPages.ts new file mode 100644 index 0000000..15fa01a --- /dev/null +++ b/src/tenantPages.ts @@ -0,0 +1,21 @@ +/** + * tenantPages.ts — Dynamic page imports per tenant + * + * Uses Vite's glob import to resolve tenant-specific pages. + * Pages not found in the active tenant fall back to loop42's pages. + */ + +const tenantId = import.meta.env.VITE_TENANT || 'loop42'; + +// All tenant pages as lazy loaders +const allPages = import.meta.glob('../tenants/*/pages/*.vue'); + +function resolve(page: string) { + const tenantKey = `../tenants/${tenantId}/pages/${page}.vue`; + const fallbackKey = `../tenants/loop42/pages/${page}.vue`; + return allPages[tenantKey] || allPages[fallbackKey]; +} + +export const HomePage = resolve('HomePage'); +export const ImpressumPage = resolve('ImpressumPage'); +export const DatenschutzPage = resolve('DatenschutzPage'); diff --git a/tenants/dev/config.ts b/tenants/dev/config.ts new file mode 100644 index 0000000..2520af0 --- /dev/null +++ b/tenants/dev/config.ts @@ -0,0 +1,16 @@ +import type { TenantConfig } from '../../src/tenant'; + +const config: TenantConfig = { + id: 'dev', + name: 'loop42 Dev', + domain: 'loop42.dev', + defaultTheme: 'titan', + features: { + graph: true, + trace: true, + viewer: true, + devTools: true, + }, +}; + +export default config; diff --git a/tenants/dev/pages/HomePage.vue b/tenants/dev/pages/HomePage.vue new file mode 100644 index 0000000..5cb1ad6 --- /dev/null +++ b/tenants/dev/pages/HomePage.vue @@ -0,0 +1,55 @@ + + + diff --git a/tenants/loop42/config.ts b/tenants/loop42/config.ts new file mode 100644 index 0000000..1104563 --- /dev/null +++ b/tenants/loop42/config.ts @@ -0,0 +1,16 @@ +import type { TenantConfig } from '../../src/tenant'; + +const config: TenantConfig = { + id: 'loop42', + name: 'loop42', + domain: 'loop42.de', + defaultTheme: 'loop42', + features: { + graph: false, + trace: false, + viewer: false, + devTools: false, + }, +}; + +export default config; diff --git a/src/views/DatenschutzView.vue b/tenants/loop42/pages/DatenschutzPage.vue similarity index 100% rename from src/views/DatenschutzView.vue rename to tenants/loop42/pages/DatenschutzPage.vue diff --git a/src/views/CompanyView.vue b/tenants/loop42/pages/HomePage.vue similarity index 100% rename from src/views/CompanyView.vue rename to tenants/loop42/pages/HomePage.vue diff --git a/src/views/ImpressumView.vue b/tenants/loop42/pages/ImpressumPage.vue similarity index 100% rename from src/views/ImpressumView.vue rename to tenants/loop42/pages/ImpressumPage.vue