Multi-tenant build system with K8s namespace separation
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 <noreply@anthropic.com>
This commit is contained in:
parent
db10ab93fd
commit
42c6079755
1
.env.loop42
Normal file
1
.env.loop42
Normal file
@ -0,0 +1 @@
|
|||||||
|
VITE_TENANT=loop42
|
||||||
@ -1 +1,2 @@
|
|||||||
VITE_WS_URL=wss://assay.loop42.de/ws
|
# Default production tenant (overridden by --mode loop42 or --mode dev)
|
||||||
|
VITE_TENANT=loop42
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
FROM node:22-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
|
ARG TENANT=loop42
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npx vite build --mode $TENANT
|
||||||
|
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|||||||
10
src/App.vue
10
src/App.vue
@ -14,7 +14,7 @@
|
|||||||
<TtsPlayerBar />
|
<TtsPlayerBar />
|
||||||
<!-- Socket views: v-if=visited (lazy first mount), class-based hide (preserve scroll) -->
|
<!-- Socket views: v-if=visited (lazy first mount), class-based hide (preserve scroll) -->
|
||||||
<AgentsView v-if="visited.nyx" :class="{ 'view-hidden': route.name !== 'nyx' }" />
|
<AgentsView v-if="visited.nyx" :class="{ 'view-hidden': route.name !== 'nyx' }" />
|
||||||
<ViewerView v-if="visited.viewer" :class="{ 'view-hidden': route.name !== 'viewer' }" />
|
<ViewerView v-if="ViewerView && visited.viewer" :class="{ 'view-hidden': route.name !== 'viewer' }" />
|
||||||
<!-- Non-socket views: scrollable -->
|
<!-- Non-socket views: scrollable -->
|
||||||
<div v-if="routerReady && !isSocketRoute" class="page-scroll">
|
<div v-if="routerReady && !isSocketRoute" class="page-scroll">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
@ -33,6 +33,7 @@ import { useRoute } from 'vue-router';
|
|||||||
import { ws, auth, agents } from './store';
|
import { ws, auth, agents } from './store';
|
||||||
import { THEME_LOGOS, useTheme } from './composables/useTheme';
|
import { THEME_LOGOS, useTheme } from './composables/useTheme';
|
||||||
import { useChatStore } from './store/chat';
|
import { useChatStore } from './store/chat';
|
||||||
|
import tenant from './tenant';
|
||||||
|
|
||||||
import { defineAsyncComponent } from 'vue';
|
import { defineAsyncComponent } from 'vue';
|
||||||
|
|
||||||
@ -44,11 +45,14 @@ import router from './router';
|
|||||||
|
|
||||||
import TtsPlayerBar from './components/TtsPlayerBar.vue';
|
import TtsPlayerBar from './components/TtsPlayerBar.vue';
|
||||||
const AgentsView = defineAsyncComponent(() => import('./views/AgentsView.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 chatStore = useChatStore();
|
||||||
const visited = reactive({ nyx: false, viewer: false });
|
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);
|
const routerReady = ref(false);
|
||||||
router.isReady().then(() => { routerReady.value = true; });
|
router.isReady().then(() => { routerReady.value = true; });
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { ref, watch, type Component } from 'vue';
|
import { ref, watch, type Component } from 'vue';
|
||||||
import { CommandLineIcon, SunIcon, CubeTransparentIcon } from '@heroicons/vue/24/outline';
|
import { CommandLineIcon, SunIcon, CubeTransparentIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import tenant from '../tenant';
|
||||||
|
|
||||||
export type Theme = 'titan' | 'eras' | 'loop42';
|
export type Theme = 'titan' | 'eras' | 'loop42';
|
||||||
|
|
||||||
@ -37,9 +38,10 @@ export function agentLogo(agentId: string): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
const defaultTheme = (tenant.defaultTheme || 'loop42') as Theme;
|
||||||
const theme = ref<Theme>(
|
const theme = ref<Theme>(
|
||||||
stored === 'workhorse' ? 'loop42' : // migrate legacy name
|
stored === 'workhorse' ? 'loop42' : // migrate legacy name
|
||||||
(stored as Theme) || 'loop42'
|
(stored as Theme) || defaultTheme
|
||||||
);
|
);
|
||||||
|
|
||||||
const THEME_FAVICONS: Record<Theme, string> = {
|
const THEME_FAVICONS: Record<Theme, string> = {
|
||||||
|
|||||||
@ -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 LoginView from './views/LoginView.vue';
|
||||||
import { THEME_NAMES, useTheme } from './composables/useTheme';
|
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({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes: [
|
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: '/' },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auth guard — redirect to /login for protected routes
|
// Auth guard — redirect to /login for protected routes
|
||||||
|
|||||||
34
src/tenant.ts
Normal file
34
src/tenant.ts
Normal file
@ -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;
|
||||||
21
src/tenantPages.ts
Normal file
21
src/tenantPages.ts
Normal file
@ -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');
|
||||||
16
tenants/dev/config.ts
Normal file
16
tenants/dev/config.ts
Normal file
@ -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;
|
||||||
55
tenants/dev/pages/HomePage.vue
Normal file
55
tenants/dev/pages/HomePage.vue
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dev-home">
|
||||||
|
<div class="hero">
|
||||||
|
<h1>loop42 <em>Dev</em></h1>
|
||||||
|
<p>Development environment with full debug tools.</p>
|
||||||
|
<router-link to="/nyx" class="btn">Open nyx</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dev-home { color: var(--text); }
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 6rem 2rem 4rem;
|
||||||
|
min-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: clamp(2rem, 5vw, 3.5rem);
|
||||||
|
font-weight: 300;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 em {
|
||||||
|
font-style: normal;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.btn:hover { opacity: 0.85; }
|
||||||
|
</style>
|
||||||
16
tenants/loop42/config.ts
Normal file
16
tenants/loop42/config.ts
Normal file
@ -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;
|
||||||
Reference in New Issue
Block a user