This commit is contained in:
2026-03-20 19:31:54 +01:00
parent 117e794753
commit 5b383b9fe4
25 changed files with 3270 additions and 0 deletions

View File

@@ -110,6 +110,26 @@ build-analytics-view:
changes: changes:
- analytics-view/**/* - analytics-view/**/*
build-devops-view:
stage: build
image: node:22-alpine
cache:
key: "${CI_COMMIT_REF_SLUG}-devops-view"
paths:
- devops-view/node_modules
script:
- cd devops-view
- npm ci
- npm run build
artifacts:
paths:
- devops-view/dist
expire_in: 1h
rules:
- if: $CI_COMMIT_BRANCH == "main"
changes:
- devops-view/**/*
build-auth-view: build-auth-view:
stage: build stage: build
image: node:22-alpine image: node:22-alpine
@@ -268,6 +288,25 @@ publish-auth-view:
changes: changes:
- auth-view/**/* - auth-view/**/*
publish-devops-view:
stage: publish
image: docker:27
services:
- docker:27-dind
variables:
DOCKER_TLS_CERTDIR: ""
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
script:
- docker build -t $REGISTRY/devops-view:${CI_COMMIT_SHORT_SHA} -t $REGISTRY/devops-view:latest -f devops-view/docker/Dockerfile devops-view/
- docker push $REGISTRY/devops-view:${CI_COMMIT_SHORT_SHA}
- docker push $REGISTRY/devops-view:latest
needs: [build-devops-view]
rules:
- if: $CI_COMMIT_BRANCH == "main"
changes:
- devops-view/**/*
publish-portfolio-view: publish-portfolio-view:
stage: publish stage: publish
image: docker:27 image: docker:27
@@ -439,6 +478,25 @@ deploy-portfolio-view:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d portfolio-view docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d portfolio-view
docker image prune -af docker image prune -af
ENDSSH ENDSSH
deploy-devops-view:
<<: *deploy_setup
needs: [publish-devops-view]
rules:
- if: $CI_COMMIT_BRANCH == "main"
changes:
- devops-view/**/*
script:
- |
ssh $VPS_USER@$VPS_HOST << ENDSSH
set -e
echo "$CI_REGISTRY_PASSWORD" | docker login registry.gitlab.com -u "$CI_REGISTRY_USER" --password-stdin
cd /opt/services
export CI_COMMIT_SHORT_SHA=${CI_COMMIT_SHORT_SHA}
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull devops-view
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d devops-view
docker image prune -af
ENDSSH
# Deploy all services at once (manual trigger) # Deploy all services at once (manual trigger)
deploy-all: deploy-all:

27
devops-view/.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# Dependencies
node_modules/
# Build output
dist/
# Logs
*.log
npm-debug.log*
# Editor
.vscode/
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.local
.env.*.local

View File

@@ -0,0 +1,11 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html/devops
EXPOSE 80

View File

@@ -0,0 +1,7 @@
server {
listen 80;
location /devops/ {
alias /usr/share/nginx/html/devops/;
try_files $uri $uri/ /devops/index.html;
}
}

18
devops-view/index.html Normal file
View File

@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DevOps Assistant</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2490
devops-view/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
devops-view/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "devops-view",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.7.2",
"vite": "^6.2.0",
"@tailwindcss/vite": "^4.0.0",
"tailwindcss": "^4.0.0"
}
}

41
devops-view/src/App.tsx Normal file
View File

