Compare commits

40 Commits

Author SHA1 Message Date
559f3c61bc portfolio tech 2026-03-27 22:43:42 +01:00
f1c47a7f90 portfolio tech 2026-03-27 22:10:33 +01:00
76b13bcd85 portfolio tech 2026-03-27 22:06:53 +01:00
37fd64c986 portfolio tech 2026-03-27 21:31:30 +01:00
98ff92c4b7 portfolio anim 2026-03-27 19:05:18 +01:00
41e0c180d3 portfolio anim 2026-03-27 18:51:40 +01:00
73f7e8fcd5 portfolio anim 2026-03-27 18:31:56 +01:00
7754bce7af portfolio anim 2026-03-27 18:17:50 +01:00
eaa87310f1 button guest 2026-03-22 03:22:00 +01:00
27f8bf664c button guest 2026-03-22 03:11:07 +01:00
0ae477369a button guest 2026-03-22 02:49:05 +01:00
e235f3ff3d button guest 2026-03-22 02:37:19 +01:00
b122600ffa button txt 2026-03-22 01:58:12 +01:00
5b383b9fe4 devops 2026-03-20 19:31:54 +01:00
117e794753 hr cv 2026-03-19 19:18:02 +01:00
5ae4f831bf port 2026-03-19 01:15:10 +01:00
94b7c2adcc port 2026-03-19 00:56:35 +01:00
2cf699c276 port 2026-03-19 00:48:37 +01:00
184f4fb542 port 2026-03-19 00:34:52 +01:00
80bc42c785 restore rag-service 2026-03-18 23:40:34 +01:00
908206b5cd portfolio view 2026-03-18 23:20:32 +01:00
e815c02f70 eye 2026-03-18 22:18:14 +01:00
0b8356693a hr guest 2026-03-18 21:57:34 +01:00
260e460ded mail lowcase 2026-03-17 00:28:10 +01:00
69349a8788 add other format for vector 2026-03-17 00:03:01 +01:00
30e69ccbb3 bug reg 2026-03-16 23:31:28 +01:00
ed18993aeb auth media 2026-03-16 00:23:21 +01:00
dbe8427987 auth media 2026-03-15 23:31:12 +01:00
f1fa09c8e8 auth media 2026-03-15 22:23:10 +01:00
ab999c558d auth media 2026-03-14 23:16:06 +01:00
e587480abb auth media 2026-03-14 22:36:44 +01:00
efcdc75171 auth media 2026-03-14 22:24:46 +01:00
f661ef8918 auth media 2026-03-14 22:15:17 +01:00
895448945a add analytics-view 2026-03-13 23:14:51 +01:00
c5a3f5607d as view 2026-03-13 22:30:55 +01:00
19f4e07b4d auth gateway rag view 2026-03-09 13:41:48 +01:00
cacc701881 Merge branch 'main' of https://gitlab.com/mirage74-group/post-hub-platform 2026-03-09 13:32:29 +01:00
43cdb31b1e auth gateway rag view 2026-03-09 13:28:09 +01:00
2b905b3999 auth gateway rag view 2026-03-09 13:25:34 +01:00
5dec55b315 Merge branch 'auth-to-gateway' into 'main'
auth-to-gateway

See merge request mirage74-group/post-hub-platform!1
2026-03-09 13:06:11 +01:00
134 changed files with 18174 additions and 872 deletions

4
.gitignore vendored
View File

@@ -1,2 +1,6 @@
.idea/
*.iml
authid.txt
vps-config-docs.txt
auth-view/node_modules/
auth-view/dist/

View File

@@ -90,6 +90,86 @@ build-rag-view:
changes:
- rag-view/**/*
build-analytics-view:
stage: build
image: node:22-alpine
cache:
key: "${CI_COMMIT_REF_SLUG}-analytics-view"
paths:
- analytics-view/node_modules
script:
- cd analytics-view
- npm ci
- npm run build
artifacts:
paths:
- analytics-view/dist
expire_in: 1h
rules:
- if: $CI_COMMIT_BRANCH == "main"
changes:
- 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:
stage: build
image: node:22-alpine
cache:
key: "${CI_COMMIT_REF_SLUG}-auth-view"
paths:
- auth-view/node_modules
script:
- cd auth-view
- npm ci
- npm run build
artifacts:
paths:
- auth-view/dist
expire_in: 1h
rules:
- if: $CI_COMMIT_BRANCH == "main"
changes:
- auth-view/**/*
build-portfolio-view:
stage: build
image: node:22-alpine
cache:
key: "${CI_COMMIT_REF_SLUG}-portfolio-view"
paths:
- portfolio-view/node_modules
script:
- cd portfolio-view
- npm ci
- npm run build
artifacts:
paths:
- portfolio-view/dist
expire_in: 1h
rules:
- if: $CI_COMMIT_BRANCH == "main"
changes:
- portfolio-view/**/*
# ══════════════════════════════════════════════════════════
# PUBLISH DOCKER IMAGES
# ══════════════════════════════════════════════════════════
@@ -161,7 +241,7 @@ publish-rag-view:
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
script:
- docker build -t $REGISTRY/rag-view:${CI_COMMIT_SHORT_SHA} -t $REGISTRY/rag-view:latest -f rag-view/docker/Dockerfile rag-view/
- docker build --build-arg VITE_GUEST_EMAIL=$VITE_GUEST_EMAIL --build-arg VITE_GUEST_PASSWORD=$VITE_GUEST_PASSWORD -t $REGISTRY/rag-view:${CI_COMMIT_SHORT_SHA} -t $REGISTRY/rag-view:latest -f rag-view/docker/Dockerfile rag-view/
- docker push $REGISTRY/rag-view:${CI_COMMIT_SHORT_SHA}
- docker push $REGISTRY/rag-view:latest
needs: [build-rag-view]
@@ -170,6 +250,82 @@ publish-rag-view:
changes:
- rag-view/**/*
publish-analytics-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 --build-arg VITE_GUEST_EMAIL=$VITE_GUEST_EMAIL --build-arg VITE_GUEST_PASSWORD=$VITE_GUEST_PASSWORD -t $REGISTRY/analytics-view:${CI_COMMIT_SHORT_SHA} -t $REGISTRY/analytics-view:latest -f analytics-view/docker/Dockerfile analytics-view/
- docker push $REGISTRY/analytics-view:${CI_COMMIT_SHORT_SHA}
- docker push $REGISTRY/analytics-view:latest
needs: [build-analytics-view]
rules:
- if: $CI_COMMIT_BRANCH == "main"
changes:
- analytics-view/**/*
publish-auth-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/auth-view:${CI_COMMIT_SHORT_SHA} -t $REGISTRY/auth-view:latest -f auth-view/docker/Dockerfile auth-view/
- docker push $REGISTRY/auth-view:${CI_COMMIT_SHORT_SHA}
- docker push $REGISTRY/auth-view:latest
needs: [build-auth-view]
rules:
- if: $CI_COMMIT_BRANCH == "main"
changes:
- 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:
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/portfolio-view:${CI_COMMIT_SHORT_SHA} -t $REGISTRY/portfolio-view:latest -f portfolio-view/docker/Dockerfile portfolio-view/
- docker push $REGISTRY/portfolio-view:${CI_COMMIT_SHORT_SHA}
- docker push $REGISTRY/portfolio-view:latest
needs: [build-portfolio-view]
rules:
- if: $CI_COMMIT_BRANCH == "main"
changes:
- portfolio-view/**/*
# ══════════════════════════════════════════════════════════
# DEPLOY TO VPS
# ══════════════════════════════════════════════════════════
@@ -202,6 +358,7 @@ deploy-rag:
cd /opt/services
export CI_COMMIT_SHORT_SHA=${CI_COMMIT_SHORT_SHA}
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull rag-service
curl -s http://localhost:8500/v1/health/state/critical | jq -r '.[].ServiceID' | xargs -I{} curl -X PUT http://localhost:8500/v1/agent/service/deregister/{}
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d rag-service
docker image prune -af
ENDSSH
@@ -221,6 +378,7 @@ deploy-gateway:
cd /opt/services
export CI_COMMIT_SHORT_SHA=${CI_COMMIT_SHORT_SHA}
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull gateway-service
curl -s http://localhost:8500/v1/health/state/critical | jq -r '.[].ServiceID' | xargs -I{} curl -X PUT http://localhost:8500/v1/agent/service/deregister/{}
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d gateway-service
docker image prune -af
ENDSSH
@@ -240,6 +398,7 @@ deploy-analytics:
cd /opt/services
export CI_COMMIT_SHORT_SHA=${CI_COMMIT_SHORT_SHA}
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull analytics-service
curl -s http://localhost:8500/v1/health/state/critical | jq -r '.[].ServiceID' | xargs -I{} curl -X PUT http://localhost:8500/v1/agent/service/deregister/{}
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d analytics-service
docker image prune -af
ENDSSH
@@ -263,6 +422,82 @@ deploy-rag-view:
docker image prune -af
ENDSSH
deploy-analytics-view:
<<: *deploy_setup
needs: [publish-analytics-view]
rules:
- if: $CI_COMMIT_BRANCH == "main"
changes:
- analytics-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 analytics-view
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d analytics-view
docker image prune -af
ENDSSH
deploy-auth-view:
<<: *deploy_setup
needs: [publish-auth-view]
rules:
- if: $CI_COMMIT_BRANCH == "main"
changes:
- auth-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 auth-view
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d auth-view
docker image prune -af
ENDSSH
deploy-portfolio-view:
<<: *deploy_setup
needs: [publish-portfolio-view]
rules:
- if: $CI_COMMIT_BRANCH == "main"
changes:
- portfolio-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 portfolio-view
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d portfolio-view
docker image prune -af
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:
<<: *deploy_setup

View File

@@ -2,12 +2,16 @@ package com.posthub.analytics.controller;
import com.posthub.analytics.dto.DailyStatsResponse;
import com.posthub.analytics.dto.DashboardResponse;
import com.posthub.analytics.dto.EventLogResponse;
import com.posthub.analytics.model.UserStats;
import com.posthub.analytics.service.AnalyticsQueryService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.List;
@RestController
@@ -34,4 +38,14 @@ public class AnalyticsController {
return ResponseEntity.ok(queryService.getDailyStats(days));
}
@GetMapping("/events")
public ResponseEntity<Page<EventLogResponse>> getEvents(
@RequestParam(required = false) String eventType,
@RequestParam(required = false) String userId,
@RequestParam(required = false) LocalDate dateFrom,
@RequestParam(required = false) LocalDate dateTo,
Pageable pageable) {
return ResponseEntity.ok(queryService.getEvents(eventType, userId, dateFrom, dateTo, pageable));
}
}

