diff --git a/apps/app-htmx/build.mjs b/apps/app-htmx/build.mjs
new file mode 100644
index 000000000..8018e8e8d
--- /dev/null
+++ b/apps/app-htmx/build.mjs
@@ -0,0 +1,29 @@
+import * as esbuild from "esbuild";
+
+await esbuild.build({
+ keepNames: true,
+ bundle: true,
+ platform: "node",
+ format: "esm",
+ target: "esnext",
+ metafile: true,
+ outdir: "out",
+ entryPoints: ["src/index.ts"],
+ mainFields: ["module", "main"],
+ external: [
+ '@clerk/fastify',
+ '@fastify/formbody',
+ '@fastify/view',
+ 'fastify',
+ 'nunjucks',
+ ],
+ banner: {
+ js: [
+ `import { createRequire as topLevelCreateRequire } from 'module';`,
+ `const require = topLevelCreateRequire(import.meta.url);`,
+ `import { fileURLToPath as topLevelFileUrlToPath, URL as topLevelURL } from "url"`,
+ `const __dirname = topLevelFileUrlToPath(new topLevelURL(".", import.meta.url))`,
+ ].join("\n"),
+ },
+
+});
\ No newline at end of file
diff --git a/apps/app-htmx/package.json b/apps/app-htmx/package.json
new file mode 100644
index 000000000..d023b3272
--- /dev/null
+++ b/apps/app-htmx/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "@acme/app-htmx",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "start": "esbuild src/index.ts --bundle --platform=node | npm run with-env node",
+ "dev": "node build.mjs && npm run with-env node ./out/index.js",
+ "with-env": "dotenv -e ../../.env --"
+ },
+ "devDependencies": {
+ "@types/nunjucks": "3.2.6",
+ "dotenv-cli": "^7.3.0",
+ "esbuild": "^0.19.9"
+ },
+ "dependencies": {
+ "@acme/source-control": "*",
+ "@acme/super-schema": "*",
+ "@clerk/fastify": "0.6.30",
+ "@fastify/formbody": "7.4.0",
+ "@fastify/view": "8.2.0",
+ "date-fns": "3.3.1",
+ "fastify": "4.25.2",
+ "nunjucks": "3.2.4"
+ }
+}
diff --git a/apps/app-htmx/src/app-config.ts b/apps/app-htmx/src/app-config.ts
new file mode 100644
index 000000000..db538e5aa
--- /dev/null
+++ b/apps/app-htmx/src/app-config.ts
@@ -0,0 +1,37 @@
+import { z } from "zod"
+
+const ENVSchema = z.object({
+ SUPER_DATABASE_URL: z.string(),
+ SUPER_DATABASE_AUTH_TOKEN: z.string().optional(),
+ TENANT_DATABASE_AUTH_TOKEN: z.string().optional(),
+
+ // TODO: remove after removing next
+ NEXT_PUBLIC_EXTRACT_API_URL: z.string(),
+ NEXT_PUBLIC_TRANSFORM_API_URL: z.string(),
+
+ CLERK_PUBLISHABLE_KEY: z.string(),
+ CLERK_SECRET_KEY: z.string(),
+ CLERK_DOMAIN: z.string(),
+ APP_HTMX_PORT: z.coerce.number().optional(),
+});
+
+const parsedEnv = ENVSchema.safeParse(process.env);
+if (!parsedEnv.success) throw new Error(`Invalid environment: ${parsedEnv.error}`);
+
+export const AppConfig = {
+ port: parsedEnv.data.APP_HTMX_PORT,
+ superDatabase: {
+ url: parsedEnv.data.SUPER_DATABASE_URL,
+ authToken: parsedEnv.data.SUPER_DATABASE_AUTH_TOKEN,
+ },
+ tenantDatabaseAuthToken: parsedEnv.data.TENANT_DATABASE_AUTH_TOKEN,
+ apis: {
+ extractStart: parsedEnv.data.NEXT_PUBLIC_EXTRACT_API_URL,
+ transformStart: parsedEnv.data.NEXT_PUBLIC_TRANSFORM_API_URL,
+ },
+ clerk: {
+ domain: parsedEnv.data.CLERK_DOMAIN,
+ publishableKey: parsedEnv.data.CLERK_PUBLISHABLE_KEY,
+ secretKey: "", // should be left in ENV
+ }
+}
\ No newline at end of file
diff --git a/apps/app-htmx/src/context/htmx.context.ts b/apps/app-htmx/src/context/htmx.context.ts
new file mode 100644
index 000000000..3c767c685
--- /dev/null
+++ b/apps/app-htmx/src/context/htmx.context.ts
@@ -0,0 +1,7 @@
+import type { IncomingHttpHeaders } from "http";
+
+export const htmxContext = (headers: IncomingHttpHeaders) => ({
+ htmx: {
+ boosted: 'hx-boosted' in headers,
+ }
+});
\ No newline at end of file
diff --git a/apps/app-htmx/src/context/page.context.ts b/apps/app-htmx/src/context/page.context.ts
new file mode 100644
index 000000000..9cf649b94
--- /dev/null
+++ b/apps/app-htmx/src/context/page.context.ts
@@ -0,0 +1,5 @@
+export const pageContext = (title: string) => ({
+ page: {
+ title
+ },
+});
\ No newline at end of file
diff --git a/apps/app-htmx/src/context/tenant-list.context.ts b/apps/app-htmx/src/context/tenant-list.context.ts
new file mode 100644
index 000000000..bbdafd10e
--- /dev/null
+++ b/apps/app-htmx/src/context/tenant-list.context.ts
@@ -0,0 +1,17 @@
+import { getTenants } from "@acme/super-schema";
+import { createClient } from "@libsql/client";
+import { drizzle } from "drizzle-orm/libsql";
+import { AppConfig } from "src/app-config";
+
+const loadTenants = async ()=> {
+ const superDb = drizzle(createClient(AppConfig.superDatabase));
+
+ const tenants = await getTenants(superDb);
+
+ return tenants;
+}
+const TENANT_LIST = await loadTenants();
+
+export const tenantListContext = () => ({
+ tenantList: TENANT_LIST
+})
\ No newline at end of file
diff --git a/apps/app-htmx/src/functions/fetch-repository.ts b/apps/app-htmx/src/functions/fetch-repository.ts
new file mode 100644
index 000000000..2e5387f77
--- /dev/null
+++ b/apps/app-htmx/src/functions/fetch-repository.ts
@@ -0,0 +1,42 @@
+import { GitHubSourceControl, GitlabSourceControl, type SourceControl } from "@acme/source-control";
+import { clerkClient } from "@clerk/fastify";
+
+const getUserForgeAccessToken = async (userId: string, forge: "github" | "gitlab") => {
+ const userTokens = await clerkClient.users.getUserOauthAccessToken(userId, `oauth_${forge}`);
+ if (userTokens[0] === undefined) throw new Error("no token");
+ return userTokens[0].token;
+}
+
+type Props = {
+ forge: "github" | "gitlab";
+ userId: string;
+ repositoryId: number;
+ repositoryName: string;
+ namespaceName: string;
+}
+export const tryFetchRepository = async ({
+ userId,
+ forge,
+ namespaceName,
+ repositoryId,
+ repositoryName,
+}: Props) => {
+ const token = await getUserForgeAccessToken(userId, forge);
+
+ let sc: SourceControl;
+ if (forge === 'github') sc = new GitHubSourceControl(token);
+ else sc = new GitlabSourceControl(token);
+
+ try {
+ const { repository, namespace } = await sc.fetchRepository(repositoryId, namespaceName, repositoryName);
+ return { repository, namespace };
+ } catch (error) {
+ console.log("FAILED TO FETCH REPOSITORY");
+ console.log(error);
+ }
+
+ return {
+ repository: undefined,
+ namespace: undefined
+ }
+}
\ No newline at end of file
diff --git a/apps/app-htmx/src/functions/get-crawl-stats.ts b/apps/app-htmx/src/functions/get-crawl-stats.ts
new file mode 100644
index 000000000..a2c7f690b
--- /dev/null
+++ b/apps/app-htmx/src/functions/get-crawl-stats.ts
@@ -0,0 +1,53 @@
+import type { Tenant } from "@acme/super-schema";
+import { type Client, createClient } from "@libsql/client";
+import { fromUnixTime } from "date-fns/fromUnixTime";
+import { formatDistanceToNow } from "date-fns/formatDistanceToNow";
+import { AppConfig } from "src/app-config";
+import { z } from "zod";
+
+const formatDuration = (s: number) => {
+ const seconds = s % 60;
+ if (s < 60) return `${seconds}s`
+ const m = (s - seconds) / 60;
+ const minutes = m % 60;
+ if (m < 60) return `${minutes}m ${seconds.toString().padStart(2,'0')}s`
+ const h = (m - minutes) / 60;
+ return `${h}h ${minutes.toString().padStart(2, '0')}m ${seconds.toString().padStart(2, '0')}s`
+}
+
+const rowSchema = z.object({
+ crawl_instance: z.number(),
+ event_count: z.number(),
+ crawl_duration_sec: z.number(),
+ crawl_started_at: z.number()
+});
+
+const readCrawlStats =async (client:Client) => {
+ const x = await client.execute(`
+ SELECT
+ ce.instance_id as crawl_instance,
+ COUNT(ce.instance_id) as event_count,
+ MAX(ce.timestamp) - ci.started_at AS crawl_duration_sec,
+ ci.started_at as crawl_started_at
+FROM crawl_events ce
+JOIN crawl_instances ci ON ce.instance_id = ci.id
+GROUP BY ce.instance_id;
+ `);
+ const rawStats = x.rows.map(row => rowSchema.parse(row));
+
+ return rawStats.map((rawStat)=>({
+ instanceId: rawStat.crawl_instance,
+ eventCount: rawStat.event_count,
+ duration: formatDuration(rawStat.crawl_duration_sec),
+ startedAt: formatDistanceToNow(fromUnixTime(rawStat.crawl_started_at))
+ }))
+}
+
+export const getCrawlStats = async (tenant: Tenant) => {
+ const client = createClient({
+ url: tenant.dbUrl,
+ authToken: AppConfig.tenantDatabaseAuthToken
+ });
+
+ return await readCrawlStats(client);
+}
\ No newline at end of file
diff --git a/apps/app-htmx/src/functions/get-repositories.ts b/apps/app-htmx/src/functions/get-repositories.ts
new file mode 100644
index 000000000..88279aaa1
--- /dev/null
+++ b/apps/app-htmx/src/functions/get-repositories.ts
@@ -0,0 +1,14 @@
+import { namespaces, repositories } from "@acme/extract-schema";
+import { eq } from "drizzle-orm";
+import type { LibSQLDatabase } from "drizzle-orm/libsql";
+
+export const getRepositories = async (db: LibSQLDatabase) => {
+ const repos = await db.select({
+ forge: repositories.forgeType,
+ name: repositories.name,
+ org: namespaces.name,
+ projectId: repositories.externalId,
+ }).from(repositories).innerJoin(namespaces, eq(repositories.namespaceId, namespaces.id)).all()
+
+ return repos;
+}
\ No newline at end of file
diff --git a/apps/app-htmx/src/index.ts b/apps/app-htmx/src/index.ts
new file mode 100644
index 000000000..77f72bce7
--- /dev/null
+++ b/apps/app-htmx/src/index.ts
@@ -0,0 +1,40 @@
+import Fastify from "fastify";
+import nunjucks from "nunjucks";
+import { fastifyView } from "@fastify/view";
+import path from "path";
+import { fileURLToPath } from "url";
+import { clerkPlugin } from "@clerk/fastify";
+import { Home } from "./pages/page.home.js";
+import fastifyFormbody from "@fastify/formbody";
+import { SignIn } from "./pages/page.sign-in.js";
+import { ExtractRepository } from "./pages/repository/extract.js";
+import { RegisterRepository } from "./pages/repository/register.js";
+import { AppConfig } from "./app-config.js";
+import { StartTransform } from "./pages/start-transform.js";
+
+AppConfig; // ensure loaded before starting server
+
+const TEMPLATE_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "../", "src", "views");
+
+const fastify = Fastify({ logger: true });
+
+
+await fastify.register(fastifyView, {
+ engine: {
+ nunjucks: nunjucks,
+ },
+ templates: TEMPLATE_DIR
+});
+await fastify.register(clerkPlugin);
+await fastify.register(fastifyFormbody);
+
+fastify.get('/', Home);
+fastify.get('/sign-in',SignIn);
+fastify.post('/repository/extract', ExtractRepository);
+fastify.post('/repository/register', RegisterRepository);
+fastify.post('/transform', StartTransform);
+
+const PORT = AppConfig.port || 3001;
+
+await fastify.listen({ port: PORT, host: "127.0.0.1" })
+console.log(`Server started on http://127.0.0.1:${PORT}/`);
\ No newline at end of file
diff --git a/apps/app-htmx/src/pages/page.home.ts b/apps/app-htmx/src/pages/page.home.ts
new file mode 100644
index 000000000..a57015bc6
--- /dev/null
+++ b/apps/app-htmx/src/pages/page.home.ts
@@ -0,0 +1,59 @@
+import { getAuth } from "@clerk/fastify";
+import type { RouteHandlerMethod } from "fastify";
+import { redirectToSignIn } from "./redirect-to-sign-in";
+import { pageContext } from "src/context/page.context";
+import { htmxContext } from "src/context/htmx.context";
+import { tenantListContext } from "src/context/tenant-list.context";
+import { drizzle } from "drizzle-orm/libsql";
+import { createClient } from "@libsql/client";
+import { getRepositories } from "src/functions/get-repositories";
+import { z } from "zod";
+import { AppConfig } from "src/app-config";
+import { getCrawlStats } from "src/functions/get-crawl-stats";
+
+const QuerySchema = z.object({
+ tenant: z.string().optional()
+});
+
+export const Home: RouteHandlerMethod = async (request, reply) => {
+ const auth = getAuth(request);
+ if (!auth.sessionId) return redirectToSignIn(reply);
+
+ const parsedQuery = QuerySchema.safeParse(request.query);
+ const query = parsedQuery.success ? parsedQuery.data : {};
+
+ const page = pageContext("Home");
+ const htmx = htmxContext(request.headers);
+ const tenantList = tenantListContext();
+
+
+ const tenantQuery = query.tenant || tenantList.tenantList[0]!.name;
+ const tenant = tenantList.tenantList.find(t => t.name === tenantQuery);
+ if (!tenant) return reply.redirect(303, "/");
+ const targetTenantId = tenant.id;
+
+ const db = drizzle(createClient({
+ url: tenant.dbUrl,
+ authToken: AppConfig.tenantDatabaseAuthToken
+ }));
+
+ const repos = await getRepositories(db);
+
+ const tenantCrawlStats = await getCrawlStats(tenant);
+
+ return reply.view("page.home.html", {
+ auth,
+ ...page,
+ ...htmx,
+ ...tenantList,
+ targetTenant: tenant,
+ targetTenantId,
+ repos,
+ dates: {
+ yesterday: new Date(new Date().setDate(new Date().getDate() - 1)).toISOString().slice(0, 10),
+ today: new Date().toISOString().slice(0, 10)
+ },
+ AppConfig,
+ tenantCrawlStats
+ });
+}
diff --git a/apps/app-htmx/src/pages/page.sign-in.ts b/apps/app-htmx/src/pages/page.sign-in.ts
new file mode 100644
index 000000000..bd979c3c6
--- /dev/null
+++ b/apps/app-htmx/src/pages/page.sign-in.ts
@@ -0,0 +1,17 @@
+import { getAuth } from "@clerk/fastify";
+import type { RouteHandlerMethod } from "fastify";
+import { pageContext } from "../context/page.context.js";
+import { htmxContext } from "../context/htmx.context.js";
+import { AppConfig } from "src/app-config.js";
+
+export const SignIn: RouteHandlerMethod = async (request, reply) => {
+ const _auth = getAuth(request);
+
+ const page = pageContext("Sign In");
+ const htmx = htmxContext(request.headers);
+ return reply.view("page.sign-in.html", {
+ ...page,
+ ...htmx,
+ AppConfig
+ });
+}
diff --git a/apps/app-htmx/src/pages/redirect-to-sign-in.ts b/apps/app-htmx/src/pages/redirect-to-sign-in.ts
new file mode 100644
index 000000000..e76ab4940
--- /dev/null
+++ b/apps/app-htmx/src/pages/redirect-to-sign-in.ts
@@ -0,0 +1,3 @@
+import type { FastifyReply } from "fastify";
+
+export const redirectToSignIn = (reply: FastifyReply) => reply.redirect(303, '/sign-in');
\ No newline at end of file
diff --git a/apps/app-htmx/src/pages/repository/extract.ts b/apps/app-htmx/src/pages/repository/extract.ts
new file mode 100644
index 000000000..e69d32615
--- /dev/null
+++ b/apps/app-htmx/src/pages/repository/extract.ts
@@ -0,0 +1,72 @@
+import type { RouteHandlerMethod } from "fastify";
+import { clerkClient, getAuth } from "@clerk/fastify"
+import { z } from "zod";
+import { tenantListContext } from "src/context/tenant-list.context";
+import { AppConfig } from "src/app-config";
+
+const ExtractInputSchema = z.object({
+ target_tenant_id: z.coerce.number(),
+ forge: z.literal("github").or(z.literal("gitlab")),
+ owner: z.string().optional(),
+ repo: z.string().optional(),
+ project_id: z.coerce.number().optional()
+}).and(z.discriminatedUnion("qtype", [
+ z.object({
+ qtype: z.literal('last'),
+ last: z.coerce.number(),
+ }),
+ z.object({
+ qtype: z.literal('between'),
+ from: z.coerce.date(),
+ to: z.coerce.date(),
+ })
+]));
+
+export const ExtractRepository: RouteHandlerMethod = async (request, reply) => {
+ const auth = getAuth(request);
+ const {tenantList} = tenantListContext();
+ if (!auth.userId) return reply.status(404).send();
+ const parsedExtractInput = ExtractInputSchema.safeParse(request.body);
+
+ if (!parsedExtractInput.success) return reply.view("component.log.html", { error: parsedExtractInput.error });
+
+ const extractInput = parsedExtractInput.data;
+ const tenant = tenantList.find(tenant=>tenant.id === extractInput.target_tenant_id);
+ if (!tenant) return reply.view("component.log.html", { error: `Invalid target_tenant_id: ${extractInput.target_tenant_id}` });
+
+ let to = new Date();
+ let from = new Date(to);
+ if (extractInput.qtype === 'last') from.setDate(to.getDate() - extractInput.last);
+ if (extractInput.qtype === 'between') {
+ from = extractInput.from;
+ to = extractInput.to;
+ // from.setUTCHours(0, 0, 0, 0);
+ // to.setUTCHours(23, 59, 59, 999);
+ }
+
+ const requestBody = JSON.stringify({
+ repositoryId: extractInput.project_id || 0,
+ repositoryName: extractInput.repo || "",
+ namespaceName: extractInput.owner || "",
+ sourceControl: extractInput.forge,
+ from,
+ to,
+ tenantId: tenant.id,
+ });
+
+ const apiToken = await clerkClient.sessions.getToken(auth.sessionId, "dashboard") as string;
+
+ const res = await fetch(AppConfig.apis.extractStart, {
+ method: 'post',
+ body: requestBody,
+ headers: {
+ 'Authorization': 'Bearer ' + apiToken
+ }
+ });
+
+ const body = await res.text();
+
+ if (res.status !== 200) return reply.view('component.log.html', { error: body });
+
+ return reply.view('component.log.html', { log: body });
+}
\ No newline at end of file
diff --git a/apps/app-htmx/src/pages/repository/register.ts b/apps/app-htmx/src/pages/repository/register.ts
new file mode 100644
index 000000000..8d6e4bf52
--- /dev/null
+++ b/apps/app-htmx/src/pages/repository/register.ts
@@ -0,0 +1,57 @@
+import type { RouteHandlerMethod } from "fastify";
+import { getAuth } from "@clerk/fastify"
+import { z } from "zod";
+import { tryFetchRepository } from "src/functions/fetch-repository";
+import { tenantListContext } from "src/context/tenant-list.context";
+
+const RegisterInput = z.object({
+ target_tenant_id: z.coerce.number(),
+ forge: z.literal("github").or(z.literal("gitlab")),
+ owner: z.string().optional(),
+ repo: z.string().optional(),
+ project_id: z.string().optional()
+})
+
+export const RegisterRepository: RouteHandlerMethod = async (request, reply) => {
+ const auth = getAuth(request);
+ if (!auth.userId) return reply.status(404).send(); // hide actions from unauthenticated users
+
+ const { tenantList } = tenantListContext();
+
+ const safeInput = RegisterInput.safeParse(request.body);
+ if (!safeInput.success) return reply.status(400).send();
+
+ const input = {
+ userId: auth.userId,
+ forge: safeInput.data.forge,
+ namespaceName: safeInput.data.owner || "",
+ repositoryName: safeInput.data.repo || "",
+ repositoryId: Number(safeInput.data.project_id) || 0
+ }
+
+ const { repository, namespace } = await tryFetchRepository(input);
+
+ if (!repository || !namespace) return reply.view("component.log.html",
+ safeInput.data.forge === "github" ? { error: `Repository ${input.namespaceName}/${input.repositoryName} not found` }
+ : { error: `Project ${input.repositoryId} not found` });
+
+ const targetTenantId = safeInput.data.target_tenant_id;
+ const tenant = tenantList.find(tenant=>tenant.id === targetTenantId);
+ if (!tenant) return reply.view("component.log.html", { error: `Invalid target_tenant_id: ${targetTenantId}` });
+
+ const repo = {
+ name: repository.name,
+ org: namespace.name,
+ forge: repository.forgeType,
+ projectId: repository.externalId
+ }
+
+ return reply.view("component.repository.html", {
+ repo,
+ targetTenantId,
+ dates: {
+ yesterday: new Date(new Date().setDate(new Date().getDate() - 1)).toISOString().slice(0, 10),
+ today: new Date().toISOString().slice(0, 10)
+ }
+ });
+}
\ No newline at end of file
diff --git a/apps/app-htmx/src/pages/start-transform.ts b/apps/app-htmx/src/pages/start-transform.ts
new file mode 100644
index 000000000..076d1c303
--- /dev/null
+++ b/apps/app-htmx/src/pages/start-transform.ts
@@ -0,0 +1,39 @@
+import type { RouteHandlerMethod } from "fastify";
+import { clerkClient, getAuth } from "@clerk/fastify"
+import { z } from "zod";
+import { tenantListContext } from "src/context/tenant-list.context";
+import { AppConfig } from "src/app-config";
+
+const TransformInputSchema = z.object({
+ target_tenant_id: z.coerce.number(),
+});
+
+export const StartTransform: RouteHandlerMethod = async (request, reply) => {
+ const auth = getAuth(request);
+ const {tenantList} = tenantListContext();
+ if (!auth.userId) return reply.status(404).send();
+ const parsedTransformInput = TransformInputSchema.safeParse(request.body);
+
+ if (!parsedTransformInput.success) return reply.view("component.log.html", { error: parsedTransformInput.error });
+
+ const extractInput = parsedTransformInput.data;
+ const tenant = tenantList.find(tenant=>tenant.id === extractInput.target_tenant_id);
+ if (!tenant) return reply.view("component.log.html", { error: `Invalid target_tenant_id: ${extractInput.target_tenant_id}` });
+
+ const apiToken = await clerkClient.sessions.getToken(auth.sessionId, "dashboard") as string;
+ const requestBody = JSON.stringify({ tenantId: tenant.id });
+
+ const res = await fetch(AppConfig.apis.transformStart, {
+ method: 'post',
+ body: requestBody,
+ headers: {
+ 'Authorization': 'Bearer ' + apiToken
+ }
+ });
+
+ const body = await res.text();
+
+ if (res.status !== 200) return reply.view('component.log.html', { error: body });
+
+ return reply.view('component.log.html', { log: body });
+}
\ No newline at end of file
diff --git a/apps/app-htmx/src/plugins/auth.plugin.ts b/apps/app-htmx/src/plugins/auth.plugin.ts
new file mode 100644
index 000000000..f088fc12a
--- /dev/null
+++ b/apps/app-htmx/src/plugins/auth.plugin.ts
@@ -0,0 +1,13 @@
+import { getAuth } from "@clerk/fastify";
+import type { FastifyPluginAsync } from "fastify";
+
+export const authPlugin: FastifyPluginAsync = async (fastify) => {
+ fastify.addHook('preHandler', (request, reply) => {
+ const auth = getAuth(request);
+ if (!auth.userId) {
+ return reply.redirect(303, '/sign-in');
+ }
+ return Promise.resolve();
+ });
+ return Promise.resolve();
+}
\ No newline at end of file
diff --git a/apps/app-htmx/src/views/component.crawl-stats.html b/apps/app-htmx/src/views/component.crawl-stats.html
new file mode 100644
index 000000000..51c320f61
--- /dev/null
+++ b/apps/app-htmx/src/views/component.crawl-stats.html
@@ -0,0 +1,22 @@
+
+
+
+
+
When
+
Id
+
Event Count
+
Duration
+
+
+
+ {% for stat in tenantCrawlStats %}
+
+
{{stat.startedAt}} ago
+
{{stat.instanceId}}
+
{{stat.eventCount}}
+
{{stat.duration}}
+
+ {% endfor %}
+
+
+
\ No newline at end of file
diff --git a/apps/app-htmx/src/views/component.log.html b/apps/app-htmx/src/views/component.log.html
new file mode 100644
index 000000000..b551b0744
--- /dev/null
+++ b/apps/app-htmx/src/views/component.log.html
@@ -0,0 +1,5 @@
+{% if log %}
+
{{log}}
+{% elseif error %}
+
{{error}}
+{% endif %}
\ No newline at end of file
diff --git a/apps/app-htmx/src/views/component.repository.html b/apps/app-htmx/src/views/component.repository.html
new file mode 100644
index 000000000..955cc3a3c
--- /dev/null
+++ b/apps/app-htmx/src/views/component.repository.html
@@ -0,0 +1,40 @@
+