devops
This commit is contained in:
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" />
|
||||
Reference in New Issue
Block a user