devops
This commit is contained in:
@@ -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
|
||||||
@@ -440,6 +479,25 @@ deploy-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:
|
||||||
<<: *deploy_setup
|
<<: *deploy_setup
|
||||||
|
|||||||
27
devops-view/.gitignore
vendored
Normal file
27
devops-view/.gitignore
vendored
Normal 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
|
||||||
11
devops-view/docker/Dockerfile
Normal file
11
devops-view/docker/Dockerfile
Normal 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
|
||||||
7
devops-view/docker/nginx.conf
Normal file
7
devops-view/docker/nginx.conf
Normal 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
18
devops-view/index.html
Normal 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
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
24
devops-view/package.json
Normal 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
41
devops-view/src/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
devops-view/src/components/chat/ChatInput.tsx
Normal file
71
devops-view/src/components/chat/ChatInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
devops-view/src/components/chat/ChatMessage.tsx
Normal file
36
devops-view/src/components/chat/ChatMessage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
devops-view/src/components/chat/ChatSection.tsx
Normal file
169
devops-view/src/components/chat/ChatSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
devops-view/src/components/examples/ExampleQuestions.tsx
Normal file
46
devops-view/src/components/examples/ExampleQuestions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
devops-view/src/components/hero/HeroSection.tsx
Normal file
22
devops-view/src/components/hero/HeroSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
devops-view/src/components/layout/Footer.tsx
Normal file
11
devops-view/src/components/layout/Footer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
devops-view/src/components/layout/Header.tsx
Normal file
17
devops-view/src/components/layout/Header.tsx
Normal 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">
|
||||||
|
>_ 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
devops-view/src/hooks/useDevopsGuestAuth.ts
Normal file
58
devops-view/src/hooks/useDevopsGuestAuth.ts
Normal 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
30
devops-view/src/index.css
Normal 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
13
devops-view/src/main.tsx
Normal 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>
|
||||||
|
);
|
||||||
29
devops-view/src/services/api.ts
Normal file
29
devops-view/src/services/api.ts
Normal 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>;
|
||||||
|
}
|
||||||
26
devops-view/src/types/index.ts
Normal file
26
devops-view/src/types/index.ts
Normal 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
1
devops-view/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
22
devops-view/tsconfig.app.json
Normal file
22
devops-view/tsconfig.app.json
Normal 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"]
|
||||||
|
}
|
||||||
7
devops-view/tsconfig.json
Normal file
7
devops-view/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
20
devops-view/tsconfig.node.json
Normal file
20
devops-view/tsconfig.node.json
Normal 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"]
|
||||||
|
}
|
||||||
16
devops-view/vite.config.ts
Normal file
16
devops-view/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user