View File

@@ -0,0 +1,23 @@
package com.posthub.analytics.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
import java.time.LocalDate;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class EventLogResponse {
private Long id;
private String eventType;
private String userId;
private String chatId;
private Instant eventTimestamp;
private LocalDate eventDate;
}

View File

@@ -2,6 +2,7 @@ package com.posthub.analytics.repository;
import com.posthub.analytics.model.EventLog;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@@ -10,7 +11,7 @@ import java.time.LocalDate;
import java.util.List;
@Repository
public interface EventLogRepository extends JpaRepository<EventLog, Long> {
public interface EventLogRepository extends JpaRepository<EventLog, Long>, JpaSpecificationExecutor<EventLog> {
@Query("SELECT COUNT(DISTINCT e.userId) FROM EventLog e " +
"WHERE e.eventDate BETWEEN :from AND :to")

View File

@@ -0,0 +1,35 @@
package com.posthub.analytics.repository;
import com.posthub.analytics.model.EventLog;
import org.springframework.data.jpa.domain.Specification;
import java.time.LocalDate;
public final class EventLogSpecification {
private EventLogSpecification() {}
public static Specification<EventLog> hasEventType(String eventType) {
return (root, query, cb) -> eventType == null || eventType.isBlank()
? null
: cb.equal(root.get("eventType"), eventType);
}
public static Specification<EventLog> hasUserId(String userId) {
return (root, query, cb) -> userId == null || userId.isBlank()
? null
: cb.equal(root.get("userId"), userId);
}
public static Specification<EventLog> dateFrom(LocalDate dateFrom) {
return (root, query, cb) -> dateFrom == null
? null
: cb.greaterThanOrEqualTo(root.get("eventDate"), dateFrom);
}
public static Specification<EventLog> dateTo(LocalDate dateTo) {
return (root, query, cb) -> dateTo == null
? null
: cb.lessThanOrEqualTo(root.get("eventDate"), dateTo);
}
}

View File

@@ -2,12 +2,18 @@ package com.posthub.analytics.service;
import com.posthub.analytics.dto.DailyStatsResponse;
import com.posthub.analytics.dto.DashboardResponse;
import com.posthub.analytics.dto.EventLogResponse;
import com.posthub.analytics.model.DailyStats;
import com.posthub.analytics.model.EventLog;
import com.posthub.analytics.model.UserStats;
import com.posthub.analytics.repository.DailyStatsRepository;
import com.posthub.analytics.repository.EventLogRepository;
import com.posthub.analytics.repository.EventLogSpecification;
import com.posthub.analytics.repository.UserStatsRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -76,4 +82,28 @@ public class AnalyticsQueryService {
.activeUsers(ds.getActiveUsers())
.build();
}
public Page<EventLogResponse> getEvents(String eventType, String userId,
LocalDate dateFrom, LocalDate dateTo,
Pageable pageable) {
Specification<EventLog> spec = Specification
.where(EventLogSpecification.hasEventType(eventType))
.and(EventLogSpecification.hasUserId(userId))
.and(EventLogSpecification.dateFrom(dateFrom))
.and(EventLogSpecification.dateTo(dateTo));
return eventLogRepository.findAll(spec, pageable)
.map(this::toEventResponse);
}
private EventLogResponse toEventResponse(EventLog e) {
return EventLogResponse.builder()
.id(e.getId())
.eventType(e.getEventType())
.userId(e.getUserId())
.chatId(e.getChatId())
.eventTimestamp(e.getEventTimestamp())
.eventDate(e.getEventDate())
.build();
}
}

View File

@@ -42,6 +42,9 @@ spring:
health-check-interval: 15s
prefer-ip-address: true
instance-id: ${spring.application.name}:${random.value}
deregister-critical-service-after: 1m
lifecycle:
enabled: true
analytics:
kafka:

View File

@@ -0,0 +1,10 @@
{
"permissions": {
"additionalDirectories": [
"C:\\Users\\balex\\IdeaProjects\\post-hub-platform"
],
"allow": [
"Bash(npm install:*)"
]
}
}

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(find /c/Users/balex/IdeaProjects/post-hub-platform -type f \\\\\\(-name *.tsx -o -name *.ts -o -name *.jsx -o -name *.js -o -name *.vue \\\\\\) ! -path */node_modules/* ! -path */.next/*)"
]
}
}

23
analytics-view/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,15 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG VITE_GUEST_EMAIL
ARG VITE_GUEST_PASSWORD
ENV VITE_GUEST_EMAIL=$VITE_GUEST_EMAIL
ENV VITE_GUEST_PASSWORD=$VITE_GUEST_PASSWORD
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/analyticsview
EXPOSE 80

View File

@@ -0,0 +1,8 @@
server {
listen 80;
root /usr/share/nginx/html;
location /analyticsview/ {
try_files $uri $uri/ /analyticsview/index.html;
}
}

13
analytics-view/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/analyticsview/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Analytics Viewer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4403
analytics-view/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
{
"name": "analytics-view",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.0.2",
"recharts": "^2.15.0"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@tailwindcss/postcss": "^4.0.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.21",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"postcss": "^8.5.3",
"tailwindcss": "^4.0.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.29.1",
"vite": "^6.3.1"
}
}

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

View File

@@ -0,0 +1,38 @@
import { Route, Routes } from "react-router-dom";
import { Dashboard } from "./components/Dashboard";
import { EventsTable } from "./components/EventsTable";
import { LoginForm } from "./components/LoginForm";
import { Navbar } from "./components/Navbar";
import { useAuth } from "./hooks/useAuth";
export default function App() {
const { auth, login, logout, refresh } = useAuth();
if (!auth) {
return (
<LoginForm
onLogin={async (email, password) => {
await login(email, password);
}}
/>
);
}
return (
<div className="min-h-screen bg-slate-900">
<Navbar username={auth.username} onLogout={logout} />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<Routes>
<Route
path="/"
element={<Dashboard token={auth.token} onUnauthorized={refresh} />}
/>
<Route
path="/events"
element={<EventsTable token={auth.token} onUnauthorized={refresh} />}
/>
</Routes>
</main>
</div>
);
}

View File

@@ -0,0 +1,75 @@
import type {
ActiveUser,
DailyStats,
DashboardStats,
EventFilters,
EventLog,
PageResponse,
} from "../types";
const API_BASE = `${import.meta.env.VITE_API_BASE_URL ?? ""}/api/analytics`;
async function apiFetch<T>(
path: string,
token: string,
onUnauthorized: () => Promise<string | null>,
): Promise<T> {
let res = await fetch(`${API_BASE}${path}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 401) {
const newToken = await onUnauthorized();
if (!newToken) throw new Error("Unauthorized");
res = await fetch(`${API_BASE}${path}`, {
headers: { Authorization: `Bearer ${newToken}` },
});
}
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json() as Promise<T>;
}
export function getDashboard(
token: string,
onUnauthorized: () => Promise<string | null>,
): Promise<DashboardStats> {
return apiFetch<DashboardStats>("/dashboard", token, onUnauthorized);
}
export function getActiveUsers(
token: string,
onUnauthorized: () => Promise<string | null>,
days = 7,
): Promise<ActiveUser[]> {
return apiFetch<ActiveUser[]>(`/users/active?days=${days}`, token, onUnauthorized);
}
export function getDailyStats(
token: string,
onUnauthorized: () => Promise<string | null>,
days = 30,
): Promise<DailyStats[]> {
return apiFetch<DailyStats[]>(`/queries/daily?days=${days}`, token, onUnauthorized);
}
export function getEvents(
token: string,
onUnauthorized: () => Promise<string | null>,
filters: EventFilters,
): Promise<PageResponse<EventLog>> {
const params = new URLSearchParams();
params.set("page", String(filters.page));
params.set("size", String(filters.size));
params.set("sort", `${filters.sortField},${filters.sortDir}`);
if (filters.eventType && filters.eventType !== "ALL") params.set("eventType", filters.eventType);
if (filters.userId) params.set("userId", filters.userId);
if (filters.dateFrom) params.set("dateFrom", filters.dateFrom);
if (filters.dateTo) params.set("dateTo", filters.dateTo);
return apiFetch<PageResponse<EventLog>>(
`/events?${params.toString()}`,
token,
onUnauthorized,
);
}

View File

@@ -0,0 +1,19 @@
import type { AuthResponse } from "../types";
const AUTH_BASE = `${import.meta.env.VITE_API_BASE_URL ?? ""}/api/auth`;
export async function login(email: string, password: string): Promise<AuthResponse> {
const res = await fetch(`${AUTH_BASE}/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error(`Login failed: ${res.status}`);
return res.json() as Promise<AuthResponse>;
}
export async function refreshToken(token: string): Promise<AuthResponse> {
const res = await fetch(`${AUTH_BASE}/refresh/token?token=${encodeURIComponent(token)}`);
if (!res.ok) throw new Error(`Refresh failed: ${res.status}`);
return res.json() as Promise<AuthResponse>;
}

View File

@@ -0,0 +1,184 @@
import { useEffect, useState } from "react";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { getDashboard, getActiveUsers, getDailyStats } from "../api/analyticsApi";
import type { ActiveUser, DailyStats, DashboardStats } from "../types";
interface Props {
token: string;
onUnauthorized: () => Promise<string | null>;
}
interface StatCardProps {
label: string;
value: number | string;
color: string;
}
function StatCard({ label, value, color }: StatCardProps) {
return (
<div className={`bg-slate-800 rounded-xl border ${color} p-5`}>
<p className="text-slate-400 text-sm">{label}</p>
<p className="text-3xl font-bold text-white mt-1">{value}</p>
</div>
);
}
export function Dashboard({ token, onUnauthorized }: Props) {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [daily, setDaily] = useState<DailyStats[]>([]);
const [activeUsers, setActiveUsers] = useState<ActiveUser[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
async function load() {
setLoading(true);
setError("");
try {
const [s, d, u] = await Promise.all([
getDashboard(token, onUnauthorized),
getDailyStats(token, onUnauthorized, 30),
getActiveUsers(token, onUnauthorized, 7),
]);
setStats(s);
setDaily(d);
setActiveUsers(u);
} catch {
setError("Failed to load dashboard data.");
} finally {
setLoading(false);
}
}
void load();
}, [token, onUnauthorized]);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-10 w-10 border-t-2 border-indigo-500" />
</div>
);
}
if (error) {
return (
<div className="bg-red-900/40 border border-red-700 rounded-xl p-6 text-red-300">{error}</div>
);
}
return (
<div className="space-y-6">
{/* Stat cards */}
{stats && (
<>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard label="Total Queries" value={stats.totalQueries} color="border-indigo-700" />
<StatCard label="Total Users" value={stats.totalUsers} color="border-teal-700" />
<StatCard label="Active Today" value={stats.activeUsersToday} color="border-emerald-700" />
<StatCard
label="Event Types"
value={Object.keys(stats.eventBreakdown).length}
color="border-violet-700"
/>
</div>
{/* Event breakdown */}
<div className="bg-slate-800 rounded-xl border border-slate-700 p-5">
<h2 className="text-slate-200 font-semibold mb-4">Event Breakdown</h2>
<div className="flex flex-wrap gap-3">
{Object.entries(stats.eventBreakdown).map(([type, count]) => (
<div
key={type}
className="bg-slate-700 rounded-lg px-4 py-2 flex items-center gap-2"
>
<span className="text-slate-300 text-sm">{type}</span>
<span className="text-indigo-400 font-bold">{count}</span>
</div>
))}
</div>
</div>
</>
)}
{/* Daily queries chart */}
{daily.length > 0 && (
<div className="bg-slate-800 rounded-xl border border-slate-700 p-5">
<h2 className="text-slate-200 font-semibold mb-4">Daily Queries (last 30 days)</h2>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={daily} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis
dataKey="date"
tick={{ fill: "#94a3b8", fontSize: 11 }}
tickFormatter={(v: string) => v.slice(5)}
/>
<YAxis tick={{ fill: "#94a3b8", fontSize: 11 }} />
<Tooltip
contentStyle={{ backgroundColor: "#1e293b", border: "1px solid #334155", borderRadius: 8 }}
labelStyle={{ color: "#cbd5e1" }}
itemStyle={{ color: "#818cf8" }}
/>
<Line
type="monotone"
dataKey="queries"
stroke="#818cf8"
strokeWidth={2}
dot={false}
name="Queries"
/>
<Line
type="monotone"
dataKey="activeUsers"
stroke="#34d399"
strokeWidth={2}
dot={false}
name="Active Users"
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
{/* Active users table */}
{activeUsers.length > 0 && (
<div className="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
<div className="px-5 py-4 border-b border-slate-700">
<h2 className="text-slate-200 font-semibold">Active Users (last 7 days)</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-slate-400 text-left">
<th className="px-5 py-3 font-medium">User ID</th>
<th className="px-5 py-3 font-medium">Queries</th>
<th className="px-5 py-3 font-medium">Chats</th>
<th className="px-5 py-3 font-medium">First Seen</th>
<th className="px-5 py-3 font-medium">Last Active</th>
</tr>
</thead>
<tbody>
{activeUsers.map((u) => (
<tr key={u.id} className="border-t border-slate-700 hover:bg-slate-700/40">
<td className="px-5 py-3 text-indigo-400 font-medium">{u.userId}</td>
<td className="px-5 py-3 text-slate-200">{u.totalQueries}</td>
<td className="px-5 py-3 text-slate-200">{u.totalChats}</td>
<td className="px-5 py-3 text-slate-400">{u.firstSeen.slice(0, 10)}</td>
<td className="px-5 py-3 text-slate-400">{u.lastActive.slice(0, 10)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,241 @@
import { useCallback, useEffect, useState } from "react";
import { getEvents } from "../api/analyticsApi";
import type { EventFilters, EventLog, PageResponse, SortDir } from "../types";
import { EVENT_TYPES } from "../types";
import { Pagination } from "./Pagination";
interface Props {
token: string;
onUnauthorized: () => Promise<string | null>;
}
type SortableField = keyof Pick<EventLog, "id" | "eventType" | "userId" | "chatId" | "eventTimestamp" | "eventDate">;
const COLUMNS: { key: SortableField; label: string }[] = [
{ key: "id", label: "ID" },
{ key: "eventType", label: "Event Type" },
{ key: "userId", label: "User ID" },
{ key: "chatId", label: "Chat ID" },
{ key: "eventTimestamp", label: "Timestamp" },
{ key: "eventDate", label: "Date" },
];
const EVENT_TYPE_COLORS: Record<string, string> = {
QUERY_SENT: "bg-indigo-900/50 text-indigo-300 border-indigo-700",
CHAT_CREATED: "bg-emerald-900/50 text-emerald-300 border-emerald-700",
CHAT_DELETED: "bg-red-900/50 text-red-300 border-red-700",
USER_CREATED: "bg-teal-900/50 text-teal-300 border-teal-700",
};
const DEFAULT_FILTERS: EventFilters = {
eventType: "ALL",
userId: "",
dateFrom: "",
dateTo: "",
page: 0,
size: 20,
sortField: "eventTimestamp",
sortDir: "desc",
};
function SortIcon({ active, dir }: { active: boolean; dir: SortDir }) {
if (!active) return <span className="ml-1 text-slate-600"></span>;
return <span className="ml-1 text-indigo-400">{dir === "asc" ? "↑" : "↓"}</span>;
}
export function EventsTable({ token, onUnauthorized }: Props) {
const [filters, setFilters] = useState<EventFilters>(DEFAULT_FILTERS);
const [data, setData] = useState<PageResponse<EventLog> | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const fetchEvents = useCallback(
async (f: EventFilters) => {
setLoading(true);
setError("");
try {
const res = await getEvents(token, onUnauthorized, f);
setData(res);
} catch {
setError("Failed to load events.");
} finally {
setLoading(false);
}
},
[token, onUnauthorized],
);
useEffect(() => {
void fetchEvents(filters);
}, [filters, fetchEvents]);
function handleSort(field: SortableField) {
setFilters((prev) => ({
...prev,
page: 0,
sortField: field,
sortDir: prev.sortField === field && prev.sortDir === "asc" ? "desc" : "asc",
}));
}
function handleFilterChange(patch: Partial<EventFilters>) {
setFilters((prev) => ({ ...prev, ...patch, page: 0 }));
}
function handleReset() {
setFilters(DEFAULT_FILTERS);
}
return (
<div className="space-y-4">
{/* Filters */}
<div className="bg-slate-800 rounded-xl border border-slate-700 p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
{/* Event type */}
<div>
<label className="block text-xs font-medium text-slate-400 mb-1">Event Type</label>
<select
value={filters.eventType}
onChange={(e) => handleFilterChange({ eventType: e.target.value })}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-slate-200 text-sm
focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
{EVENT_TYPES.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
{/* User ID */}
<div>
<label className="block text-xs font-medium text-slate-400 mb-1">User ID</label>
<input
type="number"
value={filters.userId}
onChange={(e) => handleFilterChange({ userId: e.target.value })}
placeholder="Any"
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-slate-200 text-sm
placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
{/* Date From */}
<div>
<label className="block text-xs font-medium text-slate-400 mb-1">Date From</label>
<input
type="date"
value={filters.dateFrom}
onChange={(e) => handleFilterChange({ dateFrom: e.target.value })}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-slate-200 text-sm
focus:outline-none focus:ring-2 focus:ring-indigo-500 [color-scheme:dark]"
/>
</div>
{/* Date To */}
<div>
<label className="block text-xs font-medium text-slate-400 mb-1">Date To</label>
<input
type="date"
value={filters.dateTo}
onChange={(e) => handleFilterChange({ dateTo: e.target.value })}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-slate-200 text-sm
focus:outline-none focus:ring-2 focus:ring-indigo-500 [color-scheme:dark]"
/>
</div>
</div>
<div className="flex items-center justify-between mt-3">
<button
onClick={handleReset}
className="text-sm text-slate-400 hover:text-slate-200 transition-colors"
>
Reset filters
</button>
{data && (
<span className="text-xs text-slate-500">
{data.totalElements} events found
</span>
)}
</div>
</div>
{/* Table */}
<div className="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
{loading && (
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-indigo-500" />
</div>
)}
{!loading && error && (
<div className="p-6 text-red-300 bg-red-900/30">{error}</div>
)}
{!loading && !error && data && (
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-slate-400 text-left border-b border-slate-700">
{COLUMNS.map((col) => (
<th
key={col.key}
className="px-4 py-3 font-medium cursor-pointer hover:text-slate-200 select-none whitespace-nowrap"
onClick={() => handleSort(col.key)}
>
{col.label}
<SortIcon
active={filters.sortField === col.key}
dir={filters.sortDir}
/>
</th>
))}
</tr>
</thead>
<tbody>
{data.content.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-10 text-center text-slate-500">
No events found
</td>
</tr>
) : (
data.content.map((event) => (
<tr key={event.id} className="border-t border-slate-700/60 hover:bg-slate-700/30">
<td className="px-4 py-3 text-slate-400">{event.id}</td>
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-0.5 rounded border text-xs font-medium
${EVENT_TYPE_COLORS[event.eventType] ?? "bg-slate-700 text-slate-300 border-slate-600"}`}
>
{event.eventType}
</span>
</td>
<td className="px-4 py-3 text-slate-200">{event.userId}</td>
<td className="px-4 py-3 text-slate-400">{event.chatId ?? "—"}</td>
<td className="px-4 py-3 text-slate-300 whitespace-nowrap font-mono text-xs">
{event.eventTimestamp.replace("T", " ").slice(0, 19)}
</td>
<td className="px-4 py-3 text-slate-400">{event.eventDate}</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className="px-4 py-3 border-t border-slate-700">
<Pagination
page={data.number}
totalPages={data.totalPages}
totalElements={data.totalElements}
pageSize={data.size}
onPageChange={(p) => setFilters((prev) => ({ ...prev, page: p }))}
/>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,117 @@
import { FormEvent, useState } from "react";
import { GUEST_EMAIL, GUEST_PASSWORD } from "../constants";
interface Props {
onLogin: (email: string, password: string) => Promise<void>;
}
export function LoginForm({ onLogin }: Props) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
await onLogin(email, password);
} catch {
setError("Invalid credentials. Please try again.");
} finally {
setLoading(false);
}
}
async function handleGuestLogin() {
setError("");
setLoading(true);
try {
await onLogin(GUEST_EMAIL, GUEST_PASSWORD);
} catch {
setError("Guest login failed. Please try again.");
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-slate-900">
<div className="w-full max-w-md">
<div className="bg-slate-800 rounded-2xl shadow-2xl border border-slate-700 p-8">
{/* Header */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-indigo-600 mb-4">
<svg className="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<h1 className="text-2xl font-bold text-white">Analytics Viewer</h1>
<p className="text-slate-400 text-sm mt-1">Sign in to access the dashboard</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-slate-300 mb-1.5">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
placeholder="you@example.com"
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-4 py-2.5 text-white placeholder-slate-500
focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-1.5">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
placeholder="••••••••"
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-4 py-2.5 text-white placeholder-slate-500
focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition"
/>
</div>
{error && (
<div className="bg-red-900/40 border border-red-700 rounded-lg px-4 py-3 text-red-300 text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-60 disabled:cursor-not-allowed
text-white font-semibold rounded-lg px-4 py-2.5 transition-colors"
>
{loading ? "Signing in…" : "Sign In"}
</button>
<button
type="button"
onClick={handleGuestLogin}
disabled={loading}
className="w-full bg-slate-700 hover:bg-slate-600 disabled:opacity-60 disabled:cursor-not-allowed
text-slate-300 font-semibold rounded-lg px-4 py-2.5 transition-colors"
>
Login as Guest
</button>
</form>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { Link, useLocation } from "react-router-dom";
interface Props {
username: string;
onLogout: () => void;
}
export function Navbar({ username, onLogout }: Props) {
const { pathname } = useLocation();
function navClass(path: string) {
const base =
"px-4 py-2 rounded-lg text-sm font-medium transition-colors";
return pathname === path
? `${base} bg-indigo-600 text-white`
: `${base} text-slate-300 hover:text-white hover:bg-slate-700`;
}
return (
<nav className="bg-slate-800 border-b border-slate-700 sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-14">
{/* Logo + nav links */}
<div className="flex items-center gap-2">
<span className="text-indigo-400 font-bold text-lg mr-4 hidden sm:block">
Analytics
</span>
<Link to="/" className={navClass("/")}>
Dashboard
</Link>
<Link to="/events" className={navClass("/events")}>
Events
</Link>
</div>
{/* User info + logout */}
<div className="flex items-center gap-3">
<span className="text-slate-400 text-sm hidden sm:block">
{username}
</span>
<button
onClick={onLogout}
className="text-sm font-medium text-slate-300 hover:text-white bg-slate-700 hover:bg-slate-600
px-3 py-1.5 rounded-lg transition-colors"
>
Logout
</button>
</div>
</div>
</div>
</nav>
);
}

View File

@@ -0,0 +1,73 @@
interface Props {
page: number;
totalPages: number;
totalElements: number;
pageSize: number;
onPageChange: (page: number) => void;
}
export function Pagination({ page, totalPages, totalElements, pageSize, onPageChange }: Props) {
if (totalPages <= 1) return null;
const from = page * pageSize + 1;
const to = Math.min((page + 1) * pageSize, totalElements);
// Build visible page numbers (window of 5 around current)
const pages: (number | "…")[] = [];
if (totalPages <= 7) {
for (let i = 0; i < totalPages; i++) pages.push(i);
} else {
pages.push(0);
if (page > 2) pages.push("…");
for (let i = Math.max(1, page - 1); i <= Math.min(totalPages - 2, page + 1); i++) {
pages.push(i);
}
if (page < totalPages - 3) pages.push("…");
pages.push(totalPages - 1);
}
function btnClass(active: boolean, disabled: boolean) {
const base = "px-3 py-1.5 rounded-lg text-sm font-medium transition-colors";
if (disabled) return `${base} opacity-40 cursor-not-allowed text-slate-500`;
if (active) return `${base} bg-indigo-600 text-white`;
return `${base} text-slate-300 hover:bg-slate-700 hover:text-white`;
}
return (
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 mt-4">
<p className="text-sm text-slate-400">
Showing <span className="text-slate-200">{from}{to}</span> of{" "}
<span className="text-slate-200">{totalElements}</span> events
</p>
<div className="flex items-center gap-1">
<button
disabled={page === 0}
onClick={() => onPageChange(page - 1)}
className={btnClass(false, page === 0)}
>
Prev
</button>
{pages.map((p, i) =>
p === "…" ? (
<span key={`ellipsis-${i}`} className="px-2 text-slate-500"></span>
) : (
<button
key={p}
onClick={() => onPageChange(p)}
className={btnClass(p === page, false)}
>
{p + 1}
</button>
)
)}
<button
disabled={page === totalPages - 1}
onClick={() => onPageChange(page + 1)}
className={btnClass(false, page === totalPages - 1)}
>
Next
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export const GUEST_EMAIL = import.meta.env.VITE_GUEST_EMAIL;
export const GUEST_PASSWORD = import.meta.env.VITE_GUEST_PASSWORD;

View File

@@ -0,0 +1,35 @@
import { useCallback, useState } from "react";
import { login as apiLogin, refreshToken as apiRefresh } from "../api/authApi";
import type { AuthPayload } from "../types";
export function useAuth() {
const [auth, setAuth] = useState<AuthPayload | null>(null);
const login = useCallback(async (email: string, password: string) => {
const res = await apiLogin(email, password);
setAuth(res.payload);
return res.payload;
}, []);
const logout = useCallback(() => {
setAuth(null);
}, []);
// Returns new access token or null if refresh fails → triggers logout
const refresh = useCallback(async (): Promise<string | null> => {
if (!auth?.refreshToken) {
setAuth(null);
return null;
}
try {
const res = await apiRefresh(auth.refreshToken);
setAuth(res.payload);
return res.payload.token;
} catch {
setAuth(null);
return null;
}
}, [auth]);
return { auth, login, logout, refresh };
}

View File

@@ -0,0 +1,17 @@
@import "tailwindcss";
:root {
color-scheme: dark;
}
body {
margin: 0;
min-height: 100vh;
background-color: #0f172a;
color: #e2e8f0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
#root {
min-height: 100vh;
}

View File

@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import "./index.css";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter basename="/analyticsview">
<App />
</BrowserRouter>
</StrictMode>,
);

View File

@@ -0,0 +1,79 @@
export interface AuthPayload {
id: number;
username: string;
email: string;
registrationStatus: string;
lastLogin: string;
token: string;
refreshToken: string;
}
export interface AuthResponse {
message: string;
payload: AuthPayload;
success: boolean;
}
export interface DashboardStats {
totalQueries: number;
totalUsers: number;
activeUsersToday: number;
eventBreakdown: Record<string, number>;
}
export interface ActiveUser {
id: number;
userId: number;
totalQueries: number;
totalChats: number;
firstSeen: string;
lastActive: string;
}
export interface DailyStats {
date: string;
queries: number;
newUsers: number;
newChats: number;
activeUsers: number;
}
export interface EventLog {
id: number;
eventType: string;
userId: string;
chatId: string | null;
eventTimestamp: string;
eventDate: string;
}
export interface PageResponse<T> {
content: T[];
totalElements: number;
totalPages: number;
number: number;
size: number;
first: boolean;
last: boolean;
}
export type SortDir = "asc" | "desc";
export interface EventFilters {
eventType: string;
userId: string;
dateFrom: string;
dateTo: string;
page: number;
size: number;
sortField: string;
sortDir: SortDir;
}
export const EVENT_TYPES = [
"ALL",
"USER_CREATED",
"CHAT_CREATED",
"CHAT_DELETED",
"QUERY_SENT",
] as const;

1
analytics-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,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
base: "/analyticsview/",
});

View File

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

View File

@@ -0,0 +1,8 @@
server {
listen 80;
root /usr/share/nginx/html;
location /auth/ {
try_files $uri $uri/ /auth/index.html;
}
}

View File

@@ -0,0 +1,28 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
}
);

13
auth-view/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/auth/favicon.ico" sizes="any" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Auth Post Hub</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3688
auth-view/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{
"name": "rag-topic-viewer",
"name": "auth-view",
"private": true,
"version": "0.0.0",
"type": "module",
@@ -10,7 +10,6 @@
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.80.7",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.0.2"
@@ -29,7 +28,6 @@
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "^5.8.3",
"typescript-eslint": "^8.35.1",
"vite": "^7.1.7"
}
}

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

15
auth-view/src/App.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import LoginPage from "./pages/LoginPage";
import RegisterPage from "./pages/RegisterPage";
export default function App() {
return (
<BrowserRouter basename="/auth">
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
</BrowserRouter>
);
}

1
auth-view/src/index.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

10
auth-view/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);

View File

@@ -0,0 +1,209 @@
import { useEffect, useState, type SubmitEvent } from "react";
import { useSearchParams, Link } from "react-router-dom";
function saveTokensAndRedirect(token: string, refreshToken: string, redirect: string) {
localStorage.setItem("token", token);
localStorage.setItem("refreshToken", refreshToken);
window.location.href = redirect;
}
export default function LoginPage() {
const [searchParams] = useSearchParams();
const redirect = searchParams.get("redirect") ?? "/ragview";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
// Handle OAuth2 callback — token & refreshToken in URL
useEffect(() => {
const token = searchParams.get("token");
const refreshToken = searchParams.get("refreshToken");
if (token && refreshToken) {
saveTokensAndRedirect(token, refreshToken, redirect);
}
}, [searchParams, redirect]);
async function handleSubmit(e: SubmitEvent<HTMLFormElement>) {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.toLowerCase(), password }),
});
const data = await res.json();
if (data.success && data.payload?.token) {
saveTokensAndRedirect(data.payload.token, data.payload.refreshToken ?? "", redirect);
} else {
setError(data.message ?? "Login failed");
}
} catch {
setError("Network error. Please try again.");
} finally {
setLoading(false);
}
}
const registerHref = `/register?redirect=${encodeURIComponent(redirect)}`;
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center px-4">
<div className="w-full max-w-md">
{/* Card */}
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-8 shadow-2xl">
<h1 className="text-2xl font-bold text-white mb-2 text-center">Welcome back</h1>
<p className="text-gray-400 text-sm text-center mb-8">Sign in to your account</p>
{/* Email/Password form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">
Email
</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="w-full bg-gray-800 border border-gray-700 text-white rounded-lg px-4 py-2.5 text-sm placeholder-gray-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">
Password
</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="w-full bg-gray-800 border border-gray-700 text-white rounded-lg px-4 py-2.5 pr-10 text-sm placeholder-gray-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute inset-y-0 right-0 z-10 flex items-center px-3 text-gray-400 hover:text-gray-200 transition"
tabIndex={-1}
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
</button>
</div>
</div>
{error && (
<p className="text-red-400 text-sm bg-red-950/50 border border-red-800 rounded-lg px-3 py-2">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold rounded-lg py-2.5 text-sm transition"
>
{loading ? "Signing in…" : "Sign in"}
</button>
</form>
{/* Divider */}
<div className="flex items-center gap-3 my-6">
<div className="flex-1 h-px bg-gray-800" />
<span className="text-gray-500 text-xs font-medium">or</span>
<div className="flex-1 h-px bg-gray-800" />
</div>
{/* OAuth2 buttons */}
<div className="space-y-3">
<a
href="/oauth2/authorization/google"
className="flex items-center gap-3 w-full bg-gray-800 hover:bg-gray-700 border border-gray-700 text-gray-200 rounded-lg px-4 py-2.5 text-sm font-medium transition"
>
<GoogleIcon />
Login with Google
</a>
<a
href="/oauth2/authorization/github"
className="flex items-center gap-3 w-full bg-gray-800 hover:bg-gray-700 border border-gray-700 text-gray-200 rounded-lg px-4 py-2.5 text-sm font-medium transition"
>
<GitHubIcon />
Login with GitHub
</a>
<a
href="/oauth2/authorization/facebook"
className="flex items-center gap-3 w-full bg-gray-800 hover:bg-gray-700 border border-gray-700 text-gray-200 rounded-lg px-4 py-2.5 text-sm font-medium transition"
>
<FacebookIcon />
Login with Facebook
</a>
</div>
{/* Register link */}
<p className="text-center text-sm text-gray-500 mt-6">
Don&apos;t have an account?{" "}
<Link
to={registerHref}
className="text-indigo-400 hover:text-indigo-300 font-medium transition"
>
Register
</Link>
</p>
</div>
</div>
</div>
);
}
function EyeIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2"/>
</svg>
);
}
function EyeOffIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<line x1="1" y1="1" x2="23" y2="23" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
);
}
function GoogleIcon() {
return (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.64 9.205c0-.639-.057-1.252-.164-1.841H9v3.481h4.844a4.14 4.14 0 0 1-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615z" fill="#4285F4"/>
<path d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z" fill="#34A853"/>
<path d="M3.964 10.71A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.042l3.007-2.332z" fill="#FBBC05"/>
<path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z" fill="#EA4335"/>
</svg>
);
}
function GitHubIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.509 11.509 0 0 1 12 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12z"/>
</svg>
);
}
function FacebookIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="#1877F2" xmlns="http://www.w3.org/2000/svg">
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.41c0-3.025 1.792-4.697 4.533-4.697 1.312 0 2.686.236 2.686.236v2.97h-1.513c-1.491 0-1.956.93-1.956 1.874v2.25h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z"/>
</svg>
);
}

View File

@@ -0,0 +1,143 @@
import { useState, type SubmitEvent } from "react";
import { useSearchParams, Link } from "react-router-dom";
function saveTokensAndRedirect(token: string, refreshToken: string, redirect: string) {
localStorage.setItem("token", token);
localStorage.setItem("refreshToken", refreshToken);
window.location.href = redirect;
}
export default function RegisterPage() {
const [searchParams] = useSearchParams();
const redirect = searchParams.get("redirect") ?? "/ragview";
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: SubmitEvent<HTMLFormElement>) {
e.preventDefault();
setError("");
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
setLoading(true);
try {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, email: email.toLowerCase(), password, confirmPassword }),
});
const data = await res.json();
if (data.success && data.payload?.token) {
saveTokensAndRedirect(data.payload.token, data.payload.refreshToken ?? "", redirect);
} else {
setError(data.message ?? "Registration failed");
}
} catch {
setError("Network error. Please try again.");
} finally {
setLoading(false);
}
}
const loginHref = `/login?redirect=${encodeURIComponent(redirect)}`;
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center px-4">
<div className="w-full max-w-md">
{/* Card */}
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-8 shadow-2xl">
<h1 className="text-2xl font-bold text-white mb-2 text-center">Create account</h1>
<p className="text-gray-400 text-sm text-center mb-8">Sign up to get started</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">
Username
</label>
<input
type="text"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="johndoe"
className="w-full bg-gray-800 border border-gray-700 text-white rounded-lg px-4 py-2.5 text-sm placeholder-gray-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">
Email
</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="w-full bg-gray-800 border border-gray-700 text-white rounded-lg px-4 py-2.5 text-sm placeholder-gray-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">
Password
</label>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="w-full bg-gray-800 border border-gray-700 text-white rounded-lg px-4 py-2.5 text-sm placeholder-gray-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">
Confirm Password
</label>
<input
type="password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
className="w-full bg-gray-800 border border-gray-700 text-white rounded-lg px-4 py-2.5 text-sm placeholder-gray-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition"
/>
</div>
{error && (
<p className="text-red-400 text-sm bg-red-950/50 border border-red-800 rounded-lg px-3 py-2">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold rounded-lg py-2.5 text-sm transition"
>
{loading ? "Creating account…" : "Create account"}
</button>
</form>
{/* Login link */}
<p className="text-center text-sm text-gray-500 mt-6">
Already have an account?{" "}
<Link
to={loginHref}
className="text-indigo-400 hover:text-indigo-300 font-medium transition"
>
Login
</Link>
</p>
</div>
</div>
</div>
);
}

1
auth-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"]
}

7
auth-view/tsconfig.json Normal file
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"]
}

7
auth-view/vite.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
base: "/auth/",
});

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,
},
},
},
});

View File

@@ -106,6 +106,13 @@
<version>${lombok.version}</version>
<optional>true</optional>
</dependency>
<!-- OAuth2 Client (social login) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
</dependencies>
<build>

View File

@@ -1,5 +1,7 @@
package com.posthub.gateway.config;
import com.posthub.gateway.security.OAuth2AuthenticationSuccessHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
@@ -13,8 +15,11 @@ import reactor.core.publisher.Mono;
@Configuration
@EnableWebFluxSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler;
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
return http
@@ -27,7 +32,9 @@ public class SecurityConfig {
.pathMatchers(
"/api/auth/login",
"/api/auth/register",
"/api/auth/refresh/token"
"/api/auth/refresh/token",
"/oauth2/authorization/**",
"/login/oauth2/code/**"
).permitAll()
.pathMatchers(
"/actuator/**",
@@ -36,6 +43,9 @@ public class SecurityConfig {
).permitAll()
.anyExchange().permitAll()
)
.oauth2Login(oauth2 -> oauth2
.authenticationSuccessHandler(oAuth2AuthenticationSuccessHandler)
)
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint((exchange, ex) -> {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);

View File

@@ -54,6 +54,18 @@ public class AuthController {
});
}
@PostMapping("/hr-guest-token")
public Mono<ResponseEntity<RagResponse<UserProfileDTO>>> hrGuestToken() {
return authService.hrGuestToken()
.map(ResponseEntity::ok);
}
@PostMapping("/devops-guest-token")
public Mono<ResponseEntity<RagResponse<UserProfileDTO>>> devopsGuestToken() {
return authService.devopsGuestToken()
.map(ResponseEntity::ok);
}
private void addAuthCookie(ServerHttpResponse response, String token) {
ResponseCookie cookie = ResponseCookie.from("Authorization", token)
.httpOnly(true)

View File

@@ -1,5 +1,6 @@
package com.posthub.gateway.model.entity;
import com.posthub.gateway.model.enums.AuthProvider;
import com.posthub.gateway.model.enums.RegistrationStatus;
import com.posthub.gateway.model.enums.UserRole;
import lombok.Getter;
@@ -40,4 +41,10 @@ public class User {
@Column("role")
private UserRole role;
@Column("auth_provider")
private AuthProvider authProvider;
@Column("provider_id")
private String providerId;
}

View File

@@ -0,0 +1,8 @@
package com.posthub.gateway.model.enums;
public enum AuthProvider {
LOCAL,
GOOGLE,
FACEBOOK,
GITHUB
}

View File

@@ -1,6 +1,7 @@
package com.posthub.gateway.repository;
import com.posthub.gateway.model.entity.User;
import com.posthub.gateway.model.enums.AuthProvider;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Mono;
@@ -11,4 +12,6 @@ public interface UserRepository extends ReactiveCrudRepository<User, Integer> {
Mono<User> findByUsername(String username);
Mono<Boolean> existsByEmail(String email);
Mono<User> findByAuthProviderAndProviderId(AuthProvider authProvider, String providerId);
}

View File

@@ -0,0 +1,48 @@
package com.posthub.gateway.security;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.oauth2.client.userinfo.DefaultReactiveOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Slf4j
@Component
public class GitHubEmailFetcher extends DefaultReactiveOAuth2UserService {
@Override
public Mono<OAuth2User> loadUser(OAuth2UserRequest userRequest) {
return super.loadUser(userRequest)
.flatMap(user -> {
if (user.getAttribute("email") != null) {
return Mono.just(user);
}
if (!"github".equals(userRequest.getClientRegistration().getRegistrationId())) {
return Mono.just(user);
}
String token = userRequest.getAccessToken().getTokenValue();
return WebClient.create("https://api.github.com")
.get()
.uri("/user/emails")
.headers(h -> h.setBearerAuth(token))
.retrieve()
.bodyToFlux(Map.class)
.filter(e -> Boolean.TRUE.equals(e.get("primary")))
.next()
.map(e -> {
Map<String, Object> attrs = new HashMap<>(user.getAttributes());
attrs.put("email", e.get("email"));
return (OAuth2User) new DefaultOAuth2User(
user.getAuthorities(), attrs, "id");
})
.defaultIfEmpty(user);
});
}
}

View File

@@ -46,12 +46,12 @@ public class JwtTokenProvider {
claims.put(USER_REGISTRATION_STATUS, user.getRegistrationStatus().name());
claims.put(SESSION_ID, sessionId);
claims.put(LAST_UPDATE, LocalDateTime.now().toString());
return createToken(claims, user.getEmail());
return createToken(claims, user.getEmail(), jwtValidityInMilliseconds);
}
public String refreshToken(String token) {
Claims claims = getAllClaimsFromToken(token);
return createToken(claims, claims.getSubject());
return createToken(claims, claims.getSubject(), jwtValidityInMilliseconds);
}
public boolean validateToken(String token) {
@@ -99,13 +99,26 @@ public class JwtTokenProvider {
}
}
private String createToken(Map<String, Object> claims, String subject) {
private String createToken(Map<String, Object> claims, String subject, long validityMs) {
return Jwts.builder()
.claims(claims)
.subject(subject)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + jwtValidityInMilliseconds))
.expiration(new Date(System.currentTimeMillis() + validityMs))
.signWith(secretKey)
.compact();
}
public String generateHrGuestToken(@NonNull User user) {
Map<String, Object> claims = new HashMap<>();
claims.put(USER_ID, user.getId());
claims.put(USERNAME, user.getUsername());
claims.put(USER_EMAIL, user.getEmail());
claims.put(USER_ROLE, user.getRole().name());
claims.put(USER_REGISTRATION_STATUS, user.getRegistrationStatus().name());
claims.put(SESSION_ID, "guest");
claims.put(LAST_UPDATE, LocalDateTime.now().toString());
return createToken(claims, user.getEmail(), 3_600_000L);
}
}

View File

@@ -0,0 +1,158 @@
package com.posthub.gateway.security;
import com.posthub.gateway.model.entity.RefreshToken;
import com.posthub.gateway.model.entity.User;
import com.posthub.gateway.model.enums.AuthProvider;
import com.posthub.gateway.model.enums.RegistrationStatus;
import com.posthub.gateway.model.enums.UserRole;
import com.posthub.gateway.repository.RefreshTokenRepository;
import com.posthub.gateway.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.server.WebFilterExchange;
import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import java.net.URI;
import java.time.LocalDateTime;
import java.util.UUID;
@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler implements ServerAuthenticationSuccessHandler {
private final UserRepository userRepository;
private final RefreshTokenRepository refreshTokenRepository;
private final JwtTokenProvider jwtTokenProvider;
@Value("${oauth2.redirect-uri:https://balexvic.com/auth/login}")
private String redirectUri;
@Override
public Mono<Void> onAuthenticationSuccess(WebFilterExchange webFilterExchange, Authentication authentication) {
OAuth2AuthenticationToken authToken = (OAuth2AuthenticationToken) authentication;
OAuth2User oAuth2User = authToken.getPrincipal();
String registrationId = authToken.getAuthorizedClientRegistrationId();
AuthProvider provider = AuthProvider.valueOf(registrationId.toUpperCase());
String providerId = extractProviderId(oAuth2User, registrationId);
String email = extractEmail(oAuth2User, registrationId);
String name = extractName(oAuth2User, registrationId);
log.info("OAuth2 login attempt: provider={}, providerId={}, email={}", provider, providerId, email);
return findOrCreateUser(provider, providerId, email, name)
.flatMap(user -> {
user.setLastLogin(LocalDateTime.now());
user.setUpdated(LocalDateTime.now());
return userRepository.save(user);
})
.flatMap(this::generateTokensAndRedirect)
.flatMap(redirectUrl -> {
log.info("OAuth2 redirect to: {}", redirectUrl);
webFilterExchange.getExchange().getResponse().setStatusCode(HttpStatus.FOUND);
webFilterExchange.getExchange().getResponse().getHeaders().setLocation(URI.create(redirectUrl));
return webFilterExchange.getExchange().getResponse().setComplete();
})
.onErrorResume(e -> {
log.error("OAuth2 authentication failed", e);
webFilterExchange.getExchange().getResponse().setStatusCode(HttpStatus.FOUND);
webFilterExchange.getExchange().getResponse().getHeaders()
.setLocation(URI.create(redirectUri + "?error=auth_failed"));
return webFilterExchange.getExchange().getResponse().setComplete();
});
}
private Mono<User> findOrCreateUser(AuthProvider provider, String providerId, String email, String name) {
// 1. Find by provider + providerId (returning OAuth2 user)
return userRepository.findByAuthProviderAndProviderId(provider, providerId)
.doOnNext(u -> log.info("Found user by provider: id={}, email={}", u.getId(), u.getEmail()))
.switchIfEmpty(Mono.defer(() -> {
// 2. Find by email (link existing LOCAL account)
if (email == null) {
return createNewUser(provider, providerId, null, name);
}
return userRepository.findByEmail(email)
.doOnNext(u -> log.info("Found existing user by email: id={}, provider={}", u.getId(), u.getAuthProvider()))
.flatMap(existingUser -> linkAccount(existingUser, provider, providerId))
.switchIfEmpty(Mono.defer(() -> createNewUser(provider, providerId, email, name)));
}));
}
private Mono<User> linkAccount(User existingUser, AuthProvider provider, String providerId) {
existingUser.setAuthProvider(provider);
existingUser.setProviderId(providerId);
existingUser.setUpdated(LocalDateTime.now());
return userRepository.save(existingUser)
.doOnNext(u -> log.info("Linked account: id={}, newProvider={}", u.getId(), u.getAuthProvider()));
}
private Mono<User> createNewUser(AuthProvider provider, String providerId, String email, String name) {
User user = new User();
user.setAuthProvider(provider);
user.setProviderId(providerId);
user.setEmail(email);
user.setUsername(name != null ? name : "user_" + providerId.substring(0, 8));
user.setRegistrationStatus(RegistrationStatus.ACTIVE);
user.setRole(UserRole.USER);
user.setCreated(LocalDateTime.now());
user.setUpdated(LocalDateTime.now());
user.setDeleted(false);
log.info("Creating new OAuth2 user: provider={}, email={}", provider, email);
return userRepository.save(user);
}
private Mono<String> generateTokensAndRedirect(User user) {
return refreshTokenRepository.findByUserId(user.getId())
.flatMap(existing -> {
existing.setCreated(LocalDateTime.now());
existing.setToken(generateUuid());
existing.setSessionId(generateUuid());
return refreshTokenRepository.save(existing);
})
.switchIfEmpty(Mono.defer(() -> {
RefreshToken newToken = new RefreshToken();
newToken.setUserId(user.getId());
newToken.setCreated(LocalDateTime.now());
newToken.setToken(generateUuid());
newToken.setSessionId(generateUuid());
return refreshTokenRepository.save(newToken);
}))
.map(refreshToken -> {
String accessToken = jwtTokenProvider.generateToken(user, refreshToken.getSessionId());
log.info("Generated tokens for user: id={}, email={}", user.getId(), user.getEmail());
return redirectUri + "?token=" + accessToken + "&refreshToken=" + refreshToken.getToken();
});
}
private String extractProviderId(OAuth2User oAuth2User, String registrationId) {
return switch (registrationId) {
case "google" -> oAuth2User.getAttribute("sub");
case "facebook" -> oAuth2User.getAttribute("id");
case "github" -> String.valueOf(oAuth2User.getAttributes().get("id"));
default -> oAuth2User.getName();
};
}
private String extractEmail(OAuth2User oAuth2User, String registrationId) {
return oAuth2User.getAttribute("email");
}
private String extractName(OAuth2User oAuth2User, String registrationId) {
return switch (registrationId) {
case "github" -> oAuth2User.getAttribute("login");
default -> oAuth2User.getAttribute("name");
};
}
private String generateUuid() {
return UUID.randomUUID().toString().replace("-", "");
}
}

View File

@@ -15,6 +15,7 @@ import com.posthub.gateway.security.JwtTokenProvider;
import com.posthub.gateway.util.PasswordUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@@ -34,8 +35,14 @@ public class AuthService {
private final JwtTokenProvider jwtTokenProvider;
private final PasswordEncoder passwordEncoder;
@Value("${hr-guest.email}")
private String hrGuestEmail;
@Value("${devops-guest.email}")
private String devopsGuestEmail;
public Mono<RagResponse<UserProfileDTO>> login(LoginRequest request) {
return userRepository.findByEmail(request.getEmail())
return userRepository.findByEmail(request.getEmail().toLowerCase())
.switchIfEmpty(Mono.error(new ResponseStatusException(
HttpStatus.UNAUTHORIZED, ApiErrorMessage.INVALID_USER_OR_PASSWORD.getMessage())))
.flatMap(user -> {
@@ -67,7 +74,7 @@ public class AuthService {
return userRepository.findByUsername(request.getUsername())
.flatMap(existing -> Mono.<User>error(new ResponseStatusException(
HttpStatus.CONFLICT, ApiErrorMessage.USERNAME_ALREADY_EXISTS.getMessage(request.getUsername()))))
.switchIfEmpty(userRepository.existsByEmail(request.getEmail())
.switchIfEmpty(userRepository.existsByEmail(request.getEmail().toLowerCase())
.flatMap(exists -> {
if (exists) {
return Mono.<User>error(new ResponseStatusException(
@@ -75,7 +82,7 @@ public class AuthService {
}
User user = new User();
user.setUsername(request.getUsername());
user.setEmail(request.getEmail());
user.setEmail(request.getEmail().toLowerCase());
user.setPassword(passwordEncoder.encode(request.getPassword()));
user.setRegistrationStatus(RegistrationStatus.ACTIVE);
user.setRole(UserRole.USER);
@@ -104,6 +111,28 @@ public class AuthService {
});
}
public Mono<RagResponse<UserProfileDTO>> hrGuestToken() {
return userRepository.findByEmail(hrGuestEmail)
.switchIfEmpty(Mono.error(new ResponseStatusException(
HttpStatus.NOT_FOUND, "HR guest user not found")))
.map(user -> {
String accessToken = jwtTokenProvider.generateHrGuestToken(user);
UserProfileDTO dto = toUserProfileDto(user, accessToken, null);
return RagResponse.createSuccessfulWithNewToken(dto);
});
}
public Mono<RagResponse<UserProfileDTO>> devopsGuestToken() {
return userRepository.findByEmail(devopsGuestEmail)
.switchIfEmpty(Mono.error(new ResponseStatusException(
HttpStatus.NOT_FOUND, "DevOps guest user not found")))
.map(user -> {
String accessToken = jwtTokenProvider.generateHrGuestToken(user);
UserProfileDTO dto = toUserProfileDto(user, accessToken, null);
return RagResponse.createSuccessfulWithNewToken(dto);
});
}
private Mono<RagResponse<UserProfileDTO>> generateTokensAndBuildResponse(User user) {
return refreshTokenRepository.findByUserId(user.getId())
.flatMap(existing -> {

View File

@@ -6,6 +6,34 @@ spring:
application:
name: gateway-service
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID:}
client-secret: ${GOOGLE_CLIENT_SECRET:}
scope: email,profile
github:
client-id: ${GITHUB_CLIENT_ID:}
client-secret: ${GITHUB_CLIENT_SECRET:}
scope: user:email,read:user
facebook:
client-id: ${FACEBOOK_CLIENT_ID:}
client-secret: ${FACEBOOK_CLIENT_SECRET:}
scope: email,public_profile
provider:
facebook:
authorization-uri: https://www.facebook.com/v21.0/dialog/oauth
token-uri: https://graph.facebook.com/v21.0/oauth/access_token
user-info-uri: https://graph.facebook.com/v21.0/me?fields=id,name,email
# ---- Guest user (portfolio chat) ----
hr-guest:
email: ${HR_GUEST_EMAIL:}
devops-guest:
email: ${DEVOPS_GUEST_EMAIL:}
# ---- R2DBC (reactive DB) ----
r2dbc:
url: r2dbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:appdb}
@@ -27,6 +55,9 @@ spring:
health-check-interval: 15s
prefer-ip-address: true
instance-id: ${spring.application.name}:${random.value}
deregister-critical-service-after: 1m
lifecycle:
enabled: true
gateway:
server:
@@ -61,20 +92,37 @@ spring:
- RewritePath=/api/rag(?<segment>/?.*), ${segment}
- AddRequestHeader=X-Forwarded-Prefix, /api/rag
# Analytics Service - API endpoints
- id: analytics-service-api
uri: lb://analytics-service
predicates:
- Path=/api/analytics/**
- Method=GET,POST
filters:
- AddRequestHeader=X-Forwarded-Prefix, /api/analytics
# ---- JWT ----
jwt:
secret: ${JWT_SECRET:}
expiration: ${JWT_EXPIRATION:103600000}
# ---- OAuth2 redirect after success ----
oauth2:
redirect-uri: ${OAUTH2_REDIRECT_URI:https://balexvic.com/auth/login}
# ---- Auth path config ----
auth:
public-paths:
- /api/auth/login
- /api/auth/register
- /api/auth/refresh/token
- /oauth2/authorization/**
- /login/oauth2/code/**
- /actuator/**
- /api/*/v3/api-docs/**
- /api/*/swagger-ui/**
- /api/auth/hr-guest-token
- /api/auth/devops-guest-token
admin-paths:
- /api/*/admin/**
@@ -93,7 +141,7 @@ management:
health:
show-details: always
gateway:
enabled: true
access: unrestricted
# ---- Logging ----
logging:

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(npm run:*)"
]
}
}

25
portfolio-view/.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Build output
dist/
dist-ssr/
# Editor
.vscode/
.idea/
# Logs
*.log
npm-debug.log*
# Environment
.env
.env.local
.env.*.local
# OS
.DS_Store
Thumbs.db

View File

@@ -0,0 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip

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
EXPOSE 80

View File

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

17
portfolio-view/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Alexander — Java / Fullstack Developer. Switzerland, Bern. Spring Boot, React, AI." />
<title>Alexander — Java / Fullstack Developer</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;1,9..40,300&family=JetBrains+Mono:wght@400;500&display=block" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
{
"name": "portfolio-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": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "~5.7.2",
"vite": "^6.0.5"
}
}

View File

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<!-- Background -->
<rect width="32" height="32" rx="5" fill="#09090b"/>
<!-- Subtle cyan border -->
<rect x="0.5" y="0.5" width="31" height="31" rx="4.5" stroke="#22d3ee" stroke-opacity="0.25"/>
<!-- Small < bracket top-left -->
<path d="M3.5 9.5 L1.5 12 L3.5 14.5" stroke="#22d3ee" stroke-opacity="0.45" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Small /> closing tag bottom-right -->
<line x1="26.5" y1="18" x2="25" y2="23" stroke="#22d3ee" stroke-opacity="0.45" stroke-width="1.25" stroke-linecap="round"/>
<path d="M28.5 18 L30.5 20.5 L28.5 23" stroke="#22d3ee" stroke-opacity="0.45" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Geometric letter A (main element) -->
<path d="M16 5 L7 27" stroke="#22d3ee" stroke-width="2.5" stroke-linecap="round"/>
<path d="M16 5 L25 27" stroke="#22d3ee" stroke-width="2.5" stroke-linecap="round"/>
<line x1="10.5" y1="19" x2="21.5" y2="19" stroke="#22d3ee" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,27 @@
import { Header } from './components/layout/Header'
import { Footer } from './components/layout/Footer'
import { HeroSection } from './components/hero/HeroSection'
import { TechStackSection } from './components/tech/TechStackSection'
import { ArchitectureSection } from './components/architecture/ArchitectureSection'
import { ProjectsSection } from './components/projects/ProjectsSection'
import { DeliverSection } from './components/deliver/DeliverSection'
import { ChatWidget } from './components/chat/ChatWidget'
function App() {
return (
<div className="min-h-screen bg-zinc-950 text-zinc-50">
<Header />
<main>
<HeroSection />
<TechStackSection />
<ArchitectureSection />
<ProjectsSection />
<DeliverSection />
</main>
<Footer />
<ChatWidget />
</div>
)
}
export default App

View File

@@ -0,0 +1,348 @@
import { useState } from 'react'
const ACCENT = '#22d3ee'
const BOX_BG = '#27272a' // zinc-800
const BOX_BORDER = '#3f3f46' // zinc-700
const TEXT = '#fafafa'
const TEXT_MUTED = '#a1a1aa'
type BoxProps = { x: number; y: number; w: number; h: number; label: string; sub?: string }
function ServiceBox({ x, y, w, h, label, sub }: BoxProps) {
return (
<g>
<rect x={x} y={y} width={w} height={h} rx={7} fill={BOX_BG} stroke={BOX_BORDER} strokeWidth={1.5} />
<text
x={x + w / 2} y={sub ? y + h / 2 - 5 : y + h / 2 + 5}
textAnchor="middle" fill={TEXT} fontSize={12}
fontFamily="JetBrains Mono, monospace" fontWeight={600}
>
{label}
</text>
{sub && (
<text
x={x + w / 2} y={y + h / 2 + 10}
textAnchor="middle" fill={TEXT_MUTED} fontSize={10}
fontFamily="JetBrains Mono, monospace"
>
{sub}
</text>
)}
</g>
)
}
function ClickableServiceBox({ x, y, w, h, label, sub, href }: BoxProps & { href: string }) {
const [hovered, setHovered] = useState(false)
return (
<a href={href} target="_blank" rel="noopener noreferrer">
<g
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
cursor: 'pointer',
filter: hovered ? `drop-shadow(0 0 7px ${ACCENT}90)` : 'none',
transition: 'filter 0.2s',
}}
>
<rect
x={x} y={y} width={w} height={h} rx={7}
fill={BOX_BG}
stroke={hovered ? ACCENT : BOX_BORDER}
strokeWidth={hovered ? 2 : 1.5}
style={{ transition: 'stroke 0.2s, stroke-width 0.2s' }}
/>
<text
x={x + w / 2} y={sub ? y + h / 2 - 5 : y + h / 2 + 5}
textAnchor="middle" fill={TEXT} fontSize={12}
fontFamily="JetBrains Mono, monospace" fontWeight={600}
>
{label}
</text>
{sub && (
<text
x={x + w / 2} y={y + h / 2 + 10}
textAnchor="middle" fill={hovered ? ACCENT : TEXT_MUTED} fontSize={10}
fontFamily="JetBrains Mono, monospace"
style={{ transition: 'fill 0.2s' }}
>
{sub}
</text>
)}
</g>
</a>
)
}
function InfraBox({ x, y, w, h, label, sub }: BoxProps) {
return (
<g>
<rect x={x} y={y} width={w} height={h} rx={7} fill={BOX_BG} stroke={ACCENT} strokeWidth={1} strokeDasharray="4 3" opacity={0.9} />
<text
x={x + w / 2} y={sub ? y + h / 2 - 5 : y + h / 2 + 5}
textAnchor="middle" fill={TEXT} fontSize={12}
fontFamily="JetBrains Mono, monospace" fontWeight={600}
>
{label}
</text>
{sub && (
<text
x={x + w / 2} y={y + h / 2 + 10}
textAnchor="middle" fill={TEXT_MUTED} fontSize={10}
fontFamily="JetBrains Mono, monospace"
>
{sub}
</text>
)}
</g>
)
}
export function ArchitectureSection() {
const W = 1000
const H = 530
// ── Row Y positions (top edge) ──────────────────────────────
const Y_BROWSER = 18
const Y_NGINX = 88
const Y_GATEWAY = 158
const Y_SVC = 252
const Y_INFRA = 372
const H_SM = 38 // Browser, nginx
const H_MD = 44 // Gateway, services, infra
const CX = 490 // horizontal centre of the canvas
// ── Derived bottom edges ────────────────────────────────────
const browserBot = Y_BROWSER + H_SM
const nginxBot = Y_NGINX + H_SM
const gwBot = Y_GATEWAY + H_MD
const svcBot = Y_SVC + H_MD
// ── nginx box ───────────────────────────────────────────────
const nginxW = 150
const nginxX = CX - nginxW / 2 // 415
const nginxMidY = Y_NGINX + H_SM / 2
// ── Gateway box ─────────────────────────────────────────────
const gwW = 225
const gwX = CX - gwW / 2 // 378
const gwRight = gwX + gwW // 603
// ── Frontend views (left cluster, row 4) ───────────────────
const FE_W = 115
const feViews = [
{ label: 'auth-view', sub: '/auth/', x: 15 },
{ label: 'rag-view', sub: '/ragview/', x: 140, href: 'https://balexvic.com/ragview/' },
{ label: 'analytics-view', sub: '/analytics/', x: 265, href: 'https://balexvic.com/analyticsview/' },
]
const feCx = (i: number) => feViews[i].x + FE_W / 2
// ── Backend services (right cluster, row 4) ─────────────────
const BE_W = 130
const beServices = [
{ label: 'auth-service', sub: 'JWT / OAuth2', x: 400 },
{ label: 'post-service', sub: 'CRUD / feed', x: 540 },
{ label: 'rag-service', sub: 'AI / RAG', x: 680 },
{ label: 'analytics-service', sub: 'metrics', x: 820 },
]
const beCx = (i: number) => beServices[i].x + BE_W / 2
// beCx: 465, 605, 745, 885
// ── Infra row (centred under backend cluster) ───────────────
// Backend spans x=400 → 950 (width=550)
// 3 × 148 + 2 × 14 = 472 → start = 400 + (550-472)/2 = 439
const INF_W = 148
const infra = [
{ label: 'PostgreSQL', sub: 'persistence', x: 439 }, // cx = 513
{ label: 'Kafka', sub: 'event bus', x: 601 }, // cx = 675
{ label: 'Consul', sub: 'service mesh', x: 763 }, // cx = 837
]
const infCx = (i: number) => infra[i].x + INF_W / 2
// infCx: 513, 675, 837
// ── Consul right edge (for the L-path endpoint) ─────────────
const consulRight = infra[2].x + INF_W // 763+148 = 911
// Routing rail on the far right (clear of all service boxes, rightmost=950)
const RAIL_X = 972
return (
<section id="architecture" className="py-24 px-6">
<div className="max-w-5xl mx-auto">
{/* Header */}
<div className="mb-14">
<p className="font-mono text-xs text-accent tracking-widest mb-3 uppercase">
System Design
</p>
<h2 className="font-heading font-bold text-4xl md:text-5xl text-white tracking-tight">
Platform Architecture
</h2>
</div>
{/* Diagram */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900/60 p-4 overflow-x-auto">
<svg
viewBox={`0 0 ${W} ${H}`}
width="100%"
style={{ maxWidth: W, display: 'block', margin: '0 auto' }}
aria-label="Microservices architecture diagram"
>
<defs>
<marker id="arr" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill={ACCENT} />
</marker>
</defs>
{/* ═══════════════════════════════════════════════════════
SOLID ROUTING ARROWS (request routing)
═══════════════════════════════════════════════════════ */}
{/* Browser → nginx */}
<line x1={CX} y1={browserBot} x2={CX} y2={Y_NGINX - 2}
stroke={ACCENT} strokeWidth={1.5} strokeOpacity={0.85} markerEnd="url(#arr)" />
{/* nginx → Spring Cloud Gateway */}
<line x1={CX} y1={nginxBot} x2={CX} y2={Y_GATEWAY - 2}
stroke={ACCENT} strokeWidth={1.5} strokeOpacity={0.85} markerEnd="url(#arr)" />
{/* nginx → frontend views (from nginx LEFT side) */}
{feViews.map((fv, i) => (
<line key={fv.label + '-route'}
x1={nginxX} y1={nginxMidY}
x2={feCx(i)} y2={Y_SVC - 2}
stroke={ACCENT} strokeWidth={1.2} strokeOpacity={0.65} markerEnd="url(#arr)" />
))}
{/* Gateway → backend services */}
{beServices.map((bs, i) => (
<line key={bs.label + '-route'}
x1={CX} y1={gwBot}
x2={beCx(i)} y2={Y_SVC - 2}
stroke={ACCENT} strokeWidth={1.5} strokeOpacity={0.75} markerEnd="url(#arr)" />
))}
{/* ═══════════════════════════════════════════════════════
DASHED INFRA DEPENDENCY ARROWS
═══════════════════════════════════════════════════════ */}
{/* ── All 4 services → PostgreSQL ── */}
{[0, 1, 2, 3].map(i => (
<line key={`svc${i}-pg`}
x1={beCx(i)} y1={svcBot}
x2={infCx(0)} y2={Y_INFRA - 2}
stroke={ACCENT} strokeWidth={0.9} strokeOpacity={0.45} strokeDasharray="5 4" markerEnd="url(#arr)" />
))}
{/* ── post-service, rag-service, analytics-service → Kafka ── */}
{[1, 2, 3].map(i => (
<line key={`svc${i}-kafka`}
x1={beCx(i)} y1={svcBot}
x2={infCx(1)} y2={Y_INFRA - 2}
stroke={ACCENT} strokeWidth={0.9} strokeOpacity={0.45} strokeDasharray="5 4" markerEnd="url(#arr)" />
))}
{/* ── All 4 backend services → Consul (faint — many lines) ── */}
{[0, 1, 2, 3].map(i => (
<line key={`svc${i}-consul`}
x1={beCx(i)} y1={svcBot}
x2={infCx(2)} y2={Y_INFRA - 2}
stroke={ACCENT} strokeWidth={0.7} strokeOpacity={0.22} strokeDasharray="5 4" markerEnd="url(#arr)" />
))}
{/* ── Gateway → Consul (L-path along right rail, avoids all boxes) ──
M gwRight, gwMid → RAIL_X, gwMid → RAIL_X, consulMidY → consulRight, consulMidY */}
<path
d={`M ${gwRight} ${Y_GATEWAY + H_MD / 2}
L ${RAIL_X} ${Y_GATEWAY + H_MD / 2}
L ${RAIL_X} ${Y_INFRA + H_MD / 2}
L ${consulRight + 2} ${Y_INFRA + H_MD / 2}`}
stroke={ACCENT} strokeWidth={1} strokeOpacity={0.5} strokeDasharray="5 4" fill="none" markerEnd="url(#arr)" />
{/* ═══════════════════════════════════════════════════════
BOXES (rendered on top of arrows)
═══════════════════════════════════════════════════════ */}
{/* Browser */}
<ServiceBox
x={CX - 50} y={Y_BROWSER} w={100} h={H_SM}
label="Browser" />
{/* nginx */}
<ServiceBox
x={nginxX} y={Y_NGINX} w={nginxW} h={H_SM}
label="nginx" sub="reverse proxy" />
{/* Spring Cloud Gateway */}
<ServiceBox
x={gwX} y={Y_GATEWAY} w={gwW} h={H_MD}
label="Spring Cloud Gateway" sub="port 8080" />
{/* Cluster labels row 4 */}
<text
x={feViews[1].x + FE_W / 2} y={Y_SVC - 12}
textAnchor="middle" fill={TEXT_MUTED} fontSize={9}
fontFamily="JetBrains Mono, monospace" letterSpacing={1.5}
>
FRONTEND
</text>
<text
x={(beServices[0].x + beServices[3].x + BE_W) / 2} y={Y_SVC - 12}
textAnchor="middle" fill={TEXT_MUTED} fontSize={9}
fontFamily="JetBrains Mono, monospace" letterSpacing={1.5}
>
BACKEND SERVICES
</text>
{/* Subtle divider between clusters */}
<line x1={388} y1={Y_SVC - 18} x2={388} y2={Y_SVC + H_MD + 4}
stroke={BOX_BORDER} strokeWidth={1} strokeOpacity={0.5} strokeDasharray="3 4" />
{/* Frontend view boxes */}
{feViews.map(fv =>
fv.href ? (
<ClickableServiceBox key={fv.label} x={fv.x} y={Y_SVC} w={FE_W} h={H_MD} label={fv.label} sub={fv.sub} href={fv.href} />
) : (
<ServiceBox key={fv.label} x={fv.x} y={Y_SVC} w={FE_W} h={H_MD} label={fv.label} sub={fv.sub} />
)
)}
{/* Backend service boxes */}
{beServices.map(bs => (
<ServiceBox key={bs.label} x={bs.x} y={Y_SVC} w={BE_W} h={H_MD} label={bs.label} sub={bs.sub} />
))}
{/* "SHARED INFRASTRUCTURE" label */}
<text
x={W / 2} y={Y_INFRA - 11}
textAnchor="middle" fill={TEXT_MUTED} fontSize={9}
fontFamily="JetBrains Mono, monospace" letterSpacing={2}
>
SHARED INFRASTRUCTURE
</text>
{/* Infra boxes */}
{infra.map(inf => (
<InfraBox key={inf.label} x={inf.x} y={Y_INFRA} w={INF_W} h={H_MD} label={inf.label} sub={inf.sub} />
))}
{/* ═══════════════════════════════════════════════════════
LEGEND
═══════════════════════════════════════════════════════ */}
<g transform={`translate(14, ${H - 52})`}>
<line x1={0} y1={8} x2={28} y2={8} stroke={ACCENT} strokeWidth={1.5} markerEnd="url(#arr)" />
<text x={34} y={12} fill={TEXT_MUTED} fontSize={10} fontFamily="JetBrains Mono, monospace">
request routing
</text>
<line x1={0} y1={28} x2={28} y2={28} stroke={ACCENT} strokeWidth={1} strokeDasharray="5 4" markerEnd="url(#arr)" />
<text x={34} y={32} fill={TEXT_MUTED} fontSize={10} fontFamily="JetBrains Mono, monospace">
infra dependency
</text>
</g>
</svg>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,67 @@
import { useState, useRef, type KeyboardEvent } from 'react'
interface ChatInputProps {
onSend: (message: string) => void
disabled: boolean
}
export function ChatInput({ onSend, disabled }: ChatInputProps) {
const [value, setValue] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
const handleSend = () => {
const trimmed = value.trim()
if (!trimmed || disabled) return
onSend(trimmed)
setValue('')
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
}
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
const handleInput = () => {
const el = textareaRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 120)}px`
}
return (
<div className="flex items-end gap-2 p-3 border-t border-zinc-800 bg-zinc-950/50">
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
onInput={handleInput}
disabled={disabled}
rows={1}
placeholder="Ask me about my experience, projects, or tech stack..."
className="flex-1 resize-none bg-zinc-800/60 border border-zinc-700 rounded-md px-3 py-2 text-sm text-zinc-100 placeholder-zinc-600 font-body focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20 transition-colors disabled:opacity-40 disabled:cursor-not-allowed min-h-[38px] max-h-[120px] leading-relaxed"
/>
<button
onClick={handleSend}
disabled={disabled || !value.trim()}
aria-label="Send message"
className="shrink-0 w-9 h-9 flex items-center justify-center rounded-md bg-accent text-zinc-950 hover:bg-accent-hover transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
<path
d="M7.5 1v13M1 7.5l6.5-6.5 6.5 6.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import type { ChatMessageType } from '../../types'
interface ChatMessageProps {
message: ChatMessageType
}
export function ChatMessage({ message }: ChatMessageProps) {
const isUser = message.role === 'user'
return (
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-3`}>
{!isUser && (
<div className="shrink-0 w-6 h-6 rounded-full bg-accent/20 border border-accent/30 flex items-center justify-center mr-2 mt-0.5">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<circle cx="6" cy="6" r="3" fill="#22d3ee" />
<circle cx="6" cy="6" r="5.5" stroke="#22d3ee" strokeOpacity="0.4" />
</svg>
</div>
)}
<div
className={`max-w-[80%] px-3 py-2 rounded-lg text-sm leading-relaxed font-body ${
isUser
? 'bg-accent/15 text-zinc-100 border border-accent/20'
: 'bg-zinc-800 text-zinc-200 border border-zinc-700'
}`}
>
{message.content}
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More