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

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" />