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
|
||||
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
|
||||
|
||||
10
src/App.vue
10
src/App.vue
@ -14,7 +14,7 @@
|
||||
<TtsPlayerBar />
|
||||
<!-- Socket views: v-if=visited (lazy first mount), class-based hide (preserve scroll) -->
|
||||
<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 -->
|
||||
<div v-if="routerReady && !isSocketRoute" class="page-scroll">
|
||||
<RouterView />
|
||||
@ -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; });
|
||||
|
||||
@ -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<Theme>(
|
||||
stored === 'workhorse' ? 'loop42' : // migrate legacy name
|
||||
(stored as Theme) || 'loop42'
|
||||
(stored as Theme) || defaultTheme
|
||||
);
|
||||
|
||||
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 { 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
|
||||
|
||||
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