@@ -0,0 +1,41 @@
import { useState } from "react";
import { Header } from "./components/layout/Header";
import { Footer } from "./components/layout/Footer";
import { HeroSection } from "./components/hero/HeroSection";
import { ExampleQuestions } from "./components/examples/ExampleQuestions";
import { ChatSection } from "./components/chat/ChatSection";
import { useDevopsGuestAuth } from "./hooks/useDevopsGuestAuth";
export default function App() {
const { token, isLoading, error } = useDevopsGuestAuth();
const [pendingQuestion, setPendingQuestion] = useState<string | null>(null);
function handleExampleSelect(question: string) {
setPendingQuestion(question);
}
function handleQuestionConsumed() {
setPendingQuestion(null);
}
return (
<div className="min-h-screen flex flex-col bg-gray-950 text-gray-100">
<Header />
<main className="flex-1 flex flex-col max-w-5xl w-full mx-auto px-4 py-6">
<HeroSection />
<ExampleQuestions
onSelect={handleExampleSelect}
disabled={isLoading || !token}
/>
<ChatSection
token={token}
isAuthLoading={isLoading}
authError={error}
pendingQuestion={pendingQuestion}
onQuestionConsumed={handleQuestionConsumed}
/>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { useState, useEffect } from "react";
interface Props {
onSend: (text: string) => void;
disabled: boolean;
prefill?: string;
}
export function ChatInput({ onSend, disabled, prefill }: Props) {
const [value, setValue] = useState("");
useEffect(() => {
if (prefill) {
setValue(prefill);
}
}, [prefill]);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const trimmed = value.trim();
if (!trimmed || disabled) return;
onSend(trimmed);
setValue("");
}
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}
return (
<form
onSubmit={handleSubmit}
className="flex gap-2 items-end border-t border-green-900/30 pt-4"
>
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
disabled={disabled}
placeholder="Ask about architecture, services, configs..."
rows={2}
className="
flex-1 resize-none rounded-lg px-4 py-3
bg-gray-900 border border-green-900/40
text-gray-200 placeholder-gray-600
font-mono text-sm leading-relaxed
focus:outline-none focus:border-green-700/60 focus:ring-1 focus:ring-green-700/30
disabled:opacity-40 disabled:cursor-not-allowed
transition-colors
"
/>
<button
type="submit"
disabled={disabled || !value.trim()}
className="
px-5 py-3 rounded-lg font-mono text-sm font-bold
bg-green-700 hover:bg-green-600 active:bg-green-800
text-gray-950
disabled:opacity-30 disabled:cursor-not-allowed
transition-colors
whitespace-nowrap
"
>
send
</button>
</form>
);
}

View File

@@ -0,0 +1,36 @@
import type { Message } from "../../types";
interface Props {
message: Message;
}
export function ChatMessage({ message }: Props) {
const isUser = message.role === "USER";
return (
<div
className={`flex gap-3 ${isUser ? "flex-row-reverse" : "flex-row"} mb-4`}
>
<div
className={`
flex-shrink-0 w-8 h-8 rounded-md flex items-center justify-center font-mono text-xs font-bold
${isUser ? "bg-green-900/60 text-green-300" : "bg-gray-800 text-gray-400"}
`}
>
{isUser ? "you" : "ai"}
</div>
<div
className={`
max-w-[80%] px-4 py-3 rounded-lg font-mono text-sm leading-relaxed whitespace-pre-wrap
${
isUser
? "bg-green-950/50 border border-green-800/40 text-green-100"
: "bg-gray-900 border border-gray-800/60 text-gray-200"
}
`}
>
{message.content}
</div>
</div>
);
}

View File

