Compare commits
40 Commits
auth-to-ga
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 559f3c61bc | |||
| f1c47a7f90 | |||
| 76b13bcd85 | |||
| 37fd64c986 | |||
| 98ff92c4b7 | |||
| 41e0c180d3 | |||
| 73f7e8fcd5 | |||
| 7754bce7af | |||
| eaa87310f1 | |||
| 27f8bf664c | |||
| 0ae477369a | |||
| e235f3ff3d | |||
| b122600ffa | |||
| 5b383b9fe4 | |||
| 117e794753 | |||
| 5ae4f831bf | |||
| 94b7c2adcc | |||
| 2cf699c276 | |||
| 184f4fb542 | |||
| 80bc42c785 | |||
| 908206b5cd | |||
| e815c02f70 | |||
| 0b8356693a | |||
| 260e460ded | |||
| 69349a8788 | |||
| 30e69ccbb3 | |||
| ed18993aeb | |||
| dbe8427987 | |||
| f1fa09c8e8 | |||
| ab999c558d | |||
| e587480abb | |||
| efcdc75171 | |||
| f661ef8918 | |||
| 895448945a | |||
| c5a3f5607d | |||
| 19f4e07b4d | |||
| cacc701881 | |||
| 43cdb31b1e | |||
| 2b905b3999 | |||
| 5dec55b315 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,2 +1,6 @@
|
|||||||
.idea/
|
.idea/
|
||||||
*.iml
|
*.iml
|
||||||
|
authid.txt
|
||||||
|
vps-config-docs.txt
|
||||||
|
auth-view/node_modules/
|
||||||
|
auth-view/dist/
|
||||||
237
.gitlab-ci.yml
237
.gitlab-ci.yml
@@ -90,6 +90,86 @@ build-rag-view:
|
|||||||
changes:
|
changes:
|
||||||
- rag-view/**/*
|
- 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
|
# PUBLISH DOCKER IMAGES
|
||||||
# ══════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════
|
||||||
@@ -161,7 +241,7 @@ publish-rag-view:
|
|||||||
before_script:
|
before_script:
|
||||||
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
|
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
|
||||||
script:
|
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:${CI_COMMIT_SHORT_SHA}
|
||||||
- docker push $REGISTRY/rag-view:latest
|
- docker push $REGISTRY/rag-view:latest
|
||||||
needs: [build-rag-view]
|
needs: [build-rag-view]
|
||||||
@@ -170,6 +250,82 @@ publish-rag-view:
|
|||||||
changes:
|
changes:
|
||||||
- rag-view/**/*
|
- 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
|
# DEPLOY TO VPS
|
||||||
# ══════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════
|
||||||
@@ -202,6 +358,7 @@ deploy-rag:
|
|||||||
cd /opt/services
|
cd /opt/services
|
||||||
export CI_COMMIT_SHORT_SHA=${CI_COMMIT_SHORT_SHA}
|
export CI_COMMIT_SHORT_SHA=${CI_COMMIT_SHORT_SHA}
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull rag-service
|
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 compose -f docker-compose.yml -f docker-compose.prod.yml up -d rag-service
|
||||||
docker image prune -af
|
docker image prune -af
|
||||||
ENDSSH
|
ENDSSH
|
||||||
@@ -221,6 +378,7 @@ deploy-gateway:
|
|||||||
cd /opt/services
|
cd /opt/services
|
||||||
export CI_COMMIT_SHORT_SHA=${CI_COMMIT_SHORT_SHA}
|
export CI_COMMIT_SHORT_SHA=${CI_COMMIT_SHORT_SHA}
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull gateway-service
|
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 compose -f docker-compose.yml -f docker-compose.prod.yml up -d gateway-service
|
||||||
docker image prune -af
|
docker image prune -af
|
||||||
ENDSSH
|
ENDSSH
|
||||||
@@ -240,6 +398,7 @@ deploy-analytics:
|
|||||||
cd /opt/services
|
cd /opt/services
|
||||||
export CI_COMMIT_SHORT_SHA=${CI_COMMIT_SHORT_SHA}
|
export CI_COMMIT_SHORT_SHA=${CI_COMMIT_SHORT_SHA}
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull analytics-service
|
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 compose -f docker-compose.yml -f docker-compose.prod.yml up -d analytics-service
|
||||||
docker image prune -af
|
docker image prune -af
|
||||||
ENDSSH
|
ENDSSH
|
||||||
@@ -263,6 +422,82 @@ deploy-rag-view:
|
|||||||
docker image prune -af
|
docker image prune -af
|
||||||
ENDSSH
|
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 services at once (manual trigger)
|
||||||
deploy-all:
|
deploy-all:
|
||||||
<<: *deploy_setup
|
<<: *deploy_setup
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ package com.posthub.analytics.controller;
|
|||||||
|
|
||||||
import com.posthub.analytics.dto.DailyStatsResponse;
|
import com.posthub.analytics.dto.DailyStatsResponse;
|
||||||
import com.posthub.analytics.dto.DashboardResponse;
|
import com.posthub.analytics.dto.DashboardResponse;
|
||||||
|
import com.posthub.analytics.dto.EventLogResponse;
|
||||||
import com.posthub.analytics.model.UserStats;
|
import com.posthub.analytics.model.UserStats;
|
||||||
import com.posthub.analytics.service.AnalyticsQueryService;
|
import com.posthub.analytics.service.AnalyticsQueryService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -34,4 +38,14 @@ public class AnalyticsController {
|
|||||||
return ResponseEntity.ok(queryService.getDailyStats(days));
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.posthub.analytics.repository;
|
|||||||
|
|
||||||
import com.posthub.analytics.model.EventLog;
|
import com.posthub.analytics.model.EventLog;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
@@ -10,7 +11,7 @@ import java.time.LocalDate;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Repository
|
@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 " +
|
@Query("SELECT COUNT(DISTINCT e.userId) FROM EventLog e " +
|
||||||
"WHERE e.eventDate BETWEEN :from AND :to")
|
"WHERE e.eventDate BETWEEN :from AND :to")
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,18 @@ package com.posthub.analytics.service;
|
|||||||
|
|
||||||
import com.posthub.analytics.dto.DailyStatsResponse;
|
import com.posthub.analytics.dto.DailyStatsResponse;
|
||||||
import com.posthub.analytics.dto.DashboardResponse;
|
import com.posthub.analytics.dto.DashboardResponse;
|
||||||
|
import com.posthub.analytics.dto.EventLogResponse;
|
||||||
import com.posthub.analytics.model.DailyStats;
|
import com.posthub.analytics.model.DailyStats;
|
||||||
|
import com.posthub.analytics.model.EventLog;
|
||||||
import com.posthub.analytics.model.UserStats;
|
import com.posthub.analytics.model.UserStats;
|
||||||
import com.posthub.analytics.repository.DailyStatsRepository;
|
import com.posthub.analytics.repository.DailyStatsRepository;
|
||||||
import com.posthub.analytics.repository.EventLogRepository;
|
import com.posthub.analytics.repository.EventLogRepository;
|
||||||
|
import com.posthub.analytics.repository.EventLogSpecification;
|
||||||
import com.posthub.analytics.repository.UserStatsRepository;
|
import com.posthub.analytics.repository.UserStatsRepository;
|
||||||
import lombok.RequiredArgsConstructor;
|
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.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -76,4 +82,28 @@ public class AnalyticsQueryService {
|
|||||||
.activeUsers(ds.getActiveUsers())
|
.activeUsers(ds.getActiveUsers())
|
||||||
.build();
|
.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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -42,6 +42,9 @@ spring:
|
|||||||
health-check-interval: 15s
|
health-check-interval: 15s
|
||||||
prefer-ip-address: true
|
prefer-ip-address: true
|
||||||
instance-id: ${spring.application.name}:${random.value}
|
instance-id: ${spring.application.name}:${random.value}
|
||||||
|
deregister-critical-service-after: 1m
|
||||||
|
lifecycle:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
analytics:
|
analytics:
|
||||||
kafka:
|
kafka:
|
||||||
|
|||||||
10
analytics-view/.claude/settings.json
Normal file
10
analytics-view/.claude/settings.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"additionalDirectories": [
|
||||||
|
"C:\\Users\\balex\\IdeaProjects\\post-hub-platform"
|
||||||
|
],
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm install:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
7
analytics-view/.claude/settings.local.json
Normal file
7
analytics-view/.claude/settings.local.json
Normal 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
23
analytics-view/.gitignore
vendored
Normal 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?
|
||||||
15
analytics-view/docker/Dockerfile
Normal file
15
analytics-view/docker/Dockerfile
Normal 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
|
||||||
8
analytics-view/docker/nginx.conf
Normal file
8
analytics-view/docker/nginx.conf
Normal 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
13
analytics-view/index.html
Normal 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
4403
analytics-view/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
analytics-view/package.json
Normal file
35
analytics-view/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
analytics-view/postcss.config.js
Normal file
5
analytics-view/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
38
analytics-view/src/App.tsx
Normal file
38
analytics-view/src/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
analytics-view/src/api/analyticsApi.ts
Normal file
75
analytics-view/src/api/analyticsApi.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
19
analytics-view/src/api/authApi.ts
Normal file
19
analytics-view/src/api/authApi.ts
Normal 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>;
|
||||||
|
}
|
||||||
184
analytics-view/src/components/Dashboard.tsx
Normal file
184
analytics-view/src/components/Dashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
241
analytics-view/src/components/EventsTable.tsx
Normal file
241
analytics-view/src/components/EventsTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
analytics-view/src/components/LoginForm.tsx
Normal file
117
analytics-view/src/components/LoginForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
analytics-view/src/components/Navbar.tsx
Normal file
53
analytics-view/src/components/Navbar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
analytics-view/src/components/Pagination.tsx
Normal file
73
analytics-view/src/components/Pagination.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
analytics-view/src/constants.ts
Normal file
2
analytics-view/src/constants.ts
Normal 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;
|
||||||
35
analytics-view/src/hooks/useAuth.ts
Normal file
35
analytics-view/src/hooks/useAuth.ts
Normal 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 };
|
||||||
|
}
|
||||||
17
analytics-view/src/index.css
Normal file
17
analytics-view/src/index.css
Normal 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;
|
||||||
|
}
|
||||||
13
analytics-view/src/main.tsx
Normal file
13
analytics-view/src/main.tsx
Normal 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>,
|
||||||
|
);
|
||||||
79
analytics-view/src/types/index.ts
Normal file
79
analytics-view/src/types/index.ts
Normal 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
1
analytics-view/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
22
analytics-view/tsconfig.app.json
Normal file
22
analytics-view/tsconfig.app.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
analytics-view/tsconfig.json
Normal file
7
analytics-view/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
20
analytics-view/tsconfig.node.json
Normal file
20
analytics-view/tsconfig.node.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
7
analytics-view/vite.config.ts
Normal file
7
analytics-view/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
base: "/analyticsview/",
|
||||||
|
});
|
||||||
11
auth-view/docker/Dockerfile
Normal file
11
auth-view/docker/Dockerfile
Normal 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
|
||||||
8
auth-view/docker/nginx.conf
Normal file
8
auth-view/docker/nginx.conf
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
|
||||||
|
location /auth/ {
|
||||||
|
try_files $uri $uri/ /auth/index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
auth-view/eslint.config.js
Normal file
28
auth-view/eslint.config.js
Normal 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
13
auth-view/index.html
Normal 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
3688
auth-view/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "rag-topic-viewer",
|
"name": "auth-view",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -10,7 +10,6 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.80.7",
|
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-router-dom": "^7.0.2"
|
"react-router-dom": "^7.0.2"
|
||||||
@@ -29,7 +28,6 @@
|
|||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"typescript-eslint": "^8.35.1",
|
|
||||||
"vite": "^7.1.7"
|
"vite": "^7.1.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
5
auth-view/postcss.config.js
Normal file
5
auth-view/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
15
auth-view/src/App.tsx
Normal file
15
auth-view/src/App.tsx
Normal 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
1
auth-view/src/index.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
10
auth-view/src/main.tsx
Normal file
10
auth-view/src/main.tsx
Normal 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>
|
||||||
|
);
|
||||||
209
auth-view/src/pages/LoginPage.tsx
Normal file
209
auth-view/src/pages/LoginPage.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
auth-view/src/pages/RegisterPage.tsx
Normal file
143
auth-view/src/pages/RegisterPage.tsx
Normal 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
1
auth-view/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
22
auth-view/tsconfig.app.json
Normal file
22
auth-view/tsconfig.app.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
auth-view/tsconfig.json
Normal file
7
auth-view/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
20
auth-view/tsconfig.node.json
Normal file
20
auth-view/tsconfig.node.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
7
auth-view/vite.config.ts
Normal file
7
auth-view/vite.config.ts
Normal 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
27
devops-view/.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
11
devops-view/docker/Dockerfile
Normal file
11
devops-view/docker/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html/devops
|
||||||
|
EXPOSE 80
|
||||||
7
devops-view/docker/nginx.conf
Normal file
7
devops-view/docker/nginx.conf
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
location /devops/ {
|
||||||
|
alias /usr/share/nginx/html/devops/;
|
||||||
|
try_files $uri $uri/ /devops/index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
devops-view/index.html
Normal file
18
devops-view/index.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>DevOps Assistant</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2490
devops-view/package-lock.json
generated
Normal file
2490
devops-view/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
devops-view/package.json
Normal file
24
devops-view/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "devops-view",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "~5.7.2",
|
||||||
|
"vite": "^6.2.0",
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"tailwindcss": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
41
devops-view/src/App.tsx
Normal file
41
devops-view/src/App.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Header } from "./components/layout/Header";
|
||||||
|
import { Footer } from "./components/layout/Footer";
|
||||||
|
import { HeroSection } from "./components/hero/HeroSection";
|
||||||
|
import { ExampleQuestions } from "./components/examples/ExampleQuestions";
|
||||||
|
import { ChatSection } from "./components/chat/ChatSection";
|
||||||
|
import { useDevopsGuestAuth } from "./hooks/useDevopsGuestAuth";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { token, isLoading, error } = useDevopsGuestAuth();
|
||||||
|
const [pendingQuestion, setPendingQuestion] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function handleExampleSelect(question: string) {
|
||||||
|
setPendingQuestion(question);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleQuestionConsumed() {
|
||||||
|
setPendingQuestion(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-gray-950 text-gray-100">
|
||||||
|
<Header />
|
||||||
|
<main className="flex-1 flex flex-col max-w-5xl w-full mx-auto px-4 py-6">
|
||||||
|
<HeroSection />
|
||||||
|
<ExampleQuestions
|
||||||
|
onSelect={handleExampleSelect}
|
||||||
|
disabled={isLoading || !token}
|
||||||
|
/>
|
||||||
|
<ChatSection
|
||||||
|
token={token}
|
||||||
|
isAuthLoading={isLoading}
|
||||||
|
authError={error}
|
||||||
|
pendingQuestion={pendingQuestion}
|
||||||
|
onQuestionConsumed={handleQuestionConsumed}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
devops-view/src/components/chat/ChatInput.tsx
Normal file
71
devops-view/src/components/chat/ChatInput.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSend: (text: string) => void;
|
||||||
|
disabled: boolean;
|
||||||
|
prefill?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatInput({ onSend, disabled, prefill }: Props) {
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (prefill) {
|
||||||
|
setValue(prefill);
|
||||||
|
}
|
||||||
|
}, [prefill]);
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed || disabled) return;
|
||||||
|
onSend(trimmed);
|
||||||
|
setValue("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex gap-2 items-end border-t border-green-900/30 pt-4"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder="Ask about architecture, services, configs..."
|
||||||
|
rows={2}
|
||||||
|
className="
|
||||||
|
flex-1 resize-none rounded-lg px-4 py-3
|
||||||
|
bg-gray-900 border border-green-900/40
|
||||||
|
text-gray-200 placeholder-gray-600
|
||||||
|
font-mono text-sm leading-relaxed
|
||||||
|
focus:outline-none focus:border-green-700/60 focus:ring-1 focus:ring-green-700/30
|
||||||
|
disabled:opacity-40 disabled:cursor-not-allowed
|
||||||
|
transition-colors
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={disabled || !value.trim()}
|
||||||
|
className="
|
||||||
|
px-5 py-3 rounded-lg font-mono text-sm font-bold
|
||||||
|
bg-green-700 hover:bg-green-600 active:bg-green-800
|
||||||
|
text-gray-950
|
||||||
|
disabled:opacity-30 disabled:cursor-not-allowed
|
||||||
|
transition-colors
|
||||||
|
whitespace-nowrap
|
||||||
|
"
|
||||||
|
>
|
||||||
|
send →
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
devops-view/src/components/chat/ChatMessage.tsx
Normal file
36
devops-view/src/components/chat/ChatMessage.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Message } from "../../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
message: Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatMessage({ message }: Props) {
|
||||||
|
const isUser = message.role === "USER";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex gap-3 ${isUser ? "flex-row-reverse" : "flex-row"} mb-4`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
flex-shrink-0 w-8 h-8 rounded-md flex items-center justify-center font-mono text-xs font-bold
|
||||||
|
${isUser ? "bg-green-900/60 text-green-300" : "bg-gray-800 text-gray-400"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{isUser ? "you" : "ai"}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
max-w-[80%] px-4 py-3 rounded-lg font-mono text-sm leading-relaxed whitespace-pre-wrap
|
||||||
|
${
|
||||||
|
isUser
|
||||||
|
? "bg-green-950/50 border border-green-800/40 text-green-100"
|
||||||
|
: "bg-gray-900 border border-gray-800/60 text-gray-200"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{message.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
devops-view/src/components/chat/ChatSection.tsx
Normal file
169
devops-view/src/components/chat/ChatSection.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import type { Message } from "../../types";
|
||||||
|
import { createChat, sendMessage } from "../../services/api";
|
||||||
|
import { ChatMessage } from "./ChatMessage";
|
||||||
|
import { ChatInput } from "./ChatInput";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
token: string | null;
|
||||||
|
isAuthLoading: boolean;
|
||||||
|
authError: string | null;
|
||||||
|
pendingQuestion: string | null;
|
||||||
|
onQuestionConsumed: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatSection({
|
||||||
|
token,
|
||||||
|
isAuthLoading,
|
||||||
|
authError,
|
||||||
|
pendingQuestion,
|
||||||
|
onQuestionConsumed,
|
||||||
|
}: Props) {
|
||||||
|
const [chatId, setChatId] = useState<number | null>(null);
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [isSending, setIsSending] = useState(false);
|
||||||
|
const [chatError, setChatError] = useState<string | null>(null);
|
||||||
|
const [prefill, setPrefill] = useState<string | undefined>(undefined);
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
const initializedRef = useRef(false);
|
||||||
|
|
||||||
|
// Init chat once token is available
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || initializedRef.current) return;
|
||||||
|
initializedRef.current = true;
|
||||||
|
|
||||||
|
createChat(token)
|
||||||
|
.then((chat) => setChatId(chat.id))
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
setChatError(
|
||||||
|
err instanceof Error ? err.message : "Failed to start chat"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
// Consume pending question from example cards
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingQuestion && chatId && !isSending) {
|
||||||
|
setPrefill(pendingQuestion);
|
||||||
|
onQuestionConsumed();
|
||||||
|
}
|
||||||
|
}, [pendingQuestion, chatId, isSending, onQuestionConsumed]);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom on new messages
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const handleSend = useCallback(
|
||||||
|
async (text: string) => {
|
||||||
|
if (!token || !chatId) return;
|
||||||
|
setPrefill(undefined);
|
||||||
|
|
||||||
|
const userMsg: Message = {
|
||||||
|
id: `user-${Date.now()}`,
|
||||||
|
content: text,
|
||||||
|
role: "USER",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
setMessages((prev) => [...prev, userMsg]);
|
||||||
|
setIsSending(true);
|
||||||
|
setChatError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await sendMessage(token, chatId, text);
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: response.id,
|
||||||
|
content: response.content,
|
||||||
|
role: "ASSISTANT",
|
||||||
|
createdAt: response.createdAt,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} catch (err) {
|
||||||
|
setChatError(
|
||||||
|
err instanceof Error ? err.message : "Failed to get response"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsSending(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[token, chatId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Trigger send when prefill is set from example questions
|
||||||
|
useEffect(() => {
|
||||||
|
if (prefill && chatId && !isSending) {
|
||||||
|
handleSend(prefill);
|
||||||
|
setPrefill(undefined);
|
||||||
|
}
|
||||||
|
}, [prefill, chatId, isSending, handleSend]);
|
||||||
|
|
||||||
|
const isInputDisabled = isAuthLoading || !token || !chatId || isSending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col flex-1 min-h-0">
|
||||||
|
<p className="text-gray-500 font-mono text-xs uppercase tracking-widest mb-3">
|
||||||
|
// chat
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col flex-1 min-h-0 rounded-lg border border-green-900/30 bg-gray-950/40 overflow-hidden">
|
||||||
|
{/* Message list */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
{isAuthLoading && (
|
||||||
|
<div className="flex items-center gap-2 text-gray-500 font-mono text-sm">
|
||||||
|
<span className="inline-block w-2 h-2 rounded-full bg-green-700 animate-pulse" />
|
||||||
|
Authenticating...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{authError && (
|
||||||
|
<div className="text-red-400 font-mono text-sm bg-red-950/20 border border-red-900/30 rounded px-4 py-3">
|
||||||
|
Auth error: {authError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isAuthLoading && token && !chatId && !chatError && (
|
||||||
|
<div className="flex items-center gap-2 text-gray-500 font-mono text-sm">
|
||||||
|
<span className="inline-block w-2 h-2 rounded-full bg-green-700 animate-pulse" />
|
||||||
|
Initializing chat session...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{chatError && (
|
||||||
|
<div className="text-red-400 font-mono text-sm bg-red-950/20 border border-red-900/30 rounded px-4 py-3">
|
||||||
|
{chatError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.length === 0 && chatId && !chatError && (
|
||||||
|
<div className="text-gray-600 font-mono text-sm text-center py-8">
|
||||||
|
Ask a question to get started.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<ChatMessage key={msg.id} message={msg} />
|
||||||
|
))}
|
||||||
|
{isSending && (
|
||||||
|
<div className="flex gap-3 mb-4">
|
||||||
|
<div className="w-8 h-8 rounded-md flex items-center justify-center bg-gray-800 text-gray-400 font-mono text-xs font-bold flex-shrink-0">
|
||||||
|
ai
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3 rounded-lg bg-gray-900 border border-gray-800/60 font-mono text-sm text-gray-500">
|
||||||
|
<span className="inline-flex gap-1">
|
||||||
|
<span className="animate-bounce [animation-delay:0ms]">.</span>
|
||||||
|
<span className="animate-bounce [animation-delay:150ms]">.</span>
|
||||||
|
<span className="animate-bounce [animation-delay:300ms]">.</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
<ChatInput
|
||||||
|
onSend={handleSend}
|
||||||
|
disabled={isInputDisabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
devops-view/src/components/examples/ExampleQuestions.tsx
Normal file
46
devops-view/src/components/examples/ExampleQuestions.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
const QUESTIONS = [
|
||||||
|
"What port does analytics-service use?",
|
||||||
|
"How does JWT authentication work?",
|
||||||
|
"What Kafka topics are configured?",
|
||||||
|
"Describe the CI/CD pipeline",
|
||||||
|
"What databases are used?",
|
||||||
|
"How is Nginx configured?",
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSelect: (question: string) => void;
|
||||||
|
disabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExampleQuestions({ onSelect, disabled }: Props) {
|
||||||
|
return (
|
||||||
|
<section className="mb-6">
|
||||||
|
<p className="text-gray-500 font-mono text-xs uppercase tracking-widest mb-3">
|
||||||
|
// example questions
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||||
|
{QUESTIONS.map((q) => (
|
||||||
|
<button
|
||||||
|
key={q}
|
||||||
|
onClick={() => onSelect(q)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="
|
||||||
|
group text-left px-4 py-3 rounded-lg border border-green-900/40
|
||||||
|
bg-gray-900/60 hover:bg-green-950/40 hover:border-green-700/60
|
||||||
|
text-gray-300 hover:text-green-300
|
||||||
|
font-mono text-sm leading-snug
|
||||||
|
transition-all duration-200
|
||||||
|
disabled:opacity-40 disabled:cursor-not-allowed
|
||||||
|
hover:shadow-[0_0_12px_rgba(74,222,128,0.08)]
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span className="text-green-600 group-hover:text-green-400 mr-1.5 transition-colors">
|
||||||
|
$
|
||||||
|
</span>
|
||||||
|
{q}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
devops-view/src/components/hero/HeroSection.tsx
Normal file
22
devops-view/src/components/hero/HeroSection.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export function HeroSection() {
|
||||||
|
return (
|
||||||
|
<section className="py-10 text-center">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full border border-green-800/50 bg-green-950/30 mb-5">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-green-400" />
|
||||||
|
<span className="text-green-400/80 font-mono text-xs tracking-widest uppercase">
|
||||||
|
Powered by RAG
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-bold font-mono text-green-300 mb-3 tracking-tight">
|
||||||
|
DevOps Assistant
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 font-mono text-base max-w-lg mx-auto leading-relaxed">
|
||||||
|
Ask anything about the platform architecture.
|
||||||
|
<br />
|
||||||
|
<span className="text-gray-500 text-sm">
|
||||||
|
Answers based on real infrastructure docs.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
devops-view/src/components/layout/Footer.tsx
Normal file
11
devops-view/src/components/layout/Footer.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="border-t border-green-900/30 bg-gray-950/60 mt-auto">
|
||||||
|
<div className="max-w-5xl mx-auto px-4 h-10 flex items-center justify-center">
|
||||||
|
<span className="text-gray-600 font-mono text-xs">
|
||||||
|
powered by RAG · answers based on real infrastructure docs
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
devops-view/src/components/layout/Header.tsx
Normal file
17
devops-view/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export function Header() {
|
||||||
|
return (
|
||||||
|
<header className="border-b border-green-900/40 bg-gray-950/80 backdrop-blur-sm sticky top-0 z-10">
|
||||||
|
<div className="max-w-5xl mx-auto px-4 h-14 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-green-400 font-mono text-lg font-bold tracking-tight">
|
||||||
|
>_ devops-assistant
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block w-2 h-2 rounded-full bg-green-400 animate-pulse" />
|
||||||
|
<span className="text-green-400/70 font-mono text-xs">online</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
devops-view/src/hooks/useDevopsGuestAuth.ts
Normal file
58
devops-view/src/hooks/useDevopsGuestAuth.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import type { AuthResponse } from "../types";
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
token: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
const RETRY_DELAY_MS = 3000;
|
||||||
|
|
||||||
|
export function useDevopsGuestAuth(): AuthState {
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const attemptsRef = useRef(0);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function fetchToken() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/devops-guest-token", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = (await res.json()) as AuthResponse;
|
||||||
|
if (!cancelled) {
|
||||||
|
setToken(data.payload.token);
|
||||||
|
setIsLoading(false);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelled) return;
|
||||||
|
attemptsRef.current += 1;
|
||||||
|
if (attemptsRef.current < MAX_RETRIES) {
|
||||||
|
timerRef.current = setTimeout(fetchToken, RETRY_DELAY_MS);
|
||||||
|
} else {
|
||||||
|
setIsLoading(false);
|
||||||
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Authentication failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchToken();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (timerRef.current !== null) clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { token, isLoading, error };
|
||||||
|
}
|
||||||
30
devops-view/src/index.css
Normal file
30
devops-view/src/index.css
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
font-family: "JetBrains Mono", "Source Code Pro", monospace;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background-color: #030712;
|
||||||
|
color: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #14532d55;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #14532d99;
|
||||||
|
}
|
||||||
13
devops-view/src/main.tsx
Normal file
13
devops-view/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import "./index.css";
|
||||||
|
import App from "./App";
|
||||||
|
|
||||||
|
const rootEl = document.getElementById("root");
|
||||||
|
if (!rootEl) throw new Error("Root element not found");
|
||||||
|
|
||||||
|
createRoot(rootEl).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
29
devops-view/src/services/api.ts
Normal file
29
devops-view/src/services/api.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Chat, CreateEntryResponse } from "../types";
|
||||||
|
|
||||||
|
const BASE = "/api/rag";
|
||||||
|
|
||||||
|
export async function createChat(token: string): Promise<Chat> {
|
||||||
|
const res = await fetch(`${BASE}/chat/new?title=DevOps%20Chat`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Failed to create chat: ${res.status}`);
|
||||||
|
return res.json() as Promise<Chat>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendMessage(
|
||||||
|
token: string,
|
||||||
|
chatId: number,
|
||||||
|
content: string
|
||||||
|
): Promise<CreateEntryResponse> {
|
||||||
|
const res = await fetch(`${BASE}/entry/${chatId}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ content, onlyContext: true }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Failed to send message: ${res.status}`);
|
||||||
|
return res.json() as Promise<CreateEntryResponse>;
|
||||||
|
}
|
||||||
26
devops-view/src/types/index.ts
Normal file
26
devops-view/src/types/index.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export interface AuthResponse {
|
||||||
|
success: boolean;
|
||||||
|
payload: {
|
||||||
|
token: string;
|
||||||
|
refreshToken: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Chat {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: number | string;
|
||||||
|
content: string;
|
||||||
|
role: "USER" | "ASSISTANT";
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateEntryResponse {
|
||||||
|
id: number;
|
||||||
|
content: string;
|
||||||
|
role: "ASSISTANT";
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
1
devops-view/src/vite-env.d.ts
vendored
Normal file
1
devops-view/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
22
devops-view/tsconfig.app.json
Normal file
22
devops-view/tsconfig.app.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
devops-view/tsconfig.json
Normal file
7
devops-view/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
20
devops-view/tsconfig.node.json
Normal file
20
devops-view/tsconfig.node.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
16
devops-view/vite.config.ts
Normal file
16
devops-view/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: "/devops/",
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://localhost:8080",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -106,6 +106,13 @@
|
|||||||
<version>${lombok.version}</version>
|
<version>${lombok.version}</version>
|
||||||
<optional>true</optional>
|
<optional>true</optional>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- OAuth2 Client (social login) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-oauth2-client</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.posthub.gateway.config;
|
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.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
@@ -13,8 +15,11 @@ import reactor.core.publisher.Mono;
|
|||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebFluxSecurity
|
@EnableWebFluxSecurity
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler;
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
|
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
|
||||||
return http
|
return http
|
||||||
@@ -27,7 +32,9 @@ public class SecurityConfig {
|
|||||||
.pathMatchers(
|
.pathMatchers(
|
||||||
"/api/auth/login",
|
"/api/auth/login",
|
||||||
"/api/auth/register",
|
"/api/auth/register",
|
||||||
"/api/auth/refresh/token"
|
"/api/auth/refresh/token",
|
||||||
|
"/oauth2/authorization/**",
|
||||||
|
"/login/oauth2/code/**"
|
||||||
).permitAll()
|
).permitAll()
|
||||||
.pathMatchers(
|
.pathMatchers(
|
||||||
"/actuator/**",
|
"/actuator/**",
|
||||||
@@ -36,6 +43,9 @@ public class SecurityConfig {
|
|||||||
).permitAll()
|
).permitAll()
|
||||||
.anyExchange().permitAll()
|
.anyExchange().permitAll()
|
||||||
)
|
)
|
||||||
|
.oauth2Login(oauth2 -> oauth2
|
||||||
|
.authenticationSuccessHandler(oAuth2AuthenticationSuccessHandler)
|
||||||
|
)
|
||||||
.exceptionHandling(exceptions -> exceptions
|
.exceptionHandling(exceptions -> exceptions
|
||||||
.authenticationEntryPoint((exchange, ex) -> {
|
.authenticationEntryPoint((exchange, ex) -> {
|
||||||
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
|
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
|
||||||
|
|||||||
@@ -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) {
|
private void addAuthCookie(ServerHttpResponse response, String token) {
|
||||||
ResponseCookie cookie = ResponseCookie.from("Authorization", token)
|
ResponseCookie cookie = ResponseCookie.from("Authorization", token)
|
||||||
.httpOnly(true)
|
.httpOnly(true)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.posthub.gateway.model.entity;
|
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.RegistrationStatus;
|
||||||
import com.posthub.gateway.model.enums.UserRole;
|
import com.posthub.gateway.model.enums.UserRole;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
@@ -40,4 +41,10 @@ public class User {
|
|||||||
|
|
||||||
@Column("role")
|
@Column("role")
|
||||||
private UserRole role;
|
private UserRole role;
|
||||||
|
|
||||||
|
@Column("auth_provider")
|
||||||
|
private AuthProvider authProvider;
|
||||||
|
|
||||||
|
@Column("provider_id")
|
||||||
|
private String providerId;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.posthub.gateway.model.enums;
|
||||||
|
|
||||||
|
public enum AuthProvider {
|
||||||
|
LOCAL,
|
||||||
|
GOOGLE,
|
||||||
|
FACEBOOK,
|
||||||
|
GITHUB
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.posthub.gateway.repository;
|
package com.posthub.gateway.repository;
|
||||||
|
|
||||||
import com.posthub.gateway.model.entity.User;
|
import com.posthub.gateway.model.entity.User;
|
||||||
|
import com.posthub.gateway.model.enums.AuthProvider;
|
||||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
@@ -11,4 +12,6 @@ public interface UserRepository extends ReactiveCrudRepository<User, Integer> {
|
|||||||
Mono<User> findByUsername(String username);
|
Mono<User> findByUsername(String username);
|
||||||
|
|
||||||
Mono<Boolean> existsByEmail(String email);
|
Mono<Boolean> existsByEmail(String email);
|
||||||
|
|
||||||
|
Mono<User> findByAuthProviderAndProviderId(AuthProvider authProvider, String providerId);
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,12 +46,12 @@ public class JwtTokenProvider {
|
|||||||
claims.put(USER_REGISTRATION_STATUS, user.getRegistrationStatus().name());
|
claims.put(USER_REGISTRATION_STATUS, user.getRegistrationStatus().name());
|
||||||
claims.put(SESSION_ID, sessionId);
|
claims.put(SESSION_ID, sessionId);
|
||||||
claims.put(LAST_UPDATE, LocalDateTime.now().toString());
|
claims.put(LAST_UPDATE, LocalDateTime.now().toString());
|
||||||
return createToken(claims, user.getEmail());
|
return createToken(claims, user.getEmail(), jwtValidityInMilliseconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
public String refreshToken(String token) {
|
public String refreshToken(String token) {
|
||||||
Claims claims = getAllClaimsFromToken(token);
|
Claims claims = getAllClaimsFromToken(token);
|
||||||
return createToken(claims, claims.getSubject());
|
return createToken(claims, claims.getSubject(), jwtValidityInMilliseconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean validateToken(String token) {
|
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()
|
return Jwts.builder()
|
||||||
.claims(claims)
|
.claims(claims)
|
||||||
.subject(subject)
|
.subject(subject)
|
||||||
.issuedAt(new Date())
|
.issuedAt(new Date())
|
||||||
.expiration(new Date(System.currentTimeMillis() + jwtValidityInMilliseconds))
|
.expiration(new Date(System.currentTimeMillis() + validityMs))
|
||||||
.signWith(secretKey)
|
.signWith(secretKey)
|
||||||
.compact();
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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("-", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import com.posthub.gateway.security.JwtTokenProvider;
|
|||||||
import com.posthub.gateway.util.PasswordUtils;
|
import com.posthub.gateway.util.PasswordUtils;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -34,8 +35,14 @@ public class AuthService {
|
|||||||
private final JwtTokenProvider jwtTokenProvider;
|
private final JwtTokenProvider jwtTokenProvider;
|
||||||
private final PasswordEncoder passwordEncoder;
|
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) {
|
public Mono<RagResponse<UserProfileDTO>> login(LoginRequest request) {
|
||||||
return userRepository.findByEmail(request.getEmail())
|
return userRepository.findByEmail(request.getEmail().toLowerCase())
|
||||||
.switchIfEmpty(Mono.error(new ResponseStatusException(
|
.switchIfEmpty(Mono.error(new ResponseStatusException(
|
||||||
HttpStatus.UNAUTHORIZED, ApiErrorMessage.INVALID_USER_OR_PASSWORD.getMessage())))
|
HttpStatus.UNAUTHORIZED, ApiErrorMessage.INVALID_USER_OR_PASSWORD.getMessage())))
|
||||||
.flatMap(user -> {
|
.flatMap(user -> {
|
||||||
@@ -67,7 +74,7 @@ public class AuthService {
|
|||||||
return userRepository.findByUsername(request.getUsername())
|
return userRepository.findByUsername(request.getUsername())
|
||||||
.flatMap(existing -> Mono.<User>error(new ResponseStatusException(
|
.flatMap(existing -> Mono.<User>error(new ResponseStatusException(
|
||||||
HttpStatus.CONFLICT, ApiErrorMessage.USERNAME_ALREADY_EXISTS.getMessage(request.getUsername()))))
|
HttpStatus.CONFLICT, ApiErrorMessage.USERNAME_ALREADY_EXISTS.getMessage(request.getUsername()))))
|
||||||
.switchIfEmpty(userRepository.existsByEmail(request.getEmail())
|
.switchIfEmpty(userRepository.existsByEmail(request.getEmail().toLowerCase())
|
||||||
.flatMap(exists -> {
|
.flatMap(exists -> {
|
||||||
if (exists) {
|
if (exists) {
|
||||||
return Mono.<User>error(new ResponseStatusException(
|
return Mono.<User>error(new ResponseStatusException(
|
||||||
@@ -75,7 +82,7 @@ public class AuthService {
|
|||||||
}
|
}
|
||||||
User user = new User();
|
User user = new User();
|
||||||
user.setUsername(request.getUsername());
|
user.setUsername(request.getUsername());
|
||||||
user.setEmail(request.getEmail());
|
user.setEmail(request.getEmail().toLowerCase());
|
||||||
user.setPassword(passwordEncoder.encode(request.getPassword()));
|
user.setPassword(passwordEncoder.encode(request.getPassword()));
|
||||||
user.setRegistrationStatus(RegistrationStatus.ACTIVE);
|
user.setRegistrationStatus(RegistrationStatus.ACTIVE);
|
||||||
user.setRole(UserRole.USER);
|
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) {
|
private Mono<RagResponse<UserProfileDTO>> generateTokensAndBuildResponse(User user) {
|
||||||
return refreshTokenRepository.findByUserId(user.getId())
|
return refreshTokenRepository.findByUserId(user.getId())
|
||||||
.flatMap(existing -> {
|
.flatMap(existing -> {
|
||||||
|
|||||||
@@ -6,6 +6,34 @@ spring:
|
|||||||
application:
|
application:
|
||||||
name: gateway-service
|
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 (reactive DB) ----
|
||||||
r2dbc:
|
r2dbc:
|
||||||
url: r2dbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:appdb}
|
url: r2dbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:appdb}
|
||||||
@@ -27,6 +55,9 @@ spring:
|
|||||||
health-check-interval: 15s
|
health-check-interval: 15s
|
||||||
prefer-ip-address: true
|
prefer-ip-address: true
|
||||||
instance-id: ${spring.application.name}:${random.value}
|
instance-id: ${spring.application.name}:${random.value}
|
||||||
|
deregister-critical-service-after: 1m
|
||||||
|
lifecycle:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
gateway:
|
gateway:
|
||||||
server:
|
server:
|
||||||
@@ -61,20 +92,37 @@ spring:
|
|||||||
- RewritePath=/api/rag(?<segment>/?.*), ${segment}
|
- RewritePath=/api/rag(?<segment>/?.*), ${segment}
|
||||||
- AddRequestHeader=X-Forwarded-Prefix, /api/rag
|
- 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 ----
|
||||||
jwt:
|
jwt:
|
||||||
secret: ${JWT_SECRET:}
|
secret: ${JWT_SECRET:}
|
||||||
expiration: ${JWT_EXPIRATION:103600000}
|
expiration: ${JWT_EXPIRATION:103600000}
|
||||||
|
|
||||||
|
# ---- OAuth2 redirect after success ----
|
||||||
|
oauth2:
|
||||||
|
redirect-uri: ${OAUTH2_REDIRECT_URI:https://balexvic.com/auth/login}
|
||||||
|
|
||||||
# ---- Auth path config ----
|
# ---- Auth path config ----
|
||||||
auth:
|
auth:
|
||||||
public-paths:
|
public-paths:
|
||||||
- /api/auth/login
|
- /api/auth/login
|
||||||
- /api/auth/register
|
- /api/auth/register
|
||||||
- /api/auth/refresh/token
|
- /api/auth/refresh/token
|
||||||
|
- /oauth2/authorization/**
|
||||||
|
- /login/oauth2/code/**
|
||||||
- /actuator/**
|
- /actuator/**
|
||||||
- /api/*/v3/api-docs/**
|
- /api/*/v3/api-docs/**
|
||||||
- /api/*/swagger-ui/**
|
- /api/*/swagger-ui/**
|
||||||
|
- /api/auth/hr-guest-token
|
||||||
|
- /api/auth/devops-guest-token
|
||||||
admin-paths:
|
admin-paths:
|
||||||
- /api/*/admin/**
|
- /api/*/admin/**
|
||||||
|
|
||||||
@@ -93,7 +141,7 @@ management:
|
|||||||
health:
|
health:
|
||||||
show-details: always
|
show-details: always
|
||||||
gateway:
|
gateway:
|
||||||
enabled: true
|
access: unrestricted
|
||||||
|
|
||||||
# ---- Logging ----
|
# ---- Logging ----
|
||||||
logging:
|
logging:
|
||||||
|
|||||||
7
portfolio-view/.claude/settings.json
Normal file
7
portfolio-view/.claude/settings.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm run:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
25
portfolio-view/.gitignore
vendored
Normal file
25
portfolio-view/.gitignore
vendored
Normal 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
|
||||||
3
portfolio-view/.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
3
portfolio-view/.mvn/wrapper/maven-wrapper.properties
vendored
Normal 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
|
||||||
11
portfolio-view/docker/Dockerfile
Normal file
11
portfolio-view/docker/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
9
portfolio-view/docker/nginx.conf
Normal file
9
portfolio-view/docker/nginx.conf
Normal 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
17
portfolio-view/index.html
Normal 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
2490
portfolio-view/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
portfolio-view/package.json
Normal file
24
portfolio-view/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
portfolio-view/public/favicon.svg
Normal file
18
portfolio-view/public/favicon.svg
Normal 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 |
27
portfolio-view/src/App.tsx
Normal file
27
portfolio-view/src/App.tsx
Normal 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
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
67
portfolio-view/src/components/chat/ChatInput.tsx
Normal file
67
portfolio-view/src/components/chat/ChatInput.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
portfolio-view/src/components/chat/ChatMessage.tsx
Normal file
32
portfolio-view/src/components/chat/ChatMessage.tsx
Normal 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
Reference in New Issue
Block a user