@@ -0,0 +1,169 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { Message } from "../../types";
import { createChat, sendMessage } from "../../services/api";
import { ChatMessage } from "./ChatMessage";
import { ChatInput } from "./ChatInput";
interface Props {
token: string | null;
isAuthLoading: boolean;
authError: string | null;
pendingQuestion: string | null;
onQuestionConsumed: () => void;
}
export function ChatSection({
token,
isAuthLoading,
authError,
pendingQuestion,
onQuestionConsumed,
}: Props) {
const [chatId, setChatId] = useState<number | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [isSending, setIsSending] = useState(false);
const [chatError, setChatError] = useState<string | null>(null);
const [prefill, setPrefill] = useState<string | undefined>(undefined);
const bottomRef = useRef<HTMLDivElement>(null);
const initializedRef = useRef(false);
// Init chat once token is available
useEffect(() => {
if (!token || initializedRef.current) return;
initializedRef.current = true;
createChat(token)
.then((chat) => setChatId(chat.id))
.catch((err: unknown) => {
setChatError(
err instanceof Error ? err.message : "Failed to start chat"
);
});
}, [token]);
// Consume pending question from example cards
useEffect(() => {
if (pendingQuestion && chatId && !isSending) {
setPrefill(pendingQuestion);
onQuestionConsumed();
}
}, [pendingQuestion, chatId, isSending, onQuestionConsumed]);
// Auto-scroll to bottom on new messages
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSend = useCallback(
async (text: string) => {
if (!token || !chatId) return;
setPrefill(undefined);
const userMsg: Message = {
id: `user-${Date.now()}`,
content: text,
role: "USER",
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMsg]);
setIsSending(true);
setChatError(null);
try {
const response = await sendMessage(token, chatId, text);
setMessages((prev) => [
...prev,
{
id: response.id,
content: response.content,
role: "ASSISTANT",
createdAt: response.createdAt,
},
]);
} catch (err) {
setChatError(
err instanceof Error ? err.message : "Failed to get response"
);
} finally {
setIsSending(false);
}
},
[token, chatId]
);
// Trigger send when prefill is set from example questions
useEffect(() => {
if (prefill && chatId && !isSending) {
handleSend(prefill);
setPrefill(undefined);
}
}, [prefill, chatId, isSending, handleSend]);
const isInputDisabled = isAuthLoading || !token || !chatId || isSending;
return (
<section className="flex flex-col flex-1 min-h-0">
<p className="text-gray-500 font-mono text-xs uppercase tracking-widest mb-3">
// chat
</p>
<div className="flex flex-col flex-1 min-h-0 rounded-lg border border-green-900/30 bg-gray-950/40 overflow-hidden">
{/* Message list */}
<div className="flex-1 overflow-y-auto p-4">
{isAuthLoading && (
<div className="flex items-center gap-2 text-gray-500 font-mono text-sm">
<span className="inline-block w-2 h-2 rounded-full bg-green-700 animate-pulse" />
Authenticating...
</div>
)}
{authError && (
<div className="text-red-400 font-mono text-sm bg-red-950/20 border border-red-900/30 rounded px-4 py-3">
Auth error: {authError}
</div>
)}
{!isAuthLoading && token && !chatId && !chatError && (
<div className="flex items-center gap-2 text-gray-500 font-mono text-sm">
<span className="inline-block w-2 h-2 rounded-full bg-green-700 animate-pulse" />
Initializing chat session...
</div>
)}
{chatError && (
<div className="text-red-400 font-mono text-sm bg-red-950/20 border border-red-900/30 rounded px-4 py-3">
{chatError}
</div>
)}
{messages.length === 0 && chatId && !chatError && (
<div className="text-gray-600 font-mono text-sm text-center py-8">
Ask a question to get started.
</div>
)}
{messages.map((msg) => (
<ChatMessage key={msg.id} message={msg} />
))}
{isSending && (
<div className="flex gap-3 mb-4">
<div className="w-8 h-8 rounded-md flex items-center justify-center bg-gray-800 text-gray-400 font-mono text-xs font-bold flex-shrink-0">
ai
</div>
<div className="px-4 py-3 rounded-lg bg-gray-900 border border-gray-800/60 font-mono text-sm text-gray-500">
<span className="inline-flex gap-1">
<span className="animate-bounce [animation-delay:0ms]">.</span>
<span className="animate-bounce [animation-delay:150ms]">.</span>
<span className="animate-bounce [animation-delay:300ms]">.</span>
</span>
</div>
</div>
)}
<div ref={bottomRef} />
</div>
{/* Input */}
<div className="px-4 pb-4">
<ChatInput
onSend={handleSend}
disabled={isInputDisabled}
/>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,46 @@
const QUESTIONS = [
"What port does analytics-service use?",
"How does JWT authentication work?",
"What Kafka topics are configured?",
"Describe the CI/CD pipeline",
"What databases are used?",
"How is Nginx configured?",
];
interface Props {
onSelect: (question: string) => void;
disabled: boolean;
}
export function ExampleQuestions({ onSelect, disabled }: Props) {
return (
<section className="mb-6">
<p className="text-gray-500 font-mono text-xs uppercase tracking-widest mb-3">
// example questions
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{QUESTIONS.map((q) => (
<button
key={q}
onClick={() => onSelect(q)}
disabled={disabled}
className="
group text-left px-4 py-3 rounded-lg border border-green-900/40
bg-gray-900/60 hover:bg-green-950/40 hover:border-green-700/60
text-gray-300 hover:text-green-300
font-mono text-sm leading-snug
transition-all duration-200
disabled:opacity-40 disabled:cursor-not-allowed
hover:shadow-[0_0_12px_rgba(74,222,128,0.08)]
"
>
<span className="text-green-600 group-hover:text-green-400 mr-1.5 transition-colors">
$
</span>
{q}
</button>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,22 @@
export function HeroSection() {
return (
<section className="py-10 text-center">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full border border-green-800/50 bg-green-950/30 mb-5">
<span className="w-1.5 h-1.5 rounded-full bg-green-400" />
<span className="text-green-400/80 font-mono text-xs tracking-widest uppercase">
Powered by RAG
</span>
</div>
<h1 className="text-4xl font-bold font-mono text-green-300 mb-3 tracking-tight">
DevOps Assistant
</h1>
<p className="text-gray-400 font-mono text-base max-w-lg mx-auto leading-relaxed">
Ask anything about the platform architecture.
<br />
<span className="text-gray-500 text-sm">
Answers based on real infrastructure docs.
</span>
</p>
</section>
);
}

View File

@@ -0,0 +1,11 @@
export function Footer() {
return (
<footer className="border-t border-green-900/30 bg-gray-950/60 mt-auto">
<div className="max-w-5xl mx-auto px-4 h-10 flex items-center justify-center">
<span className="text-gray-600 font-mono text-xs">
powered by RAG · answers based on real infrastructure docs
</span>
</div>
</footer>
);
}

View File

@@ -0,0 +1,17 @@
export function Header() {
return (
<header className="border-b border-green-900/40 bg-gray-950/80 backdrop-blur-sm sticky top-0 z-10">
<div className="max-w-5xl mx-auto px-4 h-14 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-green-400 font-mono text-lg font-bold tracking-tight">
&gt;_ devops-assistant
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="inline-block w-2 h-2 rounded-full bg-green-400 animate-pulse" />
<span className="text-green-400/70 font-mono text-xs">online</span>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,58 @@
import { useState, useEffect, useRef } from "react";
import type { AuthResponse } from "../types";
interface AuthState {
token: string | null;
isLoading: boolean;
error: string | null;
}
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 3000;
export function useDevopsGuestAuth(): AuthState {
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const attemptsRef = useRef(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
let cancelled = false;
async function fetchToken() {
try {
const res = await fetch("/api/auth/devops-guest-token", {
method: "POST",
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as AuthResponse;
if (!cancelled) {
setToken(data.payload.token);
setIsLoading(false);
setError(null);
}
} catch (err) {
if (cancelled) return;
attemptsRef.current += 1;
if (attemptsRef.current < MAX_RETRIES) {
timerRef.current = setTimeout(fetchToken, RETRY_DELAY_MS);
} else {
setIsLoading(false);
setError(
err instanceof Error ? err.message : "Authentication failed"
);
}
}
}
fetchToken();
return () => {
cancelled = true;
if (timerRef.current !== null) clearTimeout(timerRef.current);
};
}, []);
return { token, isLoading, error };
}

30
devops-view/src/index.css Normal file
View File

@@ -0,0 +1,30 @@
@import "tailwindcss";
:root {
font-family: "JetBrains Mono", "Source Code Pro", monospace;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
background-color: #030712;
color: #f1f5f9;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #14532d55;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #14532d99;
}

13
devops-view/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("Root element not found");
createRoot(rootEl).render(
<StrictMode>
<App />
</StrictMode>
);

View File

@@ -0,0 +1,29 @@
import type { Chat, CreateEntryResponse } from "../types";
const BASE = "/api/rag";
export async function createChat(token: string): Promise<Chat> {
const res = await fetch(`${BASE}/chat/new?title=DevOps%20Chat`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Failed to create chat: ${res.status}`);
return res.json() as Promise<Chat>;
}
export async function sendMessage(
token: string,
chatId: number,
content: string
): Promise<CreateEntryResponse> {
const res = await fetch(`${BASE}/entry/${chatId}`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ content, onlyContext: true }),
});
if (!res.ok) throw new Error(`Failed to send message: ${res.status}`);
return res.json() as Promise<CreateEntryResponse>;
}

View File

@@ -0,0 +1,26 @@
export interface AuthResponse {
success: boolean;
payload: {
token: string;
refreshToken: string | null;
};
}
export interface Chat {
id: number;
title: string;
}
export interface Message {
id: number | string;
content: string;
role: "USER" | "ASSISTANT";
createdAt: string;
}
export interface CreateEntryResponse {
id: number;
content: string;
role: "ASSISTANT";
createdAt: string;
}

1
devops-view/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,16 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
base: "/devops/",
plugins: [react(), tailwindcss()],
server: {
proxy: {
"/api": {
target: "http://localhost:8080",
changeOrigin: true,
},
},
},
});